消息怎样不丢失:发送确认、持久化、消费提交与业务对账

沿订单支付后发券的链路,解释消息在生产、存储和消费阶段的丢失窗口,比较 RocketMQ、Kafka、RabbitMQ 的确认边界,并把 Outbox、幂等、重试、死信和业务对账接成可恢复的流程。

订单已经支付,用户却没收到活动赠券。生产者日志里有“发送成功”,监控里也没有明显积压。消息到底丢在哪儿?

这个问题不能只回答“生产者重试、Broker 持久化、消费者手动 ACK”。发送成功可能只意味着数据进入了客户端缓冲区;Broker 确认的耐久范围取决于刷盘和复制策略;消费者返回成功也可能只是把任务交给了一条内存中的工作线程。每一层都显示成功,业务仍然可能没有完成。

本文沿一条“符合活动条件的订单支付后发一次券”的链路分析。示例是设计推演,不是某家公司的实际架构,也不包含压测或实验。协议行为以官方文档为依据:RocketMQ 的旧 API 明确限定为 4.x Remoting,5.x 消费接口单独说明;Kafka 配置引用 4.1;RabbitMQ 以 AMQP 0-9-1 确认机制和对应队列类型为范围。生产环境还要核对实际版本、存储和部署方式。

这条链路的目标是:支付事实已经持久化后,即使进程退出、网络超时或约定范围内的节点故障,也能恢复应当发券的任务;允许消息重复到达,但同一项发券义务只产生一次业务效果。所有副本同时损坏、数据被人为删除、恢复超过保留窗口,都不能靠一个 ACK 配置自动解决。

一、先定义“没完成”,再谈“丢失”

对发券业务来说,完成条件是券系统里存在已提交的发券结果,且它对应正确的订单、活动和用户。消息被发送、投递、读取,都只是中间状态。

有四种看起来相似、处理方式不同的情况:消息仍在队列里等待,是延迟;同一事件再次到达,是重复;事件发去了错误的 Topic、队列或消费组,是路由或订阅错误;系统已经没有能恢复该任务的消息和业务记录,才形成真正的恢复缺口。消息还在死信队列里也不能算业务完成,它只是被保存下来等待处理。

这里有两个边界需要分开。传输可靠性回答“约定的接收方有没有收到数据”;业务可靠性回答“应当产生的业务效果有没有落地”。一个 Broker 可以正确保存消息,消费者也可以正确确认,但业务代码因为错误分支跳过发券。此时传输层没有丢消息,用户的权益仍然缺了一份。

因此,系统至少要有两种可查询证据:上游的“应当发券”记录,下游的“已经发券”记录。前者告诉我们欠了什么,后者告诉我们完成了什么。消息轨迹、位点和消费成功数只能辅助定位,不能替代这两份业务事实。

本文采用如下契约:订单付款时固化活动资格,生成稳定的 eventId;事件携带 orderId、campaignId、userId、schemaVersion。同一订单在同一活动下的这一项赠券义务,用 (orderId, campaignId, benefitType) 标识。后续如果允许再次赠券,必须增加新的业务操作编号,不能为了去重而把合法操作也拦住。

eventId 用来追踪和识别同一逻辑事件的重发,业务键用来约束权益发放。两者可以重合,也可以不同。传输层生成的消息 ID 不一定在应用重发时保持不变,所以不能直接把它当成所有场景下的幂等键。

二、确认究竟确认了什么

发送端收到 Broker 的确认,说明 Broker 按当前协议和配置接受了消息。消费端发送确认,说明消费者宣告自己已经完成约定的处理。这是两次独立的责任交接。RabbitMQ 官方也明确区分 publisher confirms 与 consumer acknowledgements,它们分别覆盖发布侧和消费侧。确认机制

支付事实、发送确认和消费确认分别交接什么责任

图里最容易被省略的是左边的业务数据库。假如订单已提交,而事件只放在生产者内存里,进程退出后 Broker 根本不知道有这项任务,更不可能重投。反过来,如果消费者先 ACK,再开始发券,Broker 可以认为处理完成,而本地崩溃会让发券工作永久停在中途。

可见现象 已经证明的事情 尚未证明的事情
调用发送 API 返回 Future 请求被客户端接受或排入缓冲区,依 API 而定 Broker 已接受、下游已处理
Broker 的发送确认成功 满足当前确认策略的接收条件 任意故障都能恢复、赠券已完成
消费者读到事件 本次投递已到达消费进程 业务事务已经提交
消费者返回成功或提交进度 应用宣告该处理边界完成 这个宣告没有写早、业务代码没有漏执行
券系统里有成功结果 对应业务操作已经落地 所有其他应发券订单也已完成

