消息队列把同步调用改成异步交付,可以削峰、解耦并吸收短时故障,但它不会自动带来“消息绝不丢失且绝不重复”。可靠性来自生产者、消息代理、消费者和业务存储共同遵守的协议。
问题背景:可靠不是绝不重复
分布式系统中,发送方可能在消息已写入后没有收到确认。它无法区分“写入失败”和“写入成功但确认丢失”,只能重试,因此重复是正常情况。工程上常见且可实现的语义是至少一次交付,再由消费者保证幂等。
所谓“恰好一次”通常限定在特定系统和事务边界内。消息最终要写入外部数据库、调用第三方接口时,仍要重新审视边界,不能把产品名词当作端到端保证。
一条消息的四段旅程
可靠链路至少包含四个问题:业务事件如何与发送保持一致;代理是否持久化并复制;消费者何时确认;业务写入怎样抵抗重复。任何一段只写“失败后重试”都不够,还需要重试上限、退避、可观测性和人工处置入口。
生产确认与代理持久化
生产者必须使用代理提供的确认机制。只要网络写操作返回就认为成功,会遗漏代理拒绝、节点故障和超时。确认等待也要有截止时间,超时后进入可控重试,而不是无限阻塞请求线程。
业务数据库写入与消息发送之间存在“双写”问题。常用解决方法是 Outbox:在同一个本地事务中写业务记录和待发送事件,由独立发布器读取 Outbox 并发送。
01BEGIN;0203UPDATE orders04SET status = 'paid', updated_at = CURRENT_TIMESTAMP05WHERE id = 'ord_7f3' AND status = 'pending';0607INSERT INTO event_outbox(id, aggregate_id, event_type, payload, created_at)08VALUES ('evt_01', 'ord_7f3', 'order.paid', '{"orderId":"ord_7f3"}', CURRENT_TIMESTAMP);0910COMMIT;
发布器成功发送后标记事件,失败则重试。Outbox 解决“业务成功但消息未产生”的问题,但发布器仍可能重复发送,所以消费者幂等仍然必要。
消费确认、重试和死信
消费者不应在业务处理前确认。正确顺序通常是:获取消息、校验、执行业务事务、提交成功、确认消息。处理超时或进程崩溃时,消息会重新投递。
重试应区分瞬时故障与永久错误。数据库短暂不可用可以指数退避;字段缺失或业务规则冲突不会通过重试自愈,应记录原因并进入死信流程。死信队列不是垃圾桶,需要告警、查询、修复和受控重放工具。
重复重试还可能造成“重试风暴”。设置最大次数、随机抖动、消费者并发上限与依赖熔断,可以避免故障被放大。
实践:幂等消费者
下面的 Java 示例展示基本思路:用消息 ID 建立唯一约束,并让去重记录与业务写入处于同一事务。
01@Transactional02public void handle(OrderPaid event) {03 boolean firstSeen = processedMessageRepository.tryInsert(04 event.messageId(), event.occurredAt()05 );06 if (!firstSeen) {07 return;08 }0910 int changed = orderRepository.markPaid(11 event.orderId(), event.paidAmount()12 );13 if (changed != 1) {14 throw new IllegalStateException("order state conflict");15 }16}
tryInsert 依赖数据库唯一索引,不能先查询再插入,否则两个并发消费者可能同时通过查询。事务回滚时,去重记录也要回滚,避免业务没有完成却永久跳过消息。
涉及发送邮件、扣费等外部副作用时,本地事务无法覆盖外部系统。应使用外部系统支持的幂等键,或把副作用再次拆成可追踪的任务。
顺序、积压与可观测性
全局顺序代价很高。通常只需要同一订单、账户或设备键内有序,可以用业务键选择分区。同键消息由一个顺序消费者处理,但热点键会限制吞吐。
必须观察生产速率、消费速率、积压量、最老消息年龄、失败率、重试次数和死信数量。仅观察“队列长度”可能掩盖持续流入时的真实延迟。扩容消费者前还要检查下游数据库是否承受得住。
常见误区与总结
自动确认会让进程崩溃时丢失正在处理的消息;无限重试会阻塞正常消息;把大文件直接放入消息会增加复制和网络成本;修改事件字段却不做版本兼容,会让旧消费者失败。
可靠消息系统的核心不是某个开关,而是确认、持久化、幂等、有限重试、死信处置和观测形成闭环。延伸阅读可以继续进入 Kafka 分区、副本机制、事件驱动契约与分布式一致性。