去年我在一个分片项目里排查过一条特别诡异的查询:SELECT * FROM t_order o JOIN t_user u ON o.user_id = u.user_id WHERE u.user_id = ?。明明两个表都按 user_id 分片,结果 t_order 却扫了全部分片,性能直接被打崩。一开始我以为路由写错了,后来把解析产物和路由日志放在一起看才明白:解析器没有错,它老老实实把两个表、两个 user_id 谓词都识别出来了,但路由层没有绑定表信息,u.user_id 这个条件只能作用于 t_user,并不能自动推导到 t_order。那次排查之后我最大的感触是,不读懂 SQL 解析层的输出边界,想做分库分表的性能优化,基本等于盲人摸象。
这篇源码分析,就围绕 Sharding-JDBC 的 SQL 解析模块展开。我会按照“它在哪、怎么演进的、调用链长什么样、解析结果怎么用、常见坑在哪”这条线来拆,不需要你把每个类名背下来,但希望你读完能自己在源码里找到对应位置。
1. 分片链路里解析层的位置:从 SQL 字符串到路由条件的第一个中转站
先明确一点:解析层不负责决定数据落在哪个库哪张表。它真正做的,是把字符串形式的 SQL 转成结构化对象,让路由层有“事实”可用。
1.1 一条分片 SQL 在 Sharding-JDBC 内部要过哪些关卡
一次带分片键的查询,最终执行前至少要经过五个阶段:解析、路由、改写、执行、归并。
- 解析:SQL 字符串变成抽象语法树和语义模型。
- 路由:根据分片键和分片算法,算出要访问哪些数据节点。
- 改写:把逻辑表名替换成真实表名,补充分片条件,处理分页和聚合函数。
- 执行:把改写后的 SQL 发到目标数据源。
- 归并:把多个结果集合并成一份,模拟单库单表的效果。
路由和改写是大家最愿意研究的,但这两个模块吃的“原料”全部来自解析层。路由引擎需要回答三个问题:这条 SQL 涉及哪些逻辑表?分片键上有没有等值或 IN 条件?SQL 类型是什么,排序字段和 limit 是什么?前两个问题的答案都靠解析器给。
1.2 解析结果一旦不全,背锅的往往是路由
最容易出问题的场景是子查询和 JOIN。比如下面这种:
SELECT * FROM t_order WHERE user_id IN (SELECT user_id FROM t_order_config WHERE status = 'NEW')早期版本对子查询的解析能力有限,外层的 IN 条件可能无法识别出内层返回的 user_id,最终整个 t_order 被当作无分片键条件处理,变成全分片扫描。你从应用日志看到的往往是路由日志里出现route all shards之类的结果,于是第一反应去查路由算法,其实根子还在解析。
这也是我写这篇源码分析的一个原因:很多“路由坑”其实是“解析坑”。不把解析层的职责边界划清楚,排障时很容易绕远路。
2. 三代解析引擎变迁:为什么最终押注 ANTLR
Sharding-JDBC 的 SQL 解析实现并不是一蹴而就的,社区公开文档里也提过,它大体经历了三代:先基于 Druid,再自研,最后转向 ANTLR。理解这段历史,比直接看当前代码更能明白很多设计取舍。
2.1 第一代:Druid 解析器带来的便利与耦合
最早的实现直接借用了 Druid 的 SQL AST。好处很明显,不用自己写词法分析器和语法分析器,Druid 对 MySQL 语法的覆盖度也足够,像表名提取、表达式解析这些基础能力都是现成的。
但问题同样明显:Druid 的 AST 对象和分片路由逻辑被强耦合在一起,升级 Druid 版本时不敢轻易动核心对象;对 PostgreSQL、Oracle、SQLServer 这些方言,Druid 的支持能力参差不齐。分库分表中间件一旦要面向多数据库类型,这种绑定就变得很难受。
2.2 第二代:自研解析器的边界问题
后来社区决定不把第三方解析器绑死在内核里,开始自研 SQL 解析器。第二代解析器试图用一套自己的语法定义和 AST 模型,去兼容多种数据库方言。思路是对的,但 SQL 语法太庞杂,尤其是 CTE、窗口函数、复杂 JOIN、批量 INSERT 这类写法,语法规则很难一次覆盖。
我见过不少从旧版本升级上来的业务,跑正常的 CRUD 都没问题,一到报表类 SQL 就报解析失败。原因往往是自研解析器对某个边缘语法没建规则。这不是说实现得不够努力,而是纯手工维护多方言文法,成本实在太高。
2.3 第三代:ANTLR4 文法与 Visitor 机制带来的扩展能力
第三代的思路换成了 ANTLR4。ANTLR 本身是解析器生成器,它做的事情是:按不同方言的.g4文法文件,生成对应的 Lexer 和 Parser;上层只写 Visitor,把语法树转成统一语义模型。
这个设计最大的好处是内核和方言解耦。如果 MySQL 多了一种新语法,只需要改 MySQL 的.g4文件和对应 Visitor,不需要动路由、改写、执行这些核心模块。社区要支持新数据库,也是“加一套文法 + 一套 Visitor”。
最终你会在源码里看到一整套按数据库类型拆分的模块,核心包名通常是这样的结构:
parser/sql/dialect/mysql parser/sql/dialect/postgresql parser/sql/dialect/oracle parser/sql/dialect/sqlserver parser/sql/statement每个方言目录下都有各自的 Lexer、Parser 和 Visitor,而statement目录里存放的是与方言无关的 SQLStatement 和 Segment 模型。这篇文章后面的内容,主要围绕第三代实现来展开。
3. 从 SQL 入口到 ParseASTNode:逐层剖开解析调用链
现在我们把眼光落到当前代码上。不同版本的包名和类名会漂移,但核心链路很固定:缓存查找、词法语法解析、Visitor 转换。其中词法语法解析先生成的是一棵“语法解析树”,它还不能直接给路由用,后续会再转一次。
3.1 入口类与核心职责:SQLParserEngine 并非直接解析器
入口门面通常是SQLParserEngine,我习惯把它理解成一个“带缓存的管理器”,而不是真正的解析器。真正的解析执行逻辑往往在SQLParserExecutor等类里。
我按照个人理解把调用骨架简化成这样:
public final class SQLParserEngine { private final SQLParserExecutor executor; public SQLParserEngine(String databaseType, boolean sqlCommentParseEnabled) { executor = new SQLParserExecutor(databaseType, sqlCommentParseEnabled); } public SQLStatement parse(String sql, boolean useCache) { return executor.parse(sql, useCache); } }不同 tag 的方法签名可能略有差异,但职责是不变的:对外提供parse(sql, useCache),对内协调缓存和解析器组件。你打开源码时不用死记类名,只要顺着这个职责去找,一定能定位到真正的实现。
3.2 缓存命中为什么能让重复解析接近零成本
解析是最耗 CPU 的阶段之一,因为词法分析和语法分析要做大量状态迁移。所以引擎里一定会有缓存,通常以databaseType + sql为键,缓存对象就是解析得到的语义模型。
关键点在于:这个缓存可以安全地缓存到“与参数值无关”的程度。因为PreparedStatement的 SQL 文本里参数是?占位符,解析器看到的是?,不是具体数值,所以同一条 SQL 不管绑定的参数值怎么变,解析结果都是一样的。
但路由缓存就不一样了。路由结果依赖分片键的具体参数值,user_id = 1和user_id = 2走的可能是不一样的数据节点,所以路由缓存不能只拿 SQL 文本做键,必须带上参数执行上下文。很多同学把这两个缓存的职责混在一起,导致排查“为什么换了参数还是走同一个分片”时一头雾水。
3.3 解析异常是怎么被抛出来的
ANTLR 生成的分析器在遇到无法匹配的 Token 时,会触发错误恢复机制。Sharding-JDBC 通常会挂自定义错误监听器,让解析在遇到第一个严重语法错误时就快速失败,而不是让 ANTLR 尝试一堆恢复策略。
所以你经常会在日志里看到类似SQLParsingException: You have an error in your SQL syntax的异常。这类异常大多发生在:
- databaseType 配置成了与真实数据库不一致的方言;
- SQL 里用了当前方言文法不支持的写法;
- 某些老版本对
FOR UPDATE、WITH、窗口函数的覆盖不到位。
有些问题可以通过升级版本来解决,但如果是文法本身没有覆盖,升级可能也白搭,这就需要走扩展文法或者改写业务 SQL 的路线。
3.4 输出物 ParseASTNode 到底是什么
解析器第一阶段的输出,通常是一个ParseASTNode。它里面包含了解析后的语法树根节点、Token 流等信息。注意,它还不是最终的SQLStatement,因为此时对象还带着 ANTLR 的语法树味道,路由层直接用会很别扭。
下一步,引擎会调用 Visitor,把ParseASTNode转成纯框架定义的SQLStatement。这个转折点特别关键,我甚至建议你读源码时在断点里同时看这两个对象,看一眼什么叫“从通用语法树到业务语义模型”。
4. 词法分析、语法分析与 Segment 语义化:路由才能看懂的模型
很多第一次看 ANTLR 代码的人会盯着.g4文法或生成的Parser发呆,其实对分片功能来说,那些只是“基础设施”。真正有价值的部分在 Visitor 里:它是把语法树变成路由能听懂语言的关键翻译官。
4.1 为什么不能直接把 ANTLR 的语法树给路由用
ANTLR 生成的树是 CST,也就是具体语法树,里面包含了大量语法细节。举个例子:
SELECT o.order_id FROM t_order o WHERE o.user_id = 1CST 里会出现的节点可能包括SELECT、selectItems、fromClause、whereClause、equalityExpr、columnName,甚至还有括号、运算符优先级相关节点。路由层要的是更抽象的信息:“表名是 t_order,别名叫 o;投影列是 order_id;条件列是 owner 为 o 的 user_id”。
这些抽象信息被整理成一棵语义树,也就是SQLStatement。它不再关心 SQL 的具体书写位置和符号,只关心“这条语句表达了什么业务语义”。
4.2 Segment 是语义模型的最小积木
SQLStatement的内部会挂一批 Segment,它们才是路由和改写真正操作的对象。常见的有:
| Segment | 含义 | 典型字段 |
|---|---|---|
| TableSegment | 表信息 | 逻辑表名、别名、schema |
| ColumnSegment | 列信息 | owner、列名 |
| PredicateSegment | 谓词条件 | 左表达式、右表达式、比较符 |
| OrderBySegment | 排序项 | 排序列、排序方向 |
| LimitSegment | 分页信息 | offset、rowCount |
| ProjectionsSegment | 投影列集合 | 普通列、聚合列、星号占位符 |
比如WhereSegment内部会挂一个表达式树,叶子节点往往是 ColumnSegment 或 LiteralExpressionSegment。当路由引擎需要判断 user_id 是否有等值条件时,它就会遍历表达式树,找到 owner 和 column 都匹配的节点,再取出右边的值。
4.3 方言差异是怎么被抹平的
不同数据库的 SQL 差异非常大。MySQL 的LIMIT ? OFFSET ?,PostgreSQL 也写了 LIMIT 语法,但 Oracle 早期版本用的是ROWNUM或FETCH FIRST。解析器不可能让路由层去认真判断这些差异。
所以各方言 Visitor 会把各自特异的写法,统一转换成框架内部的 Segment。比如 Oracle 的FETCH FIRST 10 ROWS ONLY会被转换成同一个LimitSegment,底层字段都是 offset 和 rowCount。路由层和改写层只认 Segment,不需要了解方言语法。
这也是第三代设计最舒服的地方:加新方言的代价被控制在“Visitor + 文法”这个范围内,内核稳定性不会受到影响。
5. 解析结束后、路由开始前:参数占位符和缓存里藏着的两个陷阱
解析器产出了 SQLStatement 之后,接下来进入路由阶段。但这里还有两个特别容易被忽略的细节,处理不好照样翻车。
5.1 参数占位符的定位与语义
分库分表场景下,绝大多数 SQL 是预编译的,也就是长这样:
SELECT * FROM t_order WHERE user_id = ? AND status = 'NEW'解析器在处理?时,并不会去关心实际值是多少,因为它拿不到。它要做的,是把?标记成一个ParameterMarkerSegment,记录它在 SQL 文本里的位置和索引。
到了路由阶段,框架会拿着参数列表,按索引找到?对应的真实值,再根据分片算法算出节点。比如user_id = ?这个谓词,参数列表索引是 0,实际参数值是 100,路由引擎会用 100 去算分片。
这里有一个人容易踩的坑:如果 SQL 里有字面量也有占位符,比如:
SELECT * FROM t_order WHERE user_id = ? AND order_status = 1order_status 的字面量 1 会被直接挂在 LiteralExpressionSegment 里,它不会参与分片条件,除非路由层有特殊处理。但解析结果里它依然存在,改写阶段可能会把它原样带回最终 SQL。所以你在看解析产物时,要分清哪些是“路由用的”,哪些是“改写要保留的”。
5.2 解析缓存与路由缓存的键为什么不能一样
前面提到解析缓存键是 SQL 文本,这个设计是安全的。但如果你试图把路由结果也按 SQL 文本缓存,就会出问题:
- 第一次执行
user_id = 1,路由到分片 0; - 第二次执行
user_id = 2,如果走了路由缓存且键只有 SQL,很可能还是分片 0。
实际实现中,路由缓存会结合参数上下文,或者干脆不做缓存。这个问题在低并发下不明显,一旦压测流量上来,就可能出现“数据没查到但也不报错”的诡异现象。遇到这种情况,不要急着怀疑解析器,先看缓存键设计是否合理。
5.3 JOIN 场景下解析结果的边界到底在哪
回到开头那个 JOIN 例子:
SELECT o.order_id, u.user_name FROM t_order o JOIN t_user u ON o.user_id = u.user_id WHERE o.status = 'NEW' AND u.user_id = ? ORDER BY o.create_time DESC LIMIT 10解析器能告诉路由层的信息包括:
- 两个 TableSegment:t_order(别名 o)、t_user(别名 u);
- 两个投影列 Segment;
- 三个谓词:
o.user_id = u.user_id、o.status = 'NEW'、u.user_id = ?; - 排序字段 o.create_time;
- 分页 LIMIT 10。
从解析结果上看,全局只有 u.user_id 这一个分片键条件。路由层能不能把它推导到 t_order 上,取决于两个表是否配置了绑定表关系。
如果t_order和t_user被配置成绑定表,且分片键都是 user_id,路由层会基于等值 JOIN 做条件下推,让 t_order 也用u.user_id = ?这个值去算分片,从而避免全分片扫描。如果没有配置绑定表,t_order 就没有分片键条件,只能扫全分片。
这个逻辑不在解析器里,但解析器提供的结构是路由判断的前提。解析器输出越完整,路由层能做的优化就越多。所以调试这类问题,第一步永远是确认解析结果里的 PredicateSegment 和 TableSegment 是否和业务预期一致。
6. 源码级调试经验:断点、异常对照与版本差异
最后分享一些我在读源码和排障时实际用过的套路,希望对你有帮助。
6.1 三段验证法:文本、Token、Segment
面对一条解析不达预期的 SQL,我习惯分三段验证:
第一段,确认 SQL 文本进入解析器的原始字符串长什么样。有时候是 ORM 层在处理换行、注释、转义字符时已经改了内容。
第二段,看词法分析后的 Token 流。打开 Lexer 相关断点,逐个检查 Token 类型。这里能发现很多“看起来一样实际不一样”的问题,比如全角括号、中文引号、隐藏字符。
第三段,看 Visitor 转换后的 Segment。断点打在SQLStatement对象创建完成的瞬间,直接检查getSegments()或getTables()。如果一个谓词没出现在结果里,那就是 Visitor 没有匹配到对应语法规则。
这个顺序能帮你快速判断问题出在词法层、语法层还是语义层,不用一上来就瞎猜。
6.2 常见异常与排查链路
| 现象 | 常见根因 | 排查思路 |
|---|---|---|
报SQLParsingException: You have an error in your SQL syntax | 方言配置不对,或当前版本文法不支持的写法 | 先核对 databaseType,再看是否是WITH、FOR UPDATE、窗口函数等边界语法 |
| 分片键条件明明存在,却路由全分片 | 条件是 JOIN 另一张表上的,且没有绑定表;或条件藏在子查询里 | 直接看解析结果中的 PredicateSegment,确认 owner 和 column 是否匹配路由规则 |
| 同一 SQL 不同参数路由结果不变 | 路由缓存键设计不合理,把参数上下文排除在外 | 检查缓存键是否包含参数值或请求上下文 |
| 查询结果不报错但少数据 | 改写阶段把参数占位符位置改错了 | 比对改写前后 SQL,重点看?的顺序和数量 |
6.3 版本演进中解析器 API 的重要变化
如果你手头项目用的不是最新版,会发现类名和包名有变化。几个明显节点:
早期版本解析结果主要从ParseContext派生,SQLStatement 的概念还没现在这么统一;4.x 中SQLStatement和 Segment 体系逐渐稳定,各方言 Visitor 并存;5.x 以后解析器和元数据、缓存体系拆分得更细,入口类也发生了迁移。
我的建议是别盯着具体类名记 API,记职责链:入口门面、解析执行器、ANTLR 生成的 Lexer/Parser、方言 Visitor、统一 SQLStatement。这套职责不管在哪个版本都存在,只是名字和归属可能变。
6.4 如果真要二开解析器,从哪里下手风险最小
有些团队会遇到特殊 SQL 格式,想直接改解析器。我不太建议直接改.g4文法,因为文法改动牵一发动全身,而且生成代码没法人工维护。更稳妥的思路是:
- 如果只是想让路由识别某个新谓词,优先在 Visitor 层面增加处理逻辑;
- 如果需要支持方言新语法,再考虑增补文法,并配套写对应测试用例;
- 任何时候都不要绕过
SQLStatement直接把 ANTLR 语法树暴露给路由层,否则后续升级会很痛苦。
在我的实际经验里,90% 的“解析器不支持”问题,最后都能通过调整写作方式解决,真正需要动文法的场景非常少。
写在最后
我多次调试路由问题后得到的体会是:解析器最难的其实不是“把 SQL 读出来”,而是克制自己,不去替路由层做判断。它负责把事实采集完整,路由策略怎么用这些事实,是另一层的事情。
下次你再遇到“分片键条件明明写了却扫全分片”,我建议你第一件事不是翻路由算法,而是先问一句:解析结果里的 PredicateSegment 完整吗?表别名和列 owner 对应得上吗?如果答案都是肯定的,再往上走到路由配置里找问题。想清楚解析器哪些事情不做,比弄清楚它做了哪些事情,往往更能帮你省时间。