责任交接前,当前持有者要留下恢复依据;交接发生后,下一层要满足自己的存储与执行承诺。为了容忍确认丢失,两层的恢复能力会有重叠,这也是重复消息很难完全消除的原因。

“至少一次”可以理解为在指定故障模型、重试机制和保留期限内,未确认的消息仍有重新投递机会。它不等于无限重试,更不等于业务一定成功。“最多一次”避免重新投递,却可能让失败的任务无人再处理。所谓“恰好一次”还必须说明覆盖的系统边界:消息系统内部的事务,并不会自动把任意外部数据库或 HTTP 服务纳入同一原子提交。

三、生产者:超时不是失败事实,重试也不是持久化

假设生产者发出消息,Broker 已经接收,但响应在网络上丢了。生产者看到超时,无法区分“根本没到 Broker”和“已经接收,只是确认没回来”。如果放弃重试,第一种情况会缺任务;如果重试,第二种情况会重复。

这不是把网络超时调长就能消除的问题。网络连接断开、进程重启、服务端在返回前切换,都能制造相同的未知结果。可靠方案通常选择保存任务并重发,同时要求下游接受重复事件。

发送处理至少要区分三类结果。明确确认成功,可以推进发送记录;明确拒绝,例如无法满足策略或消息不合法,需要保留任务并分类处置;超时、连接中断或回调丢失属于结果未知,不能当成“Broker 肯定没有这条消息”。具体异常是否能证明未接收,要以对应客户端和协议为准。

异步发送还多一层容易漏掉的窗口。调用返回了 Future,不代表 Future 已成功;应用必须处理成功和失败回调,并保证失败任务有恢复入口。线程池关闭时也不能直接丢弃尚未完成的发送任务。单向发送没有服务端响应,不适合拿来证明关键业务消息已被接受。同步发送能让调用方等待结果,但也不消除结果未知的窗口。

SDK 的重试次数和时间是有限的,而且重试任务通常位于客户端内存中。应用退出后,这些内存状态可能消失。因此,“开启三次重试”与“重启后还能继续发送”是两回事。RocketMQ 最佳实践建议结合业务持久化处理发送失败;更严格的设计还要把发送意图保存到第一次尝试之前,否则会留下保存失败记录之前的崩溃窗口。

重发时应保持相同的逻辑事件 ID。重新生成消息对象可以,但不能把一次订单支付改造成两个不同的业务事件。发送日志也应记录业务键、事件 ID、目标 Topic、尝试次数和确认结果,否则排查时只能看到几个彼此不关联的消息 ID。

四、数据库提交与发送之间,用 Outbox 留住任务

最直接的双写方式是先提交订单,再发送事件。它的缺口是:订单提交之后、发送之前进程退出。调换顺序也没有解决问题:先发送,后提交订单,可能出现券已经发出而订单事务回滚。

Outbox,也就是本地消息表,把“付款事实”和“需要发送的事件”放进同一个本地数据库事务。它不让数据库和 Broker 原子提交,而是让业务提交之后始终有一条可重放的发送记录。

-- 示意结构,订单和 Outbox 必须属于同一本地事务资源。
BEGIN;
UPDATE orders
SET payment_status = 'PAID'
WHERE order_id = :order_id AND payment_status = 'UNPAID';
-- 必须检查更新结果与既有支付状态,不能无条件重复生成事件。
INSERT INTO outbox(event_id, business_key, topic, payload, status)
VALUES (:event_id, :business_key, 'order-paid', :payload, 'PENDING');
COMMIT;

真实实现还要给支付操作和事件业务键设置合适的唯一约束。重复支付回调如果已完成相同操作,应返回已有结果,不再创建新的发券义务;普通 SQL 错误不能被当成幂等命中吞掉。示例省略了这些分支,但它们决定了这条事务是否正确。

另一个发送进程扫描 PENDING 记录,读取 payload,等待符合契约的发送确认,然后把记录更新为 CONFIRMED。这个状态只表示已确认发布,不表示券已到账。状态名如果直接写成 SUCCESS,很容易在监控和补偿逻辑里被误用。

扫描器可以使用租约领取任务,防止多个实例长期同时发送同一行。领取者退出后,租约到期,其他实例继续处理。数据库更新要校验领取版本或持有者,避免过期实例覆盖新状态;不过租约并不能阻止一个暂停后恢复的旧实例已经发出的网络请求。因此即使有领取锁,下游仍然要幂等。

