这是《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
执行流程:
MULTI开启事务;- 后续命令进入队列,返回
QUEUED; EXEC时按入队顺序依次执行;- 执行期间不会插入其他客户端命令。
它提供的是串行执行,不是传统数据库的完整 ACID:
- 没有回滚计划;
- 运行时类型错误不会撤销已执行命令;
- 入队错误通常导致
EXEC拒绝执行; - 不支持条件等待和跨 key 逻辑。
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
参数:
KEYS:脚本操作的 key 列表;ARGV:普通参数;- 第一个数字表示 key 数量。
推荐所有 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 的适用场景
适合:
- 多步骤读写同一组 key;
- 需要条件判断;
- 高竞争下的库存、限额、状态机;
- 组合命令保持原子性。
不适合:
- 长循环、大集合遍历;
- 外部 IO 或睡眠;
- 操作大量不相关 key;
- Cluster 中跨槽 key 操作;
- 复杂业务逻辑(可读性和维护性差)。
脚本越短越好,只做数据判断和修改,业务编排放在应用层。
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 | 强 | 服务端管理 | 可复用脚本 |
本章小结
- MULTI/EXEC 保证执行期间不插入其他命令,但不提供传统回滚;
- WATCH 实现乐观锁,竞争高时重试成本上升;
- Lua 是复合原子操作的首选,但必须短小、可控、key 全部显式传入;
- Cluster 中脚本 key 必须同槽,hash tag 要谨慎使用;
- Functions 提供服务端逻辑管理能力,但复杂业务不要都塞进 Redis。
思考题
- 为什么 Redis 不支持运行时错误后的自动回滚?
- 库存扣减用 WATCH 和 Lua 哪个更好?从正确性、吞吐、代码复杂度分析。
- 一个 Lua 脚本遍历 10 万个 Set 成员会有什么后果?