MQ消息最终一致性方案实战:本地消息表、事务消息与对账补偿

发布时间:2026/10/16 1:29:44
MQ消息最终一致性方案实战:本地消息表、事务消息与对账补偿
上个月线上又出事故了用户支付成功订单状态也更新了但积分账户迟迟没有到账。客服按下“补发积分”按钮后用户反而收到了双倍积分。钱扣了积分乱了订单与积分之间横着一个消息队列——这种问题正是 MQ 消息最终一致性方案要解决的。我前前后后在这方面踩过不少坑也完整落过几套方案。这篇文章就从这条扣款链路说起把最终一致性的问题本质、三种主流技术路线、我自己落地的一套具体实现、一次排错全流程以及“最终”到底应该量化到什么程度一次讲清楚。适合后端开发、架构师特别是正在纠结“到底要不要上最终一致性、怎么上”的同学。1. 从一次线上扣款失败说起最终一致性到底在解决什么问题1.1 事故现场订单已支付、积分没到账先还原一下当时的场景。模拟项目X里用户在支付中心完成支付后支付中心会回调订单服务订单服务要做两件事一是把订单状态改成“已支付”二是让积分服务给用户加积分。最早的实现很直接就在回调接口里同步调用积分服务的接口先更新本地订单状态再发起一次远程调用。结果某天积分服务所在机房的网络出现抖动接口超时。订单服务这边的代码是“先更新订单状态再调积分接口调用失败就抛异常回滚”于是订单状态被回滚了但支付中心那边并不知道它还认为这次回调成功。用户实际付了钱却看到订单变成“未支付”积分自然也没加。这是一类非常典型的分布式一致性问题本地事务管不住远程调用一方的成功和另一方的成功无法绑定在同一个原子操作里。后来我们把逻辑改成“先更新订单状态再往 MQ 发一条消息积分服务异步消费”。这样确实把耦合拆开了但很快又冒出两个新问题一种是消息发送失败积分服务根本感知不到增量变更积分丢失另一种是消息被重复投递比如 MQ 发送超时后重试或者消费者处理完但回执失败积分服务就会加两次积分。丢消息和重复消息本质上都是因为“发消息”和“业务提交”之间缺少一个可靠的协调者。1.2 本地事务与远端调用的天然矛盾为什么分布式系统里很难做到像单机数据库那样的强一致核心原因在于一个业务操作通常跨越了多个独立的系统、数据库和网络而每个系统都有自己的本地事务。你把订单状态写在订单库里把积分流水写在积分库里这两个库没有办法共享同一个事务上下文。无论你用同步 RPC 还是异步消息都无法保证“订单库提交”和“积分库更新”这两个动作在同一时刻成功或失败。这时候从理论上讲分布式事务有 2PC、TCC 这些思路。但实际上很多业务场景并不需要实时的强一致用户支付完成之后积分晚几分钟到账完全可以接受。与其为了一个不存在的严格原子性付出巨大代价不如接受一个现实短时间内数据可能不一致但经过一段时间后所有副本最终会达到一致状态。这也就是最终一致性。它不承诺“任何时候都相同”只承诺“在没有新更新的情况下经过一个时间窗口后所有节点最终收敛”。理解了这一点你会发现 MQ 消息最终一致性方案并不是什么高深玄学它的核心价值就是解决一件事怎么把一个业务操作和一个异步消息的投递从“无法原子化”变成“看起来原子化”。不管用本地消息表还是事务消息本质上都是在对这个矛盾做补偿。1.3 最终一致性的适用边界在哪里不是所有业务都能用最终一致性。判断标准很简单你的业务能不能容忍一个短暂的不一致窗口以及这个窗口内出现问题后能不能补偿、能不能被接受。强一致性要求极高的场景典型的是账户余额的实时扣减、库存的唯一性扣减、订单支付状态本身。这些数据一旦在短时间内不一致可能会直接造成超卖、负数余额、重复支付等严重问题。这类场景应该优先考虑数据锁、强一致事务而不是让消息异步到处飞。可最终一致性非常适合的场景是那些“即使晚一点也对用户体验影响不大且可以通过补偿修正”的数据比如积分发放、用户通知、搜索索引同步、报表统计、缓存更新。这里要特别提醒一点线上经常有人把“库存扣减”也丢到消息方案里做最终一致性结果超卖得一塌糊涂。库存这种强约束资源要么用数据库行锁/乐观锁要么用专门的库存中间件。把最终一致性当成万能药是最容易踩的坑。2. 最终一致性的三条主流技术路线本地消息表、事务消息与对账补偿2.1 本地消息表最朴素也最可靠的落地方案本地消息表的思路非常朴素但也很实用。它的核心是在业务服务的同一个数据库里除了业务表之外再建一张消息记录表。业务操作和消息记录写入放在同一个本地事务里要么都成功要么都失败。如果业务事务提交了消息表里就一定有一条对应的消息记录。之后由后台定时任务扫描这张表把未发送的消息拿到 MQ 去投递投递成功后再更新消息状态。为什么它能解决一致性问题因为“写业务表”和“写消息表”在同一个事务里数据库层面天然保证二者同时生效。业务成功消息一定落库业务失败消息也不会出现。这样就完全绕开了“远程投递无法与本地事务同步”的问题。MQ 那一刻不可用也没关系消息安安静静躺在业务库里等恢复后再投递。它的不足也很明显业务表与消息表耦合在同一个库数据库本身会成为瓶颈每次业务提交都要多写一条消息记录多占一份存储和 IO。如果业务量很大需要做分库分表时消息表也要跟着拆复杂度会上来。但中小规模系统、团队人数不多、追求可控和可排查性的时候这套方案其实比很多花哨的方案都稳。2.2 事务消息用半消息和回查换更少的表事务消息方案避免了本地消息表占用业务数据库的问题。它的思想是把“消息是否发送成功”这件事交给 MQ 来协调。整个流程可以拆成四步第一步生产者先向 MQ 发送一条半消息注意此时消息只是暂存消费者不可见第二步生产者执行业务本地事务第三步如果本地事务提交成功生产者告诉 MQ 提交这条半消息消息变为可见并开始投递如果事务回滚则告诉 MQ 删除这条半消息第四步如果生产者完成本地事务后一直没有告诉 MQ 结果MQ 会主动回查生产者询问这条消息对应的本地事务到底提交了没有。回查机制是这套方案安全性的关键。MQ 在回查时会调用生产者提供的检查接口生产者需要根据消息里携带的业务标识去查询本地事务的真实状态然后告诉 MQ 是提交还是回滚。这里有个很容易犯的错后面我会专门展开回查接口必须查询与业务操作同库同事务的数据而不能依赖缓存、外部服务或者其他系统的状态。事务消息相比本地消息表它的伸缩性更好消息状态由 MQ 统一管理业务数据库里不用再多一张消息表。但代价是你必须充分信任并理解 MQ 的半消息和回查机制而且回查接口的实现质量直接决定了整个方案的可靠性。它并不是“换了个地方存消息”那么简单理解不到位照样会把数据搞乱。2.3 对账补偿最终兜底的“最后一根保险丝”很多人以为把消息可靠地投递出去最终一致性就搞定了。这远远不够。真实世界里可能发生消息丢失、消费者逻辑 bug、数据库数据被误删、甚至人为操作失误。消息中间件只能保证它投递了但保证不了你的业务系统没有写错。所以任何时候最终一致性方案里都还要配一个对账系统。对账的做法是定期比如每隔五分钟或每天用一个定时任务扫描业务系统中的关键数据把“应该发生”的事件和“实际发生”的事件做比对。比如检查所有已支付订单是否都有对应的积分流水把所有已发送的消息状态和消费者的处理结果做一次交叉核对。发现不一致时自动发起补偿或者生成工单交给人工处理。用一个大白话类比本地消息表、事务消息这些方案负责让正常情况下消息不漏不重而对账系统负责在异常情况下把漏网之鱼捞回来。前面方案做得再好最后这笔“保险丝”也不能省因为一旦生产环境出现数据不一致它能决定你几天能睡个好觉。2.4 选型对比与决策依据把三条路线放在一起对比才更容易选出适合自己的方案。技术路线核心思想优点缺点适用场景本地消息表业务表与消息表同库同事务后台任务负责投递实现简单不依赖 MQ 特殊能力状态可控、好排查消息表占用业务库分库分表和水平扩展时有压力中小规模、团队对 MQ 机制掌握一般、追求稳妥事务消息半消息加回查由 MQ 协调消息与本地事务的一致性业务库不落消息表伸缩性好状态由 MQ 托管依赖 MQ 的特殊能力回查逻辑实现不当有巨大风险大规模、高并发、团队能把回查机制吃透对账补偿定期扫描双方数据自动或人工修正不一致兜底能力最强能覆盖所有漏网问题修复有延迟补偿逻辑需要细致设计任何方案都应该搭配使用选型的决策依据我通常看四点。首先是团队对消息中间件内部机制的熟悉程度不理解半消息和回查机制之前不要轻易上事务消息其次是消息量和业务库压力如果业务库本来就吃紧本地消息表会增加额外负担再次是对一致性窗口的容忍度如果业务上要求分钟级别修正对账任务频率也要跟上最后是问题排查能力本地消息表出问题可以直接看数据库事务消息出问题则要同时查 MQ 状态、回查日志、消费者状态链路更长。3. 我们自己落地的一套本地消息表方案含关键代码与细节3.1 整体架构与消息流转设计我们最后落地的主方案是本地消息表加对账而不是事务消息。原因很实际我们当时的 MQ 集群没有现成的事务消息能力而且团队部分同学对半消息的机制理解还不够深。在稳定优先的项目阶段先用最可靠、最容易被大家理解的方式把链路跑通比追新更重要。这个选择在后续排障中确实带来了很大便利——出了问题直接翻业务数据库里的消息表就能定位。整体架构是这样订单服务收到回调后在同一个本地事务里写订单表和消息表后台一个定时任务每隔几秒扫描消息表把未发送的消息投递到 MQ积分服务作为消费者从 MQ 拉取消息先做幂等判断再执行业务操作操作完成后回调消息服务的接口确认消息已消费与此同时对账任务定时扫描订单表和积分流水表发现漏单自动触发补偿。整个链路里MQ 只是一个传输管道真正的可靠性保障在业务数据库和两张业务表里。在消息流转上状态机要设计清楚。我习惯把消息状态分成这样几类0表示待发送1表示已投递未确认2表示已完成3表示重试失败待人工处理。状态之间只能按固定方向迁移不允许跳变。这样设计的好处是任何时候看到一条消息都能准确知道它处在什么阶段也方便统计积压量和定位问题。3.2 消息表结构与业务操作同事务的关键实现消息表设计是整个方案的基石。字段上除了主键之外我至少会保留这几项msg_id消息唯一标识、biz_type业务类型、biz_id业务主键、payload业务负载、status消息状态、retry_count重试次数、next_retry_time下次重试时间、create_time 和 update_time。其中 msg_id 必须有唯一索引这是后续幂等和排查的基础。建表 SQL 大致长这样CREATE TABLE msg_record ( id bigint(20) NOT NULL AUTO_INCREMENT, msg_id varchar(64) NOT NULL COMMENT 消息唯一ID, biz_type varchar(64) NOT NULL COMMENT 业务类型如 order_paid, biz_id varchar(64) NOT NULL COMMENT 业务主键如订单号, payload text NOT NULL COMMENT 业务消息内容, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待发送 1已投递未确认 2已完成 3待人工处理, retry_count int(11) NOT NULL DEFAULT 0, next_retry_time datetime NOT NULL, create_time datetime NOT NULL, update_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_msg_id (msg_id), KEY idx_status_next_retry (status, next_retry_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;业务写入的关键是必须把订单更新和消息插入放在同一个Transactional方法里。伪代码如下Transactional(rollbackFor Exception.class) public void handlePaidNotify(PaymentNotify notify) { // 1. 更新订单状态 orderDao.updateStatus(notify.getOrderId(), PAID); // 2. 插入一条待发送的消息记录 MsgRecord msg new MsgRecord(); msg.setMsgId(generateMsgId(notify.getOrderId())); msg.setBizType(order_paid); msg.setBizId(notify.getOrderId()); msg.setPayload(buildPayload(notify)); msg.setStatus(0); msg.setNextRetryTime(new Date()); msgRecordDao.insert(msg); }这里要特别注意业务操作和消息插入之间不能有任何远程调用不能有 MQ 操作否则本地事务的原子性就被破坏了。有不少人图省事把“发消息”也写进这个事务方法里结果事务提交失败但消息已经发出去了消费者侧就会出现幽灵消息。正确的做法就是事务里只做本地操作投递动作全部放到事务提交之后。3.3 消息投递、确认与失败重试的细节投递环节由定时任务负责。每隔几秒扫描status 0 AND next_retry_time NOW()的消息逐条投递到 MQ。投递成功后把状态更新为 1表示已投递但还没收到消费确认。为什么要设置next_retry_time因为如果 MQ 长时间不可用或者消息一直投递失败我们不能让定时任务每秒钟都把这批失败消息拉出来重试一遍那样会打爆数据库和 MQ。正确的做法是采用指数退避第一次失败后等 1 分钟第二次等 5 分钟第三次等 15 分钟最多尝试 10 次。投递成功后消息进入“已投递未确认”状态。消费者处理完业务并提交本地事务后会调用我们的确认接口把消息状态更新为 2。这里有个很常见的问题消费者已经执行完业务逻辑但在回调确认接口时网络超时了导致消息状态迟迟停留在 1。定时任务在超时后只能重新投递这条消息于是消费者又收到一次重复消息。这也是我为什么一直强调幂等设计在最终一致性方案里绝对不可或缺。如果一条消息重试超过 10 次仍然没有成功我会把它的状态改成 3踢出正常的重试队列。定时任务扫描时专门跳过 3改为由告警系统通知负责人介入。这样做可以避免问题消息和正常消息混在一起导致积压指标被污染。3.4 消费者侧幂等消费的唯一键设计消费者侧的核心是保证“同一业务消息只被有效处理一次”。MQ 只能保证消息不丢不能保证严格不重复尤其是在投递超时重试、消费处理完成但回执失败这些场景下重复几乎是必然的。所以幂等必须设计在消费者自己的数据库里。最简单可靠的幂等方案是建一张消费者记录表以 msg_id 作为唯一键。消费者收到消息后先尝试插入记录如果插入成功说明还没处理过继续执行业务逻辑如果插入时遇到唯一键冲突说明这条消息已经被处理过了直接返回成功不再执行任何业务逻辑。表结构大致如下CREATE TABLE consumer_record ( msg_id varchar(64) NOT NULL, biz_id varchar(64) NOT NULL, consume_time datetime NOT NULL, PRIMARY KEY (msg_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;在代码里我习惯的做法是将“插入消费记录”和“业务更新”放在同一个本地事务中。比如积分服务加积分的逻辑就是先插入消费记录再插入积分流水再更新用户积分总额全部在一个事务里完成。如果加积分成功但事务提交超时重复消息过来时唯一键冲突会让它直接走到“已处理”分支不会再次加积分。有人会问只靠业务主键不行吗比如只记录订单号发现订单已加过积分就不再加。顺着这个思路做当然也可以但建议还是用 msg_id 做唯一键更稳妥。因为同一笔订单可能产生多条不同类型的消息比如支付成功消息、提醒消息、对账消息如果都用订单号做幂等键会互相干扰。msg_id 精确到单条消息是更细颗粒度的幂等保证。3.5 定时任务扫描与补偿处理定时任务除了负责投递还要负责补偿。扫描逻辑要分成两层。第一层是常规投递扫描处理 status0 的消息第二层是超时重查扫描处理 status1 且超过超时阈值比如 10 分钟仍未确认的消息。对于那些已经进入状态 3 的消息由后台任务自动生成告警推到值班群人工介入处理。对账任务和定时扫描任务最好分开跑。对账任务比较重一般周期可以放在 5 分钟到 1 小时之间视业务容忍度而定。它会从订单表查出最近一段时间所有已支付订单再到积分流水表里比对是否存在对应流水。如果发现缺失就把该订单重新生成一条补偿消息投递到 MQ。对账任务同时也是本地消息表方案的第二道保险哪怕前面的投递和重试机制全部失效对账任务也能兜底。补偿处理有一个原则需要守住补偿消息一定不能和普通消息共用同一个 msg_id否则消费者侧的幂等记录会直接把补偿请求拦掉。我们实际做法是在补偿消息的 msg_id 上加一个特殊前缀比如compensate:order_paid:{orderId}:{actualTime}。这样既保留了业务相关性又绕开了历史幂等记录。4. 踩坑实录半消息回查重复发积分的完整排查链路4.1 问题的表象与第一波排查本地消息表方案稳定跑了一阵之后我们尝试在一个边缘业务上试点事务消息。当时计划很简单也按官方文档做了半消息、提交、回滚和回查的接入。上线后没过几天对账任务报警有一批用户订单不存在但积分账户却多出了积分。这意味着消息被投递了业务也执行了但对应的本地事务根本没有提交成功。第一波排查从表象入手。我先去 MQ 控制台查了这批消息的投递记录确认消息确实被消费者拉走了状态是成功。然后去看积分服务日志发现积分加分的业务逻辑确实正常执行了。到这里链路里“消费者”这一侧没有问题问题几乎肯定出在生产者侧的“半消息提交”判断上要么提交信号被错误发送要么回查结果被错误返回。4.2 顺着消息ID打通生产者日志接下来要做的是把这条消息的完整生命周期串起来。我拿到 MQ 里的 msg_id 之后去生产者服务的日志系统里反查这个 msg_id 关联的所有日志。结果很意外生产者的日志显示本地事务在业务校验阶段就抛了异常事务回滚了根本没有生成订单。但 MQ 侧却收到了“提交”指令。这一步非常关键。既然本地事务回滚了那谁告诉 MQ 可以提交答案只能是回查接口。当时的生产者代码里回查接口的实现是“查缓存里有没有该订单的标记有就返回提交成功没有就返回回滚”。缓存里的标记却在半消息发送之后、本地事务提交之前就被写入。本地事务回滚时并没有清理这个缓存标记于是回查接口拿缓存判断误以为事务成功向 MQ 返回了提交。消息因此被投递到消费者积分被加了出去。4.3 定位到回查逻辑的漏洞这个 bug 的根子在于回查逻辑没有和本地事务绑定。回查接口的正确语义是回答一个问题这条消息对应的本地事务最终到底提交了没有。所以它的依据必须是和业务操作同库、同事务写入的数据。当时的实现却把判断依据放到了外部缓存而且这个缓存还是在事务提交前写入的完全违背了回查设计的基本前提。项目里为什么会出现这种写法当时的想法是“半消息发出后立刻写个缓存标记后续流程想快速拿到订单状态”。问题是快速状态和真实状态之间没有对齐一旦本地事务回滚缓存就成了脏数据。这个坑提醒我们在事务消息方案里回查接口必须谨慎再谨慎任何为了“快”而引入的外部状态都可能在某个瞬间让数据产生不可逆的错误。4.4 修复方案与事后验证修复方案分了三步。第一步回查接口改读业务数据库里的事务记录表用 msg_id 直接查查得到且状态为提交成功才返回提交查不到或者状态为回滚一律返回回滚。第二步删掉半消息发送后立刻写缓存标记的逻辑改成本地事务提交成功后再写同时给缓存标记设置过期时间防止以后出现脏数据。第三步给对账任务加上自动冲正能力针对“订单不存在但积分流水存在”的数据自动扣回多发放的积分。修复之后我们又做了两轮验证。第一轮让本地事务发生回滚观察 MQ 控制台确认回查返回的是回滚消费者没有收到消息。第二轮模拟 commit 消息丢失的情况让半消息停留在待回查状态此时回查接口能查到 msg_record 记录返回提交消息被再次投递。两轮场景都通过才把修复版本重新上线。4.5 从这次踩坑提炼出的三条通用教训这个事故虽然出在边缘业务但带来的教训我到现在都记得。第一条事务消息的回查逻辑必须基于本地事务的事实不能用缓存、不能用外部服务状态、更不能在事务提交之前写任何“提前状态”。第二条消息投递成功不等于消费成功消费成功不等于回执成功每一层都可能发生重复或丢失幂等和确认机制在任何链路上都不能省。第三条对账系统不是可有可无的补充而是整套最终一致性方案的最后一根保险丝。这次如果不是对账任务先报警脏数据可能在系统里躺上很久。5. 关于“最终”的量化延迟指标、监控报警与故障演练5.1 最终一致性应该控制在什么时间量级“最终”不是一个可以无限拖延的词。很多项目出问题就是因为没人定义“最终到底多最终”。我建议每个链路在建立初期就明确一致性窗口和可量化指标。以我们当时的积分场景为例正常情况下消息从产生到消费完成P95 要小于 5 秒P99 要小于 10 秒。对账任务的周期定在 5 分钟这条链路最迟的修复时间是下个对账周期因此所有指标都可以围绕这两个数字设定。具体到报警上我会先定一套基础 SLI。指标项正常目标报警阈值消息产生到消费完成 P95 5 秒 10 秒消息产生到消费完成 P99 10 秒 30 秒消息表待发送积压数量 50 500重试次数超过 5 次的消息数0 10死信消息数0 0这套指标表建议贴在项目文档里每季度重新审视一遍。业务变了、流量变了指标也该跟着调整。5.2 监控和报警的埋点设计监控光看 MQ 的堆积量是不够的因为最终一致性问题往往发生在生产者和消费者的业务逻辑里。我会在每个关键节点埋点生产者生成消息时记录耗时投递成功和失败各记一个计数消费者收到消息后记录消费开始时间消费完成或失败再记一个计数。把这些数据接入监控系统就能算出刚才说的 P95、P99 延迟。报警规则也要分级。死信消息出现属于必须立即处理的问题配短信和电话消息积压超过阈值属于需要尽快排查的问题报警到值班群的即时通知重试次数超过 5 次的量很大说明可能有一个系统性 bug需要拉起专项排查。还有一条线上经常忽略的数据库消息表扫表的耗时也要监控。如果扫描任务一次运行时间超过几秒后面消息积压只会越来越严重这不是 MQ 的锅是数据库查询或锁竞争出了问题。5.3 故障演练怎么设计才算有效监控配好之后还必须用故障演练来验证。我们当时设计过四类演练场景每个都实打实跑过。第一类停掉消费者服务 5 分钟观察消息是否积压、重启后是否自动恢复消费。第二类人为把 MQ 连接地址改错模拟 MQ 不可用观察本地消息表是否正常积压恢复后是否能自动补投。第三类手动触发同一消息重复投递几十次验证消费者幂等表是否真的能拦截全部重复。第四类人为把消费者的回执接口改成失败验证超时重投逻辑是否符合预期。演练结束后要记录两个数字恢复时间和数据完整率。恢复时间是指从故障发生到数据完全对齐消耗了多久数据完整率是指演练后有业务数据的偏差程度。跑一次演练发现数据对不齐比在线上事故里发现要好太多。我个人的经验是这类演练最好纳入每季度的常规运维计划不要等出了事故才想起来。5.4 我的几点实操体会最后说几句掏心窝的话。本地消息表这套方案说出来确实不炫但它把最复杂的状态转移交给了数据库事务理解成本极低出问题也最好排查。后面当我们试点事务消息时团队里真正能把半消息和回查机制讲清楚的人其实不多这直接导致了那次线上脏数据事故。所以我的建议是如果你的团队对消息中间件内部机制掌握得一般宁可先用本地消息表别急着上更高级的玩法。反过来如果你所在团队已经能熟练掌控半消息、回查逻辑和 MQ 内部状态流转事务消息在扩展性和对下游的友好度上确实更有优势。但无论选哪种对账系统一定要配没有对账兜底的一致性方案在线上环境里是站不住脚的。毕竟线上永远比你想象的更容易出意外而最终一致性方案存在的意义就是让这些意外发生后系统仍然能在有限时间内恢复到一个干净状态。