news 2026/10/14 4:03:26

Sharding-JDBC SQL解析源码剖析:从语法树到路由条件的关键链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Sharding-JDBC SQL解析源码剖析:从语法树到路由条件的关键链路

去年我在一个分片项目里排查过一条特别诡异的查询: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 = 1

CST 里会出现的节点可能包括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 = 1

order_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 对应得上吗?如果答案都是肯定的,再往上走到路由配置里找问题。想清楚解析器哪些事情不做,比弄清楚它做了哪些事情,往往更能帮你省时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 4:03:13

AirLLM轻量化推理方案:分层加载与量化,小显存跑大模型

过去一年里,大模型推理部署这条路上,我被“显存不够”这四个字折磨了无数次。本地跑个7B模型要挑显卡,跑13B就得掂量掂量家底,更别说那些动辄几十B的大家伙。直到我认真研究了AirLLM这套轻量化推理方案,才算是找到了一…

作者头像 李华
网站建设 2026/10/14 4:02:24

GCC 编译工具链实战:从编译四阶段到链接错误排查

简介:这份资源是面向C/C开发者与编程学习者的GCC编译器完整工具包,适用于Linux、类Unix及Windows平台下的C/C程序编译与构建。包内共1508个文件,以716个h头文件、245个hpp头文件、190个a静态库、78个exe可执行程序、20个dll动态库及36个tcc文…

作者头像 李华
网站建设 2026/10/14 4:02:23

从S车厂退出看车企战略收缩:大市场不等于好市场

汽车行业最近有一句话在我关注的小圈子里反复出现:“S的今天就是H的明天?”没头没尾,却让很多人争论。这里说的S车厂,是那个被戏称为“小车之王”、后来干脆退出全球最大单体汽车市场的小型车专业户;H车厂则是声量庞大…

作者头像 李华
网站建设 2026/10/14 4:02:21

SpringBoot+Vue+MyBatis企业级二手交易系统:从架构设计到部署实战

做毕设或者拿来当项目经验的同学,看到“企业级二手物品交易系统”这个标签,第一反应可能是又一套CRUD堆砌。但把标题拆开看,SpringBoot Vue MyBatis MySQL这套组合,本质上是目前Java全栈项目里最主流、也最容易被面试官追问细节…

作者头像 李华
网站建设 2026/10/14 4:01:50

Windows平台glog日志库编译与避坑实战

简介:这份资源是面向Windows平台C开发者的glog日志库集成包,适合需要在VS2017环境下为项目引入高效日志系统的中高级开发者。glog作为Google开源的跨平台日志工具,支持INFO、WARNING、ERROR、FATAL多级别输出、堆栈回溯、VLOG调试日志及日志速…

作者头像 李华
网站建设 2026/10/14 4:01:50

AI生成代码不可轻信:识别伪代码与验证实战指南

开头想从一次被AI坑的现场经历切入,这种体验应该能找到很多共鸣:让AI生成一段业务逻辑,它分分钟给你一段整洁的代码,注释规范、命名优雅,但跑起来就原形毕露。我见过太多开发者和非技术背景的朋友盯着屏幕上的"完…

作者头像 李华