
遇到过这个现象没SpringBoot 服务一启动前几个带 ShardingSphere 的路由 SQL 慢到离谱有的甚至等了两三秒才返回但是等个几十秒再去调同一个接口速度又恢复正常了。后台日志里也没报错就是卡在那不动看连接池状态也正常找半天找不到原因。这个场景我在好几套项目里都碰到过基本都是引入 ShardingSphere 5.0 之后出现的。网上关于这个问题的讨论不少但大部分只告诉你“做个预热”具体怎么设计、怎么落地、预热哪些东西、预热完效果能好到什么程度很少有人把整个链路讲透。这篇就把我从定位到解决的全过程掰开揉碎写清楚包含完整可复制的预热组件代码以及几个实际踩坑记录。1. 为什么首次查询会慢先搞懂 ShardingSphere 5.0 的加载机制不把原理说清楚预热方案就容易做偏。很多人一上来就写个ApplicationRunner执行几条 SQL结果预热完该慢还是慢原因就是没搞清楚 ShardingSphere 5.0 启动后到底哪些东西是“懒加载”的。1.1 表面现象日志没有报错卡在 SQL 执行前先描述一下实际现象。项目用的 SpringBoot 2.7 ShardingSphere 5.0.0分库分表一共 8 个库、64 张订单表启动过程很顺利应用端口一开接口能通。但是第一个查询订单列表的请求打进来耗时 2800ms紧接着同一个请求再打一次耗时 90ms。差别非常大而且只在服务刚启动后出现。打开 DEBUG 日志再看细节第一次查询时ShardingSphere-Core里有大量元数据加载相关的日志包括Loading table meta data、loading index meta data之类的记录后续请求就没有了。这说明慢的根源不是网络、不是连接池创建慢而是 ShardingSphere 引擎本身在第一次遇到 SQL 时要做大量初始化工作。1.2 本质原因规则引擎加载完毕但执行链路是首次懒加载ShardingSphere 5.0 在 5.x 这代重构后数据源启动加载是分阶段的第一阶段启动加载配置、创建ShardingSphereDataSource、初始化规则配置和分片算法这些在 Spring 容器刷新阶段完成。第二阶段真实物理连接的创建、每个分片表的元数据加载、SQL 解析结果缓存的填充很多都推迟到第一次实际执行 SQL 时才触发。第二阶段恰恰是性能瓶颈所在。具体分四块物理链路初始化HikariCP 连接池默认minimumIdle配置不当的话启动时可能只有 0 个或极少几个连接第一次请求需要现场建连接到 8 个分库元数据加载ShardingSphere 需要读取每个分片表的字段、索引、主键信息用于后续 SQL 改写和结果归并这时候会对information_schema发起大量元数据查询解析器与路由引擎初始化一条 SQL 从文本变成抽象语法树需要经过词法解析、语法解析、逻辑路由、改写、执行计划构建5.0 的解析器首次加载相关组件时开销明显执行引擎初始化分布式查询的线程池、归并处理器、结果集流式处理器的初始化延迟到首次执行时进行。1.3 为什么 4.x 时代这个问题不明显5.0 却卡得明显用过 ShardingSphere 4.x 的老项目应该对“首次慢”感觉没那么强烈。原因在于 4.x 的 SQL 解析使用了相对轻的逻辑很多解析逻辑在 driver 初始化阶段就完成了而且规则引擎的复杂度低不少。5.0 做了一个非常大的架构调整解析引擎升级为新一代的SQL Parser支持更完整的 SQL 语法树同时引入了gated的元数据加载机制。功能强大了代价就是首次执行时要做的事情更多。尤其是分片键不是等值查询而是IN或范围查询时路由计算涉及的分片遍历在首次执行时需要建立分片路由缓存慢得更明显。注意如果你用的是 5.0 的 Spring Boot Starter而不是传统ShardingSphereDataSource手动配置的方式启动时对元数据的加载会比纯 Java 配置方式还要再激进一点但核心懒加载问题依旧存在。所以结论很直接要解决首次慢就得把第二阶段的工作提前到流量进来之前做完。2. 预热方案设计到底要预热哪些内容目标清楚了方案就好设计了。但“预热”不能简单理解为启动后拿一条 SQL 跑一遍而是要覆盖上述四类懒加载内容缺一块都可能在特定场景下再次踩坑。2.1 预热对象拆解连接池、元数据、解析缓存、执行引擎我最终把预热拆成四层预热层处理内容触发方式不预热的后果连接层HikariCP 到 8 个分库的物理连接各分库执行SELECT 1首次请求要现场建连光建连就耗时几百毫秒元数据层各分库分表的字段、索引、主键信息执行一组代表性查询 SQL首次执行时后台大量元数据加载 SQL阻塞请求解析路由层SQL 解析结果、路由结果、改写语句缓存预先执行核心业务 SQL第一次执行时解析加路由计算耗时巨大执行引擎层分布式查询线程池、归并处理器让查询真正走一遍执行链路首次分布式查询时引擎初始化开销极高注意这四层之间有关联关系如果你预热的 SQL 没有触发分布式路由那执行引擎层中的分片归并逻辑就没有被初始化。所以预热 SQL 不能只拿SELECT 1或简单的单表查询来敷衍必须包含真实走分库分表路由的业务 SQL。2.2 方案选型启动预热 vs 请求预热我为什么选启动预热常见的预热触发时机有两种。第一种是接到第一个请求后异步预热。比如通过OncePerRequestFilter记录首次请求后台线程池执行预热 SQL。优点是服务启动快缺点很致命第一个用户已经卡成狗了预热的收益他完全享受不到而且并发场景下第一个请求和预热线程同时在跑谁都没快起来。第二种是服务启动完成、Spring 容器ApplicationReadyEvent触发后立即执行预热任务。这样流量进来之前准备工作已经做完了。缺点是应用启动时间会变长几秒但可以通过异步线程池将预热任务放到后台端口照常开放后续请求到达时预热大概率已跑完即使没跑完也只会影响极少数最先进入的请求。我最终选择的是后者而且是带阻塞开关的后台预热。具体来说在监听ApplicationReadyEvent的子线程里执行预热同时把预热的 SQL 设计成“集群关键路径 SQL”的集合。等预热线程跑完打一条ShardingSphere preheat finished日志方便监控系统确认。2.3 需要避免的坑预热不是越全越好预热虽然重要但不是把所有 SQL 一股脑全执行一遍。一个订单系统可能有 300 多个查询 SQL如果全部预热启动时间会非常难看而且会对数据库造成不小的压力预热的收益边际递减还很严重。所以预热 SQL 的选取要遵循几个原则按热度选重点保证首页、详情页、列表页等核心接口的 SQL按复杂度选子查询、IN列表、多表关联的 SQL 优先级更高因为解析和路由计算开销更大按分片场景选等值查询和范围查询都要有路由逻辑不一样控制并发度预热线程池不要开太大建议 2 到 4 个连接否则启动瞬间给数据库造成压力影响其他服务。3. 动手落地一个可复用的 ShardingSphere 预热组件理论说完了直接上代码。下面这套是基于 MyBatis 3 SpringBoot 方案的预热组件实测在两边项目落地过一处是电商订单域一处是支付流水查询域均稳定运行。3.1 第一步改造连接池配置给预热打好底子先看连接池。如果 HikariCP 的最小连接数是 0那不管怎么做 SQL 预热连接本身的创建都发生在第一次请求时。我建议至少把minimumIdle设置到 2 到 4这样连接池一初始化就会预先建好一批连接。spring: datasource: type: com.zaxxer.hikari.HikariDataSource hikari: minimum-idle: 4 maximum-pool-size: 16 connection-timeout: 30000 connection-test-query: SELECT 1 idle-timeout: 600000 max-lifetime: 1800000这里minimumIdle是保证启动后物理连接就能建好。如果你的应用配置了多数据源注意所有数据源都要同步调整别只改了主库。注意connection-test-query虽然在 HikariCP 中不是必填项HikariCP 默认用 JDBC4 的isValid()来测活但 ShardingSphere 代理的数据源链路里明确设置后预热阶段的报错会更直观建议保留。3.2 第二步编写预热执行器核心代码直接复制下面是预热的完整代码。核心思路是拿到 MyBatis 里所有的MappedStatement筛选出查询类型的 SQL过滤出需要预热的白名单然后往 ShardingSphere 数据源里跑一遍。Component public class ShardingSpherePreheatRunner implements ApplicationListenerApplicationReadyEvent { private static final Logger log LoggerFactory.getLogger(ShardingSpherePreheatRunner.class); private static final ListString PREHEAT_SQL_NAME_PATTERNS Arrays.asList( OrderMapper.selectByUserId, OrderMapper.selectOrderDetail, OrderMapper.selectPageList, OrderMapper.countByStatus ); Autowired private SqlSessionFactory sqlSessionFactory; Autowired private DataSource dataSource; private ExecutorService preheatExecutor; Override public void onApplicationEvent(ApplicationReadyEvent event) { preheatExecutor Executors.newFixedThreadPool(4, r - { Thread t new Thread(r, shardingsphere-preheat); t.setDaemon(true); return t; }); preheatExecutor.submit(this::doPreheat); } private void doPreheat() { long start System.currentTimeMillis(); log.info(ShardingSphere preheat start); try { Configuration configuration sqlSessionFactory.getConfiguration(); ListString executedSqls new ArrayList(); for (String statementName : PREHEAT_SQL_NAME_PATTERNS) { MappedStatement ms getMappedStatement(configuration, statementName); if (ms null) { continue; } if (!SqlCommandType.SELECT.equals(ms.getSqlCommandType())) { continue; } BoundSql boundSql ms.getBoundSql(new HashMap()); String sql boundSql.getSql(); // 只保留真正会走分片路由的 SQL避免无意义执行 if (!sql.toLowerCase().contains(order_id) !sql.toLowerCase().contains(user_id)) { continue; } executePreheatSql(sql); executedSqls.add(statementName); } // 显式触发一次跨分片聚合查询把归并处理器也初始化掉 executePreheatSql(SELECT user_id, COUNT(*) FROM t_order GROUP BY user_id); executePreheatSql(SELECT * FROM t_order WHERE user_id IN (1001, 1002, 1003)); log.info(ShardingSphere preheat finished, sqlCount{}, cost{}ms, executedSqls.size(), System.currentTimeMillis() - start); } catch (Exception e) { log.error(ShardingSphere preheat error, e); } finally { preheatExecutor.shutdown(); } } private MappedStatement getMappedStatement(Configuration configuration, String statementName) { try { return configuration.getMappedStatement(statementName); } catch (Exception e) { return null; } } private void executePreheatSql(String sql) { try (Connection connection dataSource.getConnection(); PreparedStatement ps connection.prepareStatement(sql)) { ps.setQueryTimeout(10); // 这里不能用 executeQuery 后完全不读结果集部分驱动必须读完结果集才算真正执行完 try (ResultSet rs ps.executeQuery()) { // 只读第一行避免把全表数据拉回来 if (rs.next()) { // no-op } } } catch (Exception e) { log.warn(Preheat sql error, sql{}, error{}, sql, e.getMessage()); } } }这套代码有几处细节需要特别说明。第一MappedStatement的获取不是遍历所有 statement而是走白名单。项目里 mapper 多的时候全量遍历本身不慢但全量执行的 SQL 数量太多、时间太长。白名单可以放到application.yml配置里用配置中心动态调整更科学我这里为了示例写死了。第二执行 SQL 时只rs.next()一次。这样既完成了 SQL 到数据库的完整路由和执行链路又不会拖回大量业务数据避免预热本身对数据库造成压力。第三ps.setQueryTimeout(10)一定要加。万一某条 SQL 执行计划有问题预热线程会被锁住不能影响主流程。3.3 第三步验证预热的完整性别踩“没触发路由”的坑代码写完不是结束要验证预热是否真的生效。我在第一次落地时犯过一个错误白名单里加的是OrderMapper.selectById但这条 SQL 在 MyBatis XML 里写的是WHERE id #{id}而分片键是user_id导致这条 SQL 根本没走 ShardingSphere 的分片路由而是走了 broadcast 或单分片路由预热的元数据加载和解析缓存就只热了一半。验证方式很简单在预热执行后通过 ShardingSphere 的指标或日志观察看日志中是否出现实际的物理 SQL 路由记录比如Actual SQL: SELECT * FROM t_order_0003看information_schema相关元数据查询是否已经执行过最好在监控面板看一次预热的耗时曲线如果预热后首次请求耗时依然超过 500ms说明还有链路没热到需要继续排查。3.4 配合配置这几个参数值得调整除了连接池参数还有几个 ShardingSphere 和 SpringBoot 侧的配置可以配合预热一起调。有时候首次查询慢不是引擎问题而是 Feign/RestTemplate 等调用链路的懒加载那种场景和 ShardingSphere 无关但症状很像。建议对核心接口在启动后做一次 HTTP 级别自测能更全面发现热点。ShardingSphere 5.0 的 SQL 解析缓存默认是开启的这点不用调。但如果你在 5.0 之前的版本有sql-parser.cache之类的配置可以确认开启状态。5.0 里比较大的一次配置变更在于spring.shardingsphere.rules.sharding.key-generators等节点下新增了很多子配置建议升级后核对官方文档别照着 4.x 的配置硬套。再就是 SpringBoot 的spring.datasource.hikari.initialization-fail-timeout参数它决定连接池启动时如果拿不到连接是报错还是继续。ShardingSphere 场景建议保持默认让启动快速失败别等连接池超时拖很久才暴露问题。4. 预热后的效果与压测对比直接说结果。在电商订单域的那套项目上预热设计前服务每次发版后第一次查询接口耗时普遍在 1600ms 到 2800ms 之间而且因为灰度流量较小这个问题会持续存在几分钟。加了预热后我连续观察了十多次发版核心查询接口的首次耗时稳定在 180ms 以内后续请求在 60ms 到 90ms 之间效果非常明显。压测数据更有说服力。我用 JMeter 模拟了 20 个并发用户在预热完成前和完成后分别测试场景首次请求 P99后续请求 P99启动时间增加无预热3200ms240ms0ms预热核心白名单 SQL170ms78ms3.2s预热全部 SQL150ms75ms28s对比后可以发现预热全部 SQL 的收益比预热白名单并没有本质提升但启动时间差了接近 9 倍。所以强烈建议走“核心 SQL 白名单 少量复杂聚合查询”的预热策略这样启动时间增加可控同时能让核心链路完全热起来。5. 常见问题与排查技巧实录实际落地过程中团队同事也踩了不少坑这块单独整理出来优先级比较高的问题都在这。5.1 预热了但首查还是慢从哪里开始排查第一反应看日志里有没有Connection is not available, request timed out如果有说明连接池预热失败检查minimumIdle是否大于 0、数据库最大连接数是否够用。如果没有连接超时再看 ShardingSphere 的执行日志确认你预热的 SQL 是否真的走了分片路由。很有可能你预热的 SQL 只命中了单分片分布式执行引擎、归并处理器并没有初始化完成。这种情况就按第三个小节里说的额外加一条跨分片聚合或IN查询来补热。还有一种比较隐蔽的情况项目里配了多级缓存或 Redis 缓存首次查询慢其实是缓存 Miss 之后走了数据库而数据库层面又没预热导致看起来像是 ShardingSphere 预热失效。排查时可以把数据库慢日志打开对比预热 SQL 和实际请求 SQL 是否都命中了。5.2 预热线程把数据库打满怎么办这个问题在系统分片数量较多时容易出现。我遇到过一次16 个分片库、每库 64 张表预热 SQL 并发度开到 8结果直接把一个从库的 CPU 打到 90% 以上。调整方案有两个一是降低预热线程池的并发度这个最简单把线程数从 4 调到 2二是在预热代码里对每个分片库按顺序执行不要并发打所有库。注意预热的本质是把引擎内部的缓存填满而不是压测数据库所以预热期间数据库压力越低越好。不要把预热设计成并行风暴。5.3 服务启动过慢发布窗口等不起怎么办如果你的发布流程对启动时间极其敏感比如用了 Kubernetes 的滚动发布Readiness 探针给的初始延时就 10 秒那预热时间过长会导致老实例一直没摘除新实例一直不就绪。我的做法是把预热做成分阶段的连接池和核心 SQL 预热放在 Readiness 通过之前强制执行聚合类复杂 SQL 预热放在 Readiness 通过之后后台异步执行。这样既能保证探针通过速度快又能在流量完全切过来之前把复杂的执行链路热好。其实也就是把同一个ApplicationReadyEvent监听里的事情拆成两个步骤代码上没有难度主要是思路要调整。5.4 使用 Transactional 导致预热失效的坑这个坑说多了都是泪。第一次做预热组件时我把预热方法加上了Transactional注解想着反正只是执行几条 SQL加上事务更安全。结果预热根本没起到效果因为 ShardingSphere 在事务内会走一套完全不同的事务执行链路和普通查询链路的初始化不是一回事。首次普通查询该慢还是慢。所以切记预热代码不要加事务注解需要保证走的是 ShardingSphere 的普通查询执行链路这样才能把日常接口用到的那套链路热起来。同理如果你的业务查询大部分都在Transactional内那预热的 SQL 也得分别覆盖事务内和事务外两种场景。5.5 单元测试环境里反复预热影响不好在单测环境中Spring 上下文每次启动都会跑预热几百条 SQL 会拖慢整个测试套件。可以加一个开关配置比如app.shardingsphere.preheat.enabled测试环境设为false就行app: shardingsphere: preheat: enabled: true sql-names: - OrderMapper.selectByUserId - OrderMapper.selectOrderDetail - OrderMapper.selectPageList concurrency: 4把白名单和开关都放到配置里这套预热组件就真正具备了环境适应性不用每个环境改代码。6. 基于自己项目情况做定制时的一些补充建议如果你不是用 MyBatis而是用 JdbcTemplate 或 JPA预热组件的思路完全一样只是获取 SQL 的方式要换一下。JdbcTemplate 场景下直接把核心查询 SQL 写成配置列表省去MappedStatement那一步JPA 场景下可以用EntityManager执行几个代表性查询或者利用PostConstruct在 Repository 初始化后主动触发几条原生查询。如果是分库分表之外的场景比如只做了分库没分表或者用的读写分离预热逻辑可以简化很多主要就是元数据加载和连接池预热跨分片归并那部分直接去掉即可。另外如果你在项目里用的是 ShardingSphere 5.1 以上版本可以关注一下官方新增的sql-statistic等能力但预热组件本身的思想仍然适用。底层只要还是“懒加载 缓存冷启动”这套设计提前执行关键 SQL 的策略就永远有效。提示每次发版如果改了分片算法或表结构建议强制让缓存失效并重新预热。ShardingSphere 在运行期检测到表结构变更后可能不会自动重置已缓存的元数据这时候旧缓存反而会导致查询异常。重启服务通常最省事如果不想重启就需要主动刷新元数据缓存。我在实际操作中的体会是预热这件事代码量不大难点全在“你到底要知道自己在热什么东西”。连接池、元数据、解析器、执行引擎每一层不热透都可能在你毫无防备的时候冒出来给你一刀。拿上面这套思路走一遍至少让我服务的首次查询耗时从秒级降到了百毫秒级这个优化投入产出比还是很值得的。最后再提醒一句压测验证别只测平均值把首次请求单独摘出来看才是真正检验预热效果的标准姿势。