多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

ShardingSphere 5.0首次SQL慢?从根因到启动预热方案全解析

ShardingSphere 5.0首次SQL慢?从根因到启动预热方案全解析 先说个我踩过的坑。去年给公司一个商品中心做分库分表改造SpringBoot 集成 ShardingSphere 5.0 启动之后连上去跑第一条 SQL好家伙直接卡了 3 秒多才返回。一开始我以为是数据量太大后来发现哪怕查一条空表也要这么久。你要是也遇到过这类“启动后首次查询慢”的诡异现象大概率是 ShardingSphere 和应用连接池在偷懒没有提前把自己该加载的东西加载完。这篇文章就把我自己的排查过程、根因分析和一套稳妥的预热方案完整写出来照着做基本能把这个首次查询的耗时打下来。这个问题的适用范围其实很广只要是 SpringBoot ShardingSphere 5.x 的项目不管你是分库分表、读写分离还是数据加密首次 SQL 慢都有可能出现。文章适合正在搞 ShardingSphere 落地的后端开发也适合刚接手分库分表项目、被线上“第一个请求超时”搞懵的运维同学。我会把原理、代码、配置、验证方法都拆开讲清楚尽量让看完的人能直接抄作业。1. 启动后首次 SQL 慢到底慢在哪一步先说结论慢不是 ShardingSphere 在 SQL 执行阶段多花了时间而是它和底层数据库连接池在“第一次被使用”的时候才做了一堆本可以提前做的初始化工作。我当时排查的方式很简单先在 application.yml 里把 ShardingSphere 的 SQL 日志打开然后在启动完成后马上执行一条SELECT COUNT(1) FROM t_order WHERE order_id 1看日志里 SQL 的执行耗时分布。结果很有意思PreparedStatement 执行只花了 30 毫秒左右但整个接口从进入到返回等了足足 3 秒。这就说明耗时不在数据库本身而在进入 SQL 执行链路之前的某个环节。接着我继续往细了查。在接口方法入口、Service 层、Mapper 层、DataSource.getConnection() 这几处分别埋了时间戳。最后发现时间几乎全花在第一次调用DataSource.getConnection()上。这个连接并不是 ShardingSphere 逻辑数据源自己持有物理连接而是要向下从 HikariCP 连接池拿真实连接。而 HikariCP 在 SpringBoot 项目里默认是懒加载连接项目启动的时候池子里其实是空的第一次拿连接才真正触发与 MySQL 建连。再加上 ShardingSphere 5.0 在首次执行 SQL 时还会做路由解析、规则绑定、元数据获取等操作两者叠加在一起首次查询的时间自然就被拉得很难看。这个现象和业务复杂度无关几乎是一个必现问题。只要你的应用一启动就立刻有流量进来或者像我们这样启动完马上执行接口自测一定能感受到那种“卡第一下”的体验。1.1 连接池的懒初始化机制这里需要先弄明白 HikariCP 的初始化逻辑。SpringBoot 2.x 默认的数据源连接池就是 HikariCP它的设计目标是极高吞吐和极低延迟但“启动即建立所有连接”并不是它的默认行为。HikariCP 在初始化的时候会启动一个 HouseKeeper 后台任务但这个任务主要是维护连接数量也就是当连接数低于 minimumIdle 时才补充连接。默认情况下minimumIdle 的值和 maximumPoolSize 一样而 maximumPoolSize 默认是 10。听起来像是启动时要建 10 个连接但实际并不是这样连接池初始状态是空的只有第一次 getConnection() 才会同步创建一个连接然后再慢慢由 HouseKeeper 补到 minimumIdle 数量。如果你用的是 Java 17 和最新版 SpringBoot 3.x这个行为也一样HikariCP 一直保持了“按需创建连接”的策略。只有当配置了initializationFailTimeout这个参数并设置大于 0 时连接池启动时才会同步尝试建立连接用来保证数据源是可用的。默认值其实是 1但它的语义只是尝试获取一个连接来验证数据源可用性拿完就标记为成功并不会顺手创建一堆连接放在池子里。所以SpringBoot 项目启动完成后HikariCP 大多数情况下是空池子。第一次请求进来时连接池要经历“新建 TCP 连接 - MySQL 认证 - 设置会话变量 - 返回连接”这一整套动作。光建一个连接普遍要 200ms 到 500ms如果数据库和业务应用之间还隔着云网络或者安全组策略这个时间还会更长。而 ShardingSphere 5.0 下面不只有一个真实数据源分两个库就是两个连接池分四个库就是四个连接池首次查询如果走了全分片路由那就要把这几个库的连接几乎都建立一遍耗时自然成倍增长。1.2 ShardingSphere 内核的懒加载设计ShardingSphere 5.0 对整个内核做了大幅重构引入了ShardingSphereDataSource、ShardingSphereRuntimeContext、ShardingSphereMetaData等概念。虽然启动构建 DataSource 时会初始化不少规则配置但很多内部组件用的是懒加载机制尤其是 SQL 解析引擎、路由引擎里的部分缓存、分布式序列生成器都是在第一次真正执行 SQL 时才完成加载。举一个具体例子分片策略里的KeyGenerateAlgorithm如果用雪花算法snowflake它的工作节点初始化、时间戳起始值处理某些版本是延迟到第一次生成 ID 时才触发的。再比如分片路由过程中需要根据分片键值计算目标表名这个计算依赖的ShardingAlgorithm实例虽然规则解析在启动时就完成了但算法内部一些需要从外部配置读取的属性映射也可能在首次使用时才真正解析完。懒加载本身算不上设计缺陷它是为了缩短应用启动时间、避免加载用不到的功能而刻意做的取舍。但副作用就是“启动后第一炮”会很难受。尤其是 ShardingSphere 5.0 刚发布那阵子SQL 解析引擎的初始化比 4.x 版本要重不少5.0 默认还开启了sql-show的解析日志打印首次解析一条 SQL 需要构建语法树、生成分析结果并缓存这些操作本来就是冷启动成本的一部分。所以你会发现不管是在 SpringBoot 和 ShardingSphere 5.0 环境做接口压测还是在 Kotlin、Play Framework 这类 JVM Web 框架里集成同一个内核首次请求慢的现象都差不多。它本质上是框架初始化时机的问题不完全是某一个开发框架的责任。1.3 首次执行还需要触发的组件清单把槽点拉出来列个清单会更有感知。一次看似简单的SELECT * FROM t_order WHERE order_id 1在 ShardingSphere 内部大致要经过以下几步将 SQL 解析成抽象语法树绑定分片规则根据分片键值计算实际物理表名多分片条件下生成路由结果如果没走分片键会退化成全路由在逻辑数据源上获取物理连接这时才真正触发 HikariCP 建连如果是首次操作某个分片表还会加载对应的表元数据将逻辑 SQL 改写成真实库表名对应的物理 SQL执行结果归并时首次构建结果集归并引擎这里面任何一步的冷启动成本都不算高但串在一起就是肉眼可见的延迟。而预热的核心思路就是把这些冷启动动作提前到应用启动完成后主动跑一遍让真正接收流量时所有组件都处于“热”状态。2. 预热方案选型先搞清楚自己要预热什么知道了慢的原因接下来的问题就是怎么预热。我在网上看到有不少人给出的方案是在项目启动后随便执行一条 SQL比如写个PostConstruct方法去查一下某张表。这个思路方向对但太粗糙了很容易漏掉关键部分。你必须想清楚到底要触发哪些数据源、哪些分片表、哪些路由策略才能真正达到“暖机”效果。2.1 预热目标拆解连接池、分片路由、元数据我把预热的对象拆成了三个层面。第一层是连接池。所有真实数据源连接池都要提前把连接创建好至少把minimumIdle对应的连接数建立起来。如果应用分了 4 个库那这 4 个库的 HikariCP 池子都要激活。第二层是分片路由。你需要让 ShardingSphere 对核心业务表至少执行一次带分片键的精确查询比如WHERE order_id 1这样它会把分片算法、表路由结果、SQL 改写逻辑都跑一遍。不同分片键的查询最好各来一条因为不同分片键可能命中不同的分片算法实例。第三层是元数据。ShardingSphere 5.0 会缓存表的列信息、主键信息等元数据。如果应用涉及加密字段、广播表、绑定表也建议预热时触达因为这些会额外增加元数据解析和路由改写逻辑。如果只预热连接池不预热路由第一次查询还是会慢在规则解析上如果只预热某一张分片表另一张核心分片表第一次查询时还是要经历冷启动。所以一份合格的预热清单必须从业务实际查询链路出发把所有核心表、核心分片键、广播表、绑定表都覆盖到。2.2 不同预热时机的优缺点对比实现预热的方式很多常见的有下面这几种。我把它们放在一起对比一下方便你选型。预热方式触发时机优点缺点PostConstruct初始化方法Bean 初始化完成后代码最简单直接写在配置类里执行过早其他 Bean 未必就绪容易拿到不完整的上下文ApplicationRunner/CommandLineRunnerSpringBoot 启动完成后上下文完整适合做启动后置动作执行顺序可控启动时间会变长需要控制预热耗时ApplicationListener 监听事件自定义时机触发灵活可以监听ApplicationReadyEvent等事件要写监听器比 Runner 多一层理解成本Actuator / 健康检查触发外部调用触发不影响启动时长按需预热依赖外部调用第一次真实请求可能仍然慢定时任务兜底应用运行期间可以周期性刷新连接、缓存增加了维护成本需要处理并发访问冲突从实践角度看我推荐“ApplicationRunner 触发预热 配置项开关”的组合方案。项目启动后自动执行一遍一旦发现预热有问题还可以通过配置开关关掉不影响应用启动。至于 Actuator 健康检查可以作为兜底手段比如 Kubernetes 的存活探针本来就会周期性地访问应用端口你可以在探针处理逻辑里顺带触发一次查询这样也无缝完成了预热。3. 代码实战基于 ApplicationRunner 的启动预热下面进入正题我直接给出一个可落地的预热组件设计。这个方案已经在生产环境跑了大半年平稳度过了多次发版重启核心思路就是应用启动完成后主动执行一组经过挑选的 SQL覆盖所有真实数据源和核心路由规则。3.1 实现一个支持开关和耗时统计的预热执行器先定义一个配置前缀用来控制预热开关、预热 SQL 列表、每次预热超时时间。这样不同环境可以自由调整。spring: shardingsphere: datasource: names: ds0,ds1 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/ds0?useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 ds1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/ds1?useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 rules: sharding: tables: t_order: actual-data-nodes: ds$-{0..1}.t_order_$-{0..1} table-strategy: standard: sharding-column: order_id sharding-algorithm-name: table_inline key-generate-strategy: column: order_id key-generator-name: snowflake t_order_item: actual-data-nodes: ds$-{0..1}.t_order_item_$-{0..1} table-strategy: standard: sharding-column: order_id sharding-algorithm-name: table_inline binding-tables: - t_order,t_order_item broadcast-tables: - t_config sharding-algorithms: table_inline: type: INLINE props: algorithm-expression: t_order_$-{order_id % 2} key-generators: snowflake: type: SNOWFLAKE props: sql-show: false接下来写一个预热配置类读取配置项并在 SpringBoot 启动完成后执行预热逻辑。import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import javax.sql.DataSource; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Statement; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; Component ConfigurationProperties(prefix sharding.preheat) public class ShardingSpherePreheatRunner implements ApplicationRunner { private boolean enabled true; private ListString sqls new ArrayList(); private long timeoutSeconds 30; private final DataSource dataSource; public ShardingSpherePreheatRunner(DataSource dataSource) { this.dataSource dataSource; } public void setEnabled(boolean enabled) { this.enabled enabled; } public void setSqls(ListString sqls) { this.sqls sqls; } public void setTimeoutSeconds(long timeoutSeconds) { this.timeoutSeconds timeoutSeconds; } Override public void run(ApplicationArguments args) { if (!enabled) { return; } long start System.currentTimeMillis(); log.info(ShardingSphere preheat start, sqls {}, sqls.size()); try (Connection connection dataSource.getConnection(); Statement statement connection.createStatement()) { for (String sql : sqls) { long sqlStart System.currentTimeMillis(); try (ResultSet rs statement.executeQuery(sql)) { // 只需要触发执行不需要消费结果 } catch (Exception e) { log.error(preheat sql failed, sql {}, sql, e); } long cost System.currentTimeMillis() - sqlStart; log.info(preheat sql cost {} ms, sql {}, cost, sql); } } catch (Exception e) { log.error(ShardingSphere preheat failed, e); } long totalCost System.currentTimeMillis() - start; log.info(ShardingSphere preheat finished, total cost {} ms, totalCost); } }然后写一个自动配置类或者直接在启动类同包下定义Configuration把预热 SQL 列表交给 Spring 管理。import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.Arrays; import java.util.List; Configuration public class PreheatConfig { Bean public ListString preheatSqls() { return Arrays.asList( SELECT COUNT(1) FROM t_order WHERE order_id 1, SELECT COUNT(1) FROM t_order WHERE order_id 2, SELECT COUNT(1) FROM t_order_item WHERE order_id 1, SELECT COUNT(1) FROM t_config ); } }如果你是用代码方式构建 ShardingSphere 数据源也可以把dataSource强制转换成ShardingSphereDataSource然后调用它的内部方法来获取所有真实数据源并对每个数据源做连接预热。但在 SpringBoot Starter 的自动配置体系中直接注入 DataSource 就已经是 ShardingSphere 的逻辑数据源了所以上面的写法最简单也最不容易出错。3.2 预热 SQL 应该如何挑选预热 SQL 不是随便写几条就行它要尽量贴近真实业务请求。我总结了几条实用的选择原则。第一条是覆盖核心分片表的精确查询。对每一张分片表至少要执行一条带分片键的WHERE查询触发 ShardingSphere 的标准路由引擎。分片键取值最好覆盖不同的分片目标比如order_id 1和order_id 2在一张表分片算法是order_id % 2时一个命中表后缀 1一个命中表后缀 0这样两张物理分表的元数据和路由缓存都能被加载。第二条是覆盖绑定表和广播表。绑定表之间的关联查询会走绑定路由如果不预热第一次关联查询时要额外计算关联关系。广播表是所有分片库都存在的表一次预热会触发所有数据源连接这正好把连接池也带起来了。第三条是尽量使用COUNT(1)而不是SELECT *减少网络回包数据量。预热的目的不是取数据而是触发路由、建连、元数据加载这些机制返回行数越少越好。SELECT COUNT(1)在 MySQL 里走覆盖索引扫描即可对业务表影响也小。第 4 条是别把写操作放进预热 SQL 里特别是INSERT和UPDATE。写操作可能会改变数据状态如果表里有唯一键约束还可能因为重复插入导致异常。预热就老老实实用只读 SQL。上面示例里我把t_config广播表加了进去一个重要原因就是广播表存在所有分片库里跑一次SELECT COUNT(1) FROM t_config时ShardingSphere 会向 ds0 和 ds1 各发一条 SQL也就同时激活了两个物理数据源的全部连接池。3.3 配置连接池参数让预热效果更彻底单纯执行几条 SQL 还只能建立少量连接因为一个连接池默认可能只建立一个物理连接。如果业务高峰期需要 20 个连接而你启动时只建了 1 个那么后续连接还是要边用边建依然会有建连延迟。所以配合预热我建议主动设置 HikariCP 的参数让连接池在预热阶段就把连接补到合理水位。minimum-idle可以设置成你期望的常用连接数maximum-pool-size设置成峰值连接数。这样预热跑完池子里就已经有 5 个或 10 个空闲连接挂在那边真实请求来了直接复用。另外initialization-fail-timeout这个参数值得用起来。它表示连接池初始化时尝试获取一个连接的超时时间如果设置大于 0应用启动时就会同步验证能否正常建连。虽然它只建立一个连接但至少能让数据库连接在启动阶段就暴露问题而不是等到第一次请求才报错。spring: datasource: hikari: minimum-idle: 10 maximum-pool-size: 20 connection-timeout: 30000 initialization-fail-timeout: 1000需要注意如果你的 ShardingSphere 配置里每个数据源都单独写了 HikariCP 配置那么spring.datasource.hikari这层全局配置不一定会覆盖子数据源。每个数据源自己的参数要单独检查。我见过有人在全局配置了maximum-pool-size50但分库数据源里继承的还是默认值 10结果压测时连接池成了瓶颈。这一点在配置多数据源时尤其容易踩坑。4. 给预热增加并发能力与兜底策略虽然上面这套 Runner 已经能解决大部分问题但在真实场景里我还遇到了几个进阶需求表数量多的时候一条一条预热太慢应用启动后如果数据库还没就绪预热失败会导致整个启动过程异常以及压测过程中发现光预热一次还不够高峰期连接池被重新打回低水位后还是要等建连。针对这些情况我做了第二版优化。4.1 使用多线程并发预热核心分片表当核心分片表有几十张时串行执行每一条预热 SQL 可能要好几秒。在启动阶段拖慢应用业务上是不能接受的。这时可以把预热 SQL 拆成多个任务用线程池并发执行。import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Component; import javax.sql.DataSource; import java.sql.Connection; import java.sql.Statement; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; Component public class ConcurrentShardingSpherePreheater { private final DataSource dataSource; private final ThreadPoolTaskExecutor preheatExecutor; public ConcurrentShardingSpherePreheater(DataSource dataSource) { this.dataSource dataSource; ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setThreadNamePrefix(sharding-preheat-); executor.initialize(); this.preheatExecutor executor; } public void preheat(ListString sqls, long timeoutSeconds) throws InterruptedException { CountDownLatch latch new CountDownLatch(sqls.size()); ListString failedSqls new ArrayList(); for (String sql : sqls) { preheatExecutor.execute(() - { long start System.currentTimeMillis(); try (Connection connection dataSource.getConnection(); Statement statement connection.createStatement()) { statement.executeQuery(sql); } catch (Exception e) { failedSqls.add(sql); } finally { latch.countDown(); } }); } boolean completed latch.await(timeoutSeconds, TimeUnit.SECONDS); if (!completed) { log.error(preheat timeout, not all sqls finished); } if (!failedSqls.isEmpty()) { log.error(preheat failed sqls: {}, failedSqls); } } }并发预热带来的收益很明显4 张表从 3 秒锐减到 800ms 左右同时所有表的路由和连接池都得到了预热。但要注意并发线程数不建议太高否则启动瞬间数据库会接到一波密集请求反而把数据库连接数打满影响其他正常服务。7 到 8 个并发线程是一个相对保守的区间。这里还有一个细节因为所有线程共用同一个DataSource.getConnection()ShardingSphere 会对逻辑数据源的连接获取做锁控制并发场景下连接池本身会成为瓶颈所以线程数不是越大越好要结合 HikariCP 的maximum-pool-size来考虑。4.2 预热失败不能拖垮应用启动预热是一种优化手段不是业务主链路所以它绝对不能成为应用启动的阻碍。我在代码里已经加了try-catch和CountDownLatch.await的超时控制就是为了防止预热环节出问题导致整个 SpringBoot 上下文启动失败。还有人问过我一个场景如果数据库配置错了预热失败会不会抛异常导致启动停止答案是看你把异常怎么处理。ApplicationRunner.run方法抛出异常时SpringBoot 默认是会终止启动流程的。所以强烈建议在预热方法内部把所有异常都吞掉起码要打成日志不要往外抛。等业务请求进来时如果确实断了连再走正常的异常处理链路自然会暴露出来。不过吞异常不意味着完全无视。我习惯把预热失败的 SQL 打 warn 级日志并附上当前数据源信息方便启动后去排查。同时通过 Actuator 的 health 端点做联动如果预热失败就把健康检查状态置为暂时不健康让负载均衡器不要把流量打进来。这样既不阻断启动也不会在系统还没完全准备好时接收大量请求。4.3 定时兜底连接回收后的二次预热策略HikariCP 的空闲连接默认存活时间是 10 分钟maxLifetime默认 30 分钟。如果你的应用有较长时间没有访问某个分片库连接池里的空闲连接可能被回收。这时如果突然来一波大流量还是会有一个短暂的建连过程。所以我在生产环境里又加了一个兜底机制。通过 Spring 的Scheduled注解写一个定时任务每隔 5 分钟对核心分片表执行一次轻量级查询保持连接池水温和路由缓存的热度。这个定时任务只做预热不参与业务逻辑即使失败也只记录日志不影响主流程。import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import javax.sql.DataSource; import java.sql.Connection; import java.sql.Statement; Component public class PreheatScheduler { private final DataSource dataSource; public PreheatScheduler(DataSource dataSource) { this.dataSource dataSource; } Scheduled(fixedDelay 5 * 60 * 1000, initialDelay 60 * 1000) public void keepWarm() { try (Connection connection dataSource.getConnection(); Statement statement connection.createStatement()) { statement.executeQuery(SELECT COUNT(1) FROM t_order WHERE order_id 1); } catch (Exception e) { log.warn(keep warm failed, e); } } }定时预热要注意和业务高峰期错开不要在整点和大促峰值时间抢连接。我用的是 fixedDelay表示上一次执行完成后再等固定时间间隔不会出现任务重叠。5. 验证预热效果不能只靠感觉很多人写完预热代码跑一次接口觉得快了就认为大功告成。实际上这样不够严谨。你至少要从耗时数字、连接池状态、ShardingSphere 路由日志三个角度去验证确保预热真正落到了实处。5.1 启动后立即执行真实查询对比预热前后耗时最直接的验证方法是在 SpringBoot 启动完成后写一个临时接口或者 CommandLineRunner让它在预热执行之后立刻执行一条和预热 SQL 等价的业务查询并输出详细耗时。我当时的做法是在 Application 启动类里加了一个CommandLineRunner第一个打印预热前查询耗时第二个打印预热后查询耗时。对比如下预热前首次查询3062ms预热后首次查询45ms这个对比非常直观也让我确认所有耗时都集中在第一次查询的链路初始化上。如果你也想复现这个对比记得在启动时暂时把预热的enabled配置设为 false先跑一次拿原始数据再打开预热跑一次。如果两次查询耗时差不多那说明你的慢查询根因可能不在 ShardingSphere 冷启动上而需要从 SQL 执行计划、数据库索引、网络延迟这些方向再排查。预热不是万能药它只解决“初始化时机”带来的延迟。5.2 通过连接池指标确认物理连接已建立HikariCP 本身自带监控指标如果你引入了micrometer-registry-prometheus可以在 Prometheus 里看到hikaricp_connections_active、hikaricp_connections_idle、hikaricp_connections_pending这些指标。验证时重点关注hikaricp_connections_idle。预热完成后这个值应当约等于配置的minimum-idle值。如果为 0说明连接池仍然是空的预热并没有真正触发建连。这种情况通常是因为预热 SQL 没有走真实的物理数据源或者 ShardingSphere 的路由结果没有命中任何分片。举例来说如果分片表路由条件是order_id % 2而你的预热 SQL 用了WHERE id 1这个id并不是分片键那 ShardingSphere 只能走全路由它会把 SQL 广播到所有分片库。虽然这样也能建连但消耗更大。如果你所在的表路由配置里压根没有匹配任何分片键SQL 甚至可能报错。所以预热 SQL 的分片键一定要写对。5.3 打开 ShardingSphere SQL 日志观察实际下发的物理 SQLShardingSphere 5.0 在props里有一个sql-show配置。设为 true 后SQL 执行时会在日志里打印逻辑 SQL 和实际下发的物理 SQL。这个日志对验证预热效果非常有帮助。预热SELECT COUNT(1) FROM t_order WHERE order_id 1时日志中应当出现类似Actual SQL: ds0 ::: SELECT COUNT(1) FROM t_order_1这表示路由结果正确SQL 被改写到对应的物理分表。如果日志只输出了Actual SQL: ds0 ds1 ::: SELECT COUNT(1) FROM t_order_0 t_order_1这类广播执行说明分片键没有生效走了全路由。虽然全路由也能把连接池热起来但会让单次查询扫描所有分片表性能开销大。在预热阶段发现并修正分片键问题可以提前排除掉很多上线后的隐患。sql-show 本身有性能损耗正式环境建议只在排查问题的时候临时打开排查完马上关掉。6. 预热过程中的常见坑和排查思路最后我把实际工作中遇到过的问题整理成一个速查表都是比较高频的坑希望能帮你少走弯路。问题现象可能原因解决方法预热 SQL 执行时间非常长走了全分片路由扫描了所有分片表确保 SQL 带准确的分片键改为精确路由预热后连接池 idle 仍为 0预热 SQL 没有真正下发到物理库检查路由配置和表名用 sql-show 日志验证启动时预热报错应用启动失败ApplicationRunner 抛出了未捕获异常在预热方法内部捕获所有异常不要向外抛预热过程很慢拖长了启动时间预热 SQL 过多或串行执行用线程池并发预热控制 SQL 数量和超时时间启动后第一次插入数据仍然慢只预热了查询没有预热分布式 ID 生成器增加一次带主键生成的 INSERT 预热但注意幂等性多个分片库中某个库连接池一直空预热 SQL 只覆盖了部分分片键预热 SQL 覆盖不同分片键保证路由到不同库和表定时预热和业务查询同时执行出现连接竞争定时任务没有避开流量高峰调整定时任务执行时间或者降低预热频率全局 HikariCP 配置不生效子数据源有自己的配置覆盖了全局配置检查 ShardingSphere 每个数据源里的 HikariCP 参数第 4 个问题我想单独多说一句。很多人预热只写查询结果上线后第一次往分片表插入数据还是明显卡顿。原因是分布式主键的生成器没有预加载。解决思路有两个一是在预热 SQL 里加一条明确指定主键的 INSERT比如INSERT INTO t_order (order_id, ...) VALUES (999, ...)插入后立刻回滚或删除只为了触发主键生成逻辑二是直接调用snowflake算法的generateKey()方法在启动阶段主动生成一个 ID让算法内部完成时间戳、workerId 的初始化。第二种更干净不会污染数据。定时任务如果担心和业务流量冲突可以用开关控制在不同环境选择是否启用。比如灰度测试时可以关闭定时预热减少对压测的干扰正式环境则一直开启。另外还有个容易被忽略的点ShardingSphere 5.0 和 5.1、5.2 在配置项细节上有些差异。比如 5.1 之后把key-generators的配置格式做了调整某些版本要求声明key-generator-name时必须同时写type。如果你用的版本和网上博客不一致照着配置容易报错。所以我建议以官方文档对应版本的示例为准不要盲目照抄旧文章。7. 一些额外的经验与建议预热这个技巧本身不难但它反映出的问题是很多中间件框架共通的懒加载设计可以优化正常启动速度但会让“首次体验”变差。在接手类似 ShardingSphere 这类偏重的中间件时我建议你把“启动后预热”直接纳入标准上线流程而不是等线上反馈慢了才补救。关于预热 SQL 的选择我再补充一个技巧可以优先选择带索引的精确查询同时把结果集大小控制住。SELECT COUNT(1)是一个很好的选择但如果你用的数据库是 MySQL 且表特别大COUNT(1)也可能因为 InnoDB 的统计信息更新而变慢。这时候可以改成SELECT id FROM t_order WHERE order_id 1 LIMIT 1只取一行数据同样能触发分片路由和连接池建连但数据库成本更低。我还在线上遇到过一种情况就是业务有读写分离策略主库和从库都有独立的数据源。只预热主库 SQL 的话第一次走从库的查询依然会慢因为从库连接池是独立的。所以读写分离场景下预热 SQL 至少要包含一条强制走从库的查询。ShardingSphere 本身可以通过hint或者Slave注解方式路由到从库但如果你没有用这类能力也可以直接为从库的连接池单独执行一次SELECT 1让连接池热起来。最后再说一个运维层面的事。预热结束后最好在日志里打印一个明确的“preheat finished”标志方便发布系统判断应用是否已真正就绪。之前我们团队在 CI/CD 流水线里靠这个日志来判断应用是否进入了可服务状态。如果没打这个日志就认为预热失败直接报警或者回滚。这对多节点滚动发布尤其有用可以避免旧节点还没停、新节点还在冷启动时负载均衡就把流量导过来造成间歇性超时。我自己在实际操作中最大的感受是这类问题一定要用数据说话。你别凭感觉觉得“好像快了一点”而是要打开日志、打开监控、对比连接池指标一步一步确认热度到位了然后再纳入标准发布流程。预热这个动作看起来只是多加了一条 Runner但它实际是在替线上流量打前站把那些所有“第一次”才做的事提前做完真正收到流量的时候系统才能给别人一种“我一直都在原地等着”的从容感。
返回列表