⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 http://www.iocoder.cn/Fight/gaoxiaoshujugelifanganSpringBootMybatislanjieqiJSqlParserquanjiexi/ 「芋道源码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

真凶不是 SQL 写得太多,是「漏写一条就出事」

凌晨两点告警:测试环境的订单出现在了生产看板上。复盘下来一句话——新加的 Mapper 方法忘了带 env 字段。

这是所有「靠业务层手写过滤条件」的方案的死穴:

  • 业务层 if-tenant:人写人忘,每加一条 SQL 就多一个事故概率;
  • Mapper BaseService 封装:通用方法拦得住,自定义复杂 SQL 拦不住;
  • 物理分库:彻底但成本高,数据库实例翻倍。

真正的修法不是让大家更细心,是让 SQL 在出库前自动带上隔离条件——这就是 MyBatis 拦截器 + JSqlParser 的活路:拦截 StatementHandler.prepare,把原始 SQL 解析成 AST,往 WHERE 子树里插一刀,再 toString 回去。业务代码不动一行,每一条 SQL 都自动带 env = 'prod'。

决策矩阵:哪种隔离方案适合你

不是所有项目都该上拦截器——先看自己在哪个象限:

表数量 多租户复杂度 推荐方案 理由
< 10 张 简单环境隔离 业务层 if (env == "prod") 维护成本低,盯紧 Code Review 即可
10-50 张 tenant_id 列 + 标准查询 MyBatis Plus 内置 TenantLineHandler 已经造好的轮子,5 行配置接入
50+ 张 复杂 SQL / 子查询 / Join 本文方案:拦截器 + JSqlParser 唯一能正确改写 AST 的方案
任意 跨数据中心 / 强合规 物理分库(每租户一库) 隔离最彻底,成本最高

为什么不直接用 MyBatis Plus? 因为 Plus 的 TenantLineHandler 对子查询、UNION、复杂 JOIN 的支持是有边界的——一旦你的 SQL 里出现 FROM (SELECT ... FROM xxx) 这种嵌套,Plus 内置改写经常漏改。自己基于 JSqlParser 写一个,AST 怎么改自己说了算。

核心思路:拦 prepare、改 AST、放 PreparedStatement

很多人疑惑:MyBatis 有 query / update / prepare 三种拦截点,为什么必须拦 prepare?

MyBatis 的执行链:

  1. 解析 mapper xml,生成 BoundSql(含原始 SQL + 参数)
  2. StatementHandler.prepare() 调用 JDBC Connection.prepareStatement(sql) —— SQL 在这里被「钉死」
  3. query / update 只是执行已经准备好的 PreparedStatement

关键就是第 2 步——SQL 一旦进入 PreparedStatement,就再也改不动了。所以必须在 prepare 里改 SQL,再让它走原流程。

JSqlParser 提供两个核心能力:

  • CCJSqlParserUtil.parse(sql):把 SQL 字符串解析成 Statement(AST 根)
  • 修改 AST 后调用 statement.toString() 重新生成 SQL 字符串

整个改写过程完全在内存里发生,不下数据库,性能开销可以忽略。

代码骨架:拦截器主体

只贴关键部分——分发不同 SQL 类型到对应的改写方法:

@Component
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare",
args = {Connection.class, Integer.class})
})
public class DataIsolationInterceptor implements Interceptor {

@Value("${spring.profiles.active}")
private String env;

@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = handler.getBoundSql();
String originalSql = boundSql.getSql();

String newSql = injectEnvCondition(originalSql);

// 反射把改写后的 SQL 写回 BoundSql
MetaObject meta = SystemMetaObject.forObject(boundSql);
meta.setValue("sql", newSql);

return invocation.proceed();
}

private String injectEnvCondition(String sql) throws JSQLParserException {
Statement stmt = CCJSqlParserUtil.parse(sql);
if (stmt instanceof Select) return rewriteSelect((Select) stmt);
if (stmt instanceof Insert) return rewriteInsert((Insert) stmt);
if (stmt instanceof Update) return rewriteUpdate((Update) stmt);
if (stmt instanceof Delete) return rewriteDelete((Delete) stmt);
return sql; // DDL 等不动
}
}

关键算法:往 WHERE 树里插一刀

四种 SQL 改写本质都是「往 AST 加一个 AND env = 'prod'」,但每种语句的挂点不同:

// SELECT / UPDATE / DELETE 通用:把原 WHERE 包成 AND
private Expression appendEnvAnd(Expression originalWhere, String tableAlias) {
EqualsTo envEq = new EqualsTo();
envEq.setLeftExpression(new Column(
StringUtils.isNotBlank(tableAlias) ? tableAlias + ".env" : "env"));
envEq.setRightExpression(new StringValue(env));

if (originalWhere == null) {
return envEq; // 原 SQL 没 WHERE,直接返回
}
AndExpression and = new AndExpression();
and.setLeftExpression(originalWhere);
and.setRightExpression(envEq);
return and;
}

多表 JOIN 的关键细节——每张表都得加自己的 t.env = 'prod',不然漏了 JOIN 条件就会跨环境串数据。SQL 不强制带别名,所以代码里要兜底取「别名 → 表名」:

