
开头讲一个我实际遇到的场景某天线上系统突然报警说商品库存扣多了。排查下来发现扣减库存的流程是先读库存、再写库存两步操作之间没有做任何保护两个请求同时进来读到同一个初始值最后各写各的库存就超卖了。那次之后我老老实实把 Redis 的事务命令捡起来重新过了一遍才发现很多人包括当时的我对 Redis Transaction 的理解是错的——它和 MySQL 里那种“能回滚”的事务完全是两码事。这篇文章就把我重新学习的笔记整理出来覆盖 MULTI、EXEC、DISCARD、WATCH、UNWATCH 这几条命令的用法、执行机制、常见坑和真实场景适合刚接触 Redis 的开发者也适合写过 Redis 但没仔细研究过事务细节的工程师。1. Redis 事务能解决什么从单命令原子性到批量一致性1.1 单命令原子性带来的“错觉”Redis 官方对命令的说明里经常出现 atomic 这个单词比如 INCR 是原子操作、SET 是原子操作。这个“原子”指的是单条命令在 Redis 内部执行时不会被其他命令打断因为 Redis 命令的执行是单线程模型。但这造成了一个很自然的错觉既然每条命令都原子了那一组命令合在一起是不是也自动安全了答案显然是否定的。在 Redis 里执行“读 A、根据 A 算新值、写 A”这三步中间完全可以插入另一个客户端发来的命令。这就好像你在排队打饭你的三张饭票是分开递给阿姨的中间可能会插进来一个加塞的人。Redis 事务就是为了解决这种“多命令组合需要不被插队”的问题而存在的。1.2 事务的“排队-执行”模型Redis 事务的核心思路可以归纳成六个字先排队后执行。你用 MULTI 开启一个事务后后续输入的命令不会立即执行而是被 Redis 放进一个队列里。等到你发送 EXECRedis 才把这个队列里的命令一条一条取出来执行执行期间不会再处理其他客户端的命令请求。这个模型有两点需要特别注意。第一命令入队时 Redis 只是检查命令是否存在、参数数量是否对并不会真正执行命令逻辑。第二一旦 EXEC 开始执行队列里的命令会“一气呵成”地跑完中间不会因为某条命令运行出错而自动停止。这种设计与传统关系型数据库的事务差异很大后面我会专门分析为什么 Redis 要这样做。1.3 为什么 Redis 事务不支持回滚很多人第一次用 Redis 事务时都会问一个问题如果我事务里的第二条命令执行失败了Redis 会把第一条命令撤销吗答案是不会。Redis 官方文档里甚至直接说了事务过程中如果遇到命令执行错误Redis 不会做回滚而是继续执行剩余命令。为什么不支持回滚官方给出的理由很实在Redis 事务中的错误绝大多数是编程错误比如命令拼错、对 string 类型的键执行了 list 命令。这种错误在开发阶段就应当被发现而不是等到生产环境靠回滚来兜底。另一个原因是回滚机制会让 Redis 内部实现变得复杂很多而 Redis 一贯的设计哲学是“简单高效”能不做的事就不做。理解了这一点你就不会用 MySQL 的思维去套 Redis 事务了。2. 事务指令速查与逐条拆解2.1 MULTI 与 EXEC开启事务和执行事务MULTI 是事务的起点它的作用是把连接状态标记为“事务中”。之后每发送一条命令Redis 都会返回一个 QUEUED 字符串表示命令已进入队列。直到你发送 EXECRedis 才会真正批量执行队列里的命令并把所有结果一次性返回给你。下面是一段最基础的事务交互建议你直接在命令行里体验一下127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET user:1001:name zhang QUEUED 127.0.0.1:6379 INCR counter:login QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (integer) 1注意 EXEC 的返回结果是一个数组数组里的每个元素对应事务里一条命令的执行结果顺序和入队顺序完全一致。这一条特性很重要你要把 EXEC 的返回值当作一个整体来看而不是分别去查每一条命令的执行状态。还有一个小细节如果在 MULTI 之后没有执行 EXEC直接断开连接Redis 会自动丢弃事务队列不会留下半截事务。这个行为在日常开发中能帮我们省掉不少清理工作。2.2 DISCARD放弃事务DISCARD 命令的作用是清除当前连接上所有已入队的命令并退出事务状态。它相当于告诉你这个事务我不想要了把队列扔了吧。使用场景很典型你在事务里入队了一堆命令但是在中途判断某个前置条件不满足后续命令就没必要执行了。这个时候如果直接 EXEC会造成无意义的写入如果直接断开连接又显得太粗暴。DISCARD 就是为此设计的127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET user:1001:name zhang QUEUED 127.0.0.1:6379 DISCARD OK 127.0.0.1:6379 GET user:1001:name (nil)注意一个细节DISCARD 也会解除 WATCH 对键的监视这个特性在并发场景下非常有用。假设你 WATCH 了一个键发现它已经被别人改过事务没法继续此时用 DISCARD 结束事务Redis 会自动帮你 UNWATCH不需要再手动调一次。2.3 WATCH 与 UNWATCH乐观锁的实现WATCH 是 Redis 事务里最有价值的一条命令它让事务从“无条件的批量执行”升级成“有条件地执行”。WATCH 可以监视一个或多个键在 EXEC 执行前Redis 会检查这些键在 WATCH 之后是否被修改过。只要其中任何一个键被修改过EXEC 就会直接返回 nil事务队列里的所有命令都不会执行。用一个生活化的例子解释你去自动售货机买东西投币前先看了一下货架上的可乐还剩最后一瓶WATCH 可乐。在你操作投币期间另一个人把最后一瓶买走了键被修改。等你按下购买键EXEC售货机告诉你交易取消因为商品状态已经变化了。你没有买到可乐也没有被扣钱。127.0.0.1:6379 WATCH product:1001:stock OK 127.0.0.1:6379 MULTI OK 127.0.0.1:6379 DECR product:1001:stock QUEUED 127.0.0.1:6379 EXEC (nil)上面的交互里EXEC 返回 nil 而不是数组说明 WATCH 的键在事务期间发生了变化事务被中止。如果你不希望继续监视某些键可以发送 UNWATCH它会取消当前连接上所有 WATCH 监视。UNWATCH 在执行 EXEC 或 DISCARD 之后也会被自动触发所以大多数场景下你不需要手动调用它。3. 事务执行机制与错误处理全景3.1 入队阶段错误一票否决我把事务中可能出现的错误分成两类第一类是入队阶段错误。当你在 MULTI 之后发送命令时Redis 会先检查命令是否存在、参数数量是否正确。如果命令根本不存在或者参数个数不对Redis 不会把它加入队列而是直接返回错误信息。举几个例子SET key缺少 value 参数、GETSET拼写成GETSETTT、INCRB不存在的命令。这类错误的特点是在 EXEC 之前就能被发现。但 Redis 的处理策略有些特殊——一旦入队阶段出现任何一条命令错误Redis 在后续收到 EXEC 时会直接拒绝执行整个事务返回一个错误事务里所有命令全部作废。注意MULTI 状态会被保留还是退出实测是事务状态会被清理这是一个需要记住的坑。下面这段交互可以看到完整过程127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET user:1001:name zhang QUEUED 127.0.0.1:6379 INCRB user:1001:age (error) ERR unknown command INCRB 127.0.0.1:6379 EXEC (error) EXECABORT Transaction discarded because of previous errors.这一条很重要入队阶段的一个拼写错误会导致整个事务被丢弃。实际操作中这类错误在开发环境很容易暴露但一旦代码走了动态命令拼接比如根据条件拼出不同的命令集合就可能把错误带到生产环境。3.2 执行阶段错误部分成功第二类错误是执行阶段错误也就是命令语法没问题、也成功入队了但在 EXEC 真正执行时发现数据类型不匹配、键不存在等运行时错误。典型的是对一个字符串string键执行 LPUSH或者对不存在的键执行 GET 并期望它是整数。127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET user:1001:age 18 QUEUED 127.0.0.1:6379 LPUSH user:1001:age 19 QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value注意这里的关键差异EXEC 返回了一个数组第一条命令执行成功第二条命令报错。Redis 不会因为第二条报错而回滚第一条的写入。也就是说在 Redis 事务里“部分成功”是允许发生的。这是它与传统数据库事务最大的不同也是很多初学者最容易踩的坑以为事务能保证 all-or-nothing结果发现它只保证“不被插队执行”不保证“要么全成要么全败”。3.3 事务中的隔离性到底怎么样有些文章会把 Redis 事务说成“具备隔离性”这个说法需要仔细辨别。在 EXEC 执行期间Redis 是单线程处理命令的所以确实不会穿插其他客户端的命令从这个角度看事务内的命令之间是隔离的。但是 WATCH 提供的隔离语义更接近乐观锁多个客户端可以同时 WATCH 同一个键也可以几乎同时提交 EXEC但只有一个客户端能成功看谁先抢到执行机会。需要说明的是WATCH 检查是在 EXEC 执行的瞬间完成的。如果两个客户端同时执行 EXECRedis 会串行地处理它们第一个到达的会成功第二个到达时发现键已被修改事务被放弃。这种机制不依赖任何锁也避免了死锁代价是并发高时事务成功率较低需要客户端自己处理重试逻辑。4. 实战库存扣减与秒杀场景的三种方案4.1 方案一WATCH MULTI 实现 CAS最经典的场景就是库存扣减。假设我们有一个 keystock:1001保存商品库存用户下单时要先判断库存大于 0然后执行减一。如果不做保护两个请求同时读到库存 1都会认为有货最后都执行减一库存变成 -1超卖就发生了。用 WATCH 加事务可以这样写WATCH stock:1001 MULTI DECR stock:1001 EXEC这个方案的关键点是必须在 WATCH 之后再读库存吗不一定。你甚至不需要先读库存直接乐观地执行 DECR然后检查 EXEC 的返回值。如果 EXEC 返回 nil说明库存键在 WATCH 之后被别人改过本次减库存失败如果返回了数组就说明减库存成功。这种“先干再说失败重试”的思路就是 CASCompare And Swap的乐观锁思想。在 Java 客户端里面伪代码大概是这样的while (true) { var tx redis.multi(); tx.decr(stock:1001); var result tx.exec(); if (result ! null) { // 事务成功减库存成功 break; } // 事务失败说明并发冲突重试 }这套方案实现简单不依赖 Lua也不需要额外的锁适合中小流量的场景。但缺点也很明显并发冲突严重时大量请求会在循环里反复重试给 Redis 带来额外压力。4.2 方案二Lua 脚本完全替代事务如果说 WATCHMULTI 是“乐观锁重试”那么 Lua 脚本就是“一口气把逻辑写完”。Redis 从 2.6 开始支持在服务端执行 Lua 脚本脚本在执行期间同样不会被其他命令插入而且脚本内部可以完成读、判断、写整个流程真正做到了原子性。库存扣减脚本大致长这样local stock tonumber(redis.call(GET, KEYS[1])) if stock 0 then redis.call(DECR, KEYS[1]) return 1 end return 0这段脚本解决的问题和 WATCHMULTI 一样但用户体验好了不止一个量级。不需要重试循环因为脚本内部已经完成了“判断库存是否足够”和“减库存”两个动作不存在中间被插队的窗口。这也解释了为什么很多生产系统中的秒杀逻辑都用 Lua 脚本实现——简单、可靠、性能还高。4.3 方案三分布式锁 事务改造还有一种常见思路是先获取分布式锁再执行读和写最后释放锁。这种方案在库存不多、并发量可控的场景下也能用。但要注意Redis 事务和分布式锁并不冲突它们解决的是不同层面的问题锁解决的是“多个客户端互斥操作”事务解决的是“一组命令不被插入”。在实际项目中我见过一种混合用法用分布式锁保证同一时刻只有一个客户端操作某个商品库存同时用 MULTI/EXEC 把“扣减库存记录订单”绑定在一起。这样做确实复杂也引入了锁过期、锁重入等一系列新问题。除非业务上确实需要强互斥否则我更推荐用 Lua 脚本代替这套组合拳。选型时我的判断标准很简单需要跨多条命令的原子逻辑优先 Lua只是想避免并发写冲突优先 WATCH原本就有锁组件且能接受锁的代价才考虑分布式锁方案。5. 常见问题与避坑实录5.1 问题速查表为了让你排查时不用翻文档我把日常最容易踩的坑整理成了一张速查表每一条都是我或者身边同事真实遇到过的。问题现象根本原因处理方式EXEC 返回 nil事务没执行WATCH 的键在事务期间被修改重试事务或者改用 Lua 脚本控制冲突窗口EXEC 返回 EXECABORT入队阶段存在命令错误检查命令拼写、参数数量尤其是动态拼接的命令事务中部分命令成功、部分失败执行阶段错误如类型不匹配设计时不依赖回滚提前用 TYPE 校验键类型MULTI 后忘记 EXEC连接一直挂起事务队列未执行检查客户端连接状态断开连接会自动丢弃队列事务内命令结果不符合预期命令在 EXEC 前未执行读到的是旧值不要在多条命令之间依赖彼此的中间结果事务执行期间大量超时一个事务里命令过多阻塞 Redis控制单事务命令数量必要时拆分多个事务第 5 行那条“事务内命令结果不符合预期”很有迷惑性。有人写这样的代码想实现“把键 A 的值加 1再把键 B 设为键 A 的新值”MULTI INCR key_A SET key_B key_A_value EXEC问题是 Redis 事务里的命令之间不会互相读取结果SET 用的 key_A_value 是客户端在事务之前读到的旧值而不是 INCR 之后的新值。这种场景必须用 Lua 脚本或者把两步合成一步用SET key_B value里带上计算后的值。5.2 我的事务使用心得把事务命令过了一遍之后我对它们的定位有了更清晰的认知。Redis 事务不是一个功能丰富的“数据库事务”它是一个非常朴素的“命令批量执行器”。如果你需要的是严格的回滚和一致性保证那得选 Lua 脚本或数据库事务如果你只是想让一组命令不被其他人插队那 MULTI/EXEC 是完全够用的。有几个心得很想分享出来。第一个生产环境的代码里尽量不要手写 MULTI/EXEC 裸命令建议用客户端库更高阶的事务 API并在测试环境跑一遍完整的事务流程。第二个事务里的命令数量不要贪多我见过有人一个事务塞了上百条命令直接导致 Redis 阻塞几秒线上告警哗哗响。第三个WATCH 的事务失败后一定要有重试机制但重试不能无上限建议设置最大重试次数超过后直接提示用户稍后再试。第三个心得特别想展开说曾有一个同事在秒杀系统里用 WATCHMULTI 做库存扣减失败后无限重试。结果瞬时并发一上来Redis 的 CPU 直接被重试请求打满原本要保护库存的系统自己先倒了。后来把重试上限调成 3 次再失败就返回“已售罄”或者让用户重新提交现象立刻就平稳了。最后再说一个大家容易忽略的点在 Redis Cluster 集群模式下事务要求所有命令的键必须落在同一个节点上否则会报跨槽slot错误。如果你用的是集群版务必把业务相关的键设计在同一个哈希槽内比如给键名加相同的哈希标签hash tag。我自己就在这上面栽过跟头以为本地单机没问题上了集群才发现事务完全跑不起来。这类细节文档里只会轻描淡写提一句真正踩过坑才知道值多少钱。