当前位置:首页 > 问答 > 正文

Redis 事务没用好,结果生产环境突然出大问题了,真是教训深刻

(来源:某电商平台后端工程师技术复盘分享)

那天晚上十点多,我正准备下班,手机突然开始疯狂报警,短信、电话、监控大屏一片红,显示核心服务的响应时间从平时的几十毫秒飙到了十几秒,紧接着大量下单请求失败,用户页面直接报错,我心里咯噔一下,知道出大事了。

问题的核心,就出在一个我们自以为用得滚瓜烂熟的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的键已经被“别人”(那个成功的请求)修改了。

Redis 事务没用好,结果生产环境突然出大问题了,真是教训深刻

恐怖的连锁反应发生了:第一次事务失败,代码进入重试逻辑,重试时,它又会再次WATCHGET、判断库存(此时可能还有库存)、再进入事务、再执行EXEC……然后很可能因为同样的原因再次失败,这个过程在瞬间被海量请求重复,形成了“重试风暴”。

(来源:当时监控到的Redis慢查询日志) Redis的CPU使用率瞬间被打满,大量的WATCH命令和失败的事务EXEC操作本身就会消耗资源,更致命的是,由于这些操作都是在同一个连接里频繁执行,导致其他正常的Redis命令(比如简单的读缓存)也被阻塞在队列里等待,整个Redis的服务能力呈断崖式下跌,这就是为什么连不涉及秒杀的业务也受到了严重影响,页面全面卡死。

第二个坑:对事务原子性的误解

我们当时还有一个天真的想法,以为用事务能保证“查库存”和“扣库存”的原子性,但实际上,WATCHMULTI/EXEC之间的代码(比如这里的redis.get并不在事务范围内,事务块内只是打包了DECRSADD两个命令。

这意味着,在极高并发下,可能出现这种情况:

Redis 事务没用好,结果生产环境突然出大问题了,真是教训深刻

  1. 请求A和请求B同时WATCH了库存键,假设库存为1。
  2. 它们都执行GET,看到库存是1,都决定继续。
  3. 请求A先执行EXEC,成功扣减库存到0,并记录了用户。
  4. 请求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事务这种带有乐观锁机制的工具,必须充分理解其原理和适用边界,在设计和测试阶段,一定要用等同生产环境的流量进行压测,模拟真正的并发冲击,否则埋下的坑,总有一天会在你最不希望的时候爆发。