目录
一、核心思想
二、本地消息表字段设计(常用)
三、完整执行流程
阶段 1:业务方本地事务(原子)
阶段 2:定时任务轮询投递
阶段 3:消费端处理
四、优缺点
✅ 优点
❌ 缺点
五、常见坑 & 优化点
六、和其他方案对比
七、适用场景
八、变种:事务状态表 / 可靠消息表
核心用途:实现分布式事务,最终一致性方案,最经典的「可靠消息最终一致性」方案,也叫事务消息的落地思路(不依赖 MQ 事务消息能力也能做)。
一、核心思想
把业务操作和消息记录放在同一个本地数据库事务里。
- 在同一个事务中:执行业务 SQL + 插入一条
本地消息记录(状态:待发送) - 事务提交成功 → 消息已经落库;事务回滚 → 消息记录也一起回滚,不会产生脏消息
- 单独起一个消息投递任务(定时任务),轮询本地消息表,把状态 = 待发送的消息投递到 MQ
- MQ 投递成功,更新本地消息状态为已发送;投递失败则重试,直到成功
- 消费端消费成功后,也可以回调更新状态;消费失败则消费端自行重试 / 人工兜底
一句话:用本地数据库事务保证【业务执行】和【消息落库】原子性;再通过定时任务保证消息一定能发出去。
二、本地消息表字段设计(常用)
sql
CREATE TABLE local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_id VARCHAR(64) NOT NULL COMMENT '消息唯一id,幂等key', topic VARCHAR(64) NOT NULL COMMENT 'MQ主题', msg_content TEXT NOT NULL COMMENT '消息体JSON', status TINYINT NOT NULL COMMENT '消息状态:0待发送,1已发送,2发送失败,3已消费', retry_count INT DEFAULT 0 COMMENT '重试次数', next_retry_time DATETIME COMMENT '下一次重试时间(退避)', create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_msg_id(msg_id) );状态枚举:
- 0:待发送(业务事务提交成功,消息入库,还没投 MQ)
- 1:已发送(成功推送到 MQ)
- 2:发送失败(投递 MQ 失败,等待定时任务重试)
- 3:已消费(消费方成功处理)
三、完整执行流程
阶段 1:业务方本地事务(原子)
开启本地事务 1. 更新业务数据(例:订单创建、扣库存) 2. insert 本地消息记录 status=0 提交事务✅ 要么业务和消息同时入库;要么一起回滚,不会出现业务成功,消息没记录的情况。
阶段 2:定时任务轮询投递
定时任务(比如每 5s)查询:status in (0,2) AND next_retry_time <= now() AND retry_count < max
- 拿到消息,先锁行(悲观锁 / SELECT ... FOR UPDATE,防止多实例重复投递)
- 发送消息到 MQ
- ✅ MQ 返回成功 → update status=1,更新 update_time
- ❌ MQ 异常 / 超时 → retry_count+1,设置退避时间,status=2
- 超过最大重试次数 → 标记为死信,人工后台处理
⚠️ 这里会有消息重复投递(网络抖动,MQ 收到消息但是 ACK 丢了,本地任务重试),所以消费端必须做幂等!
阶段 3:消费端处理
- 消费者收到消息,先根据
msg_id做幂等判断(消费记录表) - 执行业务逻辑
- 业务处理成功,ACK;失败不 ACK,MQ 重发
- 可选:消费成功后回调生产者接口,更新本地消息表 status=3(已消费)
四、优缺点
✅ 优点
- 实现简单,不需要依赖特殊的分布式事务组件(TCC、Seata AT)
- 不依赖 MQ 的事务消息(RocketMQ 才有事务消息,Kafka/RabbitMQ 没有,本地消息表通用)
- 可靠性高:消息持久化到数据库,宕机重启后定时任务继续重试
- 可控:重试次数、退避策略、死信人工处理都可以自己控制
❌ 缺点
- 对业务库有侵入:多一张消息表,业务表和消息表在同一个库,增加数据库压力
- 定时任务轮询有延迟,不适合强实时场景(最终一致性,不是强一致)
- 高并发场景:轮询扫描消息表会有数据库压力,需要加索引、分库分表、分页限制
- 重复消息必然存在,消费端必须幂等
五、常见坑 & 优化点
重复投递问题定时任务投递成功但是更新本地消息表超时,任务再次重试投递。 解决:消费端幂等,唯一 msg_id。
定时任务多实例重复捞取消息多台服务同时跑定时任务,读到同一条消息,并发投递。 解决:数据库行锁
select ... for update;或者分布式锁(Redisson)。轮询扫全表性能差不要
select * from local_message where status=0,status 建立联合索引(status, next_retry_time),分页查询,限制每次捞取条数。重试风暴失败消息不要固定间隔重试,使用指数退避:10s → 30s → 1min → 5min。
消息表数据膨胀历史已消费消息可以定时归档(迁移到历史表),避免表越来越大。
六、和其他方案对比
本地消息表 VS MQ 事务消息(RocketMQ)
- 本地消息表:通用性强,所有 MQ 都能用;业务库多一张表,代码量稍多
- RocketMQ 事务消息:不用建本地消息表;只能 RocketMQ 使用,理解成本高,半消息机制
本地消息表 VS TCC
- 本地消息表:最终一致性,适合异步通知场景(订单创建通知积分、通知库存)
- TCC:强补偿型,适合短事务、资金类,代码侵入更大
本地消息表 VS Seata AT
- Seata AT:全局事务,追求接近强一致;依赖 Seata 组件,有锁、性能损耗
七、适用场景
适合异步通知、最终一致性场景:
- 订单创建成功后,通知积分服务发放积分
- 支付成功后通知订单、物流、会员系统 不适合:转账、扣余额这类强一致性业务。
八、变种:事务状态表 / 可靠消息表
还有一种消息状态表方案,和本地消息表思路几乎一样,只是把消息体放到 MQ,本地只存消息状态,减少数据库存储压力。