4、本地消息表方案:设计原理、实现步骤与优缺点分析
本地消息表,这个名字听起来挺朴素的。但你别小看它,这玩意儿在分布式事务的江湖里,可是个老牌劲旅。我最早接触它是在做电商订单系统的时候,那时候Seata还没这么流行,大家用的最多的就是它。
说白了,它的核心思想就一句话:把分布式事务的难题,拆解成本地事务 + 异步重试。你想想看,如果能把一个跨库、跨服务的操作,变成一个本地数据库的事务,那问题不就简单多了?
4.1 设计原理:一个表,两件事
本地消息表方案,核心就两个角色:
- 主动方:发起事务的服务,比如订单服务。
- 被动方:接收消息并执行的服务,比如积分服务。
它的工作流程,我习惯用「两阶段」来理解:
第一阶段:主动方本地事务
主动方在自己的数据库里,干两件事:
- 执行核心业务操作(比如创建订单,状态为“待支付”)。
- 往一张「本地消息表」里插入一条记录(比如“给用户增加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;
我的习惯:索引要建在 status 和 create_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吧。
总结一下:本地消息表方案,是分布式事务的“入门级”方案。它不完美,但足够实用。如果你刚开始接触分布式事务,我建议你先从它入手。理解了它的原理,再看其他方案,会轻松很多。
好了,这一章就到这里。下一章我们聊聊「事务消息方案」,看看消息中间件是怎么解决这个问题的。