JSQLParser unexpected token:SQL解析异常定位与修复
1. 先把报错链路捋清楚到底是谁在解析你的 SQL线上服务跑到凌晨两点突然刷出一片红色的Caused by: net.sf.jsqlparser.parser.ParseException: Encountered unexpected token第一反应往往是SQL 写错了。但真到排查阶段你会发现这条 SQL 在数据库客户端里跑得好好的偏偏经过应用层就炸了。原因很简单——数据库引擎和 JSQLParser 是两套完全独立的语法体系前者是执行者后者只是阅读者。能被 MySQL 执行的 SQL不代表能被 JSQLParser 顺利读成抽象语法树。我在做分页插件、多租户数据隔离、SQL 审计和慢查询治理这几类项目时几乎每个项目都会撞上这个异常。它出现的位置通常很隐蔽可能是 MyBatis 拦截器在改 SQL可能是分页组件在拼LIMIT也可能是你自研的权限过滤器在给WHERE追加条件。这些组件背后都依赖 JSQLParser 把 SQL 字符串解析成一颗 AST再在树上做改写。只要解析这一步没过去后面所有逻辑都直接短路。所以这篇文章不是讲JSQLParser 是什么而是讲当它甩出unexpected token时你怎么在十几分钟内定位到那个捣乱的字符或关键字并且给出能直接抄的修复方案。不管你是刚接手一个分页报错的 Java 后端还是想搞明白拦截器为什么偶尔抽风这里的内容都能对得上。先给一个判断标准ParseException属于语法层面的失败意味着解析器在读到某个位置时发现下一个 token 不符合当前语法状态机的期望。它和我们常说的SQL 执行报错完全是两码事。SQL 执行报错是数据库嫌你语义有问题比如表不存在、字段类型不匹配而ParseException是解析器嫌你语法结构不对连 AST 都没建起来。认清这一点排查方向就不会跑偏到去数据库上验证这条死路上。1.1 JSQLParser 在技术链路里的实际位置大部分人是被动认识 JSQLParser 的。你在pom.xml里加了 MyBatis-Plus、PageHelper、ShardingSphere或者某些数据权限框架它们会把 JSQLParser 作为传递依赖带进来。然后在某个时刻抛出异常堆栈里第一次出现net.sf.jsqlparser这个包名你才知道原来中间还有这么一层。它的核心工作是把 SQL 文本变成 Java 对象树。比如SELECT id, name FROM user WHERE age 18会被解析成一个Select对象里面挂着PlainSelect、SelectItem列表、FromItem、Where表达式等节点。分页插件就在这棵树上找到Select给它补一个Limit数据权限框架就在Where节点上挂一个AND tenant_id 123。整个过程不接触数据库纯内存计算。也正因为如此它对语法的宽容度远低于真实数据库。数据库厂商为了兼容历史代码往往会容忍一些不那么标准的写法甚至支持大量方言扩展。而 JSQLParser 作为一个开源通用解析器它的语法文件是有限的覆盖的是 SQL 标准的公共子集加部分常见方言。凡是超出这个子集的写法就会在那个 token 上直接卡住。1.2 unexpected token 这句话到底在说什么把异常信息拆开看它其实包含三个关键信息Encountered unexpected token后面跟的是它实际读到的那个 tokenWas expecting one of后面跟的是它当时能接受的一串候选 token。很多人只看前半句就懵了其实后半句才是解题钥匙。举个典型例子报错长这样Caused by: net.sf.jsqlparser.parser.ParseException: Encountered unexpected token: ( ( at line 1, column 34. Was expecting one of: ) , . :: ...这里column 34直接告诉你出错位置Was expecting one of告诉你解析器在那个位置希望看到什么。你只要把 SQL 字符串数到第 34 个字符看看那里写了什么基本就能锁定问题。这个方法听起来很笨但在没有任何工具辅助的情况下它就是最快的手段。注意column是从 1 开始计数的而且它统计的是原始 SQL 字符串的字符位置不是 token 序号。SQL 里有中文、换行、制表符时这个列号可能和你肉眼数的对不上需要留意。1.3 为什么同样的 SQL 昨天不报今天报这是最让人头疼的一类现象。代码没改SQL 没改昨天还好好的今天开始报。常见原因有三个方向。一是动态 SQL 拼接引入了变量。MyBatis 的if标签会根据运行时参数决定是否拼接某段条件某个新参数值触发了之前没走到的分支这个分支里恰好有解析器不支持的写法。二是在多数据源场景下请求被路由到了不同的数据库而你的拦截器对不同方言处理不一致比如给 PostgreSQL 的 SQL 拼了反引号。三是依赖版本被间接升级了。某次构建时JSQLParser 从 4.3 跳到了 4.6某些曾经过得去的语法在新版本里反而变严格了这类升级引起的回归在传递依赖里特别隐蔽。所以记住一个排查原则先确认报错 SQL 的原文再确认解析器的版本这两件事没确认之前别急着改代码。2. unexpected token 的五种高频成因逐个拆给你看把线上真实案例归归类unexpected token基本离不开下面五种成因。这五种我在不同项目里至少各踩过两三次每一种都有很明确的识别特征和对应解法。2.1 方言语法超出解析器能力范围这是最普遍的一类。解析器认识的语法是有限的而业务 SQL 里塞满了各种方言。典型清单包括PostgreSQL 的强制类型转换WHERE id ?::bigint那个::在很多版本里是靠开关才认识的。MySQL 的INSERT ... ON DUPLICATE KEY UPDATE老版本解析器直接懵。GROUP BY x WITH ROLLUPWITH后面的内容经常被判为 unexpected。窗口函数OVER (PARTITION BY ...)在早期版本支持很差。LIMIT 10 OFFSET 20里的OFFSET以及LIMIT 20, 10这种逗号形式二者支持程度不同。SELECT ... FOR UPDATE SKIP LOCKED这种加锁子句。识别特征是unexpected token指向的关键词很方言比如ROLLUP、SKIP、KEY。这类问题的解法有两条路要么打开解析器的兼容开关要么把 SQL 改写成标准写法。2.2 保留字和标识符撞车数据库允许你把某些关键字当表名或列名用只要加引号或者在某些上下文里它能自动消歧。但解析器不一定这么宽容。我见过最典型的是列名叫order、desc、group、key、usage、interval、user的表。比如SELECT id, order, desc FROM t_order WHERE usage 0这段 SQL 在数据库里如果order是列名可能跑得通但解析器读到order时会试图把它当作ORDER BY的开头后面发现跟着逗号又对不上于是抛错。识别特征是出错位置基本就在那个可疑单词上而且Was expecting one of里会出现大量和ORDER BY、GROUP BY相关的候选。解法是加反引号或双引号把标识符括起来SELECT id, \order, desc FROM ...。但要注意不同数据库的标识符引号不同MySQL 用反引号PostgreSQL 和 Oracle 用双引号。如果你的拦截器在转换时把引号搞混反而会引入新问题。2.3 转义字符与引号体系不一致字符串常量里的反斜杠和引号是另一大坑源。有些 SQL 里写LIKE 100\%这个\%在 MySQL 里是合法转义但解析器默认遇到反斜杠可能直接报错需要通过开关显式告诉它反斜杠是转义字符。另一个变种是标识符里带方括号。SQL Server 用[column]表示标识符解析器需要开启方括号引号支持才能识别。还有单引号内的单引号写法its如果 SQL 是动态拼出来的很容易拼成its解析器读到第二个单引号就断句了。这类问题的识别特征是出错位置靠近引号或者反斜杠而且报错 token 往往是或\\这种符号。2.4 版本能力边界与升级断层JSQLParser 每个大版本的能力边界变化都很大。4.x 到 5.x 之间包结构有过调整API 也有增删。有些方法在新版本里被废弃或改名你的代码可能编译期没报错运行期却因为调了旧 API 解析行为不一致而失败。更麻烦的是依赖冲突。你的项目直接依赖 4.6但某个中间件传递依赖了 4.2Maven 的调解规则选了较短路径的那个版本实际生效的可能不是你以为的版本。这时候你在测试环境用 4.6 验证通过生产环境跑的是 4.2结果就分化了。排查方法是把所有相关依赖的版本树打出来mvn dependency:tree -Dincludescom.github.jsqlparser:jsqlparser看清楚实际生效的是哪个版本再对着那个版本的语法文件去判断。2.5 动态拼接导致的薛定谔的 SQL最后一类最折磨人因为报错的 SQL 和你以为的 SQL 根本不是同一条。拦截器链里可能有多个组件依次改写 SQL前一个组件加上的东西后一个组件不认识。或者分页组件先解析一次拼上LIMIT权限组件再解析一次第二次解析的 SQL 已经带了LIMIT且格式被改写过导致第二次解析失败。识别特征是堆栈里出现多次net.sf.jsqlparser的解析调用或者你打印出来的 SQL 和报错行号对不上。解法是把每次改写前后的 SQL 都打出来按调用顺序对比找到是哪一步引入了非法片段。3. 三步定位法从报错信息反推捣乱字符前面讲了成因现在讲怎么定位。这套方法我在多个项目里用下来平均定位时间能压到十分钟以内。3.1 第一步拿到完整的 SQL 和完整的堆栈这是最容易被忽略的一步。很多人看到异常就直接去改代码结果改了半天发现改的是另一个分支的 SQL。要做的是先把两样东西拿到手真正传给解析器的那条完整 SQL 字符串以及包含所有Caused by的完整堆栈。打印 SQL 有个坑别用日志框架默认的toString有些 SQL 对象重写toString时会格式化格式化和原始字符串的列号对不上。正确做法是在调用解析前把原始字符串原样打到日志里包括所有空白字符。可以在解析入口包一层public static Statement parseSafely(String sql) { try { return CCJSqlParserUtil.parse(sql); } catch (JSQLParserException e) { // 用定界符包起来防止首尾空白被日志框架吃掉 log.error(parse failed, raw sql [{}], sql, e); throw e; } }用[和]包起来是为了确认 SQL 首尾有没有多余的空白或隐藏字符。我真见过 SQL 末尾带了一个不可见字符导致解析失败的案例。3.2 第二步最小化复现拿到 SQL 后别直接在生产环境试。写一个独立的测试类把 SQL 硬编码进去跑一次解析Test void reproduce() throws Exception { String sql SELECT id, order FROM t_order WHERE usage ?::bigint; CCJSqlParserUtil.parse(sql); }能稳定复现之后就可以开始做减法了。把WHERE、ORDER BY、LIMIT一段一段删掉删到哪一段不报错了问题就出在那一段。这个二分过程比盯着报错信息猜要快得多。3.3 第三步用列号精确定位当 SQL 很长、不好裁的时候直接用列号定位。假设报错是line 1, column 87写个三行的小工具把第 87 个字符的前后文打出来String sql ...; int col 87; int start Math.max(0, col - 30); int end Math.min(sql.length(), col 30); System.out.println(sql.substring(start, end)); System.out.println( .repeat(col - start - 1) ^);输出会像这样... WHERE created_at DATE_SUB(NOW(), INTERVAL 7 DAY) ^那个箭头指的位置就是解析器卡住的地方。这个方法对识别某个关键字被误判特别有效因为箭头会精准指向冲突词。提示列号是字符位置SQL 里如果有中文或多字节字符Java 字符串的 length 和字节长度不一致定位时要按字符数算不要按字节数算。3.4 一个真实案例的完整定位过程说一个我印象最深的案例。某个报表接口偶尔报错报错信息是Encountered unexpected token: ) ) at line 1, column 156.SQL 打印出来是这样的SELECT a.id, b.name FROM table_a a LEFT JOIN table_b b ON a.id b.aid WHERE a.status IN (1,2,3) AND (b.type 1 OR b.type 2)看起来完全正常对不对。但列号 156 处是右括号。我按前面的方法把这段 SQL 拿去解析居然不报错。说明真正传给解析器的 SQL 和日志打出来的不一样。继续追在拦截器链里加日志发现权限组件在拼接条件时把AND (...)拼到了WHERE子句末尾但它拼的时候多拼了一个右括号变成... OR b.type 2))。数据库对多余右括号可能容忍或者报语法错但解析器直接判 unexpected。定位到具体组件后问题五分钟就解决了。这个案例告诉我们报错信息给的列号永远是对真正传入的那条 SQL而言的。日志打出来的 SQL 如果不包含拦截器改写的结果就永远是误导。4. 开箱可用的修复方案与解析器参数配置定位完之后就是修。修复手段按侵入性从低到高排优先用侵入性低的。4.1 打开解析器的兼容开关JSQLParser 提供了不少配置项很多unexpected token其实是被默认关闭的开关挡住的。这些开关通过parser.withXxx链式调用设置Statement stmt CCJSqlParserUtil.parse(sql, parser - parser .withAllowComplexParsing(true) // 允许复杂的解析分支 .withSquareBracketQuoting(true) // 支持 [identifier] 写法 .withBackslashEscapeCharacter(true) // 反斜杠作为转义字符 .withUnsupportedStatements(true) // 容忍不支持的语句类型 );下面这张表是我整理的常用开关和它们的适用场景遇到对应报错时可以逐个打开试试。配置项作用典型适用场景withAllowComplexParsing简化复杂语法的解析路径深层嵌套子查询、多表关联withSquareBracketQuoting识别[col]标识符SQL Server 风格 SQLwithBackslashEscapeCharacter把反斜杠当转义符LIKE 100\%这类写法withUnsupportedStatements不支持的语句不抛异常冷门 DDL、存储过程调用withTimeOut设置解析超时超长动态 SQL 防卡死需要注意的是开关也不是万能的。有些方言语法是完全不在语法文件里的开了开关也识别不了这时候只能走改写路线。4.2 改写成解析器认识的标准 SQL改写的核心思路是把方言特色写成所有解析器都能懂的等价形式。?::bigint这种 PostgreSQL 强转改成CAST(? AS bigint)兼容性立刻提升。ON DUPLICATE KEY UPDATE这种 MySQL 特色 upsert在通用链路里最好拆成先SELECT再UPDATE或INSERT的两步逻辑或者只在最终执行前才拼上这段解析阶段绕开它。LIMIT 20, 10改成LIMIT 10 OFFSET 20前者是 MySQL 语法糖后者更标准。保留字列名统一加引号并且根据目标数据库选对引号类型。如果拦截器要跨数据库复用建议在元数据层面维护一份需要加引号的列名清单解析前自动加引号而不是靠人肉排查。4.3 版本升级与 API 迁移如果确认问题来自版本能力不足升级是最直接的解法。升级前先做三件事第一锁死版本。在pom.xml顶层声明 JSQLParser 版本避免传递依赖覆盖dependencyManagement dependencies dependency groupIdcom.github.jsqlparser/groupId artifactIdjsqlparser/artifactId version5.0/version /dependency /dependencies /dependencyManagement第二检查 API 变化。4.x 到 5.x 里CCJSqlParserUtil.parse依然可用但一些枚举和访问器有调整比如SelectItem、Expression接口的方法签名。升级后编译一次把编译错误逐个修掉。第三回归测试。把线上抓到的所有报错 SQL 整理成一个测试用例集升级后全跑一遍确保没有回归。这件事我强烈建议做成常驻的用例集因为它同时解决了下次升级和换引擎两个问题。4.4 换一个解析引擎实在搞不定的情况下可以考虑换引擎。可选的有 Alibaba Druid 的 SQL 解析器、Apache Calcite 的解析模块。Druid 的宽容度更高对方的方言支持面更广国内项目里常用来做 SQL 监控和防火墙Calcite 更偏标准 SQL扩展性强但学习成本高。换引擎的成本在于 API 完全不兼容。Druid 用SQLUtils.parseStatements(sql, DbType.mysql)返回的是它自己的 AST 节点原有的改写逻辑要用新的访问器重写一遍。所以这条路线适合本身就重度依赖 SQL 改写、且现有引擎反复出问题的项目轻度使用不值得。5. 常见问题速查表与实操踩坑心得5.1 常见问题速查表报错关键词最可能原因首选解法unexpected token: ::强转语法不被识别改写为CAST(... AS ...)unexpected token: [方括号标识符开启withSquareBracketQuoting出错在某个列名上保留字冲突给标识符加引号unexpected token: \\转义符未开启开启withBackslashEscapeCharacter出错在ROLLUP、SKIP、KEY方言子句不支持改写或升级版本报错列号与日志 SQL 对不上拦截器改写引入差异每步改写前后打日志测试环境正常生产报错依赖版本不一致用dependency:tree核对偶发性报错动态 SQL 分支触发收集触发时的参数组合报错在右括号或逗号处拼接括括号不平衡检查拼接逻辑的括号计数5.2 实操心得那些文档里不会写的经验第一解析失败时不要只看最外层异常一定要看最内层的Caused by。JSQLParser 的异常经常被包装在业务异常里最外层是分页处理失败最内层才是ParseException。跳过外层直接找最内层能省掉一半的迷惑时间。第二给解析入口加一个降级开关。生产环境的拦截器可以设计成解析失败时跳过改写、直接放行原 SQL并上报一条告警。这样做的好处是解析器的边界问题不会直接导致业务不可用你可以从容地离线收集和修复。这个降级策略我在线上系统里一直保留救过好几次急。第三把 SQL 归一化和解析分开。归一化去多余空格、统一大小写、统一引号之后再解析成功率会明显提升。很多unexpected token其实是格式混乱引起的比如换行符把ORDER和BY拆到了两行中间还夹了注释。第四警惕注释。/* comment */和-- comment在 SQL 里的位置很敏感。有些拦截器拼接条件时把内容插到了注释中间导致注释外的正常 SQL 被吞掉。解析前先把注释统一处理掉是个稳妥的做法。第五写一份已知不支持语法的清单并在 CI 里加一条静态检查。每当线上发现一种新的不支持写法就补进清单和用例集。坚持半年你会发现自己项目的 SQL 解析失败率会降到非常低。这件事我做过投入很小收益很持久。5.3 关于最近高频出现的两类报错联想顺带说两个最近常被一起搜到的报错它们和今天讲的unexpected token名字像但根因完全不同别混在一起排查。一个是前端层面的uncaught syntaxerror: invalid or unexpected token。这个属于 JavaScript 语法错误通常是字符串里混入了非法字符、模板字符串引号不匹配或者后端返回了非预期的内容被当代码执行。它和 SQL 解析没有任何关系排查时看浏览器控制台指向的文件和行号检查那段代码的字符编码。另一个是接口调用里出现的unexpected status 401 unauthorized: invalid token。这是鉴权层面的问题token 过期、签名不对或者请求头没带上都会触发。它虽然也叫 token但和 SQL 解析器读到的 token 完全不是一个概念。碰到这个先检查凭证的有效期和传递方式。把三者的区别记清楚能避免在错误的代码层反复翻找。我见过有人拿着 401 的错误去 SQL 里找问题浪费了大半天。5.4 一个可以直接落地的拦截器骨架最后给一段我在实际项目里用过的解析拦截骨架思路是能解析就改写解析不了就放行并告警可以直接参考public class SafeSqlInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object arg invocation.getArgs()[0]; if (!(arg instanceof MappedStatement)) { return invocation.proceed(); } MappedStatement ms (MappedStatement) arg; BoundSql boundSql ms.getBoundSql(invocation.getArgs()[1]); String original boundSql.getSql(); try { Statement stmt CCJSqlParserUtil.parse(original, parser - parser .withAllowComplexParsing(true) .withSquareBracketQuoting(true) .withBackslashEscapeCharacter(true)); // 在这里做你的改写逻辑改完写回 boundSql String rewritten stmt.toString(); ReflectUtil.setFieldValue(boundSql, sql, rewritten); } catch (JSQLParserException e) { // 解析失败放行原 SQL并上报告警 Metrics.counter(sql.parse.failed).increment(); log.warn(sql parse skipped, fallback to raw. sql[{}], original, e); } return invocation.proceed(); } }这段代码的关键在于catch分支的处理方式。很多人的第一反应是直接抛异常结果一个解析边界问题直接变成接口 500。改成放行加告警之后线上稳定性会有质的提升同时你依然能通过告警量掌握解析失败的真实规模。我个人在实际项目里的体会是处理unexpected token这件事七成精力应该花在定位上而不是修复上。只要你把真实 SQL 拿准了、把列号用起来了、把拦截器每步改写都打了日志绝大多数问题都能在十分钟内看清。真正难缠的从来不是那个 token 本身而是你手上那条 SQL 和你以为的那条 SQL 之间的差距。把日志补齐把用例集建起来剩下的就是个体力活了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →