本地消息表性能瓶颈:高频写入拖垮主库?异步批量落盘 + 分表策略优化!

公司用本地消息表做分布式事务的最终一致性。每笔订单创建后往消息表里插一条"待发送"记录,后台定时任务轮询发送。业务量上来以后,消息表单表积压了几千万条数据,每次 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
公众号:服务端技术精选
    评论
    0 评论
avatar

取消