Redis 事务没用好,结果生产环境突然出大问题了,真是教训深刻
- 问答
- 2025-12-25 05:00:57
- 2
(来源:某电商平台后端工程师技术复盘分享)
那天晚上十点多,我正准备下班,手机突然开始疯狂报警,短信、电话、监控大屏一片红,显示核心服务的响应时间从平时的几十毫秒飙到了十几秒,紧接着大量下单请求失败,用户页面直接报错,我心里咯噔一下,知道出大事了。
问题的核心,就出在一个我们自以为用得滚瓜烂熟的Redis事务上。
事情是这样的,我们平台有个“每日限时秒杀”活动,为了防止超卖,库存校验和扣减的逻辑非常关键,最初的版本,我们用的是Redis的WATCH+MULTI+EXEC这套经典的事务机制,伪代码大概是这样的:
(来源:事故复盘文档中的代码片段)
redis.watch('seckill_stock_' + itemId) // 开始监视库存键
current_stock = redis.get('seckill_stock_' + itemId)
if current_stock > 0:
multi = redis.multi() // 开启事务
multi.decr('seckill_stock_' + itemId) // 库存减一
multi.sadd('successful_orders_' + itemId, userId) // 记录成功用户
result = multi.exec() // 执行事务
if result is None: // 说明事务执行失败,可能是watch的键被改动了
// 重试或者返回失败
else:
// 下单成功
else:
// 库存不足
这套逻辑在开发和测试环境一直跑得好好的,压力测试也没发现大问题,但到了真正的大流量生产环境,情况就变了。
第一个坑:乐观锁的“重试风暴”
WATCH是一种乐观锁,它假设冲突不常发生,在事务执行前监视一个或多个键,如果这些键在WATCH之后、EXEC之前被其他客户端修改了,那么整个事务就会失败,返回nil,我们的代码里也确实写了重试逻辑。
但在晚八点秒杀开始的瞬间,成千上万的请求同时涌来,绝大部分请求在redis.get('seckill_stock_' + itemId)时看到的库存都是大于0的,于是它们都进入了事务块,只有少数几个请求能成功执行EXEC并扣减库存,库存键seckill_stock_XXX在极短时间内被频繁修改,这导致其他几乎所有开启了事务的请求,在执行EXEC时都失败了——因为它们WATCH的键已经被“别人”(那个成功的请求)修改了。

恐怖的连锁反应发生了:第一次事务失败,代码进入重试逻辑,重试时,它又会再次WATCH、GET、判断库存(此时可能还有库存)、再进入事务、再执行EXEC……然后很可能因为同样的原因再次失败,这个过程在瞬间被海量请求重复,形成了“重试风暴”。
(来源:当时监控到的Redis慢查询日志)
Redis的CPU使用率瞬间被打满,大量的WATCH命令和失败的事务EXEC操作本身就会消耗资源,更致命的是,由于这些操作都是在同一个连接里频繁执行,导致其他正常的Redis命令(比如简单的读缓存)也被阻塞在队列里等待,整个Redis的服务能力呈断崖式下跌,这就是为什么连不涉及秒杀的业务也受到了严重影响,页面全面卡死。
第二个坑:对事务原子性的误解
我们当时还有一个天真的想法,以为用事务能保证“查库存”和“扣库存”的原子性,但实际上,WATCH和MULTI/EXEC之间的代码(比如这里的redis.get)并不在事务范围内,事务块内只是打包了DECR和SADD两个命令。
这意味着,在极高并发下,可能出现这种情况:

- 请求A和请求B同时
WATCH了库存键,假设库存为1。 - 它们都执行
GET,看到库存是1,都决定继续。 - 请求A先执行
EXEC,成功扣减库存到0,并记录了用户。 - 请求B再执行
EXEC,因为键被修改,事务失败,这是我们期望的,没问题。
但还有一种更隐蔽的情况:如果请求A的事务还没执行完(比如网络稍有延迟),请求B在A执行EXEC前瞬间GET,它看到的库存也是1,然后也发起了事务,虽然请求B的事务最终会因为A的执行而失败,但这个“判断有库存”的请求B已经进入了后续流程(可能记录了日志,或者触发了其他非原子操作),造成了逻辑上的混乱,我们当时一些错误的日志报警就来源于此。
惨痛的教训与修复
那天晚上,我们只能先紧急下线了秒杀活动,重启了Redis实例,服务才慢慢恢复,事后复盘,我们才深刻理解到:在超高并发场景下,用WATCH来实现分布式锁或扣减,效率极低,很容易导致系统雪崩。
(来源:团队后续的技术方案评审记录)
我们最终的解决方案是弃用事务,改用Redis的Lua脚本,把判断库存和扣减库存的逻辑全部写在一个Lua脚本里,因为Lua脚本在执行时是原子性的,整个脚本在执行期间不会被其他命令打断,相当于一个“真正”的事务,这样既避免了WATCH带来的重试开销,也确保了判断和扣减操作的原子性。
伪代码变成了:
local stock = redis.call('get', KEYS[1])
if stock and tonumber(stock) > 0 then
redis.call('decr', KEYS[1])
redis.call('sadd', KEYS[2], ARGV[1])
return 1 -- 成功
end
return 0 -- 失败
这次事故给我的教训极其深刻:不能因为技术在简单场景下工作正常,就想当然地认为它能应对复杂极端的情况。 尤其是像Redis事务这种带有乐观锁机制的工具,必须充分理解其原理和适用边界,在设计和测试阶段,一定要用等同生产环境的流量进行压测,模拟真正的并发冲击,否则埋下的坑,总有一天会在你最不希望的时候爆发。
本文由黎家于2025-12-25发表在笙亿网络策划,如有疑问,请联系我们。
本文链接:https://haoid.cn/wenda/67968.html
