☰
SpringBoot本地消息表:无中间件实现分布式事务最终一致性
2026/10/16 11:53:21 网站建设 项目流程

这两年做交易类的微服务项目,最头疼的往往不是业务逻辑本身,而是“下单成功之后,积分没加”、“库存扣了但订单显示失败”这种数据对不上的问题。微服务一拆,数据库跟着拆,以前一个本地事务能搞定的事情,现在得跨服务跨库去协调。一上来就上 TCC、Seata、消息中间件,很多团队其实扛不住那个成本。所以我这次想聊聊一个很务实的方案:SpringBoot 配合本地消息表,在不依赖任何额外中间件的情况下,把分布式事务的最终一致性做起来。这个方案非常适合那些还在用单库、服务拆了但不想立刻引入 MQ 的团队,尤其是订单、支付、积分、账户这类核心交易链路。

本地消息表不是什么新东西,它的思想其实很朴素:把“发消息”这一步和“业务操作”放在同一个本地数据库事务里,业务成功了,消息一定落库;业务失败了,消息也跟着回滚。之后由一个后台任务去扫这张表,把消息挨个投递给下游服务,下游消费成功再更新消息状态。没有 Kafka、没有 RocketMQ、没有 Seata,只要一个关系型数据库加一个定时任务,就能把“跨服务的最终一致性”给撑起来。

这篇文章我会从问题拆解开始,逐步讲清楚表结构怎么设计、事务怎么编排、定时任务怎么写、消费端幂等怎么做,最后再把我实际踩过的坑和排查经验整理出来。适合正在做服务化拆分、又不想一开始就引入重载中间件的朋友参考。

1. 分布式事务的痛点与本地消息表方案的定位

1.1 微服务拆分后,为什么一个简单操作会变得这么难

先把场景具象化:用户在商城下单,订单服务创建一条订单记录,同时需要告诉库存服务扣减库存,告诉积分服务给用户加积分。在单体时代,这三个操作都在一个数据库里,一个@Transactional就完事,要么全成功,要么全回滚。

拆分成微服务之后,订单库、库存库、积分库各管各的。下单这个动作变成了“订单服务本地写库 + 远程调用库存服务 + 远程调用积分服务”。如果调库存服务成功了,调积分服务超时了,怎么办?订单已经建好了,不可能回滚库存,积分又没加上。用户看起来就是“钱扣了,积分没到账”。

很多团队第一反应是上分布式事务框架。但无论是 XA 两阶段提交还是 TCC,都有不小的代价。XA 对数据库支持和锁时间要求高,TCC 需要你为每个服务手写 confirm 和 cancel 逻辑,开发和维护成本都不低。更现实的问题是,业务量根本没大到需要那种级别的一致性保障,杀鸡用牛刀,还把自己绑死在特定中间件上。

1.2 本地消息表的核心思想:把“远程调用”降级为“异步投递”

本地消息表这个方案,核心思路是:你不需要在本地事务执行过程中远程调用别人,你只需要在这个事务里往自己库里写一条消息记录。整个分布式操作被拆成了两个阶段:

第一阶段,本地事务里完成业务写操作,同时把需要通知下游的消息写入本地消息表。这个事务只涉及本地库,所以它是绝对可靠的。

第二阶段,后台定时任务扫描本地消息表,找到状态为待发送的消息,将这些消息投递给下游服务的接口。投递成功,更新消息状态为已送达;投递失败,保留待发送状态并记录重试次数,等下一次扫描再试。

下游服务收到消息后,执行自己的业务逻辑。因为消息可能被重复投递,所以下游必须做幂等,重复执行不会产生副作用。

整个过程没有分布式锁、没有全局事务、没有消息中间件,只有一个可靠的本地数据库事务加一个轮询任务。最终的效果是:上游业务操作成功了,消息一定会被投递出去;下游因为各种原因失败了,会不断重试,直到成功或者进入死信。

1.3 为什么说它是“无中间件依赖”的可靠方案

拿它和引入消息中间件做对比。你用 RocketMQ 的事务消息,需要部署 Broker,需要保证 Broker 的高可用,需要处理消息堆积、消费位点这些运维问题。你用 Kafka,也一样,而且 Kafka 在事务消息这块的支持并不算特别顺手。

本地消息表方案,依赖的东西只有三个:业务数据库、定时任务、HTTP 调用(或者 RPC 调用)。这三个东西在绝大多数微服务项目里本来就有。数据库你不可能不用,定时任务用一个@Scheduled注解就能起,远程调用用 Feign 或者普通的 HTTP Client 都行。所以从落地角度看,它几乎零额外成本。

