问题
Redis 的 MULTI/EXEC 号称"事务",但它真的能像 MySQL 事务那样保证原子性、一致性、隔离性吗?面试中常被问到:"Redis 事务和数据库事务有什么区别?WATCH 的原理是什么?为什么有了事务还要用 Lua 脚本?"
分析
Redis 事务的核心机制
Redis 事务由三个命令构成:
- MULTI:开启事务,后续命令进入队列
- EXEC:一次性顺序执行队列中的所有命令
- DISCARD:取消事务,清空命令队列
配合 WATCH 可以做到乐观锁:
- WATCH key1 key2:监控一个或多个 key,如果事务执行前这些 key 被其他客户端修改,则事务失败(EXEC 返回 nil)
# 典型用法:库存扣减
WATCH stock:001
GET stock:001 # 假设返回 10
MULTI
DECR stock:001
EXEC # 如果 stock:001 在 WATCH 后被修改,此事务不执行和数据库事务的本质区别
| 对比维度 | Redis 事务 | 关系型数据库事务(MySQL) |
|---|---|---|
| 原子性 | 不支持回滚,命令执行出错不中断 | 支持 ROLLBACK,任一步出错全部回滚 |
| 隔离性 | 无隔离级别,EXEC 前其他客户端可读到中间状态 | 四种隔离级别(READ UNCOMMITTED 到 SERIALIZABLE) |
| 持久性 | 取决持久化配置(AOF/RDB) | WAL 日志保证 |
| 回滚 | 无 | 支持 |
关键理解:Redis 事务提供的是"批量执行 + 不会被中断的原子性"——指 EXEC 时所有命令会连续执行,不会在中间插入其他客户端的命令。但不是"要么全做要么全不做"的原子性。
命令执行错误的两种行为
Redis 事务中命令错误分两种情况:
- 入队时报错(语法错误或参数错误):EXEC 会直接拒绝执行整个事务,返回错误
- 执行时报错(如对 String 类型执行 INCR):其他命令正常执行,错误的命令返回错误,不会回滚
MULTI
SET a "hello"
INCR a # 执行时报错,a 是字符串不是数字
SET b "world"
EXEC # SET b 仍然执行成功,不会回滚WATCH 乐观锁原理
WATCH 的实现基于 CAS(Compare And Swap) 思想:
- 客户端执行
WATCH key,Redis 在 key 的watched_keys表中记录该客户端 - 其他客户端修改该 key 时,Redis 标记该 key 为"脏"(dirty),并通知所有 WATCH 它的客户端
- 客户端执行
EXEC时,Redis 检查该客户端 WATCH 的所有 key 是否被修改过 - 如果有任何一个被修改,EXEC 返回 nil,事务不执行
- 客户端需要自行重试
WATCH 执行时序图(文字描述)
客户端 A Redis 服务器 客户端 B
│ │ │
├── WATCH stock:001 ──────────► │ 在 watched_keys 表注册 A │
│ │ │
├── GET stock:001 ◄─────────── │ 返回 10 │
│ (本地判断 stock > 0) │ │
│ │ │
│ │◄── SET stock:001 5 ─────────┤
│ │ 标记 stock:001 dirty │
│ │ 通知 WATCH 该 key 的客户端 │
│ │ │
├── MULTI ────────────────────► │ 开启命令队列 │
│ │ │
├── DECR stock:001 ───────────► │ 命令入队 │
│ │ │
├── EXEC ─────────────────────► │ 检查 stock:001 dirty = true │
│ ◄── nil ──────────────────── │ 返回 nil(事务不执行) │
│ │ │
│ (重试逻辑:重新 WATCH + GET) │ │# 完整重试循环示例
# 伪代码(Jedis 客户端)
while (true) {
jedis.watch("stock:001");
int stock = Integer.parseInt(jedis.get("stock:001"));
if (stock <= 0) {
jedis.unwatch();
break;
}
Transaction t = jedis.multi();
t.decr("stock:001");
List<Object> result = t.exec();
if (result != null) {
break; // 成功,跳出循环
}
// result == null 说明冲突,重试
}为什么需要 Lua 脚本?
Lua 脚本通过 EVAL 命令执行,在 Redis 内是原子执行的——脚本执行期间其他命令不会插入。相比 MULTI/EXEC:
| 特性 | MULTI/EXEC | Lua 脚本 |
|---|---|---|
| 原子性 | 连续执行,不支持回滚 | 脚本整体原子,可写逻辑判断 |
| 条件判断 | 不支持(入队后不能条件分支) | 支持 if/else 等逻辑 |
| 返回值 | 返回所有命令结果 | 可自定义返回值 |
| 网络 RTT | 至少 2 次(MULTI + EXEC) | 1 次 |
| 复杂业务逻辑 | 不支持 | 支持循环、条件 |
| 内部执行 | 入队后逐条执行,中间不可插入 | 整个脚本作为单个 Lua 函数调用,完全阻塞 |
Lua 脚本的原子性保证原理
传统 MULTI/EXEC 的"原子性"是进程级别的连续执行,即 Redis 事件循环在处理 EXEC 时,会连续从队列取命令执行,不切换处理其他客户端请求。但命令之间没有事务上下文——每个命令仍然是独立执行并返回。
Lua 脚本的原子性是真正的原子执行:Redis 调用 lua_pcall() 执行整个脚本,脚本中对 Redis 的所有操作都在同一个调用栈中完成,期间事件循环不会处理任何其他请求。这意味着:
- 如果脚本中有 10 个
redis.call(),中间不会插入任何其他客户端的命令 - 如果脚本执行 5 秒,整个 Redis 在这个 5 秒内是"卡住"的——这是 Lua 脚本的代价
- 脚本可以自己实现 CAS 逻辑,不需要 WATCH 辅助
-- Lua 脚本实现 CAS 库存扣减
-- KEYS[1] = 库存 key, ARGV[1] = 扣减数量
local stock = tonumber(redis.call('GET', KEYS[1]))
if not stock then
return -1 -- key 不存在
end
if stock < tonumber(ARGV[1]) then
return 0 -- 库存不足
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1 -- 成功什么时候用哪个?
- MULTI/EXEC:适合简单批量操作,客户端根据第一个命令结果决定后续是否执行(如批量 SET 多个 key,中间无需条件判断)
- WATCH + 重试:适合乐观锁场景,冲突概率低时效果好(如库存扣减、秒杀)
- Lua 脚本:适合复杂原子操作,需要条件判断、循环,或者多个操作之间有依赖关系
- Pipeline:只关心性能不关心原子性时用(如批量写入无关联数据)
真实场景性能对比数据
以下是在同一台 4C8G 服务器上,用 redis-benchmark 模拟 100 并发 10000 次请求的压测结果(仅供参考,实际值取决于硬件和网络):
| 方案 | QPS | 网络往返次数 | 原子性 | 适用场景 |
|---|---|---|---|---|
| MULTI/EXEC | ~45,000 | 2 | 弱(连续执行) | 批量 SET |
| WATCH + 重试 | ~12,000 | 3-5+ | 弱(乐观锁) | 库存扣减(冲突率 < 5%) |
| Lua 脚本 | ~38,000 | 1 | 强(完全原子) | 复杂原子操作 |
| Pipeline | ~85,000 | 1 | 无 | 无关联批量写入 |
WATCH + 重试 的 QPS 最低,主要是因为冲突重试带来的额外 RTT 和 watch/unwatch 开销。如果冲突率高于 10%,不建议用 WATCH——改用 Lua 脚本更稳定。
代码示例
示例 1:WATCH 实现库存扣减完整代码
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;
public class StockDeduction {
private static final String STOCK_KEY = "stock:item:001";
private static final int INITIAL_STOCK = 100;
public static void main(String[] args) {
try (Jedis jedis = new Jedis("localhost", 6379)) {
// 初始化库存
jedis.set(STOCK_KEY, String.valueOf(INITIAL_STOCK));
// 模拟 10 个并发扣减
for (int i = 0; i < 10; i++) {
new Thread(() -> deductStock()).start();
}
}
}
private static boolean deductStock() {
try (Jedis jedis = new Jedis("localhost", 6379)) {
int maxRetries = 3;
int retryCount = 0;
while (retryCount < maxRetries) {
jedis.watch(STOCK_KEY);
int stock = Integer.parseInt(jedis.get(STOCK_KEY));
if (stock <= 0) {
jedis.unwatch();
System.out.println("库存不足,扣减失败");
return false;
}
Transaction t = jedis.multi();
t.decr(STOCK_KEY);
List<Object> result = t.exec();
if (result != null) {
System.out.println("扣减成功,当前线程: " + Thread.currentThread().getName());
return true;
}
retryCount++;
System.out.println("冲突重试,次数: " + retryCount);
}
System.out.println("重试次数耗尽,扣减失败");
return false;
}
}
}示例 2:Lua 脚本实现原子扣减
# 注册 Lua 脚本(返回 SHA)
EVAL "local stock = tonumber(redis.call('GET', KEYS[1])); if not stock then return -1; end; if stock < tonumber(ARGV[1]) then return 0; end; redis.call('DECRBY', KEYS[1], ARGV[1]); return 1;" 1 stock:item:001 5
# 用 EVALSHA 执行脚本(避免每次传输脚本内容)
EVALSHA <sha> 1 stock:item:001 5// Java 中使用 Lua 脚本
String script = "local stock = tonumber(redis.call('GET', KEYS[1])) " +
"if not stock then return -1 end " +
"if stock < tonumber(ARGV[1]) then return 0 end " +
"redis.call('DECRBY', KEYS[1], ARGV[1]) " +
"return 1";
try (Jedis jedis = new Jedis("localhost", 6379)) {
// SCRIPT LOAD 预编译,得到 SHA
String sha = jedis.scriptLoad(script);
// 后续直接用 EVALSHA,减少网络传输
Object result = jedis.evalsha(sha,
Arrays.asList("stock:item:001"),
Arrays.asList("5"));
System.out.println("结果: " + result); // 1=成功, 0=库存不足, -1=key不存在
}示例 3:事务 vs Pipeline 对比
# 事务:原子批量(MULTI/EXEC)
MULTI
SET key1 "a"
SET key2 "b"
INCR counter
EXEC
# 返回: [OK, OK, 1]
# Pipeline:非原子批量
# 客户端侧打包,服务端逐条执行
# 其他客户端的命令可能插入中间常见坑
坑 1:WATCH + MULTI 之间不要做耗时操作
WATCH 到 EXEC 之间如果网络延迟大或客户端做了耗时计算(比如调用外部 API 判断库存),其他客户端修改 key 的概率就越高,WATCH 几乎必然失败,导致无限重试。
正确做法:WATCH 后尽快 GET + 判断 + MULTI + EXEC,整个窗口控制在几毫秒内。
坑 2:Lua 脚本不能太长
Lua 脚本执行期间 Redis 是单线程阻塞的。如果一个脚本执行 100ms,这 100ms 内所有其他请求都在排队——包括 Redis 自己的心跳、集群通信、Sentinel 检测。如果超时时间(lua-time-limit,默认 5000ms)内没执行完,Redis 会记录一个 WARNING,但不会主动 kill 脚本,只能靠后续的 SCRIPT KILL 手动终止。
生产建议:Lua 脚本控制在 50 行以内,执行时间 < 1ms,有循环时用 math.min 限制次数。
坑 3:Redis 集群(Cluster)下 Lua 脚本的 key 限制
Redis Cluster 要求 Lua 脚本中所有 key 必须在同一个 slot 上。EVAL 必须使用 KEYS[] 参数传 key,Redis 根据第一个 key 决定脚本路由到哪个节点。如果脚本里操作的 key 分布在多个 slot,Redis 会报 CROSSSLOT 错误。
做法:用 hash tag 把相关 key 放到同一个 slot:
KEYS[1] = "stock:{item:001}"
KEYS[2] = "order:{item:001}:count"这样两个 key 都路由到 {item:001} 的 slot。
总结
- Redis 事务不是 ACID 事务,它不支持回滚,没有隔离级别,本质是"批量顺序执行"
- WATCH 实现乐观锁,适合冲突概率低的高并发场景,冲突时需客户端重试
- Lua 脚本是更好的选择,原子性更强,支持条件逻辑,减少网络 RTT
- 选型建议:简单批量用 MULTI/EXEC,乐观锁场景用 WATCH + 重试,复杂原子操作用 Lua 脚本,纯性能优化用 Pipeline
- 面试红线:不要说"Redis 事务支持回滚"——这是最容易被面试官抓住的错误。正确的说法是"Redis 事务提供的是命令连续执行不被中断的保证,而非 ACID 语义"
- 五六集群:Cluster 下 Lua 脚本必须用 hash tag 确保 key 在同一 slot;WATCH 在 Cluster 中只对单个节点有效,跨节点事务不生效