1. Redis事务是什么先别想成MySQL那种事务把Redis事务当数据库事务用是不少新手踩的第一个大坑。Redis事务的本质是一组命令的排队执行机制客户端先发出MULTI开启事务把多条命令装进一个队列再统一发给Redis服务端按顺序执行整个过程不会被其他客户端的命令插入。这套机制解决的问题很直接——保证一批命令在Redis服务端是连续、按顺序执行完的。假如你在执行事务过程中恰好有其他请求进来对方命令也会被 Redis 认认真真地排在你后面不会打断你的事务。但难点也在这里Redis事务的原子性和MySQL事务的原子性是两个完全不同的概念。MySQL里任一语句失败会整体回滚Redis里是失败的命令不影响其他命令继续执行。很多人在面试和工作中翻车就是没搞清楚这一点。2. Redis事务的核心命令与执行流程2.1 MULTI / EXEC / DISCARD / WATCH 四大命令Redis事务全部由四个命令构成真正用到的其实就这四条MULTI开启事务标记事务块开始。此后的命令都会进入一个队列而不是立即执行。EXEC执行事务块中的所有命令。DISCARD取消事务清空队列放弃执行。WATCH监视一个或多个键如果在EXEC之前被其他客户端修改事务将被取消乐观锁实现。除此之外事务过程中还有两个隐含的关键行为需要注意事务队列中的命令在真正EXEC执行前不会产生任何实际效果也就是说你MULTI之后执行GET、SET返回的永远是QUEUED而不是命令的实际结果。如果客户端在MULTI后没有执行EXEC而是直接断开连接Redis会丢弃事务队列不会产生任何脏数据。2.2 一条命令从发送到执行Redis内部怎么走的当你依次发送以下命令时可以感受一下事务的过程127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET user:1001 zhangsan QUEUED 127.0.0.1:6379 INCR user:count QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (integer) 1在这个过程中MULTI后Redis会进入一个事务状态。在这个状态下收到的命令不会立刻进入执行器而是先进行语法校验如果语法没毛病就放入一个FIFO队列中同时回给客户端QUEUED如果命令本身存在比如拼错了命令名直接报错并且后续EXEC时会整体拒绝执行。这个阶段的关键点在于Redis采用的是惰性队列只有EXEC时才真正批量执行队列里的所有命令。从性能角度讲这种设计还有一个巨大的优势减少网络开销。事务中的命令都先攒在客户端或者通过pipeline批量发出一次网络交互搞定一整批操作在高频写入场景下比起一条条round trip要省非常多时间。2.3 一个最常用的入门实例批量插入假设你运营着一个社区系统用户注册时需要初始化多个数据比如用户基本信息、默认头像、默认权限标记。你不用事务时代码大概是SET user:1001 {json} SET user:1001:avatar /default.png SET user:1001:role reader这三个操作如果不放在一个事务里假设执行到第二步时服务崩溃或者网络抖动用户1001可能就有了基本信息却没有默认头像产生数据不一致。事务的正确做法是MULTI SET user:1001 {json} SET user:1001:avatar /default.png SET user:1001:role reader EXEC这样三个键要么全部写入要么全部不写在EXEC之前系统自动清空队列是因为错误能够保证初始化逻辑的完整性。实际项目中这种先初始化多个键的场景用事务来包是最直接有效的做法。3. Redis事务的原子性和ACID深度解读3.1 Redis事务为什么不是真正原子的直接说结论Redis事务不支持回滚不具备数据库意义上的原子性。这背后其实有设计理念的原因Redis的作者认为Redis事务出错通常是在开发阶段就能发现的编程错误生产环境很少出现。回滚功能会显著增加Redis内部复杂度和运行成本不符合Redis极简高性能的定位。事务执行中的错误不会导致脏数据因为命令要么执行、要么报错并不会像MySQL半路停机那样出现不可控状态。所以你在事务里执行SET a 1紧接着执行INCR a对一个字符串而不是数值执行自增前一句会正常生效后一句会返回一个错误。但已经执行的那条不会回滚。注意这是和MySQL最核心的区别。如果你要求的就是要么全成功要么全不成功你必须用Lua脚本或者接受这种部分成功的现实。3.2 Redis事务遇到的三种错误类型理解Redis事务的可靠性就要区分三类错误错误类型示例行为入队错误命令拼写错误、参数个数不对整个事务拒绝执行EXECABORT执行错误对字符串做INCR、类型不匹配出错的那条命令报错其他命令照常执行服务器宕机错误执行过程中Redis崩溃部分命令可能已写入磁盘恢复后需要看AOF日志其中入队错误是最能体现Redis事务不会粗枝大叶的地方只要任何一个命令语法有问题Redis干脆整个事务就不执行了。而执行错误简单说就是过程遇到一笔坏账Redis不会为了它把前面办好的事情再推翻重来而是接着把后面的活干完最后告诉你一会儿哪一笔出了问题。3.3 Redis事务的三种结束方式EXEC、DISCARD、连接断开在实际业务开发中事务三个出口的行为逻辑必须背熟EXEC事务队列中的命令按顺序执行事务结束。DISCARD事务队列被清空与事务相关的WATCH监视也被取消。连接断开Redis会检测到客户端掉线主动清空事务队列。这就相当于DISCARD的效果不需要额外处理。实际开发中有一个细节很多人忽略一旦用MULTI开启了事务你不能在中间用SELECT切换数据库。Redis规定事务运行期间不允许SELECT包括WATCH后、EXEC前也不行。如果你有这种需求只能拆成多个事务。4. WATCH乐观锁解决并发覆盖问题的实战关键4.1 WATCH的实现机制CAS思想WATCH命令关注的是版本对比实际上就是典型的CASCompare And Swap思路。你在执行EXEC之前先WATCH一个或多个键Redis会记录这些键对应的版本号。然后你在这个基础上做业务逻辑比如计算、查库、组装业务数据到了EXEC时Redis会重新检查watch的键是否在WATCH后被修改过。前提是WATCH命令本身要在MULTI之前执行正确的操作顺序是WATCH stock:1001 val GET stock:1001 # 业务计算比如判断库存是否充足 MULTI DECR stock:1001 EXEC如果从WATCH到EXEC这段时间内有其他客户端修改了stock:1001EXEC会直接返回空nil告诉你别执行了。此时事务根本没有运行也就不会产生并发覆盖。4.2 典型场景秒杀系统中的库存扣减网上秒杀系统最常见的超卖问题用Redis事务WATCH可以轻松解决核心扣减问题。模拟代码如下import redis r redis.Redis(hostlocalhost, port6379, db0) def sec_kill(user_id, goods_id): key fstock:{goods_id} with r.pipeline() as pipe: while True: try: pipe.watch(key) # 监视库存键 stock int(pipe.get(key)) if stock 0: return sold_out pipe.multi() pipe.decr(key) pipe.rpush(forder:{goods_id}, user_id) pipe.execute() return ok except redis.WatchError: continue这个例子里最关键的地方在于WATCH使用轮询重试机制一旦发现库存被其他请求改了就整个重来一遍continue直到没有并发冲突才真正执行扣减。相比MySQL的悲观锁SELECT FOR UPDATE这种乐观锁方案在超高并发场景下性能好得多因为大多数并发请求其实是冲突不严重的不用死等锁释放。4.3 WATCH的过期键坑一个容易忽略的假象WATCH键如果设置了过期时间在监视期间过期会被删除。但Redis对WATCH过期键的处理有一个很隐蔽的行为在EXEC时不会把过期键当作被修改触发WatchError所以事务可能依然成功执行而底层的键已经被删除了。比如你WATCH一个5秒后过期的键在5秒后执行EXECRedis不会拦截事务但事务读到的值其实已经是空的。这算是一个冷门陷阱生产环境我会尽量不让WATCH依赖过期键。5. 事务中的Lua脚本更高级的原子性替代方案5.1 为什么Lua脚本能取代大部分事务场景Redis从2.6开始支持在服务端执行Lua脚本而且Lua脚本在Redis中是真正原子的——整个脚本作为一个整体执行中间不会插入任何其他命令。从效果上说Lua脚本解决了Redis事务执行过程中出错无法回滚的痛点因为脚本内部完全由你控制执行逻辑可以在逻辑层面对整个操作进行串联和校验一旦某一步不满足条件可以手动return不做任何写入。相比事务Lua更像一个有小逻辑的服务端存储过程。用Lua实现同样的库存扣减local stock tonumber(redis.call(GET, KEYS[1])) if stock 0 then return 0 end redis.call(DECR, KEYS[1]) redis.call(RPUSH, KEYS[2], ARGV[1]) return 1调用方式Redis-cliredis-cli --eval stock.lua stock:1001 , user:1001这段脚本里你可以做复杂的判断和计算而因为整个脚本原子性执行不存在并发交错的问题。所以在秒杀、限流、防重、分布式锁等要求严格原子性的场景Lua脚本才是首选工具Redis事务更适合那种纯批量、无依赖、不用回滚的操作。5.2 管道Pipeline与事务的区别很多同学会把Pipeline和事务混淆。它们确实往往一起用但解决的是不同的问题Pipeline优化网络传输。把多条命令一次发给Redis节省网络round-trip时间。但命令之间没有任何批量执行保护中间可以插入其他客户端的命令。事务保证命令连续执行同时也能减少网络交互命令仍然可以打包发送。客户端框架里比如Spring Data Redis的SessionCallback或Jedis的pipeline()接口经常是两者结合使用用Pipeline打包命令用MULTI/EXEC包裹事务。要注意的是一旦在Pipeline中开启事务发出去的每一条命令返回的都是QUEUED要等到EXEC时才会真正看到执行结果这个返回值顺序一定要仔细处理。5.3 锁和事务联手防超卖实战方案结合上面内容我给出一个比较稳妥的高并发防超卖流程先用SET NX EX命令获取分布式锁防止多机器实例之间跨节点并发。拿到锁后在Redis里读取库存并做扣减此时单节点内互斥已经保证如果需要更强一致性可在锁内再套一层事务或Lua。释放锁用Lua脚本保证比较和删除是原子的。这个方案把Redis分布式锁解决多实例互斥的能力和事务/Lua解决单实例批量原子的能力结合起来是目前生产环境下最主流的做法。如果真的遇到跨库跨系统的分布式事务比如订单和库存分属两个服务那就得用消息队列、本地消息表、TCC等分布式事务方案了单靠Redis是搞不定的。6. 事务实践中的常见问题与高效排查6.1 事务在Spring/Java中的常见错误Java后端是重灾区。很多人在Spring Boot项目里用了Transactional注解天真地以为Redis操作也受事务保护实际上Spring的Transactional只对关系型数据库事务生效并不作用于Redis。正确做法是stringRedisTemplate.execute(new SessionCallbackListObject() { Override public ListObject execute(RedisOperations operations) throws DataAccessException { operations.watch(stock:1001); operations.multi(); operations.opsForValue().decrement(stock:1001); operations.opsForList().rightPush(order:1001, user123); return operations.exec(); } });这里有个细节Spring的RedisTemplate默认开启了事务同步setEnableTransactionSupport如果你在Transactional方法里同时操作Redis和MySQL事务提交时机、缓存失效时机都会变得复杂经常出现MySQL还没提交Redis已经写了或者反过来。我的建议是Redis事务和数据库事务不要混在同一个方法里用注解管理强制用SessionCallback显式控制或者干脆拆成两个服务方法避免不必要的心智负担。6.2 事务执行的返回结果怎么判断成功还是失败一个很实际的问题是EXEC返回的结果如何判定成功如果EXEC返回的是数组每个元素对应一条命令的结果代表事务正常执行完。如果EXEC返回的是nil并且之前有WATCH的键被修改说明乐观锁冲突事务没执行。如果EXEC返回EXECABORT错误说明事务队列中有一条命令在入队时就语法错误整个事务被拒。不少人在开发时只看EXEC有没有抛异常忽略了WATCH冲突时EXEC返回nil的情况导致数据没有被扣减但代码以为成功了。所以我强烈建议在涉及资金、库存等核心业务时对EXEC的返回值显式判空并处理重试逻辑。6.3 事务与持久化策略AOF/RDB之间的关系Redis事务和持久化也有微妙关系。事务执行过程中如果开启了AOFRedis会把事务期间的命令按格式写入AOF文件如果这个时候Redis发生宕机AOF恢复时能把整个事务的一系列命令重放。所以事务在持久化层面可以保证重启后存在但需要注意Redis默认的RDB快照方式比如save配置可能会丢失最后一次事务的少量数据这是由RDB策略本身的特性决定的并不是事务的bug。如果你对事务持久性要求极高比如需要保证事务执行结果不丢失可以设置appendfsync always但这会极大降低性能一般不建议生产环境直接配置多数场景下用everysec就足够了。6.4 主从复制架构下的事务行为在主从master-slave架构中主节点执行事务写入的数据通过复制流同步给从节点。但有一个已知的坑Redis的复制是数据层面的复制不是命令队列的复制。所以如果主从之间网络延迟较大从节点可能在主节点事务执行完的一瞬间数据还没有完全同步完导致读从库的请求读到旧数据这也就解释了为什么很多人用Redis主从做读写分离时会间歇性地读到事务执行之前的数据。这种情况下的解决方案是核心业务强制读主库从库仅负责扩展读并接受一定延迟比如报表、日志查询。涉及分布式事务一致性要求高的场景采用WAIT命令等待指定数量的从节点完成同步。使用RedLock等方案时特别注意在从库上的锁失效问题。7. Redis事务的常见面试题与底层原理延伸在面试中Redis事务相关的考察主要集中在以下几个方面建议做一个整体梳理7.1 Redis事务的实现原理Redis事务底层依赖于server全局维护的一个事务状态结构。开启MULTI后Redis将客户端标记为REDIS_MULTI状态后续命令不再走正常的命令执行器而是进入事务队列一个数组。每个命令在入队时会做语法检查检查通过则以redisCommand argv等参数形式存入队列EXEC时Redis把队列中的命令逐个取出调用call函数执行最终批量返回结果。这种设计的好处是极快——因为不做复杂的锁管理、不回滚、不处理隔离级别Redis事务在性能上远超数据库事务。坏处也很明显——丢掉了数据库事务中隔离性、持久性等各种保证换取了极致速度。7.2 Redis事务与Lua脚本的取舍面试常见的追问是既然有事务为何还要Lua脚本正确答案是事务不保证回滚Lua可以通过逻辑条件来避免写入。事务的命令之间不具备逻辑判断能力Lua可以在中间随时中断。事务的WATCH本质上是一种乐观锁遇到并发冲突需要客户端重试Lua脚本是天然原子、无冲突的。事务中没法根据前一条命令的结果动态决定下一条命令Lua可以。因此在实现复杂业务原子操作时例如库存扣减、复杂校验、多个条件下的写入Lua是比事务更优选的技术方案。7.3 分布式事务订单与库存与Redis事务的边界从热搜词里看到很多人在搜订单与库存分布式事务、分布式事务一致性这里也顺带提一嘴边界问题Redis事务只解决单节点上多条命令的原子性根本不能解决跨应用、跨数据库的分布式事务问题。跨系统一致性需要引入消息队列最终一致性、本地消息表、TCC事务、Seata框架等方案。有一种常见误用在订单服务扣减库存时先用Redis扣库存防超卖再写数据库订单最后通过消息队列通知库存系统。这里Redis事务只负责扣减库存这一环节的可靠性它无法保证订单写入失败时把库存加回来那需要额外的补偿逻辑比如定时任务扫描对账、消息失败重试等。7.4 Redis 6.X之后对事务的优化和现状Redis 6.0开始引入了多线程IO处理但事务执行仍然是在单线程事件循环中以串行方式完成的所以事务的原子性并没有因为IO线程化而改变。实际上Redis的事务性能和扩展性本质上仍然是单线程模型决定的——一个事务内的所有命令不会并发执行这也是为什么事务天然不存在隔离级别困扰。从版本演进来看Redis官方从来没有打算把事务进化成数据库那样复杂而是通过Lua脚本、函数Redis 7.0的Function特性提供了更强的服务端逻辑能力。如果你需要真正的ACID事务应该在数据库层解决而不是指望Redis。8. 实际项目复盘我是怎么使用Redis事务的最后分享一下我的实操经验。在我维护的一个高并发券码兑换系统中用户提交一个兑换码时需要校验码是否有效、是否已使用、标记为已使用并生成兑换记录这四步是必须连续完成的。最初版本我用的是MULTI/EXEC包住这四个操作发现两个问题WATCH机制在高并发重试下客户端连接上频繁出现watch error导致大量重试不仅拖慢了响应时间也让Redis服务端CPU飙升。执行过程中一旦出现数据类型错误比如兑换码被错误设计成了非String整批操作部分成功让系统进入脏数据状态排查起来十分痛苦。后来我改用Lua脚本重写这段逻辑整个流程变成一个EVAL调用Redis内部一次跑完没有中间状态也没有并发冲突。性能不降反升因为少了一整轮WATCH-MULTI-EXEC的交互流程。这个案例给我的启示是能用Lua就用LuaRedis事务更适合当批量提交工具而不是复杂业务原子保障工具。还有一个处理细节值得说在写事务时最好把所有key在参数中集中声明尤其是用Lua时采用KEYS和ARGV分离的方式这样在Redis Cluster环境下可以保证同一slot下的key操作能在同一个节点完成避免跨slot的CROSSSLOT错误。如果你的事务涉及的key没有任何共同hash tag就没有办法在一个节点里执行此时必须拆分业务逻辑。另外事务和缓存穿透问题的边界也很容易踩混。网上常说的缓存穿透是指请求查询一个不存在的key穿透到数据库有人用Redis事务配合互斥锁来防止击穿比如多个线程同时发现缓存为空用一个SETNX锁让其中一个线程去加载数据其他线程等待。这个锁的实现和事务没有必然关系但如果用了MULTI/EXEC去包裹“先查缓存再回源更新”的逻辑同样会遇到并发窗口问题。我的建议是缓存层面的数据一致性优先用SETNX过期时间做互斥或者用Lua一次性完成缓存不存在则从DB加载的判断不要死磕事务。在使用Redis事务的这几年我的总结就一句话把它当成能一口气让Redis连续执行多条命令的批处理工具千万别把它当成能够帮你搞定一致性问题的银弹。真正需要强一致性的地方要么接受Lua脚本要么引入业务层面的补偿和重试机制要么在数据库事务里解决。把合适的技术用在合适的层级系统才能又稳又快。