当然,它也是有代价的。最大的代价是数据一致性达成有时间窗口,不是实时的。这个窗口取决于定时任务扫描的频率。第二个代价是消息表本身会无限增长,需要定期清理。第三是投递的可靠性完全靠自己的重试机制保障,如果重试逻辑写得不够健壮,消息可能会丢。这些代价在中小规模场景下完全可以接受,并且可以通过设计来规避,后面我会展开讲。

2. 方案整体设计:表结构与消息状态机

2.1 一个订单加积分的具体例子

空谈概念不好理解,我用一个具体的例子贯穿全文。

假设有两个服务:订单服务(Order Service)和积分服务(Point Service)。用户下单后,订单服务先在自己的库里创建订单,然后要通知积分服务给这个用户增加 100 积分。我们不允许在创建订单的本地事务里直接远程调用积分服务,因为那样又回到了分布式事务的坑里。

正确做法是:订单服务在同一个本地事务里,插入订单记录、插入一条本地消息,消息内容是“给用户 ID 为 9527 的用户增加 100 积分,关联订单号 ORDER-20250101-0001”。事务提交后,后台任务扫描到这条消息,通过 HTTP 调用积分服务提供的接口。积分服务收到请求后,先检查这条消息是否已经处理过,没处理过才真正加积分,然后返回处理成功。后台任务收到成功响应后,把这条消息的状态标记为已完成。

如果积分服务挂了,调用失败,消息状态保持待发送,重试次数加一。下一次扫描再试。如果一直失败,超过最大重试次数,消息进入死信状态,由人工排查处理。

这个例子里,订单记录和积分数据最终一定是一致的,只是中间可能有延迟。对大多数业务来说,下单后积分延迟几秒甚至几分钟到账,完全不是问题。

2.2 本地消息表的字段设计

消息表是整个方案的核心载体,字段设计直接影响后续开发的复杂度和可靠性。下面是我自己整理过的一版结构,虽然不是最精简的,但各方面考虑得比较周全。

CREATE TABLE `local_message` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `msg_id` VARCHAR(64) NOT NULL COMMENT '全局唯一消息ID,消费端幂等依据', `biz_key` VARCHAR(128) NOT NULL COMMENT '业务键,例如订单号:ORDER-20250101-0001', `msg_type` VARCHAR(32) NOT NULL COMMENT '消息类型,例如 POINT_ADD', `msg_body` TEXT NOT NULL COMMENT '消息体,JSON格式,存放下游需要的数据', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待发送,1已发送,2已完成,3死信', `retry_count` INT NOT NULL DEFAULT 0 COMMENT '已重试次数', `next_retry_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 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`), UNIQUE KEY `uk_msg_id` (`msg_id`), KEY `idx_status_next_retry_time` (`status`, `next_retry_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地消息表';

字段设计的逻辑:

msg_id是全局唯一消息 ID,一般用 UUID 或者雪花算法生成。它有两个作用,一个是消息表本身唯一,另一个是下游消费端拿它做幂等判断。如果你没有这个 ID,下游面对多次投递只能靠业务键去重,很容易漏,所以必须一开始就定下来。

biz_key是业务键,比如订单号、支付流水号。排查问题的时候,你通过biz_key就能快速找到某个订单关联的所有消息,不用去翻无聊的日志。它也能辅助消费端做幂等,比如消息类型是“增加积分”,消费端可以用“订单号+用户ID”建唯一索引,天然去重。

msg_body存放 JSON 格式的消息内容。我不建议只存一个下游服务的接口 ID,因为这样排查和补偿的时候你根本不知道当时要发什么数据。把完整的 JSON 放进去,后续扩容、死信重放都很方便。

status是状态字段。0、1、2、3 四个值足够用了,不需要更精细的状态。有些人会加一个“发送中”状态,但我觉得没必要,因为你没法区分“发送中”和“超时失败”,不如用待发送加重试次数来处理。

next_retry_time很关键。定时任务扫描的时候,不是扫所有待发送的消息,而是只扫next_retry_time小于当前时间的消息。这个字段是实现指数退避的基础,没有它,消息失败后只能固定频率重试,容易把下游打到半死。

索引设计上,不要单独对status建索引,因为数据量大了之后状态区分度不高。(status, next_retry_time)联合索引才是真正会被查询条件用到的索引。

