目录

打破传统:用 MySQL 替换 Redis 解决库存预留问题

Shopify 最近更新了一篇文章,讲了他们如何使用 MySQL 替换 Redis 实现库存预留(Inventory Reservation)。我进行了一番阅读,并结合自己的实验做了一些分析。

一、传统库存模型的问题

比如用 Redis + MySQL 的方案:

Redis:
stock:iphone = 1000

用户下单后:

DECR stock:iphone

MySQL 常见的一种库存设计:

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 可以很好处理并发,但预订信息和库存账本位于两个不同系统中。

比如:

  1. Redis 扣减库存
  2. MySQL 创建订单
  3. 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条记录,然后在扣库存时:

  1. 预留库存
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可以并行消费不同库存单元,不再排队等待同一把锁。

  1. 标记 reservation
UPDATE stock_unit
SET status = 1
WHERE id IN (...);
  1. 事务提交

三、其他关键优化

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 会涉及:

  1. secondary index lock

锁定idx_lookup中的索引记录。

  1. 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,并不是一种“更快”的方案,而是更适合他们业务的“更可扩展、更强一致”的方案。