Outbox 的关键窗口发生在 Broker 接受事件之后、扫描器标记 CONFIRMED 之前。扫描器此时退出,恢复后看到的还是待发送状态,只能再次发送。任务没有丢,但事件可能出现两份。反过来,先标记已发送再发消息,会把这个窗口变成缺任务,不能这样省一次重复。

Outbox 表本身也需要维护:未确认记录要有扫描索引、退避时间和告警;已确认记录保留多久,要结合审计、补发和下游恢复期限决定。积压不能无限增长,数据库故障也要有备份与恢复方案。给表起名叫“可靠消息”并不会使它脱离数据库的耐久边界。

RocketMQ 事务消息是另一种发布侧协调方式:先保存不可消费的半消息,再根据本地事务结果确认,确认丢失时通过回查恢复。回查需要能查询持久化事务结果,暂时查不到应保持未知,不能直接回滚。具体机制见 半消息与事务回查。两种方案都需要处理重复消费,都不保证下游业务自动成功。

五、Broker:写入、刷盘和复制防的是不同故障

服务端把数据写入进程内存、写入操作系统页缓存、刷到稳定存储、复制到另一台机器,是不同的完成边界。“已经落盘”如果不说明是哪一步,很难判断故障后是否还存在。

应用进程退出时,操作系统页缓存通常仍在,重启进程可能继续读到数据;整机断电时,尚未写入稳定存储的内容可能消失。同步刷盘降低这个窗口,但单块磁盘或整台主机永久损坏时,还需要其他可用副本。复制到同一主机上的两个目录,也不能抵御整机丢失。

刷盘和复制不能相互替代。副本可能暂时都依赖各自的页缓存;刷盘后的唯一副本也可能连同磁盘一起损坏。可靠性要明确允许失败多少个节点、副本是否跨故障域、失败后是否允许降低确认门槛,以及恢复出来的数据是否足够新。

系统与范围 关键确认条件 容易误解的边界
RocketMQ 4.x 传统主从部署 SYNC_FLUSH 与 SYNC_MASTER 分别涉及刷盘和主从复制等待,应检查发送状态 刷盘同步不等于异地容灾;复制等待不等于所有副本都已物理刷盘
Kafka 4.1,传统 ISR 模型 acks=all 配合 min.insync.replicas,不足门槛时拒绝写入 all 指当前 ISR,不是所有配置副本;复制确认不等于逐消息 fsync
RabbitMQ AMQP 0-9-1 正确路由、持久消息、耐久队列与 publisher confirms;复制能力依队列类型而定 confirm 不证明消费者完成,普通集群也不自动使所有队列内容复制

RocketMQ 旧主从配置中的同步刷盘和同步复制是两个维度。发送端要检查实际 SendStatus,不能只要 API 没抛异常就认定满足耐久契约。FLUSH_DISK_TIMEOUT、FLUSH_SLAVE_TIMEOUT 等结果可能表示所要求的等待没有完成,不证明消息一定不存在;处理上仍要保存事件并允许重发。这些旧配置不能直接当作 5.x Controller、其他复制模式或托管服务的通用说明。4.x 发送示例

Kafka 的 ISR 是当前保持同步的副本集合。比如配置三副本,min.insync.replicas=2,生产者使用 acks=all:门槛要求足够的 ISR,确认等待的是当前 ISR 的复制,而不是“只等任意两个副本”。当只剩一个 ISR 时,降低门槛让写入继续,会改变原先的故障保证。Kafka 4.1 的 Eligible Leader Replicas(ELR)还会影响选主与最小 ISR 语义,启用时需按该机制核对,不能照搬传统模型。生产者配置、Topic 配置

Kafka 生产者幂等能够抑制协议范围内的重试重复;它不能识别应用自行创建的两条相同业务事件,也不能自动约束券数据库。讨论端到端恰好一次时,应另外说明输入位点、输出和外部业务状态是否处在共同的事务边界。

RabbitMQ 还要检查路由。消息找不到目标队列时也可能收到 publisher confirm;使用 mandatory 发布并处理 basic.return,可以识别无法路由的情况,但仍要校验实际队列绑定是不是业务需要的那一个。对于持久消息和耐久队列,确认涉及持久化;quorum queue 的确认还涉及副本多数接受。Publisher confirms 的确认时机

