消息队列、Outbox 与幂等消费:接受重复,避免丢失意图
订单已经保存,但发通知时网络断了;或者通知发成功了,程序却在保存“已发送”之前崩溃。问题的根源是数据库和外部系统有各自的提交点。单靠调整两行代码的顺序,很难抹掉中间空档。
1. 队列解决解耦和缓冲
生产者提交消息,Broker 暂存并转发,消费者处理。队列让生产者不必一直等待消费者,也能吸收短时突发流量。若长期生产速度大于消费能力,积压仍会一直增长,队列不创造无限容量。
生产者确认与消费者确认是不同阶段。Broker 确认接收消息,不表示业务消费者已处理;消费者发送 ack,也应明确是在何种业务完成点之后。不同队列类型、复制和持久化配置决定故障下的保证,不能只凭“用了 MQ”断言消息不丢。
2. Outbox 把发送意图放进事务
在同一个数据库事务内保存订单和 outbox 记录,再由独立 relay 发布消息。这样订单提交时,待发送的意图也一起保存;事务回滚时两者都不存在。
-- 表已创建的 PostgreSQL 事务示意,参数来自校验后的用例。
BEGIN;
INSERT INTO orders(id, user_id, total_minor)
VALUES ($1, $2, $3);
INSERT INTO outbox(event_id, aggregate_id, event_type, payload)
VALUES ($4, $1, 'OrderPlaced', $5::jsonb);
COMMIT;
一个真实驱动通常分别执行 BEGIN、两条参数化语句和 COMMIT,不是把这整段当一个多语句参数化接口直接粘贴。库存扣减与幂等记录也应纳入相应本地事务,示意省略了这些表。
relay 发布成功后更新 outbox 状态。但如果发布完成、标记之前崩溃,恢复后会再次发布。所以 Outbox 保存的是可靠发送意图,未把任意外部副作用变成 exactly-once。
3. 消费者怎样处理重复
为每个逻辑事件分配稳定 event ID。消费者用去重表的唯一约束登记该 ID,并在同一数据库事务中执行业务更新。重复插入时识别已处理结果,而不是再次扣款或增加积分。
开始本地事务
尝试登记 (consumer_name, event_id)
若已处理:结束本次消费,不重复修改业务
若首次:更新业务记录并提交事务
提交成功后发送 ack
如果业务动作是外部 HTTP 调用,本地事务不会包住它。需要外部系统支持幂等键,或者再保存可恢复的发送意图。不要把进程内 set() 当成多实例、重启后仍有效的去重记录。
4. 顺序、毒消息和重放
同一订单的状态变更可能需要有序处理;不同订单通常可以并行。用业务键分区有助建立局部顺序,但重试、多个消费者和重新投递仍需按 Broker 实际保证分析。顺序和去重也不是同一个属性。
一直处理失败的消息要有尝试次数、退避、死信或人工处理记录,避免热循环反复重投。重放前确认消费者逻辑和 Schema 版本,避免把历史通知再次发送给所有用户。能重放数据投影,不等于能无条件重放外部动作。
练习与参考
人为在“发出消息”和“标记已发送”之间终止 relay,再重启。预期可以出现重复消息,但订单和消费者侧业务效果仍符合约定。监控最老未发送事件的年龄,而不仅是总队列长度。
