正则抠 SQL 翻车之后:聊聊解析器这件事

做过 SQL 审计或上线影响面评估的,大概都试过一招:正则抠 FROM / JOIN。
Pattern p = Pattern.compile("(?i)(?:from|join)\\s+([a-zA-Z0-9_`.]+)");
Matcher m = p.matcher(sql);
while (m.find()) tables.add(m.group(1));
看起来能跑,真拿去扫一批线上 SQL,就容易翻车:
- CTE(
WITH w AS (...))里的w被当成表; - 链式 JOIN 漏掉最后一张;
FROM (VALUES (1),(2)) AS t把VALUES抠成了表名;schema.table混进库名,去重全乱。
根因其实就一句:SQL 是有文法的语言,用字符串剪刀去剪,永远在补漏洞。
正经做法是把 SQL 解析成 AST,再问这棵树:动了哪些表、是不是只读、有没有持锁。这件事业界早有成熟方案——Druid SQL Parser、JSqlParser 都能干。
多数场景,Druid / JSqlParser 够用
日常 CRUD、普通 JOIN、分页,这两个库都好使。比如抽表名:
// Druid
List<SQLStatement> stmts = SQLUtils.parseStatements(
"SELECT u.id FROM users u JOIN orders o ON u.id = o.uid",
DbType.mysql);
SchemaStatVisitor visitor = SQLUtils.createSchemaStatVisitor(DbType.mysql);
stmts.get(0).accept(visitor);
System.out.println(visitor.getTables().keySet());
// [users, orders]
// JSqlParser
Statement stmt = CCJSqlParserUtil.parse(
"SELECT u.id FROM users u JOIN orders o ON u.id = o.uid");
TablesNamesFinder finder = new TablesNamesFinder();
System.out.println(finder.getTableList(stmt));
// [users, orders]
网关审计、读写分离路由、简单改写,很多人就这么用,没问题。
但业务 SQL 一旦"长歪",局面就不一样了。
真业务里有一类 SQL:换到达梦方言,差距就出来了
信创迁移、报表拆字段、老系统遗留语句里,经常能撞上这种东西——Oracle / 达梦风格的 CONNECT BY 拆逗号,再套中文表名、多层子查询:
SELECT REGEXP_SUBSTR(E14, '[^,]+', 1, LEVEL) AS E14_SPLIT,
G14, H14
FROM (
SELECT REPLACE(E14, '''', '') E14, G14, H14
FROM (SELECT * FROM TB9_3.业务影响分析)
WHERE day_id = '202601--' AND org_cd = 'F0001'
) T
CONNECT BY LEVEL <= LENGTH(E14) - LENGTH(REPLACE(E14, ',', '')) + 1
AND PRIOR ROWID = ROWID
AND PRIOR SYS_GUID() IS NOT NULL
不是"写得花哨",是业务上真的这么跑。拿三个解析器对着测(对比环境:Druid 1.2.23、JSqlParser 4.9、jkit-sql 2.0.1;Druid 分别用 oracle / dm,jkit 走 dm):
@Test
public void testComplexBusinessSql() {
String sql = "SELECT REGEXP_SUBSTR(E14, '[^,]+', 1, LEVEL) AS E14_SPLIT,\n" +
" G14, H14 FROM(SELECT REPLACE(E14,'''','') E14,G14, H14 FROM (select * from TB9_3.业务影响分析 )\n" +
"WHERE day_id = '202601--' AND org_cd = 'F0001') T\n" +
"CONNECT BY LEVEL <= LENGTH(E14) - LENGTH(REPLACE(E14, ',', '')) + 1\n" +
"AND PRIOR ROWID = ROWID \n" +
"AND PRIOR SYS_GUID() IS NOT NULL";
System.out.println("druid(oracle): " + parseDruid(sql, "oracle"));
System.out.println("druid(dm): " + parseDruid(sql, "dm"));
System.out.println("jsqlparser: " + parseJsql(sql));
System.out.println("jkit-sql(dm): " + parseJkit(sql, "dm"));
}
static boolean parseJkit(String sql, String dialect) {
try {
com.alianga.jkit.sql.SQL.parse(
sql, com.alianga.jkit.sql.SqlDialect.fromName(dialect));
return true;
} catch (Throwable e) {
return false;
}
}
static boolean parseDruid(String sql, String dialect) {
try {
List<?> stmts = SQLUtils.parseStatements(sql, toDbType(dialect));
return stmts != null && !stmts.isEmpty();
} catch (Throwable e) {
return false;
}
}
static boolean parseJsql(String sql) {
try {
return CCJSqlParserUtil.parse(sql) != null;
} catch (Throwable e) {
return false;
}
}
/** 跟 Druid DbType 对齐;dm/dameng 走达梦,避免“方言映射错了才失败”。 */
static com.alibaba.druid.DbType toDbType(String dialect) {
switch (dialect.toLowerCase(Locale.ROOT)) {
case "dm":
case "dameng":
return com.alibaba.druid.DbType.dm;
case "oracle":
return com.alibaba.druid.DbType.oracle;
case "mysql":
return com.alibaba.druid.DbType.mysql;
default:
return com.alibaba.druid.DbType.other;
}
}

复跑结论先说清楚,避免误导:
- 普通
CONNECT BY PRIOR emp_id = mgr_id,Druid 官方语料本来就能过,不是“凡是 CONNECT BY 都挂”; - 正文这条业务 SQL:Druid
1.2.23在DbType.oracle下能解析成功,换成DbType.dm才失败;JSqlParser4.9失败,卡在SYS_GUID();jkit-sql2.0.1用SqlDialect.fromName("dm")能过。 - 所以差距不在“Druid 一律解析不来”,而在达梦方言下这条 REGEXP_SUBSTR + 中文表名 + 嵌套 CONNECT BY 组合。
跑出来是:
| 解析器 | 版本 | 方言 / 入口 | 结果(上述业务 SQL) |
|---|---|---|---|
| Druid | 1.2.23 | DbType.oracle | 解析成功 |
| Druid | 1.2.23 | DbType.dm | 解析失败 |
| JSqlParser | 4.9 | (无方言参数) | 解析失败(卡在 SYS_GUID()) |
| jkit-sql | 2.0.1 | SqlDialect.dm | 解析成功 |
所以这篇不是要说“扔掉 Druid / JSqlParser”。常见路径、甚至 Oracle 方言下的复杂层次查询,它们往往够用。但信创落地常走达梦:同一条 SQL 换 DbType.dm,Druid 就可能挂;JSqlParser 又过不了 SYS_GUID() 这类组合。这时才需要一个覆盖面更宽、又不想拖进连接池全家桶的解析器。
这就是 jkit-sql 的切入点。
jkit-sql 是什么?
jkit-sql 是一个零第三方依赖、纯 JDK 手写的 SQL 解析器:
- 词法:
char[]逐字符扫描 + 关键字开地址哈希; - 语法:手写递归下降;
- 产物:AST(
SqlSelect/SqlJoin/SqlOrderBy…)。
能力上对标 Druid SQL Parser、JSqlParser 的常用入口——解析、抽表列、改写、跨方言格式化。有一条边界建议先记住:
它不执行 SQL,也不引 JDBC 驱动。
不连库、不跑查询、不替代 MyBatis / JPA。它只干这一层:
SQL 文本 ──parse()──▶ AST ──改写/统计/格式化/转换──▶ SQL 文本
◀──toSqlString()──
真正落库,交给你自己的数据源;需要启动时按实体建表,再看搭档模块 jkit-sql-auto。
它能帮你干什么?
下面几类需求,ORM 做起来别扭,解析器一行就够:
1. 一条 SQL 动了哪些表、哪些列
import com.alianga.jkit.sql.SQL;
List<String> tables = SQL.tables(
"SELECT u.id FROM users u "
+ "LEFT JOIN order_t b ON u.id = b.uid "
+ "WHERE b.amount > 100");
// [users, order_t]
SqlSchemaStat stat = SQL.stat(
"SELECT u.id, u.name FROM users u WHERE u.age > 18 ORDER BY u.name");
stat.tableNames(); // [users]
stat.getColumns(); // 含 WHERE / ORDER BY 里的列
stat.getConditions(); // [users.age > 18]
tables() 只收真实被访问的表:库名、CTE 名、例程名会剔掉,不会污染审计结果。
2. 判断走主库还是从库
SqlStatement stmt = SQL.parse("SELECT * FROM t WHERE id = 1 FOR UPDATE");
stmt.isReadOnly(); // false —— 持锁,走主库
FOR UPDATE / LOCK IN SHARE MODE 都会被判成写。读写分离怎么挂拦截器,后面再展开。
3. 把拼接字面量收编成 ?
String safe = SQL.parameterize(
"SELECT * FROM users WHERE name = 'alice' AND age = 18");
// SELECT * FROM users WHERE name = ? AND age = ?
4. 统一注入租户条件 / 改表名
SqlStatement out = SQL.andWhere(
SQL.parse("SELECT id FROM orders WHERE status = 1"),
"tenant_id = ?");
// SELECT id FROM orders WHERE status = 1 AND tenant_id = ?
多租户规则链、分表改名这些,后面再展开。
5. 跨方言分页 / 迁移
LIMIT / TOP / ROWNUM / OFFSET FETCH 一套 API;MySQL 迁 PostgreSQL 时,convert(...) 能把 GROUP_CONCAT → STRING_AGG 这类函数翻过去:
String pg = SQL.convert(
"SELECT GROUP_CONCAT(name) FROM t",
SqlDialect.MYSQL, SqlDialect.POSTGRES);
// SELECT STRING_AGG(name) FROM t
6. 启动时按实体自动建表
jkit-sql-auto 认 JPA / MyBatis-Plus 注解,无编译依赖,已有项目可直接接入,后面单独写。
为什么是手写,而不是生成器?
两条常见路线:
- 生成器派(如 JSqlParser,JavaCC):文法好维护,生成代码体积大,热路径更慢;
- 手写派(Druid、jkit-sql):词法 + 递归下降,可控、轻、快。
jkit-sql 选了手写,并且只依赖自己的内核,JDK 8+。Druid 往往是“连接池 + 监控 + 解析”一起上;你要是只想在网关 / 审计里安静解析改写一条 SQL,会更在意依赖面。
性能对比和成功率数字后面再写,这篇先把定位说清楚。
最小例子
import com.alianga.jkit.sql.SQL;
import com.alianga.jkit.sql.ast.SqlStatement;
SqlStatement stmt = SQL.parse(
"SELECT u.id, b.name FROM users u "
+ "LEFT JOIN order_t b ON u.id = b.uid "
+ "WHERE u.age > 18");
stmt.type(); // SELECT
stmt.isReadOnly(); // true
SQL.tables(stmt); // [users, order_t]
没有连接池,没有 SessionFactory,没有一堆 XML。
它不做什么
- 不执行 SQL,不管连接池、事务、结果映射;
- 不是 ORM,不做对象关系映射;
- 不做查询优化,不生成执行计划;
- 适合网关、审计、防火墙、改写、迁移、建表这类中间层,不当业务数据访问层本身。
需要“读懂并改造别人给的 SQL”时用它;需要“方便存取业务对象”时,继续用 MyBatis / JPA。
小结
正则抠表名不靠谱;Druid / JSqlParser 覆盖了大多数日常 SQL,复杂 CONNECT BY 在 Oracle 下 Druid 也能扛。真正容易拉开差距的,是达梦方言 + 嵌套拆分 + 中文标识符叠在一起的业务遗留语句。jkit-sql 做的是 SQL 文本和 AST 之间那一层:零第三方依赖,不越界执行。
后面还会写性能、读写分离、注入防护、防火墙、跨方言分页、多租户、脱敏、迁移和自动建表。性能那篇会专门看手写词法在热路径上到底有多快。
你那边有没有“正则抠 SQL”,或某条方言 SQL 把解析器干趴的经历?评论区见。
Q.E.D.


