基于 TCC 思想的分布式库存一致性方案

基于 TCC 思想的分布式库存一致性方案

如果外卖系统的菜品域没有完整的库存能力,就会出现一种尴尬:用户已经成功创建订单,商家备货时却发现菜品售罄,最终只能取消订单。

这个项目的目标不是简单增加一个 stock 字段,而是从 0 到 1 建设一套能够支撑真实交易链路的库存系统:

  • 高并发预扣时不能超卖;
  • 系统超时、重启、消息重复后不能多扣或多返;
  • 即时单和预约单使用不同的库存确认时机;
  • Redis 提供低延迟在线库存,MySQL 提供可恢复、可审计的操作记录;
  • 任意一个步骤失败后,事务都能通过重试、补偿和对账最终收敛。

本文使用 TCC 的业务语义,并结合 Saga 补偿、Reservation 状态机、Transactional Outbox 和 Redis 幂等 Fence,完整拆解这套方案如何落地。

文中的表名、Topic 名和部分字段经过抽象,重点是说明设计思想和故障恢复方式。

1. 整体架构

flowchart LR
    Order["订单服务"] -->|"1. Reserve请求"| StockAPI["StockSvr API"]

    subgraph StockSvr["StockSvr"]
        StockAPI --> Reservation["Reservation Service"]
        Reservation -->|"2. 预写"| MySQLDB[("Stock MySQL
Reservation / Outbox
Snapshot / Ledger / Inbox")] Reservation --> Executor["Operation Executor"] RecoveryWorker["Reservation Recovery Worker"] --> Executor MySQLDB --> RecoveryWorker Executor -->|"3. EVALSHA"| Lua["Redis Lua"] Lua --> OnlineStock[("Redis在线库存
available / reserved / Fence")] Executor -->|"4. RESERVED + Outbox PENDING"| MySQLDB Publisher["Outbox Publisher"] -->|"5. 发布"| Kafka[("Kafka")] MySQLDB --> Publisher Scheduler["时间轮 / 超时扫描"] --> Reservation end Kafka -->|"6. 消费库存变更事件"| Projector["MySQL Stock Projector"] Projector -->|"7. 本地事务更新库存投影
8. sync_status=SYNCED"| MySQLDB Kafka --> Downstream["订单 / 商家 / 监控等下游"] Reconciler["对账与修复任务"] --> OnlineStock Reconciler --> MySQLDB

1.1 正常预扣流程

下面是整体架构对应的正常预扣时序。在线预扣在 Reservation 和 Outbox 的本地事务提交后即可返回,MySQL Stock 投影通过 Kafka 异步完成。

sequenceDiagram
    participant O as Order Service
    participant S as StockSvr
    participant M as Stock MySQL
    participant R as Redis
    participant P as Outbox Publisher
    participant K as Kafka
    participant C as MySQL Stock Consumer

    O->>S: 1. Reserve(order, item, dish, qty)
    S->>M: 2. INSERT Reservation(PREPARING)
    M-->>S: inserted

    S->>R: 3. EVALSHA TryLua(operation_id, stock_bucket_id)
    R->>R: available -= qty
reserved += qty
写Fence和op结果 R-->>S: SUCCEEDED + redis_version S->>M: 4. TX: Reservation=RESERVED
sync_status=PENDING
INSERT Outbox(PENDING) M-->>S: commit S-->>O: 5. 预扣成功 P->>M: 6. 读取Redis预扣成功事件
Outbox(PENDING) P->>K: 7. 发布InventoryRedisReserved K-->>P: Broker ACK P->>M: 8. Outbox=SENT K->>C: 9. At-least-once投递 C->>M: 10. TX: INSERT Inbox
UPDATE Stock Snapshot
INSERT Ledger
sync_status=SYNCED M-->>C: commit C-->>K: 11. Commit Offset

这套架构里有两个不同维度的事实源:

  • Redis 是在线可售数量的事实源:下单时是否还有库存,以 Redis Lua 的原子执行结果为准;
  • MySQL 是库存操作记录和状态流转的事实源:记录某张订单对某个菜品做过什么操作、执行到了哪一步,以及如何恢复和审计。

这里要区分两个状态:

  • reservation.status=RESERVED:Redis 在线预扣已经成功,订单可以继续创建;
  • reservation.sync_status=SYNCED:Redis 的变更已经通过 Kafka 落入 MySQL Stock 投影。

不能等 MySQL Stock 异步更新完成后才认为在线预扣成功,否则 Kafka 和 MySQL 投影延迟会进入下单关键路径;也不能用 Outbox 的 SENT 代表下游已经落库,SENT 只代表 Kafka Broker 已确认接收。

2. 业务语义:Try、Confirm、Cancel

一次库存事务以 Reservation 的 id(雪花算法预生成)标识,单个菜品操作使用以下业务唯一键:

order_item_id + dish_id

其中 order_item_id 唯一标识订单内的一条菜品明细,dish_id 标识被操作的菜品。Try 到达后先按 order_item_id + dish_id 反查 Reservation:不存在才创建,已经存在则根据当前状态幂等返回。已经 Cancel 的 Reservation 不能被复活;如果业务重新创建订单明细,应生成新的 order_item_id 和 Reservation。

2.1 Try:预扣库存

Try 阶段要完成两件事:

  1. 判断菜品当前是否有足够的可售库存;
  2. 将库存从 available 转移到 reserved,为当前订单冻结资源。

数量变化为:

available -= quantity
reserved  += quantity

注意,这两个变化必须在同一段 Redis Lua 中完成。不能先 GET available,再由应用执行 DECR,否则并发请求可能同时读到相同余量并一起扣减,造成超卖。

2.2 Confirm:正式确认

Confirm 表示订单已经成功,预扣库存正式转为已售库存。

reserved -= quantity
sold     += quantity

Confirm 不再减少 available,因为库存已经在 Try 阶段从可售池中移出。

不同订单的 Confirm 时机不同:

  • 即时单:消费订单域发布的 OrderCreated 或等价的“订单创建成功”事件后 Confirm;
  • 预约单:消费 OrderCreated 后先从 RESERVED 进入 CREATED_RESERVED,表示订单已经创建但库存仍在预约占用池;到 processing_time 再由时间轮触发 Confirm。

预约单不能过早 Confirm,否则库存会长时间占用并降低商家当前可售率;但也不能完全不预留,否则履约时间到达时可能无货。

2.3 Cancel:释放预扣

Cancel 只处理订单创建失败这一种业务结果。订单域完成创建失败落库后发布 OrderCreatedFailed,库存服务消费该事件并释放 Try 阶段的预扣:

reserved  -= quantity
available += quantity

OrderCanceled 不能触发 Cancel。因为订单一旦创建成功,Try 阶段已经结束,后续用户取消属于退款语义,应进入独立的 Refund 流程。即时单通常执行 sold -> available;预约单如果尚未到 Confirm 时间,则由 Refund 流程释放仍被占用的 reserved,但业务状态仍应记录为 REFUNDED,不能伪装成订单创建失败。

系统可以扫描长时间停留在 RESERVED 的记录,但扫描任务只能告警、补拉订单事件或向订单域核验状态,不能仅凭超时自行执行 Cancel。库存域不能替订单域判断订单创建是否失败。

如果 Cancel 已经完成,晚到的 Confirm 不能重新扣减库存。后续重新下单应创建新的订单明细和 Reservation,不能复用已经 Cancel 的记录。

3. 状态机设计

stateDiagram-v2
    [*] --> PREPARING: 创建Reservation
    PREPARING --> RESERVED: Redis预扣成功
    PREPARING --> REJECTED: 库存不足
    PREPARING --> CANCELING: OrderCreatedFailed

    RESERVED --> CONFIRMING: 即时单OrderCreated
    RESERVED --> CREATED_RESERVED: 预约单OrderCreated
    RESERVED --> CANCELING: OrderCreatedFailed CAS抢占
    CREATED_RESERVED --> CONFIRMING: 到processing_time
    CREATED_RESERVED --> REFUNDING: 预约单退款

    CONFIRMING --> CONFIRMED: Redis确认成功
    CANCELING --> CANCELED: Redis释放成功
    CONFIRMED --> REFUNDING: 订单退款
    REFUNDING --> REFUNDED: Redis回补成功

    CONFIRMING --> CONFIRMING: 失败后重试
    CANCELING --> CANCELING: 失败后重试
    REFUNDING --> REFUNDING: 失败后重试

    REJECTED --> [*]
    CANCELED --> [*]
    REFUNDED --> [*]

核心规则如下:

  • CONFIRMINGCANCELING 只能有一个从 RESERVED 抢占成功;
  • 预约单收到 OrderCreated 后必须进入 CREATED_RESERVED,该状态禁止再进入 CANCELING
  • Cancel 只能由 OrderCreatedFailed 驱动,OrderCanceled 只能进入 Refund;
  • CANCELED 表示订单创建失败后的预扣释放,REFUNDED 表示订单创建成功后的库存回补;
  • CANCELEDREFUNDEDREJECTED 都是终态;
  • 状态更新必须使用 status + version 做 CAS;
  • 执行到一半失败时保留中间态,由 Reservation Recovery Worker 继续推进;
  • 重复请求读取当前状态后返回历史结果,不重新执行完整流程。

例如 Confirm 抢占状态可以使用:

UPDATE inventory_reservation
SET status = 30,
    version = version + 1,
    updated_at = NOW(3)
WHERE id = ?
  AND status = 20
  AND version = ?;

如果影响行数为 0,需要重新读取记录:

  • 当前已经是 CONFIRMED:按幂等成功返回;
  • 当前是 CONFIRMING:说明已有任务执行,当前请求可以等待或返回处理中;
  • 当前是 CANCELING/CANCELED:Confirm 与既有终态冲突;
  • 当前是 REFUNDING/REFUNDED:订单已经进入逆向流程,拒绝 Confirm;
  • 版本变化但仍为 RESERVED:重新获取版本后再竞争。

4. MySQL Schema

MySQL 不承担下单链路的热点库存扣减,而是负责记录库存事务生命周期,并保存 Redis 库存的可审计投影。建议至少包含七类表。

4.1 菜品库存基线表

CREATE TABLE dish_stock_baseline (
    id               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键',
    stock_bucket_id  VARCHAR(64) NOT NULL COMMENT '库存桶ID,贯穿MySQL、Redis和Kafka',
    merchant_id      BIGINT UNSIGNED NOT NULL COMMENT '商家ID',
    dish_id          BIGINT UNSIGNED NOT NULL COMMENT '菜品ID',
    stock_date       DATE NOT NULL COMMENT '商家时区下的营业日',
    timezone         VARCHAR(32) NOT NULL COMMENT '营业日计算时区',
    total_stock      BIGINT NOT NULL COMMENT '商家设置的库存总量基线',
    baseline_version BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '基线版本号,基线调整时递增,用于对账与Redis重建',
    stock_status     TINYINT NOT NULL DEFAULT 1 COMMENT '菜品库存状态:1=enabled, 2=disabled',
    created_at       DATETIME(3) NOT NULL COMMENT '创建时间',
    updated_at       DATETIME(3) NOT NULL COMMENT '更新时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_stock_bucket_id (stock_bucket_id),
    UNIQUE KEY uk_merchant_dish_date (merchant_id, dish_id, stock_date)
) ENGINE=InnoDB;

该表保存商家设置的库存基线和版本,用于审计、对账与 Redis 重建。在线下单不直接锁这张表扣库存,否则热门菜品会形成 MySQL 热点行。

4.2 MySQL Stock 投影表

Redis 是在线数量事实源,MySQL Stock 则异步保存一份可查询、可审计的数量投影:

CREATE TABLE dish_stock_snapshot (
    stock_bucket_id   VARCHAR(64) NOT NULL COMMENT '库存桶ID',
    merchant_id       BIGINT UNSIGNED NOT NULL COMMENT '商家ID',
    dish_id           BIGINT UNSIGNED NOT NULL COMMENT '菜品ID',
    stock_date        DATE NOT NULL COMMENT '营业日',
    total_stock       BIGINT NOT NULL,
    available_stock   BIGINT NOT NULL,
    reserved_stock    BIGINT NOT NULL,
    sold_stock        BIGINT NOT NULL,
    redis_version     BIGINT UNSIGNED NOT NULL COMMENT '已投影的Redis版本',
    updated_at        DATETIME(3) NOT NULL,
    PRIMARY KEY (stock_bucket_id),
    KEY idx_dish_date (merchant_id, dish_id, stock_date)
) ENGINE=InnoDB;

Kafka Consumer 只接受更大的 redis_version,防止旧消息覆盖新库存。该表用于后台查询、审计和对账,不参与在线 Try 的余量判断。

4.3 库存预扣状态表

每个订单明细一条记录:

CREATE TABLE inventory_reservation (
    id              BIGINT UNSIGNED NOT NULL COMMENT '库存事务ID,雪花算法预生成,跨服务/Redis/Kafka统一引用',
    order_id        BIGINT UNSIGNED NOT NULL COMMENT '订单ID,用于按订单聚合查询所有明细',
    order_item_id   BIGINT UNSIGNED NOT NULL COMMENT '订单明细ID,唯一标识订单内一条菜品行;同一订单点两份相同菜品也有不同order_item_id',
    merchant_id     BIGINT UNSIGNED NOT NULL COMMENT '商家ID,用于分片与按商家查询',
    dish_id         BIGINT UNSIGNED NOT NULL COMMENT '菜品ID,定位Redis库存Key',
    stock_bucket_id VARCHAR(64) NOT NULL COMMENT '绑定的每日库存桶,Confirm/Cancel/Refund不得重新计算',
    stock_date      DATE NOT NULL COMMENT '商家时区下的库存营业日',
    quantity        INT UNSIGNED NOT NULL COMMENT '本次预扣数量',
    order_type      TINYINT NOT NULL COMMENT '订单类型:1=instant即时单, 2=scheduled预约单',
    processing_time DATETIME(3) DEFAULT NULL COMMENT '预约单履约时间,到点触发Confirm;即时单为空',
    expire_at       DATETIME(3) NOT NULL COMMENT '预扣关注时间,超时仅告警、补拉事件或向订单域核验,不直接Cancel',
    status          TINYINT NOT NULL COMMENT '事务状态:10=preparing, 20=reserved, 25=created_reserved, 30=confirming, 40=confirmed, 50=canceling, 60=canceled, 70=rejected, 80=refunding, 90=refunded',
    current_operation_id VARCHAR(64) NOT NULL COMMENT '当前Redis动作的幂等ID;Try可由reservation_id确定性生成',
    retry_count     INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前中间状态的恢复次数',
    next_retry_at   DATETIME(3) NOT NULL COMMENT '恢复任务下次执行时间',
    last_error      VARCHAR(1024) DEFAULT NULL COMMENT '最近一次Redis调用错误',
    sync_status     TINYINT NOT NULL DEFAULT 10 COMMENT 'MySQL Stock同步状态:10=pending, 20=synced, 30=retry, 40=dead',
    expected_redis_version BIGINT UNSIGNED DEFAULT NULL COMMENT '当前业务状态期望投影到MySQL Stock的Redis版本',
    synced_redis_version   BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'MySQL Stock已经完成投影的Redis版本',
    version         INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号,配合status做CAS状态转换',
    created_at      DATETIME(3) NOT NULL COMMENT '创建时间',
    updated_at      DATETIME(3) NOT NULL COMMENT '更新时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_order_item_dish (order_item_id, dish_id),
    KEY idx_stock_bucket (stock_bucket_id),
    KEY idx_sync_status (sync_status, updated_at),
    KEY idx_expire_scan (status, expire_at),
    KEY idx_state_retry (status, next_retry_at),
    KEY idx_schedule_scan (status, processing_time),
    KEY idx_order_id (order_id)
) ENGINE=InnoDB;

这张表回答的是:“这张订单对这个菜品的库存事务现在进行到哪一步?”

它不是库存数量表,也不应该使用它的记录数直接回答在线可售库存。

4.4 库存业务流水表

流水表只追加,不原地修改历史记录:

CREATE TABLE inventory_ledger (
    id               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键',
    ledger_id        VARCHAR(64) NOT NULL COMMENT '流水记录ID,全局唯一',
    operation_id     VARCHAR(64) NOT NULL COMMENT '对应的Redis幂等操作ID',
    reservation_id   BIGINT UNSIGNED DEFAULT NULL COMMENT '所属库存事务ID;每日初始化和人工调整可为空',
    stock_bucket_id  VARCHAR(64) NOT NULL COMMENT '所属每日库存桶',
    order_id         BIGINT UNSIGNED DEFAULT NULL COMMENT '订单ID;非订单类库存操作可为空',
    order_item_id    BIGINT UNSIGNED DEFAULT NULL COMMENT '订单明细ID;非订单类库存操作可为空',
    merchant_id      BIGINT UNSIGNED NOT NULL COMMENT '商家ID',
    dish_id          BIGINT UNSIGNED NOT NULL COMMENT '菜品ID',
    action           TINYINT NOT NULL COMMENT '流水动作:1=init, 2=try, 3=confirm, 4=cancel, 5=adjust, 6=refund',
    quantity         INT UNSIGNED NOT NULL COMMENT '本次操作数量',
    available_delta  BIGINT NOT NULL DEFAULT 0 COMMENT '可售库存变化量',
    reserved_delta   BIGINT NOT NULL DEFAULT 0 COMMENT '预扣库存变化量',
    sold_delta       BIGINT NOT NULL DEFAULT 0 COMMENT '已售库存变化量',
    redis_version    BIGINT UNSIGNED DEFAULT NULL COMMENT '执行该操作后Redis返回的库存版本,用于对账',
    created_at       DATETIME(3) NOT NULL COMMENT '创建时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_ledger_id (ledger_id),
    UNIQUE KEY uk_operation_id (operation_id),
    KEY idx_bucket_created (stock_bucket_id, created_at),
    KEY idx_dish_created (merchant_id, dish_id, created_at)
) ENGINE=InnoDB;

三种核心流水的 delta 如下:

Action available_delta reserved_delta sold_delta
Try -quantity +quantity 0
Confirm 0 -quantity +quantity
Cancel +quantity -quantity 0
Refund before Confirm +quantity -quantity 0
Refund after Confirm +quantity 0 -quantity

流水用于审计和对账,也可以结合库存基线重建 Redis。operation_id 唯一键保证同一个 Redis 操作不会重复落账。

4.5 非订单 Operation WAL 表

Try、Confirm、Cancel、Refund 都有 Reservation,可以直接用 PREPARING/CONFIRMING/CANCELING/REFUNDING 作为可扫描的恢复状态,不需要再写一条重复的 WAL。

每日库存桶初始化、商家调库存和对账修复没有 Reservation。只有这些非订单操作需要通用 Operation WAL:先记录要做什么,再调用 Redis

CREATE TABLE inventory_operation_wal (
    id                BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键',
    operation_id      VARCHAR(64) NOT NULL COMMENT '操作ID,作为Redis幂等键,保证同一动作不重复扣减',
    stock_bucket_id   VARCHAR(64) NOT NULL COMMENT '所属库存桶',
    action            TINYINT NOT NULL COMMENT '操作类型:1=init_bucket, 2=adjust, 3=repair',
    status            TINYINT NOT NULL COMMENT 'Redis执行状态:10=init, 20=executing, 30=succeeded, 40=retry, 50=dead',
    request_payload   JSON NOT NULL COMMENT '调用Redis前的操作意图参数',
    result_payload    JSON DEFAULT NULL COMMENT 'Redis返回的执行结果',
    retry_count       INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Redis执行重试次数',
    next_retry_at     DATETIME(3) NOT NULL COMMENT '下次Redis重试时间,指数退避',
    lease_owner       VARCHAR(64) DEFAULT NULL COMMENT '当前持有执行租约的Worker标识',
    lease_expire_at   DATETIME(3) DEFAULT NULL COMMENT '执行租约到期时间,到期可被其他Worker抢占',
    last_error        VARCHAR(1024) DEFAULT NULL COMMENT '最近一次执行错误信息',
    created_at        DATETIME(3) NOT NULL COMMENT '创建时间',
    updated_at        DATETIME(3) NOT NULL COMMENT '更新时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_operation_id (operation_id),
    UNIQUE KEY uk_bucket_action (stock_bucket_id, operation_id),
    KEY idx_retry (status, next_retry_at),
    KEY idx_lease (status, lease_expire_at)
) ENGINE=InnoDB;

非订单操作可以在提交 WAL 后立即执行;后台 Worker 持续扫描 INIT/RETRY,处理进程退出或网络错误留下的任务。

订单操作的 Worker 扫描 Reservation 中间状态,非订单操作的 Worker 扫描这张 WAL 表。两者即使重复执行,Redis operation_id 幂等键也会阻止重复修改。

4.6 Transactional Outbox 与 Consumer Inbox

CREATE TABLE inventory_outbox (
    id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    event_id        VARCHAR(64) NOT NULL COMMENT '全局唯一事件ID',
    aggregate_id    BIGINT UNSIGNED NOT NULL COMMENT 'reservation_id',
    event_type      VARCHAR(64) NOT NULL,
    partition_key   VARCHAR(64) NOT NULL COMMENT '通常使用order_id',
    schema_version  INT UNSIGNED NOT NULL DEFAULT 1,
    payload         JSON NOT NULL,
    status          TINYINT NOT NULL DEFAULT 10 COMMENT '10=pending, 20=sending, 30=sent, 40=retry',
    retry_count     INT UNSIGNED NOT NULL DEFAULT 0,
    next_retry_at   DATETIME(3) NOT NULL,
    created_at      DATETIME(3) NOT NULL,
    sent_at         DATETIME(3) DEFAULT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uk_event_id (event_id),
    KEY idx_publish (status, next_retry_at)
) ENGINE=InnoDB;

Redis 成功后,以下三项必须在同一个 MySQL 本地事务中提交:

  1. Reservation 更新为 RESERVEDsync_status=PENDING
  2. 保存 Redis 返回的 expected_redis_version
  3. Outbox 插入 InventoryRedisReserved 事件,状态为 PENDING

Publisher 读取代表“Redis 预扣成功”的 Outbox(PENDING) 事件并发布 Kafka。Kafka Broker 返回 ACK 后,Publisher 才把 Outbox 更新为 SENT。Publisher 在 ACK 后、更新 Outbox 前崩溃会造成重复消息,因此 MySQL Stock Consumer 还需要一张 Inbox 表:

CREATE TABLE inventory_consumer_inbox (
    event_id       VARCHAR(64) NOT NULL,
    consumer_group VARCHAR(64) NOT NULL,
    consumed_at    DATETIME(3) NOT NULL,
    PRIMARY KEY (event_id, consumer_group)
) ENGINE=InnoDB;

Consumer 在同一个 MySQL Stock 本地事务中插入 Inbox、更新 dish_stock_snapshot、追加 inventory_ledger,最后将 Reservation 的 sync_status 更新为 SYNCED。Inbox 唯一键保证 Kafka 重复投递不会重复修改 MySQL Stock。

5. Redis Schema

5.1 库存数量 Hash

Key: inv:{merchant_id:dish_id:stock_date}:stock

Fields:
  total       100
  available    72
  reserved      8
  sold          20
  version      391
  updated_at   1784982600123

Redis Cluster 中,Lua 涉及的所有 Key 必须在同一个 Slot。这里使用 {merchant_id:dish_id:stock_date} 作为 hash tag:

inv:{10001:90001:2026-07-26}:stock
inv:{10001:90001:2026-07-26}:reservation:100123001
inv:{10001:90001:2026-07-26}:op:op_try_abc
inv:{10001:90001:2026-07-26}:op:op_confirm_abc

5.2 Reservation Fence

仅有每个动作自己的 operation_id 还不够。Try 和 Cancel 是两个不同的操作,如果 Cancel 先执行、Try 后到达,它们各自都可能认为自己是第一次执行。

因此需要增加一条以 reservation_id 为维度的事务栅栏:

Key: inv:{merchant_id:dish_id:stock_date}:reservation:{reservation_id}

Fields:
  phase         RESERVED
  quantity      2
  try_op_id     op_try_abc
  confirm_op_id ""
  cancel_op_id  ""
  refund_op_id  ""
  version       392
  updated_at    1784982600456

phase 只允许单向转换:

NONE -> RESERVED -> CONFIRMED
NONE -> CANCELED
RESERVED -> CANCELED
RESERVED -> REFUNDED
CONFIRMED -> REFUNDED

其中 NONE -> CANCELED 是一条空回滚栅栏:它不修改库存数量,但会阻止晚到的 Try 再次冻结库存。

Try、Confirm、Cancel、Refund 的 Lua 都必须同时检查并更新这条 Fence:

  • Try 发现 phase=CANCELED/CONFIRMED/REFUNDED,直接拒绝执行;
  • Confirm 只有在 phase=RESERVED 时才移动数量;
  • Cancel 发现 phase=NONE,只写 CANCELED Fence,不增加 available;
  • Cancel 发现 phase=RESERVED,才执行 reserved -> available
  • Refund 发现 phase=RESERVED 时执行 reserved -> available,发现 phase=CONFIRMED 时执行 sold -> available,最终统一进入 REFUNDED
  • 任意终态被重复调用,都返回第一次执行结果。

5.3 Redis 操作幂等记录

Key: inv:{merchant_id:dish_id:stock_date}:op:{operation_id}

Fields:
  action       TRY
  result       SUCCEEDED
  quantity     2
  version      392
  operated_at  1784982600456

该记录必须和库存数量在同一段 Lua 中写入。如果服务在 Redis 扣减成功后、更新 MySQL 前崩溃,重试相同 operation_id 时,Lua 会返回第一次结果,不再扣减第二次。

幂等 Key 的 TTL 应覆盖:

订单最长生命周期 + 消息最大重试时间 + 对账修复窗口 + 安全余量

对于审计要求较高的交易,也可以不单独创建大量 Key,而是在同 Slot 的 Hash 中保存近期操作,终态归档后再清理。

5.4 Try Lua 的核心逻辑

下面是简化后的伪代码:

-- KEYS[1] = stock key
-- KEYS[2] = operation idempotency key
-- KEYS[3] = reservation fence key
-- ARGV[1] = quantity
-- ARGV[2] = now
-- ARGV[3] = operation ttl

local previous = redis.call("HGET", KEYS[2], "result")
if previous then
    local version = redis.call("HGET", KEYS[2], "version")
    return {previous, version}
end

local phase = redis.call("HGET", KEYS[3], "phase")
if phase == "CANCELED" or phase == "CONFIRMED" or phase == "REFUNDED" then
    return {"FENCED", redis.call("HGET", KEYS[3], "version") or "0"}
end
if phase == "RESERVED" then
    return {"SUCCEEDED", redis.call("HGET", KEYS[3], "version") or "0"}
end

local available = tonumber(redis.call("HGET", KEYS[1], "available") or "-1")
local quantity = tonumber(ARGV[1])

if available < quantity then
    local version = redis.call("HGET", KEYS[1], "version") or "0"
    redis.call("HSET", KEYS[2],
        "action", "TRY",
        "result", "INSUFFICIENT",
        "quantity", quantity,
        "version", version,
        "operated_at", ARGV[2])
    redis.call("PEXPIRE", KEYS[2], ARGV[3])
    return {"INSUFFICIENT", version}
end

redis.call("HINCRBY", KEYS[1], "available", -quantity)
redis.call("HINCRBY", KEYS[1], "reserved", quantity)
local version = redis.call("HINCRBY", KEYS[1], "version", 1)
redis.call("HSET", KEYS[1], "updated_at", ARGV[2])

redis.call("HSET", KEYS[3],
    "phase", "RESERVED",
    "quantity", quantity,
    "try_op_id", KEYS[2],
    "version", version,
    "updated_at", ARGV[2])

redis.call("HSET", KEYS[2],
    "action", "TRY",
    "result", "SUCCEEDED",
    "quantity", quantity,
    "version", version,
    "operated_at", ARGV[2])
redis.call("PEXPIRE", KEYS[2], ARGV[3])

return {"SUCCEEDED", version}

Confirm 和 Cancel 使用相同模式:

  • 先检查 operation_id 是否已经执行;
  • 再检查 Reservation Fence 是否允许当前阶段执行;
  • 校验 reserved >= quantity
  • 原子修改 Hash 中的数量;
  • 推进 Fence 的 phase
  • 保存操作结果;
  • 返回新的库存版本。

6. Try 完整时序

第 1.1 节已经给出正常成功路径。在线预扣在 Reservation 和 Outbox 的本地事务提交后成立,后续 MySQL Stock 落账不阻塞下单。本节继续拆解每一步的持久化边界和异常恢复方式。

sequenceDiagram
    participant O as Order Service
    participant A as Inventory API
    participant M as MySQL
    participant R as Redis
    participant W as Recovery Worker
    participant P as Outbox Publisher
    participant K as Kafka

    O->>A: Try(order_id, order_item_id, dish_id, qty)
    A->>M: 按order_item_id+dish_id查询
不存在则INSERT Reservation(PREPARING) M-->>A: inserted A->>R: EVALSHA TryLua(operation_id) alt 库存充足 R-->>A: SUCCEEDED + redis_version A->>M: TX: Reservation=RESERVED
sync_status=PENDING
INSERT Outbox(PENDING) M-->>A: commit A-->>O: reserved P->>M: 读取Redis预扣成功事件
Outbox(PENDING) P->>K: 发布InventoryRedisReserved K-->>P: Broker ACK P->>M: Outbox=SENT else 库存不足 R-->>A: INSUFFICIENT A->>M: Reservation=REJECTED M-->>A: updated A-->>O: insufficient stock else Redis结果未知或进程退出 R--xA: timeout W->>M: 扫描Reservation(PREPARING) W->>R: 使用相同operation_id重试 R-->>W: 返回首次执行结果或执行操作 W->>M: 补齐Reservation状态和Outbox end

这里需要特别强调:MySQL 和 Redis 之间不存在一个真正的跨存储原子事务。系统不是通过“同时写成功”保证一致,而是通过以下机制把任何中间状态变成可恢复状态:

  1. 执行 Redis 前,先把 Reservation 以 PREPARING 状态持久化,并保存确定性的 operation_id
  2. Redis 使用 Lua 保证单菜品数量检查和变更原子;
  3. Redis 保存 operation_id 的执行结果,保证同一个动作重复调用不重复扣减;
  4. MySQL 状态机使用 CAS 控制合法状态转换;
  5. 后台任务持续扫描并重试 Reservation 中间状态;
  6. Redis 成功后,在同一 MySQL 本地事务中更新 Reservation 并写 Outbox;
  7. Publisher 收到 Kafka ACK 后把 Outbox 标记为 SENT
  8. MySQL Stock Consumer 使用 Inbox 幂等更新 Snapshot、Ledger 和 Reservation 的同步状态;
  9. 对账任务负责发现并修复长期差异。

6.1 第一步:创建 Reservation

Try 请求到达后:

  1. 根据 order_item_id + dish_id 查询 Reservation,不存在才插入;
  2. 新记录状态为 PREPARING
  3. 保存由 reservation_id 确定性生成的 current_operation_id

这里是单条 INSERT,依赖 MySQL autocommit 即可,不需要显式开启事务,也不需要再插入一条表达相同意图的 Try WAL。

唯一键冲突代表请求重复。此时读取已有 Reservation:

  • RESERVED:返回预扣成功;
  • REJECTED:返回库存不足;
  • PREPARING:触发或等待 Recovery Worker 执行;
  • 已进入终态:返回历史结果,不创建第二次预扣。

6.2 第二步:执行 Redis Lua

API 在正常链路中直接执行 Redis Lua,以减少用户等待时间。后台 Worker 和同步 API 使用完全相同的 Executor。

Redis 返回三类结果:

  • SUCCEEDED:预扣成功;
  • INSUFFICIENT:库存不足;
  • UNKNOWN:超时、连接断开等导致客户端不知道 Redis 是否已执行。

对于 UNKNOWN,不能假设失败后直接再扣一次,也不能直接返还库存。正确做法是使用相同 operation_id 重试,让 Lua 通过 Redis 操作记录返回第一次结果。

6.3 第三步:落 Reservation 状态和 Outbox

Redis 成功后,再开启一个 MySQL 本地事务:

  1. PREPARING -> RESERVED
  2. sync_status 设为 PENDING
  3. expected_redis_version 更新为 Lua 返回版本;
  4. 插入 InventoryRedisReserved Outbox,状态为 PENDING

如果此时 MySQL 失败,Reservation 仍然是 PREPARING。后台 Worker 扫描到该状态后,使用相同 operation_id 重试 Redis,命中操作幂等记录后再补齐 MySQL。

这里由 Reservation 状态机直接承担恢复日志的作用:Redis 成功但 MySQL 未确认时,PREPARING 仍然是一笔可扫描、可恢复的任务。

6.4 第四步:Outbox 发布 Kafka

Outbox Publisher 读取 PENDING/RETRY 中代表“Redis 预扣成功”的 InventoryRedisReserved 事件,把 event_id 作为消息唯一标识发送到 Kafka。

只有收到 Kafka Broker ACK 后,Publisher 才把 Outbox 标记为 SENT。这里的 SENT 只说明 Kafka 已经持久化消息,不代表 MySQL Stock Consumer 已经处理完成。

Publisher 可能在收到 ACK 后、更新 Outbox 前崩溃,因此同一 event_id 可能被重复发送。Kafka 使用 at-least-once 语义,消费端必须幂等。

6.5 第五步:MySQL Stock 异步落账

MySQL Stock Consumer 收到 InventoryRedisReserved 后,在一个本地事务内:

  1. 插入 Inbox;唯一键冲突表示事件已经处理,直接幂等返回;
  2. 按事件中的 delta 更新 dish_stock_snapshot
  3. 追加 Try Ledger;
  4. 推进 Reservation 的 synced_redis_version;只有它不小于 expected_redis_version 时才把 sync_status 更新为 SYNCED
  5. 提交事务后再提交 Kafka Offset。

更新 Snapshot 时需要带 redis_version

UPDATE dish_stock_snapshot
SET total_stock = ?,
    available_stock = ?,
    reserved_stock = ?,
    sold_stock = ?,
    redis_version = ?,
    updated_at = NOW(3)
WHERE stock_bucket_id = ?
  AND redis_version < ?;

库存投影事件携带 Redis 操作后的绝对数量快照,而不只携带 delta。这样即使版本 12 比版本 11 先到,Consumer 也可以直接把 MySQL Stock 推进到版本 12;随后到达的版本 11 会被条件更新拒绝,不会漏算一次 delta。

如果 Consumer 失败,Kafka 会重新投递;如果消息乱序,redis_version 会阻止旧事件覆盖新状态。长期未同步的 Reservation 可以通过 sync_status=PENDING/RETRY 被扫描和告警。

7. 多菜品订单如何处理

单个订单通常包含多个菜品,而 Redis Cluster 无法在不同 Slot 的 Key 上执行一段 Lua。这里有两种选择。

7.1 每个菜品独立预扣,订单级补偿

默认方案是每个菜品独立执行 Try:

flowchart TD
    A["创建订单级库存事务"] --> B["并发预扣Dish A"]
    A --> C["并发预扣Dish B"]
    A --> D["并发预扣Dish C"]
    B --> E{"是否全部成功"}
    C --> E
    D --> E
    E -->|是| F["订单级Try成功"]
    E -->|否| G["订单域落订单创建失败"]
    G --> H["发布OrderCreatedFailed"]
    H --> I["Cancel所有已成功菜品"]

它不是跨菜品瞬时原子,而是 TCC 内部结合 Saga 补偿:

  • 所有菜品 Try 成功,订单级预扣成功;
  • 任意菜品库存不足,库存服务只返回失败结果,不自行决定 Cancel;
  • 订单域确认整单创建失败并发布 OrderCreatedFailed,库存服务再对已经成功的菜品执行 Cancel;
  • Cancel 失败时保留 CANCELING,由 Reservation Recovery Worker 重试直到释放。

7.2 同商家菜品放入同一 Slot

也可以使用 {merchant_id} 作为 hash tag,把同一商家的所有菜品放在同一 Redis Slot,然后用一段 Lua 原子预扣整单菜品。

这种方案原子性更强,但一个热门商家的全部库存会集中到同一个 Redis Slot,容易形成热点。是否采用,需要结合商家流量分布、单订单菜品数和 Redis Cluster 容量评估。

8. Confirm 流程

8.1 即时单

订单创建成功后,订单域发布事件:

{
  "event_id": "evt_order_created_01",
  "event_type": "OrderCreated",
  "schema_version": 1,
  "occurred_at": "2026-07-25T20:30:00.123+08:00",
  "trace_id": "trace_abc",
  "order_id": 90000001,
  "order_type": "INSTANT",
  "merchant_id": 10001,
  "inventory_reservation_ids": [100123001]
}

库存消费者收到消息后:

  1. 根据 order_id 查询订单下所有 Reservation,并校验事件携带的 inventory_reservation_ids
  2. 使用 CAS 把每条 RESERVED 抢占为 CONFIRMING
  3. 在同一 CAS 中写入确定性的 Confirm current_operation_id
  4. Executor 执行 Confirm Lua;
  5. Redis 将 reserved 转为 sold
  6. MySQL 更新为 CONFIRMED 并插入 Confirm Outbox;
  7. MySQL Stock Consumer 异步更新 Snapshot、Ledger 和 sync_status

Kafka 可能重复投递,Confirm 消费必须幂等。重复消息最终会读取到 CONFIRMED 并直接返回成功。

8.2 预约单

预约单 Try 成功后,Reservation 保存 processing_time。收到 OrderCreated 时,库存服务先通过 CAS 把 RESERVED 更新为 CREATED_RESERVED,明确记录订单已经创建成功。时间轮在接近履约时间时再从 CREATED_RESERVED 触发 Confirm。

时间轮只负责“准时触发”,不能成为唯一可靠来源。系统还需要一个补偿扫描任务:

SELECT id
FROM inventory_reservation
WHERE order_type = 2
  AND status = 25
  AND processing_time <= NOW(3)
ORDER BY processing_time
LIMIT 500;

这样即使时间轮分片迁移、进程重启或触发消息丢失,扫描任务仍能补发 Confirm。

9. Cancel 流程

Cancel 只有一个业务入口:订单域发布 OrderCreatedFailed

多菜品 Try 部分失败、订单服务写库失败或订单创建流程超时,都必须先由订单域形成明确的“创建失败”事实,再发布 OrderCreatedFailed。库存服务不能根据局部失败或本地超时自行推断整张订单失败。

长时间没有收到 OrderCreatedOrderCreatedFailed 时,扫描任务只允许:

  1. 告警并记录卡住的 Reservation;
  2. 重放或补拉订单生命周期事件;
  3. 查询订单域的权威状态。

即使查询发现订单创建失败,也应由订单域补发 OrderCreatedFailed,再进入统一 Cancel 消费链路,避免两个领域同时拥有订单最终状态的决策权。

Cancel 的关键不是“把数量加回去”,而是先获得合法的状态转换权:

UPDATE inventory_reservation
SET status = 50,
    version = version + 1,
    updated_at = NOW(3)
WHERE id = ?
  AND status = 20
  AND version = ?;

只有 RESERVED -> CANCELING 成功的任务才能执行 Cancel Lua。CAS 时同时保存确定性的 Cancel current_operation_id。即时单 OrderCreated 触发的 Confirm 与 OrderCreatedFailed 会竞争同一个 RESERVED 状态,因此只有一方可以成功。预约单一旦由 OrderCreated 推进到 CREATED_RESERVED,Cancel 的 CAS 就必然失败,后续逆向只能进入 Refund。

Cancel Lua 使用独立的 operation_id

op_cancel_{reservation_id}

Lua 需要同时检查 Reservation Fence:

  • Fence 为 RESERVED:执行 reserved -= quantityavailable += quantity,再把 Fence 改为 CANCELED
  • Fence 不存在:说明 Try 尚未在 Redis 生效,只写入 CANCELED Fence,不修改任何库存数量;
  • Fence 已经是 CANCELED:直接返回上一次结果;
  • Fence 为 CONFIRMED:拒绝 Cancel,并上报终态冲突。

第二种情况非常重要。Cancel 不能因为没有找到 Try 记录就直接增加 available,否则会产生空回滚,凭空增加库存。Cancel 写入的 CANCELED Fence 还会阻止正在路上或稍后重试的 Try,避免库存事务悬挂。

9.1 为什么 OrderCanceled 必须走 Refund

OrderCanceled 表示订单已经创建成功,已经越过 Try/Cancel 的业务边界。库存服务收到订单取消或退款事件后,通过 CAS 进入 REFUNDING 并保存独立的 Refund operation_id

op_refund_{reservation_id}

Refund 根据库存所处阶段执行回补:

  • 即时单已经 CONFIRMED:执行 sold -= quantityavailable += quantity
  • 预约单处于 CREATED_RESERVED:执行 reserved -= quantityavailable += quantity
  • 已经 REFUNDED:幂等返回首次结果;
  • 退款数量、部分退款和是否允许恢复可售库存,以退款事件和业务策略为准。

Refund 完成后状态进入 REFUNDED,并写入独立的 Refund Ledger 和 Outbox。它和 Cancel 的数量变化可能相似,但业务含义完全不同:Cancel 表示订单没有创建成功,Refund 表示订单创建成功后发生逆向交易。

10. Reservation 状态机如何保证可重入

幂等和可重入不是同一个概念:

  • 幂等:同一个动作执行多次,只产生一次业务效果;
  • 可重入:动作执行到一半失败后,再次进入时能够从持久化状态继续。

10.1 Reservation Recovery Worker

Worker 周期扫描:

SELECT *
FROM inventory_reservation
WHERE status IN (10, 30, 50, 80)
  AND next_retry_at <= NOW(3)
ORDER BY next_retry_at
LIMIT 100
FOR UPDATE SKIP LOCKED;

执行流程:

  1. 根据 Reservation 中间状态判断目标动作;
  2. 读取 current_operation_id
  3. 判断目标动作是否已经完成;
  4. 使用原 operation_id 调用 Redis Lua;
  5. 根据 Redis 返回补齐 Reservation 状态和 Outbox;
  6. 失败则更新 retry_count/next_retry_at/last_error 并指数退避;
  7. 超过阈值后告警,但保留中间状态供人工或修复任务继续处理。

10.2 各故障窗口如何恢复

故障窗口 可观察状态 恢复方式
Reservation 写入失败 MySQL 无记录 整个 Try 失败,订单重试
Reservation 已插入,Redis 未执行 PREPARING Worker 执行 Redis
Redis 已成功,客户端超时 Redis 有 op 记录,MySQL 未完成 相同 operation_id 重试并读取首次结果
Redis 成功,MySQL 状态提交失败 Redis 数量已变,Reservation 仍为中间态 Worker 命中 Redis op 记录并补齐 MySQL
MySQL 状态成功,Kafka 未发送 Outbox PENDING Publisher 重试发送
Kafka 已发送,Outbox 未标记 Kafka 可能重复 使用相同 event_id 重发,消费者通过 Inbox 幂等
Confirm/Cancel/Refund 执行中进程退出 CONFIRMING/CANCELING/REFUNDING Recovery Worker 从中间态继续

所谓可重入,就是每次重试都先查看这些持久化状态,而不是从头盲目执行整段代码。

11. Kafka Topic 与事件设计

11.1 入站事件

库存服务主要消费订单域事件:

Topic Event 用途
order.lifecycle.v1 OrderCreated 即时单触发 Confirm
order.lifecycle.v1 OrderCreatedFailed 创建失败触发 Cancel
order.lifecycle.v1 OrderCanceled / RefundRequested 订单创建后的逆向交易触发 Refund
inventory.schedule.v1 InventoryConfirmDue 预约单到期触发 Confirm

同一订单的事件使用 order_id 作为 Kafka Partition Key,尽量保持订单内顺序。但消费端仍然必须校验状态和版本,不能把正确性完全建立在消息顺序上。

11.2 出站事件

Topic Event 用途
inventory.stock-change.v1 InventoryRedisReserved Redis Try 成功,同步 MySQL Stock
inventory.stock-change.v1 InventoryRedisConfirmed Redis Confirm 成功,同步 MySQL Stock
inventory.stock-change.v1 InventoryRedisCanceled Redis Cancel 成功,同步 MySQL Stock
inventory.stock-change.v1 StockBucketInitialized 每日库存桶初始化
inventory.stock-change.v1 StockBaselineAdjusted 商家调整当日库存
inventory.lifecycle.v1 InventoryReserved Try 成功
inventory.lifecycle.v1 InventoryRejected 库存不足
inventory.lifecycle.v1 InventoryConfirmed Confirm 完成
inventory.lifecycle.v1 InventoryCanceled Cancel 完成
inventory.lifecycle.v1 InventoryOperationDead 自动恢复失败,需要介入

统一事件 Envelope:

{
  "event_id": "evt_inventory_reserved_01",
  "event_type": "InventoryRedisReserved",
  "schema_version": 1,
  "occurred_at": "2026-07-25T20:30:00.456+08:00",
  "producer": "inventory-service",
  "trace_id": "trace_abc",
  "partition_key": "bucket_10001_90001_20260726",
  "data": {
    "order_id": 90000001,
    "merchant_id": 10001,
    "dish_id": 90001,
    "stock_bucket_id": "bucket_10001_90001_20260726",
    "stock_date": "2026-07-26",
    "order_type": "INSTANT",
    "processing_time": null,
    "reservation_id": 100123001,
    "order_item_id": 50000001,
    "action": "TRY",
    "quantity": 2,
    "available_delta": -2,
    "reserved_delta": 2,
    "sold_delta": 0,
    "redis_version": 392,
    "after": {
      "total": 100,
      "available": 72,
      "reserved": 8,
      "sold": 20
    },
    "reason_code": null
  }
}

设计时注意:

  • event_id 全局唯一,用于消费幂等;
  • schema_version 支持事件演进;
  • reservation_idorder_item_id + dish_id 防止旧事件操作其他预扣;
  • MySQL Stock 同步事件按 stock_bucket_id 分区,并携带 redis_version 和操作后的绝对数量;
  • 对订单下游发布的生命周期事件仍按 order_id 分区;
  • Consumer 可以维护 Inbox 表或消费幂等表,避免重复副作用;
  • Kafka 采用 at-least-once 语义,系统设计目标是“允许重复,不能丢失”。

12. 每日库存如何更新

每日库存不能在零点直接执行:

available = total

因为昨天的 Reservation 可能仍在 Confirm 或 Cancel。直接覆盖同一个 Redis Key,会把尚未完成的预扣抹掉,造成库存多卖或少卖。

正确做法是使用按营业日隔离的库存桶

stock_bucket_id = merchant_id + dish_id + stock_date + baseline_version

Redis Key 也携带营业日:

inv:{10001:90001:2026-07-26}:stock

12.1 每日初始化正常流程

sequenceDiagram
    participant J as Daily Stock Job
    participant S as StockSvr
    participant M as Stock MySQL
    participant R as Redis
    participant P as Outbox Publisher
    participant K as Kafka

    J->>S: 1. 初始化次日库存(商家时区)
    S->>M: 2. TX: INSERT baseline
INSERT WAL(INIT_BUCKET) M-->>S: commit S->>R: 3. Lua初始化带日期的库存桶 R->>R: SET total/available
reserved=0, sold=0, version=1 R-->>S: SUCCEEDED S->>M: 4. TX: WAL=SUCCEEDED
INSERT Outbox(StockBucketInitialized) M-->>S: commit P->>K: 5. 发布库存桶初始化事件 K-->>P: Broker ACK P->>M: 6. Outbox=SENT

每天的更新流程是:

  1. 按商家所在时区,在营业日前一天提前生成次日 dish_stock_baseline
  2. Baseline 和 INIT_BUCKET WAL 在同一个 MySQL 事务中提交;
  3. Executor 使用 Lua 初始化新的 Redis Key;
  4. Lua 使用 stock_bucket_id + baseline_version 幂等,重复初始化不会覆盖已发生交易的库存桶;
  5. Redis 初始化成功后写 Outbox,异步建立 MySQL Stock Snapshot 和 Ledger;
  6. 扫描任务持续重试初始化失败的库存桶。

为了防止定时任务遗漏,可以增加懒加载兜底:订单第一次访问不存在的库存桶时,从 MySQL Baseline 发起初始化,但仍然必须走相同 WAL 和 Lua,不能由请求线程直接 SET 一份库存。并发懒加载通过 stock_bucket_id 唯一键和 Lua 幂等收敛。

12.2 即时单和预约单选择哪个库存桶

即时单按商家时区的当前营业日选择库存:

stock_date = merchantBusinessDate(now, timezone)

预约单按 processing_time 所在营业日选择:

stock_date = merchantBusinessDate(processing_time, timezone)

例如用户在 7 月 25 日预订 7 月 27 日的订单,Try 应操作:

inv:{merchant_id:dish_id:2026-07-27}:stock

Reservation 创建时就要保存 stock_bucket_idstock_date。后续 Confirm、Cancel 必须继续操作这个库存桶,不能根据执行当天的日期重新计算,否则跨天 Cancel 会把库存返还到错误日期。

12.3 商家当天调整库存

商家把库存从 100 修改为 120 时,不能直接把 available 设置为 120,因为已经存在 reservedsold

调整库存也需要:

ADJUST WAL -> Redis Lua -> Outbox -> Kafka -> MySQL Stock

Lua 根据期望版本原子计算:

new_total = merchant_input
new_available = max(0, new_total - reserved - sold)
version += 1

例如:

修改前:total=100, available=70, reserved=10, sold=20
修改后:total=120, available=90, reserved=10, sold=20

如果新总量小于 reserved + sold,已经接受的订单不能被撤销:

total=20, reserved=10, sold=20
available=0, deficit=10

系统把可售库存置为 0,并记录 deficit 告警,由商家或运营处理,不能为了满足新配置而取消已经确认的库存。

12.4 旧库存桶何时删除

旧库存桶的生命周期是:

OPEN -> CLOSED -> ARCHIVED
  • OPEN:允许新的 Try、Confirm 和 Cancel;
  • CLOSED:禁止新的 Try,但允许已有 Reservation 继续 Confirm 或 Cancel;
  • ARCHIVED:不存在未完成 Reservation 后,MySQL 归档并给 Redis Key 设置清理 TTL。

零点只负责切换新请求使用的库存桶,不删除旧桶。旧桶必须等 PREPARING/RESERVED/CREATED_RESERVED/CONFIRMING/CANCELING/REFUNDING 全部清零以后才能归档。

13. 对账、修复与 Redis 重建

12.1 对账公式

对于一个菜品,在同一库存基线版本下:

expected_available =
    baseline_total
    + sum(adjust_delta)
    - sum(valid_try_quantity)
    + sum(canceled_quantity)

或者直接根据 Ledger delta 聚合:

expected_available = baseline_total + sum(available_delta)
expected_reserved  = sum(reserved_delta)
expected_sold      = sum(sold_delta)

对账任务按 merchant_id + dish_id 分片,比较 MySQL 期望值和 Redis 实际值。

12.2 在线修复

不能在发现差异后直接 SET available = expected,因为修复期间可能还有在线 Try。

安全修复方式是:

  1. 读取 Redis 当前 version
  2. 根据 MySQL 计算期望数量;
  3. Lua 中再次检查 Redis version 是否未变化;
  4. 版本一致才更新,否则重新计算;
  5. 修复操作本身也生成 operation_id 和 Ledger;
  6. 大批量差异限速处理,避免影响下单链路。

12.3 Redis 整体丢失

如果发生极端故障,需要从 MySQL 重建 Redis:

  1. 暂停受影响库存分片的新 Try,库存安全优先;
  2. 加载 dish_stock_baseline
  3. 聚合已提交 Ledger,恢复 available、reserved 和 sold;
  4. 检查 Reservation 中间状态和未完成的非订单 Operation WAL,逐条恢复;
  5. 完成全量对账;
  6. 小流量恢复写入,再逐步放开。

MySQL 之所以必须保留完整操作记录,就是为了在 Redis 数据损坏、主从回退或误操作后,系统仍然有可审计、可重建的依据。

14. 超卖与少卖分别如何避免

13.1 超卖

超卖通常来自并发检查和扣减不原子,或者重复消息造成重复扣减。

本方案通过以下机制避免:

  • Redis Lua 原子执行“检查 available + 转入 reserved”;
  • operation_id 保证重复 Try 只生效一次;
  • Reservation 唯一键阻止同一订单重复创建预扣;
  • Confirm 和 Cancel 使用 CAS 竞争终态;
  • Redis 故障时失败关闭,不绕过库存继续下单。

13.2 少卖

少卖通常表现为库存被预扣后没有及时释放,Redis 数量长期低于真实可售数量。

本方案通过以下机制避免:

  • Reservation 保存 expire_at,用于发现长时间未收到订单终态事件的记录;
  • 扫描任务负责告警、补拉事件或向订单域核验,但不直接 Cancel;
  • 订单域通过 Outbox 可靠发布 OrderCreatedFailed
  • CANCELING 状态由 Reservation Recovery Worker 持续重试;
  • 多菜品 Try 部分失败后,由订单域发布 OrderCreatedFailed 驱动补偿;
  • MySQL Ledger 与 Redis 定期对账;
  • Redis op 记录解决“实际已执行但调用方误以为失败”的不确定窗口。

15. 性能与容量设计

正常 Try 链路包含:

  1. 一条 MySQL INSERT:写入 Reservation(PREPARING)
  2. 一次 Redis Lua;
  3. 一次 MySQL 本地事务:更新 Reservation=RESERVED 并写 Outbox。

它比单纯执行一次 DECR 更复杂,但换来了完整的故障恢复能力。MySQL Stock 投影通过 Kafka 异步完成,不进入下单返回路径。通过连接池、批量 Outbox 发布、Reservation 分片扫描和合理索引,平均事务耗时可以控制在 50ms 以内。

系统可支撑 10 万级日库存操作,并通过完整的故障恢复与对账机制保障库存一致性。

一致性指标必须给出明确统计口径,推荐同时关注:

  • MySQL 期望库存与 Redis 实际库存一致的校验比例;
  • 超卖订单数;
  • 少卖差异数;
  • P95/P99 差异修复时长;
  • Reservation 中间状态积压量和最老任务年龄;
  • 非订单 Operation WAL 积压量;
  • Outbox 发布延迟和 MySQL Stock 投影延迟;
  • Try/Confirm/Cancel 成功率和 TP99。

“零资损”不能只靠一句结果描述,需要由超卖监控、库存对账、异常补偿和长期业务指标共同证明。

16. 标准 TCC 的正常流程

标准 TCC 是一种由全局事务协调器驱动的两阶段分布式事务方案。虽然业务接口被命名为 Try、Confirm、Cancel,但从协调流程看,它仍然是“准备阶段 + 提交或回滚阶段”。

参与角色通常包括:

  • 事务发起方:开启一个全局事务;
  • 事务协调器:保存全局事务和各分支事务状态;
  • 事务参与者:订单、库存、优惠券、账户等资源服务;
  • 业务资源:各参与者自己的数据库和冻结资源。
sequenceDiagram
    participant B as Business
    participant C as TCC Coordinator
    participant O as Order Participant
    participant I as Inventory Participant
    participant P as Promotion Participant

    B->>C: Begin global transaction(XID)
    C->>O: Try(XID, branch_id)
    O-->>C: Try success
    C->>I: Try(XID, branch_id)
    I-->>C: Try success
    C->>P: Try(XID, branch_id)

    alt 所有Try成功
        P-->>C: Try success
        C->>C: 持久化全局COMMIT决议
        C->>O: Confirm
        C->>I: Confirm
        C->>P: Confirm
        Note over C,P: Confirm失败会持续重试
    else 任意Try失败或超时
        P-->>C: Try failed
        C->>C: 持久化全局ROLLBACK决议
        C->>O: Cancel
        C->>I: Cancel
        C->>P: Cancel
        Note over C,P: Cancel失败会持续重试
    end

每个阶段的职责是:

  1. Try:检查业务条件并预留资源,例如冻结库存、冻结余额,但不完成最终业务提交;
  2. Confirm:全局 Try 全部成功后,协调器通知所有参与者正式提交冻结资源;
  3. Cancel:任意参与者 Try 失败或全局事务超时后,协调器通知已经 Try 的参与者释放资源;
  4. Coordinator Recovery:协调器持久化最终决议,进程重启后继续重试 Confirm 或 Cancel,直到所有分支收敛。

标准 TCC 还要求参与者使用 XID + branch_id 建立分支事务记录,并处理重复调用、空回滚和悬挂问题。只实现三个同名接口,并不自动等于实现了 TCC。

17. 为什么这里不使用标准 TCC

这套库存方案使用了 Try、Confirm、Cancel 的业务语义,但没有直接引入标准 TCC 协调器,主要有以下原因。

16.1 事务生命周期不适合

标准 TCC 更适合生命周期较短、参与者边界明确的业务事务。例如一次下单同时冻结账户、优惠券和库存,所有 Try 在一个短时间窗口内完成,然后由协调器统一做出 Commit 或 Rollback 决议。

预约单的库存可能在下单后很久才到 processing_time 正式确认。如果让全局 TCC 事务和分支上下文维持几个小时,会带来大量长事务状态、超时配置和协调器存储压力,也会让业务取消、改约等状态很难表达。

16.2 库存是独立领域事务

本文的核心一致性边界在库存服务内部:

  • MySQL 保存库存操作生命周期;
  • Redis 保存在线数量;
  • Kafka 接收订单事实变化。

订单创建是否成功已经由订单域自己提交,库存服务根据订单事件推进自己的状态。这里没有一个协调器同时控制订单数据库、Redis 和所有下游资源,也没有让多个参与者共同等待同一个全局决议。

16.3 需要事件驱动和长时间补偿

即时单由 OrderCreated 触发 Confirm,预约单由时间轮触发 Confirm,只有 OrderCreatedFailed 才触发 Cancel。订单创建后的 OrderCanceled 属于逆向交易,由 Refund 流程处理。这些决策来自业务事件和状态,而不是一个全局 TCC Coordinator。

Reservation 状态机、重试和对账更适合表达这种长生命周期、事件驱动的最终一致性流程。它允许订单主链路先完成本域事务,也不会因为库存后续通知或某个下游暂时异常而长期占用一个全局事务上下文。

16.4 降低协调器和框架耦合

引入标准 TCC 除了增加一个协调器,还要求所有参与者实现统一分支协议、全局事务传播、事务日志、恢复任务和防悬挂机制。对于只有库存内部双存储需要收敛的场景,这部分复杂度和收益不一定匹配。

因此这里选择的是:

TCC业务语义
+ 库存领域状态机
+ Reservation Recovery Worker
+ 非订单Operation WAL
+ Transactional Outbox / Consumer Inbox
+ Redis幂等与Fence
+ Kafka事件
+ 补偿与对账

它更接近一个借用 TCC 三阶段语义的状态机驱动 Saga,而不是标准 TCC 分布式事务。

18. 为什么这套方案能够保证最终一致性

最终一致性不表示“过一段时间大概会一致”,而是需要同时具备安全性和活性。

17.1 安全性:错误结果不能发生

系统通过以下不变量保证不会多扣或多返:

  1. 同一个 reservation_id + action 只有一条 MySQL 业务流水;
  2. 同一个 operation_id 在 Redis 只产生一次数量变化;
  3. Try 只能执行 available -> reserved
  4. 即时单的 Confirm 和 Cancel 通过 RESERVED 上的 CAS 竞争订单创建终态;
  5. 预约单收到 OrderCreated 后进入 CREATED_RESERVED,不能再转为 Cancel;
  6. Cancel 只能由 OrderCreatedFailed 驱动,没有看到 Try 时只写 Fence,不能增加 available;
  7. Refund 只能从 CREATED_RESERVEDCONFIRMED 进入,不能伪装成 Cancel;
  8. Cancel 或 Refund 已经形成终态后,迟到 Try/Confirm 会被 Fence 拒绝;
  9. Redis 成功后,Reservation 状态和 Outbox 在一个本地事务中提交;
  10. MySQL Stock Consumer 通过 Inbox 和 redis_version 幂等更新 Snapshot 与 Ledger。

这些约束保证系统即使发生重复消息、请求超时和并发竞争,也不会进入一个业务上非法的数量状态。

17.2 活性:中间状态最终会继续推进

系统通过以下机制保证未完成动作不会永久停留:

  1. 订单操作调用 Redis 前先进入 Reservation 中间状态,非订单操作先写 Operation WAL,未完成操作都可以被扫描出来;
  2. 同步 API 失败后,后台 Worker 使用原 operation_id 重试;
  3. CONFIRMING/CANCELING/REFUNDING 都有明确的恢复入口;
  4. Outbox Publisher 持续重试未发送事件;
  5. 时间轮丢失时,数据库扫描补触发预约单;
  6. 超时 Reservation 会被扫描任务发现并告警、补拉订单事件,但只有 OrderCreatedFailed 能推进 Cancel;
  7. 对账任务发现 Redis 和 MySQL 长期差异并执行版本化修复;
  8. 自动重试超过阈值后进入 Dead 状态并告警,由人工或修复任务继续处理。

在满足“MySQL 和 Redis 最终恢复、重试任务持续运行、永久失败能够被人工处理”这些前提时,每条 Reservation 最终都会进入:

REJECTED / CONFIRMED / CANCELED / REFUNDED

同时 Redis 数量会和 MySQL Ledger 推导出的期望值收敛。这才是本方案最终一致性的完整含义。

19. 空回滚与悬挂问题

虽然它不是标准 TCC,但只要存在 Try 和 Cancel 的异步竞态,就仍然会遇到与 TCC 类似的空回滚和悬挂风险。

18.1 空回滚

典型时序如下:

  1. Try 请求已经发出,但还没有执行到 Redis;
  2. 订单域已经确认创建失败,并发布 OrderCreatedFailed
  3. Cancel 没有找到预扣记录;
  4. 如果 Cancel 直接执行 available += quantity,库存会凭空增加。

这就是空回滚。

本方案的处理方式是:Cancel 发现 Redis Fence 不存在时,不修改数量,只创建:

reservation.phase = CANCELED

重复 Cancel 读取到相同终态后直接返回成功,所以空回滚既不会增加库存,也不会无限报错。

18.2 悬挂

悬挂是空回滚之后的迟到 Try:

  1. Cancel 已经把事务判定为回滚;
  2. 因为网络延迟,原 Try 此时才到达 Redis;
  3. 如果 Try 继续冻结库存,就会出现全局已经取消、资源却被预扣的悬挂状态。

Try Lua 在修改库存前必须读取 Reservation Fence。只要发现 phase=CANCELED,就返回 FENCED,不能再执行 available -> reserved

因此:

Cancel before Try:
    只写CANCELED Fence,不返还数量

Late Try after Cancel:
    看到CANCELED Fence,拒绝预扣

18.3 重复调用和阶段乱序

Fence 之外还需要两层保护:

  • operation_id 解决同一阶段重复调用,例如 Try 重试三次只能预扣一次;
  • MySQL status + version CAS 解决 Confirm 和 Cancel 并发竞争。

Redis Fence 和 MySQL 状态机都采用单向状态转换。两边任何一方暂时落后,都可以通过 Reservation Recovery Worker 和对账补齐,但不能执行逆向状态扭转。

18.4 为什么有三个阶段仍不能称为标准 TCC

判断是否为标准 TCC,关键不在接口名字,而在事务协议:

对比项 标准 TCC 本文方案
最终决议者 全局事务协调器 订单事件、状态机和时间轮
事务标识 XID + branch_id reservation_id + operation_id
参与者 多个统一注册的分支事务 库存领域内部 MySQL、Redis及事件处理
Confirm 时机 全部 Try 成功后立即由协调器触发 即时单事件触发,预约单到 processing_time 触发
Cancel 时机 全局事务失败或超时 仅由订单域 OrderCreatedFailed 触发
恢复中心 Coordinator 持久化全局决议 Reservation Recovery Worker、扫描任务和对账
一致性模型 协调器驱动的业务两阶段提交 状态机驱动的事件最终一致性

所以更准确的描述是:

该方案借用了 TCC 的预留、确认和释放语义,但没有采用全局事务协调器和标准分支事务协议;它本质上是一套基于 Reservation 状态机、Transactional Outbox、Redis 幂等 Fence 和补偿对账实现的最终一致性库存方案。

20. 方案总结

这套库存系统最核心的设计不是 TCC 三个英文单词,而是把分布式事务中的每一个不确定窗口都变成可观察、可重试的状态:

  • Try 用 Redis Lua 保证高并发下不超卖;
  • MySQL Reservation 和 Ledger 保存事务生命周期与审计记录;
  • Reservation 中间状态解决订单类 Redis 操作的故障恢复;
  • 非订单 Operation WAL 负责库存桶初始化、调整和修复;
  • 状态机和 CAS 解决 Confirm、Cancel、Refund 的并发竞争与语义隔离;
  • 只有 OrderCreatedFailed 能触发 Cancel,OrderCanceled 必须进入 Refund;
  • Redis operation_id 解决重复执行和结果未知;
  • Transactional Outbox 和 Consumer Inbox 解决数据库状态、Kafka 与 MySQL Stock 投影的一致性;
  • 时间轮加扫描补偿解决预约单触发丢失;
  • 对账和重建机制兜住双存储的长期一致性。

最终,Redis 负责在线性能,MySQL 负责记录、恢复和审计,状态机负责约束流程方向,幂等负责抵抗重复,可重入负责从局部失败中继续。它们组合在一起,才是一套真正能够在生产环境落地的最终一致性库存方案。


文章作者: 小风雷
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 小风雷 !
评论
 本篇
基于 TCC 思想的分布式库存一致性方案 基于 TCC 思想的分布式库存一致性方案
从库存预扣、Reservation状态机、Redis Lua、Outbox到Confirm与Cancel,完整拆解高并发库存系统的最终一致性设计。
2026-07-25
下一篇 
ClaudeCode 的执行机制:ReAct 与 Plan-and-Execute 在 TypeScript 源码中的实现 ClaudeCode 的执行机制:ReAct 与 Plan-and-Execute 在 TypeScript 源码中的实现
基于 ClaudeCode TypeScript 源码,拆解它如何用 query 主循环实现 ReAct,如何通过 plan mode、plan file、approval flow 叠加出 Plan-and-Execute,以及这两套机制分别在什么条件下触发、判断依据是什么、如何持续传递给大模型。
2026-04-06
  目录