打破传统:用 MySQL 替换 Redis 解决库存预留问题
Shopify 最近更新了一篇文章,讲了他们如何使用 MySQL 替换 Redis 实现库存预留(Inventory Reservation)。我进行了一番阅读,并结合自己的实验做了一些分析。
一、传统库存模型的问题
比如用 Redis + MySQL 的方案:
Redis:
stock:iphone = 1000用户下单后:
DECR stock:iphoneMySQL 常见的一种库存设计:
stock(
sku_id PRIMARY KEY,
quantity INT
)扣减库存:
UPDATE stock
SET quantity = quantity - 1
WHERE sku_id = 1 AND quantity > 0;大家自然而然使用了 Redis + MySQL 的方案,因为 Redis 的快 + MySQL 的稳很好适配了超卖保护的需求。
但是 Shopify 还是指出,Redis 模型还是有局限性。
Redis + MySQL:无法原子化
虽然 Redis 可以很好处理并发,但预订信息和库存账本位于两个不同系统中。
比如:
- Redis 扣减库存
- MySQL 创建订单
- Redis 清理 reservation
这几个操作无法封装在一个原子步骤中。虽然概率很小,但 Redis 可能失败、MySQL 可能失败、网络可能失败,于是会出现各种不一致问题,最终只能依赖 MQ、对账任务、补偿逻辑来实现最终一致性,但并不是强一致性。
multi-location awareness
此外,Shopify 还指出,Redis 模型不具备「multi-location awareness」,我的理解是,真实的库存系统不只有一个数字,还有仓库位置等其他维度,但 Redis 说到底“只会存key”,擅长做一个高速计数器,并不擅长复杂调度,于是增加了维护成本。
与之相比,MySQL 天然适合这种复杂调度,因此 Shopify 在思考是否可以把 reservation 也收敛回 MySQL ,这样,库存、reservation、订单都可以封装在同一个事务中。
代价是牺牲部分吞吐,但对电商系统来说,库存错误的业务成本,比 TPS 要贵得多,超卖退款、商家投诉、财务对帐、用户体验,这些远比少几千 TPS 严重。牺牲部分吞吐,换来了强一致性、更低的系统复杂度、更容易对账。
二、核心思路:库存=可分配资源的集合
传统库存模型:
CREATE TABLE stock (
id INT PRIMARY KEY,
quantity INT NOT NULL
);每次扣减库存:
UPDATE stock
SET quantity = quantity - 1
WHERE id = 1 AND quantity > 0;这样在高并发下有一个问题,所有请求都会竞争同一行,大量连接会等待行锁。
我在自己的电脑上做了一个小实验,50并发时,tps为9700,200并发时下降到3483,500并发时下降到只有521了。SHOW ENGINE INNODB STATUS\G看:
------- TRX HAS BEEN WAITING 0 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `bench`.`stock` trx id 194375 lock_mode X locks rec but not gap waiting确实都在等待行锁。
此外,和 Redis 的问题一样,这个例子本质还是只操作一个数字,实际的库存系统非常复杂,所以在此之上还要做很多业务逻辑。
Shopify 利用 FOR UPDATE SKIP LOCKED进行了新的设计:
stock_unit (
id BIGINT PRIMARY KEY,
sku_id,
status TINYINT -- 0=可用 1=已预留
)一行就是一个库存单元,1000个库存就是1000条记录,然后在扣库存时:
- 预留库存
SELECT id
FROM stock_unit
WHERE sku_id = 1 AND status = 0
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;SKIP LOCKED很关键。如果只有FOR UPDATE,那某一行被锁定时,后续事务会进入等待。有了SKIP LOCKED,则会直接跳过已经锁定的记录,继续寻找下一批可用库存,这样多个worker可以并行消费不同库存单元,不再排队等待同一把锁。
- 标记 reservation
UPDATE stock_unit
SET status = 1
WHERE id IN (...);- 事务提交
三、其他关键优化
1. 有限库存池
一个商品有50万库存,当然不能直接 insert 50万条记录,而是维护一个有限大小的 reservation pool,比如1000条,被消耗后,异步补充。这个上限不能太小,不然突发情况来不及补充库存,可能出现库存不足;不能太大,因为要保持表结构紧凑,让SKIP LOCKED不至于扫描太久。
2. 复合主键优化
如果只使用自增 ID 作为主键,每次 reservation 时会对应两个行锁。
例如:
CREATE TABLE inventory_reservation (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
shop_id BIGINT,
item_id BIGINT,
location_id BIGINT,
status TINYINT,
...
);此时业务查询通常会是:
SELECT id
FROM inventory_reservation
WHERE shop_id = ?
AND item_id = ?
AND location_id = ?
AND status = 0
LIMIT 1
FOR UPDATE SKIP LOCKED;为了支持这个查询,数据库通常还需要建立二级索引:
INDEX idx_lookup(shop_id, item_id, location_id, status)这样,InnoDB 的数据结构里:
- 主键索引(PRIMARY KEY)是聚簇索引(clustered index)
- 其它索引都是二级索引(secondary index)
执行查询时:
先扫描 secondary index
再回表到 clustered index于是一次 reservation 会涉及:
- secondary index lock
锁定idx_lookup中的索引记录。
- clustered index lock
锁定对应的真实数据行。
也就是说,一次 reservation,需要两次索引锁操作。
于是,Shopify 使用复合主键,直接把业务筛选条件放入 PRIMARY KEY:
PRIMARY KEY (
shop_id,
item_id,
location_id,
id
)这样,
WHERE shop_id = ?
AND item_id = ?
AND location_id = ?可以直接命中 clustered index,不再需要secondary index -> clustered index。于是,每次 reservation 只需要命中一次 clustered index。
在高频锁竞争场景下,锁管理本身就是成本。
四、这个方案并不是”更快“
相比UPDATE stock SET quantity = quantity - 1这种简单路径,Shopify的方案事务更复杂、数据量更大、存在扫描成本、需要维护 reservation pool,并不是一种“更快”的方案,而是更适合他们业务的“更可扩展、更强一致”的方案。