按时间分表后,路由算法怎么做到查热数据永远不进历史库

分表路由的难题从来不在“怎么分”,而在“怎么找”。按时间分表看起来最直观,但当你既要保证热数据写入效率,又要避免查询穿透历史库时,路由算法就从一个简单的分片键选择问题,变成了一个多层级、多策略的组合决策。

按时间分表后让查热数据永远不进历史库,核心思路是在路由层引入“时间窗口感知”——让路由组件知道当前时间的“热”边界在哪里,并在 SQL 解析阶段就完成过滤条件的改写或路由目标的裁剪。这不是 ORM 层加个拦截器就能搞定的事,得从三层结构去设计。

路由层要能识别查询语句的时间意图

先说最容易被忽略的一点:很多团队在分表后,路由逻辑只做了最简单的“根据分片键计算目标表”这一步。比如按月份分表,order_time 是分片键,查询条件里带了 order_time between '2025-01-01' and '2025-01-31',路由算出目标表是 orders_202501,完事。

但问题来了,如果查询条件是 user_id = 12345,没有时间条件呢?路由层不知道往哪条路走,只能全表扫描。这是分表后最常见的性能杀手。

所以在路由层必须做 SQL 解析,提取出查询中的时间约束。这里有个关键设计:路由组件不仅要拿到分片键的值,还要判断这个值相对于“热窗口”的位置。热窗口不是固定的,它取决于业务定义——可能是最近 3 个月,可能是当前季度,可能是最近 7 天。这个定义要作为配置项注入到路由组件里。

举个例子,假设热窗口定义为“当前月 + 前两个月”,当前是 2025 年 3 月。路由组件解析到查询条件里的时间范围是 2025-03-012025-03-15,判断这个范围完全落在热窗口内,那么目标表就只锁定 orders_202501orders_202502orders_202503,绝不会去碰 2024 年及以前的表。

实现上可以用 ANTLR 或 JSqlParser 这类 SQL 解析工具,在路由层抽取出 WHERE 子句中的时间表达式,和配置的热窗口做区间重叠判断。如果没有时间条件,直接拒绝查询或强制要求带时间范围——这比放行全表扫描要安全得多。

冷热表元数据要实时可查

路由层光知道热窗口还不够,还需要知道每张物理表的时间覆盖范围。这件事不能靠约定,比如“表名后缀是年月,所以覆盖整个月”——生产环境里因为数据迁移、补数据、表结构变更,经常出现一张表覆盖的时间范围不是整月的情况。

所以需要一个轻量级的元数据表,记录每张分表的实际时间边界:

