Yujun's Blog
本地消息表的性能瓶颈问题
本地消息表的性能瓶颈问题
在微服务的开发环境下,我们享受着独立开发、独立部署的敏捷性,但同时也面临着一个不变的挑战,即如何在分布式的环境中保证数据的一致性?
当一个业务操作需要跨越多个服务、多个数据库时,传统的ACID事务便无能为力。 于是最终一致性成为了我们的核心设计思想,而本地消息表方案,就是实现可靠消息最终一致性的经典模式,被广泛应用于各种系统中。
它优雅可靠,但也藏着一个巨大的性能问题。思考下:当我们的业务量从百万级迈向亿级,这张小小的task表(本地消息表)会有什么性能问题?
Part 1: 本地消息表
先来回顾下本地消息表方案。 设想一个经典的电商场景: 用户在订单服务中创建了一个订单,支付成功后,需要通知积分服务为该用户增加积分。
这个流程可能我们刚开发的时候是这样的:
- 先更新订单状态,再调用积分服务接口:但是如果积分服务挂了,或者网络抖动导致调用失败怎么办?订单状态已改,积分却没加上,数据不一致情况就会出现。
 - 引入MQ:订单服务在更新完数据库后,发送一条消息到MQ,积分服务消费消息。这比直接调用好,但问题依旧存在:如果在发送MQ消息时,订单服务自己挂了,或者MQ服务不可用,消息没发出去怎么办?
 
所以问题的核心在于:更新订单数据库的操作和通知外部(发送MQ消息)的操作,是两个独立的行为,无法被同一个原子事务包裹。所以我们就要想一种解决方案把他俩原子性的包裹起来。
于是,本地消息表方案应运而生。
它的核心思想是:利用本地数据库事务的原子性,将业务数据操作和待发送的消息绑定在一起。 具体流程如下:
- 开启本地数据库事务。
 - 在订单服务中,执行
UPDATE orders SET status = 'paid' WHERE order_id = ?。 - 在同一个事务中,向一张本地的
local_message_table表里INSERT一条消息记录,内容是“订单XXX需要增加积分”,状态为“待发送”。 - 提交本地数据库事务。
 
由于这两个操作在同一个事务里,它们要么同时成功,要么同时失败。这就100%保证了:只要订单状态被成功修改,那么“需要发送积分消息”这个意图就一定被可靠地记录了下来。
接下来,一个独立的后台任务会轮询扫描这张local_message_table,把“待发送”的消息取出来,投递到MQ,然后更新表里的状态为“已发送”。
至此,一个可靠的消息投递机制就完成了。
Part 2: 当task表长到一亿行
但是上述方案的性能点,可能你也发现了:轮询扫描。
我们的轮询任务通常执行着这样的SQL:
SELECT * FROM local_message_table WHERE status = 0 ORDER BY update_time ASC LIMIT 100;
在业务初期,这张表很小,一切很友好。但随着时间推移,表里累积了数千万甚至上亿条“已发送”的历史记录。此时,可能会出现以下情况:
- 索引失效:
status字段的区分度极低(只有“待发送”、“已发送”等几个值)。当99.99%的数据都是“已发送”时,status = 0这个条件的索引几乎形同虚设,数据库不得不进行大范围的扫描,性能急剧下降。 - I/O风暴:在一张TB级的巨表中频繁查询,即使走了索引,也会对数据库的I/O造成巨大压力。
 - 锁竞争:多个轮询任务实例为了并发处理,会同时扫描这张表,可能引发不必要的锁竞争。
 
这就好像让我们在一个堆满了已读信件的巨大仓库里,找到那几封还没读的新信件。你不得不翻阅成堆的旧信,效率可想而知。
Part 3: 优化重构
一:基础优化(治标) - 数据库层面优化
这是任何系统都必须做的基础工作,成本低,见效快。
- 建立高效的复合索引:
 
- 不要只在
status上建索引。 - 必须建立一个复合索引,将筛选能力强的字段放在前面。一个典型的优选索引是 
(status, update_time)这样的复合索引。 - 这样数据库可以先通过
status=0快速定位到一个很小的索引范围,然后在这个小范围内根据update_time进行排序和查找,避免了大范围的扫描。 
- 数据归档与清理:
 
- 核心原则:本地消息表不应该永久存储所有历史消息。它的使命是可靠投递,投递成功后,它的价值就大大降低了。
 - 实现:
*   定期归档/删除:通过一个低优先级的后台Job,定期将
status为“已发送”且时间超过一定阈值(如7天)的数据,迁移到一张历史表中,或者直接删除。定期将已处理完毕且超过一定时限(如7天)的数据,迁移到历史表或直接删除。 * 分区表:如果数据库支持,按时间(天/月)对task表进行分区。清理数据时,直接删除整个旧分区(DROP PARTITION),其效率远高于逐行DELETE。 
做完这两步,我们能确保线上的task表始终保持在一个相对小而美的状态,大部分中小规模的系统,到这一步问题就已经解决了。
二:调度优化(治本) - 任务调度策略优化
如果数据量实在太大,基础优化已不足以支撑,我们需要改变任务的调度方式。
引入延迟消息: 核心思想是从我不断地轮询,变为到了该做事的时间再叫我。实现步骤如下:
- 在写入本地消息表的同时,向MQ发送一个延迟消息(如RocketMQ支持这个特性)。
 - 这个延迟消息仅包含一个
task_id,并在比如5秒后才对消费者可见。 - 一个专门的校验消费者收到这个延迟消息后,根据
task_id去精准查询task表。 - 如果查出来状态还是“待发送”,就发送正式消息到业务MQ并更新状态;如果已经是“已发送”,说明主流程已经成功了,直接ACK掉延迟消息即可。
 - 彻底消除了对数据库的轮询,变为由事件驱动的精准
SELECT ... WHERE id = ?。 
三:架构调整 - 告别轮询,拥抱CDC
这是业界最前沿、最高性能的终极解决方案。
引入基于数据库Binlog的CDC(Change Data Capture,变更数据捕获)
核心思想是:让数据库本身成为事件的源头,从应用拉取变为数据推送。不再由应用层去拉数据,而是让数据库在数据写入时主动推出来。架构如下:
- 业务代码的事务中,依然只负责向
local_message_table里INSERT一条记录。除此之外,什么都不用管。 - 部署一个Binlog采集工具(如 Alibaba Canal 或 Debezium)。
 - Canal伪装成MySQL的一个从库Slave,实时订阅并解析数据库的Binlog。
 - 当Canal监听到
local_message_table有新的INSERT时,会立即捕获这条数据变更。 - Canal将捕获到的消息,直接推送到消息队列MQ。
 - 下游的业务消费者(如积分服务)直接消费即可。
 
产生的效果也就很明显了:
- 零轮询:对数据库的轮询压力彻底消失。
 - 准实时:从数据写入到消息投递到MQ,是准实时的(毫秒级)。
 - 完全解耦:业务代码和消息发送逻辑完全解耦,业务方甚至不需要知道MQ的存在。
 - 高性能:这是目前已知的、对数据库最友好的最终一致性方案。
 
总结与展望
简单来说,这就是一个从笨办法不断进化到智能自动化的过程,目的是为了让系统在处理海量任务时,依然能保持高效和稳定。