我们用 MySQL 替换 Redis 做库存预留,而且它撑住了规模
文章摘要
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 行池」这个设计到底是不是好设计上,双方来回交锋了十几层。
- 最热烈的技术辩论由 bijowo1676 挑起,他的立场很不客气:「每个 shop*SKU 组合建 1000 行不是最好的设计。如果一个候选人在 Shopify 的系统设计面试里提出这个方案,我怀疑他能不能过 Senior+。」他的替代方案是每个「购物车*SKU」一行:一行代表一个购物车,持有该 SKU 的多个单位的信息;不需要 1000 行上限和补货进程这种权宜之计,永远只处理单行而不是 N 行。他还认为 Shopify「错误地定义了他们想解决的问题」——在支付时解决太晚了,应该在结账(Checkout)时就解决,「PAY 按钮应该只做一件事:从信用卡扣钱」。
- 这个提议遭到了持续而扎实的反驳,其中 soontimes 的追问最有条理,来回了近十轮。核心问题是:你打算在什么时刻插入这一行?插入前你必须确保库存没被耗尽,这意味着你需要知道计数并锁住那一行,于是你仍然在同一个商品上有争用;他们那个 1000 行缓冲的意义正是不必每次都锁单行,只在缓冲空了才锁。bijowo1676 主张用聚合查询(甚至用 READ UNCOMMITTED 跑 sum)来做检查,soontimes 指出:「我不理解这怎么防止超卖。你有一个报告库存为空或超卖的检查,但这个检查怎么阻止两个并发的行为者为最后一件商品各插入一行?」bijowo1676 反问「现有设计又是怎么解决并发争抢最后一件的?」,soontimes 给出了清晰的机械解释:靠 SKIP LOCKED——假设只剩一件,第一个查询扫描缓冲表、锁住需要的行(这里是一行)并把行移到另一张表;第二个查询扫描该表发现无行(即使第一个还没提交,那行也已被锁住并被忽略),检查能否扩充缓冲,发现完全售罄于是中止。数据库保证你不会超卖。他最后点破关键:「为了避免竞态,你需要原子地插入预留并递减可用量。你提出的方案不是原子的。要让它原子,你需要锁住整个范围,以确保在『检查可用性』和『记录预留』这两点之间没有新行出现。行为者实际上是在竞争同一个聚合行——这就等同于单个带 quantity 字段的库存行,而那正是文章开头就否决掉的方案。」
- codedokode 用另一个角度解释了同一件事,这条是全帖对问题本质讲得最清楚的:想象 100 个顾客要买商品 A,一个线程开启事务、查询 A 的数量、UPDATE 它,然后去查别的商品;数据库会锁住那一行直到事务结束,另外 99 个线程在第一个事务提交前无法继续(可以读但不能更新)。这就是为什么要每单位一行——事务 1 只锁住 A 的若干行,事务 2 因为 SKIP LOCK 不必等待锁释放而直接跳过它们、锁住接下来的几行。他也指出这不是非黑即白:如果可用数量真的很大(1 万件),你可以用 100 行、每行 100 件;代价是每个事务即使只想预留一件也要锁整行(100 件),而且每行数量可能不同,凑齐要预留的数量需要做更多工作。他后来还就「为什么用 DELETE 而不是 UPDATE 状态」自问自答,猜测在 MVCC 下 UPDATE 实现为「标记删除 + 插入新版本」可能比 DELETE 更贵;sgarland 纠正说这是 Postgres 的 MVCC 工作方式,MySQL/InnoDB 是原地更新元组并用 undo log 重建旧版本。
- admax88qqq 从产品角度给出了对 bijowo1676「应该在加入购物车时预留」的最有力反驳:「这是个大胆而过度自信的说法。购物车放弃是真实存在的。人们从不清空购物车,他们只是走开了。Shopify 有意选择在支付时做,因为更早做会因为人们『预留』了商品然后走开、导致其他人看到缺货也走开而损失销售。谁先掏钱谁得到商品——这是他们选择的设计约束,你不能直接说『他们的方案错了因为他们解决了错的问题』。」zer00eyz 补充了更多现实复杂度:把心智模型映射得太接近实体零售购物车是有问题的——SKU 和购物车条目并非一一对应(第一个条目是 SKU-A + SKU-B 的捆绑、第二个是 SKU-A + SKU-C 的捆绑、第三个是 5 个 SKU-B,你在哪里保存 SKU 的重新聚合?),还没算上预留时的仓位、不同客户不同的多仓发货规则(这会吃掉毛利)。而当 bijowo1676 用「在沃尔玛你得先从货架拿走商品才付款」来类比时,zer00eyz 的回击很有说服力:「如果线下沃尔玛像线上沃尔玛那样运作,过道里会塞满半满的购物车和别人拿不到的商品,你根本没法在店里移动。」他引用数据说 70% 的购物车被放弃,并指出「售罄提示」比「拿走你的钱然后告诉你没货」或「因为某人放进购物车且永远不会结账所以你买不到」都要好。
- fauigerzigerk 提出了一个礼貌但实质的疑问:这不是关于 Shopify 的整体规模,而是关于特定卖家特定仓位特定 SKU 的争用——在高峰时段,有多少购物车在支付阶段争抢同一个 SKU?这真的能对单行造成过大的锁争用吗?「我知道 Shopify 的工程师既不蠢也不缺经验,所以我很惊讶,我本希望听到关于这个具体问题的更多内容。」kevincox 给出了答案:闪购对 Shopify 是巨大的扩展性问题,有些名人想在广告倒计时结束后的几分钟窗口里卖出成千上万件商品——这是极其罕见的情况,但是他们想支持的特性。sgarland 从另一个角度回答:「当你考虑到在持有行锁的同时还有多少其他(往往写得不好的)数据库查询和服务调用在执行时,是的,它会迅速累加起来。如果整条流水线要 500 毫秒,恭喜,你每秒卖不出超过 2 件那个 SKU。」misiek08 提供了一线数据:他们观察到单个 SKU 在良好的营销和价格下有每秒 200 多笔购买。
- jhhh 给出了本帖最凝练的一条元评论:「这篇文章给我的主要感受是,到了 2026 年我们还没发展出足以可扩展且持久地并发递减一个数字的技术。这已经导致多个组织去开发数据库 hack(多行)或复杂的架构方案(redis),把这个过程的原子性给毁掉了。」jbird99 的一句「公司为了避免运行不同的软件能走多远啊」也引发共鸣,matwood 的回应代表主流意见:「大多数公司最好只选 MySQL 或 PG,只在绝对必要时才添加别的东西。每加一个软件都增加复杂度。」anonymars 补充了运维角度:通过技术变更解决问题往往比通过运维和人来解决更容易也更便宜——现在你只需要 MySQL 的专业知识和维护,而不是 Redis 加 MySQL。
- jdw64 提出了相反方向的质疑:Redis 在单个事件循环里能处理数万并发连接,而 MySQL 是每连接一线程,「无论我怎么看,这都像是倒退」。他建议在 DB 前加一个调度层。codedokode 的回复很有力:Redis 没有事务和持久化——没有持久化意味着机器关机或进程崩溃时数据会丢,重启后还需要时间重新生成数据,这就是为什么 Redis 是缓存而不是数据库;你可以修掉持久化问题(Redis 能写 WAL),但那样它就无法处理那些成千上万的并发连接了。「Redis(以及其他 NoSQL 存储)没有什么相对 SQL 数据库的魔法架构优势。它们只是在 ACID 保证上偷工减料、跳过 fsync。一旦你开始做 fsync,你的事务吞吐会掉到 SQL 数据库的水平。」他后来还跑了一个微基准:在他的消费级 SSD 上逐条追加记录并每条 fdatasync,吞吐是每秒 120 次 fsync,约 0.5 MB/s。jdw64 反驳说并发连接数和持久写吞吐是两回事,而且 AOF 不必每条记录一次 fsync,Redis 可以用组提交(group commit)在多个命令间共享 fsync,「这可能意味着你基准里几十倍的差别」。CoolCold 补充了一个实用的 fio 测试方法和典型数字:消费级 SSD 100-500 iops,数据中心 SSD 数千,廉价 VPS 只有 5-30 iops,「fsync/fdatasync 往往被程序员忽视」。两人最后友好收场,jdw64 说「我们观点不同,但我认为你有成熟的工程心态,最终只有真实测量能定论」。
- 有几条评论指向了别的技术路线。sharno 和 jamilbk 都认为 TigerBeetle 似乎正是为这类事务处理而生的;neerajsi 提到 TigerBeetle 的两阶段转账看起来就是为这个场景量身定做的,但也怀疑 TB 未必适合追踪所有商品库存,而 jorangreef(TigerBeetle 方)回应说 TB 本来就是被设计来追踪库存的(作为复式记账的另一种形式)。giovannibonetti 提出了用持久化工作流(Temporal、Restate、DBOS)的方案:每个购物车有自己的 workflow,库存条目也有一个,购物车 workflow 发信号给库存 workflow 并等待响应,库存 workflow 维护一个账本控制每个单位归属哪个购物车并批量写入,这样即使 10 万顾客同一秒尝试购买同一商品也能扛住,且不需要 1000 行的启发式。azuanrb 的回应比较克制:持久化工作流是不同,未必更简单,除非团队已经熟悉,否则不会仅为这件事引入一个,而且你还得考虑运行和管理它本身所需的基础设施。
- 关于 AI 写作的争论占了相当大篇幅。energy123 给出了最具体的分析:小标题和要点符号泛滥、信息密度低(与标准技术英语相反)、包含无用细节(比如罗列 Shopify 的规模数据)、使用对比排比(contrastive parallelism)等 LLM 习气。他举例那个小标题「真正的瓶颈:连接数,不是 CPU」有两处 AI 味道——AI 喜欢说「最终的要点」「关键在于」这类空洞而铿锵的句子,然后是「连接数,不是 CPU」这种对比排比。「对比排比在技术写作里就不该怎么出现,无论是不是 AI 生成的。」这条给不少人提供了一个新词汇,Breza 说「我从没听过 contrastive parallelism 这个说法,我爱它——我是说我受不了 LLM 这么干的频率,但给我的恼火起个名字很不错」。trueno 是零售平台从业者,他说自己本来很期待获得洞察,结果意识到整篇文章是 AI 写的:「> 解决方案:SKIP LOCKED > 核心思路:每单位一行,设计上有界。酷,谢了 claude。现在我在想 Shopify 的工程文化到底是什么样。」不过他也说自己乐见一切收敛到 SQL,只是不喜欢用 AI 写的文章来颠覆一个重要的设计模式:「任何玩过 AI 的人都很清楚,你可以说服 claude 为你想死守的任何山头写一篇长篇大论。」akamaka 的反驳很直接:「我觉得 Shopify 这篇文章非常易读,还学到了一些 MySQL 特性。另一方面,我从你的评论里没得到任何价值。」他还指出一个重要的事实核查——他读完了整篇文章,里面没有任何对 Redis 的批评:听起来 Redis 工作得很好并达成了他们想要的目标,那是简单 MySQL 实现做不到的;最后他们找到办法让一切在 MySQL 上跑通,解决了并排使用多个数据库带来的固有复杂性,但这同样不是对 Redis 的批评,任何一对数据库都会有这个问题。
- 也有人受够了这场争论。Culonavirus 说:「所以?谁在乎。这对我来说很有意思。文章、艺术、电子游戏、代码,一切都会越来越『被 AI 触碰』,你阻止不了……在每第二三篇文章下面读到『这是 AI』开始比 AI 写作本身更烦人了。」cataphract 观察到一个固定剧本:「在每一篇 AI 写的帖子里,我们都有这个脚本:某条顶楼评论抱怨这是 AI 写的,某人茫然地说他没看出来,另一个人指出那些 claudeism。有时还有人贴 pangram 的链接。我觉得我们需要版主定个政策,方向我也不知道。」lelanthran 则在支线里反复要求:每次有人说「这种文风不是新东西」,我就要一个 2022 年前带有全部这些 AI 特征的博客链接,「我还在等,我觉得我永远等不到」。thunky 的回应也值得一提:「好消息:你最重的抱怨跟这篇文章的整体内容和准确性毫无关系,只是文风攻击。」
- 另一条完全非技术的支线由 kennywinker 开启,他指出 Shopify 的创始人和 COO 都在资助极右翼、创始人认为只有富人才该有投票权,「不过话说回来,他们换了数据库」。这条引出了一系列关于工作环境、CEO 言论的争论,也有人(annexrichmond)要求提供更具体的依据而非仅一个播客链接。kimos 以前雇员身份留下了一条较为克制的评论:「我也对 Shopify 的工程师评价很高,但对 Shopify 管理层不再如此。它从自下而上的信任驱动变成了自上而下的 AI 优先、AI 一切。很难表达内部运作和文化改变得有多深、多彻底。」
- 一些零散但有意思的点:srcreigh 注意到「为了做成这件事,他们不得不从主数据库移除 50% 的读和 33% 的事务,这挺迷人的」;shay_ker 说「slop 之外,我喜欢这篇文章链接的那篇关于 innodb 加锁的文章」;arichard123 提供了一个务实的反面视角——他有个客户并不太在乎把最后一件商品卖两次,他们会打电话给顾客道歉、提供替代品折扣,把这单生意保住;atomicnumber3 贡献了整帖被赞最多的一句俏皮话:「我从没在任何一家公司工作过,能让『描述他们系统实际怎么运作』通过该公司自己的系统设计面试。」