ShardingSphere-JDBC源码解析:从SQL解析到路由的完整链路 最近在排查一个线上分库分表查询变慢的问题顺着调用链一头扎进了 ShardingSphere 5.0.0-beta 的 JDBC 源码里。逐行看下来发现 JDBC 这条路径上 SQL 解析与路由的耦合比想象中更深很多奇怪的执行计划问题并不在分片算法而是早在 SQL 路由阶段就埋下了。我把这条链路单独抽出来写成一篇源码笔记后续应该还有第二篇讲改写和执行。这篇先聚焦从 SQL 文本到路由结果的前半程适合正在用 ShardingSphere-JDBC 做分库分表、又想知道它内部到底怎么工作的读者。我会尽量用源码里的真实类名和关键方法说话也会把一些读代码时才注意到的细节点出来。1. 从一条SQL走进JDBC先锁定入口再谈解析路由很多读者习惯直接从 ShardingRule 开始看路由但实际生产环境里 SQL 是从 JDBC 驱动走进来的入口不搞清楚后面所有调用链都会对不上号。5.0.0-beta 的 JDBC 实现已经和 4.x 大不一样数据源、连接、语句对象各层都做了拆分先从这三个对象看起。1.1 ShardingDataSource 的 getConnection 到底做了什么用户代码拿到的 DataSource 通常是ShardingSphereDataSource它内部持有的是ShardingRuntimeContext。getConnection()方法看起来只是简单new一个连接对象但这里已经埋了第一次路由前置判断连接本身并不绑定具体库真正的库选型发生在后面创建语句时。关键点在于ShardingRuntimeContext的初始化。它接收的是ShardingRule和ShardingSphereData前者包含分片规则、绑定表规则、广播表规则后者包含实际数据库连接池。初始化过程中最容易被忽略的是SQLStatementParserEngine的预热——它默认会做一次引擎加载但不会预解析任何 SQL。很多性能问题就出在第一次查询时才加载解析器不过好在后续有缓存兜底。1.2 真正的执行入口ShardingPreparedStatement 的两阶段准备当应用执行PreparedStatement.executeQuery()时实际调用的是ShardingPreparedStatement的executeQuery()。这个方法内部很清晰分两阶段第一阶段把 SQL 和参数绑定到当前语句上下文第二阶段调用ShardingSphereConnection的prepareStatement()生成真正的执行计划。第二阶段是核心因为这里会触发SQLStatementParserEngine.parse()也就是 SQL 解析的正式开始。有一点值得注意5.0.0-beta 的ShardingPreparedStatement并没有在executeQuery()里直接做路由而是把路由结果放在QueryContext中再交给执行引擎。这个设计让 SQL 解析和路由的结果可以被复用比如同一语句对象多次执行时可以走RouteContext缓存避免重复解析。// 简化自 ShardingPreparedStatement.executeQuery public ResultSet executeQuery() throws SQLException { QueryContext queryContext createQueryContext(); // 内部触发 SQL 解析 ListQueryResult queryResults executeQuery(queryContext); // 内部触发路由和执行 return new ShardingResultSet(queryResults); }1.3 为什么要先走SQL解析再决定路由直接回答因为路由的输入不是 SQL 字符串是结构化的SQLStatement。字符串里都是字符但只有把表名、分片键、查询条件提取出来路由引擎才能判断该命中的数据节点。这一点在 4.x 时代做得比较粗糙很多场景靠正则直接匹配。5.0.0-beta 全面转向 AST抽象语法树解析后的SQLStatement内部已经区分了TableSegment、WhereSegment、PredicateSegment这些结构决定了后续是走单播路由、广播路由还是绑定表路由。所以SQL 解析不是为了解析而解析它是路由的前置条件。2. SQL解析引擎SQLStatement才是路由的通用语言整个 SQL 解析引擎的核心在org.apache.shardingsphere.sql.parser包里。5.0.0-beta 把解析器按数据库方言分成了多个模块比如 MySQL、PostgreSQL、Oracle、SQLServer 各自独立。这里有个很重要的设计解析器使用ANTLR生成语法树但对外输出的不是ParseTree而是统一过后的SQLStatement。这种设计的意图很明显——路由引擎不需要关心 SQL 用的是哪种方言只认SQLStatement这一层抽象。2.1 ParseEngine 的初始化与缓存的坑SQLStatementParserEngine是解析引擎的入口构造时需要传入ParseRule和DatabaseType。每个数据库类型对应一套解析器。这里有一个容易被忽略的细节SQLStatementParserEngine持有的是SQLStatementCache而非SQLParseResultCache它缓存的是SQL字符串 - SQLStatement的映射。这个缓存的坑有两个。第一个坑是 key 默认带上了SQLStatementCache的CacheOption配置如果缓存大小设置不当高并发下会出现频繁淘汰导致解析器被反复创建。第二个坑是缓存命中只针对完全一样的 SQL如果代码里每条查询都拼上不同的注释或者空格缓存基本失效性能会劣化严重。// 简化自 SQLStatementParserEngine public SQLStatement parse(final String sql, final boolean useCache) { if (useCache) { OptionalSQLStatement cached cache.get(sql); if (cached.isPresent()) { return cached.get(); } } SQLStatement statement parse0(sql); if (useCache) { cache.put(sql, statement); } return statement; }2.2 从SQL文本到ParseContext的一次完整旅行一次解析的完整链路如下SQLStatementParserEngine.parse()根据databaseType选定具体的SQLParserExecutor然后调用SQLParserExecutor.execute(sql, useCache)。这一步会调用SQLParser.createStatement(sql)。核心是把文本交给Lexer做词法切分再交给Parser做语法推导最后得到一个ParseTree。但这里没有直接返回ParseTree而是经过一个SQLStatementVisitor把 AST 转换成SQLStatement对象。以一条简单的SELECT * FROM t_order WHERE user_id 1为例生成的MySQLSelectStatement内部会有ProjectionSegment对应*、FromSegment对应t_order、WhereSegment对应user_id 1。这些 Segment 都是轻量级 POJO路由时只关心FromSegment和WhereSegment。2.3 访问者模式如何把AST变成SQLStatementANTLR 生成的ParseTreeVisitor接口方法很多ShardingSphere 在各个方言模块里写了对应的 Visitor 实现。这里需要理解一个关键点MySQLStatementVisitor继承的是MySQLBaseVisitor它会重写visitSelect()、visitTableName()、visitWhereClause()等方法。比如重写visitTableName()时会从TableNameContext中提取表名和别名封装成TableSegment。重写visitWhereClause()时会把WHERE子句的表达式转换成WhereSegment而表达式里的等值判断会被转换为BinaryPredicateSegment。这个转换过程直接决定了路由引擎能否提取到分片键。如果 SQL 写法太复杂比如WHERE function(user_id) 1Visitor 很可能解析不出可用的PredicateSegment路由时就会因为找不到分片键而选择全库表广播这也是很多慢查询的深层原因。2.4 解析结果的缓存SQLStatementCache与RouteCache的区别我在读源码时发现很多人把这两层缓存混为一谈其实差别很大缓存作用存储内容失效粒度SQLStatementCache避免重复解析SQL字符串对应的SQLStatement按SQL全文本RouteCache避免重复路由SQL和参数组合对应的RouteContext按SQL和参数组合路由缓存在 5.0.0-beta 里其实不是默认全开的它更依赖参数变化。如果分片键的值是动态的那么每个不同参数都要重新路由RouteCache 的命中率很低。相比之下SQLStatementCache 的命中率更好因为参数值不会参与解析。这也是为什么解析缓存通常更值得调大。3. 路由引擎SQLStatement如何映射到真实数据节点路由是分库分表中间件最核心的决策过程。SQLStatement解析完成后路由引擎开始工作。在org.apache.shardingsphere.core.route包下路由入口是ShardingRouter.route()。它接收SQLStatement、ShardingRule和ParameterShardingValue输出RouteContext。3.1 RoutingEngine接口与路由策略的经典实现路由引擎被抽象成了RoutingEngine接口public interface RoutingEngine { RouteContext route(ShardingRule shardingRule, SQLStatement sqlStatement, ListObject parameters); }这个接口有一堆实现类但实际被频繁调用的主要有四个SingleTableRoutingEngine当查询不涉及分片表、绑定表时使用直接定位到单张物理表。ShardingTableRoutingEngine最核心的分片路由实现负责根据分片键和分片算法计算数据节点。BroadcastRoutingEngine针对广播表的处理会把 SQL 同时路由到所有库的同一张表。IgnoreRoutingEngine针对SHOW、DESC、事务控制等语句直接返回空路由。很多 SQL 会同时涉及分片表和广播表此时引擎会组合执行多个RoutingEngine最后把RouteContext合并。源码里MixedTableRouter就是干这件事的。3.2 绑定表路由背后的CartesianTableReference绑定表是多表关联查询时性能优化的关键。所谓绑定表就是在配置里声明t_order和t_order_item使用相同的分片键和分片策略关联查询时可以避免笛卡尔积。代码里实现逻辑在BindingTableRouter。它会把两个表的TableSegment放入BindingTableRule通过BindingTableRule.getBindingActualTables()找出同库同编号的实际表。这里有个容易踩坑的点绑定表路由对于 IN 条件中的多个分片值会生成多个路由结果然后在路由结果做笛卡尔积时进行裁剪。源码里是CartesianTableReference负责把两个表的路由结果按相同数据节点配对从而消除无效组合。如果绑定表规则没配对成功比如分片键类型不一致就会退化为普通关联产生全库表笛卡尔积查询性能可能呈指数级下降。3.3 路由结果RouteContext与数据节点DataNode的组装路由完成后最终产物是RouteContext里面包含一组RouteUnit。RouteUnit的粒度是逻辑表 - 实际数据源 实际表名。例如逻辑表t_order被分到两个库、每个库两张表那么t_order对应的RouteUnit可能有 4 个。每个RouteUnit里面还有DataSourceName和ActualTableName。组装时有个细节RouteResult会去重因为绑定表关联查询中t_order和t_order_item命中同一数据节点时RouteUnit是按逻辑表维度拆分的不会合并成一条。这种粒度设计是为了后续 SQL 改写时能分别替换表名但也会带来一定的性能开销。3.4 广播路由与单播路由的取舍逻辑广播路由主要用于广播表这类表在每个库都有一份完整副本所以任何增删改查都需要路由到所有库。源码中的BroadcastRoutingEngine就是遍历所有DataSourceName为每个源生成一个指向同名表的RouteUnit。单播路由则是只选择一个数据源执行典型场景是SHOW DATABASES LIKE ...这类语句。UnicastRoutingEngine会从所有数据源里随便挑一个保证语义满足即可。我在实际排障时见过一个误区有人以为SELECT * FROM t_config走的是单播路由但t_config如果配置为广播表实际是广播。要搞清楚表的类型不能想当然。4. 实战拆解一个查询在5.0.0-beta下的完整路由轨迹这一节拿一个具体例子把从解析到路由的完整流程串起来。配置采用典型的两库四表ds0、ds1逻辑表t_order分为t_order_0到t_order_3分片键为order_id分片算法采用取模order_id % 4。4.1 初始化配置与表规则配置完成后ShardingRule会包含两个TableRule实例每个TableRule内部维护actualDataNodes。actualDataNodes的生成逻辑在TableRule构造器里它会根据DataNode的格式ds${0..1}.t_order_${0..3}展开成具体列表然后在路由时用DataNodeRouter进行匹配。这里有个小细节TableRule构造时会调用ShardingStrategy配置的分片算法实例化但算法实例的初始化是懒加载的第一次路由时才真正加载。这也是为什么某些配置错误的算法在启动阶段不报错直到第一条 SQL 才抛异常。4.2 单表查询的解析路由过程执行SELECT * FROM t_order WHERE order_id 10SQLStatementParserEngine解析得到MySQLSelectStatementWhereSegment里包含order_id 10的等值谓词。ShardingRouter.route()调用ShardingTableRoutingEngine从WhereSegment提取等值条件找到order_id对应的ShardingValue。分片算法order_id % 4计算出10 % 4 2于是定位到数据节点ds0.t_order_2假设规则如此。RouteContext中只有一个RouteUnit数据源是ds0逻辑表t_order被替换为t_order_2。这一步里有个关键方法叫getShardingCondition它负责从SQLStatement中提取分片条件。源码里对等值、IN、BETWEEN 分别有不同的ShardingCondition实现提取失败时不会抛异常而是返回空集合然后路由引擎会选择全路由。这个行为需要警惕。4.3 绑定表关联查询触发绑定表路由的场景再看SELECT * FROM t_order o JOIN t_order_item i ON o.order_id i.order_id WHERE o.order_id IN (1, 2)。解析完成后FromSegment里有两个TableSegment。ShardingTableRoutingEngine发现t_order_item与t_order存在BindingTableRule于是只对t_order做分片路由t_order_item的路由则根据绑定规则推导。关键代码在BindingTableRule.getBindingActualTable()它会通过逻辑表名和分片值找到对应的实际表名。比如order_id1对应t_order_1那么t_order_item也映射到t_order_item_1数据源也必然相同。这就是绑定表避免笛卡尔积的核心。但这里存在一个限制绑定表推导只适用于等值和 IN 条件。如果ON子句使用的是表达式比如o.order_id 1 i.order_id解析器无法提取有效分片键绑定路由会失效退化成 cartesian product。我看到不少生产慢查询都是这种写法。4.4 路由结果为空时发生了什么还有一种容易被忽略的情况路由结果为空。比如SELECT * FROM t_order WHERE order_id -1而分片算法对负数取模结果仍然能映射到某个节点所以不为空。但如果order_id 100取模后是 0可能对应t_order_0不为空。真正出现空路由的场景是使用HintShardingAlgorithm且强制路由值不在范围内或者是单表路由但表名不存在。此时RouteContext的RouteUnits为空执行引擎不会报错而是返回一个空结果集。这个行为在源码里是明确的RoutingResult.getRouteUnits().isEmpty()时直接跳过执行。对业务来说这往往是查不到数据而不是查询失败容易掩盖配置错误。5. 源码里学到的性能优化与排查心得阅读源码不只是为了好奇最终要落到生产环境的问题排查和性能调优上。这一节分享几个从源码里读出来的实用点。5.1 SQLStatementCache 的并发控制并发场景下SQLStatementCache的实现使用ConcurrentHashMap作为底层存储但它的get和put并没有做锁同步。也就是说同一个 SQL 在高并发下可能被多个线程同时解析重复构建 AST。这不是 bug而是为了避免锁竞争采取的策略。如果你发现监控里解析耗时偏高可以考虑调大缓存容量减少淘汰频率而不是盲目加锁。另外一个优化思路如果应用使用了 MyBatis尽量用#{}而不是${}拼接参数。因为#{}生成的 SQL 模板是固定的缓存命中率极高${}会把参数直接拼进 SQL导致每一条不同参数值的 SQL 都要重新解析。5.2 分片键提取的优先级从ShardingTableRoutingEngine的源码看分片键提取的顺序是先等值条件再 IN 条件最后 BETWEEN。这个顺序影响路由粒度等值条件单值路由最精确。IN 条件会展开成多个ShardingValue路由结果可能有多个节点。BETWEEN 条件会生成一个范围值部分分片算法如按时间分片能直接定位但多数算法会退化为全路由。所以建议业务查询尽量使用等值和 IN 条件。我在修改了一个报表查询从BETWEEN改成IN之后路由节点数从 36 个降到了 6 个查询耗时下降了接近一半。5.3 解析失败时的降级策略ShardingSphere 在解析失败时并不总是抛出异常它有一套降级策略。具体来说SQLStatementParserEngine.parse()如果遇到无法识别的 SQL可能会返回一个DALStatement或UnknownSQLStatement这种语句不会走分片路由而是走IgnoreRoutingEngine最终原样执行。这个设计对老系统迁移比较友好但也容易埋雷某些你以为会被分片的 SQL可能因为语法太个性化导致解析器认不出来结果直接发到默认数据源。排查时如果发现某些查询没有走分片可以用show processlist看实际执行节点再反向查解析日志确认是不是走了降级路径。我个人在实际排查中养成了一个习惯线上遇到诡异的分片结果先开SQLStatementParserEngine的 debug 日志看它返回的SQLStatement类型。如果类型是UnknownSQLStatement那么后面的路由逻辑再复杂也是白搭——它根本没进入分片路由。最后再分享一个小技巧读源码时不要一次性看全部分支先从ShardingRouter.route()入手跟着一条简单查询走完解析和路由再回去看各个RoutingEngine实现。这样比从底层往上读要快得多。等这条主线理清楚了下一篇我会继续写 SQL 改写和执行的源码那时你会觉得整条链路更加顺滑。