Skip to content

幂等消费

本页结论:at-least-once 投递下重复不是意外而是常态,业务必须预期重复。幂等消费的标准做法是规格 §5.4:processed_messages 唯一键与业务写入放在同一个本地事务里,事务提交之后才 ACK/提交 offset。「业务已提交、确认未发出」的崩溃窗口无法消除,只能靠幂等表兜住。

为什么重复不可避免

四个产品的确认机制不同(RabbitMQ ACK、Kafka offset 提交、RocketMQ 消费位点、Pulsar ack),但失败窗口的位置相同:业务副作用已生效,向 Broker 的确认还没发出。此时 Broker 认为消息未确认,必然重投。反过来若先确认再写业务,窗口就变成丢消息。顺序只能二选一,本仓库的选择是:业务事务提交 → 确认,重复交给幂等表处理(完整推导见投递语义)。

§5.4 基准实现(所有产品通用)

text
1. 开启本地数据库事务
2. 尝试将 messageId 插入 processed_messages(唯一键)
3. 唯一键冲突 → 记录 duplicate_skipped,安全确认消息
4. 首次处理 → 执行业务写入,提交本地事务
5. 数据库提交成功后才 ACK / commitSync / 提交消费位点

两个细节决定正确性:

  • 第 2 步与第 4 步必须同事务。如果幂等键先单独提交、业务写入后失败,重投到来时会被误判为「已处理」而跳过——业务永远不会执行。
  • 第 5 步永远在最后。确认是「业务已持久化」的通知,不是业务本身的一部分。

崩溃窗口:已验证的实验证据

本仓库用崩溃注入复现了这个窗口(第 2 步成功、第 5 步前 Runtime.halt(137),重启后消息带 redelivered=true 到达,被幂等表拦截为 duplicate_skipped,业务表恰好 3 行):

