先说明一点我在日常答疑里见过不少人把 Redis 事务和关系型数据库事务画等号然后踩坑。这篇文章就从原理到实操把 Redis 事务完整过一遍包括事务队列怎么工作、WATCH 乐观锁该怎么用、事务和 Lua 哪个更适合你的场景以及我在生产环境里真实踩过的几个坑。整篇内容不绕弯子适合以下读者准备面试 Redis 相关岗位的开发者、正在做秒杀/库存/订单类业务需要保证并发一致性的后端工程师、以及用 Redis 事务写过业务但没搞懂内部机制的“半懂型”选手。1. Redis 事务到底解决什么问题——先别急着敲命令1.1 关于 Redis 事务最常见的误解Redis 事务经常被拿来和 MySQL 事务对标。很多人一听到“事务”两个字下意识就觉得它有回滚、有隔离级别、能保证 ACID。但事实上Redis 事务的核心能力只有一条将多个命令打包成一个步骤按顺序执行并且执行过程中不会被其他客户端的命令插队。这有点像你去食堂打饭你把自己要的菜一个个告诉阿姨阿姨按顺序把菜打到盘子里期间不会有人插队。但“按顺序打到盘子里”不等于“这些菜一定都是好的”更不等于“如果其中一道菜没炒熟前面的菜都要倒掉重来”。所以第一件事就是把预期摆正Redis 事务不是 MySQL 事务的替代品它解决的是“并发场景下多个操作不能原子地被其他客户端干扰”的问题而不是“多个操作中某个失败后整体回滚”的问题。1.2 事务命令全家桶MULTI / EXEC / DISCARD / WATCHRedis 事务相关的命令一共四个MULTI、EXEC、DISCARD、WATCH还有一个配套的 UNWATCH。MULTI开启事务后续命令进入队列而不是立即执行。EXEC提交事务按顺序执行队列里全部命令。DISCARD清空命令队列取消事务。WATCH在事务开始前监控一个或多个 key如果事务提交前这些 key 被其他客户端修改则事务整体被拒绝。这个组合看起来很简单但真正把它们用对、用出价值需要对底层机制有清楚的认知。下面我按执行流程一步步拆开讲。2. 事务执行全流程命令是怎么“排队”的2.1 MULTI 到 EXEC事务队列与命令入队当客户端发送 MULTI 命令时Redis 会为这个客户端进入一个特殊状态。从这一刻开始这个客户端后续发送的命令不会被立即执行而是被放入一个事务队列中。直到收到 EXECRedis 才以单线程的方式逐条执行队列里的命令。这里要强调一个关键点EXEC 之前队列里的命令是不会读取或修改任何数据的。你可以简单理解为所有命令都是“记账”真正“掏钱”是在 EXEC 那一刻。举个例子。你在命令行里依次输入MULTI SET user:1024:balance 100 INCRBY user:1024:point 50 EXEC在输入 EXEC 之前的每一步Redis 只返回 QUEUED表示命令已经进队。执行 EXEC 后Redis 才会依次执行 SET 和 INCRBY并把这两个命令的返回结果一次性打包返回来。如果你在输入 EXEC 之前后悔了可以用 DISCARD 清空队列事务直接作废之前进队的命令一条都不会执行。2.2 两种错误两种命运——原子性到底保不保这是全网被问爆的问题Redis 事务遇到错误会不会回滚答案要分两种情况来看因为这两种情况的行为完全不同。情况一命令入队时报错。比如你写了一个语法有问题的命令或者使用了不存在的命令Redis 会在入队阶段直接提示错误。这种情况下如果继续执行 EXEC会发现所有命令都被拒绝执行事务整体作废。这有点像你点菜时告诉服务员一个菜谱上根本不存在的菜服务员直接拒绝记录整份菜单。MULTI SET user:1024:name test GET user:1024:name GET # 缺少参数语法错误 EXEC这里的 GET 缺少参数入队阶段就会报错。执行 EXEC 后返回 EXECABORT事务整体终止前面的 SET 也不会执行。所以从结果上看这种错误下事务是“全有或全无”的。情况二命令执行时报错。比如命令语法没问题但执行时类型不匹配——对字符串 key 执行 LPUSH。这种情况下 Redis 不会停止执行后续命令会跳过报错的那条继续执行队列里剩下的命令已经成功执行的结果也不会回滚。MULTI SET user:1024:name test LPUSH user:1024:name new # 对字符串 key 执行列表操作运行时错误 SET user:1024:age 20 EXEC执行结果SET name 成功LPUSH 返回错误SET age 成功。事务并不会因为中间某条命令失败而回滚前面的操作。这就是 Redis 官方文档里说的Redis 事务不支持回滚事务里的某条命令失败时其他命令照常执行。为什么这么设计官方给出的理由也很实在Redis 命令失败的场景基本是编程错误这类错误在开发阶段就应该被发现不值得为它引入回滚机制的复杂度。我在实际项目里也是这么理解的把 Redis 事务当成“批量原子执行器”而不是“安全回滚保护伞”心态会稳很多。3. WATCH 乐观锁Redis 事务的灵魂3.1 从秒杀场景说清楚 CAS 思路上一节讲的事务队列解决的是“执行过程不被打断”但它解决不了另一个经典问题竞态条件。举个例子两个客户端同时做“读取库存、判断库存、扣减库存”三个操作如果只靠 MULTI/EXEC在读取库存到执行扣减之间另一个客户端可能已经把库存改掉了。WATCH 就是为了解决这个问题而生的。它的工作方式可以类比成“带校验的乐观锁”在事务开始前 WATCH 某个 keyEXEC 执行前 Redis 会检查这个 key 是否被其他客户端修改过。如果被修改了这个事务直接拒绝执行返回 nil。这里有个非常关键的细节WATCH 必须在 MULTI 之前执行。一旦执行了 MULTI就不能再使用 WATCH 了。WATCH 本身只是“盯住”key它本身不会阻塞其他客户端对 key 的读写。经典的秒杀扣库存伪代码如下WATCH stock:1001 stock GET stock:1001 if stock 0 then return 已售罄 MULTI DECR stock:1001 EXEC执行到 EXEC 时如果 stock:1001 在这期间被其他客户端修改过EXEC 就会返回空结果表示事务被拒绝执行。此时客户端需要重新执行 WATCH、重新读取库存、重新判断这就是 CASCompare And Swap的经典循环。3.2 WATCH 失效与竞态条件WATCH 还有一个容易忽略的特性如果事务正常执行完成WATCH 会自动失效不需要手动 UNWATCH。如果事务因为 WATCH 检测到 key 被修改而失败WATCH 同样会失效但此时你需要重新 WATCH 才能开启下一轮监控。另外UNWATCH 的作用是在事务执行前手动取消对所有 key 的监控。一个典型的场景是你 WATCH 了一个 key但后续逻辑判断不需要事务了此时不 UNWATCH 就会一直持有监控状态。虽然影响不大但如果连接是长连接并且后续有别的操作建议养成显式 UNWATCH 的习惯。在实际的生产代码里WATCH 配合循环重试是比较通用的写法。比如用 Java 写一个库存扣减while (true) { jedis.watch(stock:1001); int stock Integer.parseInt(jedis.get(stock:1001)); if (stock 0) { jedis.unwatch(); throw new RuntimeException(已售罄); } Transaction tx jedis.multi(); tx.decr(stock:1001); ListObject result tx.exec(); if (result ! null) { break; // 执行成功跳出循环 } // result 为 null 说明事务被 WATCH 拦截重试 }这种写法在并发量不大、单个 key 竞争不激烈的场景下完全够用。但如果单个 key 的 QPS 非常高频繁的重试会放大数据库压力这时候就要考虑 Lua 脚本方案了后面会专门讲。4. Redis 事务与 ACID该有的和不该有的4.1 原子性、一致性、隔离性、持久性逐项拆解把 Redis 事务跟 ACID 逐条对标是最容易区分它和 MySQL 事务差异的方式。下面这张表建议收藏ACID 特性Redis 事务表现说明原子性部分支持入队错误时全拒绝运行时错误不回滚一致性支持单线程顺序执行数据不会被并发写坏隔离性支持事务执行期间不会被其他客户端命令插入持久性依赖配置与 RDB / AOF 持久化策略有关极端情况会丢数据这里重点说下原子性。很多人把“不回滚”理解成“原子性不保证”这个理解不够精确。Redis 事务在“并发干扰”这个层面是原子的——执行期间不会有别的命令插进来。但在“失败回滚”这个层面它不具备传统事务的回滚能力。所以官方文档用了“部分原子性”这类表述实际的语义是要么被 WATCH 拦截整体不执行要么执行后部分命令可能失败但不回滚。我一般这样跟团队新人解释Redis 事务保证的是“执行过程中不会有人插队”但不保证“队列里所有人都不出错”。这个点理解清楚了用法上就不会出大问题。4.2 为什么持久化依赖配置极端场景丢数据持久性这块有个很容易被忽略的问题。Redis 默认的 RDB 快照是异步执行的如果事务执行完还没触发快照Redis 突然宕机那么最近这段时间的数据可能没来得及落盘事务执行的改动自然也就丢了。如果开了 AOF 且配置为 always 模式每次写命令都会同步刷盘这时候持久性才有比较强的保障。但 always 模式性能损耗比较大生产环境一般会结合业务量选择 everysec。我个人的建议是如果你的业务对 Redis 里的数据丢失零容忍首先应该审视的是为什么把这么重要的数据只放在 Redis 里而不是指望 Redis 帮你解决持久化问题。Redis 缓存 数据库双写缓存丢失最多回源重建这才是正常的架构姿势。5. 事务还是 Lua 脚本选型对比与实操建议5.1 为什么说 Lua 脚本是加强版事务Redis 从 2.6 开始支持 Lua 脚本官方对 Lua 脚本的定位描述得非常清楚它在服务端以原子方式执行一段脚本脚本执行期间不会被其他命令插队。从原子性角度来说Lua 脚本的隔离性和 MULTI/EXEC 是同一级别但它有几个明显优势第一Lua 脚本可以在脚本内部做逻辑判断比如读库存、判断库存是否充足、执行扣减整个过程在一个脚本里完成不需要客户端多次请求。第二Lua 脚本天然支持“失败的逻辑”比如库存不足可以直接返回错误码不会像事务那样出现“命令执行失败但其他命令继续跑”的情况。第三Lua 脚本不需要 WATCH 循环重试因为它本身就是原子的。所以我的选择标准很明确需要先判断再执行读改写组合的场景优先用 Lua只是简单批量执行多条写命令、不需要中间逻辑判断的场景用 MULTI/EXEC 就够了。5.2 库存扣减实战事务版和 Lua 版对照事务版的库存扣减前面已经演示过 WATCH 循环的写法虽然能用但客户端和 Redis 之间需要多次网络交互而且并发高的时候重试成本不低。Lua 版本的实现更紧凑。假设 key 是 stock:1001库存字段值是一个普通字符串数字下面这段脚本就是完整扣减逻辑local stock redis.call(get, KEYS[1]) if not stock then redis.call(set, KEYS[1], 0) stock 0 end if tonumber(stock) 0 then return -1 end local new_stock redis.call(decr, KEYS[1]) return new_stock调用时把 KEYS[1] 传入 stock:1001 即可。这段脚本在服务端一次性执行不存在“读到的库存过期”问题也不需要循环重试。如果返回 -1说明库存不足如果返回大于等于 0说明扣减成功。在 Java 里用 Jedis 调用 Lua 脚本的代码类似String lua local stock redis.call(get, KEYS[1]) ...; // 上面的脚本 Long result (Long) jedis.eval(lua, Collections.singletonList(stock:1001), Collections.emptyList()); if (result -1L) { // 已售罄 } else { // 扣减成功剩余库存为 result }这里有个性能细节值得注意每次 eval 脚本都会把 Lua 代码传到服务端Redis 会缓存编译后的脚本。如果脚本很长的可以考虑在服务端加载脚本后拿到 SHA后续用 evalsha 调用减少带宽消耗。不过这个优化通常在脚本被调用非常频繁时才值得做。5.3 事务嵌套与 Spring 注解的“幻觉”很多人在 Spring 项目里用过 Transactional 注解管理数据库事务就想当然地以为这个注解也能管理 Redis 事务。但实际测试下来会发现Spring 的 Transactional 只管数据源事务Redis 操作并不会被纳入同一个事务管理。Spring Data Redis 里有一个 RedisTemplate 的 execute 方法可以配合 SessionCallback 实现 Redis 事务但这是另一套机制跟 Transactional 没有关系。如果你在一个 Transactional 方法里同时操作数据库和 Redis期望两者要么一起成功要么一起失败那大概率会失望。数据库提交成功了Redis 操作失败并没有全局回滚机制。真正要保证跨存储的一致性需要引入分布式事务方案比如基于消息队列的最终一致性、本地消息表、或者 Seata 这类分布式事务框架。但这类方案的复杂度和成本都比较高我的建议是在架构设计阶段就把“Redis 缓存数据”和“数据库主数据”的主从关系想清楚不要把 Redis 当作唯一数据源否则后面补一致性会非常痛苦。6. 常见问题排查与避坑实录6.1 常见问题速查表日常排障过程中Redis 事务相关的问题其实很好定位因为套路有限。下面按频率整理了一张速查表基本上覆盖了我遇到过的绝大多数问题。现象可能原因解决方案EXEC 返回空数组WATCH 的 key 在事务提交前被修改重新 WATCH 重试或改用 Lua入队时报错 EXECABORT命令语法错误、命令不存在检查命令拼写和参数数量某条命令执行时报错但其他命令成功类型不匹配、运行时错误代码层面做类型检查和异常处理事务里读不到自己刚写的数据误把读操作放在 EXEC 之前把读操作放进事务队列或用 LuaWATCH 后执行 MULTI再 WATCH 别的 keyWATCH 必须在 MULTI 前调整顺序先 WATCH 再 MULTI高并发下 WATCH 重试爆炸单 key 竞争激烈改用 Lua / 分布式锁限流6.2 几个容易让人怀疑人生的运行细节第一个细节事务队列里命令的返回值是在 EXEC 时才拿到的。很多人图省事在 MULTI 之后立刻执行 GET 并期望拿到结果结果 GET 返回的是 QUEUED一脸懵。这是对 Redis 事务模型理解不到位导致的。第二个细节不要在事务里用依赖旧值的命令组合。比如你想执行“读取 counter加 100写回”如果按“GET counter → MULTI → SET counter新值→ EXEC”的思路就会让竞态条件乘虚而入。正确做法是用 INCRBY 这种原子命令或者用 WATCH 保证变量不被其他客户端修改。第三个细节Redis Cluster 模式下事务的 key 必须落在同一个 slot。事务队列里的命令涉及的 key 如果 hash 不在同一个 slot会直接报错。这个限制在集群环境尤其需要注意我见过有团队把多 key 事务从单机迁移到集群后才发现跑不通。解决办法是让这些 key 使用相同的 hash tag比如把 user:1024 和 order:1024 改成 {user:1024} 和 {order:1024}保证落同一个 slot但这本身会带来数据分布不均的风险需要权衡。第四个细节事务执行过程中的错误不会影响连接状态。事务里单条命令失败连接仍然可用后续正常命令继续执行。但如果客户端没有读取和处理 EXEC 返回的结果列表错误可能会被吞掉排查时就会发现“命令明明失败了但代码没抛异常”。所以用 Redis 事务时一定要检查 EXEC 的返回结果。6.3 个人实践中的复盘什么时候我不建议用 Redis 事务最后分享一个带着教训的经验。早些年在做活动系统的时候我一度很喜欢用 WATCH MULTI 做库存扣减因为它看起来很正统。但随着流量上涨单个热门商品 key 的竞争越来越激烈WATCH 循环重试的频率明显上升Redis 的 RTT 和客户端线程消耗都上来了。后来我把扣减逻辑统一改成了 Lua 脚本同样的业务代码更短还少了几次网络往返P99 延迟肉眼可见地降了下来。现在我的选型逻辑已经稳定了纯批量写、不依赖前置判断用 MULTI/EXEC需要读改写、条件判断、复杂流程直接用 Lua如果要保证跨 Redis 和数据库的一致性Redis 事务解决不了应该在设计层面规避。Redis 事务本身不是银弹但它也不是鸡肋。把它的边界摸清楚在合适的场景里用它能解决很多并发一致性问题。如果你现在正准备在项目里引入 Redis 事务我建议先问自己一句这个操作里有没有“先读后写”的逻辑有就优先考虑 Lua没有再用事务队列这样能少走很多弯路。