写这篇文章的起因挺实在的:前阵子帮团队排查一个商品扣库存的并发问题,代码里用了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 也并不是传统意义上的“提交”,它只是把排好队的命令一口气执行掉。这个设计简化了非常多的底层复杂度,但也决定了事务的行为边界。