越强的确认通常意味着更多等待和更严格的可用条件。需要副本而副本不足时,正确行为可能就是暂时不接受新消息。业务应把这件事表现为可恢复的待处理状态,不能为了让页面继续显示“成功”而静默降级确认标准。

六、消费端:业务提交之后再确认

消费者收到支付事件,开始执行发券。这里也有一个无法仅靠调换顺序消除的双写问题:业务数据库提交和消费确认,通常不在一个本地事务里。

发券事务与消费确认之间的崩溃窗口

先 ACK 再提交业务,消费者在中间退出时,Broker 认为任务已完成,券却没发。先提交业务再 ACK,中间退出时,Broker 可能重新投递;这一次券已发过,需要通过幂等识别。关键业务通常选择后一种,把不可恢复的遗漏变成可以约束的重复。

对于 RocketMQ 4.x Push,消费监听器返回成功前,要完成约定的业务提交。RabbitMQ 手动确认也应放在处理完成之后。RocketMQ 5.x SimpleConsumer 通过 receive、ack 和不可见时间管理消息;处理超过不可见期限时,可能再次被投递,延长不可见时间只是在延长当前处理机会,不能证明旧执行者已经停止。RocketMQ 消费者类型

一个常见错误是监听器提交异步任务后立即返回成功。这个任务只是进了线程池的内存队列,进程退出就消失了。Broker 不会因为工作线程还没完成而自动撤销之前的成功确认。若确实需要解耦,必须先把任务交给另一个有耐久和恢复承诺的系统,并把这次交接纳入可靠链路;不能把内存任务队列当成持久接收方。

Kafka 用消费位点表达进度,需要注意提交值是下一条应读取的位置。例如 offset 100 和 101 并发执行,101 已完成、100 仍在处理,直接提交 102 就可能在恢复时跳过 100。应维护每个分区连续完成的前缀,只有 100 和 101 都完成才能推进到 102。这里的“连续”指没有遗留未完成的已交付记录,不要求日志的每一个整数 offset 都可见。KafkaConsumer API

关闭自动提交只能防止一种错误时机,不会自动实现这套并发进度管理。重平衡时还要停止向已撤销分区分发工作,协调在途任务和提交,并处理已经失去分区所有权的旧任务。即使位点提交被拒绝,旧任务仍可能执行外部业务,所以业务幂等不能省略。

自动提交适不适合,要看 poll、业务执行、再次 poll 与提交的相对时机。把数据交给异步线程之后继续 poll,很容易让位点进度超过业务完成进度。消息批量获取和业务并发越复杂,越要明确“哪一条已经持久完成”,而不只看消费函数是不是被调用了。

七、幂等要和业务效果一起提交

在发券示例中,券系统可以用唯一约束保证同一业务键最多有一份发券结果。收到重复事件时读取既有结果并返回成功,不能再次增加用户券余额。

收到事件,校验结构和业务资格
开始券数据库事务
  尝试创建 grant_record,业务键有唯一约束
  若是该唯一键冲突:退出本次写入,读取已提交结果
  若是其他数据库错误:回滚,交给重试机制
  若首次创建成功:写入券实体或权益流水,记录成功结果
提交事务
只有持久化结果满足约定后,才确认消息

发券记录和券实体必须在同一本地事务里,否则仍有问题。先插入“已处理”标记,后发券:中间崩溃后重试看到标记,会误认为已经发了。先发券,后插入标记:中间崩溃又可能重复发放。把两者合并在一个事务里,才能让“已经处理”成为可信的事实。

还要区分处理中和已完成。如果使用 Inbox 表先持久接收事件,再异步处理,那么 PENDING 只能表示事件已被本地系统接管,不能当作发券成功。此时消费者可以在可靠接管之后确认,但 Inbox 的工作进程、失败状态、恢复扫描和对账就成为新的责任持有者。多加一张表不会取消后续执行责任。

唯一约束应当覆盖业务义务,不能过粗或过细。只用 userId 会拦掉用户的其他合法赠券;只用每次新生成的 UUID 又拦不住重试。不同事件触发相同权益时,按业务键去重还能防止上游重复生成两个事件造成两次发放。事件 ID 更多用于解释它从哪里来,权益业务键用于定义是否应当再次生效。

如果发券要调用外部服务,本地数据库无法包住对方的 HTTP 请求。必须要求对方支持稳定幂等键和结果查询,或者设计可恢复的任务状态与补偿协议。外部请求超时以后,本地不能直接认定对方没执行,也不能无条件重做不可幂等的操作。查询对方结果、复用原业务键、记录待确认状态,都是为了保留未知结果的恢复入口。

