Redis 事务不是你想的那种事务别被名字骗了Redis 的事务和 MySQL 的事务差了十万八千里你好欢迎回来如果你刚从关系型数据库转过来听到 Redis 也有事务功能第一反应可能是终于可以保证原子性了然后你兴致勃勃地写了一段代码MULTI DECRBY wallet:1001100INCRBY wallet:1002100EXEC一切顺利你觉得万事大吉。直到有一天wallet:1001余额不足但wallet:1002却神奇地多了 100 块钱。你打开 Redis 文档愣住了Redis 事务不支持回滚。今天我们就来彻底搞懂Redis 的事务到底是什么能做什么不能做什么以及在实际项目中到底该怎么用。一、 先泼冷水Redis 事务 ≠ 数据库事务让我们先建立一个正确的认知特性MySQL 事务Redis 事务原子性✅ 要么全成功要么全失败回滚❌ 不支持回滚某条失败其他继续执行一致性✅ 约束检查⚠️ 部分保证保证命令顺序执行隔离性✅ MVCC/锁⚠️ 单线程天然隔离持久性✅ WAL 日志⚠️ 依赖持久化配置一句话总结Redis 的事务只是一组命令的批量执行中间不会被其他客户端的命令打断但不保证要么全做、要么全不做。经典翻车案例127.0.0.1:6379MULTI OK127.0.0.1:6379SET nameTomQUEUED127.0.0.1:6379INCR name# 这里故意出错对字符串做自增QUEUED127.0.0.1:6379SET age18QUEUED127.0.0.1:6379EXEC1)OK2)(error)ERR value is not an integer or out of range# 这条失败了3)OK结果name被设成了 “Tom”age被设成了 18。中间那条错误的命令只是报错不会导致回滚。这能叫事务吗严格来说不能。它更像是一个命令打包工具。二、 事务的核心命令Redis 事务涉及三个命令非常简单命令作用返回值MULTI开启事务标记事务块开始OKEXEC执行事务块内的所有命令数组包含每条命令的返回值DISCARD取消事务清空命令队列OK还有一个辅助命令|WATCH| 乐观锁监视一个或多个 key | OK |基本用法# 1. 开启事务127.0.0.1:6379MULTI OK# 2. 输入命令不会立即执行只是排队127.0.0.1:6379SET user:1TomQUEUED127.0.0.1:6379SET user:2JerryQUEUED127.0.0.1:6379INCR counter QUEUED# 3. 执行事务127.0.0.1:6379EXEC1)OK2)OK3)(integer)1取消事务127.0.0.1:6379MULTI OK127.0.0.1:6379SET nameTomQUEUED127.0.0.1:6379DISCARD# 放弃什么都不做OK127.0.0.1:6379GET name# name 没有被设置(nil)三、 Redis 事务的特性详解3.1 原子性No是隔离性Redis 事务保证了命令的隔离执行——即事务中的命令会按顺序执行中间不会插入其他客户端的命令。原因Redis 是单线程的。当执行EXEC时Redis 会锁住自己一口气执行完队列里的所有命令然后才处理下一个请求。这就意味着事务中的命令是串行且连续的不会被别的客户端打断。但是如果事务中某条命令执行失败比如语法错误或类型错误其他命令依然会执行完毕。Redis 不会回滚。3.2 两种失败情况失败类型发生时机后果示例语法错误QUEUED阶段整个事务被拒绝EXEC失败SET name缺少参数运行时错误EXEC执行阶段只报错那条其他继续执行INCR namename 是字符串# 语法错误在 QUEUED 时就能发现127.0.0.1:6379MULTI OK127.0.0.1:6379SET name(error)ERR wrong number of argumentsforsetcommand127.0.0.1:6379SET age18QUEUED127.0.0.1:6379EXEC(error)EXECABORT Transaction discarded because of previous errors.# 整个事务被丢弃age 也没有被设置# 运行时错误只有 EXEC 时才发现127.0.0.1:6379MULTI OK127.0.0.1:6379SET nameTomQUEUED127.0.0.1:6379INCR name QUEUED127.0.0.1:6379SET age18QUEUED127.0.0.1:6379EXEC1)OK2)(error)ERR value is not an integer or out of range# 只有这条失败3)OK# name 和 age 都被设置了所以千万别指望 Redis 事务能像数据库事务那样全部成功或全部失败。四、 乐观锁 WATCHRedis 真正的并发控制既然 Redis 事务不支持回滚那怎么解决并发修改的问题比如两个人同时抢最后一个库存。场景# 用户 A 和用户 B 同时执行# 1. 检查库存stockGET product:stock# 假设是 1# 2. 如果 stock 0扣减DECR product:stock如果两人同时检查到库存为 1都执行了DECR库存就变成了 -1。这明显不对。解决方案WATCH MULTI EXECWATCH命令实现了乐观锁。它会监视一个或多个 key如果在执行EXEC时发现这些 key 被其他客户端修改了事务就会失败。# 正确的秒杀代码127.0.0.1:6379WATCH product:stock# 监视库存OK127.0.0.1:6379GET product:stock1127.0.0.1:6379MULTI OK127.0.0.1:6379DECR product:stock QUEUED127.0.0.1:6379EXEC1)(integer)0# 成功了扣减到 0如果另一个客户端在WATCH之后、EXEC之前修改了product:stock# 用户 A 的视角127.0.0.1:6379WATCH product:stock OK127.0.0.1:6379GET product:stock1# 就在这个时候用户 B 执行了 DECR product:stock库存变成了 0127.0.0.1:6379MULTI OK127.0.0.1:6379DECR product:stock QUEUED127.0.0.1:6379EXEC(nil)# 返回 nil表示事务失败因为 WATCH 的 key 被改过了核心逻辑EXEC返回(nil)表示事务执行失败需要重试重新 WATCH、重新 GET、重新判断这就像乐观锁的版本号机制只不过 Redis 帮你自动检查了WATCH 的生命周期WATCH在调用EXEC或DISCARD后自动失效也可以手动UNWATCH取消监视127.0.0.1:6379WATCH key1 key2 OK127.0.0.1:6379UNWATCH# 取消对所有 key 的监视OK五、 实际应用场景5.1 秒杀/抢购WATCH 实现乐观锁defseckill(user_id,product_id):whileTrue:redis.watch(fstock:{product_id})stockredis.get(fstock:{product_id})ifstock0:redis.unwatch()return已售罄# 开始事务pipelineredis.pipeline()pipeline.multi()pipeline.decr(fstock:{product_id})pipeline.sadd(fbuyers:{product_id},user_id)# 执行事务resultpipeline.execute()# 如果返回 None说明有并发修改重试ifresultisnotNone:return抢购成功# 否则继续循环重试5.2 转账注意Redis 事务不能回滚# 危险的转账代码WATCH account:1001 account:1002 balance1GET account:1001ifbalance1100: UNWATCHreturn余额不足MULTI DECRBY account:1001100INCRBY account:1002100EXEC# 问题如果 DECRBY 成功但 INCRBY 失败比如 key 类型错误# 100 块钱就凭空蒸发了结论涉及金额的强一致性场景不要用 Redis 事务老老实实用数据库。Redis 事务适合那些可以容忍重试、逻辑简单的场景。5.3 批量执行保证中间不被插入# 场景需要同时更新多个 key且中间不能让其他请求读取到中间状态MULTI SET config:rate1000SET config:limit5000SET config:window60EXEC# 保证这三个设置要么都没生效要么全部生效在同一个瞬间完成虽然没有回滚但至少保证了这些更新对外可见是原子的——其他客户端要么看到旧的三个值要么看到新的三个值不会看到新旧混合的状态。六、 为什么不支持回滚这是 Redis 官方文档里明确写的设计决策Redis 命令只会因为语法错误或数据类型错误而失败而这些错误应该在开发阶段就被发现。在生产环境中Redis 命令几乎不会失败。官方理由性能优先回滚需要记录命令执行前的状态开销太大设计简单Redis 追求简单高效回滚机制会让代码复杂 10 倍错误可预见大部分错误如类型不匹配是编程 bug不应该用事务机制来掩盖个人观点这个设计是合理的。如果你需要强 ACID 事务Redis 不是正确答案PostgreSQL 才是。七、 Redis 事务 vs Lua 脚本Redis 从 2.6 版本开始支持 Lua 脚本。Lua 脚本可以替代事务而且更强大。特性MULTI/EXECLua 脚本原子性隔离执行不回滚整个脚本原子执行不回滚条件判断需要 WATCH 重试脚本内直接写 if/else网络开销多次往返MULTI, 命令, EXEC一次发送一次返回复杂度适合简单排队适合复杂逻辑返回值数组脚本自定义Lua 脚本示例-- 秒杀脚本检查库存 扣减 记录用户localstockredis.call(get,KEYS[1])iftonumber(stock)0thenreturn0endredis.call(decr,KEYS[1])redis.call(sadd,KEYS[2],ARGV[1])return1# 调用脚本EVALlocal stock redis.call(get, KEYS[1]); if tonumber(stock) 0 then return 0 end; redis.call(decr, KEYS[1]); redis.call(sadd, KEYS[2], ARGV[1]); return 12stock:1001 buyers:1001user123结论简单的命令排队用MULTI/EXEC复杂的条件逻辑用 Lua 脚本Redis 7.0 还支持函数八、 最佳实践与避坑指南✅ 推荐做法用 Lua 脚本代替事务如果你的 Redis 版本 ≥ 2.6优先考虑 Lua 脚本WATCH 时一定要处理失败重试whileTrue:redis.watch(key)# 读取数据、做判断piperedis.pipeline()pipe.multi()pipe.commands...ifpipe.execute()isnotNone:break# 否则继续循环把事务写短不要在事务里放太多命令否则长时间阻塞 Redis对一致性要求高的场景不要用 Redis用关系型数据库或分布式事务框架❌ 避免踩坑不要指望回滚某个命令失败后其他命令还会执行不要在事务中使用WATCH不存在的 keyWATCH一个不存在的 key 不会报错但也没啥用不要在事务中执行KEYS、FLUSHALL会阻塞整个 Redis不要把DISCARD当作回滚它只是清空命令队列不能撤销已执行的命令九、 面试高频题Q1Redis 事务支持回滚吗A不支持。事务中某条命令执行失败时其他命令依然会继续执行。Redis 的设计哲学是大多数命令失败都是编程错误应该在开发阶段发现而不是依赖回滚机制。Q2WATCH的实现原理是什么AWATCH会在 Redis 服务器端标记被监视的 key。当执行EXEC时Redis 会检查这些 key 在WATCH之后是否被修改过。如果被修改事务失败返回(nil)。这本质上是一种乐观锁机制。Q3MULTI和Pipeline有什么区别AMULTIEXEC保证命令的原子执行不被其他命令打断Pipeline只是批量发送命令减少网络开销但命令之间可能被其他客户端的命令插入两者可以结合使用Pipeline发送MULTI、命令、EXECQ4如何用 Redis 实现秒杀A用WATCHMULTIEXEC实现乐观锁或者用 Lua 脚本实现。关键在于检查库存和扣减库存必须是原子操作且要处理并发冲突重试。十、 总结Redis 的事务是一把刻度不准的尺子——你要理解它的刻度才能在合适的场合使用它。你的需求推荐方案保证一组命令连续执行不被插入MULTI/EXEC解决并发修改冲突如秒杀WATCHMULTI 重试复杂的条件逻辑如 if 判断Lua 脚本强一致性、需要回滚别用 Redis用 MySQL减少网络往返不关心隔离Pipeline最后的忠告如果你习惯 MySQL 的事务忘掉它Redis 的事务是另一个物种如果你真的需要强 ACID选错工具比用错方法更致命在 80% 的场景下Lua 脚本比原生事务更合适下一期预告Redis 主从复制与哨兵模式——从单机到高可用。我们来聊聊怎么让 Redis 永不宕机。事务有风险回滚不存在。且用且珍惜下期见