本地消息表性能瓶颈:高频写入拖垮主库?异步批量落盘 + 分表策略优化!
公司用本地消息表做分布式事务的最终一致性。每笔订单创建后往消息表里插一条"待发送"记录,后台定时任务轮询发送。业务量上来以后,消息表单表积压了几千万条数据,每次 INSERT 都要等几十毫秒,DB 连接池打满,主库 CPU 飙到 80%。订单创建都跟着变慢了。
本地消息表本来是为了解耦,结果自己成了瓶颈。今天聊聊怎么把消息表的高频写入从主库的业务路径上剥离,用异步批量落盘加分表来扛住海量消息。
问题到底出在哪
本地消息表最朴素的实现是业务代码里直接 INSERT:
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order); // 订单入库
messageMapper.insert(new Message( // 消息入库 —— 跟订单在同一个事务
order.getId(), "ORDER_CREATED", "PENDING"
));
}
这种写法,消息的 INSERT 跟业务 SQL 在同一个事务里。单看一条没问题,但当每秒几千个订单同时创建,消息表的 INSERT 就变成了主库的写入热点。更要命的是消息表的数据只写不删(要保留查重和回溯),单表越来越大,索引越来越深,写入越来越慢。
方案一:异步批量落盘
核心思路:业务代码不直接写消息表,而是把消息写入内存队列,后台线程批量刷盘。
业务线程 后台线程
│ │
├─ 订单入库 │
├─ 消息入队列(内存,几乎零开销) │
└─ 返回 │
├─ 攒够 100 条 or 1 秒
├─ 批量 INSERT 到消息表
└─ 返回
这里的 trade-off 是:事务提交后才写消息,如果服务在消息刷盘前崩溃了,队列里的消息会丢。对于允许最终一致的场景(消息表本来就是做最终一致性的),这个风险可以接受。对于不允许丢的场景,可以在事务提交后立即入队,然后用 WAL(write-ahead log)文件做兜底。
@Component
public class AsyncMessageWriter {
// 内存队列
private final LinkedBlockingQueue<Message> queue =
new LinkedBlockingQueue<>(10000);
@PostConstruct
public void start() {
// 批量落盘线程
executor.submit(() -> {
List<Message> batch = new ArrayList<>(200);
while (true) {
queue.drainTo(batch, 200); // 最多取 200 条
if (batch.isEmpty()) {
Thread.sleep(100); // 没数据歇一会儿
continue;
}
messageMapper.batchInsert(batch); // 批量写入
batch.clear();
}
});
}
// 业务代码调用:入队(不阻塞)
public void enqueue(Message msg) {
if (!queue.offer(msg)) {
log.warn("消息队列已满,触发降级");
messageMapper.insert(msg); // 降级为直接写入
}
}
}
批量 INSERT 的性能比单条 INSERT 高一个数量级。MySQL 的 batch insert 语法一条 SQL 插多行,减少了网络往返和事务开销。
方案二:分表
即使异步批量落盘,单表的数据还是会持续增长。几千万行数据,哪怕只是 SELECT 一条索引扫描也会变慢。
分表的思路很简单:按时间维度拆分消息表,让热点数据集中在最近的一张表上。
message_202606 ← 当前写入的表(热)
message_202605 ← 上个月(温,偶尔查)
message_202604 ← 再上个月(冷,基本不查)
分表的维度可以按天、按周、按月。对于消息表场景,按月比较合适——消息一般最近一个月的查得多,再早的基本是归档数据。
分表之后,路由逻辑很简单:
public String getTableName() {
return "message_" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
}
定时任务创建下个月的表,保证新月份来的时候表已经建好了。
分表 + 批量落盘的组合效果:热点数据只在当月的表中,这个表的大小可控。上个月的表可以定期归档甚至删除。写入从单表变成小表,索引深度变浅,INSERT 和 SELECT 都变快。
需要注意的点
异步落盘不能丢数据
如果业务要求消息绝对不能丢,队列写满后不要丢弃,而是堵住业务线程或者降级为同步写入:
public void enqueue(Message msg) {
if (!queue.offer(msg, 100, MILLISECONDS)) {
// 队列满 → 降级为同步写入,保证不丢
messageMapper.insert(msg);
}
}
分表后跨月查询处理
如果需要查跨两个月的数据,在应用层 UNION。一个月也就 30 张表,最多查 2 张就够了。
批量落盘的事务边界
批量 INSERT 是对一百条消息一个事务。如果其中一条失败(比如唯一键冲突),整个 batch 都回滚。解决方法有两个:一是用 INSERT IGNORE 跳过冲突;二是在入队前就已经做了唯一性校验。
总结
本地消息表的性能问题,本质是把消息的写入路径跟业务的写入路径耦合在一起了。
两条腿走路:
异步批量落盘——业务线程入队即返回,后台线程攒批写入。写入量从 N 次单条变成一次批量,性能提升一个数量级。
按月分表——热点数据集中在当月表,单表可控。历史表定期归档,索引不膨胀。
两条线一起上,消息表从瓶颈变回它本来的角色——分布式事务的可靠保障。
有用的话转给还在主库上裸写消息表 INSERT 的同事。
标题:本地消息表性能瓶颈:高频写入拖垮主库?异步批量落盘 + 分表策略优化!
作者:jiangyi
地址:http://jiangyi.space/articles/2026/06/12/1780759182864.html
公众号:服务端技术精选
评论