Shopify 用 MySQL + SKIP LOCKED 替代 Redis 做库存预留——一行 SQL 如何撑住百万级并发

2026-05-22 29 预计阅读时间:1 分钟
来源:oschina.net AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:12 分钟

买家点下"完成购买"的那几秒,库存预留必须绝对准确:多卖一件,两个订单撞上同一库存,商家被迫取消订单、承担客服成本;少卖一件,页面显示有货却下单失败,白白丢掉一笔成交。在 Shopify 的量级下,这个"几秒窗口"被放大到每秒数万次并发请求,任何锁争用或网络抖动都可能让整条链路崩塌。

Shopify 的工程团队最终做了一个反直觉的决定:把 Redis 撤掉,改用 MySQL 做库存预留的核心存储。不是因为他们不喜欢 Redis,而是因为 Redis 在这个场景下暴露了一个结构性短板——它无法在水平扩展时提供真正的行级原子性。而 MySQL 的 SKIP LOCKED,配合精心设计的复合主键,反而成了更合适的武器。

为什么 Redis 在这个场景撑不住

Redis 的原子操作(SETNXINCR、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.0SKIP 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 好",而是:当你发现一个通用组件在特定场景下产生了结构性摩擦,应该回到问题本身,看有没有更匹配的原子性模型。行级锁 + 跳过争用,恰好是库存预留这个问题的最优解形状。


相关推荐