我们用 MySQL 替换 Redis 做库存预留,而且它撑住了规模

查看原文 HN 讨论

文章摘要

Shopify 工程博客讲述了他们把「超卖保护」系统从 Redis 迁移到 MySQL 的过程。问题背景是:结账时买家点下「完成购买」,系统必须保证他要买的商品仍然可得。搞错一个方向,两个买家买到同一件最后的存货,商家得取消订单、发道歉邮件、承担客服成本;搞错另一个方向,你告诉买家已售罄但其实没有,商家白白损失一笔本该做成的生意。在 Shopify 的规模上,任何一种失败都会迅速放大——2025 年黑五,平台上的商家在峰值时创下每分钟 510 万美元销售额的纪录,而每一笔交易都触及库存。

超卖保护有两个主要操作:Reserve(支付开始时把商品标记为已预留,一个短暂的、比如几分钟的持有)和 Claim(支付成功后从库存账本——即事实来源——永久扣减数量)。此前这套系统跑在 Redis 上,每个商品一个数量 key,预留就 DECR、释放就 INCR。Redis 处理并发没问题,但预留和库存账本活在两个不同的系统里:claim 这一步需要同时更新 MySQL 和清理 Redis,而这两个操作没法包在同一个原子步骤里。取决于执行顺序,这会导致超卖(商品卖掉了但从未从账本扣减)或少卖(商品扣减了却仍被标记为已预留)。此外 Redis 模型不理解多仓位库存,还带来了维护一个独立集群的运维成本。把预留搬进和账本同一个 MySQL 数据库,意味着可以用 ACID 事务把一切包起来,彻底消除这些失败模式。

之前的尝试失败过:单行加一个数量列扛不住争用。转机是 MySQL 8 的 SKIP LOCKED,它带来了一种不同的设计——每个库存单位一行,而不是每个商品一行。一个有 10 个单位的商品就是 10 行;预留 3 个单位就是在一个事务里选中并移动 3 行。SKIP LOCKED 是可扩展性的关键:如果另一个事务锁住了某些行,MySQL 会跳过它们并返回其他可用的行,没有人在同一行上等待,争用大大降低。文章提到这个思路受 37signals 用数据库做负载分配的做法启发。

但如果所有库存都一单位一行,规模上会崩:一个跨 10 个仓位、有 5 万单位的商品意味着 50 万行,预留查询扫描它们会变慢。于是他们维护一个有界的可用行池,每个「商品/仓位」组合上限 1000 行;预留从这个池中消耗行,一个补货进程从库存账本回填。为什么是 1000?这个上限需要大到能吸收突发流量而不至于枯竭,又要小到让表保持紧凑、SKIP LOCKED 扫描足够快;他们根据闪购期间观察到的每个商品/仓位的峰值预留速率来定尺寸。如果池被抽空(极端闪购下会发生),预留路径会内联触发补货,用一把锁保证同一时刻只有一个事务在补货,其他同商品的并发预留等它完成而不是全部抢着插行,避免惊群;补货完成后等待的事务继续。买家不会看到商品不可用(除非它真的没了),代价只是那笔预留的延迟增加。

文章还列了四个关键技术决策。一、复合主键:最初原型用自增 ID 做主键,用 SHOW ENGINE INNODB STATUS 观察锁行为时发现每次预留产生两个行锁而不是一个——InnoDB 同时锁住了 WHERE 子句用的二级索引和聚簇索引。改成复合主键(shop_id、inventory_item_id、inventory_group_id、id)让过滤用的列成为主键的一部分后,降到每行一个锁。二、READ COMMITTED:在需要补货的空表上跑 SELECT ... FOR UPDATE SKIP LOCKED 时出现了间隙锁(包括「supremum」伪记录上的锁),这些锁挡住了补货事务插入新行并可能造成死锁;把这些事务的隔离级别从 MySQL 默认的 REPEATABLE READ 改成 READ COMMITTED 解决了问题。这是他们在这个代码库里第一次使用非默认隔离级别,需要框架层做一点支持来按事务设置隔离级别。三、一致的加锁顺序:reserve 和 claim 以不同顺序触碰两张表时出现死锁,修复办法是统一顺序——reserve 总是先从 units 表 DELETE 再向 reserved_quantities INSERT,claim 只碰 reserved_quantities。四、用 UNION ALL 批处理:对含多个行项目的购物车,把预留查询用 UNION ALL 批到一次往返里完成。

