消息怎样不丢失:发送确认、持久化、消费提交与业务对账
沿订单支付后发券的链路,解释消息在生产、存储和消费阶段的丢失窗口,比较 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 的日志与复制协议,可以在后续底层文章里进一步解释;这里先把发送、接收和业务完成的边界落稳。
参考资料
- RocketMQ 4.x:普通消息发送与最佳实践:发送方式、发送结果及业务侧可靠性。
- RocketMQ 5.0:消费者类型、消费重试、存储与清理:处理完成、重试和保留边界。
- Kafka 4.1:生产者配置、Topic 配置、KafkaConsumer API:复制确认、ISR 与消费位点。
- RabbitMQ:可靠性指南与确认机制:生产确认、消费确认和故障重投。
如果这篇文章对你有帮助