跳到内容

第 6 卷 / 第 15 章 / 第 2 课

15.2 Outbox、幂等消费与可恢复消息 ​

一次进程崩溃恰好发生在“数据库已提交、消息未发出”之间,于是订单已经生效,下游却永远收不到对应通知。

服务常需要同时更新数据库并发布事件。先提交数据库再发消息,进程可能在两步之间崩溃;先发消息再提交数据库,消费者可能看到最终并不存在的事实。

Transactional Outbox 把业务变更和待发布消息写入同一个本地事务,再由独立 relay 发布消息。

Outbox 关闭原子缺口 ​

以下使用 PostgreSQL 16 的数据修改 CTE;:name 表示应用绑定参数。项目表需保证 registration.id 唯一、version 非空,Outbox 的 event_id 唯一且所有事件字段非空。本例每个聚合版本只产生一个事件,还应约束 (aggregate_id, aggregate_version) 唯一。支付与名额的业务核验须在更新前完成。

sql
BEGIN;

WITH confirmed AS (
    UPDATE registration
    SET status = 'CONFIRMED', version = version + 1
    WHERE id = :registration_id
      AND status = 'PENDING_PAYMENT'
      AND version = :expected_version
    RETURNING id, version
)
INSERT INTO outbox (
    event_id, aggregate_id, aggregate_version,
    event_type, payload, occurred_at, publish_state
)
SELECT :event_id, id, version, 'RegistrationConfirmed',
       jsonb_build_object('registrationId', id, 'version', version),
       CURRENT_TIMESTAMP, 'PENDING'
FROM confirmed
RETURNING event_id, aggregate_version;

COMMIT;

只有条件更新成功的记录才会进入 confirmed,所以插入的版本一定来自这次更新。返回零行时没有产生成功事件,调用者应查询报名状态;Outbox 插入失败则整个事务回滚。不能把“检查更新行数”留在注释里,让 SQL 自己猜业务分支。

relay 可以轮询 Outbox,也可以通过数据库日志 CDC 捕获。无论哪种方式,都可能在“消息已发送、发布状态尚未记录”时崩溃,因此重复发布仍然可能发生。

Outbox 提供的是“业务变更与发布意图原子记录”,不是自动的 exactly-once 端到端处理。可靠投递还依赖 relay 恢复、重试、消息持久化和保留期;轮询器必须等 broker 确认后才能标记已发布。本文表结构不是 Debezium 默认结构,使用 Event Router 时需显式配置字段映射。

消费者必须幂等 ​

下面的 PostgreSQL 函数把去重和积分更新放在同一事务中。前提是 consumed_message 有主键 (consumer, event_id),reward_account.player_id 唯一,points 为非空整数;事件 ID 必须稳定对应同一份已验证内容。示例不允许负奖励:

sql
CREATE FUNCTION apply_reward_event(
    p_consumer text, p_event_id uuid, p_player_id uuid, p_delta integer
) RETURNS boolean LANGUAGE plpgsql AS $$
BEGIN
    IF p_delta IS NULL OR p_delta <= 0 THEN
        RAISE EXCEPTION 'reward delta must be positive';
    END IF;

    INSERT INTO consumed_message (consumer, event_id, consumed_at)
    VALUES (p_consumer, p_event_id, CURRENT_TIMESTAMP)
    ON CONFLICT (consumer, event_id) DO NOTHING;
    IF NOT FOUND THEN
        RETURN false;
    END IF;

    UPDATE reward_account
    SET points = points + p_delta
    WHERE player_id = p_player_id;
    IF NOT FOUND THEN
        RAISE EXCEPTION 'reward account not found: %', p_player_id;
    END IF;
    RETURN true;
END;
$$;

SELECT apply_reward_event(...) 返回 true 表示本次应用,false 表示已经消费。FOUND 检查的是前一条 SQL 是否影响了行;账户缺失或整数溢出会使调用失败,去重记录也随事务回滚。只有提交成功后才确认消息;提交后、确认前崩溃会造成重投,但不会再次加分。

内存 Set 无法跨重启去重。把去重记录提前提交,再另开事务更新积分,也会留下永久漏处理的窗口。若动作是外部扣款或发邮件,本地事务无法覆盖它,应再记录一个 Outbox 意图并在接收端去重。去重记录的保留期至少要覆盖允许的重投和人工重放窗口;同一 ID 对应不同内容时应报错,不能伪装成正常重复。

有些操作在限定条件下幂等,例如“尚未超过版本 7 时,把状态设置为版本 7 的值”;盲目应用旧版本仍会覆盖新状态。有些操作如“积分加 10”必须依赖事件 ID 或业务操作 ID 去重。

顺序应按业务键定义 ​

消息系统通常只能在一个分区内保证顺序。使用稳定的 aggregate_id 分区键,可以把同一报名或比赛的事件送到同一分区,但 relay 仍需按业务顺序发布,消费者也需保持该键的应用顺序。并行发布器可以先发版本 8 再发版本 7,分区不会替它们纠错;修改分区数量时也要规划同一键的迁移。不同聚合之间不应假设全局顺序。

若消费者收到的是每个聚合版本的完整连续事件流,还应检查版本:

text
当前版本 5,收到版本 6 → 应用
当前版本 6,收到版本 6 → 重复,忽略
当前版本 5,收到版本 8 → 缺少 6、7,暂缓或修复

相同版本还应对应相同事件身份和内容,否则是冲突。若订阅只包含部分事件类型,或某次状态更新根本不发事件,版本跳号不等于丢消息,需要专门的事件序号或补齐规则。只依赖到达时间无法可靠处理重放和跨分区延迟。

重试与死信队列 ​

先区分错误:

  • 瞬态错误:数据库暂时不可用,可以退避重试;
  • 永久错误:schema 不兼容、必填字段缺失,重复不会自愈;
  • 业务拒绝:不应伪装成基础设施重试;
  • 未知错误:需要保留上下文并限制重试次数。

死信队列不是垃圾桶。进入死信的消息需要:

  • 原始 payload、headers、事件 ID 和失败原因;
  • 告警和明确所有者;
  • 修复、重放或丢弃的审计操作;
  • 重放时继续遵守幂等和顺序规则。

无限重试会阻塞分区并制造成本,直接跳过又可能破坏业务不变量。

运营指标 ​

至少监控:

  • Outbox 最老待发布消息的年龄;
  • 发布和消费延迟;
  • 重复消息、幂等命中和版本缺口;
  • 重试次数、死信数量与最老死信年龄;
  • 业务对账差异,而不只是 broker 是否在线。

下一课区分 CQRS 与 Event Sourcing,说明它们何时能解决读写模型和历史重建问题,何时只是增加复杂度。

参考资料 ​

Built with VitePress | Software Systems Atlas