2.3 消息的状态流转与生命周期

消息在表里面会经历几个状态,理解清楚了才能写出正确的处理逻辑。

消息插入时,状态为 0,即待发送。后台任务扫描时会把状态为 0 且next_retry_time到时的数据捞出来,然后一条一条投递。

投递前,我会建议把状态先更新为 1,即已发送。这么做的目的是告诉其他节点“这条消息我正在投递,你别碰”。如果投递成功,再把状态更新为 2;如果投递失败,回滚状态到 0,并更新重试次数和下次重试时间。

为什么投递前要先置为已发送?因为这里涉及分布式环境下的并发问题。如果你不改状态,两个定时任务节点可能同时扫描到同一条待发送的消息,然后同时去调用下游接口,造成重复投递。虽然下游有幂等兜底,但能少一次重复就少一次。

失败回滚时的逻辑要小心。当投递失败后,不能简单地把状态改回 0,还要同时把retry_count加一,并计算新的next_retry_time。如果重试次数达到上限,就把状态置为 3,即死信。

状态机的规则可以整理成下面这个表格:

操作原状态新状态附带更新
插入消息无0 待发送设置next_retry_time为当前时间
开始投递0 待发送1 已发送无
投递成功1 已发送2 已完成更新update_time
投递失败且未达上限1 已发送0 待发送retry_count+1,重设next_retry_time
投递失败且达上限1 已发送3 死信记录最后一次错误信息
人工重放死信3 死信0 待发送重置retry_count

2.4 主流程串联起来看

把前面这些内容串成一个完整时序,大致是这样:

用户在订单服务下单,订单服务开启本地事务,插入订单数据,同时生成一条消息插入local_message表。事务提交。后台定时任务每秒扫描一次,发现待发送消息,取出消息内容,调用积分服务接口。积分服务收到请求后,先检查自己的去重记录表里有没有这条msg_id,没有则执行加积分操作并记录msg_id,返回成功。定时任务收到成功响应,将消息状态更新为已完成。

如果积分服务返回失败或者超时,定时任务捕获异常,把消息状态改回待发送,重试次数加一,下次重试时间按退避策略往后推。如果重试到第 10 次还是失败,消息进入死信状态,等待人工介入。

整个过程设计下来,你会看到没有任何一个环节依赖第三方中间件。本地事务保证业务数据和消息数据同时成功同时失败,后台任务保证消息最终会被投递出去,消费端幂等保证即使重复投递也不会产生重复数据。这三点合起来,就是最终一致性。

3. SpringBoot 实现落地:本地事务与定时投递

3.1 工程依赖与项目结构

实现层面我用 SpringBoot 来写。需要引入的基础依赖包括 Spring Web、Spring Data JPA(或者 MyBatis)、MySQL Driver,再加一个spring-boot-starter-web用来提供下游接口。如果你用的是 MyBatis-Plus,也可以,核心逻辑不受影响。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

单机演示可以直接用@Scheduled做定时任务。如果是多节点部署,后面我会讲怎么加分布式锁。

3.2 核心代码:订单事务与消息表写入同进同退

这一步是整个方案的灵魂。很多初学者会把“业务操作”和“消息写入”拆成两个事务去做,先写订单,再写消息表,认为只要都成功了就没事。但问题在于:如果订单写成功了,消息表写入时数据库突然宕机,或者事务提交时部分成功,会出现订单有记录但没消息的情况。没有消息,下游就永远不会被通知,数据不一致。

正确姿势是把业务操作和消息表插入放在同一个@Transactional事务里。我用一个简化版的订单创建来演示:

