买家点下"完成购买"的那几秒,库存预留必须绝对准确:多卖一件,两个订单撞上同一库存,商家被迫取消订单、承担客服成本;少卖一件,页面显示有货却下单失败,白白丢掉一笔成交。在 Shopify 的量级下,这个"几秒窗口"被放大到每秒数万次并发请求,任何锁争用或网络抖动都可能让整条链路崩塌。
Shopify 的工程团队最终做了一个反直觉的决定:把 Redis 撤掉,改用 MySQL 做库存预留的核心存储。不是因为他们不喜欢 Redis,而是因为 Redis 在这个场景下暴露了一个结构性短板——它无法在水平扩展时提供真正的行级原子性。而 MySQL 的 SKIP LOCKED,配合精心设计的复合主键,反而成了更合适的武器。
为什么 Redis 在这个场景撑不住
Redis 的原子操作(SETNX、INCR、Lua 脚本)在单实例上确实快且安全。但 Shopify 的规模要求多分片、多实例并行处理,问题立刻出现:
- 跨分片无事务:一个 SKU 的库存预留如果分布在多个 Redis 分片上,Lua 脚本无法跨分片原子执行,要么接受不一致,要么退回单分片——后者意味着热点 SKU 全部压在一个实例上,水平扩展失效。
- 乐观锁的 ABA 问题:用
WATCH+MULTI实现的乐观锁在高并发下频繁冲突,重试成本飙升,吞吐反而下降。 - 持久化与一致性代价:Redis 的 RDB/AOF 在故障恢复时可能丢数据,而库存预留丢一条记录就意味着超卖。
Shopify 需要的是:每个 SKU 的预留操作在逻辑上只争用那一行记录,其他 SKU 完全不受影响,且争用方不需要阻塞等待——拿不到锁就立刻跳过,让下一个请求处理。
SKIP LOCKED:不等的锁,才是好锁
MySQL 8.0 引入的 SKIP LOCKED 是这个方案的核心。它的语义非常精确:
对选中的行加锁,如果某行已经被其他事务锁住,不等待、不报错,直接跳过该行,返回剩余未被锁住的行。
这和 NOWAIT(锁住就报错)或默认行为(锁住就阻塞等待)完全不同。在库存预留场景下,它的效果是:
- 事务 A 正在处理 SKU=1001 的预留,持有该行锁。
- 事务 B 同时也要预留 SKU=1001,
SKIP LOCKED让 B 直接跳过这行,返回空结果——B 立刻知道"这个 SKU 正在被处理,我换一个策略"。 - 事务 C 要预留 SKU=1002,完全不受 A 的影响,正常拿到锁并处理。
争用被压缩到同一 SKU 的同一行上,不同 SKU 之间零干扰。 这才是真正的水平扩展友好。
复合主键设计:把争用面压到最小
Shopify 的库存预留表不是简单的 (sku_id) 主键。他们使用复合主键,把预留记录和 SKU 的关系在物理存储上对齐,确保同一 SKU 的所有预留记录在 InnoDB 中存储在相邻位置,减少页分裂和锁范围扩散。
一个典型的设计思路:
CREATE TABLE inventory_reservations (
sku_id BIGINT NOT NULL,
reservation_id BIGINT NOT NULL,
quantity INT NOT NULL,
status ENUM('pending', 'confirmed', 'released') NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
PRIMARY KEY (sku_id, reservation_id), -- 复合主键,sku_id 在前
INDEX idx_expires (status, expires_at) -- 用于过期清理
) ENGINE=InnoDB;
sku_id 放在复合主键第一位,这是关键。InnoDB 的聚簇索引按主键顺序存储数据,这意味着同一个 sku_id 的所有预留记录物理上紧挨在一起。当事务用 WHERE sku_id = ? 查询并加锁时,InnoDB 只需要锁定极少的索引页,不会波及相邻 SKU 的记录。
如果主键是 (reservation_id, sku_id),同一 SKU 的记录会散布在不同页上,锁范围和 I/O 都会显著放大。
完整的预留流程:一条 SQL 撑住核心逻辑
下面是一个可以直接改造使用的库存预留流程,覆盖查询、预留、确认、过期四个阶段。
1. 查询可用库存并预留
-- 事务开始
START TRANSACTION;
-- 查询 SKU 的可用库存,跳过正在被其他事务处理的行
SELECT available_qty
FROM inventory_stock
WHERE sku_id = 1001
FOR UPDATE SKIP LOCKED;
-- 如果返回空,说明该 SKU 正在被其他事务锁定处理,直接放弃或走降级逻辑
-- 如果返回了可用数量且足够,插入预留记录
INSERT INTO inventory_reservations
(sku_id, reservation_id, quantity, status, expires_at)
VALUES
(1001, 20250712001, 2, 'pending', NOW() + INTERVAL 10 MINUTE);
-- 扣减可用库存
UPDATE inventory_stock
SET available_qty = available_qty - 2
WHERE sku_id = 1001 AND available_qty >= 2;
-- 如果 UPDATE 的 affected_rows = 0,说明并发下库存已被其他人扣完
-- 回滚事务,返回"库存不足"
COMMIT;
FOR UPDATE SKIP LOCKED 在这里做了两件事:读取当前未被锁住的库存值,同时给该行加写锁防止其他事务同时修改。如果行已被锁住,直接跳过——不阻塞、不等待。
2. 确认预留(买家完成支付)
UPDATE inventory_reservations
SET status = 'confirmed'
WHERE sku_id = 1001 AND reservation_id = 20250712001 AND status = 'pending';
3. 过期清理(买家放弃或超时)
定期跑一个清理任务,把超时的预留释放回库存:
-- 在独立事务中执行
START TRANSACTION;
-- 找出所有已过期的 pending 预留,跳过正在被处理中的行
SELECT sku_id, reservation_id, quantity
FROM inventory_reservations
WHERE status = 'pending' AND expires_at < NOW()
FOR UPDATE SKIP LOCKED;
-- 对每条过期记录:
-- 1. 释放库存
UPDATE inventory_stock
SET available_qty = available_qty + quantity
WHERE sku_id = ?;
-- 2. 标记预留为已释放
UPDATE inventory_reservations
SET status = 'released'
WHERE sku_id = ? AND reservation_id = ? AND status = 'pending';
COMMIT;
清理任务本身也用 SKIP LOCKED,避免和正在处理的预留事务互相阻塞。
4. 用 Python 封装一个预留函数
import pymysql
from datetime import datetime, timedelta
def reserve_inventory(sku_id: int, quantity: int, reservation_id: int):
conn = pymysql.connect(
host='127.0.0.1', port=3306,
user='shop', password='shoppass', db='inventory'
)
try:
with conn.cursor() as cur:
conn.begin()
# 查询可用库存,跳过被锁住的行
cur.execute(
"SELECT available_qty FROM inventory_stock "
"WHERE sku_id = %s FOR UPDATE SKIP LOCKED",
(sku_id,)
)
row = cur.fetchone()
if row is None:
# SKU 正在被其他事务处理,走降级逻辑
conn.rollback()
return {"status": "conflict", "message": "SKU 正在被处理,请重试"}
available = row[0]
if available < quantity:
conn.rollback()
return {"status": "insufficient", "message": f"可用 {available},需要 {quantity}"}
# 插入预留记录
expires_at = datetime.utcnow() + timedelta(minutes=10)
cur.execute(
"INSERT INTO inventory_reservations "
"(sku_id, reservation_id, quantity, status, expires_at) "
"VALUES (%s, %s, %s, 'pending', %s)",
(sku_id, reservation_id, quantity, expires_at)
)
# 扣减库存
cur.execute(
"UPDATE inventory_stock SET available_qty = available_qty - %s "
"WHERE sku_id = %s AND available_qty >= %s",
(quantity, sku_id, quantity)
)
if cur.rowcount == 0:
# 并发下被其他人抢先扣完
conn.rollback()
return {"status": "insufficient", "message": "并发冲突,库存已被扣完"}
conn.commit()
return {"status": "reserved", "reservation_id": reservation_id}
except Exception as e:
conn.rollback()
return {"status": "error", "message": str(e)}
finally:
conn.close()
运行前需要先建好 inventory_stock 表:
CREATE TABLE inventory_stock (
sku_id BIGINT PRIMARY KEY,
available_qty INT NOT NULL,
total_qty INT NOT NULL
) ENGINE=InnoDB;
-- 初始化测试数据
INSERT INTO inventory_stock VALUES (1001, 50, 100);
和 Redis 方案的对比
| 维度 | Redis 方案 | MySQL + SKIP LOCKED |
|---|---|---|
| 单 SKU 并发安全 | Lua 脚本原子操作,安全 | 行锁 + SKIP LOCKED,安全 |
| 多 SKU 并发隔离 | 热点 SKU 压单分片,扩展受限 | 不同 SKU 行锁互不影响,天然隔离 |
| 跨分片事务 | 不支持 | 同实例内 InnoDB 事务天然支持 |
| 故障恢复一致性 | RDB/AOF 可能丢数据 | InnoDB crash-safe,redo log 保证 |
| 争用时的行为 | WATCH 乐观锁重试,冲突多时吞吐下降 | SKIP LOCKED 直接跳过,无重试开销 |
| 运维复杂度 | 需维护 Redis 集群 + MySQL(库存主存储) | 只需 MySQL,减少一个存储组件 |
Redis 不是不好,而是在"库存预留 + 水平扩展 + 强一致"这个约束组合下,MySQL 的行锁模型比 Redis 的分片模型更自然地匹配了问题结构。
落地前需要想清楚的几件事
- MySQL 版本必须 ≥ 8.0:
SKIP LOCKED是 8.0 新增的,5.7 没有。如果还在跑 5.7,要么升级,要么用NOWAIT+ 应用层重试模拟,但后者吞吐会差很多。 - 死锁仍可能发生:
SKIP LOCKED减少了阻塞,但不消除死锁。如果事务 A 先锁 SKU 1001 再锁 SKU 1002,事务 B 先锁 1002 再锁 1001,仍会死锁。预留逻辑尽量只锁一行,或者按固定顺序加锁。 - 过期清理的频率:预留超时后要尽快释放库存。清理任务间隔太长,库存看起来不够实际够用(假性缺货);太短,清理本身成为负担。Shopify 的量级下通常 30 秒到 1 分钟一轮。
- 读库存的分离:
FOR UPDATE SKIP LOCKED是写操作,不适合用于"页面展示库存数量"这种高频读场景。展示读应该走单独的读副本或缓存,预留写走主库。
Shopify 这个案例的核心启示不是"MySQL 比 Redis 好",而是:当你发现一个通用组件在特定场景下产生了结构性摩擦,应该回到问题本身,看有没有更匹配的原子性模型。行级锁 + 跳过争用,恰好是库存预留这个问题的最优解形状。