Shopify 把 Redis 换成 MySQL 深度拆解:SKIP LOCKED、一单元一行与「连接才是真瓶颈」的库存预留架构手术
2026 年 5 月,Shopify 工程团队公开了一件在很多架构师看来"倒退"的事:他们把跑了多年的 Redis 库存预留系统,换回了 MySQL。并且在 2025 年黑五(峰值 510 万美元/分钟)的真实流量上跑通了。
这篇文章不打算复述那篇博客。我想做的是把它拆到骨头:SKIP LOCKED 在 InnoDB 里到底改变了什么加锁语义、"一个单元一行"这个反直觉设计的数学模型是什么、复合主键为什么能把锁数从 2 降到 1、READ COMMITTED 为什么能消掉 supremum 间隙锁,以及最有价值的那一段——为什么 CPU 只有 50%、P90 延迟正常,系统却撞了天花板。最后一段我会用排队论里的小定律(Little's Law)把它量化,这是原文没写、但你在自己系统里最用得上的东西。
目录
- 背景:超卖这件事为什么这么难
- 老方案:Redis 到底输在哪里(不是性能)
- 核心概念:SKIP LOCKED 改变了什么
- 架构分析:一单元一行 + 有界池 + 补货
- 四个关键技术决策的逐条解剖
- 真正的瓶颈:连接,不是 CPU
- 代码实战:一套可跑的最小实现
- 性能优化:参数、索引与观测清单
- 灰度切换:影子模式双写怎么做
- 什么时候不该抄这套方案
- 十条踩坑清单
- 总结:这次手术真正的启示
一、背景:超卖这件事为什么这么难
做过电商的都懂这个场景:买家点了「立即支付」,从这一刻到支付成功回调,中间隔着几秒到几分钟。这段时间里库存是"薛定谔的"——你既不能真扣(支付可能失败,扣了要回滚,回滚失败就是幽灵库存),又不能不管(不管就会两个人买走同一件最后的货)。
于是几乎所有电商系统都会引入一个中间态:预留(reservation)。
- Reserve(预留):支付开始时打一个短时效的"占位",通常几分钟。
- Claim(认领):支付成功后,从库存台账(ledger,也就是真正的 source of truth)里永久扣减。
- Release(释放):支付失败或超时,把占位还回去。
看起来是三个动词的事,难点全在两个方向的对称错误上:
| 错误方向 | 后果 | 成本 |
|---|---|---|
| 超卖(oversell) | 两个买家买到同一件,商家取消订单、道歉、赔付 | 品牌损失 + 客服成本 |
| 少卖(undersell) | 明明有货却显示售罄 | 直接的收入损失,而且几乎不可观测 |
超卖会有人投诉,少卖不会。所以大多数团队的系统是悄悄偏向少卖的——因为少卖没人骂。这是我在很多库存系统里见过的"隐形亏损",包括保守的 TTL、过长的预留窗口、失败时"宁可不释放"的兜底逻辑。
Shopify 的量级把这两个方向都放大了:平台承载了美国超过 14% 的电商交易,2025 黑五峰值同比涨了 11%。在这个体量下,任何一个方向的偏差都是以"分钟"为单位在烧钱。
二、老方案:Redis 到底输在哪里(不是性能)
先说结论:Redis 不是被性能打败的,是被"事务边界"打败的。
老系统的模型极其经典,也极其简单:
# 每个 item 一个 quantity key
DECR inventory:{item_id} # 预留
INCR inventory:{item_id} # 释放
Redis 单线程执行命令,DECR 天然原子,并发几万 QPS 毫无压力。从"预留"这一个动作看,它是完美的。
问题出在 claim。
支付成功后,你要做两件事:
- 在 MySQL 的库存台账里永久扣减 quantity;
- 在 Redis 里清掉那条预留。
这两件事在两个存储系统里,没有任何办法包进一个原子操作。于是你只能选一个顺序,而两个顺序各有一种翻车方式:
顺序 A:先清 Redis,再扣 MySQL
→ Redis 清完,进程 crash / MySQL 超时
→ 预留没了,台账也没扣 → 这一件货被"凭空复活" → 超卖
顺序 B:先扣 MySQL,再清 Redis
→ MySQL 扣完,Redis 清理失败
→ 台账扣了,预留还在 → 这一件货被"扣了两次" → 少卖
你可以说:加个补偿任务嘛,加个对账嘛。对,都能做,但这本质上是在用最终一致性去兜一个需要强一致的业务语义,补偿窗口内的每一秒都是真实的业务错误。而且补偿任务本身也会挂、也会重复执行、也需要幂等。
除此之外,老方案还有两个硬伤:
- 没有多地点(multi-location)概念。现代电商的库存是分仓的,"有 10 件"不等于"这 10 件都能发到你家"。一个扁平的 quantity key 表达不了"只能从能履约的仓位预留"。
- 多一套集群要养。Redis 集群的容量规划、持久化策略、故障切换、跨可用区,全是独立的运维成本。而这套东西存的还不是 source of truth。
所以真正的动机是:把预留和台账放进同一个数据库,让 ACID 直接消灭掉一整类 bug,而不是"MySQL 比 Redis 快"。这个动机的顺序千万别搞反了——如果你的预留和台账本来就在一个库里,那这篇文章对你的价值就只剩下高并发部分了。
三、核心概念:SKIP LOCKED 改变了什么
3.1 为什么"一行一个 quantity"在 MySQL 里必死
Shopify 说"早期尝试失败了:单行 + quantity 列扛不住争抢"。这句话背后有一个可以算出来的数学上限,很多人没意识到。
假设你的预留 SQL 是:
BEGIN;
UPDATE inventory SET qty = qty - 1
WHERE item_id = 42 AND qty >= 1;
-- ... 其它业务逻辑 ...
COMMIT;
InnoDB 的行锁在 UPDATE 那一刻加上,在 COMMIT 那一刻释放。也就是说,同一行上的所有事务是严格串行的,每个事务独占这一行的时间等于"从 UPDATE 到 COMMIT 的墙钟时间",记为 W_hold。
那么这一行的理论吞吐上限就是:
TPS_max(单行) = 1 / W_hold
代入现实数字:
| W_hold(持锁时长) | 单行 TPS 上限 |
|---|---|
| 0.5 ms(纯本地、无网络) | 2000 |
| 2 ms(一次网络往返 + 提交刷盘) | 500 |
| 10 ms(事务里还夹了一次 RPC) | 100 |
| 50 ms(事务里调了支付网关,别笑,真有) | 20 |
注意这个上限跟你有多少 CPU、多少连接、多少副本一点关系都没有。它是被串行区长度锁死的。这就是阿姆达尔定律在数据库行锁上的具体形态。
热门 SKU 在闪购时的预留请求可能是每秒几千,而单行上限是几百。排队立刻形成,锁等待超时(默认 innodb_lock_wait_timeout=50s)开始批量出现,然后整个连接池被这些等待中的事务占满——注意最后这半句,它是第六节的伏笔。
3.2 SKIP LOCKED 的语义:把"等待"变成"分流"
MySQL 8.0.1 引入了 SELECT ... FOR UPDATE SKIP LOCKED(PostgreSQL 早在 9.5 就有了)。它的语义只有一句话:
扫描过程中遇到已被其它事务加锁的行,不等待,直接跳过,继续找下一行,直到满足
LIMIT或扫描结束。
对比三种锁定读的行为:
-- 1) 默认:排队等,最长等 innodb_lock_wait_timeout
SELECT ... FOR UPDATE;
-- 2) NOWAIT:遇到锁立刻报错 ER_LOCK_NOWAIT (3572)
SELECT ... FOR UPDATE NOWAIT;
-- 3) SKIP LOCKED:遇到锁就跳过,返回"我能拿到的那些"
SELECT ... FOR UPDATE SKIP LOCKED;
关键在于第三种破坏了结果集的确定性——同样的 SQL,同样的数据,两次执行可能返回不同的行。这在传统 OLTP 观念里是异端,但对于"我不在乎拿到哪一个单元,我只要拿到 N 个"这类可互换资源分配场景,它恰好就是最优解。
一句话总结这次范式转换:
从"大家抢同一把锁"变成"大家各拿一把不同的锁"。争抢消失了,因为资源被物理拆开了。
3.3 并发度上限的重新推导
拆成 N 行之后,理论并发度变成:
TPS_max(池) ≈ min(N, C_conn) / W_hold
其中 C_conn 是可用的并发数据库连接数。这个式子有两个很重要的推论:
- 行数 N 变成了可调的并发旋钮。原来你只能优化
W_hold(很难,很快触底),现在你可以直接加 N。 - 一旦 N 足够大,瓶颈立刻转移到
C_conn。这正是 Shopify 后来撞到的墙。
3.4 SKIP LOCKED 不是免费的:被跳过的行也要扫
这一点几乎所有介绍 SKIP LOCKED 的文章都不讲,但它直接决定了池子该多大。
SKIP LOCKED 的"跳过"不是魔法,它仍然要:读到那条记录 → 尝试加锁 → 发现冲突 → 放弃 → 移动到下一条。也就是说,一次 LIMIT n 的查询实际访问的记录数是:
rows_scanned ≈ n + C_concurrent
C_concurrent 是此刻正在同一个池上持锁的并发事务数(准确说是它们已锁定的行数)。这意味着:
- 并发越高,每个查询扫的行越多 → 单次查询变慢 →
W_hold变长 → 吞吐再降。这是一个正反馈退化。 - 所以池子必须显著大于峰值并发数,让"跳过"只发生在扫描的前几行,而不是扫过半个 B+ 树。
Shopify 把池上限定在每个 item/location 组合 1000 行。用上面的模型反推:如果峰值并发是几十个事务同时抢同一个 SKU,1000 行意味着扫描前缀里被跳过的比例大约在个位数百分比,几乎无感;同时 1000 行的 B+ 树深度极浅,页面几乎必然在 buffer pool 里。
这就是"为什么是 1000"的工程解释:大到能吸收突发、能让跳过成本可忽略;小到表足够紧凑、扫描足够快。它是两个反向压力的平衡点,不是拍脑袋。
四、架构分析:一单元一行 + 有界池 + 补货
4.1 三层结构
┌─────────────────────────────────────────────────┐
│ inventory_ledger (库存台账 / source of truth) │
│ 一行 = 一个 item×location 的真实可售数量 │
└───────────────┬─────────────────────────────────┘
│ 补货 replenish(异步 + 兜底同步)
▼
┌─────────────────────────────────────────────────┐
│ reservation_units (有界单元池,≤1000 行/组合) │
│ 一行 = 一个"可被预留的单元",无 quantity 列 │
│ 预留 = 把行取走;释放 = 把行放回 │
└───────────────┬─────────────────────────────────┘
│ 同一事务内移动
▼
┌─────────────────────────────────────────────────┐
│ reserved_quantities (已预留记录) │
│ 一行 = 一个 checkout token 对某 item 的占位 │
└─────────────────────────────────────────────────┘
核心洞察是:reservation_units 不是库存本身,它是库存的一个"信用额度投影"。
这个模型其实在别的地方你见过:
- 数据库自增 ID 的号段分配(segment allocation):不是每次去中心表 +1,而是一次领 1000 个号回本地慢慢发。
- TCP 的滑动窗口:不是每个字节都握手,而是预授权一段额度。
- 信号量的批量获取(
Semaphore.acquire(n))。
一旦你意识到它是"额度投影",两个必须做的事就浮出水面了(原文没展开,但生产必备):
- 对账:池是投影,投影会漂移(进程 crash 在 DELETE 之后 COMMIT 之前?不会,那在一个事务里。但补货逻辑本身可能有 bug、可能被人工干预)。必须有周期对账:
池中行数 + 已预留数量 ≤ 台账可售数量。 - 回收:预留是有 TTL 的,超时必须把行还回池子,否则池子会被"僵尸预留"慢慢抽干。
4.2 为什么不直接一单元一行到底
1 item × 50000 units × 10 locations = 500,000 行。
问题不只是磁盘,是:
- 每次
LIMIT 3 FOR UPDATE SKIP LOCKED的扫描起点选择变复杂; - 补货/回收的批量操作会产生巨大的 undo 和 binlog;
- 一个 SKU 的库存调整(比如商家一次入库 3 万件)会变成一次 3 万行的 INSERT,直接把复制延迟拉起来。
有界池把"库存规模"和"并发规模"解耦了:池的大小只跟并发需求有关,跟你仓库里有多少货完全无关。这是这个设计里最漂亮的一刀。
4.3 池空了怎么办:内联补货 + 单飞
闪购场景下,热门 SKU 的池会被瞬间抽干。这时候不能直接告诉买家"售罄"——台账里明明还有货。
Shopify 的做法是:在 reserve 路径上内联触发补货,并且用一把锁保证同一时刻只有一个事务在补,其它并发 reserve 等它补完而不是一起冲进去 INSERT。
这就是经典的 singleflight(单飞)/ 惊群抑制 模式。如果不加这把锁,会发生什么?
100 个并发发现池空 → 100 个事务同时读台账 → 100 个事务同时 INSERT 1000 行
→ 池里出现 100,000 行(超卖!)+ 主键冲突风暴 + 死锁风暴
加了锁之后,代价是"这一次预留变慢了"(要等补货完成),但正确性保住了:有货的买家永远不会被告知没货。这是一个非常典型的、值得学习的取舍——在极端路径上用延迟换正确性,而不是用降级换吞吐。
五、四个关键技术决策的逐条解剖
这四条是整篇文章里最"手上功夫"的部分。我按原文顺序展开,并补充每一条背后的 InnoDB 机制。
5.1 复合主键:把每行的锁数从 2 降到 1
现象:用自增 id 做主键时,SHOW ENGINE INNODB STATUS 显示每预留一行产生两个行锁。
机制:InnoDB 是索引组织表(IOT),数据挂在聚簇索引(主键)上。当你通过二级索引做锁定读:
-- PRIMARY KEY (id)
-- KEY idx_lookup (shop_id, inventory_item_id, inventory_group_id)
SELECT id FROM reservation_units
WHERE shop_id=1 AND inventory_item_id=42 AND inventory_group_id=7
LIMIT 3 FOR UPDATE SKIP LOCKED;
InnoDB 的执行路径是:
- 在
idx_lookup上定位记录,给二级索引记录加锁; - 回表(bookmark lookup)拿到主键值,再给聚簇索引记录加锁。
两把锁。锁的数量直接对应:锁表内存(lock struct)、锁冲突检测的开销、死锁检测图的规模(innodb_deadlock_detect 打开时是 O(锁数) 级别的图遍历)。在每秒几万行锁的场景下,这个常数因子 ×2 是要命的。
修复:把过滤列直接放进主键。
PRIMARY KEY (shop_id, inventory_item_id, inventory_group_id, id)
现在 WHERE 条件是主键前缀,查询走聚簇索引区间扫描,不需要回表,一行只加一把锁。
顺带还赚到三件事:
- 数据局部性:同一个 shop 同一个 item 的所有单元行在 B+ 树上物理相邻,一次页读能覆盖多行,buffer pool 命中率飙升。
- 少一个二级索引:写入时少维护一棵树。
- DELETE 更便宜:
DELETE ... WHERE (shop_id, item_id, group_id, id) IN (...)直接定位聚簇索引,无回表。
通用规律:在高争抢的表上,主键设计不是"标识"问题,是"锁"问题。默认给每张表配一个自增 ID,是一个在低并发下无害、在高并发下昂贵的习惯。
5.2 READ COMMITTED:干掉 supremum 间隙锁
现象:在空表(或池被抽干)上执行 SELECT ... FOR UPDATE SKIP LOCKED,会看到间隙锁,包括加在 supremum 伪记录上的锁;这把锁挡住了补货事务的 INSERT,进而形成死锁。
机制:MySQL 默认隔离级别是 REPEATABLE READ。为了防止幻读,InnoDB 在 RR 下对锁定读使用 next-key lock = 记录锁 + 前面的间隙锁。当扫描走到索引末尾却没找到记录,它会锁住"最后一条记录到正无穷"这个间隙——这个虚拟的边界就叫 supremum pseudo-record。
于是:
事务 A: SELECT ... FOR UPDATE SKIP LOCKED → 空结果,但锁住了 (last, +∞) 间隙
事务 B: INSERT 新单元行(落在那个间隙里) → 被 A 挡住,等待
事务 A: 发现池空,准备自己补货,也要 INSERT → 和 B 形成环 → 死锁
注意这里有个特别反直觉的点:SKIP LOCKED 跳过的是"记录锁",不是"间隙锁"。你以为加了 SKIP LOCKED 就不会被阻塞了,结果空表上照样出事。这个坑我见过至少三个团队踩。
修复:把这些事务的隔离级别降到 READ COMMITTED。
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
...
COMMIT;
RC 下 InnoDB 基本不加间隙锁(唯一键重复检查、外键检查等少数场景例外),锁定读退化成纯记录锁。间隙没了,补货就能插进去了。
RC 的额外代价,必须知道:
| 项目 | RR | RC |
|---|---|---|
| 幻读 | 防 | 不防 |
| 同一事务两次读 | 一致快照 | 每条语句一个新快照(不可重复读) |
| 间隙锁 | 有 | 基本没有 |
| 半一致读(semi-consistent read) | 无 | UPDATE 时有,能减少锁冲突 |
| binlog 格式 | 支持 STATEMENT | 必须 ROW(binlog_format=ROW) |
最后一行是硬约束:RC + STATEMENT 格式的 binlog 会被 MySQL 直接拒绝(Binary logging not possible)。如果你的复制链路上还有老的 STATEMENT 格式,先解决它。
另外一个工程细节:Shopify 提到这是他们代码库里第一次用非默认隔离级别,"需要框架做一点支持来按事务设置隔离级别"。这个提醒很实在——很多 ORM 的连接池会复用连接,SET TRANSACTION ISOLATION LEVEL 如果写成了 SESSION 级别而不是下一个事务级别,会污染后续所有查询。这是一个非常隐蔽的生产事故来源,第 11 节的踩坑清单里我会再强调一次。
5.3 一致的加锁顺序:死锁的唯一根治法
现象:reserve 和 claim 两条路径以不同顺序触碰两张表,形成循环等待。
reserve: INSERT reserved_quantities → DELETE reservation_units
claim: DELETE reserved_quantities
当 reserve 的事务 A 已经锁住 reserved_quantities 的某行、正在等 reservation_units,而另一个事务 B 反过来,环就成了。
修复:统一顺序。让 reserve 永远先 DELETE reservation_units,再 INSERT reserved_quantities;claim 只碰 reserved_quantities。
这是死锁四个必要条件里"循环等待"那一条的教科书式破除。写成可执行的团队规范就是:
给所有会被同一批事务触碰的资源定义一个全局偏序(比如按表名字典序、按 shard id 升序、按主键升序),任何事务只能沿这个偏序加锁。
补一条原文没说但同样重要的:批量操作内部也要有序。比如一个购物车有 5 个 SKU,如果你按用户加购顺序去 reserve,两个购物车 [A, B] 和 [B, A] 就会死锁。永远按 inventory_item_id 升序排序后再处理。
// 必须:批量预留前排序,消除购物车之间的循环等待
sort.Slice(lines, func(i, j int) bool {
if lines[i].ItemID != lines[j].ItemID {
return lines[i].ItemID < lines[j].ItemID
}
return lines[i].GroupID < lines[j].GroupID
})
5.4 UNION ALL 批量:把 N 次往返压成 1 次
一个购物车有多个行项目,如果每个 SKU 发一条 SQL,就是 N 次网络往返。在事务里,每一次往返都在延长 W_hold——而 W_hold 是我们前面推导过的、决定吞吐上限的那个变量。
W_hold = N × RTT + N × 执行时间 + 提交时间
把 5 个 SKU 从 5 次往返压成 1 次,W_hold 可能直接砍掉 60%,吞吐翻倍还多。这不是"优化了一点延迟",这是动了吞吐公式的分母。
批量写法:
(SELECT id, inventory_item_id, inventory_group_id
FROM reservation_units
WHERE shop_id = 1 AND inventory_item_id = 42 AND inventory_group_id = 7
ORDER BY id LIMIT 2 FOR UPDATE SKIP LOCKED)
UNION ALL
(SELECT id, inventory_item_id, inventory_group_id
FROM reservation_units
WHERE shop_id = 1 AND inventory_item_id = 77 AND inventory_group_id = 7
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED)
UNION ALL
(SELECT id, inventory_item_id, inventory_group_id
FROM reservation_units
WHERE shop_id = 1 AND inventory_item_id = 91 AND inventory_group_id = 3
ORDER BY id LIMIT 4 FOR UPDATE SKIP LOCKED);
⚠️ 版本与中间件注意:在 UNION 的各分支里带锁定子句,需要每个分支用括号括起来,且对 MySQL 版本有要求(8.0 系列上可用;更老的版本会直接报错)。另外,部分 SQL 代理/连接池的语句解析器对"带锁定子句的 UNION"支持不佳,可能误判为只读而路由到从库——这是灾难性的。上线前务必在你自己的代理链路上验证一次路由结果。
如果你的环境不支持,退路有两条:① 用多语句(
multiStatements=true)一次往返发多条 SQL;② 用IN条件配合窗口函数做"每组取 N 行",但窗口函数与FOR UPDATE的组合限制更多,一般不推荐。
为什么必须 UNION ALL 而不是 UNION:UNION 会做去重(隐含排序/哈希),不仅慢,还可能在语义上吞掉你本来就想要的重复结果。锁定读场景下永远用 UNION ALL。
六、真正的瓶颈:连接,不是 CPU
这一节是全文最有价值的部分,也是最容易被忽略的部分。
6.1 症状
Shopify 在生产上撞到了一个远低于目标的吞吐天花板,而所有"常规嫌疑人"都是清白的:
- 预留延迟(P90)正常;
- CPU 没跑满;
- 查询已经优化到位(复合主键、SKIP LOCKED、批量)。
但是:
- MySQL 里有线程在排队;
- 排队的活儿一跑起来 CPU 就尖刺;
- ProxySQL 层出现到后端的连接耗尽。
这是一组极其典型的"低 CPU + 高排队"信号。只要你看到这个组合,就可以基本排除"算力不足",把注意力转向并发准入通道:连接数、线程池、信号量、代理的多路复用。
6.2 用小定律(Little's Law)把它算出来
这是我要补充的核心分析。排队论里的小定律说:
L = λ × W
L:系统中平均"在场"的请求数λ:到达速率(QPS)W:每个请求在系统中停留的时间
把它翻译到数据库连接池上:
所需并发连接数 = QPS × 平均连接持有时长
注意分母那个词:连接持有时长,不是查询执行时长。这两个东西在有事务的系统里差着数量级。
来算一笔账(数字为演示用的量级示例):
| 业务进程 | QPS | 连接持有时长 | 所需连接数 |
|---|---|---|---|
| 库存预留(优化后) | 20,000 | 3 ms | 60 |
| 购物车更新(未优化,事务里有多次往返) | 3,000 | 25 ms | 75 |
| 订单创建(事务里夹了一次外部调用) | 800 | 60 ms | 48 |
| 各种后台读(没走从库) | 5,000 | 4 ms | 20 |
| 合计 | 203 |
如果你的连接池上限是 200,那么这套系统刚好在悬崖边上。而此时你去看监控:
- 预留的 P90 延迟:3ms,非常健康 ✅
- CPU:50%,很有余量 ✅
- 结论:预留系统"没问题" ❌
错。 预留系统消耗的是 60 个连接,是最大的单一消费者之一,但它不是"罪魁祸首"——真正吃掉池子的是那些QPS 不高但持有时间长的进程。购物车更新只有 3000 QPS,却比 20000 QPS 的预留吃掉更多连接。
这就是 Shopify 那句话的精确含义:
"不是因为预留慢,而是因为池子本来就快见底了,预留是压垮骆驼的最后一根稻草。"
用小定律看,QPS × 持有时长 才是连接消耗的度量,而绝大多数团队的监控只有 QPS 和延迟,没有这个乘积。
6.3 为什么"连接"在代理层特别致命:多路复用会被事务打断
再深挖一层。ProxySQL 这类代理的一大卖点是连接多路复用(multiplexing):1000 个前端连接可能只需要 50 个后端连接,因为大部分时间连接是空闲的,代理可以在语句之间把后端连接还给池子。
但是——一旦客户端开启事务,多路复用就必须关闭。因为事务状态(锁、隔离级别、临时表、未提交的更改)绑定在具体的后端连接上,代理不敢把它挪走。
ProxySQL 中会禁用多路复用的典型情况:
- 处于显式事务中(
BEGIN之后到COMMIT/ROLLBACK之前); - 使用了用户变量(
@var); - 执行过
SET语句(注意:包括SET TRANSACTION ISOLATION LEVEL!); LOCK TABLES、GET_LOCK()、预处理语句等。
看到第三条了吗?你为了消除间隙锁而加的 SET TRANSACTION ISOLATION LEVEL READ COMMITTED,本身就可能让代理放弃多路复用,从而加剧连接压力。这是一个非常隐蔽的相互作用:5.2 节的修复,可能在放大 6.1 节的问题。
结论:在有代理的架构里,"事务时长"约等于"后端连接独占时长"。缩短事务,就是在扩容连接池。
6.4 解法:给每一条 SQL 打上"谁在用"的标签
知道"连接耗尽"没用,你需要知道"谁在占着连接、占了多久"。
Shopify 的做法是两层配合:
应用层:给每条 SQL 加注释标签,标明业务进程。
/* conn_tag:checkout_completion */ SELECT ...
代理层:ProxySQL 解析这个标签,统计每个 caller 的连接持有时长总和。
最终得到的指标不是"哪个查询慢",而是"哪个业务进程占用了最多的连接时间"。这是两个完全不同的视角,后者才是连接池问题的正确视角。
这个模式其实有行业标准了,强烈建议直接用而不是自己发明格式:
- SQLCommenter(Google 提出,已并入 OpenTelemetry):格式是
/*key='value',key2='value2'*/,会自动带上application、controller、action、traceparent等。 - Ruby 生态的 Marginalia(Rails 6.1 后已内置为
ActiveRecord::QueryLogs)。 - Go 生态可以用
github.com/google/sqlcommenter或者自己包一层 driver。
标签的额外红利:慢查询日志和 performance_schema 里也能看到标签,排障时直接就知道是哪条业务链路发的 SQL,不用再去猜。
⚠️ 一个坑:注释会进入 query digest。ProxySQL 默认会把注释算进 digest(受 mysql-query_digests_keep_comment 影响),如果你的标签里带了高基数字段(比如 request_id),digest 表会爆炸。标签只放低基数维度(业务进程名、控制器名),不要放 trace id 到 digest 参与的位置。
6.5 最后的发现与收尾
有了归因数据之后,Shopify 发现:
- 结算路径上其它代码持有连接的时间远超必要,只是因为它们"不是第一个撞上限的",所以从来没被优化过;
- 清理这些代码后,主库上减少了 50% 的读和 33% 的事务;
- 顺手复查 MySQL 配置,发现
innodb_thread_concurrency是很多年前保守设定的,工作负载早就变了。调大之后,又拆掉一个隐形瓶颈。
最终效果:闪购高峰时,写节点 CPU < 50%,读节点 CPU < 16%,还有余量。
关于 innodb_thread_concurrency 补一句:这个参数在现代 MySQL(8.0)里默认是 0,即不限制。历史上在多核机器上限制并发线程数是有意义的(早期 InnoDB 的内部互斥锁扩展性差),但在 8.0 + 现代硬件上,人为设小它常常是纯粹的自伤。如果你的 my.cnf 是从 5.6/5.7 时代继承下来的,这个参数值得单独拉出来审一遍,同类的还有 innodb_concurrency_tickets、innodb_thread_sleep_delay、innodb_spin_wait_delay。
七、代码实战:一套可跑的最小实现
下面这套代码是我按照文中的设计写的可运行最小骨架,不是 Shopify 的源码。你可以直接拿去在本地 MySQL 8.0 上跑通,然后按自己的业务改。
7.1 Schema
-- ① 库存台账:source of truth
CREATE TABLE inventory_ledger (
shop_id BIGINT UNSIGNED NOT NULL,
inventory_item_id BIGINT UNSIGNED NOT NULL,
inventory_group_id BIGINT UNSIGNED NOT NULL, -- 地点/仓位分组
available INT NOT NULL, -- 可售总量
version BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (shop_id, inventory_item_id, inventory_group_id)
) ENGINE=InnoDB;
-- ② 单元池:一行 = 一个可预留单元。注意主键顺序!
CREATE TABLE reservation_units (
shop_id BIGINT UNSIGNED NOT NULL,
inventory_item_id BIGINT UNSIGNED NOT NULL,
inventory_group_id BIGINT UNSIGNED NOT NULL,
unit_seq INT UNSIGNED NOT NULL, -- 组合内序号,0..999
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (shop_id, inventory_item_id, inventory_group_id, unit_seq)
) ENGINE=InnoDB;
-- ③ 已预留记录
CREATE TABLE reserved_quantities (
shop_id BIGINT UNSIGNED NOT NULL,
reservation_token BINARY(16) NOT NULL, -- 幂等键,UUID 二进制
inventory_item_id BIGINT UNSIGNED NOT NULL,
inventory_group_id BIGINT UNSIGNED NOT NULL,
quantity INT NOT NULL,
expires_at DATETIME(3) NOT NULL,
state TINYINT NOT NULL DEFAULT 0, -- 0=held 1=claimed
PRIMARY KEY (shop_id, reservation_token, inventory_item_id, inventory_group_id),
KEY idx_expiry (state, expires_at),
KEY idx_item (shop_id, inventory_item_id, inventory_group_id)
) ENGINE=InnoDB;
-- ④ 补货互斥锁行(singleflight 用,避免惊群)
CREATE TABLE pool_replenish_locks (
shop_id BIGINT UNSIGNED NOT NULL,
inventory_item_id BIGINT UNSIGNED NOT NULL,
inventory_group_id BIGINT UNSIGNED NOT NULL,
last_replenished_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (shop_id, inventory_item_id, inventory_group_id)
) ENGINE=InnoDB;
几个设计说明:
unit_seq而不是全局自增 id:组合内自增,让主键完全"组合本地化",同一个 item 的所有行在 B+ 树上连续。代价是插入时需要知道当前最大 seq(补货时反正要读,无额外成本)。reservation_token做幂等键:网络重试时同一个 token 重复 reserve,靠主键冲突挡住,不会重复扣。idx_expiry (state, expires_at):回收任务扫描用。把state放前面,避免扫到已 claim 的记录。
7.2 预留:核心 SQL
-- 每个事务独立设置隔离级别(注意是 TRANSACTION 不是 SESSION!)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
/* conn_tag:checkout_reserve */
SELECT unit_seq
FROM reservation_units
WHERE shop_id = ? AND inventory_item_id = ? AND inventory_group_id = ?
ORDER BY unit_seq
LIMIT ? -- 需要的数量
FOR UPDATE SKIP LOCKED;
-- 拿到 n 行且 n == 需要量,才继续;否则触发补货或判定售罄
/* conn_tag:checkout_reserve */
DELETE FROM reservation_units
WHERE shop_id = ? AND inventory_item_id = ? AND inventory_group_id = ?
AND unit_seq IN (?, ?, ?);
/* conn_tag:checkout_reserve */
INSERT INTO reserved_quantities
(shop_id, reservation_token, inventory_item_id, inventory_group_id,
quantity, expires_at, state)
VALUES (?, ?, ?, ?, ?, DATE_ADD(NOW(3), INTERVAL 300 SECOND), 0)
ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity);
COMMIT;
⚠️ expires_at 一定用数据库的 NOW(3) 算,不要用应用服务器的时间。 应用集群有几十上百台,NTP 漂移几百毫秒是常态,而回收任务是拿数据库时间比较的。用应用时间会导致"提前回收"(超卖)或"延迟回收"(少卖)。
7.3 Go 实现(含死锁重试、补货单飞)
package reservation
import (
"context"
"database/sql"
"errors"
"fmt"
"math/rand"
"sort"
"strings"
"time"
"github.com/go-sql-driver/mysql"
)
const (
poolCap = 1000
holdSeconds = 300
maxRetry = 3
errDeadlock = 1213 // ER_LOCK_DEADLOCK
errLockWait = 1205 // ER_LOCK_WAIT_TIMEOUT
)
var ErrOutOfStock = errors.New("reservation: out of stock")
type Line struct {
ItemID uint64
GroupID uint64
Qty int
}
type Reserver struct{ db *sql.DB }
// Reserve 对一个购物车的多个行项目做原子预留。
func (r *Reserver) Reserve(ctx context.Context, shopID uint64, token [16]byte, lines []Line) error {
// 关键:全局排序,消除购物车之间的循环等待(见 5.3)
sort.Slice(lines, func(i, j int) bool {
if lines[i].ItemID != lines[j].ItemID {
return lines[i].ItemID < lines[j].ItemID
}
return lines[i].GroupID < lines[j].GroupID
})
var lastErr error
for attempt := 0; attempt < maxRetry; attempt++ {
err := r.reserveOnce(ctx, shopID, token, lines)
if err == nil {
return nil
}
if !isRetryable(err) {
return err
}
lastErr = err
// 指数退避 + 抖动,避免重试风暴同频共振
backoff := time.Duration(1<<attempt) * 5 * time.Millisecond
jitter := time.Duration(rand.Int63n(int64(backoff)))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff + jitter):
}
}
return fmt.Errorf("reserve failed after %d attempts: %w", maxRetry, lastErr)
}
func (r *Reserver) reserveOnce(ctx context.Context, shopID uint64, token [16]byte, lines []Line) error {
// 注意:这里用 sql.LevelReadCommitted,由 driver 在事务开始时下发,
// 而不是手写 SET SESSION —— 否则会污染连接池里的后续查询。
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
for _, ln := range lines {
seqs, err := grabUnits(ctx, tx, shopID, ln)
if err != nil {
return err
}
if len(seqs) < ln.Qty {
// 池子不够:先释放本事务(回滚),走补货再重试
_ = tx.Rollback()
if rErr := r.replenish(ctx, shopID, ln); rErr != nil {
return rErr
}
return &retryableError{errors.New("pool drained, replenished")}
}
// 顺序固定:先 DELETE units,再 INSERT reserved(见 5.3)
if err := deleteUnits(ctx, tx, shopID, ln, seqs); err != nil {
return err
}
if err := insertReserved(ctx, tx, shopID, token, ln); err != nil {
return err
}
}
return tx.Commit()
}
func grabUnits(ctx context.Context, tx *sql.Tx, shopID uint64, ln Line) ([]uint32, error) {
const q = `/* conn_tag:checkout_reserve */
SELECT unit_seq FROM reservation_units
WHERE shop_id = ? AND inventory_item_id = ? AND inventory_group_id = ?
ORDER BY unit_seq
LIMIT ?
FOR UPDATE SKIP LOCKED`
rows, err := tx.QueryContext(ctx, q, shopID, ln.ItemID, ln.GroupID, ln.Qty)
if err != nil {
return nil, err
}
defer rows.Close()
seqs := make([]uint32, 0, ln.Qty)
for rows.Next() {
var s uint32
if err := rows.Scan(&s); err != nil {
return nil, err
}
seqs = append(seqs, s)
}
return seqs, rows.Err()
}
func deleteUnits(ctx context.Context, tx *sql.Tx, shopID uint64, ln Line, seqs []uint32) error {
ph := strings.TrimSuffix(strings.Repeat("?,", len(seqs)), ",")
q := fmt.Sprintf(`/* conn_tag:checkout_reserve */
DELETE FROM reservation_units
WHERE shop_id = ? AND inventory_item_id = ? AND inventory_group_id = ?
AND unit_seq IN (%s)`, ph)
args := []any{shopID, ln.ItemID, ln.GroupID}
for _, s := range seqs {
args = append(args, s)
}
res, err := tx.ExecContext(ctx, q, args...)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if int(n) != len(seqs) {
// 理论上不可能:我们持有这些行的锁。出现即说明有旁路写入,必须告警。
return fmt.Errorf("unit delete mismatch: want %d got %d", len(seqs), n)
}
return nil
}
func insertReserved(ctx context.Context, tx *sql.Tx, shopID uint64, token [16]byte, ln Line) error {
const q = `/* conn_tag:checkout_reserve */
INSERT INTO reserved_quantities
(shop_id, reservation_token, inventory_item_id, inventory_group_id,
quantity, expires_at, state)
VALUES (?, ?, ?, ?, ?, DATE_ADD(NOW(3), INTERVAL ? SECOND), 0)
ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity)`
_, err := tx.ExecContext(ctx, q, shopID, token[:], ln.ItemID, ln.GroupID, ln.Qty, holdSeconds)
return err
}
7.4 补货:数据库级 singleflight
// replenish 用一行的排他锁做单飞,保证同一 item/location 只有一个补货者。
// 其它并发调用会阻塞在这把锁上(这正是我们要的:等,而不是一起冲)。
func (r *Reserver) replenish(ctx context.Context, shopID uint64, ln Line) error {
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
// ① 抢补货权。锁不到就等(有 innodb_lock_wait_timeout 兜底)。
// 第一个进来的补完就提交,后面的拿到锁时池子已经满了,会直接空转返回。
const lockQ = `/* conn_tag:pool_replenish */
SELECT last_replenished_at FROM pool_replenish_locks
WHERE shop_id = ? AND inventory_item_id = ? AND inventory_group_id = ?
FOR UPDATE`
var last time.Time
if err := tx.QueryRowContext(ctx, lockQ, shopID, ln.ItemID, ln.GroupID).Scan(&last); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 首次:插入锁行(并发下靠主键冲突收敛,冲突方重试即可)
if _, e := tx.ExecContext(ctx,
`INSERT IGNORE INTO pool_replenish_locks
(shop_id, inventory_item_id, inventory_group_id) VALUES (?,?,?)`,
shopID, ln.ItemID, ln.GroupID); e != nil {
return e
}
return tx.Commit()
}
return err
}
// ② 读台账 + 当前池量 + 已预留量
var available, inPool, held int
if err := tx.QueryRowContext(ctx, `/* conn_tag:pool_replenish */
SELECT available FROM inventory_ledger
WHERE shop_id=? AND inventory_item_id=? AND inventory_group_id=?`,
shopID, ln.ItemID, ln.GroupID).Scan(&available); err != nil {
return err
}
if err := tx.QueryRowContext(ctx, `/* conn_tag:pool_replenish */
SELECT COUNT(*), COALESCE(MAX(unit_seq), -1) FROM reservation_units
WHERE shop_id=? AND inventory_item_id=? AND inventory_group_id=?`,
shopID, ln.ItemID, ln.GroupID).Scan(&inPool, new(int)); err != nil {
return err
}
if err := tx.QueryRowContext(ctx, `/* conn_tag:pool_replenish */
SELECT COALESCE(SUM(quantity),0) FROM reserved_quantities
WHERE shop_id=? AND inventory_item_id=? AND inventory_group_id=? AND state=0`,
shopID, ln.ItemID, ln.GroupID).Scan(&held); err != nil {
return err
}
// ③ 计算要补多少:不能超过台账剩余,也不能超过池上限
realRemaining := available - held - inPool
if realRemaining <= 0 {
return tx.Commit() // 真的没货了,不是池空
}
toAdd := min(realRemaining, poolCap-inPool)
if toAdd <= 0 {
return tx.Commit() // 池已满,说明别人刚补过
}
// ④ 批量插入(一条 INSERT,不要循环)
var sb strings.Builder
sb.WriteString(`/* conn_tag:pool_replenish */
INSERT IGNORE INTO reservation_units
(shop_id, inventory_item_id, inventory_group_id, unit_seq) VALUES `)
args := make([]any, 0, toAdd*4)
base := time.Now().UnixNano() % 1e6 // 简化示例:真实实现应基于 MAX(unit_seq)+1 分配
for i := 0; i < toAdd; i++ {
if i > 0 {
sb.WriteByte(',')
}
sb.WriteString("(?,?,?,?)")
args = append(args, shopID, ln.ItemID, ln.GroupID, uint32(base)+uint32(i))
}
if _, err := tx.ExecContext(ctx, sb.String(), args...); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `/* conn_tag:pool_replenish */
UPDATE pool_replenish_locks SET last_replenished_at = NOW(3)
WHERE shop_id=? AND inventory_item_id=? AND inventory_group_id=?`,
shopID, ln.ItemID, ln.GroupID); err != nil {
return err
}
return tx.Commit()
}
func isRetryable(err error) bool {
var re *retryableError
if errors.As(err, &re) {
return true
}
var me *mysql.MySQLError
if errors.As(err, &me) {
return me.Number == errDeadlock || me.Number == errLockWait
}
return false
}
type retryableError struct{ error }
func min(a, b int) int { if a < b { return a }; return b }
关于 unit_seq 分配:上面示例用了时间戳取模,这是简化写法,真实实现应该在同一事务里 SELECT MAX(unit_seq) 后顺序分配(因为已经持有补货锁,不存在竞争)。之所以在示例里刻意留一个 INSERT IGNORE,是提醒你:任何"生成主键"的地方都要考虑冲突,能靠数据库约束兜住的,就别靠代码逻辑兜。
7.5 过期回收(原文未展开,但生产必需)
预留是有 TTL 的。TTL 到了而没有 claim,单元必须还回池子。两种做法:
A. 惰性回收:在 reserve 发现池空时,顺手清理过期记录。优点是零额外任务;缺点是清理时机不确定,冷门 SKU 的僵尸预留可能长期不回收。
B. 后台批量回收(推荐,配合 A 使用):
-- 小批量、有序、限流。切忌一条 SQL 扫全表。
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
/* conn_tag:reservation_reaper */
SELECT shop_id, reservation_token, inventory_item_id, inventory_group_id, quantity
FROM reserved_quantities
WHERE state = 0 AND expires_at < NOW(3)
ORDER BY expires_at
LIMIT 200
FOR UPDATE SKIP LOCKED; -- 回收任务多实例并行也安全
-- 对每条:DELETE reserved_quantities,然后把 quantity 个单元还回池(受 poolCap 约束,
-- 超出上限的部分直接丢弃即可,因为台账才是真相,池只是投影)
COMMIT;
注意最后那句注释——回收时如果池已满,多出来的单元直接丢弃是安全的。因为池只是台账的投影,下次补货会重新从台账计算。这个"可丢弃"性质是有界池设计的又一个红利:它让回收逻辑变得极其简单,不需要精确守恒。
7.6 对账(必须有)
-- 每小时跑一次,任何一行有输出都应该告警
SELECT l.shop_id, l.inventory_item_id, l.inventory_group_id,
l.available,
COALESCE(p.cnt, 0) AS in_pool,
COALESCE(r.held, 0) AS held
FROM inventory_ledger l
LEFT JOIN (SELECT shop_id, inventory_item_id, inventory_group_id, COUNT(*) cnt
FROM reservation_units
GROUP BY 1,2,3) p USING (shop_id, inventory_item_id, inventory_group_id)
LEFT JOIN (SELECT shop_id, inventory_item_id, inventory_group_id, SUM(quantity) held
FROM reserved_quantities WHERE state = 0
GROUP BY 1,2,3) r USING (shop_id, inventory_item_id, inventory_group_id)
WHERE COALESCE(p.cnt,0) + COALESCE(r.held,0) > l.available; -- 投影超过了真相 = 超卖风险
八、性能优化:参数、索引与观测清单
8.1 观测:先看锁,再看别的
-- ① 当前持有和等待中的行锁(MySQL 8.0,替代旧的 innodb_locks)
SELECT ENGINE_TRANSACTION_ID, OBJECT_NAME, INDEX_NAME,
LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME IN ('reservation_units','reserved_quantities');
-- ② 谁在等谁(死锁定位神器)
SELECT * FROM sys.innodb_lock_waits\G
-- ③ 长事务排行(连接持有时长的直接证据)
SELECT trx_id, trx_state,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
trx_rows_locked, trx_rows_modified,
LEFT(trx_query, 120) AS q
FROM information_schema.innodb_trx
ORDER BY age_sec DESC LIMIT 20;
-- ④ 死锁历史
SHOW ENGINE INNODB STATUS\G -- 看 LATEST DETECTED DEADLOCK 段
-- ⑤ 连接维度:谁占着连接
SELECT USER, HOST, DB, COMMAND, TIME, STATE, LEFT(INFO, 100)
FROM information_schema.PROCESSLIST
WHERE COMMAND != 'Sleep' ORDER BY TIME DESC;
验证锁数量的小实验(这就是 5.1 那个"两把锁"的复现方法):
-- 终端 1
BEGIN;
SELECT unit_seq FROM reservation_units
WHERE shop_id=1 AND inventory_item_id=42 AND inventory_group_id=7
LIMIT 1 FOR UPDATE SKIP LOCKED;
-- 终端 2(不要提交终端 1)
SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME='reservation_units';
用自增主键 + 二级索引时,你会看到 idx_lookup 和 PRIMARY 各一把 X,REC_NOT_GAP;改成复合主键后,只剩 PRIMARY 一把。这个实验五分钟就能做完,比读十篇文章都有用——这也正是原文强调的"小脚本 + 开着第二个终端看锁"。
8.2 参数清单
| 参数 | 建议 | 理由 |
|---|---|---|
transaction_isolation | 全局保持 REPEATABLE-READ,只在预留相关事务上降到 RC | 全局改 RC 影响面太大 |
binlog_format | ROW | RC 的硬性前提 |
innodb_thread_concurrency | 0(不限)或按压测调 | 继承自 5.x 的保守值是常见隐形瓶颈 |
innodb_lock_wait_timeout | 预留路径调到 1~3 秒 | 默认 50s,在高并发下等于把连接钉死 |
innodb_deadlock_detect | 保持 ON | 高争抢下检测开销存在,但比 50s 超时好得多 |
innodb_flush_log_at_trx_commit | 1(金融级)/ 2(可容忍) | 直接影响 W_hold |
sync_binlog | 1 | 同上,和上面一起决定提交成本 |
max_connections | 结合小定律算,别拍脑袋 | 见 6.2 |
ProxySQL mysql-multiplexing | true,但要认清事务会禁用它 | 见 6.3 |
innodb_lock_wait_timeout 这条特别重要:在预留路径上,等 50 秒是完全无意义的——买家早就走了,而这 50 秒里那个连接一直被占着。设成 2 秒,快速失败、快速重试、快速释放连接。这一个参数在连接紧张的系统里往往能立竿见影。
8.3 压测脚本
用 sysbench 自定义 Lua 或者简单的 Go 压测都行。关键是压测指标要包含"连接持有时长",而不只是 QPS 和 P99:
// 压测时必须采集的四个指标
type Metrics struct {
QPS float64 // 吞吐
P99Latency time.Duration
ConnHoldTimeSum time.Duration // ← 这个才是连接池的真实消耗(小定律里的 λ×W)
DeadlockCount int64 // 死锁率是设计健康度的直接信号
}
压测场景至少要覆盖:
- 均匀分布:1000 个 SKU 平均打,验证基线吞吐。
- 单点热点:99% 流量打 1 个 SKU,验证 SKIP LOCKED 的分流效果和池扫描退化点。
- 池抽干:故意把库存设成刚好够,验证内联补货和惊群抑制。
- 多 SKU 购物车 + 乱序:验证排序是否真的消除了死锁(不排序时应该能压出死锁,排序后应该归零——这是最好的回归测试)。
九、灰度切换:影子模式双写怎么做
这是很多团队做存储迁移时最欠缺的一环。Shopify 的做法值得逐字抄:
阶段一:双写,Redis 为真相
reserve 请求
├─► Redis(source of truth,结果返回给用户)
└─► MySQL(影子写,结果只记录不使用)
└─► 比对:MySQL 的结论和 Redis 一致吗?延迟如何?
关键点:因为两套系统都在实时写,所以不存在"存量数据迁移"这件事。Redis 里的在途预留继续被 Redis 履约,MySQL 自己慢慢积累状态。这个设计消灭了迁移里最危险的环节——在途状态的搬运。
我见过太多团队在这一步栽跟头:停机迁移、双写但存量靠脚本导、或者搞一个"迁移中"的中间态。对于短生命周期状态(预留只活几分钟),最优解永远是"双写 + 等旧状态自然过期",根本不需要迁移。
阶段二:切真相源,保留 kill switch
reserve 请求
├─► MySQL(source of truth)
└─► Redis(仍然双写!)
└─► 一旦出事,开关一拨切回 Redis,Redis 有完整视图
注意这里的精髓:切换之后仍然双写。很多人切完就把旧路径删了,结果发现问题时已经无法回滚。保持双写意味着回滚是瞬时的、无损的。
阶段三:按 pod 灰度
从低流量 pod 开始,逐步推到最高流量商家。Shopify 的 pod 架构(每个 pod 是一组完整的、隔离的基础设施)天然就是灰度单元。如果你没有 pod,可以按 shop_id 取模、按地域、按商家等级来切。
阶段四:拆掉双写和 Redis 集群。
整个流程的核心哲学:每一步都可逆,直到你有足够的生产证据证明不需要可逆为止。
十、什么时候不该抄这套方案
这一节比前面所有节都重要。技术文章最大的危害就是让人不假思索地照搬。
❌ 不要抄,如果你的量级根本用不着
单行 + UPDATE ... WHERE qty >= n 在几百 TPS 以内是完全够用的,而且简单得多:没有池、没有补货、没有对账、没有回收任务。上面那套东西大概是 5 倍的代码量和 10 倍的运维复杂度。Shopify 的解法是 Shopify 规模的解法。
判断标准很简单:算一下你最热的 SKU 在峰值时的预留 TPS,跟 1 / W_hold 比。如果差一个数量级以上,别折腾。
❌ 不要抄,如果你的预留和台账本来就不在一个库
这套方案的首要收益是 ACID,性能是附带的。如果你的台账在另一个库/另一个服务,你把预留搬到 MySQL 也换不来原子性,那就只是把 Redis 的性能换成了 MySQL 的复杂度。这种情况下应该先解决"边界"问题(合并、或者上 Saga/TCC),而不是换存储。
❌ 不要抄,如果你的库存单元不可互换
SKIP LOCKED 的前提是"哪一行都行"。如果你的单元有区别(序列号、有效期批次、指定仓位),那你需要的是带条件的选择,SKIP LOCKED 可能跳过恰好符合条件的那一行而返回不符合的。这时候要么在 WHERE 里把条件加足,要么根本不适用。
❌ 不要抄,如果你的公平性要求高
SKIP LOCKED 不保证 FIFO。在极端争抢下,理论上存在某个请求反复被"挤到后面"的可能。对于库存预留这没问题(谁抢到都一样),但如果你想用同样的模式做任务队列(这是 SKIP LOCKED 最常见的另一个用途,比如 Solid Queue、Oban、que),就要评估饥饿风险。
✅ 什么时候该抄
- 你已经有 MySQL 8.0 / PostgreSQL 9.5+;
- 热点行争抢已经是明确瓶颈(锁等待超时在日志里刷屏);
- 资源是可互换的;
- 你愿意为对账和回收写额外的代码。
顺带说一句更普适的判断:原文最后那句"如果你正在为高吞吐互斥去上 Redis、Kafka 或自建协调层,你现有的数据库可能已经够了",值得贴在墙上。过去十年的分布式潮流里,有大量"为了性能引入的中间件",在今天的硬件和数据库特性下已经是纯负债。每引入一个存储系统,你就多了一个一致性边界,而一致性边界是 bug 的主要产地。
十一、十条踩坑清单
SET SESSIONvsSET TRANSACTION。用连接池时,SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED会污染这条连接上后续所有的查询,而这条连接会被归还给池子给别的业务用。必须用事务级设置(Go 的sql.TxOptions{Isolation:...}、JDBC 的Connection#setTransactionIsolation配合归还时重置、Rails 的transaction(isolation: :read_committed))。RC 忘了改
binlog_format=ROW。MySQL 会在写 binlog 时报错,而且往往是在压测通过、上线后才在某条特定语句上炸。上线前检查:SELECT @@binlog_format;。SKIP LOCKED跳不过间隙锁。空表/空池场景下照样阻塞,这就是 5.2 的坑。别以为加了 SKIP LOCKED 就万事大吉。批量预留不排序 → 死锁。购物车
[A,B]和[B,A]必然互撞。排序是一行代码的事,但不做就是线上事故。UNION忘了写ALL。去重会带来隐式排序/哈希,还可能吞掉合法的重复行。代理把带
FOR UPDATE的 UNION 判成只读,路由到从库。从库上执行锁定读要么报错要么静默错误。上线前用SELECT @@hostname或代理的 query log 验证真实路由。expires_at用应用时间算。集群时钟漂移会导致提前/延迟回收。一律用NOW(3)。innodb_lock_wait_timeout保持默认 50 秒。在连接紧张的系统里,这等于把连接钉死 50 秒。预留路径上设 1~3 秒。SQL 注释标签里塞高基数字段。trace id、request id 进了 query digest 会让 ProxySQL 的 digest 表和监控系统一起爆炸。标签只放低基数维度。
只监控 QPS 和延迟,不监控
QPS × 连接持有时长。这是本文第六节的全部主题:你会在所有指标都正常的情况下撞上天花板,然后花几周优化根本不是瓶颈的东西。
再补一条第 11 点(送的):别忘了对账。有界池是投影,投影一定会漂移。没有对账任务的库存系统,超卖只是时间问题。
十二、总结:这次手术真正的启示
把整件事压缩成三句话:
第一,"数据库扛不住"这个判断,是有保质期的。
五年前 MySQL 做不了这个工作负载,是真的。今天能做了,也是真的。SKIP LOCKED(8.0.1)、更好的锁实现、更快的 NVMe、更大的内存,把可行边界推远了。而我们的架构判断,大多是在读到某篇文章的那一年形成的,之后就再也没复查过。同样过期的还有你的 my.cnf——那个 innodb_thread_concurrency 就是活证据。
第二,最贵的瓶颈是你没在测量的那个。
Shopify 花了几周优化查询和锁,而真正的天花板在一段"没人看的代码"持有连接的时长上。这不是他们不专业,这是测量决定了视野:你有延迟监控,就会优化延迟;你有 CPU 监控,就会优化 CPU;你没有"连接持有时长按业务归因"这个指标,就永远看不见连接问题。
推论很实用:当你遇到"所有指标都正常但系统就是上不去"的情况,第一反应不该是继续优化已知的东西,而是去问"我没在测什么"。低 CPU + 高排队 = 一定有一个你没看见的准入闸门(连接、线程、信号量、代理、锁)。
第三,正确性的收益比性能的收益更值得投资。
这次迁移最大的赢面不是"更快了",而是一整类 bug 从可能变成不可能。Redis 方案里那两种翻车顺序,无论你写多少补偿代码,都只是降低概率;搬进同一个数据库之后,它们在结构上不存在了。
这两种收益的区别在于:性能优化的收益会随着流量增长被吃掉,而结构性正确的收益是永久的,而且会持续降低你的认知负担——你再也不用在每次半夜告警时怀疑"是不是那两个系统又不一致了"。
最后引用原文里我最喜欢的一句,因为它把这套系统的真实设计目标说透了:
关键不是让预留变快,而是让它成为一个好邻居。
预留和购物车、支付、订单共用一个数据库。一个占满连接或长时间持锁的系统,会危及所有其它系统。真正的门槛从来不是"我这个模块能跑多快",而是"我在跑得快的同时,有没有让数据库对别人还是健康的"。
这句话适用于你系统里的每一个模块。
参考与延伸阅读
- Shopify Engineering:We replaced Redis with MySQL for inventory reservations—and it scaled(2026-05-12)
- 37signals:Introducing Solid Queue(数据库支撑的任务队列,SKIP LOCKED 的另一个经典用法)
- MySQL 8.0 Reference Manual:Locking Reads(
FOR UPDATE/NOWAIT/SKIP LOCKED) - MySQL 8.0 Reference Manual:InnoDB Locking(record lock / gap lock / next-key lock / supremum)
- OpenTelemetry SQLCommenter 规范(SQL 注释标签的行业标准格式)
- ProxySQL 文档:Multiplexing(哪些语句会关闭多路复用)