首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >下单后库存偶尔对不上:缓存与数据库双写一致性的三种方案与线上取舍

下单后库存偶尔对不上:缓存与数据库双写一致性的三种方案与线上取舍

原创
作者头像
用户12754333
发布2026-09-14 14:14:28
发布2026-09-14 14:14:28
150
举报

导读

大促之后对账,最让人后背发凉的不是报错,而是数据库里库存明明扣了、缓存里却还是旧值,或者反过来——前端显示"有货",点下去却提示库存不足。我们在一个零售商城的订单链路上就被这种"偶尔对不上"磨了快两周:监控没有一条报错,可每天总有那么几单,缓存和数据库的库存差着数。这篇把缓存与数据库双写一致性的三种主流方案拆开讲,说清楚各自在什么场景下会丢更新、我们最后为什么选了"先更库再删缓存+延迟双删+binlog兜底"这条组合路线,并附上关键代码和五个线上踩坑。

一、先定位:双写不一致到底是怎么来的

读多写少的商品库存,标准做法是读缓存、miss 了再回源数据库。问题全出在"写"的那一瞬间:数据库和 Redis 是两个独立存储,不可能在一个本地事务里同时原子提交,只要有并发和时间差,就有空窗。

最典型的是"先更新缓存再更新库"。两个并发写请求 A、B,A 先把缓存改成 10,B 紧跟着改成 9,结果 B 的数据库更新先落盘、A 的后落盘,最后数据库是 A 的旧值、缓存是 B 的新值,两边永久错位。只要还抱着"同时更新缓存和库"的思路,这种乱序就躲不掉。

所以第一步判断是:别去更新缓存,改成删除缓存。缓存是数据库的派生数据,写操作只负责让它失效,下一次读自然回源重建,从根上少维护一份要同步的状态。

二、三种方案逐个拆,问题分别在哪

方案一:先删缓存,再更新数据库

代码语言:javascript
复制
async function updateStock(productId, newStock) {
  await redis.del(`stock:${productId}`);   // 先删缓存
  await db.query('UPDATE stock SET num=? WHERE sku_id=?', [newStock, productId]); // 再更库
}

看着稳妥,实际有经典并发漏洞:线程 A 删完缓存、还没来得及更库的瞬间,线程 B 来读,cache miss 后把数据库里的旧值回填进缓存,随后 A 的更新落库——缓存里就一直留着旧值,直到下次过期。读越频繁、这个时间窗被撞上的概率越高。

方案二:先更新数据库,再删缓存(Cache-Aside)

代码语言:javascript
复制
async function updateStock(productId, newStock) {
  await db.query('UPDATE stock SET num=? WHERE sku_id=?', [newStock, productId]);
  await redis.del(`stock:${productId}`);
}

这是 Facebook 论文里采用、也是业界默认的做法。它不是绝对一致,但出问题的概率极低:要触发脏数据,得同时满足"读请求在写更库的瞬间 miss、且读回填比写删缓存还慢",而读通常比写快得多。极端情况下仍会脏(删缓存那一步恰好网络抖动失败),所以它需要兜底,而不是单独裸奔。

方案三:只更数据库,由 binlog 订阅异步删缓存

把"删缓存"从业务代码里拿出来,交给一个独立消费者订阅 MySQL binlog,感知到库存行变更后再去删 Redis。业务方只写库,彻底不用关心缓存,删除失败还能靠消息队列重试。代价是多了一条数据链路(Canal/Debezium + MQ),有秒级延迟,适合写频繁、不想在每个业务方法里重复删缓存代码的场景。

三、线上落地:组合方案而不是三选一

单一方案都有缝,我们最后用的是三者组合,主链路保证大概率一致,异步链路兜住极端失败:

代码语言:javascript
复制
async function deductStock(productId, deduct) {
  // 1. 先更新数据库(带条件,防止超卖)
  const res = await db.query(
    'UPDATE stock SET num=num-? WHERE sku_id=? AND num>=?',
    [deduct, productId, deduct]
  );
  if (res.affectedRows === 0) throw new Error('库存不足');

  // 2. 立即删缓存
  await redis.del(`stock:${productId}`);

  // 3. 延迟双删:隔一个"主从同步+一次读回填"的时间再删一次
  setTimeout(() => redis.del(`stock:${productId}`), 500);
  mq.send('stock-changed', { productId }); // 4. binlog/MQ 消费者最终兜底
}

延迟双删的第二次删除,是为了清掉"更库瞬间被旧值回填"的缓存;500ms 不是拍脑袋,要略大于本地"一次主从同步耗时 + 一次缓存重建耗时"的 P99,我们压测后定在 400~600ms。binlog 消费者则保证即使应用删缓存彻底失败,最终也会收敛一致:

代码语言:python
复制
def on_binlog_event(event):
    if event.table == 'stock' and event.type in ('UPDATE', 'INSERT'):
        key = f"stock:{event.after['sku_id']}"
        for _ in range(3):              # 失败重试,配合 MQ 持久化
            if redis.delete(key): break
            time.sleep(0.2)

读侧也加一道保险:缓存回源用数据库主库或已同步从库,并对库存这种强一致敏感字段设较短 TTL(我们给 30s),就算一切兜底都没生效,最多 30 秒也能自愈。

这里要单独说读写分离带来的坑。我们的读请求默认走从库,大促时主从延迟一度到 800ms,结果出现一种很隐蔽的错位:写请求已经更新了主库并删了缓存,紧接着的读请求在从库上读到的还是旧值,又把旧值回填进缓存。也就是说,主从延迟会把方案二那个"理论上极小概率"的窗口实打实拉大。后来我们对库存回源做了强制走主库(或在写后短时间内 Sticky 到主库),配合前面的延迟双删,这类错位才彻底消失。如果你的架构也做了读写分离,这一步不能省,否则前面删缓存做得再干净,也会被一个延迟的从库重新污染。

四、踩坑清单

  • 坑1:用"更新缓存"代替"删除缓存"。并发写下必然乱序丢更新,派生数据只删不更,这是整套方案的前提。
  • 坑2:删缓存失败直接吞掉异常。一次网络抖动就留下永久脏数据。删除要记日志、进 MQ 重试,不能 catch 里写个空。
  • 坑3:延迟双删的延时写死成固定 1s。太短清不掉回填、太久用户看到脏数据,必须按自己环境主从延迟和重建耗时的 P99 来定,并随压测调整。
  • 坑4:binlog 消费者不做幂等。同一条变更重复投递时重复删没问题,但若消费者里还顺带写了别的状态,重复消费就会出错,消费逻辑必须幂等。
  • 坑5:给库存缓存设了和商品详情一样的几小时 TTL。强一致字段 TTL 要短,详情页这种几乎不变的才配长 TTL,按一致性敏感度分级设过期时间。

结语

缓存与数据库没有"强一致"的银弹,只有"把不一致窗口压到多小、用什么机制最终收敛"的工程取舍。记住一条主线:写操作只更数据库、缓存靠删除失效,主链路用"先更库再删缓存+延迟双删"把概率压到极低,再用 binlog 订阅和短 TTL 给极端失败兜底。下次再遇到"偶尔对不上",先别怀疑 Redis,按这条链路逐段排查删缓存到底在哪一步丢了,基本都能定位到。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 导读
  • 一、先定位:双写不一致到底是怎么来的
  • 二、三种方案逐个拆,问题分别在哪
    • 方案一:先删缓存,再更新数据库
    • 方案二:先更新数据库,再删缓存(Cache-Aside)
    • 方案三:只更数据库,由 binlog 订阅异步删缓存
  • 三、线上落地:组合方案而不是三选一
  • 四、踩坑清单
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档