@Service public class OrderService { @Resource private OrderRepository orderRepository; @Resource private LocalMessageRepository messageRepository; @Transactional(rollbackFor = Exception.class) public void createOrder(OrderCreateRequest request) { // 1. 业务操作:创建订单 Order order = new Order(); order.setOrderNo(request.getOrderNo()); order.setUserId(request.getUserId()); order.setAmount(request.getAmount()); orderRepository.save(order); // 2. 构造消息体 Map<String, Object> body = new HashMap<>(); body.put("userId", request.getUserId()); body.put("orderNo", request.getOrderNo()); body.put("points", 100); // 3. 写入本地消息表 LocalMessage message = new LocalMessage(); message.setMsgId(UUID.randomUUID().toString()); message.setBizKey(order.getOrderNo()); message.setMsgType("POINT_ADD"); message.setMsgBody(JSON.toJSONString(body)); message.setStatus(0); message.setRetryCount(0); message.setNextRetryTime(new Date()); messageRepository.save(message); } }

这里的@Transactional确保了要么订单和消息都提交,要么都回滚。如果订单保存失败,消息也不会落库;如果消息落库失败,订单同样被回滚。

注意,事务方法内部不要有任何远程调用,也不要为了“优化性能”在事务里先调远程再删除消息,这些都是反面教材。事务里只做本地数据库操作,远程投递一定是事务提交之后的事情。

3.3 后台定时任务:扫描并投递消息

事务提交后,消息静静躺在表里。接下来就需要一个后台任务去把它捞出来,投递给下游。

先用一个最简单的@Scheduled版本:

@Component public class MessageSenderJob { @Resource private LocalMessageRepository messageRepository; @Scheduled(fixedDelay = 1000) // 每轮执行完间隔1秒 public void scanAndSend() { // 捞取待发送且到期的消息 List<LocalMessage> messages = messageRepository.findPendingMessages(new Date(), 100); for (LocalMessage message : messages) { // 先把状态置为已发送,防止其他节点重复处理 int updated = messageRepository.compareAndSetStatus(message.getId(), 0, 1); if (updated == 0) { continue; } try { // 投递:远程调用积分服务 PointAddResult result = pointClient.addPoints( message.getMsgBody()); if (result.isSuccess()) { messageRepository.updateStatus(message.getId(), 2); } else { handleFail(message); } } catch (Exception e) { log.error("message send failed, msgId={}", message.getMsgId(), e); handleFail(message); } } } }

这里有个细节值得说:扫描之后、投递之前,先做一次状态比较更新(CAS)。SQL 大概长这样:

UPDATE local_message SET status = 1 WHERE id = ? AND status = 0

这个更新影响行数为 1,才说明当前节点抢到了这条消息的处理权。如果影响行数为 0,说明有其他节点已经把状态改成 1 了,当前节点直接跳过。这是在多节点部署环境下防止重复投递的第一道防线。

3.4 失败重试与死信判断

先看handleFail方法怎么写。这里需要决定重试策略。

我采用的是一种指数退避策略,按照 1 分钟、5 分钟、30 分钟、2 小时、12 小时、24 小时这样的节奏去推。每次失败,根据已重试次数算出下一次可执行时间。

private static final int MAX_RETRY_COUNT = 10; private void handleFail(LocalMessage message) { int nextRetryCount = message.getRetryCount() + 1; if (nextRetryCount >= MAX_RETRY_COUNT) { // 超过最大重试次数,标记死信 messageRepository.updateDead(message.getId()); log.error("message reaches max retry count, msgId={}", message.getMsgId()); return; } Date nextTime = calcNextRetryTime(nextRetryCount); messageRepository.updateRetry(message.getId(), 0, nextRetryCount, nextTime); } private Date calcNextRetryTime(int retryCount) { long delayMinutes = Math.min(24 * 60, 1L << Math.min(retryCount, 10)); return new Date(System.currentTimeMillis() + delayMinutes * 60 * 1000); }

这段代码的核心是把重试间隔从短到长逐步扩大,避免在下游服务恢复之前疯狂重试,把下游打垮。重试到第 10 次依然失败,就进入死信,等待人工处理。

3.5 下游消费端幂等实现

消息投递到下游后,下游需要保证幂等。为什么一定需要?因为网络是不可靠的,即使上游做了“已发送状态”的 CAS,依然可能出现这种情况:下游处理成功了,但返回响应的过程超时了。上游捕获到异常,认为投递失败,于是重置状态为待发送,再次投递。下游如果没做幂等,同一条加积分消息就被执行两次,用户凭空多出 200 分。

幂等设计的最稳妥方式是,下游建一张消费记录表,以msg_id作为唯一键:

CREATE TABLE `point_received_message` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `msg_id` VARCHAR(64) NOT NULL, `order_no` VARCHAR(64) NOT NULL, `user_id` BIGINT NOT NULL, `status` TINYINT DEFAULT 0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_msg_id` (`msg_id`) ) ENGINE=InnoDB;

积分服务接收到请求后,先尝试插入一条消费记录。如果插入成功,说明这是第一次处理,继续执行加积分逻辑。如果插入时抛出唯一键冲突异常,说明之前已经处理过,直接返回成功。

@PostMapping("/addPoints") public Result addPoints(@RequestBody PointAddRequest request) { try { receivedMessageRepository.save(new ReceivedMessage(request.getMsgId())); } catch (DuplicateKeyException e) { return Result.success(); // 已处理过,直接返回成功 } pointService.addPoints(request.getUserId(), request.getOrderNo(), request.getPoints()); return Result.success(); }

技巧:这里的去重表插入要和加积分业务操作放在同一个本地事务里。因为如果你先插入消费记录,再执行加积分,加积分失败但消费记录已提交,下次消息来了直接返回成功,积分还是没加上。反过来,如果加积分成功但消费记录提交失败,下次又会重复加积分。所以幂等表中插入记录与业务更新必须同事务。

一个更简单的变通方案是,如果你的业务表本身有唯一索引,比如积分流水表有order_no的唯一索引,那么可以直接在流水表上做幂等,不用额外建消费记录表。但通用性不如独立消费记录表。

4. 实际项目中的坑点与排查经验

方案虽然简单,但落地过程中有不少隐藏的坑。我按照自己踩过的顺序,把最典型的几个问题整理出来。

4.1 最大坑:在本地事务里做了远程调用

我见过不止一个团队把“本地消息表”做歪了。他们的代码是这样的:

@Transactional public void createOrder(OrderCreateRequest request) { saveOrder(request); saveMessage(request); // 在事务内部直接远程调用积分服务 pointClient.addPoints(request); }

这样写之后,事务的提交被拉得很长,数据库连接被长期占用。如果积分服务响应慢,事务要等它;如果积分服务挂了,事务可能超时报错,导致订单也创建失败。更严重的是,如果远程调用成功,但事务随后提交失败,消息表里没有记录,但积分服务已经加了分,数据不一致又出现了。

正确做法:本地事务里只写业务数据和消息,然后把事务提交,最后通过定时任务去投递。如果你确实需要在事务提交后立刻投递,可以用TransactionSynchronizationManager.registerSynchronization在事务提交后触发一次投递,但也要注意,这种一次性的触发不能代替定时任务兜底,只能作为加速手段。

4.2 消息重复投递不是偶发现象,而是常态

很多新人会把精力花在“尽量避免重复投递”上,但投递超时导致的重试重复在这个网络环境下根本无法完全避免。真正成熟的做法是接受重复,然后在消费端用唯一索引或者状态机去冪等。

我自己在实际项目里是这么分层的:上游用 CAS 状态更新降低重复投递概率,下游用唯一键兜底彻底解决重复消费问题。两层都做,系统才稳。

4.3 定时任务在集群环境下重复执行

单机环境直接用@Scheduled没问题。但一旦订单服务部署了多个节点,问题就来了:每个节点都会启动一个定时任务,同一秒内多个节点同时扫描消息表,虽然 CAS 状态更新能挡住大部分重复处理,但会造成不必要的数据库压力,也会让下游被无谓地调用多次。

解决办法主要有两个。一个是引入分布式锁,在任务执行前尝试获取锁,拿到锁的节点才执行本轮扫描。用 Redis 的SETNX加一个带超时时间的锁就能实现。另一个是把扫描任务交给独立的调度平台,定时任务只在一个节点上运行。如果你用的是 XXL-Job 这类平台,直接配成单节点路由策略就行。

我个人更推荐第二种。因为分布式锁本身也有过期时间设置的问题,可能锁还没执行完就过期了,另一个节点抢到锁造成重复执行。独立调度平台则简单得多。

4.4 消息表数据无限膨胀

本地消息表的数据会一直增长,成功的消息如果不清理,一年下来可能有几千万条,占用大量存储空间,还会拖慢索引。我的做法是分级处理:

状态为已完成(2)的消息,保留 3 天,之后由清理任务批量删除。状态为死信(3)的消息,保留 30 天,方便人工排查,超过 30 天后人工确认没问题再删除。

清理任务不要在业务高峰期大批量删除,要控制每批删除的数量,一次删个几百条,避免产生长时间行锁。

4.5 消息体变更与下游兼容

消息体是用 JSON 存的,这给了我们很大的灵活性。但有一个问题需要提前约定:下游消费接口必须对 JSON 中的未知字段保持兼容。比如你今天在消息体里加了一个sourceChannel字段,老版本的积分服务如果用了严格的反序列化配置,可能会报错。

解决方式是在msg_type里加版本号,比如POINT_ADD_V2,一旦消息体结构发生不兼容变化,就换一个新的msg_type。下游旧版本只处理POINT_ADD,新版本同时处理两种。这个习惯能帮你省掉很多线上兼容性问题。

5. 方案选型评估:本地消息表和其他分布式事务方案怎么选

5.1 常见方案横向对比

在决定用本地消息表之前,有必要把主流的分布式事务方案放在一起比较一下。

方案一致性类型是否需要中间件开发成本数据一致性时效典型场景
XA 两阶段提交强一致需要数据库支持低即时单库多表单体应用
TCC最终一致否高,需手写Confirm/Cancel即时资金类核心操作
可靠消息事务(RocketMQ)最终一致需要MQ中秒级异步通知、数据同步
最大努力通知最终一致否中不确定,依赖重试回调通知场景
本地消息表最终一致否低秒级到分钟级中小团队微服务、内部系统

本地消息表在“开发成本”这一项上优势很明显,比 TCC 轻、比 XA 简单,适合那些业务链路不长、对一致性时效要求不那么苛刻的场景。

5.2 本地消息表的优势与适用场景

我总结下来,下面这些情况非常适合用本地消息表:

第一个是团队规模不大,没有专门的中间件运维能力,不想为了一个积分通知功能就去搭一套 RocketMQ。第二个是业务本身允许秒级甚至分钟级延迟,比如积分到账、站内信通知、数据同步,用户晚几分钟看到完全没影响。第三个是上游和下游共享同一个数据库,或者可以接受在各自库中维护一张消息表。第四个是希望保留完整的消息记录,方便对账和问题排查。

我在上一家公司的做法就是,所有核心业务的数据变更,统一定义成消息体写入各自服务的本地消息表,然后由一个分布式调度平台驱动投递。这样做的好处是,所有一致性问题都收敛到一个模式里,团队没有学习成本,排查问题时直接从消息表追溯链路,非常方便。

5.3 本地消息表的边界与不适用场景

这个方案也有明显的短板。

它不适合高并发、大流量的场景。消息表本身就是一张数据库表,每秒扫描几万条待发送消息对数据库压力不小。如果你要做秒杀、抢购类的系统,本地消息表很可能成为瓶颈。

它不适合强一致要求的场景。比如转账、支付退款这种必须即时生效的操作,用户转完账立刻要能看到余额变了,你不可能告诉用户“请稍等几秒钟再查余额”。这类场景还是得用 TCC 或者可靠的实时调用 + 手动补偿。

它不适合跨组织、跨异构存储的场景。如果下游是外部公司的系统,你不可能在他们库里也建一张本地消息表,消息表投递失败后的协调会变得非常麻烦。

5.4 本地消息表和 MQ 可以混合演进

这个方案并不是和消息中间件对立的。等业务量上来了,你可以很平滑地把“定时任务扫描发送”替换成“事务提交后发送到 MQ”。因为消息表里的数据已经结构化了,你要做的只是把投递逻辑换一下,消息还是那些消息,下游接口也不用改。

我实际遇到过的情况是:项目初期用本地消息表跑了大半年,后来业务规模上来,团队也有能力维护 MQ 了,才逐步把部分高频消息切换到 RocketMQ。切换过程没有伤筋动骨,就是因为早期的消息模型设计得干净。所以不用一上来就追求一步到位,先把一致性做对,再在性能需要时演进。

写在最后的实际操作心得

做这个方案一年下来,我最大的体会是:技术方案的价值不在于它听起来多高级,而在于能不能用最少的成本把问题解决干净。本地消息表虽然朴实,但它把“分布式事务”这个听着吓人的概念,实实在在地落地到了每一行 SQL、每一个定时任务里。

最后分享一个小技巧。线上排查数据不一致问题时,与其看日志找半天,不如直接在消息表上按biz_key查一遍。比如用户投诉积分没到账,你就拿订单号去查消息表,看消息状态停在哪个环节:消息根本没生成,说明上游本地事务有问题;消息停在待发送,说明定时任务出故障了;消息停在已发送或者死信,说明下游消费或者网络有问题。一查一个准,比什么分布式追踪都好用。

另外提醒一点,消息表一定要记得加最后修改时间和错误信息字段,最好再加入一个单独的last_error字段用来记录最后一次失败原因,这样看死信消息时能立刻知道是下游返回了业务错误,还是连接超时,不用每次都在代码里加日志重新跑一遍。

这个方案没有太多花哨的东西,但它足够稳。如果你正在为微服务的数据一致性发愁,又不想背上沉重的中间件包袱,不妨从本地消息表开始。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询