Shopify 用 MySQL 替代 Redis 处理库存预留,并实现了规模化扩展

Hacker News Top 新闻

摘要

Shopify 工程团队描述了用 MySQL 替换 Redis 进行库存预留,利用 MySQL 的 SKIP LOCKED 特性在高峰流量下实现高吞吐量扩展,并消除了不同系统之间的一致性问题。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/08/09 02:22

# 我们用 MySQL 替换了 Redis 来做库存预留——而且它扛住了规模(2026)- Shopify 来源:https://shopify.engineering/scaling-inventory-reservations 在结账过程中,当买家点击“完成购买”时,我们需要保证他们购买的商品仍然有货。如果我们在某个方向上出错,两个买家会买到同一个最后一件库存:商家不得不取消订单、发送道歉邮件,并承担客服成本。如果我们在另一个方向上出错,我们会告诉买家某件商品已经售罄,但实际上还有货,商家就会损失一笔本应成交的销售。 在 Shopify 的规模下,任何一种错误都会迅速放大。2025 年黑色星期五,我们平台上的商家在峰值时创下了每分钟 510 万美元的销售额纪录。每一笔交易都会触及库存。 我们的超卖保护系统通过在支付处理期间预留库存来处理这个问题——这是一种短暂的持有,防止两个并发结账抢占同一个库存单位。多年来,这一直运行在 Redis 上。当我们转向统一数据库战略时,我们必须回答一个难题:MySQL 能处理同样的规模吗? 早期的尝试失败了。一个带数量列的单行无法处理争用。MySQL 8 的 `SKIP LOCKED` 特性引入了一种不同的设计:每个库存单位一行,而不是每个商品一行。受 37signals 的数据库支撑负载分配方案(https://dev.37signals.com/introducing-solid-queue/)启发,我们在 MySQL 上重建了预留系统,并在 2025 年峰值流量期间达到了高吞吐量目标。 但最难的教训并不是数据库设计。而是发现真正的瓶颈并不在我们观察和测量的东西上。这篇文章将介绍解决方案以及我们一路走来发现的东西。 ## 挑战 ### 什么是超卖保护? 超卖保护有两个主要操作: - **Reserve(预留)**:当支付开始时,我们将商品标记为已预留(短暂持有,例如几分钟)。 - **Claim(认领)**:当支付成功时,我们从库存台账(事实来源)中永久扣除数量。 结账完成取决于这个过程的快速和正确。预留缓慢会触发限流并带来更差的买家体验。错误意味着超卖(愤怒的客户)或欠卖(收入损失)。 ### 规模和正确性要求 这里的规模并不是抽象概念:Shopify 支撑着美国超过 14% 的电商业务,在 2025 年黑色星期五,我们峰值时每分钟销售额同比增长了 11%。预留运行在每一次触及库存的结账上,因此系统必须在不丢弃请求、不破坏一致性的前提下处理这种突发流量。 我们需要做到: - 在峰值流量期间支撑平台的高性能吞吐量目标 - 支持多地点库存(只从能够履约的地点预留) - 在预留和库存台账之间保持 ACID 保证 - 优先保证正确性:不超卖,不丢失预留 ### Redis 模型及其局限 之前的系统将预留存储在 Redis 中。每个商品有一个数量键,预留就是 `DECR`,释放就是 `INCR`。Redis 处理并发没有问题,但预留和库存台账存在于两个不同的系统中。 认领步骤(支付已处理,永久扣除库存)需要更新 MySQL 并清理 Redis,而这两个操作无法包裹在单个原子步骤中。根据执行顺序的不同,这可能导致超卖(商品已售出但从未从台账中扣除)或欠卖(商品已扣除但仍被标记为已预留)。 除此之外,Redis 模型没有多地点感知能力,并且增加了维护独立集群的运维成本。将预留移入与台账相同的 MySQL 数据库,意味着我们可以将一切包裹在 ACID 事务中,彻底消除这些故障模式。 ## 解决方案:SKIP LOCKED ### 核心思想:每单位一行,设计上有界 我们不采用每个商品一行并带数量列的方式,而是为每个可售单位使用一行。一个有 10 个单位的商品就有 10 行。预留三个单位意味着在单个事务中选择并移动三行。通过将预留和库存台账放在同一个数据库中,我们在预留和认领之间获得 ACID 保证——修复了 Redis 可能导致的各类错误(例如支付成功但库存未被认领,或反之)。 一个简化的预留流程如下所示: `SKIP LOCKED` 是使其可扩展的关键:如果另一个事务锁定了一些行,MySQL 会跳过它们并返回其他可用行。不会在同一个行上等待,争用更少。 但所有库存都采用每单位一行的方式在规模上会崩溃——一个在 10 个地点有 50,000 个单位的商品意味着 500,000 行,预留查询在扫描这些行时会变慢。因此,我们维护一个有限的可用行池,每个商品/地点组合上限为 1,000 行。预留从这个池中消费行;一个补充过程从库存台账中重新填充它。 **为什么是 1,000?** 这个上限需要足够大,以吸收突发流量而不会耗尽,但又足够小,以保持表紧凑并让 `SKIP LOCKED` 扫描快速。我们根据在闪购期间观察到的每个商品/地点的峰值预留速率来确定大小:1,000 给了我们足够的余量,让补充可以在持续负载下跟上,而不会让表增长到查询性能下降的程度。 **如果池空了怎么办?** 在极端闪购期间,热门商品的池可能会暂时耗尽。当这种情况发生时,预留路径会内联触发补充。一个锁确保一次只有一个事务在补充;其他并发的同一商品预留会等待它完成,而不是全部竞争插入行,避免了惊群效应。补充完成后,等待的事务会在池已满的情况下继续。买家永远不会看到商品不可用(除非它真的不可用)。这会为该特定预留增加延迟,但它保证了正确性:有可用库存的买家永远不会被拒绝。 ## 关键技术决策 ### 1. 复合主键:每行锁更少 我们的第一个原型使用自增 ID 作为主键。当我们观察到锁行为(例如使用 `SHOW ENGINE INNODB STATUS`)时,我们看到每次预留有两个行锁,而不是一个。 使用自增主键时,InnoDB 同时锁定 `WHERE` 子句中使用的二级索引和聚集索引(主键)。我们改为复合主键(`shop_id, inventory_item_id, inventory_group_id, id`),这样我们过滤的列就是主键的一部分。这减少到每行一个锁,这在每秒运行大量预留时非常重要。 要点:在这个规模下,索引和主键设计直接影响锁数量和吞吐量。 ### 2. READ COMMITTED:避免间隙(supremum)锁 当我们在一个需要补充的空表上运行 `SELECT ... FOR UPDATE SKIP LOCKED` 时,我们看到了间隙锁(包括 "supremum" 伪记录上的锁)。这些锁阻塞了补充事务插入新行,并可能导致死锁。 我们将这些事务的隔离级别从 `REPEATABLE READ`(MySQL 默认)改为 `READ COMMITTED`。在 `READ COMMITTED` 下,InnoDB 不会以同样的方式获取间隙锁,因此补充可以继续进行。Jahfer Husain 的 InnoDB 锁指南(https://jahfer.com/posts/innodb-locks/)对理解这一点非常有帮助。这是我们在该代码库中第一次使用非默认隔离级别;它需要少量框架支持来为每个事务设置隔离级别。 ### 3. 一致的锁顺序:避免死锁 当预留和认领以不同顺序触及两个表时,我们遇到了死锁。预留是先 `INSERT` 到 `reserved_quantities`,然后从 `reservation_units` 中 `DELETE`;认领则是从 `reserved_quantities` 中 `DELETE`。不同的事务可能以不同顺序锁定这两个表,形成循环。 解决方法是将顺序标准化:预留总是先从 units 表中 `DELETE`,然后再 `INSERT` 到 `reserved_quantities`。认领只触及 `reserved_quantities`。因为两条路径现在都以相同顺序获取锁,任何一方都不会持有另一方正在等待的锁——不再有循环等待。 ### 4. 使用 UNION ALL 进行批处理 每次数据库往返都有成本。对于有多个商品行的购物车,我们使用 `UNION ALL` 批处理预留查询,这样我们可以在一次往返中获取所有需要的单位: 这减少了总往返次数,并有助于降低负载下的延迟。 ## 真正的瓶颈:连接,而不是 CPU 在生产环境中,我们遇到了远低于目标的吞吐量上限。预留延迟(例如 P90)可以接受,CPU 没有打满,查询也已经优化过。所以我们把目光投向别处。 我们尝试将多个结账的预留批量合并到单个 `SKIP LOCKED` 查询中,以减少连接使用。这在负载测试中有所帮助,但增加了复杂性。我们还将一些读取负载移到了副本上。然而,还是有些东西对不上。 ### 追踪症状 在负载测试期间,我们看到了: - 线程在 MySQL 中排队 - 排队工作运行时 CPU 飙升 - ProxySQL 层到 MySQL 后端的连接耗尽 所以我们增加了一个能力,能看到哪些业务流程持有数据库连接,以及持有多长时间。知道连接耗尽并不能告诉你谁持有它们。我们需要按调用方归因。 在应用端,我们给每条 SQL 语句都加了一个注释标签,标识业务流程,比如 `/* conn_tag:checkout_completion */`。在 ProxySQL 层,我们添加了跟踪逻辑,解析该标签并测量每个调用方持有连接的时间。结果是:总连接持有时间,按业务流程细分。 这立刻显示了哪些调用方消耗了最多的连接时间。不是哪些查询慢,而是哪些流程在长事务中持有连接。如果你遇到连接限制但说不清原因,这个模式(应用层打标签,代理层聚合)实现起来很简单,而且立即可操作。 ### 我们发现了什么 一旦我们能看到连接使用情况,我们就了解到预留并不是唯一的重度使用者。结账路径上的其他部分持有连接的时间超过了必要长度。它们没有被优化,因为它们不是第一个触顶的。连接是有限的:在高吞吐量下,我们每秒需要大量短事务。当其他代码持有连接更久时,预留就成了压垮骆驼的最后一根稻草——不是因为预留慢,而是因为连接池已经接近枯竭。 结账路径的清理从主数据库上移除了 50% 的读取和 33% 的事务。我们还重新审视了 MySQL 配置。InnoDB 线程并发数在多年前被保守地设置,之后从未重新评估。我们的工作负载已经改变。在我们有余量的地方提高线程并发后,我们移除了一个直到我们将连接和 CPU 指标并排比较时才看到的瓶颈。 综合来看,清理和配置更改移除了这个上限。我们可以扩展到之前的限制之外,并达到我们的目标。在大规模闪购期间,写入方 CPU 保持在 50% 以下,读取方 CPU 低于 16%,还有余量。 ### 切换过程 我们没有从 Redis 一键切换到 MySQL。我们以所谓的“影子模式”并行运行两套系统:每次预留同时写入 Redis 和 MySQL,Redis 仍然是事实来源。这让我们可以并排比较两套系统,验证 MySQL 产生了正确的业务结果,并在真实生产流量上满足我们的性能要求。由于两套系统都在运行,没有需要迁移的在途预留。Redis 预留继续被兑现,同时 MySQL 构建自己的状态。 一旦我们对正确性和性能感到满意,我们就将事实来源切换到 MySQL。如果有任何问题,我们可以通过 kill switch 回退到 Redis;双写路径仍然活跃,所以 Redis 在任意时刻都有完整的预留视图。发布是逐步的,按 pod 推进,从低流量 pod 开始,逐步扩展到我们最高流量的商家。 ## 我们学到了什么 我们从这个项目中学到了很多,但主要有两点: ### 1. 重新审视旧决策 五年前不可能的事情(例如 MySQL 处理这种工作负载)在今天可能因为 `SKIP LOCKED` 这样的新特性而成为可能。配置也是如此:当工作负载和硬件演进时,线程限制和其他“经验法则”设置值得重新检查。如果数字对不上(例如 CPU 很低但排队严重),那就深入挖掘。 ### 2. 从小处着手并观察 我们从最小原型中获得了大量价值:一个小型 Ruby 脚本加 MySQL,没有像 Rails 那样的完整框架。观察数据库(例如在第二个终端中观察锁行为)比单纯理论教给我们的更多。在探索时,简单的工具和紧密的反馈循环胜过庞大且不透明的系统。 MySQL 现在可以处理我们过去认为需要专门基础设施的工作负载。如果你正在考虑用 Redis、Kafka 或自定义协调层来做高吞吐量的互斥,你现有的数据库可能已经足够了。 瓶颈不在我们预期的地方。我们花了数周优化查询和锁;真正的限制是连接使用,而它发生在我们根本没有关注的代码里。如果数字对不上——CPU 很低但排队很高——那就对整个路径做监控。答案往往在管道里,而不是引擎里。 至关重要的是,这并不是为了让预留变快。而是为了让它们成为安全的邻居。预留与购物车更新、支付处理和订单创建共享同一个数据库。一个让连接饱和或持锁过久的系统会危及所有这一切。真正的标准是在不损害其他一切数据库健康的前提下维持吞吐量。 对我们来说,回报是实实在在的:更可靠的预留意味着不会超卖,也意味着商家能获得更多成功的购买。 水位线 vs 竞赛迷因:库存

相似文章

Redis 与野心的代价

Lobsters Hottest

本文批评了 Redis 最近的战略方向,重点指出了许可变更引发的冲突、功能冗余泛滥,以及其向“AI 上下文引擎”定位的转变。文章分析了宏大的企业目标如何影响了项目的开放性和简洁性。