verifiedrabbitmq / consumer-crashbroker 4.1.4 · java-amqp-client-5.34.0
镜像rabbitmq:4.1.4-management@sha256:294b01e1796a8acede4619f32a1c394fae1f8021e57986ea01aad38dc2a4f502
捕获时间2026-08-19T07:55:38.494Z
耗时 / 退出码14776 ms / exit 0
断言
crashExitCode137
crashAfterBusinessCommit1
receivedTotal4
redeliveredCount1
duplicatesObserved1
duplicatesApplied0
uniqueMessageIds3
business_rows3
queueDepthAfter0
consumerExitCode0
归一化日志
[producer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash messageId=mid-1 eventType=order.created schemaVersion=1 aggregateId=order-1001 traceId=trace-1 correlationId=order-1001 destination=orders.crash routingKey=orders.crash durationMs=<ms> status=confirmed
[producer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash messageId=mid-2 eventType=order.created schemaVersion=1 aggregateId=order-1002 traceId=trace-2 correlationId=order-1002 destination=orders.crash routingKey=orders.crash durationMs=<ms> status=confirmed
[producer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash messageId=mid-3 eventType=order.created schemaVersion=1 aggregateId=order-1003 traceId=trace-3 correlationId=order-1003 destination=orders.crash routingKey=orders.crash durationMs=<ms> status=confirmed
[producer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash destination=orders.crash confirmed=3 status=done
[consumer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash messageId=mid-1 eventType=order.created schemaVersion=1 aggregateId=order-1001 traceId=trace-1 correlationId=order-1001 destination=orders.crash consumer=consumer-1 attempt=1 redelivered=false status=received
[consumer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash messageId=mid-1 eventType=order.created schemaVersion=1 aggregateId=order-1001 traceId=trace-1 correlationId=order-1001 destination=orders.crash attempt=1 status=business_committed
[consumer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash messageId=mid-1 eventType=order.created schemaVersion=1 aggregateId=order-1001 traceId=trace-1 correlationId=order-1001 destination=orders.crash status=crash_injected
[assert] crashExitCode=137 PASS
[assert] crashAfterBusinessCommit=1 PASS
[consumer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash messageId=mid-1 eventType=order.created schemaVersion=1 aggregateId=order-1001 traceId=trace-1 correlationId=order-1001 destination=orders.crash consumer=consumer-1 attempt=1 redelivered=true status=received
[consumer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash messageId=mid-1 eventType=order.created schemaVersion=1 aggregateId=order-1001 traceId=trace-1 correlationId=order-1001 destination=orders.crash attempt=1 status=duplicate_skipped
[consumer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash messageId=mid-2 eventType=order.created schemaVersion=1 aggregateId=order-1002 traceId=trace-2 correlationId=order-1002 destination=orders.crash consumer=consumer-1 attempt=1 redelivered=false status=received
[consumer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash messageId=mid-2 eventType=order.created schemaVersion=1 aggregateId=order-1002 traceId=trace-2 correlationId=order-1002 destination=orders.crash attempt=1 status=business_committed
[consumer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash messageId=mid-3 eventType=order.created schemaVersion=1 aggregateId=order-1003 traceId=trace-3 correlationId=order-1003 destination=orders.crash consumer=consumer-1 attempt=1 redelivered=false status=received
[consumer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash messageId=mid-3 eventType=order.created schemaVersion=1 aggregateId=order-1003 traceId=trace-3 correlationId=order-1003 destination=orders.crash attempt=1 status=business_committed
[consumer] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash queue=orders.crash received=3 status=done
[inspect] timestamp=<ts> level=INFO service=order-service product=rabbitmq lab=consumer-crash business_rows=3 processed_rows=3 status=snapshot
[assert] receivedTotal=4 PASS
[assert] redeliveredCount=1 PASS
[assert] duplicatesObserved=1 PASS
[assert] duplicatesApplied=0 PASS
[assert] uniqueMessageIds=3 PASS
[assert] business_rows=3 PASS
[assert] queueDepthAfter=0 PASS
[assert] consumerExitCode=0 PASS
npm run lab -- rabbitmq consumer-crash

完整实验步骤与断言解读见消费者崩溃与重投。Kafka 分卷从 offset 语义给出同样的结论(见 Kafka 可靠性「崩溃窗口与幂等消费」):「提交 offset 等于业务已成功」是禁止表述——那是两个系统上的两个独立动作。

去重键怎么选

选择适用注意
messageId(默认)通用;信封必填,天然全局唯一生产者重发若生成新 messageId,去重失效——Outbox 要求 messageId 在写发件箱时就固定
业务键(如 orderId + 操作类型)生产者可能用不同 messageId 重发同一业务动作键要能代表「同一个业务意图」,不是「同一条消息」
天然幂等的业务写法状态机式写入(UPDATE … SET status='PAID' WHERE status='UNPAID'幂等表仍然建议保留,用于观测与防回归

其他工程点:

  • 幂等记录要有保留策略(按业务窗口保留,如 7~30 天后归档),否则表无限增长;归档窗口外的极老重投按新消息处理,业务键约束兜底。
  • 用 Redis SETNX 做去重时,注意它与业务写入不在同一事务:标记成功、业务失败后 Redis 键还在,重投会被错误跳过。分布式锁/缓存只适合做前置快筛,最终裁决要落在与业务同库的唯一键上。
  • 不要根据 redelivered=true 直接跳过:重投的消息可能第一次就没处理完,必须走完整幂等流程(见消费者崩溃与重投常见误区)。

与重试的关系

重试会放大重复:同一条消息每次重试都可能在「业务已提交、未确认」处失败。因此重试与 DLQ的消费逻辑必须复用同一套幂等流程,而不是在重试路径上绕过它。观测上,duplicatesObserved/重复拦截数应与重投率一起看(见可观测性统一指标)。

保证成立的条件 / 不保证什么

  • 条件:messageId 全局唯一且生产者不随意更换;幂等表与业务同库同事务;确认动作在事务之后。
  • 不保证:业务副作用「恰好一次」中外部系统部分——调用短信、第三方 HTTP 这类无法回读校验的副作用,需要在调用前先落幂等标记并容忍「可能已发出」。
  • 不消除崩溃窗口本身,只是让窗口的后果从「重复落库」变成「一次 duplicate_skipped 日志」。

常见误区

  • 「重复是小概率,可以不管」——at-least-once 的含义是业务必须预期重复,不是「偶尔可能重复」。
  • 「先 ACK 再写库更安全」——方向反了,那会把重复窗口换成丢失窗口。
  • 「幂等表只是性能优化」——它是崩溃窗口下的正确性组件,不可省略。
  • 「Kafka 开了事务/EOS 就不用幂等了」——EOS 只覆盖 Kafka 内部 topic→topic,外部数据库副作用仍需幂等(见 Kafka 可靠性)。

官方资料

以统一实验验证消息系统语义边界