CREATE TABLE shard_metadata (
    table_name VARCHAR(64) PRIMARY KEY,
    min_time DATETIME NOT NULL,
    max_time DATETIME NOT NULL,
    is_hot TINYINT NOT NULL DEFAULT 0,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

这个表的数据量极小,可以全量缓存在路由组件内存中,定时刷新(比如每 5 分钟)。路由时先查这个缓存,找到满足时间条件的表集合,再和热窗口做交集。

is_hot 字段的价值在于,它可以由定时任务自动更新:每天凌晨跑一个脚本,判断哪些表的 max_time 距离今天小于等于热窗口天数,就把 is_hot 置为 1,其余置为 0。路由组件拿到表集合后,如果查询的时间范围完全落在 is_hot = 1 的表里,就可以在日志里打一个标记:本次查询未穿透历史库。这对监控和排查太有用了。

查询改写比拦截更优雅

路由层最常见的做法是拦截——发现查询可能扫到历史表,直接抛异常或返回空结果。但这对调用方很不友好,尤其是 BI 或报表系统,它们经常跑跨月查询。

更合理的做法是查询改写。路由层解析出原始 SQL 的时间条件后,如果发现时间范围跨了冷热边界,就自动拆成两个查询:热库部分走主库或读写分离的从库,冷库部分走专门的历史查询实例(可能是另一个低配的 MySQL 实例,或者 ClickHouse/TiDB 这类分析引擎)。

实现上可以这样:路由层生成改写后的 SQL 时,给冷库查询打上一个 hint 标记:

/* cold_query */ SELECT * FROM orders_202409 WHERE order_time >= '2024-09-01' AND order_time < '2024-10-01'

然后在数据源路由层根据这个 hint 把 SQL 发到对应的数据源。ShardingSphere 的 HintManager 就能做这个事,或者自己封装一层 DataSource 路由。

这样做的好处是,热查询永远走热库,冷查询虽然会进历史库,但走的是隔离的查询通道,不会影响线上业务。调用方无感知,也不用改 SQL。

索引设计要配合路由策略

路由算法设计得再好,如果每张表里的索引不支持时间范围查询,数据库层面还是会扫全表。按时间分表后,很多人觉得“反正是按月分的,我查一个月的数据,全表扫描也就几十万行,没事”。

但实际场景里,热查询很少是“查整个月”的。更常见的是“查最近 3 天的订单 + 某个用户 + 某个状态”。这时候索引必须是 (order_time, user_id, status) 这样的联合索引,而且 order_time 放第一列,因为路由层保证了这个条件一定存在。

这里有个容易忽视的细节:联合索引的第一列如果是时间,而查询条件里的时间范围是 BETWEEN,MySQL 会用索引做 range scan,效率没问题。但如果查询条件里时间用的是函数,比如 DATE(order_time) = '2025-03-15',索引直接失效。所以路由层在解析 SQL 时还要做一层校验,发现分片键上用了函数就直接拦截,强制调用方改成范围查询。

跨热窗口查询的降级策略

没有哪个路由算法能 100% 保证不穿透历史库。比如热窗口是 3 个月,但业务需要查“过去 6 个月的用户消费趋势”。这种查询天然要跨冷热边界。

这时候路由层的职责就不是拦,而是降级。可以设计一个查询分级机制:

  • L1:纯热查询。时间范围完全在热窗口内,路由到热库,毫秒级返回。
  • L2:跨冷热查询。时间范围部分在热窗口内,路由层拆成两个子查询,热部分走热库,冷部分走历史查询通道,结果合并。允许稍高的延迟(比如 2 秒内)。
  • L3:纯冷查询。时间范围完全在历史库,直接走历史查询通道,延迟可接受在 5 秒以上。

调用方在发起查询时可以带一个 query_level 参数,路由层根据这个参数和实际的时间范围做匹配。如果调用方声明是 L1,但时间范围跨了冷热,就直接拒绝,而不是偷偷降级——偷偷降级会导致调用方以为查询很快,实际超时了都不知道为什么。

常见问题

热窗口设多大合适?

看你的热数据定义。如果业务上 90% 的查询集中在最近 30 天,就设 30 天;如果月末月初有大量对账查询会查上个月数据,就设 60 天。关键不是拍脑袋,是拉一段时间的慢查询日志,统计 WHERE 条件里时间范围的分布,取 P95 作为热窗口。别设太大,热窗口越大,热库的表越多,写入分散后缓冲池命中率会下降。

路由层解析 SQL 会不会成为性能瓶颈?

不会,前提是你用了解析器缓存。JSqlParser 解析一条简单 SELECT 大概耗时 0.5-1ms,如果每条 SQL 都解析一遍,QPS 上万的场景确实扛不住。但实际场景里,SQL 的文本可以做 MD5 后缓存解析结果,同一个 SQL 模板只是参数不同,解析一次就够了。缓存命中率能做到 95% 以上。

如果查询不带时间条件怎么办?

两种选择:要么直接拒绝,返回错误码要求调用方加上时间范围;要么在路由层自动加上默认的时间范围(比如只查最近 7 天)。第二种对调用方更友好,但需要谨慎,因为调用方可能真的想查全量数据,你偷偷加了条件它发现数据少了会很困惑。建议第一种,同时在 API 文档和错误信息里明确说明原因。

冷热表的数据迁移怎么做?

热表变成冷表时,不建议直接在原实例上改状态。标准做法是:新建一张结构相同的表,把热表中超出热窗口的数据迁移过去,然后元数据表里把原表的 max_time 更新为热窗口边界,新建的历史表记录对应的 min_timemax_time。迁移用 pt-archiver 这类工具,可以做到不锁表、低负载。迁移完成后,路由层会自动根据新的元数据切流,不需要重启应用。