Shopify 用 MySQL 替代 Redis 进行库存预留——并且实现了扩展
在结账时,当买家点击“完成购买”时,我们需要确保他们购买的商品仍然可用。如果我们在某个方向上出错,两个买家会购买同一个最后的单位:商家必须取消一个订单,发送道歉电子邮件,并承担支持费用。如果我们在另一个方向上出错,我们告诉买家某个商品已售罄,但实际上并没有,从而造成商家失去本应出售的商品。在 Shopify 的规模下,任何一种失败都会迅速加剧。在2025年黑色星期五,使用我们平台的商家在高峰时期的销售额达到了每分钟510万美元的纪录。这些交易中的每一笔交易都涉及到库存。我们的超卖保护系统通过在支付处理期间预留库存来防止这一点——一个短暂的保持,防止两个并发的结账声称相同的单位。多年来,这一系统运行在 Redis 上。当我们朝着统一数据库战略迈进时,我们必须回答一个棘手的问题:MySQL 能否处理同样的规模?早期的尝试失败了。单行的数量列无法处理争夺。MySQL 8 的 SKIP LOCKED 特性引入了一种不同的设计:每个库存单位一行,而不是每个商品一行。受到 37signals 数据库负载分配方法的启发,我们在 MySQL 上重建了预留系统,并在2025年的高峰流量期间达到了我们的高吞吐量目标。但是,最艰难的教训并不是关于数据库设计。真正的瓶颈并不是我们观察和测量的内容。这篇文章将带您了解解决方案以及我们在这个过程中发现的内容。挑战 什么是超卖保护?超卖保护有两个主要操作:预留:当付款开始时,我们将商品标记为已预留(一个短暂的保持,例如几分钟)。认领:当付款成功时,我们从库存账本中永久扣除数量(真实来源)。结账的完成依赖于这一过程的快速和正确。缓慢的预留会触发限制,并导致更糟的买家体验。错误会导致超卖(愤怒的顾客)或少卖(收入损失)。规模和准确性要求 这里的规模不是抽象的: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 使得...
本站免费、广告极少。如果觉得有帮助,可以请我们喝杯咖啡 —— 任何金额都对持续运营有实际帮助。
☕请我喝杯咖啡