private Expression rewriteJoin(PlainSelect select) {
Table main = (Table) select.getFromItem();
Expression where = appendEnvAnd(select.getWhere(), aliasOrName(main));

for (Join join : select.getJoins()) {
if (join.getRightItem() instanceof Table joined) {
where = appendEnvAnd(where, aliasOrName(joined));
}
}
return where;
}

/** 没别名就退化用表名,不要直接 alias.getName() 否则 NPE */
private static String aliasOrName(Table t) {
return t.getAlias() != null ? t.getAlias().getName() : t.getName();
}

INSERT 改写要分三种 VALUES 形态——直接按 SetOperationList 强转会在 INSERT INTO ... VALUES (...) 上炸。JSqlParser 4.x 里 INSERT 的 source 类型对照:

SQL 形态 对应 AST
INSERT INTO t (...) VALUES (...) Values + ExpressionList
INSERT INTO t (...) VALUES (...), (...), (...) 多行 Values + MultiExpressionList(4.6 起合并为 RowConstructor)
INSERT INTO t (...) SELECT ... PlainSelect 或 SetOperationList
private void rewriteInsert(Insert insert) {
insert.getColumns().add(new Column("env"));
StringValue envVal = new StringValue(env);

Object src = insert.getSelect(); // 4.x 起 INSERT 的 source 都走 getSelect()
if (src instanceof Values values) {
// VALUES (...) 单行 / 多行
appendEnvToValues(values, envVal);
} else if (src instanceof PlainSelect ps) {
// INSERT ... SELECT 的列表里追加常量列
ps.getSelectItems().add(new SelectItem<>(envVal));
} else if (src instanceof SetOperationList ops) {
// INSERT ... UNION/INTERSECT 的每一支都追加
ops.getSelects().forEach(s -> ((PlainSelect) s).getSelectItems()
.add(new SelectItem<>(envVal)));
}
}

别想一行写完所有 INSERT 形态——VALUES、SELECT、UNION 三类挂点不同,分支判断比强转优雅得多。

测试一下效果:

-- 原始 SQL
SELECT a.username, a.code, o.org_code
FROM admin a LEFT JOIN organize o ON a.org_id = o.id
WHERE a.dr = 0;

-- 改写后
SELECT a.username, a.code, o.org_code
FROM admin a LEFT JOIN organize o ON a.org_id = o.id
WHERE a.dr = 0 AND a.env = 'prod' AND o.env = 'prod';

INSERT 也改写到位:

-- 原始
INSERT INTO admin (id, username, code) VALUES (?, ?, ?);
-- 改写后
INSERT INTO admin (id, username, code, env) VALUES (?, ?, ?, 'prod');

4 个真实生产坑,按踩到概率从高到低

坑 1:子查询只改了第一层(最常见)

-- 这种嵌套子查询很常见
SELECT * FROM (SELECT * FROM admin) sub WHERE sub.id = ?;

如果 rewriteSelect 只处理外层 FROM,子查询里的 SELECT * FROM admin 就漏改。修法是递归下钻——遇到 FromItem instanceof SubSelect 就把子查询当独立 Select 再走一遍 rewriteSelect。

坑 2:JOIN 表没别名(常见)

joined.getAlias().getName()  // 没别名时直接 NPE

写 SQL 不强制带别名是 80% 项目的常态。代码里要兜底:没别名时用表名当 alias,或者直接用 Table.getName()。

坑 3:UNION / INTERSECT 漏改(少见但破坏力大)

SELECT * FROM admin WHERE x = 1
UNION
SELECT * FROM admin WHERE x = 2;

Select 的 selectBody 在 UNION 时是 SetOperationList 不是 PlainSelect——没单独处理就直接报 ClassCastException 或漏改其中一支。出现一次就是数据串号大事故。

坑 4:动态 SQL 里有占位符或注释(高级场景)

<select id="x">
SELECT * FROM admin
<!-- this is a comment -->
<if test="name != null"> AND name = #{name}</if>
</select>

JSqlParser 4.x 解析带注释或 MyBatis 特殊 token 的 SQL 偶尔会异常。加 try-catch 兜底:解析失败走原 SQL 并记录告警日志,别让一条解析失败把整个接口打挂。

我的判断

数据隔离的本质问题不是「方案能不能跑」,而是 「能不能保证 100% 的 SQL 都被覆盖」。业务层 if-tenant 看着轻量,但只要漏写一条就是事故;MyBatis 拦截器 + JSqlParser 把这件事下沉到 SQL 解析层——业务代码该怎么写还怎么写,事故面积一次性收敛。好的隔离方案不是没有 bug,是知道 bug 会出现在哪——然后让它根本进不了 PreparedStatement。

文章目录
  1. 1. 真凶不是 SQL 写得太多,是「漏写一条就出事」
  2. 2. 决策矩阵:哪种隔离方案适合你
  3. 3. 核心思路:拦 prepare、改 AST、放 PreparedStatement
  4. 4. 代码骨架:拦截器主体
  5. 5. 关键算法:往 WHERE 树里插一刀
  6. 6. 4 个真实生产坑,按踩到概率从高到低
  7. 7. 我的判断