Yujun's Blog

本地消息表的性能瓶颈问题

October 18, 2025 (2w ago)SB-Q

本地消息表的性能瓶颈问题

在微服务的开发环境下,我们享受着独立开发、独立部署的敏捷性,但同时也面临着一个不变的挑战,即如何在分布式的环境中保证数据的一致性?

当一个业务操作需要跨越多个服务、多个数据库时,传统的ACID事务便无能为力。 于是最终一致性成为了我们的核心设计思想,而本地消息表方案,就是实现可靠消息最终一致性的经典模式,被广泛应用于各种系统中。

它优雅可靠,但也藏着一个巨大的性能问题。思考下:当我们的业务量从百万级迈向亿级,这张小小的task表(本地消息表)会有什么性能问题?

Part 1: 本地消息表

先来回顾下本地消息表方案。 设想一个经典的电商场景: 用户在订单服务中创建了一个订单,支付成功后,需要通知积分服务为该用户增加积分。

这个流程可能我们刚开发的时候是这样的:

  1. 先更新订单状态,再调用积分服务接口:但是如果积分服务挂了,或者网络抖动导致调用失败怎么办?订单状态已改,积分却没加上,数据不一致情况就会出现。
  2. 引入MQ:订单服务在更新完数据库后,发送一条消息到MQ,积分服务消费消息。这比直接调用好,但问题依旧存在:如果在发送MQ消息时,订单服务自己挂了,或者MQ服务不可用,消息没发出去怎么办?

所以问题的核心在于:更新订单数据库的操作和通知外部(发送MQ消息)的操作,是两个独立的行为,无法被同一个原子事务包裹。所以我们就要想一种解决方案把他俩原子性的包裹起来。

于是,本地消息表方案应运而生。

它的核心思想是:利用本地数据库事务的原子性,将业务数据操作和待发送的消息绑定在一起。 具体流程如下:

  1. 开启本地数据库事务。
  2. 在订单服务中,执行UPDATE orders SET status = 'paid' WHERE order_id = ?
  3. 在同一个事务中,向一张本地的local_message_table表里 INSERT 一条消息记录,内容是“订单XXX需要增加积分”,状态为“待发送”。
  4. 提交本地数据库事务。

由于这两个操作在同一个事务里,它们要么同时成功,要么同时失败。这就100%保证了:只要订单状态被成功修改,那么“需要发送积分消息”这个意图就一定被可靠地记录了下来。

接下来,一个独立的后台任务会轮询扫描这张local_message_table,把“待发送”的消息取出来,投递到MQ,然后更新表里的状态为“已发送”。

至此,一个可靠的消息投递机制就完成了。

Part 2: 当task表长到一亿行

但是上述方案的性能点,可能你也发现了:轮询扫描。

我们的轮询任务通常执行着这样的SQL:

SELECT * FROM local_message_table 
WHERE status = 0 
ORDER BY update_time ASC 
LIMIT 100;

在业务初期,这张表很小,一切很友好。但随着时间推移,表里累积了数千万甚至上亿条“已发送”的历史记录。此时,可能会出现以下情况:

  1. 索引失效:status字段的区分度极低(只有“待发送”、“已发送”等几个值)。当99.99%的数据都是“已发送”时,status = 0这个条件的索引几乎形同虚设,数据库不得不进行大范围的扫描,性能急剧下降。
  2. I/O风暴:在一张TB级的巨表中频繁查询,即使走了索引,也会对数据库的I/O造成巨大压力。
  3. 锁竞争:多个轮询任务实例为了并发处理,会同时扫描这张表,可能引发不必要的锁竞争。

这就好像让我们在一个堆满了已读信件的巨大仓库里,找到那几封还没读的新信件。你不得不翻阅成堆的旧信,效率可想而知。

Part 3: 优化重构

一:基础优化(治标) - 数据库层面优化

这是任何系统都必须做的基础工作,成本低,见效快。

  1. 建立高效的复合索引:
  • 不要只在status上建索引。
  • 必须建立一个复合索引,将筛选能力强的字段放在前面。一个典型的优选索引是 (status, update_time) 这样的复合索引。
  • 这样数据库可以先通过status=0快速定位到一个很小的索引范围,然后在这个小范围内根据update_time进行排序和查找,避免了大范围的扫描。
  1. 数据归档与清理:
  • 核心原则:本地消息表不应该永久存储所有历史消息。它的使命是可靠投递,投递成功后,它的价值就大大降低了。
  • 实现: * 定期归档/删除:通过一个低优先级的后台Job,定期将status为“已发送”且时间超过一定阈值(如7天)的数据,迁移到一张历史表中,或者直接删除。定期将已处理完毕且超过一定时限(如7天)的数据,迁移到历史表或直接删除。 * 分区表:如果数据库支持,按时间(天/月)对task表进行分区。清理数据时,直接删除整个旧分区(DROP PARTITION),其效率远高于逐行DELETE

做完这两步,我们能确保线上的task表始终保持在一个相对小而美的状态,大部分中小规模的系统,到这一步问题就已经解决了。

二:调度优化(治本) - 任务调度策略优化

如果数据量实在太大,基础优化已不足以支撑,我们需要改变任务的调度方式。

引入延迟消息: 核心思想是从我不断地轮询,变为到了该做事的时间再叫我。实现步骤如下:

  1. 在写入本地消息表的同时,向MQ发送一个延迟消息(如RocketMQ支持这个特性)。
  2. 这个延迟消息仅包含一个task_id,并在比如5秒后才对消费者可见。
  3. 一个专门的校验消费者收到这个延迟消息后,根据task_id去精准查询task表。
  4. 如果查出来状态还是“待发送”,就发送正式消息到业务MQ并更新状态;如果已经是“已发送”,说明主流程已经成功了,直接ACK掉延迟消息即可。
  5. 彻底消除了对数据库的轮询,变为由事件驱动的精准SELECT ... WHERE id = ?

三:架构调整 - 告别轮询,拥抱CDC

这是业界最前沿、最高性能的终极解决方案。

引入基于数据库Binlog的CDC(Change Data Capture,变更数据捕获)

核心思想是:让数据库本身成为事件的源头,从应用拉取变为数据推送。不再由应用层去拉数据,而是让数据库在数据写入时主动推出来。架构如下:

  1. 业务代码的事务中,依然只负责向local_message_tableINSERT 一条记录。除此之外,什么都不用管。
  2. 部署一个Binlog采集工具(如 Alibaba Canal 或 Debezium)。
  3. Canal伪装成MySQL的一个从库Slave,实时订阅并解析数据库的Binlog。
  4. 当Canal监听到local_message_table有新的INSERT时,会立即捕获这条数据变更。
  5. Canal将捕获到的消息,直接推送到消息队列MQ。
  6. 下游的业务消费者(如积分服务)直接消费即可。

产生的效果也就很明显了:

  • 零轮询:对数据库的轮询压力彻底消失。
  • 准实时:从数据写入到消息投递到MQ,是准实时的(毫秒级)。
  • 完全解耦:业务代码和消息发送逻辑完全解耦,业务方甚至不需要知道MQ的存在。
  • 高性能:这是目前已知的、对数据库最友好的最终一致性方案。

总结与展望

简单来说,这就是一个从笨办法不断进化到智能自动化的过程,目的是为了让系统在处理海量任务时,依然能保持高效和稳定。

Comments