当前位置: 首页 > news >正文

数据隔离最容易翻车的地方就是「漏写一条」?交给 MyBatis 自动解决!

👉这是一个或许对你有用的社群

🐱 一对一交流/面试小册/简历优化/求职解惑,欢迎加入「芋道快速开发平台」知识星球。下面是星球提供的部分资料:

  • 《项目实战(视频)》:从书中学,往事上“练”

  • 《互联网高频面试题》:面朝简历学习,春暖花开

  • 《架构 x 系统设计》:摧枯拉朽,掌控面试高频场景题

  • 《精进 Java 学习指南》:系统学习,互联网主流技术栈

  • 《必读 Java 源码专栏》:知其然,知其所以然

👉这是一个或许对你有用的开源项目

国产Star破10w的开源项目,前端包括管理后台、微信小程序,后端支持单体、微服务架构

RBAC权限、数据权限、SaaS多租户、商城、支付、工作流、大屏报表、ERP、CRMAI大模型、IoT物联网等功能:

  • 多模块:https://gitee.com/zhijiantianya/ruoyi-vue-pro

  • 微服务:https://gitee.com/zhijiantianya/yudao-cloud

  • 视频教程:https://doc.iocoder.cn

【国内首批】支持 JDK17/21+SpringBoot3、JDK8/11+Spring Boot2双版本

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

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

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

  • 代码骨架:拦截器主体

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

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

  • 我的判断


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

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

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

  • 业务层 if-tenant:人写人忘,每加一条 SQL 就多一个事故概率;

  • Mapper BaseService 封装:通用方法拦得住,自定义复杂 SQL 拦不住;

  • 物理分库:彻底但成本高,数据库实例翻倍。

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

基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能

  • 项目地址:https://github.com/YunaiV/ruoyi-vue-pro

  • 视频教程:https://doc.iocoder.cn/video/

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

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

表数量

多租户复杂度

推荐方案

理由

< 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 怎么改自己说了算

基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能

  • 项目地址:https://github.com/YunaiV/yudao-cloud

  • 视频教程:https://doc.iocoder.cn/video/

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

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

MyBatis 的执行链:

  1. 解析 mapper xml,生成BoundSql(含原始 SQL + 参数)

  2. StatementHandler.prepare()调用 JDBCConnection.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); } elseif (src instanceof PlainSelect ps) { // INSERT ... SELECT 的列表里追加常量列 ps.getSelectItems().add(new SelectItem<>(envVal)); } elseif (src instanceof SetOperationList ops) { // INSERT ... UNION/INTERSECT 的每一支都追加 ops.getSelects().forEach(s -> ((PlainSelect) s).getSelectItems() .add(new SelectItem<>(envVal))); } }

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

测试一下效果:

-- 原始 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;

SelectselectBody在 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


欢迎加入我的知识星球,全面提升技术能力。

👉 加入方式,长按”或“扫描”下方二维码噢

星球的内容包括:项目实战、面试招聘、源码解析、学习路线。

文章有帮助的话,在看,转发吧。 谢谢支持哟 (*^__^*)
http://www.jsqmd.com/news/766802/

相关文章:

  • 2026年当前,如何为您的孩子选择一份科学、温暖的幼儿园一日流程? - 2026年企业推荐榜
  • [理论篇-11]AI Agent(智能体)——不只是会答话的AI,而是会干活的AI
  • 5分钟快速安装HS2-HF_Patch:解锁Honey Select 2完整游戏体验的终极指南
  • 别再手动转格式了!用Python+ezdxf批量处理DWG到DXF,还能一键导出WKB给GIS用
  • AI驱动生物实验协议平台Elnora Plugins:MCP协议与技能化架构详解
  • 别再用老方法点灯了!手把手教你用DSP F28335的GPIO寄存器精准控制LED(附完整代码)
  • 告别配置迷宫:OCAuxiliaryTools如何让黑苹果配置变得轻松有趣
  • 预测新药联合建模登Nature:AI淘金化学荒野,探路亿级分子星辰大海
  • Windows平台安卓应用部署革命:APK Installer的轻量化跨平台解决方案
  • 用PySide6和OpenCV打造你的第一个桌面摄像头应用(附完整源码)
  • 2026年至今湖南市场CTPU储罐防腐胶泥供应商全景扫描与核心能力拆解 - 2026年企业推荐榜
  • HoRain云--PHP 变量
  • Navicat无限试用终极指南:macOS平台的完整解决方案
  • 用‘乞丐版’预算复刻Keithley 2450?我的DIY源表实战与元器件避坑指南(含CRHA2510AF200MFKEF替代方案)
  • 企业级Docker存储架构设计(含K8s节点适配):单机TB级持久化方案与IO隔离实践
  • VoXtream2:超低延迟流式TTS与动态语速控制技术解析
  • 保姆级教程:在YOLOv5 v6.0的yaml配置文件中,手把手教你插入CA注意力模块
  • fre:ac音频转换器:专业级开源解决方案的终极指南
  • 2026年4月更新:义乌围棋培训机构深度**与口碑推荐 - 2026年企业推荐榜
  • 全网最强小说下载器:novel-downloader一键收藏100+网站小说
  • 别再死记硬背了!从MOS管沟道宽长比到单元延时,用大白话讲透STA里的RC充放电模型
  • 别再只认识MP4了!高清电视、直播切片背后的TS文件,到底是个啥?
  • 5分钟快速上手:Retrieval-based-Voice-Conversion-WebUI语音转换终极指南
  • 手把手教你为ARM嵌入式环境编译‘带调试信息’的Glibc库,彻底告别GDB堆栈损坏警告
  • 别再乱调重力了!Simulink Simscape钟摆建模,从Revolute Joint到求解器设置的保姆级避坑指南
  • ChanlunX缠论插件:3步实现通达信专业K线分析,新手也能5分钟掌握
  • 从短信链接到应用内页面:uni-app URLScheme实战,打通用户增长的关键一环
  • 告别在线工具!用Python+Skyfield库本地计算卫星轨道与星下点(以高分五号为例)
  • 告别 User Interface:在 Xilinx UltraScale 平台上,为什么我更推荐用 AXI 接口的 DDR4 MIG IP?
  • 通过Taotoken CLI工具一键配置团队开发环境中的大模型密钥