Shopify 将库存预占系统从 Redis 迁移到 MySQL 并成功扛住峰值流量
用户点击"完成购买"时,我们必须确保他们要买的商品还有库存。这件事如果往一个方向出错,两个买家同时买到了最后一个库存:商家不得不取消订单、发致歉邮件,还要承担客服成本。如果往另一个方向出错,商品明明没卖完,我们却告诉买家已售罄,商家就会白白损失一笔本该成交的订单。
在 Shopify 的规模下,无论哪种错误都会被迅速放大。2025 年黑色星期五期间,我们的商家在峰值时每分钟成交额达到创纪录的 510 万美元,而每一笔都会触及库存系统。
我们的超卖防护系统在支付处理期间通过"预占库存"来解决这个问题——一段短暂的锁定,防止两个并发的结账流程认领同一个商品。多年来,这套机制一直跑在 Redis 上。当我们开始推行统一的数据库战略时,必须回答一个棘手的问题:MySQL 能扛住同样的规模吗?
早先的尝试都失败了。一行带数量列的设计撑不住高并发争抢。MySQL 8 的 SKIP LOCKED 特性带来了新的设计思路:一个库存单位对应一行,而不是一个商品对应一行。受到 37signals 用数据库做负载分发的方案启发,我们用 MySQL 重建了预占系统,并在 2025 年峰值流量期间达到了高吞吐目标。
但最深刻的教训其实跟数据库设计无关。我们发现,真正的瓶颈并不在我们当时观察和测量的那个东西上。这篇文章会带你了解整个方案,以及过程中我们发现了什么。
挑战
什么是超卖防护?
超卖防护包含两个核心操作:
- 预占(Reserve):支付开始时,将商品标记为已预占(短暂的锁定,例如几分钟)。
- 扣减(Claim):支付成功后,从库存台账(即权威数据源)中永久扣除对应数量。
结账能否顺畅完成,取决于这两个操作既快又准。预占一旦变慢,就会触发限流,买家体验随之下降;而出错就意味着要么超卖(激怒客户),要么少卖(白白损失收入)。
规模与正确性要求
这种规模可不是抽象概念:Shopify 支撑着全美超过 14% 的电商交易,2025 年黑色星期五高峰期每分钟销售额较前一年增长了 11%。每一笔涉及库存的结账都会触发预扣,系统必须在这种流量洪峰中稳住,不能丢请求,更不能破坏一致性。
我们面临的硬性要求:
- 在峰值流量下达到平台的高性能吞吐目标
- 支持多地点库存(只能从能发货的地点预扣)
- 保证预扣与库存账本之间的 ACID 事务
- 正确性优先:既不能超卖,也不能丢失预扣
Redis 方案及其局限
旧系统把预扣数据存在 Redis 里。每个商品对应一个数量键,预扣就是 DECR,释放就是 INCR。Redis 处理并发没问题,但预扣数据和库存账本分散在两套系统里。
落定(claim)这一步——支付完成、永久扣减库存——需要同时更新 MySQL 和清理 Redis,而这两步无法包在同一个原子操作里。根据执行顺序不同,要么超卖(商品已售出但账本未扣减),要么少卖(账本已扣减但仍被标记为预扣)。
更麻烦的是,Redis 方案既没有多地点感知能力,又额外增加了一个集群的运维成本。把预扣迁入库存账本所在的同一个 MySQL 数据库,就能把所有操作包进 ACID 事务,彻底消除上述故障模式。
解决方案:SKIP LOCKED
核心思路:一个库存单位一行,设计上天然有界
旧方案是一行商品记录配一个数量字段,新方案是一行对应一个可售单位。10 件库存就有 10 行。预扣 3 件就是在一个事务里选中并移动 3 行。预扣和库存账本在同一个数据库里,预扣和落定就能共享 ACID 保障,修掉了 Redis 方案下可能出现的一整类 Bug(比如支付成功但库存未落定,或者反过来)。
简化版的预扣流程如下:
SKIP LOCKED 才是这套方案能扩展的关键:如果某个事务已经锁住了部分行,MySQL 就跳过它们,直接返回其他可用的行。同一行上不会阻塞等待,争用大幅降低。
如果所有库存每个单位占一行,系统在大规模场景下会出问题——一个商品跨 10 个仓库共 5 万件库存,意味着 50 万行数据,预留查询扫描时速度会越来越慢。因此我们维护了一个有界可用行池,每个商品/仓库组合上限 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 锁机制指南 对我们理解这个问题帮助很大。这是这个代码库里第一次使用非默认的隔离级别,为此我们还在框架里加了对按事务设置隔离级别的支持。
3. 统一加锁顺序:避免死锁
当 reserve 和 claim 两个操作以不同顺序访问两张表时,就会出现死锁。reserve 先往 reserved_quantities 插数据,再从 reservation_units 删除数据;claim 则直接从 reserved_quantities 删除数据。不同事务可能以不同顺序锁住这两张表,从而形成环路。
解决办法是把顺序统一:reserve 先从 units 表 DELETE,再往 reserved_quantities 里 INSERT。claim 只操作 reserved_quantities。两条路径现在都按相同顺序加锁,谁也不会持有对方正在等待的锁——环路等待就此消除。
4. 用 UNION ALL 批量查询
每次访问数据库都有开销。对于有多个商品的购物车,我们用 UNION ALL 把预约查询合并,一次往返就取回所有需要的 unit:
这样总往返次数下降,在压力下的延迟表现也更好。
真正的瓶颈:连接数,而非 CPU
在实际环境中,我们的吞吐还没达到目标就遇到了天花板。预约延迟(比如 P90)其实还可以,CPU 没跑满,查询也早就优化过了。于是我们把目光转向其他地方。
我们试着把多个结算的预约合并到同一条 SKIP LOCKED 查询里,以此减少连接数。负载测试里效果不错,但也带来了额外的复杂度。我们还把一部分读流量挪到了从库上。可问题依然存在。
顺着症状找原因
负载测试期间我们观察到:
- MySQL 线程排队
- 队列任务运行时 CPU 飙升
- ProxySQL 层到 MySQL 后端的连接耗尽
于是我们增加了对各业务进程持有数据库连接情况以及持有时长的可观测性。光知道连接耗尽没用,得知道是谁占着不放。我们需要按调用方的归因分析。
在应用层,我们给每条 SQL 语句加了一个注释标签来标识对应的业务进程,例如 /* conn_tag:checkout_completion */。在 ProxySQL 层,我们增加了跟踪逻辑,解析这个标签并测量每个调用方持有连接的时长。最终得到的是按业务进程拆分的连接总占用时间。
这一下就清楚地看出哪些调用方占用了最多的连接时间。不是看哪些查询慢,而是看哪些进程在长事务里一直握着连接不释放。如果你也遇到了连接上限的问题却找不到原因,这种模式(应用层打标签、代理层聚合统计)实现起来很简单,而且立刻就能见效。
我们的发现
有了连接使用情况的可见性后,我们发现库存预订并不是唯一的大户。checkout 链路上的其他环节也持有连接的时间过长。它们之前没被优化,是因为还没先撞上连接上限。连接数是有限的:高吞吐场景下,我们需要的是每秒大量的短事务。当其他代码长时间占着连接时,库存预订就成了压垮骆驼的最后一根稻草——不是因为预订本身慢,而是连接池本来就快见底了。
对 checkout 链路的优化清理掉了主库 50% 的读操作和 33% 的事务。我们还重新审视了 MySQL 的配置。InnoDB 线程并发数多年前被设得偏保守,此后再没调整过,而我们的工作负载早已变化。在有富余的地方调高线程并发数后,我们消除了一个之前没察觉到的瓶颈——这要归功于连接指标和 CPU 指标并列对照才看出来。
优化和配置改动叠加起来,移除了性能天花板。我们突破了之前的上限,达成目标。在高流量的秒杀活动中,写库 CPU 保持在 50% 以下,读库 CPU 不到 16%,还有充足的余量。
切换上线
我们并没有一刀切地从 Redis 切换到 MySQL,而是让两套系统并行运行,我们称之为"影子模式":每次预扣库存同时写入 Redis 和 MySQL,期间 Redis 仍然是唯一权威数据源。这样我们就能并排对比两套系统,验证 MySQL 在真实生产流量下能否产生正确的业务结果、达到性能要求。由于两套系统同时在线,不存在需要迁移的进行中预扣——在 MySQL 逐步构建自身状态的过程中,Redis 上的预扣继续有效。
一旦确认了正确性和性能,我们就把权威数据源切换到 MySQL。万一出问题,可以通过一个 kill switch 切回 Redis;双写链路一直保持启用,所以 Redis 始终拥有完整的预扣视图。整个切换按 Pod 逐步推进,先从低流量的 Pod 开始,再逐步覆盖到最高流量的商户。
经验总结
这个项目让我们收获很多,最主要的两点:
1. 重新审视过去的决定
五年前做不到的事(比如用 MySQL 承载此类负载),今天借助 SKIP LOCKED 之类的新特性可能就变得可行了。配置同理:线程限制以及各种"经验法则"参数,在负载和硬件条件变化后都值得重新校准。如果数据对不上(比如 CPU 很低却出现了排队),就该深入排查。
2. 从小处着手,持续观察
一个极简原型给我们带来了很大价值:一段小的 Ruby 脚本加上 MySQL,没有 Rails 这类完整框架。直接观察数据库(比如在另一个终端里看锁的行为)比单纯看理论更有收获。在探索阶段,简单的工具加紧凑的反馈循环,胜过庞大又不透明的系统。
MySQL 如今能胜任过去被认为必须依赖专用基础设施的负载。如果你的方案正在考虑用 Redis、Kafka 或自研的协调层来实现高吞吐的互斥,很可能你现有的数据库就够了。
真正的瓶颈往往不在我们预期的地方。我们花了好几周优化查询和锁,真正的限制却出在一段根本没留意到的代码里的连接数使用上。如果数据对不上——CPU 很低但排队严重——就应该对整条链路进行埋点观测。答案通常藏在"管道"里,而不是引擎本身。
关键在于,这次改造的目的不是让预占更快,而是让预占成为安全的"邻居"。预占与购物车更新、支付处理、订单创建共享同一个数据库。一个把连接打满或长时间持有锁的系统,会危及所有这些业务。真正的衡量标准是:在不影响整个数据库健康的前提下,维持住吞吐量。
对我们来说,收效实实在在:预占更可靠,商家就不会出现超卖,购物成功率也更高。
