在分布式系统里,保证跨服务数据一致是个难题。之前聊了 2PC、TCC,今天讲讲本地消息表方案—— 它以 “最终一致性” 为目标,用简单思路解决复杂问题,尤其适合高并发、可容忍短暂不一致的场景。
一、核心思想:本地事务 + 消息异步补偿
本地消息表方案的核心逻辑:
用本地事务保证 “业务操作 + 消息记录” 原子性,再通过异步任务把消息投递给消费者,实现跨服务数据同步。即使中间环节出问题,也能靠重试、幂等保证最终一致。
编辑
以 “注册送积分” 场景为例:
用户服务新增用户后,需通知积分服务加积分。用本地消息表,流程分三步:
- 写业务 + 写消息:用户服务在同一本地事务里,完成 “新增用户” 和 “记录积分消息日志”。
- 发消息(异步) :定时任务扫描消息表,把未发送的消息投递给 MQ。
- 消费消息:积分服务监听 MQ,消费消息后完成 “加积分”,并通过 MQ 的 ACK 机制保证不丢消息。
二、流程拆解:从注册到积分到账
(一)阶段 1:用户注册(本地事务保证一致性)
用户服务需同时完成 “新增用户” 和 “记录积分消息” ,用本地事务(数据库事务)保证二者要么都成功,要么都回滚。
伪代码(以 MySQL 为例) :
// 开启本地事务
begin transaction;
try {
// 1. 新增用户(业务操作)
userMapper.insertUser(user);
// 2. 记录积分消息日志(消息落库)
messageMapper.insertMessage(
new Message(
"积分服务",
"增加积分",
"{userId:123, points:10}"
)
);
// 提交事务
commit;
} catch (Exception e) {
// 回滚:用户和消息都不生效
rollback;
throw e;
}
这一步的关键:本地事务保证业务与消息的强一致。只要数据库事务提交,就说明 “用户已新增 + 积分消息已记录”,后续即使服务宕机,消息也能靠定时任务补发。
(二)阶段 2:定时任务扫描,投递消息到 MQ
消息落库后,需异步投递给 MQ。定时任务(如 Spring Boot 的 @Scheduled)会周期性扫描消息表,把 “未发送” 状态 的消息发给 MQ。
伪代码(定时任务逻辑) :
// 每 1 分钟扫描一次消息表
@Scheduled(cron = "0 0/1 * * * ?")
public void sendMessageToMQ() {
// 查询未发送的消息
List<Message> unsentMessages = messageMapper.findUnsentMessages();
for (Message msg : unsentMessages) {
try {
// 发送消息到 MQ
mqProducer.send(msg.getTopic(), msg.getContent());
// 标记为已发送
msg.setStatus("SENT");
messageMapper.updateMessage(msg);
} catch (Exception e) {
// 发送失败,保留状态,等待下次重试
log.error("消息发送失败,待重试: {}", msg);
}
}
}
这一步的关键:重试机制。发送失败的消息会留在表中,下次定时任务继续尝试,直到发送成功(或人工干预)。
(三)阶段 3:积分服务消费消息,实现最终一致
积分服务监听 MQ,收到消息后执行 “增加积分” 操作。为避免重复消费(如 MQ 重试投递),需保证 幂等性(同一消息执行多次,结果一致)。
积分服务消费逻辑:
// 监听 MQ 消息
@RabbitListener(queues = "积分队列")
public void handleMessage(String msg) {
// 解析消息:userId、points
Map<String, Object> data = JSON.parseObject(msg, Map.class);
Long userId = data.get("userId");
Integer points = data.get("points");
// 幂等性校验:查流水表,判断是否已处理
if (pointFlowMapper.existsByUserIdAndMsgId(userId, msgId)) {
// 已处理,直接返回
return;
}
try {
// 增加用户积分
pointService.addPoints(userId, points);
// 记录流水(幂等性依据)
pointFlowMapper.insert(userId, points, msgId);
// 手动 ACK:通知 MQ 消息已消费
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
// 消费失败,MQ 会重试投递
channel.basicNack(deliveryTag, false, true);
log.error("积分增加失败,待重试: {}", msg);
}
}
这一步的关键:幂等 + ACK 机制。幂等性避免重复加积分,ACK 机制保证消息不丢失(消费失败则 MQ 重试)。
订单扣库存场景扩展
本地消息表方案也适用于订单扣库存等场景,流程如下:
编辑
- 提交订单:订单中心记录订单和扣库存消息。
- MQ 异步处理:库存系统消费消息,执行扣库存操作。
- Task 系统兜底:轮询消息表,处理未确认的消息,保证流程完整性。
三、优缺点:适合什么场景?
(一)优点:简单可靠,兼容性强
- 实现简单:基于本地事务和定时任务,不依赖复杂框架(如 TCC 的事务协调器)。
- 最终一致性:通过重试、ACK 保证消息必达,适合对实时性要求不高的场景(如积分、通知)。
- 兼容性好:支持各种语言、数据库,只要能写本地事务 + 定时任务,就能实现。
(二)缺点:数据库压力 + 实时性弱
- 数据库瓶颈:消息表频繁读写(定时任务扫描、业务写消息),高并发下可能拖慢数据库。
- 实时性差:消息靠定时任务扫描投递,会有延迟(如 1 分钟扫描一次,延迟最多 1 分钟)。
- 幂等性依赖业务实现:消费端需自己保证幂等,增加开发成本。
四、优化思路:缓解数据库压力
本地消息表的核心痛点是 “消息表读写影响业务性能” ,可通过这些方式优化:
- 异步化消息落库:
业务操作和消息落库虽在同一事务,但消息落库可异步执行(如用线程池),减少主流程耗时。不过要注意:异步落库可能导致事务提交后消息未落库,需结合日志或补偿机制。 - 分库分表 / 独立消息库:
把消息表放到独立数据库,或分库分表,避免业务库被消息读写拖垮。 - 替换消息存储:
高并发场景下,可把消息表换成 Redis、RocketMQ 等自带持久化的中间件,利用其高效读写和重试机制,减少数据库压力。
五、总结:本地消息表的适用场景
本地消息表是 “最终一致性” 的经典实践,适合以下场景:
- 对实时性要求不高(如积分、通知、物流状态同步)。
- 业务逻辑简单,不想引入复杂分布式事务框架。
- 高并发场景,但能通过优化(如分库分表、异步落库)缓解数据库压力。
它的核心逻辑是 “本地事务保证业务 + 消息的一致性,异步重试保证最终一致” 。理解这套思路后,面对 “跨服务数据同步” 需求,就能快速判断是否适用本地消息表,或结合其他方案(如 TCC + 本地消息表兜底)。
(拓展思考:如果消息表数据量极大,如何设计归档策略?定时任务扫描效率低,有没有更高效的消息投递方式?可以结合 Kafka 的事务消息、RocketMQ 的定时投递等特性深入优化~)
如果这篇文章对大家有帮助可以点赞关注,你的支持就是我的动力😊!