编程 Shopify 把 Redis 换成 MySQL 深度拆解:SKIP LOCKED、一单元一行与「连接才是真瓶颈」的库存预留架构手术

2026-08-10 13:49:44 +0800 CST views 4

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)把它量化,这是原文没写、但你在自己系统里最用得上的东西。


目录

  1. 背景:超卖这件事为什么这么难
  2. 老方案:Redis 到底输在哪里(不是性能)
  3. 核心概念:SKIP LOCKED 改变了什么
  4. 架构分析:一单元一行 + 有界池 + 补货
  5. 四个关键技术决策的逐条解剖
  6. 真正的瓶颈:连接,不是 CPU
  7. 代码实战:一套可跑的最小实现
  8. 性能优化:参数、索引与观测清单
  9. 灰度切换:影子模式双写怎么做
  10. 什么时候不该抄这套方案
  11. 十条踩坑清单
  12. 总结:这次手术真正的启示

一、背景:超卖这件事为什么这么难

做过电商的都懂这个场景:买家点了「立即支付」,从这一刻到支付成功回调,中间隔着几秒到几分钟。这段时间里库存是"薛定谔的"——你既不能真扣(支付可能失败,扣了要回滚,回滚失败就是幽灵库存),又不能不管(不管就会两个人买走同一件最后的货)。

于是几乎所有电商系统都会引入一个中间态:预留(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

支付成功后,你要做两件事:

  1. 在 MySQL 的库存台账里永久扣减 quantity;
  2. 在 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 是可用的并发数据库连接数。这个式子有两个很重要的推论:

  1. 行数 N 变成了可调的并发旋钮。原来你只能优化 W_hold(很难,很快触底),现在你可以直接加 N。
  2. 一旦 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))。

一旦你意识到它是"额度投影",两个必须做的事就浮出水面了(原文没展开,但生产必备):

  1. 对账:池是投影,投影会漂移(进程 crash 在 DELETE 之后 COMMIT 之前?不会,那在一个事务里。但补货逻辑本身可能有 bug、可能被人工干预)。必须有周期对账:池中行数 + 已预留数量 ≤ 台账可售数量
  2. 回收:预留是有 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 的执行路径是:

  1. idx_lookup 上定位记录,给二级索引记录加锁
  2. 回表(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 的额外代价,必须知道

项目RRRC
幻读不防
同一事务两次读一致快照每条语句一个新快照(不可重复读)
间隙锁基本没有
半一致读(semi-consistent read)UPDATE 时有,能减少锁冲突
binlog 格式支持 STATEMENT必须 ROWbinlog_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 而不是 UNIONUNION 会做去重(隐含排序/哈希),不仅慢,还可能在语义上吞掉你本来就想要的重复结果。锁定读场景下永远用 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,0003 ms60
购物车更新(未优化,事务里有多次往返)3,00025 ms75
订单创建(事务里夹了一次外部调用)80060 ms48
各种后台读(没走从库)5,0004 ms20
合计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 TABLESGET_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'*/,会自动带上 applicationcontrolleractiontraceparent 等。
  • 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_ticketsinnodb_thread_sleep_delayinnodb_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_lookupPRIMARY 各一把 X,REC_NOT_GAP;改成复合主键后,只剩 PRIMARY 一把。这个实验五分钟就能做完,比读十篇文章都有用——这也正是原文强调的"小脚本 + 开着第二个终端看锁"。

8.2 参数清单

参数建议理由
transaction_isolation全局保持 REPEATABLE-READ只在预留相关事务上降到 RC全局改 RC 影响面太大
binlog_formatROWRC 的硬性前提
innodb_thread_concurrency0(不限)或按压测调继承自 5.x 的保守值是常见隐形瓶颈
innodb_lock_wait_timeout预留路径调到 1~3 秒默认 50s,在高并发下等于把连接钉死
innodb_deadlock_detect保持 ON高争抢下检测开销存在,但比 50s 超时好得多
innodb_flush_log_at_trx_commit1(金融级)/ 2(可容忍)直接影响 W_hold
sync_binlog1同上,和上面一起决定提交成本
max_connections结合小定律算,别拍脑袋见 6.2
ProxySQL mysql-multiplexingtrue,但要认清事务会禁用它见 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         // 死锁率是设计健康度的直接信号
}

压测场景至少要覆盖:

  1. 均匀分布:1000 个 SKU 平均打,验证基线吞吐。
  2. 单点热点:99% 流量打 1 个 SKU,验证 SKIP LOCKED 的分流效果和池扫描退化点。
  3. 池抽干:故意把库存设成刚好够,验证内联补货和惊群抑制。
  4. 多 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 的主要产地。


十一、十条踩坑清单

  1. SET SESSION vs SET TRANSACTION。用连接池时,SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED 会污染这条连接上后续所有的查询,而这条连接会被归还给池子给别的业务用。必须用事务级设置(Go 的 sql.TxOptions{Isolation:...}、JDBC 的 Connection#setTransactionIsolation 配合归还时重置、Rails 的 transaction(isolation: :read_committed))。

  2. RC 忘了改 binlog_format=ROW。MySQL 会在写 binlog 时报错,而且往往是在压测通过、上线后才在某条特定语句上炸。上线前检查:SELECT @@binlog_format;

  3. SKIP LOCKED 跳不过间隙锁。空表/空池场景下照样阻塞,这就是 5.2 的坑。别以为加了 SKIP LOCKED 就万事大吉。

  4. 批量预留不排序 → 死锁。购物车 [A,B][B,A] 必然互撞。排序是一行代码的事,但不做就是线上事故。

  5. UNION 忘了写 ALL。去重会带来隐式排序/哈希,还可能吞掉合法的重复行。

  6. 代理把带 FOR UPDATE 的 UNION 判成只读,路由到从库。从库上执行锁定读要么报错要么静默错误。上线前用 SELECT @@hostname 或代理的 query log 验证真实路由。

  7. expires_at 用应用时间算。集群时钟漂移会导致提前/延迟回收。一律用 NOW(3)

  8. innodb_lock_wait_timeout 保持默认 50 秒。在连接紧张的系统里,这等于把连接钉死 50 秒。预留路径上设 1~3 秒。

  9. SQL 注释标签里塞高基数字段。trace id、request id 进了 query digest 会让 ProxySQL 的 digest 表和监控系统一起爆炸。标签只放低基数维度。

  10. 只监控 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 ReadsFOR UPDATE / NOWAIT / SKIP LOCKED
  • MySQL 8.0 Reference Manual:InnoDB Locking(record lock / gap lock / next-key lock / supremum)
  • OpenTelemetry SQLCommenter 规范(SQL 注释标签的行业标准格式)
  • ProxySQL 文档:Multiplexing(哪些语句会关闭多路复用)

推荐文章

Python 基于 SSE 实现流式模式
2025-02-16 17:21:01 +0800 CST
全新 Nginx 在线管理平台
2024-11-19 04:18:33 +0800 CST
Go 接口:从入门到精通
2024-11-18 07:10:00 +0800 CST
JavaScript数组 splice
2024-11-18 20:46:19 +0800 CST
程序员茄子在线接单