资讯动态

不依赖中间件,用本地消息表实现分布式事务最终一致性

发布时间:2026/10/16 4:01:47 来源:尧图企业网站定制
最近在给一个订单系统做改造碰到一个特别典型的问题订单创建之后积分服务、库存服务都要跟着更新但它们不在同一个数据库里。怎么保证两边数据一致很多人第一反应是上分布式事务中间件可我们项目连消息队列都没装只有MySQL和SpringBoot。所以我最后选了“本地消息表”这套方案把分布式最终一致性给稳住了。这个方案的核心思路不复杂把一条消息记录和业务数据写进同一个本地数据库事务里再用定时任务把消息可靠地投递给下游下游处理完回传确认。整个过程不需要任何额外的消息中间件一个MySQL、一套SpringBoot代码就够。对很多中小项目、内部系统、工具链并不重的团队来说这是性价比极高的落地方案。这篇文章就把这个方案的来龙去脉、表结构设计、核心代码实现、踩坑记录都翻出来聊一遍。适合正被跨服务数据一致性问题困扰、又不想引入重型中间件的开发同学。1. 分布式一致性问题的来龙去脉1.1 一个典型的业务场景订单、积分、库存先看一个最常见的例子用户下单成功后除了订单库要写入订单记录积分系统要增加用户积分库存系统要扣减商品库存。这三个操作本质上是独立的服务、独立的数据库但在业务上必须“要么都成功要么都失败”。如果只是用一个微服务内部的本地事务只能保住当前服务对应的数据库。积分服务和库存服务不在同一个数据库里本地事务根本管不到它们。怎么办早期很多团队硬塞一个“分布式事务协调器”实际跑起来问题一大堆不光性能差一旦协调器挂了所有业务全部瘫痪。我在实际项目里遇到的场景更朴素调用积分服务接口时偶发网络超时订单已经入库但用户积分没加。第二天用户投诉说积分不对排查了好久才发现是跨服务调用没有一致性保障。当时的第一反应是上成熟的事务消息但项目组不想因为这一个需求就引入新的中间件毕竟运维成本也是钱。1.2 为什么强一致方案在微服务环境下很难落地很多人会问数据库不是有分布式事务吗两阶段提交不是教科书上的标准答案吗理论上确实可以但放到真实的微服务环境下问题就很具体了。两阶段提交要求所有参与方在事务期间持有数据库资源锁网络抖动、服务处理慢都会放大锁的持有时间。数据库的并发能力迅速被拖垮。更要命的是协调者在第二阶段一旦宕机所有参与方都得挂起等它恢复这种状态卡死的情况在生产环境里极难排查。所以真正敢在核心链路上用强一致的团队很少大多数都是说“我们理论上支持”实际上避而远之。1.3 最终一致性才是业务能接受的答案实际上大部分业务场景并不需要“实时强一致”需要的是“过一会儿能对上账”。这就是最终一致性。拿积分来说用户下单后延迟十来秒收到积分提醒完全不影响用户体验库存系统稍微晚几秒扣减只要在超卖边界控制好业务规则用户也感知不到。最终一致性对应的是BASE理论核心思想是“基本可用、软状态、最终一致”。落到技术上就是找一个机制保证本地操作成功之后消息一定不会丢消息送出去了下游不重复处理下游确认结果能回来。这三个问题解决掉跨服务的数据流就能自愈。本地消息表就是围绕这三件事设计的。2. 为什么选本地消息表不选其他方案2.1 候选方案横向对比先看一个表把常见的分布式事务方案放在一起对比。这里不评价好坏只讲它们在特定项目里的适配度。方案中间件依赖业务侵入性能表现实现复杂度适合场景两阶段提交数据库XA、协调器高锁时间长并发低高数据库规模小、强一致硬要求TCC方案无强制依赖很高要写try/confirm/cancel三套逻辑相对好最高资金类、强一致且可回滚事务消息高性能消息队列需支持事务消息中好中已有消息中间件延迟敏感本地消息表仅数据库低取决于轮询频率低中小项目、简单服务调用链这个表是我自己画的项目选型参考你会发现一个明显规律越是不依赖中间件、侵入性越低的方案往往越简单。本地消息表没有华丽细节但它在“订单要扣积分、扣库存项目组又不想上中间件”的场景里几乎是完美答案。2.2 本地消息表解决了什么问题本地消息表这个方案的精髓不在“消息表”三个字而在“本地”两个字。业务数据和消息记录放在同一个数据库、同一个本地事务里。要么业务数据和消息一起提交成功要么一起回滚这一步就解决了“业务成功但消息丢失”的问题。举个例子创建订单时事务里先插入订单记录再插入一条“订单创建成功”的消息记录。订单记录和消息记录同时提交或同时回滚。消息里带着订单号、商品编号、数量、积分值这些下游需要的完整字段不发那些还需要回查的引用ID避免下游为了拿数据再来回请求。之后定时任务扫描消息表把状态为待发送的消息取出来调用下游服务的接口或写入消息队列。下游处理完回调一个确认接口消息表里对应的记录状态置为已完成。如果下游一直不确认定时任务就根据重试策略反复投递直到成功。2.3 这个方案的边界和适用条件本地消息表不是银弹。它有两个明显的特性需要接受第一实时性受定时任务扫描频率影响如果你把扫描周期设成5秒那消息一般会在1到5秒内被送出去不是毫秒级第二消息表会不断膨胀需要配套清理策略。这个方案最适合的场景是服务调用链不长、业务对秒级延迟可接受、没有现成消息中间件、团队规模不大。如果业务量已经大到消息表百万级膨胀或业务链路复杂到需要精确路由和灵活组播那本地消息表会比较吃力需要改造成事件表或引入真正的消息中间件。还有一点必须说本地消息表其实是一个基础模板就算以后项目上了消息中间件这个思路依然有参考价值——把业务和消息写在一个事务里比直接用事务消息要简单直观得多。3. 落地第一步消息表设计与状态机3.1 表结构设计每个字段都有存在的理由设计消息表的时候我第一版只写了id、biz_id、type、payload、status这几个字段跑了一段时间发现根本不够用。后来迭代了几个版本最终稳定下来的结构对中小项目来说基本是标准答案。CREATE TABLE local_message ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, business_id varchar(64) NOT NULL COMMENT 业务流水号全局唯一用于幂等, business_type varchar(32) NOT NULL COMMENT 业务类型比如 ORDER_CREATED, payload varchar(2000) NOT NULL COMMENT 消息体JSON字符串, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 消息状态0待发送1发送中2已完成3死亡, retry_count int(11) NOT NULL DEFAULT 0 COMMENT 累计重试次数, next_retry_time datetime DEFAULT NULL COMMENT 下次可重试时间, max_retry_count int(11) NOT NULL DEFAULT 5 COMMENT 最大重试次数超过则置为死亡, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_business (business_id, business_type), KEY idx_status_next_retry (status, next_retry_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT本地消息表;逐字段说一下为什么这么设计。business_id和business_type是幂等键。下游收到消息后通过这两个字段判断这条消息是否已经处理过。business_id用业务流水号比如订单号它天然唯一。有的团队直接用订单号事件类型拼一个字段也行但拆成两列查询和定位问题都更清晰。payload存的是完整的业务数据JSON不是只存一个ID。这样下游拿到消息直接解析就能干活不用反向调用上游接口补数据。如果消息体比较大字段类型可以改成MEDIUMTEXT实际项目中一条订单数据压到2KB以内很常见。status是状态机的核心。0表示待发送1表示已捞取正在发送中2表示下游已确认完成3表示重试耗尽放弃处理。后面会详细讲状态流转。version是乐观锁版本号这个字段强烈建议加上。多个应用实例同时扫描消息表时乐观锁能保证只有一台机器能捞到某条消息避免重复投递。3.2 状态机流转从待发送到完成路径必须清晰状态机的设计决定了整个方案能不能自洽。我画过请太多遍草图最终形成这样一套流转规则业务事务内插入消息初始状态是0待发送。定时任务扫描到状态为0且next_retry_time小于等于当前时间的记录先将状态改为1发送中再做远程调用。远程调用返回成功状态维持1发送中等待下游业务处理完成后的确认回调。下游确认回调到达状态更新为2已完成。远程调用直接异常或调用成功但超时时间内未收到确认重试计数加1next_retry_time按退避算法往后推状态回退为0。重试次数超过max_retry_count状态改为3死亡人工或告警介入处理。重点说一下为什么发送成功之后状态仍然保持1而不是直接置为完成。远程接口返回成功只代表消息被下游接收了不代表下游的业务处理已经提交。如果此时把消息状态置为已完成万一下游拿到消息后业务处理抛异常这条消息就永远不会被再次投递等于丢了。保持1状态并设置一个超时阈值就能让超时未确认的消息重新进入重试队列。3.3 索引与多实例并发处理消息表的主查询是定时任务捞消息查询条件基本是status 0 AND next_retry_time now所以联合索引idx_status_next_retry (status, next_retry_time)非常关键。没有这个索引消息表一上万扫描查询就会拖垮数据库。多实例部署时version字段配合一条UPDATE语句就能实现原子抢占。定时任务捞到一批候选消息后不直接发送先执行如下更新UPDATE local_message SET status 1, version version 1 WHERE id ? AND status 0 AND version ?更新成功的实例获得处理权更新影响行数为0则说明其他实例已抢先处理直接跳过。这样不需要Redis锁也不需要分布式协调纯粹靠数据库行锁解决了并发重复投递问题。4. SpringBoot核心代码实现4.1 项目结构和基础依赖先交代一下项目环境SpringBoot 2.7MyBatis-Plus作为ORMMySQL 8.0JDK 1.8以上。本地消息表的功能完全依赖Spring自带的Scheduled注解和数据库事务不需要额外引入中间件客户端。目录结构按常规分包即可com.example.order ├── controller ├── service │ ├── OrderService.java │ └── LocalMessageService.java ├── mapper │ ├── OrderMapper.java │ └── LocalMessageMapper.java ├── entity │ ├── OrderDO.java │ └── LocalMessageDO.java └── task └── MessageSendTask.javaMyBatis-Plus提供了一个现成的IService和BaseMapper简单的CRUD不用自己写XML。真正需要手写SQL的是捞消息和更新状态这几处我放在Mapper接口里用注解实现。4.2 关键一步业务数据与消息记录同事务写入这是整个方案的地基。业务操作和消息插入必须放在同一个事务里绝不能先操作业务后单独插入消息哪怕中间只有几毫秒的空隙只要发生崩溃就会产生“业务成功消息缺失”的数据黑洞。Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private LocalMessageMapper messageMapper; Transactional(rollbackFor Exception.class) public void createOrder(OrderDO order) { // 1. 业务数据落库 orderMapper.insert(order); // 2. 组装消息记录 LocalMessageDO message new LocalMessageDO(); message.setBusinessId(order.getOrderNo()); message.setBusinessType(ORDER_CREATED); message.setPayload(JSON.toJSONString(order)); message.setStatus(0); message.setRetryCount(0); message.setMaxRetryCount(5); message.setNextRetryTime(LocalDateTime.now()); messageMapper.insert(message); } }这段代码里有一个隐藏的细节JSON.toJSONString(order)会在事务提交前就把订单对象序列化成字符串如果后续订单字段被修改序列化结果不受影响。消息内容在插入那一刻就固定了后续重试发送的都是这条JSON不会出现下游收到的数据和其他数据不一致的情况。注意next_retry_time初始值直接设成当前时间。这样事务一提交定时任务下一次扫描就能捞到这条消息不用等待额外延迟。4.3 定时任务投递捞取、抢占、发送、失败处理定时任务负责把待发送消息捞出来投递给下游服务。这里要讲清楚三类消息的捞取规则状态为0待发送且next_retry_time已到期的消息。状态为1发送中但update_time超过N分钟仍未收到确认回调的消息视为悬挂消息强制捞起重试。重试次数未超过限度的消息。Component public class MessageSendTask { Autowired private LocalMessageMapper messageMapper; Autowired private DownstreamClient downstreamClient; // 每5秒执行一次 Scheduled(fixedDelay 5000) public void scanAndSend() { ListLocalMessageDO pendingMessages messageMapper.scanPendingMessages( LocalDateTime.now(), LocalDateTime.now().minusMinutes(5), 100); for (LocalMessageDO message : pendingMessages) { // 乐观锁抢占只有更新成功的实例才能处理 int updated messageMapper.compareAndSwapStatus( message.getId(), message.getVersion(), 0, 1); if (updated 0) { continue; } // 重新查一遍拿到最新version确保状态已是1 LocalMessageDO lockedMessage messageMapper.selectById(message.getId()); try { // 调用下游服务 Result result downstreamClient.notifyOrderCreated( lockedMessage.getBusinessId(), lockedMessage.getPayload()); if (!result.isSuccess()) { // 业务返回失败走重试逻辑 messageMapper.failAndRetry(lockedMessage.getId(), lockedMessage.getRetryCount() 1, lockedMessage.getMaxRetryCount(), calculateNextRetryTime(lockedMessage.getRetryCount() 1)); } // 成功则保持status1等待确认回调 } catch (Exception ex) { // 网络异常走重试逻辑 messageMapper.failAndRetry(lockedMessage.getId(), lockedMessage.getRetryCount() 1, lockedMessage.getMaxRetryCount(), calculateNextRetryTime(lockedMessage.getRetryCount() 1)); } } } private LocalDateTime calculateNextRetryTime(int currentRetryCount) { // 指数退避1分钟、5分钟、15分钟、30分钟、60分钟 long delayMinutes new long[]{1, 5, 15, 30, 60}[ Math.min(currentRetryCount - 1, 4)]; return LocalDateTime.now().plusMinutes(delayMinutes); } }scanPendingMessages这个SQL要同时查两类消息一类是待发送且到期一类是发送中超时的悬挂消息。对应Mapper方法如下Mapper public interface LocalMessageMapper extends BaseMapperLocalMessageDO { Select(SELECT * FROM local_message WHERE (status 0 AND next_retry_time #{now}) OR (status 1 AND update_time #{timeoutThreshold}) ORDER BY id ASC LIMIT #{limit}) ListLocalMessageDO scanPendingMessages(LocalDateTime now, LocalDateTime timeoutThreshold, int limit); }这段SQL里第二个条件就是兜底机制。下游接口接收消息后没回调确认消息状态永远是1如果没有超时兜底这条消息就会永远挂在发送中状态。所以扫描任务必须把这类悬挂消息捞回来重新投递。幂等键此时就是保护伞下游重复接收也不会出问题。4.4 发送失败退避重试与死亡判定的SQLfailAndRetry这个操作在代码里是一句UPDATEUPDATE local_message SET status 0, retry_count #{newRetryCount}, next_retry_time #{nextRetryTime}, version version 1 WHERE id #{id} AND status 1注意这里执行的时候会判断retry_count是否已经达到max_retry_count。达到就直接置为死亡状态。我把这个判断放在SQL里用一条UPDATE语句条件化处理UPDATE local_message SET status IF(retry_count 1 max_retry_count, 3, 0), retry_count retry_count 1, next_retry_time CASE WHEN retry_count 1 max_retry_count THEN NULL ELSE #{nextRetryTime} END, version version 1 WHERE id #{id} AND status 1每次重试的间隔时间必须拉长不能每5秒就重试一次。如果下游服务正在重启或网络抖动高频重试只会增加下游压力。指数退避是实战中验证过的理性选择。我的默认策略第1次重试间隔1分钟第2次5分钟第3次15分钟第4次30分钟第5次60分钟之后直接死亡。这组参数可以在配置中心动态调整方便处理应急预案。4.5 下游确认回调把消息从半成品变成已完成下游消费方处理完业务后需要调用上游的确认接口。这个接口是本地消息表方案里最后一个关键连接点。RestController RequestMapping(/message) public class MessageConfirmController { Autowired private LocalMessageService messageService; PostMapping(/confirm) public Result confirm(RequestParam String businessId, RequestParam String businessType) { messageService.markCompleted(businessId, businessType); return Result.success(); } }MarkCompleted的执行逻辑Transactional(rollbackFor Exception.class) public void markCompleted(String businessId, String businessType) { LocalMessageDO message messageMapper.selectOne( new LambdaQueryWrapperLocalMessageDO() .eq(LocalMessageDO::getBusinessId, businessId) .eq(LocalMessageDO::getBusinessType, businessType) .last(LIMIT 1)); if (message null) { return; } // 只有发送中的消息才能确认完成防止一条已完成的消息被重复确认 if (message.getStatus() 1) { messageMapper.markCompleted(message.getId(), message.getVersion()); } }这段代码里有个细节确认之前要先判断消息的当前状态。如果消息已经被重试投递过很多次下游这次才确认而消息已经被判死亡那直接忽略确认等待人工介入看数据是否真的补上了。这种做法避免了“都已经确认完了又去重试”的逻辑混乱。4.6 消费方幂等重试风暴中的安全屏障整个方案里有一个大前提下游必须保证幂等。因为上游定时任务会重试投递且同一消息可能被多个实例重复投递下游必须做到“处理一次和处理一万次的结果一致”。最稳妥的幂等做法是维护一张去重表或利用业务表自身的唯一索引。Transactional(rollbackFor Exception.class) public void handleOrderCreated(String businessId, String payload) { // 1. 先查幂等表 if (idempotentMapper.existRecord(businessId)) { return; } // 2. 处理业务 OrderDO order JSON.parseObject(payload, OrderDO.class); pointService.increase(order.getUserId(), order.getOrderNo(), order.getAmount()); // 3. 业务成功后写幂等记录 IdempotentRecord record new IdempotentRecord(); record.setBusinessId(businessId); idempotentMapper.insert(record); }关键点是第三步的幂等记录和第二步的业务处理必须在同一个本地事务里否则可能出现业务成功但幂等记录没写后续重试又重复加积分。更稳妥的做法是给幂等表的business_id加唯一索引并发情况下就算两个请求同时进来数据库唯一索引也会挡住一个不会出现双写。收到重复消息时如果查到已处理过直接返回成功并正常回调确认接口。不能返回失败让上游继续重试否则会陷入死循环。5. 实战踩坑清单5.1 事务和远程调用的顺序问题这是我在第一版代码里踩过的坑也是新手最容易犯的错。最开始写的定时任务是这样的Transactional public void send() { ListLocalMessageDO messages messageMapper.scanPendingMessages(); for (LocalMessageDO message : messages) { downstreamClient.notify(message); // 远程调用在事务里 } }事务还没提交就发起远程调用数据库连接被事务持有远程调用要等下游返回连接就一直占着。如果下游处理慢连接池很快耗尽。更麻烦的是如果下游处理成功之后事务提交前数据库崩了本地事务回滚消息状态还是待发送下次扫描会重复投递下游已经处理过只能靠幂等兜住。正确做法是定时任务方法不开启事务捞消息、改状态、远程调用全部在事务外执行。本地数据库的操作只用单条UPDATESpringBoot默认会自动提交不需要显式Transactional。这样才能把连接占用时间压到最短。5.2 多实例重复扫描导致重复投递定时任务Scheduled注解在单实例环境下没有问题一旦服务多实例部署每个实例都会执行扫描同一秒钟可能有多个容器在捞消息。最初我用的是“先select再处理”的逻辑结果同一个消息被多个实例同时发送到下游。解决方式前面已经说过了靠乐观锁version字段做CAS更新。这事最核心的一点CAS更新必须和业务发送之间保证原子性不能让两个实例都CAS成功。CAS成功的那个实例继续处理没成功那个直接丢弃。如果你担心定时任务本身的多实例调度问题也可以给任务加分布式锁但先做CAS更新更简单直接不用额外引入锁组件。5.3 消息处理成功但确认回调丢失确认回调丢包这件事比想象中频繁。下游处理完业务回调上游确认接口时正好赶上网络抖动或上游服务重启确认请求没送达消息状态一直是1超时后会重新投递。下游再次收到重复消息看到幂等记录已存在直接返回成功并补发确认最终消息状态才会走到已完成。这个坑其实不算坑因为方案设计的时候就预料到了。真正需要注意的是确认接口的HTTP状态码。下游必须在收到上游返回的Result.success()之后才真正认为确认完成如果确认接口返回500下游要记录日志并再次尝试而不是直接吞掉异常。我见过某同事A在回调确认接口里写了return Result.success()写在try块的最前面后面业务逻辑还没执行完就已经“确认成功”导致消息状态提前完成业务数据却没补齐。这种问题排查起来特别隐蔽要注意。5.4 死信消息太多怎么办消息状态变成3死亡并不代表可以不管。我见过最夸张的一次某业务表的死亡消息堆了两万条全是同一个下游服务的接口地址写错了导致的。重启服务修掉问题后已经死亡的旧消息不会自动复活必须写脚本把这些消息的状态改回0重新投递。所以要做好死亡消息的监控和告警。我建议写一个定时任务统计所有状态为3且update_time在最近10分钟内的消息数超过阈值就推送告警到企业微信或钉钉群。人工介入后脚本查询死亡消息的payload确认可以重放就改状态、清空重试计数、重新设置next_retry_time。这比一条条人工处理高效得多。5.5 消息表无限膨胀的归档策略没有清理策略的消息表会随着业务量增长变成一个大坑。我的做法是状态为2已完成的消息超过7天后在业务低峰期迁移至local_message_archive历史表再从主表删除。迁移脚本用INSERT INTO ... SELECT DELETE结合一次处理一批不能一把梭全量扫避免锁表。-- 归档已完成7天的消息 INSERT INTO local_message_archive SELECT * FROM local_message WHERE status 2 AND update_time DATE_SUB(NOW(), INTERVAL 7 DAY) LIMIT 2000; -- 从主表删除已归档的记录 DELETE FROM local_message WHERE status 2 AND update_time DATE_SUB(NOW(), INTERVAL 7 DAY) LIMIT 2000;时间窗口必须细粒度执行建议放到凌晨2点到4点之间跑且每批不要超过2000条。消息表主键是自增的全量DELETE会让binlog压力很大分批删才能控制风险。归档历史表随时能查询追数据也不怕出问题后无据可查。6. 这套方案还能扩展成什么样6.1 多消费者场景的改造如果一条业务消息要发给多个下游服务最简单的方法是给消息表增加一个consumer字段将同一业务消息分成多条记录每条记录带不同的consumer标识。定时任务投递时按consumer字段选择对应的客户端调用确认回调时也要带着consumer参数。这样做的好处是每个消费者的投递状态相互独立。积分服务处理慢了不影响库存服务的投递节奏积分服务宕机了库存服务照常完成。代价是消息表会膨胀得更快归档逻辑要相应调整。我在实践里遇到多消费者场景后优先把一锤子的通用任务改成这种模式排查问题也方便看一眼某个consumer的消息状态就知道问题出在谁身上。6.2 从消息表升级为事件表本地消息表其实就是简化版的事件存储。当业务链路越来越复杂你会发现这个消息表承载的不只是分布式事务协调它已经有业务事件的味道了。可以逐步加入trace_id、parent_id、occur_time等字段让它变成可回溯的完整业务事件记录。到这一步就可以考虑做事件回溯、对账系统、审计分析。很多团队想搞事件驱动架构第一步就是先把消息记录统一收口。本地消息表作为起步方案后续平滑演进到事件总线的技术栈是很自然的路径思维模型是兼容的。6.3 与监控告警平台联动消息表本身是宝贵的数据源。把消息表的实时指标接入监控平台可以看到待发送消息数、发送中悬挂消息数、死亡消息数、平均投递耗时这四类核心健康度指标。特别是“发送中悬挂消息数”这个指标一旦持续上涨说明下游服务大概率出了问题需要立刻介入排查。有条件的团队还可以对每条消息从投递到确认的完整耗时做分位数统计。平均耗时突然翻倍往往意味着下游接口变慢这种指标比直接监控下游服务的TP99要更贴近业务实际。我这套方案里没有专门的监控埋点靠的就是定时查询消息表聚合数据成本低且有效。一些真实感受这个方案我在两个项目里用下来最大的体会是比想象中简单也比想象中需要纪律性。简单在于核心代码量很少两张表、三个接口、一个定时任务就完成整个闭环纪律性在于必须严格遵守“同事务写业务和消息”“发送不占事务”“下游必幂等”这三条原则任何一条被破坏一致性都会悄悄塌掉。有一个很有价值的技巧值得分享给消息表加上一个“手工重试Job”把死亡消息的id列表作为参数执行失败自动回滚事务成功后打印日志。这个Job在排查问题时特别有用很多问题的定位根本不用查日志直接查消息表就能断出是哪一步断了。本地消息表不只是一种方案它还会成为你最直观的数据对账工具。如果你正在被跨服务数据一致性问题困扰、又不想引入重型中间件按这篇文章的思路走一遍大概率能解决80%的问题。剩下20%的复杂场景等你把本地消息表用熟了自然知道下一步该往哪走。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