4、本地消息表方案:设计原理、实现步骤与优缺点分析

本地消息表,这个名字听起来挺朴素的。但你别小看它,这玩意儿在分布式事务的江湖里,可是个老牌劲旅。我最早接触它是在做电商订单系统的时候,那时候Seata还没这么流行,大家用的最多的就是它。

说白了,它的核心思想就一句话:把分布式事务的难题,拆解成本地事务 + 异步重试。你想想看,如果能把一个跨库、跨服务的操作,变成一个本地数据库的事务,那问题不就简单多了?

4.1 设计原理:一个表,两件事

本地消息表方案,核心就两个角色:

  • 主动方:发起事务的服务,比如订单服务。
  • 被动方:接收消息并执行的服务,比如积分服务。

它的工作流程,我习惯用「两阶段」来理解:

第一阶段:主动方本地事务

主动方在自己的数据库里,干两件事:

  1. 执行核心业务操作(比如创建订单,状态为“待支付”)。
  2. 往一张「本地消息表」里插入一条记录(比如“给用户增加100积分”)。

这两步,必须在同一个本地事务里完成。要么都成功,要么都回滚。这是整个方案的基石。

核心要点:本地消息表必须和业务表在同一个数据库实例里。这样才能利用数据库的ACID特性,保证业务操作和消息记录的一致性。

第二阶段:异步通知与重试

主动方有一个后台定时任务,它会不停地扫描本地消息表。找到那些状态为“待发送”的记录,然后调用被动方的接口,把消息发出去。

  • 如果调用成功,就把消息状态更新为“已发送”。
  • 如果调用失败(网络超时、服务宕机),消息状态保持不变。定时任务下次扫描时,会再次尝试发送。

被动方收到消息后,执行自己的业务逻辑(比如增加积分)。为了保证幂等性,被动方通常也需要记录一下自己处理过的消息ID,防止重复执行。

4.2 实现步骤:手把手教你搭一个

嗯,光讲理论有点干。我带你走一遍实现步骤,你就全明白了。

4.2.1 建表

首先,在主动方的数据库里,建一张消息表。我一般这么设计:

CREATE TABLE `local_message` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `biz_id` varchar(64) NOT NULL COMMENT '业务ID,比如订单号',
  `message_type` varchar(32) NOT NULL COMMENT '消息类型,比如 ADD_SCORE',
  `message_body` text NOT NULL COMMENT '消息内容,JSON格式',
  `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0-待发送,1-已发送,2-发送失败',
  `retry_count` int(11) NOT NULL DEFAULT '0' COMMENT '重试次数',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_status_create_time` (`status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

我的习惯:索引要建在 statuscreate_time 上。因为定时任务经常要查“待发送且创建时间较早”的记录,这个索引能帮你省不少事。

4.2.2 主动方业务代码

在业务代码里,把业务操作和插入消息表,包在同一个事务里。我用伪代码演示一下:

@Transactional
public void createOrder(Order order) {
    // 1. 创建订单
    orderDao.insert(order);
    
    // 2. 构造消息
    Message message = new Message();
    message.setBizId(order.getId());
    message.setMessageType("ADD_SCORE");
    message.setMessageBody("{\"userId\": 123, \"score\": 100}");
    message.setStatus(0);
    
    // 3. 插入本地消息表
    messageDao.insert(message);
}

你看,代码很简单。但这里有个坑,我踩过。

我曾经犯过的错:把消息体序列化成了Java对象,结果后来改了字段类型,导致反序列化失败。我的建议是:消息体统一用JSON字符串,灵活且跨语言。

4.2.3 定时任务与重试

写一个定时任务,每隔几秒扫描一次消息表。我一般用Spring的@Scheduled:

@Scheduled(fixedDelay = 5000)
public void processPendingMessages() {
    // 1. 查询待发送的消息,限制每次处理100条
    List<Message> messages = messageDao.selectPending(100);
    
    for (Message msg : messages) {
        try {
            // 2. 调用被动方接口
            boolean success = remoteService.send(msg.getMessageBody());
            
            if (success) {
                // 3. 更新状态为已发送
                messageDao.updateStatus(msg.getId(), 1);
            }
        } catch (Exception e) {
            // 4. 记录失败,下次重试
            log.error("发送消息失败,id: {}", msg.getId(), e);
            messageDao.incrementRetryCount(msg.getId());
        }
    }
}

避坑指南:重试次数一定要有限制。我一般设置最大重试15次,超过后把状态改为“发送失败”,人工介入处理。不然消息积压起来,数据库扛不住。

4.2.4 被动方幂等处理

被动方收到消息后,第一件事不是执行,而是检查幂等。我习惯用一张去重表:

@Transactional
public void handleAddScore(String messageId, String body) {
    // 1. 检查是否处理过
    if (deduplicationDao.exists(messageId)) {
        return;
    }
    
    // 2. 执行业务
    scoreService.addScore(body);
    
    // 3. 记录已处理
    deduplicationDao.insert(messageId);
}

为什么要做幂等?因为网络可能超时。主动方没收到响应,会重试。被动方可能已经处理成功了,如果不做幂等,积分就重复加了。

4.3 优缺点分析:没有银弹

任何方案都有两面性。本地消息表也不例外。我用了这么多年,总结下来就这几条。

4.3.1 优点

  • 简单可靠:不依赖外部中间件,全靠数据库事务。对于中小团队来说,上手快,维护成本低。
  • 数据一致性高:只要本地事务成功,消息一定会被发送(通过重试保证)。最终一致性有保障。
  • 易于理解:说白了就是“先干自己的活,再通知别人”。团队成员沟通成本低。

4.3.2 缺点

  • 业务耦合:消息表是业务数据库的一部分。如果业务表结构变更,消息表也可能受影响。我见过有人把消息表和业务表放在不同库,结果事务就失效了。
  • 性能瓶颈:定时任务轮询数据库,在高并发场景下,数据库压力会很大。我记得有个项目,消息表一天涨到几百万条,查询性能直线下降。
  • 实时性差:定时任务的扫描间隔决定了延迟。你设5秒,那最坏情况就是5秒延迟。对于要求毫秒级响应的场景,不合适。
  • 消息表可能成为“大表”:如果不及时清理已发送的消息,表会越来越大。我建议定期归档或删除。

4.4 适用场景

说了这么多,到底什么时候用本地消息表?我个人的经验是:

  • 适合:内部系统、对实时性要求不高、团队规模不大、不想引入额外中间件的场景。比如订单创建后发短信、更新用户积分。
  • 不适合:高并发、低延迟、跨公司调用、消息量巨大的场景。这时候,还是上RocketMQ或Kafka吧。

总结一下:本地消息表方案,是分布式事务的“入门级”方案。它不完美,但足够实用。如果你刚开始接触分布式事务,我建议你先从它入手。理解了它的原理,再看其他方案,会轻松很多。

好了,这一章就到这里。下一章我们聊聊「事务消息方案」,看看消息中间件是怎么解决这个问题的。