写这篇文章的起因挺实在的:前阵子帮团队排查一个商品扣库存的并发问题,代码里用了Redis事务,但线上还是出现超卖。我打开日志一看,EXEC 返回了 nil,代码却没有相应处理,事务被静默跳过,后面的逻辑照常往下走。追根溯源,问题出在大家对 Redis 事务的“原子性”理解有偏差——它根本不是你以为的那个原子性。这篇文章就把这块掰开揉碎讲清楚:Redis 事务的执行机制、为什么原子性被弱化、WATCH 乐观锁怎么配合,以及真实项目里该怎么用、怎么避坑。适合所有用 Redis 做缓存、库存、计数器等场景的开发同学。
1. 为什么 Redis 事务总是被误解
1.1 单线程模型下,并发安全为什么还是难题
很多同学有个根深蒂固的印象:Redis 是单线程处理命令的,所以没有并发问题。这个说法对了一半。
单线程意味着每个命令从执行到返回之间,不会被其他命令打断,也就是单条命令天然原子。但问题恰恰出在“命令序列”上:当一段业务逻辑需要读一个值、做判断、再写回,这三条命令之间并没有任何保护。举个最常见的场景:两个客户端同时执行“读取库存 -> 判断库存是否大于0 -> 扣减库存”,它们可能都在同一个时刻读到库存为 1,然后各自扣减,最终库存变成 -1,超卖就发生了。
Redis 自己提供的 INCR、DECR、SETNX 这类原子命令,能解决一部分单值操作的问题,但业务永远比简单的加减复杂。那怎么办?Redis 给出的答案是事务机制。可这个事务,和我们平时在 MySQL 里理解的“事务”是两回事。
1.2 Redis 事务的定位:面向指令编排,而非完整 ACID
MySQL 的事务,讲的是 ACID:原子性、一致性、隔离性、持久性。Redis 的事务,在很多特性上是打了折扣的,尤其是原子性——这正是整个 Redis 事务里最容易让人踩坑的地方。
Redis 官方文档对事务的定位很明确:它提供了一种“将多个命令打包起来,按顺序执行”的机制,中间不会被其他客户端的命令插入。至于一条命令执行失败了怎么办?Redis 的态度非常直接:这取决于失败发生的时机,而且很多情况下它不会帮你回滚。
所以,我更喜欢把 Redis 事务理解成“指令编排工具”,而不是“数据安全基座”。它能保证的是:你这一批命令在执行期间,没有别的客户端插队。但它不保证:这批命令要么全部成功、要么全部失败。
这一点必须先建立认知,否则后面所有排查和设计都会走弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Redis 事务的核心执行机制
2.1 MULTI、EXEC、DISCARD 的完整生命周期
Redis 事务从开始到结束,一共经历三个关键命令:MULTI 开启事务、EXEC 执行事务、DISCARD 取消事务。
在终端里跑一遍完整过程,感受最直观:
bash复制127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET stock:1001 10
QUEUED
127.0.0.1:6379(TX)> DECRBY stock:1001 2
QUEUED
127.0.0.1:6379(TX)> INCR sold:1001
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) (integer) 8
3) (integer) 1
执行 MULTI 之后,客户端进入事务状态,后续每条命令不会立即执行,而是进入一个队列,Redis 每收到一条命令就返回 QUEUED。直到执行 EXEC,Redis 才把队列里的所有命令按顺序一次性执行完,然后把每个命令的返回结果按顺序打包返回。
整个过程有几点值得注意:
EXEC返回的数组长度和入队命令数量一致,顺序也一致,一一对应。- 队列里命令不会部分被跳过,除非碰到运行时错误(下一节详细说)。
- 如果在执行
EXEC之前想反悔,执行DISCARD即可清空队列,Redis 会退出事务状态。 - 事务队列里的命令在
EXEC之前不会触发任何写操作,这就意味着你用事务包裹的读操作,读到的是事务开始之前的数据快照,而不是执行到那一刻的最新数据。
这里有个很容易忽略的点:Redis 事务并没有像 MySQL 那样的 BEGIN 与 COMMIT/ROLLBACK 语义。MULTI 只是一个状态标记,EXEC 也并不是传统意义上的“提交”,它只是把排好队的命令一口气执行掉。这个设计简化了非常多的底层复杂度,但也决定了事务的行为边界。
2.2 弱化原子性的两个关键现场
重点来了。Redis 事务的原子性弱化,主要表现在两个完全不同的阶段。
第一个阶段是命令入队阶段。如果某条命令在入队时就被判定为语法错误,比如命令名写错、参数数量不对,那么整个事务在 EXEC 时会直接被拒绝,所有命令都不会执行,返回 EXECABORT 错误。这个行为看起来很接近“原子性”,因为要么全执行、要么全不执行。
看个例子:
bash复制127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET name zhangsan
QUEUED
127.0.0.1:6379(TX)> GETKEY name
(error) ERR unknown command 'GETKEY'
127.0.0.1:6379(TX)> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
第二条命令命令名写错了,入队时报错,执行 EXEC 时整个事务被直接丢弃,第一条 SET 也没有执行。这个环节看起来是“严格原子”的。
第二个阶段才是真正的坑:运行时错误。当命令入队时没有问题,但 EXEC 实际执行时才暴露的错误,比如对字符串 key 执行 LPUSH、哈希字段不存在、值类型不匹配等。这种情况下,Redis 不会回滚,出错的那条命令抛错,但其前后的所有其他命令照常执行。
bash复制127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET stock:1001 10
QUEUED
127.0.0.1:6379(TX)> LPUSH stock:1001 1
QUEUED
127.0.0.1:6379(TX)> INCR stock:1001
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) (integer) 11
看到了吧:SET 成功了,LPUSH 报错,但后面的 INCR stock:1001 照样执行,库存从 10 加到了 11。整批事务处于“部分成功、部分失败”的中间状态,而且没有任何回滚机制把已经执行的命令撤销。
这就是“弱化的原子性”最直观的现场:运行时错误不会中断事务,更不会触发回滚,Redis 只是忠实地把错误结果原样返回给你,至于怎么处理,全靠业务代码自己判断。
2.3 没有回滚,是偷懒还是取舍
很多从 MySQL 转过来的开发,第一次遇到这种情况都会觉得匪夷所思:事务失败居然不回滚?搞笑的吧?但 Redis 官方文档态度出奇地坚定,理由主要有三条。
第一,Redis 命令在事务中的失败,绝大多数来源于编程错误,比如类型不匹配、参数格式错误。这类问题在开发阶段就应该通过测试发现,而不是等上了生产再靠回滚兜底。
第二,回滚机制的实现成本非常高。Redis 的写路径上全是优化过度的底层数据结构,要支持任意命令的逆向操作,意味着每条命令都要有对应的 undo 逻辑,会引入大量的复杂性和性能损耗。这与 Redis “简单、高速”的核心定位直接冲突。
第三,Redis 事务的设计哲学是“把错误暴露给客户端”。入队阶段的错误可以被提前拦截,运行时错误则意味着你的业务逻辑写错了。与其默默回滚掩盖问题,不如让客户端立刻感知到失败,自行决定是重试、补偿还是记录告警。
所以,“没有回滚”不是 Redis 的缺陷,而是一种明确的设计取舍。理解了这一点,你就不会继续用 MySQL 的思维去套 Redis 事务了。
3. WATCH 与乐观锁:把控制权还给业务方
3.1 WATCH 的底层原理
如果说 MULTI/EXEC 解决了“命令不被插入”的问题,那么 WATCH 解决的就是“读改写场景下的数据竞争”问题。
WATCH 的用法很简单,在 MULTI 之前指定要监视的 key:
bash复制127.0.0.1:6379> WATCH stock:1001
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> DECR stock:1001
QUEUED
127.0.0.1:6379(TX)> EXEC
(nil)
EXEC 返回 nil,意味着被监视的 stock:1001 在 WATCH 之后、EXEC 之前被其他客户端修改过了,Redis 认为读取到的旧值已经不可信,于是放弃执行整批事务。
底层实现上,Redis 维护了一张 watched_keys 字典,key 是被监视的键,value 是监视该键的客户端列表。任何写命令触碰到被监视的 key 时,都会调用 touchWatchedKey,把对应客户端打上一个 CLIENT_DIRTY_CAS 标记。EXEC 执行前先检查这个标记,如果存在就直接拒绝执行,返回 nil。
整个机制本质上就是乐观锁,和你熟悉的 CAS(Compare And Swap)是一个思想。它不阻塞其他客户端写操作,只是在本方执行事务前做一次“版本比对”:变量值是否被改过。如果改过,那就说明我预先读到的数据已经过期,事务不能执行了,需要重试。
3.2 WATCH+MULTI 实现原子更新的完整样例
用 Java 伪代码来演示一个商品库存扣减的完整流程,业务逻辑是:读取库存、判断充足、扣减 1。这套逻辑必须要是原子的,不能出现两个请求同时读到同一个库存值。
java复制public boolean deductStock(Jedis jedis, String stockKey, int num, int maxRetry) {
for (int attempt = 0; attempt < maxRetry; attempt++) {
// 1. 监视库存 key
jedis.watch(stockKey);
// 2. 读取当前库存
String value = jedis.get(stockKey);
int stock = value == null ? 0 : Integer.parseInt(value);
// 3. 业务判断
if (stock < num) {
jedis.unwatch();
return false;
}
// 4. 开启事务并执行扣减
Transaction tx = jedis.multi();
tx.decrBy(stockKey, num);
List<Object> results = tx.exec();
// 5. 如果 exec 返回 null,说明在 watch 之后、exec 之前
// 有其他客户端修改了 stockKey,需要重试
if (results != null) {
return true;
}
// 6. 否则重试
}
return false;
}
这段代码有四个必须注意的关键点。
第一,WATCH 必须在 MULTI 之前执行。顺序反了,监视在事务队列里会变成一条事务命令,起不到乐观锁的作用。
第二,GET 读取必须发生在 WATCH 之后,而不是 MULTI 之前。如果先 GET 再 WATCH,中间有别的客户端改了值,你拿到的还是旧值,WATCH 也拦不住——因为它监视的是“监视之后”的变更。
第三,EXEC 返回 null 并不代表命令执行失败,而是代表“事务被放弃执行”。这和运行时报错是两回事,必须分清楚。
第四,DISCARD 或者 UNWATCH 可以在重试前手动解除监视,避免遗留无用的监视关系。
在实际项目里,这种“读-判-写”逻辑几乎都会配一个最大重试次数,防止高并发下多个客户端互相覆盖、无限循环。重试之间加一个极短退避,比如 Thread.sleep(10),能显著降低竞争概率。
3.3 重试策略与 WATCH 的边界
WATCH 有几个容易踩的边界条件。
第一个边界:WATCH 是一次性的。EXEC 执行完,无论成功还是返回 nil,Redis 都会自动清除所有 WATCH 监视。下一次事务要重新 WATCH,否则就不生效。这也是很多人在写重试循环时容易遗漏的点:下一轮重试必须重新调用 WATCH。
第二个边界:连接断开,WATCH 自动失效。如果客户端在 WATCH 之后、EXEC 之前断线重连,重连后的连接是没有监视状态的,事务等于裸奔。用连接池的客户端尤其要注意:一定要确保 WATCH、事务、EXEC 在同一条连接上完成,不能中途归还连接。
第三个边界:WATCH 监视的 key 如果被过期删除、被淘汰策略删除,也会触发事务放弃。因为删除本质也是一次修改,Redis 同样会执行 touchWatchedKey。
第四个边界:不要 WATCH 过多 key。虽然 Redis 做这个检查是常数级操作,但如果你一个事务里 WATCH 了几百个 key,在高写入场景下,每个被监视的 key 被修改时都会遍历一遍订阅的客户端列表,开销不可忽视。我见过有人 WATCH 整个商品分类下的所有商品 key,结果一有热门商品被购买,整片事务全部失败,性能直线下降。
为什么很多团队最后放弃了 WATCH,转而使用 Lua 脚本,我这里先埋个伏笔,下一节就来说这个演进。
4. 事务、Lua 与分布式锁:如何选型
4.1 Lua 脚本:比事务更强力的原子操作
了解 Redis 事务的局限之后,再看 Lua 脚本,你会立刻理解它的设计意图:用一段脚本描述“读-判-写”的完整逻辑,然后交给 Redis 的脚本引擎,在脚本执行期间,Redis 不会执行任何其他客户端的命令。这个隔离强度比事务队列还高,因为事务只保证“执行时不插入”,Lua 脚本连输入输出都能在内部闭环处理。
同样的扣库存逻辑,用 Lua 脚本只需要一行调用:
lua复制-- 扣库存并返回剩余库存;库存不足返回 -1
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock < tonumber(ARGV[1]) then
return -1
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return stock - tonumber(ARGV[1])
Java 侧通过 evalsha 或 eval 执行脚本,拿返回值判断走哪条分支。整个读改写过程在 Redis 内部一次性完成,没有任何窗口期,更不需要 WATCH 和重试循环,性能和心智负担都远优于 WATCH + MULTI。
Redis 7.0 开始支持的 Function(函数)机制,相当于把 Lua 脚本入库化管理,解决了脚本分发和版本管理的问题,对大规模团队运维更友好。如果项目已经升级到 Redis 7.0 以上,优先用 Function 替代原生的 Lua 脚本。
4.2 典型场景梳理:能用和不能用
结合生产实践,我整理了一个选型速查:
| 业务场景 | 推荐方案 | 原因 |
|---|---|---|
| 批量写入多个 key,但各 key 之间无逻辑依赖 | MULTI/EXEC 事务 | 简单,排队执行即可 |
| 读-判断-写的组合操作 | Lua 脚本 | 内部支持条件分支,天然原子 |
| 简单的计数器增减 | INCR/DECR 原子命令 | 单命令原子,无需事务 |
| 分布式锁的获取与释放 | SETNX + Lua | 释放锁需要判断持有者,不能用简单 DEL |
| 复杂业务逻辑(多步判断、循环) | Lua 脚本 / Function | 事务队列不支持流程控制 |
| 需要回滚的强一致业务 | 换数据库或引入分布式事务框架 | Redis 事务不回滚 |
具体说一下我用过的案例。限流器是另一个特别适合 Lua 但很多人用错场景:用 WATCH 实现滑动窗口限流,每次都带着 WATCH 和重试,代码写得又长又容易漏重试。切到 Lua 之后,一条脚本搞定判断、计数、过期时间设置,线上稳定性提升明显。
分布式锁的释放更是教科书级别的坑:如果只是 DEL lock_key,可能把别人刚获取到的锁删除。正确姿势还是 Lua:先比对锁内存的唯一标识,匹配才删除。至今我仍会在团队代码评审里看到有人用事务做这个事,其实没有必要——Lua 更适合。
4.3 事务误用排查与性能红线
使用 Redis 事务,有几个很常见的误用方式,每一个我都实际踩过或者帮别人排查过。
第一,在事务里依赖前序命令的返回值。事务里的命令在 EXEC 之前不会真正执行,所以你在同一个事务里根本拿不到前一条命令的执行结果。有人尝试在事务里 GET key,然后根据这个值决定下一条命令要不要入队——做不到,因为这个值在 EXEC 之前拿不到,真正的读取和判断都必须在事务外完成,这就又回到了 WATCH 或 Lua 的范畴。
第二,把大量命令堆进一个事务。有些同学觉得事务反正排队执行,就把几百上千条命令一股脑塞进去,结果 EXEC 时会长时间阻塞 Redis。Redis 是单线程模型,阻塞期间所有其他客户端都会卡住,接口响应时间直接飙到秒级。事务队列里的命令数量,建议控制在几十条以内;超过这个量,要么拆分,要么上 Lua 脚本。
第三,过度依赖 WATCH 导致重试风暴。高并发场景下,如果多个客户端同时 WATCH 同一个热点 key,一旦某个客户端先成功修改,其余客户端的 EXEC 全部返回 nil,全部进入重试,重试又继续互相打架。这种时候,与其上 WATCH,不如直接用 Lua 脚本,或者让客户端做随机退避,错开峰谷。
第四,忽视持久化配置对事务一致性的影响。Redis 事务本质上是把若干写入排入队列,但写入落盘与否取决于 RDB/AOF 配置。如果业务对数据丢失零容忍,只在内存里执行事务是不够的,必须配合 appendfsync always 或合理的持久化策略。这个话题在面试里经常和事务一起被问到,后面有时间单独写一篇展开讲。
5. 常见问题与排错速查表
5.1 典型问题速查
长期和 Redis 事务打交道,我整理了一份实用速查表,有些条目是看官方文档不容易发现的,直接照做能省不少排查时间。
| 现象 | 原因 | 解决方案 |
|---|---|---|
EXEC 返回 nil |
WATCH 的 key 在执行前被修改 | 加重试机制,或改用 Lua 脚本 |
EXEC 返回 EXECABORT 错误 |
入队阶段有语法错误 | 检查入队时的每条命令是否正确 |
事务中某条命令返回 WRONGTYPE |
运行时错误,类型不匹配 | 只有该命令失败,检查 key 结构和业务逻辑 |
QUEUED 没有正常返回 |
事务状态已丢失或连接断开 | 检查客户端连接是否被连接池回收 |
| WATCH 不生效 | 未在 MULTI 之前调用,或 WATCH 已过期 | 确认调用顺序,重试循环内重新 WATCH |
| 事务执行期间大量客户端超时 | 单个事务命令过多 | 控制事务队列规模,拆分或使用脚本 |
| 事务执行后数据不一致 | 运行时报错导致部分执行 | 业务侧增加补偿逻辑,或改用 Lua 脚本形成的单一入口 |
排查时最有效的抓手,其实就一个字:看返回。EXEC 的返回值、每条命令的执行结果、报错类型,这三项组合起来基本就能定位问题。
5.2 踩坑记录与调优心得
有一个场景我印象特别深:某次线上活动,库存 500 件,但就在开始的几秒内,订单系统频繁告警“库存扣减失败”。排查发现,代码里用的是 WATCH + MULTI 做库存扣减,并发一高,大量事务因为 WATCH 的 key 被其他事务修改而返回 nil,重试一次失败就放弃,于是大量请求直接走了“库存不足”分支。
后来我把这段逻辑改成了 Lua 脚本,库存扣减彻底变成单入口原子操作,活动期间的失败率直接归零。这个经历让我彻底扭转了对 Redis 事务的依赖:能用脚本解决的就别用事务,能让 Redis 内部闭环的就别在客户端绕圈子。
另一个印象深刻的是事务里误用 KEYS 命令。某同事在一个百万 key 的库上对事务里的模式匹配做统计,结果 EXEC 触发了全量扫描,线上服务整体卡顿了几秒。这是事务和重命令双重的性能红线——绝对不要在事务里执行 KEYS、SMEMBERS、HGETALL 这类可能造成大范围遍历的命令。
踩过几次坑之后,我现在在团队里立了几条简单规则:事务队列上限 50 条;涉及读改写一律走 Lua;事务必须加超时和重试;事务里的 key 数量保持最小;Redis 写命令之前确认数据类型统一。这些规则不一定适合所有团队,但足够让新人少掉一层皮。
Redis 事务的“弱化原子性”这个话题,说透了就是两件事:一是它不能回滚,二是运行时错误不会中断整批命令。这两个特性决定了它只适合做“有序批量执行”,不适合做“强一致业务逻辑”。而 WATCH 则更像是给这个轻量机制补上了乐观锁的补丁,让客户端在数据竞争时有能力感知冲突、自行重试。如果你和我一样在业务里踩过这几个坑,大概率也会得出同样的结论:优先用 Lua 脚本把复杂的读改写逻辑收拢到 Redis 内部,事务则作为轻量批量操作的默认选择。最后再送大家一个小技巧:不管用事务还是脚本,核心逻辑都要配套监控 EXEC 返回值和错误类型,线上问题能不能秒级定位,往往就取决于这一层观测做得够不够细。
