
上周快要下班的时候线上一个SQL改写任务突然开始批量报错。我把最终生成的SQL直接打出来看了一眼整个人愣住了——原始SQL里明明写的是seller_id IN (SELECT shop_id FROM t_shop WHERE shop_type 2)经过 JSQLParser 4.x 解析再 toString 之后居然变成了seller_id IN SELECT shop_id FROM t_shop WHERE shop_type 2右边的括号消失得无影无踪。这不是简单的格式化问题。缺了这层括号整条SQL提交到数据库直接语法报错等于线上批量任务全部白执行。当时我第一反应是自己拼接SQL的代码写错了排查到最后才发现问题出在 JSQLParser 表达式树输出时的括号策略上。这篇文章就完整记录一下这个坑的排查思路、根因和三种可落地的修复方案给正在用 JSQLParser 4.x 做SQL解析、改写、脱敏、生成的同学一个参考。1. 现象与复现一条SQL在解析改写后“变坏”了1.1 线上问题改写后的SQL语法报错我们内部有一套SQL改写平台核心流程很简单接收用户SQL → 用 JSQLParser 解析成 AST → 在AST上做表名替换、条件追加、字段裁剪 → toString 输出新SQL → 交给下游执行。这套流程跑了挺久平时都很稳。直到那天凌晨监控突然拉响一批改写后的SQL在 OSS 侧的数据库上执行失败。我把失败SQL捞出来做了个对比原始SQLSELECT user_id, user_name FROM t_order WHERE status 1 AND seller_id IN (SELECT shop_id FROM t_shop WHERE shop_type 2)改写后输出SELECT user_id, user_name FROM t_order WHERE status 1 AND seller_id IN SELECT shop_id FROM t_shop WHERE shop_type 2肉眼就能看出来IN后面的子查询括号丢了。这已经不只是语义变化的问题而是直接生成了非法SQL。数据库方的报错也很直白You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near SELECT shop_id ...。奇怪的点在于我们并没有手动拼接这段条件整个SQL是原样解析再原样输出的中间只是在AST上改了另一处表名。既然输入是合法SQL理论上重新toString也应该输出合法SQL。谁能想到括号会在“原样往返”的过程中丢掉。1.2 最小复现代码与版本差异为了确认不是平台业务代码的锅我写了一个最小复现代码量不到十行import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.Statement; public class InExpressionRepro { public static void main(String[] args) throws Exception { String sql SELECT * FROM t WHERE id IN (SELECT id FROM t2 WHERE age 18); Statement statement CCJSqlParserUtil.parse(sql); System.out.println(statement.toString()); } }在某个4.x小版本上输出是SELECT * FROM t WHERE id IN SELECT id FROM t2 WHERE age 18而代码本身没有任何额外逻辑纯粹就是 parse 之后 toString。这说明问题出在 JSQLParser 的表达式输出模块而不是业务代码。这里要提醒一句这个现象不是所有4.x版本都必现。我自己在不同小版本上测试过从4.3左右开始IN子查询的括号输出被修正过一部分但老版本、某些特殊写法还是会踩中。另外如果你不是直接statement.toString()而是用了自定义的 DeParser 或自己遍历 AST 拼SQL风险会更大。所以看到文章标题先别急着对号入座继续往下看完再判断。1.3 IN表达式的几种形态谁的括号会丢IN表达式在SQL里有几种常见写法我在排查时把所有形态都列了一遍比对parse再toString的输出方便定位规律IN表达式形态示例toString后括号是否正常常量列表id IN (1, 2, 3)正常输出仍为IN (1, 2, 3)子查询id IN (SELECT id FROM t2 WHERE age 18)异常可能变为IN SELECT id FROM t2...NOT IN 子查询id NOT IN (SELECT id FROM t2)异常可能变为NOT IN SELECT id FROM t2左侧为行值(a, b) IN ((1, 2), (3, 4))左侧括号存在丢失风险冗余括号id IN ((SELECT id FROM t2))双括号可能变成单括号甚至无括号这个表格基本锁定了问题范围常量列表分支是安全的子查询分支不安全左侧行值也不够安全。接下来要做的就是打开AST看看InExpression到底存了什么结构以及toString的逻辑到底在哪一步漏掉了括号。2. 定位根因InExpression的双通道设计与toString的括号缺口2.1 把AST拆开看IN节点到底存了什么JSQLParser 解析SQL时会构建一棵表达式树ASTIN对应的是net.sf.jsqlparser.expression.operators.relational.InExpression节点。我先写了一个简单的AST打印工具把刚才那条id IN (SELECT id FROM t2 WHERE age 18)的结构粗略打出来长这样InExpression ├── leftExpression: Column(id) └── rightExpression: SubSelect └── selectBody: PlainSelect ├── selectItems: [Column(id)] ├── from: Table(t2) └── where: GreaterThan(Column(age), LongValue(18))关键信息都在这个结构里InExpression的右侧不是ExpressionList而是一个SubSelect节点。在 JSQLParser 4.x 中InExpression的右侧有两种承载方式getRightItemsList()对应IN (1, 2, 3)这种常量列表类型是ExpressionList之类的ItemsListgetRightExpression()对应IN (SELECT ...)这种子查询类型是Expression通常是SubSelect。也就是说IN节点天然有两条路可以走。toString 时到底保不保括号取决于 visitor 对这两条路分别怎么处理。2.2 ToStringVisitor里漏掉的“右括号”JSQLParser 的 Statement 输出最终是交给 visitor 模式完成的。表达式部分的输出逻辑在ExpressionDeParser或ToStringVisitor这类类里。以我排查时跟踪到的简化逻辑来看visit(InExpression)大致是这样Override public void visit(InExpression inExpression) { inExpression.getLeftExpression().accept(this); if (inExpression.isNot()) { builder.append( NOT); } builder.append( IN ); if (inExpression.getRightExpression() ! null) { // 子查询分支直接输出右侧表达式没有包裹括号 inExpression.getRightExpression().accept(this); } else if (inExpression.getRightItemsList() ! null) { // 列表分支显式拼接左右括号 builder.append((); inExpression.getRightItemsList().accept(this); builder.append()); } }问题已经很清晰了子查询分支在拼接IN之后直接把右侧的SubSelecttoString 结果贴了上来。而SubSelect自身并不负责输出外层括号它的职责只是输出SELECT id FROM t2 WHERE age 18这一段。于是一组合起来就成了IN SELECT id FROM t2 WHERE age 18。反观列表分支因为代码里显式写了builder.append(()和builder.append())所以IN (1, 2, 3)始终是安全的。2.3 为什么列表分支没有问题子查询分支就出问题这里面的本质是在表达式树中括号通常不是表达式的固有属性而是父节点按语法需要“决定”是否添加的。SubSelect作为一颗子树它只负责“说出自己是什么”至于外面需不需要套一层括号是父节点InExpression的责任。你可以这样理解SubSelect就像一个中间不带包装的商品它自己在货架上是裸着的IN这个货架要求商品必须带外包装才能上架。列表分支老老实实加了包装子查询分支却忘了这一步导致裸着就发货了。这个原因还能解释另一个衍生问题不只是 IN其他需要括号包裹子查询的表达式也有类似风险只是平时大家用得少没有被触发而已。所以在表达式输出这条链路上“谁负责加括号”必须理清楚否则换一个表达式类型坑还会再踩一遍。3. 解决实战三种可落地的修复思路问题定位到这一步剩下的就是怎么修。我实际试过三种方案都跑通了分别适用于不同场景下面逐个说。3.1 方案一升级到已修复的4.x小版本最省事的方案是升级依赖。JSQLParser 4.x 的小版本迭代中确实有对InExpressiontoString 逻辑的修复。如果你们项目能控制依赖版本直接升到较新的4.x版本然后跑一遍回归测试大概率问题就消失了。我当时先试了升级从项目原本锁定的版本升到新版本后同样的最小复现代码输出立刻变成SELECT * FROM t WHERE id IN (SELECT id FROM t2 WHERE age 18)括号回来了不需要改任何业务代码。但升级不是无脑操作需要评估风险JSQLParser 4.x 各小版本之间 API 有变动比如InExpression的部分 setter 在旧版本是setRightItemsList(...)新版本推荐用setRightExpression(...)编译阶段就会暴露一部分问题解析行为可能变化同一段SQL在不同版本的AST结构可能不同如果你们有自定义 visitor 或深度依赖AST结构要重点回归输出格式可能有调整导致线上存的SQL指纹、审计字段发生变化。所以升级前建议把项目的测试用例拉起来完整跑一遍特别是SQL解析、改写相关的。如果团队没有现成的SQL回归样本库可以顺手参考我后面第4节的做法一次性补齐。3.2 方案二自定义ExpressionDeParser补括号如果依赖版本被其他系统锁死暂时升不动那就只能自己接管输出。JSQLParser 提供了 visitor 扩展点常见做法是继承ExpressionDeParser重写visit(InExpression)在子查询分支补上括号。核心逻辑如下以你具体使用的4.x源码为基准微调import net.sf.jsqlparser.expression.operators.relational.InExpression; import net.sf.jsqlparser.expression.operators.relational.ExpressionDeParser; import net.sf.jsqlparser.statement.StatementDeParser; public class SafeInExpressionDeParser extends ExpressionDeParser { Override public void visit(InExpression inExpression) { // 先输出左侧表达式 inExpression.getLeftExpression().accept(this); // 处理 NOT if (inExpression.isNot()) { append( NOT); } append( IN ); if (inExpression.getRightExpression() ! null) { append((); inExpression.getRightExpression().accept(this); append()); } else if (inExpression.getRightItemsList() ! null) { append((); inExpression.getRightItemsList().accept(this); append()); } } }然后在输出Statement时把自定义的ExpressionDeParser注入进去SafeInExpressionDeParser expressionDeParser new SafeInExpressionDeParser(); StatementDeParser statementDeParser new StatementDeParser(new StringBuilder(), expressionDeParser); statement.accept(statementDeParser); String result statementDeParser.getBuffer().toString();这里有几个细节需要强调如果右侧表达式本身已经是Parenthesis比如原SQL写的是IN ((SELECT ...))你再包一层会输出IN ((SELECT ...))虽然语法合法但看起来不干净。可以在包裹前判断一下类型只在右侧是SubSelect时补括号如果你们的代码里已经自定义过一整套 DeParser重写时最好把原有逻辑复制进来改而不是继承默认实现再覆盖避免默认实现里其他表达式的输出策略被破坏这个方案本质是“接管输出”后续如果升级JSQLParser自定义部分的兼容性要专门测试。3.3 方案三构建AST时用Parenthesis显式包裹如果问题不是出在“解析后再toString”而是你们正在手动构建SQL、需要动态生成IN (子查询)条件那方案三更合适在构造AST时直接用Parenthesis节点把子查询包一层。Parenthesis是 JSQLParser 提供的专门表示括号的表达式包装类。手动构建IN表达式可以这样写import net.sf.jsqlparser.expression.Parenthesis; import net.sf.jsqlparser.expression.operators.relational.InExpression; import net.sf.jsqlparser.schema.Column; import net.sf.jsqlparser.statement.select.SubSelect; SubSelect subSelect new SubSelect(); subSelect.setSelectBody(plainSelect); // 假设已经构建好子查询主体 InExpression inExpression new InExpression(); inExpression.setLeftExpression(new Column(t, id)); inExpression.setRightExpression(new Parenthesis(subSelect));这样输出的SQL就是WHERE t.id IN (SELECT ...)这个方案思路最直接既然父节点忘了加括号那在AST层面就自己带上括号相当于把“加括号”的责任提前到构建阶段完成。不过要注意如果未来升级了JSQLParser而新版本在visit(InExpression)子查询分支已经自动加括号那么你手动包的Parenthesis会带来双重括号IN ((SELECT ...))。双重括号合法但不美观而且可能影响SQL指纹一致性。我建议在代码里加一行注释标明这是针对某个旧版本括号丢失问题的补偿升级后需要review是否移除。3.4 三种方案怎么选一张表看清取舍我把三种方案放到一起对比过整理成下面这张表维度方案一升级版本方案二自定义DeParser方案三Parenthesis包裹适用场景能控制依赖希望根治依赖版本锁死需要兜底手动构建AST改动点可控改动量依赖文件一行需要新增类并调整输出入口每个构建点增加包装风险点版本升级带来的API/行为变化自定义输出逻辑的长期维护成本新版本自动加括号后可能双括号是否推荐长期保留推荐推荐作为过渡方案推荐配合升级计划使用从我个人的角度如果条件允许首选方案一如果暂时升不了级方案二和方案三可以组合手动构建场景用方案三解析再toString场景用方案二。三者在代码里并不冲突。4. 验证与回归把“括号不丢”变成自动化防线修复完只是第一步。SQL改写这类系统最怕的不是出一次bug而是同一个方向的问题换个马甲再出现。所以我在这次修复之后做了一套回归防线核心思路是维护一份SQL黄金样本把parse→toString后的输出和期望输出做比对并辅以二次解析校验。4.1 黄金样本覆盖IN的常见形态我整理了一个样本清单基本覆盖了IN表达式能遇到的各种形态用例编号输入SQL片段期望的输出要求1id IN (1, 2, 3)输出包含IN (1, 2, 3)2id IN (SELECT id FROM t2 WHERE age 18)输出包含IN (SELECT ...)3id NOT IN (SELECT id FROM t2)输出包含NOT IN (SELECT ...)4(a, b) IN ((1, 2), (3, 4))左侧输出包含(a, b)右侧保持IN ((1, 2)...5id IN ((SELECT id FROM t2))输出保持双括号或至少有一层括号6多层嵌套id IN (SELECT id FROM t2 WHERE x IN (1, 2))内外层IN都保留括号这份清单不需要很长但一定要覆盖“列表”和“子查询”两条分支否则不能有效防止同类问题回归。4.2 二次解析字符串断言双保险每次执行完SQL改写后我会做两件事。第一件事是字符串断言直接校验结果里是否存在关键括号String output statement.toString(); assertTrue(IN表达式缺少左括号, output.contains(IN ()); assertTrue(NOT IN表达式缺少左括号, output.contains(NOT IN ());字符串断言比较粗暴但很适合做第一层防线成本极低。第二件事是二次解析。把生成的SQL再次交给CCJSqlParserUtil.parse如果能正常解析说明至少在语法层面没有产生破坏Statement reparsed CCJSqlParserUtil.parse(output); assertTrue(reparsed instanceof Select);二次解析的意义在于很多输出错误比如IN SELECT会导致语法解析直接抛异常。只要二次解析能通过至少排除了最严重的非法SQL问题。字符串断言和二次解析两个条件同时满足我才认为这条用例通过。4.3 CI中的SQL改写回归用例怎么设计这两件事落地到CI我建议设计成参数化测试把黄金样本放在一个JSON或YAML文件里每条记录至少包含inputSql、assertContains、illegalKeywords三个字段assertContains是一个字符串列表比如[IN (, NOT IN (]测试框架循环校验输出必须全部包含illegalKeywords用于检查反面条件比如[IN SELECT, NOT IN SELECT]只要输出里出现就算失败每次代码提交、依赖升级、visitor改动都自动跑一遍这套用例。我实际跑下来的感受是这套回归的价值很快就能体现出来。就在修复后的第二周一个同事调整了自定义visitor里另一处表达式的输出顺序差点又把括号问题带进来CI在第一时间拦截住了。如果没有这套防线这种问题大概率又会在线上炸一次。5. 避坑地图IN表达式周边还有哪些括号陷阱这次排查虽然只针对IN子查询括号丢失但顺着这个问题我把IN表达式周边几个容易踩的坑也一并梳理了印象很深这里一并分享。5.1 左侧行值表达式的括号也很容易丢很多人只盯着IN右侧忽略了左侧。JSQLParser 的InExpression左侧不一定是一个简单列也可以是一个ExpressionList对应SQL里的行值比较比如WHERE (user_id, user_type) IN ((101, 1), (102, 2))如果左侧在AST中是一个ExpressionList但visitor输出时没考虑它需要括号toString后可能变成WHERE user_id, user_type IN ((101, 1), (102, 2))这在MySQL里直接语法错误。修复思路和右侧子查询一样如果左侧是ExpressionList输出时要用Parenthesis包裹或者在DeParser里对InExpression的leftExpression做类型判断判断为ExpressionList时补括号。5.2 NOT IN与子查询组合时别漏了括号NOT IN (SELECT ...)和IN (SELECT ...)是同一个问题但更容易被忽略。因为排查时你会优先盯IN而NOT IN的输出里多了个NOT字符串搜索的时候如果只搜IN (可能会漏掉NOT IN (开头的情况。我的回归用例里把NOT IN (SELECT ...)单独列了一条并且在字符串断言里同时校验IN (和NOT IN (就是这个原因。5.3 4.x版本间API差异带来的隐性坑JSQLParser 4.x 内部的InExpressionAPI有过调整。早期版本更习惯用setRightItemsList(...)来设置子查询而4.x推荐用setRightExpression(...)。如果你在旧文档或旧博客的指引下混用了API可能出现设置了rightExpression但通过getRightItemsList()拿不到值同时设置了两个字段toString时走了错误分支自定义visitor里只处理了rightItemsList导致rightExpression分支被原样拼接。我建议团队里统一规范子查询一律用setRightExpression常量列表一律用setRightItemsList不要混用。并且把这类规范写进代码review的checklist里。5.4 DML场景同样要纳入验证还有一个容易遗漏的点IN表达式不只出现在SELECT语句里。DELETE、UPDATE、甚至JOIN ON条件里都可能出现。我们的业务里有一次就是在UPDATE的WHERE子句里踩中了同样的问题UPDATE t_order SET status 0 WHERE seller_id IN (SELECT shop_id FROM t_shop WHERE shop_type 2)改写后同样可能变成IN SELECT ...。所以黄金样本不能只覆盖SELECT建议至少补充一条UPDATE、一条DELETE的用例。这类语句在线上系统的风险比SELECT更大一旦输出错误直接影响数据变更。这次排查到最后的感受是JSQLParser 这类表达式树解析器本身把很多括号输出细节做在了各个节点的toString逻辑里但是不同节点、不同版本之间并不总是保持一致。遇到IN子查询丢括号这种问题不用慌先确认AST结构再定位是哪个输出分支漏了括号然后按团队实际情况选择升级、自定义DeParser或显式包Parenthesis。最后把回归样本和CI检测补上让这种问题没机会第二次在线上出现。