RedisNotes

第 09 章:事务与 Lua

zjc 于 2026-01-09 发布

这是《Redis 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 Redis 的“原子”有边界。本章讲 MULTI/EXEC、WATCH、Lua 脚本、函数、错误处理与脚本安全,帮助你写出真正可靠的复合操作。

9.1 Redis 事务是什么

MULTI
SET stock:sku:1001 99
INCRBY stock:sku:1001 -1
SET order:1001 ok
EXEC

执行流程:

  1. MULTI 开启事务;
  2. 后续命令进入队列,返回 QUEUED
  3. EXEC 时按入队顺序依次执行;
  4. 执行期间不会插入其他客户端命令。

它提供的是串行执行,不是传统数据库的完整 ACID:

9.2 MULTI 的错误行为

入队阶段错误

MULTI
SET k v
BADCOMMAND
EXEC

BADCOMMAND 入队即报错,EXEC 会拒绝执行整个事务。

执行阶段类型错误

SET str-key hello
MULTI
INCR str-key
SET other-key ok
EXEC

INCR str-key 运行时报错,但 SET other-key ok 仍执行。前面的错误不会导致后面取消,也不会回滚。

9.3 WATCH:乐观锁

WATCH 监视 key,如果事务执行前 key 被其他客户端修改,EXEC 返回 nil,事务不执行。

WATCH stock:sku:1001
val = GET stock:sku:1001
if int(val) <= 0:
    UNWATCH
    return
MULTI
INCRBY stock:sku:1001 -1
EXEC

伪代码等价于 CAS。如果 EXEC 返回 nil,说明竞争失败,业务可重试。

Spring 示例:

public boolean deduct(String key) {
    for (int i = 0; i < 3; i++) {
        redis.watch(key);
        String value = redis.opsForValue().get(key);
        if (value == null || Integer.parseInt(value) <= 0) {
            redis.unwatch();
            return false;
        }

        redis.multi();
        redis.opsForValue().decrement(key);
        List<Object> result = redis.exec();
        if (result != null && !result.isEmpty()) {
            return true;
        }
    }
    return false;
}

高竞争场景 WATCH 重试率高,Lua 通常更合适。

9.4 Lua 脚本

Lua 脚本发送到服务端后作为一个整体执行,执行期间不被其他命令插入:

EVAL "return redis.call('GET', KEYS[1])" 1 user:1001

参数:

推荐所有 key 都通过 KEYS 传入,便于 Cluster 正确定位槽。

库存扣减脚本

local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then
    return -1
end

local num = tonumber(ARGV[1])
if stock < num then
    return 0
end

redis.call('DECRBY', KEYS[1], num)
return 1

调用:

EVAL "..." 1 stock:sku:1001 1

脚本缓存

SCRIPT LOAD "return 1"
EVALSHA <sha1> 0
SCRIPT EXISTS <sha1>
SCRIPT FLUSH

生产建议加载脚本并保存 SHA,客户端先 EVALSHA,收到 NOSCRIPT 再加载或降级 EVAL

9.5 Spring 执行 Lua

DefaultRedisScript<Long> script = new DefaultRedisScript<>("""
        local stock = tonumber(redis.call('GET', KEYS[1]))
        if stock == nil then return -1 end
        local n = tonumber(ARGV[1])
        if stock < n then return 0 end
        redis.call('DECRBY', KEYS[1], n)
        return 1
        """, Long.class);

public boolean deductStock(String key, int count) {
    Long result = redis.execute(
            script,
            List.of(key),
            String.valueOf(count));
    return result != null && result == 1;
}

脚本可以放资源文件中,启动时加载,避免代码里硬编码长字符串。

9.6 Lua 的适用场景

适合:

不适合:

脚本越短越好,只做数据判断和修改,业务编排放在应用层。

9.7 Lua 与 Cluster

Redis Cluster 中,一个脚本访问的所有 key 必须落在同一个槽:

EVAL "..." 2 user:1001 user:1002

如果两个 key 槽不同,会报错。可以使用 hash tag 强制同槽:

sec:{order-1001}:stock
sec:{order-1001}:detail

只有 {} 内的内容参与槽计算。但要小心:hash tag 会把相关数据固定到同一节点,过度使用会造成数据倾斜。

9.8 脚本超时与 kill

配置:

busy-reply-threshold 5000
# 旧名称 lua-time-limit

脚本超过阈值后,Redis 开始拒绝新命令,但脚本本身仍可能继续执行:

SCRIPT KILL

如果脚本已经执行了写命令,只能:

SHUTDOWN NOSAVE

因此上线前必须评审脚本复杂度,禁止不可控循环。

9.9 Redis Functions

Redis 7 引入 Functions,把服务端逻辑作为可管理的库存储:

#!lua name=mylib

redis.register_function('my_hset', function(keys, args)
    return redis.call('HSET', keys[1], args[1], args[2])
end)

加载:

FUNCTION LOAD "..."
FUNCTION LIST
FCALL my_hset 1 user:1001 name Tom

EVAL 相比,Functions 更适合版本化管理和复用服务端逻辑,但依然要遵守短脚本、同槽 key、无阻塞 IO 的约束。

9.10 MULTI、WATCH、Lua 对比

方案 原子性 条件逻辑 适合
单命令 SETNX、INCR
MULTI/EXEC 串行执行 固定命令序列
WATCH + MULTI 乐观并发 客户端判断 低竞争 CAS
Lua 服务端判断 高竞争复合操作
Functions 服务端管理 可复用脚本

本章小结

思考题

  1. 为什么 Redis 不支持运行时错误后的自动回滚?
  2. 库存扣减用 WATCH 和 Lua 哪个更好?从正确性、吞吐、代码复杂度分析。
  3. 一个 Lua 脚本遍历 10 万个 Set 成员会有什么后果?