15.2 Outbox、幂等消费与可恢复消息
一次进程崩溃恰好发生在“数据库已提交、消息未发出”之间,于是订单已经生效,下游却永远收不到对应通知。
服务常需要同时更新数据库并发布事件。先提交数据库再发消息,进程可能在两步之间崩溃;先发消息再提交数据库,消费者可能看到最终并不存在的事实。
Transactional Outbox 把业务变更和待发布消息写入同一个本地事务,再由独立 relay 发布消息。
Outbox 关闭原子缺口
以下使用 PostgreSQL 16 的数据修改 CTE;:name 表示应用绑定参数。项目表需保证 registration.id 唯一、version 非空,Outbox 的 event_id 唯一且所有事件字段非空。本例每个聚合版本只产生一个事件,还应约束 (aggregate_id, aggregate_version) 唯一。支付与名额的业务核验须在更新前完成。
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 必须稳定对应同一份已验证内容。示例不允许负奖励:
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,分区不会替它们纠错;修改分区数量时也要规划同一键的迁移。不同聚合之间不应假设全局顺序。
若消费者收到的是每个聚合版本的完整连续事件流,还应检查版本:
当前版本 5,收到版本 6 → 应用
当前版本 6,收到版本 6 → 重复,忽略
当前版本 5,收到版本 8 → 缺少 6、7,暂缓或修复相同版本还应对应相同事件身份和内容,否则是冲突。若订阅只包含部分事件类型,或某次状态更新根本不发事件,版本跳号不等于丢消息,需要专门的事件序号或补齐规则。只依赖到达时间无法可靠处理重放和跨分区延迟。
重试与死信队列
先区分错误:
- 瞬态错误:数据库暂时不可用,可以退避重试;
- 永久错误:schema 不兼容、必填字段缺失,重复不会自愈;
- 业务拒绝:不应伪装成基础设施重试;
- 未知错误:需要保留上下文并限制重试次数。
死信队列不是垃圾桶。进入死信的消息需要:
- 原始 payload、headers、事件 ID 和失败原因;
- 告警和明确所有者;
- 修复、重放或丢弃的审计操作;
- 重放时继续遵守幂等和顺序规则。
无限重试会阻塞分区并制造成本,直接跳过又可能破坏业务不变量。
运营指标
至少监控:
- Outbox 最老待发布消息的年龄;
- 发布和消费延迟;
- 重复消息、幂等命中和版本缺口;
- 重试次数、死信数量与最老死信年龄;
- 业务对账差异,而不只是 broker 是否在线。
下一课区分 CQRS 与 Event Sourcing,说明它们何时能解决读写模型和历史重建问题,何时只是增加复杂度。
参考资料
- Chris Richardson, Transactional Outbox
- Debezium, Outbox Event Router
- PostgreSQL, PL/pgSQL: Basic Statements
- Apache Kafka, Design: Delivery Semantics