Shopify用MySQL替换Redis实现库存预留并成功扩展
做高并发数据库架构的同学必看,Shopify用SKIP LOCKED和复合主键解决库存预留瓶颈,细节可照做,赶紧拿你的场景对比一下。
blog|Infrastructure
博客|基础设施
We replaced Redis with MySQL for inventory reservations—and it scaled
我们用MySQL替换了Redis进行库存预留——并且它实现了扩展
How we used SKIP LOCKED, composite primary keys, and connection visibility to hit our scale targets.
我们如何使用SKIP LOCKED、复合主键和连接可见性来达到扩展目标。
Published on May 12, 2026
发布于2026年5月12日
During checkout, when a buyer clicks "Complete purchase," we need to guarantee the items they're buying are still available. If we get this wrong in one direction, two buyers purchase the same last unit: the merchant has to cancel an order, send an apology email, and eat the support cost. If we get it wrong in the other direction, we tell a buyer something is sold out when it isn't, and the merchant loses a sale they should have made.
在结账过程中,当买家点击“完成购买”时,我们需要保证他们购买的商品仍然可用。如果我们在一个方向上出错,两个买家会购买同一个最后一件商品:商家必须取消订单、发送道歉邮件,并承担支持成本。如果我们在另一个方向上出错,我们会告诉买家某件商品已售罄而实际上并非如此,商家就会失去本应达成的销售。
At Shopify's scale, either failure compounds fast. On Black Friday 2025, merchants on our platform hit a record $5.1 million in sales per minute at peak. Every one of those transactions touches inventory.
在Shopify的规模下,任何一种错误都会迅速累积。在2025年黑色星期五,我们平台上的商家在高峰期达到了每分钟510万美元的销售额纪录。每一笔交易都会触及库存。
Our oversell protection system handles this by reserving inventory during payment processing—a short hold that prevents two concurrent checkouts from claiming the same unit. For years, this ran on Redis. When we moved toward a unified database strategy, we had to answer a hard question: could MySQL handle the same scale?
我们的超卖保护系统通过在支付处理期间预留库存来处理这个问题——这是一种短暂的持有,防止两个并发结账认领同一件商品。多年来,这一直在Redis上运行。当我们转向统一数据库策略时,我们必须回答一个难题:MySQL能否处理同样的规模?
Earlier attempts had failed. A single row with a quantity column couldn't handle the contention. MySQL 8's SKIP LOCKED feature introduced a different design: one row per inventory unit instead of one row per item. Inspired by 37signals' approach to database-backed load distribution, we rebuilt reservations on MySQL and hit our high-throughput targets during peak 2025 traffic.
早期的尝试失败了。带有数量列的单个行无法处理争用。MySQL 8的SKIP LOCKED特性引入了一种不同的设计:每个库存单元一行,而不是每个商品一行。受37signals的数据库负载分配方法的启发,我们在MySQL上重建了预留功能,并在2025年高峰流量期间达到了我们的高吞吐量目标。
But the hardest lesson wasn't about database design. It was discovering that the real bottleneck wasn’t what we were observing and measuring. This post walks through the solution and what we found along the way.
但最难的教训不是关于数据库设计的。而是发现真正的瓶颈并不是我们正在观察和测量的东西。这篇文章将介绍解决方案以及我们一路走来发现的东西。
The challenge
挑战
What is oversell protection?
什么是超卖保护?
Oversell protection has two main operations:
超卖保护有两个主要操作:
- Reserve: When payment starts, we mark items as reserved (a short hold, e.g. several minutes).
- Claim: When payment succeeds, we permanently deduct quantity from the inventory ledger (source of truth).
- 预留:当支付开始时,我们将商品标记为已预留(短暂持有,例如几分钟)。
- 认领:当支付成功时,我们从库存台账(事实来源)中永久扣减数量。
Checkout completion depends on this being fast and correct. Slow reservations trigger throttling and a worse buyer experience. Mistakes mean overselling (angry customers) or underselling (lost revenue).
结账完成依赖于这一过程的快速和正确。缓慢的预留会触发限流并导致更差的买家体验。错误意味着超卖(愤怒的客户)或欠卖(收入损失)。
Scale and correctness requirements
规模和正确性要求
Scale here is not abstract: Shopify powers over 14% of U.S. ecommerce, and on Black Friday 2025 we saw an 11% increase in sales per minute at peak over the prior year. Reservations run on every checkout that touches inventory, so the system must handle that burst without dropping requests or breaking consistency.
这里的规模并非抽象概念:Shopify 支撑了美国电子商务超过14%的份额,在2025年黑色星期五,我们高峰期每分钟的销售额同比增长了11%。预订发生在每一次涉及库存的结账中,因此系统必须处理这种突发流量,而不能丢弃请求或破坏一致性。
We needed to:
我们需要:
- Support the platform's high-performance throughput targets during peak traffic
- Respect multi-location inventory (only reserve from locations that can fulfill)
- Keep ACID guarantees between reservations and the inventory ledger
- Prioritize correctness: no overselling and no lost reservations
- 在高峰流量期间支持平台的高性能吞吐目标
- 尊重多地点库存(仅从可履行的地点进行预订)
- 在预订和库存台账之间保持ACID保证
- 优先保证正确性:不超卖,不丢失预订
The Redis model and its limits
Redis模型及其局限性
The previous system stored reservations in Redis. Each item had a quantity key, and reserving meant DECR, releasing meant INCR. Redis handled concurrency fine, but reservations and the inventory ledger lived in two different systems.
之前的系统将预订存储在Redis中。每个商品有一个数量键,预订执行DECR,释放执行INCR。Redis处理并发没有问题,但预订和库存台账存在于两个不同的系统中。
The claim step (payment processed, permanently deduct inventory) required updating MySQL and cleaning up Redis, and those two operations couldn't be wrapped in a single atomic step. Depending on the order, this could cause overselling (item sold but was never deducted from the ledger) or underselling (item deducted and still marked reserved).
扣减步骤(支付处理完成,永久扣减库存)需要更新MySQL并清理Redis,而这两个操作无法封装在单个原子步骤中。根据顺序的不同,这可能导致超卖(商品已售出但从未从台账中扣减)或欠卖(商品已扣减但仍标记为已预订)。
On top of that, the Redis model had no multi-location awareness and added the operational cost of a separate cluster to maintain. Moving reservations into the same MySQL database as the ledger meant we could wrap everything in ACID transactions and eliminate these failure modes entirely.
此外,Redis模型没有多地点感知,并且增加了维护单独集群的运营成本。将预订移入与台账相同的MySQL数据库,意味着我们可以将所有操作封装在ACID事务中,并彻底消除这些故障模式。
The solution: SKIP LOCKED
解决方案:SKIP LOCKED
Core idea: one row per unit, bounded by design
核心思想:每个单位一行,设计上受限
Instead of one row per item with a quantity column, we use one row per sellable unit. An item with 10 units has 10 rows. Reserving three units means selecting and moving three rows in a single transaction. By keeping reservations and the inventory ledger in the same database, we get ACID across reserve and claim—fixing classes of bugs that were possible with Redis (e.g. payment succeeds but inventory isn't claimed, or the reverse).
我们不再为每个商品使用带数量列的一行,而是为每个可售单位使用一行。一个拥有10个单位的商品就有10行。预订三个单位意味着在单个事务中选择并移动三行。通过将预订和库存台账保存在同一数据库中,我们在预订和扣减之间获得了ACID保证——修复了Redis可能导致的错误类别(例如,支付成功但库存未扣减,或反之)。
A simplified reserve flow looks like this:
简化的预订流程如下:
SKIP LOCKED is what makes this scalable: if another transaction has locked some rows, MySQL skips them and returns other available rows. No waiting on the same row, less contention.
SKIP LOCKED是实现可扩展性的关键:如果另一个事务锁定了某些行,MySQL会跳过它们并返回其他可用行。无需等待同一行,减少了争用。
But one row per unit for all inventory would break down at scale—an item with 50,000 units across 10 locations would mean 500,000 rows, and the reserve query would slow as it scans through them. Instead, we maintain a bounded pool of available rows, capped at 1,000 per item/location combination. Reservations consume rows from this pool; a replenishment process refills it from the inventory ledger.
但是,如果所有库存都按每个单位一行来存储,在规模扩大时就会崩溃——一个在10个地点有50,000个单位的商品意味着500,000行,而预订查询在扫描这些行时会变慢。相反,我们维护一个有限的可预订行池,每个商品/地点组合的上限为1,000行。预订从该池中消耗行;补货过程从库存台账中重新填充该池。
Why 1,000? The cap needs to be large enough to absorb bursts without running dry, but small enough to keep the table compact and the SKIP LOCKED scan fast. We sized it based on observed peak reservation rates per item/location during flash sales: 1,000 gives us enough headroom that replenishment can keep up under sustained load without the table growing to a point where query performance degrades.
为什么是1,000?上限需要足够大以吸收突发流量而不至于耗尽,但又足够小以保持表紧凑和SKIP LOCKED扫描快速。我们根据限时抢购期间每个商品/地点观察到的峰值预订率来调整大小:1,000提供了足够的余量,使得补货在持续负载下能够跟上,而不会使表增长到查询性能下降的程度。
What happens if the pool empties? During an extreme flash sale, the pool for a hot item can be temporarily exhausted. When that happens, the reserve path triggers replenishment inline. A lock ensures only one transaction replenishes at a time; other concurrent reserves for the same item wait for it to finish rather than all racing to insert rows, avoiding a thundering herd. Once replenishment completes, the waiting transactions proceed with a full pool. The buyer never sees the item as unavailable (unless it truly is). This adds latency to that specific reservation, but it preserves correctness: a buyer with available inventory is never turned away.
如果池空了会怎样?在极端限时抢购期间,热门商品的池可能会暂时耗尽。当这种情况发生时,预订路径会内联触发补货。一个锁确保一次只有一个事务进行补货;其他并发预订同一商品的请求会等待其完成,而不是全部竞争插入行,从而避免惊群效应。补货完成后,等待的事务会在池已满的情况下继续。买家永远不会看到商品不可用(除非它确实不可用)。这会给该特定预订增加延迟,但保持了正确性:有可用库存的买家永远不会被拒绝。
Key technical decisions
关键技术决策
1. Composite primary key: fewer locks per row
1. 复合主键:每行更少的锁
Our first prototype used an auto-increment ID as the primary key. When we observed lock behavior (e.g. with SHOW ENGINE INNODB STATUS), we saw two row locks per reservation instead of one.
我们的第一个原型使用自增ID作为主键。当我们观察锁行为时(例如使用SHOW ENGINE INNODB STATUS),我们看到每次预订有两个行锁而不是一个。
With an auto-increment primary key, InnoDB was locking both the secondary index used in the WHERE clause and the clustered index (primary key). We switched to a composite primary key (shop_id, inventory_item_id, inventory_group_id, id) so the columns we filter on are part of the primary key. That reduced to one lock per row, which was important when running many reservations per second.
使用自增主键时,InnoDB会同时锁定WHERE子句中使用的二级索引和聚簇索引(主键)。我们改用复合主键(shop_id, inventory_item_id, inventory_group_id, id),这样我们过滤的列成为主键的一部分。这减少到每行一个锁,这在每秒运行大量预订时非常重要。
Takeaway: at this scale, index and primary key design directly affect lock count and throughput.
要点:在这种规模下,索引和主键设计直接影响锁数量和吞吐量。
2. READ COMMITTED: avoiding gap (supremum) locks
2. READ COMMITTED:避免间隙(supremum)锁
When we ran SELECT ... FOR UPDATE SKIP LOCKED on an empty table that needed replenishment, we saw gap locks (including on the "supremum" pseudo-record). Those locks blocked the replenishment transaction from inserting new rows and could lead to deadlocks.
当我们在一个需要补货的空表上运行 SELECT ... FOR UPDATE SKIP LOCKED 时,我们看到了间隙锁(包括在“ supremum ”伪记录上的锁)。这些锁阻塞了补货事务插入新行,并可能导致死锁。
We changed the transaction isolation level from REPEATABLE READ (MySQL default) to READ COMMITTED for these transactions. Under READ COMMITTED, InnoDB doesn't take gap locks in the same way, so replenishment could proceed. Jahfer Husain's guide to InnoDB locking was very helpful for understanding this. This was our first use of a non-default isolation level in this codebase; it required small framework support for setting isolation per transaction.
我们将这些事务的隔离级别从 REPEATABLE READ(MySQL 默认)改为 READ COMMITTED。在 READ COMMITTED 下,InnoDB 不会以同样的方式获取间隙锁,因此补货可以继续进行。Jahfer Husain 的 InnoDB 锁指南对理解这一点非常有帮助。这是我们第一次在此代码库中使用非默认隔离级别;它需要一些框架支持来为每个事务设置隔离级别。
3. Consistent lock ordering: avoiding deadlocks
3. 一致的锁顺序:避免死锁
We hit deadlocks when reserve and claim touched two tables in different orders. Reserve was doing INSERT into reserved_quantities then DELETE from reservation_units; claim was doing DELETE from reserved_quantities. Different transactions could lock the two tables in different orders and form a cycle.
当 reserve 和 claim 以不同的顺序访问两个表时,我们遇到了死锁。Reserve 先向 reserved_quantities 插入,然后从 reservation_units 删除;claim 从 reserved_quantities 删除。不同的事务可能以不同的顺序锁定这两个表,从而形成循环。
更进一步:量化金融体系
看懂新闻只是起点——沿量化金融路径,把它变成能交付的工程能力