Redis 的短期去重键可以减轻压力,但有过期、淘汰和自身故障边界。若权益事实保留一年,去重键只存十分钟,几天后的死信重放仍可能再次发券。对重要业务,最终约束应能覆盖它的重试和重放生命周期;缓存可以辅助查询,不应成为唯一证据。

八、重试和死信:保存失败不等于处理成功

数据库暂时不可用,可以延迟重试;消息格式永远无法解析,持续立即重试只会占满工作线程。错误分类决定了恢复入口:暂时错误退避,永久或待人工判断的错误进入隔离处理,并保留原始事件、业务键、失败原因和处理记录。

重试次数有限时,任务最终可能进入死信队列。RocketMQ 5.x 的重试模型按消费者类型有所不同,达到最大重试次数后的 DLQ 是后续恢复入口,不能把“进入 DLQ”统计成业务成功。消费重试策略

死信要有负责人、告警阈值和重新处理流程。只创建一个死信 Topic,不消费、不查看,它只是把事故推迟了。重新投递也不能随手生成全新业务 ID,否则原来的幂等约束失效。人工修复 payload 时,要保留原始事件与修订记录,明确哪些字段允许改,哪些字段会改变业务义务。

把失败消息转发到另一条队列也有发送和确认的双写窗口。如果先确认原消息,再发布隔离消息,中间崩溃会失去恢复入口;如果先可靠发布再确认,则可能重复进入隔离队列。所用产品的原生死信转发保证还依赖具体队列类型和策略,不能假设任何死信配置都是无损原子搬运。

顺序消息另有代价:把某个订单的失败事件移走,让它后面的事件继续执行,可能打乱该订单的状态顺序。应该先决定是阻塞这一个业务键、记录待补状态,还是允许后续操作根据版本拒绝过时事件。不能一边宣称严格有序,一边任意把失败消息绕过去。

消息也不会永远保留。RocketMQ 的存储清理不以“所有消费者都完成”为前提;Kafka 的日志保留同样需要按时间、空间与具体策略核对。恢复时间必须覆盖故障发现、修复、积压排空和必要重放,空间也必须够用。RocketMQ 存储与清理

例如业务允许服务停三小时,再用两小时清空积压,只留两小时消息显然不够。这个五小时还没有包括发现问题的时间和安全余量。仅看队列长度也不够,应观察最老待处理事件的年龄,以及它距离不可恢复的保留边界还有多久。

九、把三次崩溃沿同一订单走完

假设订单 O-1001 属于活动 C-9,付款事务固化它应获得一张券,生成事件 E-1001-C9。券数据库起初没有对应发放结果。下面三次故障都沿用这个事件和业务键,不能每次重新计算活动资格并创建不同义务。

第一次,支付事务提交之后,发送进程尚未运行就退出。恢复后查询订单是已支付,Outbox 是 PENDING。扫描器发送事件,Broker 返回满足契约的确认,再标记 CONFIRMED。恢复依据是数据库里已有的事件,不是去日志里猜应该补什么。

第二次,Broker 已接受事件,但发送进程没收到响应就退出。恢复后 Outbox 仍然待发送,扫描器重发同一事件。Broker 或客户端可能抑制部分重复,也可能留下两条投递。券消费者按照业务键创建一次发放结果,另一条读取已有结果。上游无法判断第一次是否成功,并没有妨碍业务恢复。

第三次,券数据库事务已提交,消费者在 ACK 之前退出。Broker 尚未确认完成,之后再次投递。新消费者读到成功发放记录,不再增加权益,然后确认消息。这一次也需要允许重复,才能补上没有完成的消费确认。

三类故障分别由哪份持久记录恢复

故障窗口 恢复时能查到什么 下一步 为什么不再次发券
支付已提交,尚未发送 订单与待发送事件 扫描 Outbox 后发送 仍使用原业务键
Broker 已接收,发送确认未知 待发送事件,Broker 可能已有数据 同一事件重发 下游唯一约束
发券已提交,消费确认未知 已提交发券结果 重投后读取结果并确认 不再执行首次发放分支

若第二次故障发生后 Outbox 被错误清理,或第三次故障的去重证据已过期,原来的推理就不成立。可靠性是这些记录、状态机和保留规则共同维持的性质,不能只在代码里出现一个 try/catch 就算完成。

