消息系统把“接收请求”与“完成工作”拆开,可以吸收短时流量,也让消费者独立伸缩。代价是必须描述延迟、丢失、重复、顺序与恢复。把函数调用换成队列,并不会自动得到可靠性。

一个断连对应两种真实状态

生产者发出消息后没收到确认:可能消息没到,也可能已被持久接收但确认丢了。重试能减少丢失,却可能重复。消费者完成数据库更新后、发 ack 前崩溃,消息也可能重新投递。因此普通工程中常采用至少一次投递,加业务幂等。

RabbitMQ 的 publisher confirm 说明生产者到 broker 的处理结果;consumer acknowledgement 说明消费者对一次投递的处理结果,两个确认不会自动互相代替。确认的持久性边界仍取决于队列类型、持久化与集群配置。

用数据库事务吸收重复

消费者可以把“事件已处理”与业务更新放进同一事务。下面是 PostgreSQL 的核心步骤;应用必须根据 INSERT 返回的行数决定是否继续执行。

CREATE TABLE processed_event (
  consumer_name text NOT NULL,
  event_id text NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer_name, event_id)
);
BEGIN;
INSERT INTO processed_event(consumer_name, event_id)
VALUES ('notification-record', 'event-42')
ON CONFLICT DO NOTHING
RETURNING event_id;
-- 返回一行:在同一事务内更新业务表。
-- 返回零行:此消费者已处理,跳过业务更新。
COMMIT;
-- 只有数据库提交后才确认消息。

若事务回滚,去重记录也回滚;若提交后崩溃,下次读到重复会跳过业务更新。给外部邮件服务发信无法直接与本地数据库提交原子化,应使用对方的幂等能力或接受并说明重复窗口,不能把本地去重表说成跨所有系统的恰好一次。

Outbox 解决双写的哪一半

预约提交成功,却还没发消息进程就崩溃,会留下“有预约、无事件”。在预约事务内同时写 outbox 行,提交后由转发器读取、发送并标记已发,可以让待发送事件随数据库恢复。

转发器仍可能“已发送但尚未标记”就崩溃,所以会重复发送。事件需要稳定 event_id,消费者继续幂等。多个转发器要考虑认领、租期、并发和失败恢复;简单 SELECT 后无锁更新会重复抢同一批。不要持有数据库事务等漫长网络调用。

顺序、重试和毒消息

同一实体的事件可以按实体键分区,让单个分区内维持顺序。跨分区通常不提供总顺序;增加消费者并发或异步处理后,还要重新检查完成顺序。事件可携带版本号,由消费者拒绝过时状态,并为缺失版本设计恢复策略。

暂时错误采用有限重试、退避和抖动;结构损坏或业务永久拒绝的消息不应无限回队。进入死信后保存错误原因、事件标识、尝试次数和原始版本,并提供人工修复后的受控重放。观察最老未处理消息年龄比只看队列长度更能说明用户等待了多久。

Kafka 的事务可以协调其 topic 之间的读取、位点和写入;写到外部数据库时还需要目标系统配合。Redis Pub/Sub 是临时广播语义,订阅者断连可能永久丢消息,不能直接拿来替代有恢复要求的任务队列。

练习与面试自查

为消费者列出三个故障点:提交前、提交后 ack 前、ack 后。对每一点写出重投结果与数据库最终状态。再解释为什么把 ack 放到更新之前会出现无法恢复的工作丢失。

检查 Java 预约工程的 outbox 行:它验证原子落库,不包含真实 broker 转发。补接消息系统时,验收必须包含重复投递与转发器重启,不能只验证“收到了一条消息”。

  • “消息不丢”需要覆盖生产者、broker、消费者哪几段?
  • 为什么 Outbox 仍需要消费幂等?幂等键应跟消息重发一起变化吗?
  • 队列积压应该直接加消费者吗?先检查哪些下游与顺序约束?

参考资料