我们用 MySQL 替换了 Redis 来做库存预订,然后它撑住了
原文链接:We replaced Redis with MySQL for inventory reservations—and it scaled 作者:Emilie Noel — Shopify Engineering,2026 参考文档:MySQL 8.0 Locking Reads
一、这篇文章在讲什么
Shopify 把「库存预订(inventory reservation)」系统从 Redis 迁到了 MySQL,并在 2025 黑五峰值(每分钟 510 万美元销售额)下达标。
业务问题:超卖保护(Oversell Protection)
结账时两个操作:
| 操作 | 时机 | 含义 |
|---|---|---|
| Reserve | 开始支付 | 短期占位(几分钟),防止两个并发结账抢同一件 |
| Claim | 支付成功 | 永久扣减库存账本(source of truth) |
两种翻车方式都致命:
- 超卖:商户取消订单、道歉、赔客服成本
- 误判售罄:该成的单丢了,商户丢收入
Redis 方案的根本缺陷
老方案:每个商品一个 quantity key,预订 = DECR,释放 = INCR。
- Redis 并发处理没问题
- 预订和库存账本在两个系统里,claim 时「改 MySQL + 清 Redis」无法原子化,顺序搞反就超卖或欠卖
- 没有多地点库存概念(只能从可履约的地点扣,Redis 做不到)
- 多养一个集群的运维成本
结论:把预订挪进和账本同一个 MySQL 库,用 ACID 事务包住,这类 bug 整类消失。
二、SKIP LOCKED 深度解析
这是全文技术核心,也是这次迁移能成立的前提。
2.1 先理解 SELECT ... FOR UPDATE(锁定读)
普通 SELECT 是快照读,查完到动手之间数据可能被改,做「先检查再扣减」不安全。
FOR UPDATE 是锁定读:查出来的行加排他锁,别人不能改,直到你提交。等价于对这些行执行 UPDATE 所加的锁。这是 check-then-act 场景的正确姿势。
START TRANSACTION;
SELECT * FROM reservation_units
WHERE shop_id = 1
ORDER BY id LIMIT 3
FOR UPDATE; -- 锁住这 3 行
-- ... 搬走 / 标记这 3 行 ...
COMMIT; -- 锁释放问题在于默认行为: 如果目标行已被别的事务锁住,你只能排队等,等它提交、回滚或超时。
高并发下这会雪崩:
N 个 worker 抢同一批热点行
-> 全部阻塞排队
-> MySQL 线程拥堵
-> 事务堆积、连接池耗尽
-> 整个结账链路被拖垮2.2 SKIP LOCKED 的语义:不等了
SELECT * FROM reservation_units
WHERE shop_id = 1
ORDER BY id LIMIT 3
FOR UPDATE SKIP LOCKED;官方定义:
A locking read that uses
SKIP LOCKEDnever waits to acquire a row lock. The query executes immediately, removing locked rows from the result set.
被锁的行直接从结果集里消失,查询立即返回,你拿到的永远是「此刻没人动的那批行」。
和 NOWAIT 的区别:
| 选项 | 遇到被锁行 | 是否报错 |
|---|---|---|
FOR UPDATE(默认) |
阻塞等待 | 否(等) |
FOR UPDATE NOWAIT |
立即返回 | 报错 ERROR 3572: Do not wait for lock |
FOR UPDATE SKIP LOCKED |
静默跳过 | 不报错,继续返回其他行 |
队列场景要的是后者,报错还得写重试逻辑,跳过则天然实现了「各领各的」。
2.3 实测验证(MySQL 8.0.45)
队列表 5 行,会话 A 先 UPDATE ... WHERE id IN (1,2) 持锁不提交:
会话 B:普通 FOR UPDATE
21:59:08 开始
21:59:14 超时被杀(exit 124)
-> 被阻塞整整 6 秒,一事无成会话 C:FOR UPDATE SKIP LOCKED(同样 WHERE)
21:59:14 开始
21:59:14 立刻返回(同一秒)
-> 结果:空(id 1,2 被跳过)同一句 SQL 只差一个 SKIP LOCKED:一个卡死,一个秒回。
另一个观察:会话 B 被 timeout 掐掉后,会话 C 能立刻拿到 1,2,3,4,5(因为 A 的锁随连接断开回滚释放了)。所以 SKIP LOCKED 的「跳过」是动态的,只跳当前真正被锁的行。
2.4 为什么它对「队列表」是杀手锏
把一张表当工作队列用时,SKIP LOCKED 让 N 个 worker 各自领走不同的行,谁都不用等谁:
优化前:N 个 worker --> 排队抢同一批行 --> 串行、拥堵
优化后:N 个 worker --> 各领各的 --> 并行、无阻塞Shopify 的 reservation_units 表本质就是这玩意儿:一行 = 一个可售单位,多个结账流程同时来领,各领各的。
这也是业界经典用法,37signals 的 Solid Queue(数据库当队列)就是靠它,PostgreSQL 的 job queue 生态(如 que、good_job)也是同一套思路。
2.5 代价与边界
| 限制 | 说明 |
|---|---|
| 返回不一致视图 | 你看到的是数据的残缺子集,不适合通用事务逻辑,官方明说「只适合队列型表」 |
| 只管行级锁 | 间隙锁(gap lock)它管不了 |
| 对 SBR 复制不安全 | statement-based replication 下不可用 |
| 需 MySQL 8.0.1+ | 8.0.1 引入(WL #9439),PostgreSQL 9.5+ 更早 |
| 可能返回空集 | 全被锁时返回空,应用层必须处理这种「看起来无货」的情况 |
最后一条是 Shopify 必须搞「内联补货」的根本原因:否则买家会被误判售罄。
2.6 和文章其他设计的联动
SKIP LOCKED 解决了「等」,但每行的锁开销和表结构还得配合优化:
- 复合主键:自增主键下 InnoDB 要锁二级索引 + 聚簇索引,每行两次锁。把过滤列(
shop_id, inventory_item_id, inventory_group_id, id)塞进主键,每行只锁一次。 - READ COMMITTED:空表上
FOR UPDATE SKIP LOCKED在默认的 REPEATABLE READ 下会产生间隙锁(含 supremum 伪记录),反而挡住补货进程的 INSERT 并引发死锁。降到 RC 后间隙锁消失。 - 一行一单位 + 1000 行池:全量一行一单位会爆(5 万件 × 10 地点 = 50 万行,扫描变慢),所以维护上限 1000 行/商品地点的可用池,由补货进程从账本回填。1000 这个数是按实测峰值预订速率定的:够吸收突发,又小到 SKIP LOCKED 扫描够快。
三、四个关键工程决策
- 复合主键:
(shop_id, inventory_item_id, inventory_group_id, id),每行 1 次锁而非 2 次 - READ COMMITTED:避开 supremum 间隙锁,让补货 INSERT 不被挡
- 统一加锁顺序:reserve 先
DELETE from reservation_units再INSERT into reserved_quantities;claim 只碰reserved_quantities,无环等待,死锁消失 - UNION ALL 批量化:多行购物车一次往返取完所有单位
四、最反直觉的部分:瓶颈不是 CPU,是连接
这是全文最有价值的教训。
症状: 吞吐卡在目标线以下,但 P90 延迟正常、CPU 没满、查询已优化。
排查手段(值得抄的套路):
- 应用层给每条 SQL 打注释标签:
/* conn_tag:checkout_completion */ - ProxySQL 层解析标签,统计每个业务进程持有连接的总时长
- 得到「连接占用时长 by 业务进程」的归因视图
关键区别:不是「哪些查询慢」,而是「哪些进程长期占着连接」。
发现: 预订根本不是唯一大户。结账链路上其他代码持有连接更久,只是它们不是第一个撞墙的所以从没被优化。连接是有限资源,高频场景需要「大量短事务/秒」,别人占久了,预订就成了压垮骆驼的最后一根稻草。
结果:
- 清理结账链路,主库读 -50%、事务 -33%
- 重新评估多年前保守设置的 InnoDB
thread concurrency(工作负载早变了),调高 - 最终闪购期:写 CPU < 50%,读 CPU < 16%
五、切换方式:影子模式(Shadow Mode)
不是一刀切,而是:
- 双写 Redis + MySQL,Redis 仍是 source of truth
- 因为两套都活着,没有在途预订需要迁移,Redis 的预订继续被认可
- 对比两边的业务结果正确性和性能
- 满意后切换 source of truth 到 MySQL
- 保留 kill switch 可随时回滚(双写路径还在,Redis 始终有完整视图)
- 按 pod 灰度,从低流量到最高流量商户
六、核心心法
1. 重新审视旧决策
五年前 MySQL 做不到的事,有了 SKIP LOCKED 今天就能做。配置里的「经验值」(thread concurrency 之类)随负载和硬件演变更该复查。数字对不上(低 CPU 却排队)就要深挖。
2. 小步起步 + 观察
一个 Ruby 小脚本 + MySQL,不上 Rails 全框架。在第二个终端看锁行为(SHOW ENGINE INNODB STATUS)学到的东西比纯理论多。
3. 最有价值的一句
The bottleneck wasn’t where we expected.
我们优化了几周查询和锁,真正的限制在没人看的连接使用上。如果数字对不上,低 CPU 但高排队,就去给整条链路加埋点。答案往往在管道里,不在引擎里。
4. 目标不是「快」,是「做个好邻居」
Crucially, this wasn’t about making reservations fast. It was about making them safe neighbors.
预订和购物车更新、支付处理、订单创建共用一个数据库。一个占满连接或长期持锁的系统会拖垮所有邻居。真正的门槛是「在不损害数据库整体健康的前提下维持吞吐」。
5. 选型启示
如果你正准备为了高频互斥去上 Redis / Kafka / 自研协调层,你的数据库可能已经够用了。
最后更新:2026-09-17 原文:shopify.engineering/scaling-inventory-reservations