让消息消费不丢不重:幂等、重试、死信与顺序保障
让消息消费不丢不重:幂等、重试、死信与顺序保障
消息队列常被用来解耦系统、削峰填谷和异步处理,但“把消息发进队列”只是开始。真正困难的是:消费者重启后会不会漏数据,网络超时会不会导致重复处理,失败消息会不会一直阻塞,以及需要顺序时如何保持正确性。可靠消费的核心不是追求绝对的“只处理一次”,而是接受至少一次投递,通过幂等、状态机和可观测的补偿流程获得业务上的正确结果。
1. 先明确投递语义
多数消息系统提供至少一次投递:消息在确认前可能被再次发送。消费者完成业务处理后,如果确认请求恰好超时,代理无法判断处理是否成功,就会重新投递。这不是异常,而是可靠系统的正常行为。
至多一次投递可以减少重复,但确认过早会在进程崩溃时丢消息。所谓“恰好一次”通常只在特定边界内成立,例如同一个消息系统中的事务读写;一旦调用数据库、支付、短信或第三方接口,仍需业务幂等。因此,设计评审时要写清楚:投递语义是什么,确认点在哪里,失败后由谁恢复。
2. 为每条业务事件建立唯一身份
生产者应生成稳定的 eventId,而不是依赖队列自动生成的消息 ID。同一业务操作重试发送时必须复用 eventId。消费者以 eventId 作为幂等键,建立唯一约束或处理记录。
常见做法是在业务数据库中创建 inbox 表,字段包含 eventId、事件类型、状态、首次接收时间、完成时间和错误摘要。消费事务中先插入 inbox,再更新业务数据;若 eventId 已存在且状态为成功,直接确认消息。唯一索引比“先查询再插入”更可靠,因为并发消费者可能同时通过查询。
幂等不能只停留在入口。例如库存扣减应记录业务流水号,优惠券发放应对“用户 + 活动”建立唯一约束,第三方调用应传递其支持的幂等键。只有每个有副作用的环节都能识别重复,整个链路才真正安全。
3. 正确安排事务与确认顺序
最常见的安全顺序是:接收消息、开启数据库事务、写入幂等记录、执行本地业务、提交事务、最后确认消息。如果数据库提交前进程退出,消息会重投且没有业务结果;如果数据库提交后确认前退出,消息会重投,但幂等记录会挡住重复副作用。
不要在数据库事务中长时间调用外部服务。可以先把待执行动作写入 outbox 表,与业务状态在同一事务提交,再由独立投递器调用外部系统。这样即使服务重启,待发送动作仍能被扫描恢复。outbox 记录同样需要唯一业务键和明确状态。
4. 重试要分层,并设置时间预算
瞬时错误适合重试,例如网络抖动、连接重置、限流和部分 5xx;参数非法、权限不足、业务规则冲突通常不可重试。消费者应把错误分类,而不是对所有异常无限重放。
短暂错误可以在进程内快速重试一两次,随后把消息送入延迟队列,采用指数退避加随机抖动。例如 10 秒、1 分钟、5 分钟、30 分钟,避免依赖恢复时大量消息同时冲击下游。每条消息要设置最大次数和总时间预算,超过阈值后进入死信队列。
重试次数应随消息一起持久化,不能只存在内存。日志记录 eventId、topic、partition、offset、尝试次数和错误分类,但不要记录令牌、完整请求体或敏感业务数据。
5. 死信队列不是垃圾桶
进入死信队列意味着自动恢复路径已经失败。死信必须有负责人、告警、查询工具和重新投递流程。处理页面至少展示事件摘要、首次失败时间、最后错误、重试轨迹和关联业务 ID。
重新投递前应先修复根因,并再次检查幂等状态。若原消息格式已经过期,需要通过兼容转换器生成新版本事件,而不是直接修改历史消息。人工选择“忽略”时也要记录原因和操作者,确保后续审计能够解释数据差异。
可以按错误类型设置不同告警:单条脏数据进入死信可低优先级聚合;某个主题的死信率突然上升则应立即告警,因为它往往代表代码发布、下游故障或权限变更。
6. 顺序保障要缩小范围
全局顺序成本很高,会限制吞吐并放大阻塞。多数业务只需要同一订单、账户或设备内有序。生产者使用业务键进行分区,使同一键的事件进入同一分区;消费者对分区串行处理,不同分区仍可并行。
即便使用同一分区,也要防止旧事件覆盖新状态。事件中加入聚合版本号,消费者只接受期望的下一版本,重复版本直接忽略,跳跃版本进入等待或补偿流程。对于“状态更新”类事件,也可采用单调版本或更新时间比较。
顺序消息发生失败时,不能让一条永久错误阻塞整个分区。可在有限重试后暂停该业务键,将消息转入隔离队列,同时允许其他键继续处理;修复后再按版本顺序补放。
7. 控制并发与消费背压
消费者并发数应匹配分区数、数据库连接池和下游容量。盲目增加线程会造成连接耗尽、锁竞争和限流。通过预取数量控制单个实例持有的未确认消息,处理变慢时主动降低拉取速度。
当下游延迟升高时,应观察消费积压、处理耗时和失败率联动,而不是只看 CPU。必要时暂停低优先级主题,优先保障核心事件。扩容前还要确认瓶颈是否在消费者,否则实例越多只会更快压垮数据库。
8. 让问题可观测、可演练
监控至少覆盖生产速率、消费速率、积压量、最老消息年龄、处理延迟、重复命中数、重试次数、死信数量和确认失败。积压量可能在高流量下自然增长,“最老消息年龄”更能反映用户等待时间。
分布式追踪中应把 eventId 与原始 traceId 一起传播,消费端创建新的 span 并链接生产端上下文。这样可以从一次用户请求追踪到异步处理,同时避免把跨越数小时的任务错误地建成一个超长同步调用。
上线前通过故障注入验证:数据库提交后立即终止进程、确认请求超时、同一消息并发投递、下游限流、死信重放和分区重平衡。只有这些场景都能恢复,可靠消费才不是纸面设计。
结语
消息系统的可靠性来自一组相互配合的机制:稳定的事件身份保证可去重,数据库事务和 outbox 保证状态可恢复,分层重试与死信承接失败,业务键和版本号维护局部顺序,背压与监控保护系统容量。接受重复可能发生,并把每个副作用设计成可识别、可恢复、可审计,才能真正做到消息不丢、业务不重。
- 点赞
- 收藏
- 关注作者
评论(0)