还有一种情况不应被忽略:消费者运行正常、ACK 也成功,但代码漏了一个活动分支。Broker 不会重试这条已经确认的消息。只有上游“应发”记录与下游“实发”结果的核对,才能发现这类语义遗漏。

十、业务对账发现那些协议看不到的缺口

发送成功率高、消费错误数为零,只能说明这些指标覆盖的行为没有报错。它们无法知道某个应发券订单是不是被订阅过滤掉了,是否被错误分支跳过,或者由于配置错误根本没有创建事件。

对账应从持久化业务义务出发。订单付款时已经固化资格,因此对账不需要用今天的活动规则重新判断昨天的订单。用当时记录的订单、活动和权益类型,查询是否存在对应的已提交发放结果。

-- 示意查询:两份账在同一查询域时可连接;跨服务可通过核对任务实现。
SELECT e.order_id, e.campaign_id, e.benefit_type, e.event_id
FROM expected_benefits e
LEFT JOIN granted_benefits g
  ON g.order_id = e.order_id
 AND g.campaign_id = e.campaign_id
 AND g.benefit_type = e.benefit_type
 AND g.status = 'SUCCESS'
WHERE e.status = 'REQUIRED'
  AND e.created_at < :settlement_watermark
  AND g.order_id IS NULL;

这份差集是“待核查”,不是立即补发的授权。事件可能仍在正常等待窗口,查询可能读到延迟副本,订单也可能发生了业务上允许取消权益的变化。需要根据明确的结算水位、权威状态和取消规则决定下一步,而不是把所有差集直接重新发券。

核查顺序可以从发送记录开始:没有创建 Outbox,要查上游资格与事务逻辑;仍为待发送,要查发送器、配置和 Broker 确认;已确认发布,要查订阅、过滤、消费进度、失败记录和下游结果;已经有权益但对账没匹配,要先修正查询或键映射,不能重复发放。

修复入口应复用原来的业务键和幂等流程。即使对账判断不够及时,重复补投也不应产生第二份权益。补偿记录还应包含发现时间、原因、操作者或自动任务、原事件及最终结果,方便区分常规重试和真正的数据缺口。

监控可以围绕这条责任链设计:Outbox 的最老待发送年龄、发送未知结果数量、Broker 副本与存储健康、消费完成延迟、重试和死信规模、应发未发差集。积压数量描述负载,最老事件年龄描述等待,对账差集描述业务欠账;三者不能互相替代。

发券是低时延还是允许几分钟完成,也要写进契约。一个事件晚两秒与晚两天都没有物理丢失,但用户体验和可恢复风险完全不同。只有明确时间目标,告警和补偿才能知道何时介入。

十一、设计取舍:可靠性会占用存储,也会降低可用性

如果业务只是允许遗漏的实时埋点,要求每条都保存 Outbox、逐条对账,成本可能高于收益。对于权益、订单推进等不能静默遗漏的任务,则应从业务义务、持久发送意图、确认策略、幂等结果和恢复期限一起设计。是否需要这套链路,取决于遗漏和重复的实际代价。

可靠性更强的配置会增加延迟或降低故障期间的可用性:等待刷盘、等待副本、写入业务流水、保留更长历史,都需要资源。不能拿关闭确认、缩短保留期得到的吞吐,去宣称同等条件下性能更高。系统如果为了可用性选择弱确认,必须把可能损失什么、由谁补偿写清楚。

对关键任务,我更倾向于在依赖不可用时留下可查询的待处理状态,并告诉业务它尚未完成。后台补发可以继续恢复,而“页面显示成功、数据库没有义务记录、消息也不知道在哪里”很难修复。这个选择还要配套限流和容量管理,否则 Outbox、重试队列和数据库最终都可能被积压拖垮。

审查一条消息链路时,可以直接问:业务提交后谁还记得这项任务?发送超时后怎样知道是否要重发?确认要求的副本和耐久条件是什么?消费者在哪一步宣告成功?重复事件依靠哪份持久业务约束收敛?重试结束或历史清理后还有什么恢复依据?这些问题都能指到具体状态和代码,才算形成了可解释的保证。

顺序消费解决同一业务状态的推进次序,延迟消费解决何时允许任务开始,幂等解决多次到达怎样只生效一次。它们都建立在这条可靠交接链上。RocketMQ 的 CommitLog、ConsumeQueue,或 Kafka 的日志与复制协议,可以在后续底层文章里进一步解释;这里先把发送、接收和业务完成的边界落稳。

参考资料