文章最有价值的部分是「真正的瓶颈:连接数,不是 CPU」。在生产环境他们撞上了一个远低于目标的吞吐天花板:预留延迟(P90)可以接受、CPU 没打满、查询也已经优化过。他们尝试跨多个结账批处理预留以减少连接占用,在压测里有效但增加了复杂度;也把部分读负载移到副本。但数字仍然对不上。压测中观察到的症状是:MySQL 里线程排队、排队的工作运行时 CPU 尖峰、ProxySQL 层到 MySQL 后端的连接耗尽。于是他们加了可见性——在应用侧给每条 SQL 语句加上标识业务流程的注释标签(形如 conn_tag:checkout_completion),在 ProxySQL 层解析这个标签并测量每个调用方持有连接多久,得到按业务流程细分的连接总持有时间。这立刻显示出哪些调用方消耗了最多连接时间——不是哪些查询慢,而是哪些进程在长事务里长时间持有连接。

结果是:预留根本不是唯一的重度使用者。结账路径的其他部分持有连接的时间超出必要,它们没被优化过只是因为它们不是第一个撞上限制的。连接是有限的;在高吞吐下需要每秒大量短事务,当其他代码占着连接时,预留成了压垮骆驼的最后一根稻草——不是因为预留慢,而是因为连接池早已接近枯竭。清理结账路径移除了主库上 50% 的读和 33% 的事务。他们还重新审视了 MySQL 配置:InnoDB 线程并发度多年前被保守地设定后再没重新评估过,而工作负载已经变了;在有余量的地方提高线程并发度后,又移除了一个直到把连接指标和 CPU 指标并排看才发现的瓶颈。两项改动合起来去掉了天花板:在大流量闪购期间,写节点 CPU 保持在 50% 以下,读节点在 16% 以下,还有余量。

迁移方式是「影子模式」:每笔预留同时写入 Redis 和 MySQL,Redis 保持为事实来源,从而在真实生产流量上并排比较两个系统的业务结果和性能。因为两个系统都是活的,不存在需要迁移的在途预留。确认无误后把事实来源切到 MySQL,出问题可以用 kill switch 退回 Redis(双写路径仍然活跃,Redis 始终拥有完整视图)。上线是逐 pod 灰度的,从低流量 pod 开始,最后才到最高流量的商家。

文章总结的两条教训:重新审视旧决策(五年前不可能的事今天可能因为 SKIP LOCKED 这样的新特性而变得可能,线程限制等「经验法则」配置也值得在负载和硬件演进后重新检查;如果数字对不上——比如 CPU 低但排队严重——就要深挖);从小处开始并观察(一个最小原型带来了巨大价值:一个小 ruby 脚本加 MySQL,不用 Rails 之类的完整框架;在第二个终端里观察数据库的锁行为比纯理论教会了他们更多)。文末的落点是:MySQL 现在能处理我们过去以为需要专门基础设施的负载;如果你正为高吞吐互斥去够 Redis、Kafka 或自定义协调层,你现有的数据库可能已经够了。而更关键的一点是,这件事的本质不是让预留变快,而是让它成为「安全的邻居」——预留和购物车更新、支付处理、订单创建共用一个数据库,一个耗尽连接或持锁过久的系统会危及所有这些。真正的标准是在不损害数据库整体健康的前提下维持吞吐。

HN 评论精华

这条帖子拿到 342 分,讨论呈现出非常撕裂的面貌:关于文章是 AI 写的这件事,占据的篇幅可能超过了技术本身;另有一条围绕 Shopify 高管政治立场的支线;而真正的技术辩论则集中在「1000 行池」这个设计到底是不是好设计上,双方来回交锋了十几层。