
简介面向Java后端开发者这份PDF聚焦如何借助Mybatis实现数据权限控制解决同一查询方法在不同权限下返回不同数据集的问题并避免研发在业务代码中硬编码过滤逻辑。文档从RBAC权限模型切入梳理URL、界面元素与数据三类资源重点讲解基于Mybatis Plugin机制的PermissionHelper插件设计与PermissionRule规则实体借助jsqlparser解析和改写SQL实现按角色动态注入查询条件。同时列出该方案在数据库方言、非关系型数据库适配等方面的局限给出与AOP等方案的取舍参考。资源为单个PDF文档压缩包仅93KB便于快速通读。已有4593人学习浏览适合具备Mybatis基础、正在设计通用权限模块的读者参考。1. 数据权限不是 SQL 拼接为什么说 Mybatis 是行级权限的天然主场很多 Java 后端项目在走到中后期时都会撞上一个尴尬需求客户说「我们分公司开个独立账号他们只能看自己那一部分数据」。如果系统是 Spring Boot Mybatis 搭起来的第一反应通常是在所有查询 SQL 后面手写AND dept_id ?。问题在于这个需求往往同时在几十张业务表、几百条 Mapper XML 上生效逐条改不仅工作量大下个月新同事照旧漏写越权事故照发。这个标题要解决的就是把「谁能看哪些行」从业务代码里剥离出来统一放到 Mybatis 执行 SQL 之前的拦截器里由框架层自动追加权限条件。这也是 java 面试里关于 mybatis 的高频追问方向。适合谁手里有存量 Mybatis 项目、正在被多角色数据隔离困扰、又不想推倒重来的读者。2. 需求与边界先对齐三种数据范围模型为什么最终落到 Mybatis 拦截器2.1 数据权限到底在管什么部门树、数据归属与角色叠加先说清楚数据权限和登录授权的区别登录授权决定「你能进哪个菜单」数据权限决定「进了菜单你能看哪些行」。这两件事经常被混为一谈但实现路径完全不同。登录授权一般在 Spring Security 或 Shiro 的过滤器里做数据权限则发生在 SQL 执行前后往往要精确到一张表的一行。常见的业务模型可以归成三类。第一类是组织范围按部门树隔离上级看本部门及下级部门的数据典型场景是销售总监看华东大区所有订单销售员只看自己名下的。翻译成 SQL 就是给表加一列dept_id的范围条件。第二类是数据归属业务表里存在create_by、owner_id、seller_id这类归属字段谁创建谁可见或者谁被分配谁可见。对应条件就是owner_id 当前用户id。第三类是角色交集一个用户挂了多个角色多个角色的数据范围做并集最终条件是多组条件用 OR 串起来。这三类模型有一个共同点都能翻译成一条「行级过滤条件」也就是在业务 SQL 上追加一个 WHERE 片段。行级权限在 Java 里的标准落地方式就是在 SQL 执行层把这个 WHERE 片段注入进去。只要这个共识立住了Mybatis 拦截器就是最自然的选择因为业务 SQL 不感知这个条件规则变化时也不用改 Mapper 文件。2.2 三条实现路径对比业务代码内嵌、Mybatis 拦截器、现成数据权限框架先说不推荐的做法在 Service 层把数据查出来再用 Java 代码做内存过滤。这个方案在小数据量下看起来很美一旦加上分页、聚合导出、跨表 JOIN基本就是灾难。分页在数据库层做过滤在内存层做两个层面数据量不一致翻页会出现页数变少、内容错位的诡异现象导出时更容易把超范围数据直接流出去。我见过不止一次因为这个方案导致的线上越权事故属于典型的「看着省事后面还债」。推荐的方案是在 Mybatis 拦截器里改 SQL。思路是拦截Executor.query()在真正执行之前拿到原始 SQL 和参数把权限条件拼到 WHERE 后面再交给 Mybatis 继续执行。对业务代码是透明的SQL 层能看到完整过滤条件性能损耗也小。还有一种做法是直接用社区现成的数据权限插件比如 MyBatis-Plus 的 DataPermissionInterceptor这类方案的优点是上手快缺点是约定大于配置遇到复杂的多表 JOIN、子查询、嵌套视图时插件内置的解析逻辑往往覆盖不住最后还是要自己二次开发。我一般选 Mybatis 原生拦截器理由有三条存量 XML 不需要动可以统一处理 JOIN、子查询和聚合查询权限规则可以做成配置按月更新都行。很多人把拦截器当成黑匣子其实核心动作只有一个——拿到BoundSql改掉sql字段放回去执行。把这个链条想清楚后面所有参数和坑都有了解释。2.3 从 mybatis 源码看切入时机Executor、StatementHandler、ParameterHandler 与 ResultSetHandlerMybatis 允许通过Intercepts注解拦截四种核心对象Executor、StatementHandler、ParameterHandler、ResultSetHandler。很多做数据权限的同事一上来就选StatementHandler因为它的prepare方法能直接拿到BoundSql。但我翻了 mybatis 源码之后还是建议选Executor。看源码就会发现MappedStatement是在解析 XML 时期被注册进Configuration的而Executor.query()是顶层入口它的参数里有完整的MappedStatement和参数对象。意味着你在这一层既能拿到方法的 id格式是「Mapper 接口全名.方法名」又能拿到用户参数和RowBounds。换到StatementHandler层虽然也能碰到BoundSql但参数对象要从Connection和Handler的上下文里反推多绕一层就多一分出错的风险。ParameterHandler只能改参数改不了 SQL 结构ResultSetHandler是最后一道闸用来做结果过滤不是不行但要承受内存浪费和分页失效的代价只适合兜底。这四种拦截点的取舍可以总结成一张表拦截点能拿到什么适合做什么主要代价Executor.query/updateMappedStatement、参数对象、RowBounds判断是否需要权限、改写 SQL需要自己重建 BoundSqlStatementHandler.prepareBoundSql、Connection改写 BoundSql参数对象拿起来不直接ParameterHandler.setParametersParameterObject给已有占位符赋值改不了 SQL 结构ResultSetHandler.handleResultSets结果列表结果集过滤兜底分页已失效、内存浪费结论是数据权限这样的需求正确切入点就是Executor。这也是后面所有代码实现的基础选型。3. 用拦截器改写 BoundSql从权限注解到 SQL 条件注入的最小可运行版本3.1 先定义契约用DataScope注解标记哪些 Mapper 方法需要数据权限不要把拦截器做成「拦到所有 SQL 都加权限条件」那会让关联字典表的查询、登录用户的查询全部受害。正确做法是约定一个注解只有打了这个注解的 Mapper 方法才进入权限处理流程。这个注解同时也是配置入口后续组织范围、归属人条件都从这里读取。Target({ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface DataScope { /** 权限模型DEPT按部门范围OWNER按数据归属人ALL不限制 */ ScopeType type() default ScopeType.DEPT; /** 权限列名例如 dept_id、owner_id */ String column(); /** 多表 JOIN 时的表别名例如 o.dept_id 里的 o单表可留空 */ String alias() default ; }这里有一个容易被忽略的参数alias。当业务 SQL 是单表查询时直接写dept_id IN (...)没问题一旦 SQL 里有两个表都有dept_id字段不加别名就会报ambiguous column name数据库直接拒绝执行。所以注解里预留了别名位规则拼接时把别名带上后面 JOIN 场景才不会翻车。3.2 用 ThreadLocal 承载权限上下文请求进入 Mapper 前把规则放好有了注解以后拦截器还需要知道「当前登录用户的数据范围是什么」。这个信息不适合在每个 Mapper 里重复计算应该在请求入口处统一算好放进 ThreadLocal拦截器在执行时直接取。public class DataScopeContext { private static final ThreadLocalDataScopeRule HOLDER new ThreadLocal(); public static void set(DataScopeRule rule) { HOLDER.set(rule); } public static DataScopeRule get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }注意这里存的是一个DataScopeRule对象而不是 userId。原因是权限计算往往包含部门树遍历、是否包含下级、数据范围列表等多个字段如果在每个 Mapper 执行时都重新查一次部门关系性能损耗会被放大。正确习惯是在 Filter 或 Controller 切面里把当前用户的所有权限范围算好封装成规则对象放进去。规则对象至少包含用户 id、可见部门 id 列表、是否包含下级部门、数据归属字段的取值列表。3.3 拦截器核心在 Executor 层改写 SQL 并重建 BoundSql这是整套方案里最核心的一段代码。拦截器做四件事判断当前方法是否需要数据权限取出原始 BoundSql把权限条件拼进 SQL重建 BoundSql 并交还给 Mybatis 执行。Component Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) public class DataScopeInterceptor implements Interceptor { private final DataScopeHandler handler; public DataScopeInterceptor(DataScopeHandler handler) { this.handler handler; } Override public Object intercept(Invocation invocation) throws Throwable { // 1. 拿到执行中的 MappedStatement 和参数对象 MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; String methodId ms.getId(); // 2. 判断这个 Mapper 方法是否需要加数据权限 if (!handler.needProcess(methodId)) { return invocation.proceed(); } // 3. 取当前线程里的权限规则没有就放行 DataScopeRule rule DataScopeContext.get(); if (rule null || rule.isEmpty()) { return invocation.proceed(); } // 4. 从 MappedStatement 中获取原始 BoundSql BoundSql boundSql ms.getBoundSql(parameter); String originalSql boundSql.getSql(); // 5. 由处理器完成 SQL 改写 String targetSql handler.buildSql(originalSql, rule); // 6. 重建 BoundSql 并补充附加参数 BoundSql newBoundSql new BoundSql( ms.getConfiguration(), targetSql, boundSql.getParameterMappings(), parameter); copyAdditionalParameters(boundSql, newBoundSql); // 7. 用反射把新 BoundSql 放回 MappedStatement Field sqlSourceField MappedStatement.class.getDeclaredField(sqlSource); sqlSourceField.setAccessible(true); sqlSourceField.set(ms, new StaticSqlSource(ms.getConfiguration(), targetSql, boundSql.getParameterMappings())); return invocation.proceed(); } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { // 需要外部传参时在这里读取 } private void copyAdditionalParameters(BoundSql source, BoundSql target) throws Exception { // BoundSql 内部有一个 additionalParameters 集合 // 动态 SQL 里的 foreach、_parameter 都依赖它必须复制过去 Field field BoundSql.class.getDeclaredField(additionalParameters); field.setAccessible(true); MapString, Object additional (MapString, Object) field.get(source); for (Map.EntryString, Object entry : additional.entrySet()) { target.setAdditionalParameter(entry.getKey(), entry.getValue()); } } }这段代码最需要理解的两个点第一为什么重建BoundSql而不是直接改原对象因为BoundSql里的sql是 final 字段没有提供 setter只能新建。第二为什么要复制additionalParameters使用foreach、choose这类动态 SQL 时Mybatis 会把循环产生的参数放进这个集合比如list.iterator、__frch_item_0等。只重建 BoundSql 不复制这些参数执行阶段会报「Parameter __frch_item_0 not found」这是很多人第一次写拦截器时踩得最密集的坑。提示上面第七步用反射替换 MappedStatement 里的 sqlSource 是兼容性最好的做法也是社区里普遍采用的方式。注意在 Spring 环境中这个拦截器最好声明为单例 Bean不要每次请求都 new。3.4 SQL 改写的最小实现主查询 WHERE 的两种拼法buildSql的简单版本可以用字符串拼接完成适合单表查询、SQL 结构受控的项目。核心逻辑是判断主干 SQL 中是否已有 WHERE。public String buildSql(String originalSql, DataScopeRule rule) { String permissionCondition rule.toSqlCondition(); if (permissionCondition null || permissionCondition.isEmpty()) { return originalSql; } // 简化写法只在主干末尾追加适合没有子查询的场景 String trimmed originalSql.trim(); String lower trimmed.toLowerCase(); int idx lastIndexOfMainWhere(lower); if (idx 0) { return trimmed.substring(0, idx 5) ( permissionCondition ) AND trimmed.substring(idx 5); } return trimmed WHERE permissionCondition; }这个版本能跑但lastIndexOfMainWhere很容易写错因为 SQL 里可能多个 WHERE比如SELECT * FROM t WHERE id IN (SELECT id FROM t2 WHERE ...)一旦字符串定位错了权限条件会拼到子查询里主表数据一条都过滤不到。所以真实项目我不建议自己维护字符串定位逻辑更稳妥的是引入JSqlParser依赖把原始 SQL 解析成语法树在语法树层面操作 WHERE 节点。这样无论 SQL 多复杂都能准确找到主查询的位置。4. 权限规则配置化组织树、多表别名与规则表达式的统一处理4.1 权限规则从哪里来把用户组织范围翻译成 SQL 片段权限规则不可能写死在拦截器里否则每次调整组织架构都要改代码重启。常见做法是把规则源放在用户组织关系表里系统启动时或用户登录时加载该用户可见的部门 id 集合再结合DataScope注解里的元数据组合成一条最终的条件。public class DataScopeRule { private Long userId; private ScopeType type; private ListLong deptIds; private Long ownerId; private boolean includeChildren; public String toSqlCondition() { if (type ScopeType.ALL) { return 11 ; } StringBuilder condition new StringBuilder(); String column resolveColumn(); if (type ScopeType.DEPT) { condition.append(column).append( IN (); for (Long deptId : deptIds) { condition.append(deptId).append(,); } condition.deleteCharAt(condition.length() - 1); condition.append()); if (includeChildren) { condition.append( OR ).append(column) .append( IN (SELECT id FROM sys_dept WHERE ancestors LIKE ) .append(deptIds.get(0)).append(%)); } } else if (type ScopeType.OWNER) { condition.append(column).append( ).append(ownerId); } return condition.toString(); } }这段逻辑里有两个参数值得说明。第一个是ancestors LIKE的写法部门表存了祖先路径时用LIKE 父id%就可以一次取出所有下级组织比递归查效率高得多这也是组织树权限最常用的 SQL 技巧。第二个是11当用户拥有全部数据权限时拼一个恒真条件既不改变原 SQL 语义也避免拼接逻辑里出现空字符串。4.2 多表 JOIN 时权限列怎么定位别名不是可选项实际业务里权限控制很少只作用在一张表上。以订单列表为例sys_order表通过dept_id关联sys_dept前端展示需要 JOIN 出部门名称。这个时候权限条件必须限定是订单表的dept_id而不是部门表的dept_id否则数据库分不清你指的是哪一列。所以注解里的alias参数在这一步派上用场。拼接条件时不要直接拼dept_id而是拼o.dept_id。注解参数说明示例type权限模型DEPT / OWNER / ALLDataScope(type ScopeType.DEPT, ...)column权限列名对应业务表字段column dept_idalias多表查询时的表别名单表留空alias o拼出来是o.dept_idincludeChildren是否包含下级部门的数据可与规则对象里的同名属性双向传递这里有一个常见误用单表查询时也把别名写成t结果 SQL 里实际没有t这个别名拼出来的t.dept_id直接报unknown column。我的习惯是规则解析器里做防御如果别名在原始 SQL 中不存在就自动降级为不带别名的裸字段名宁可条件宽松一点也不能让查询因为别名直接报错。当然这种降级要在测试环境里加日志警告方便发现问题。4.3 从注解到 MappedStatement 的元数据传递不要每次拦截时都反射注解第 3 章的拦截器里每次执行都要判断methodId是否需要处理这个判断如果靠反射getAnnotation来做性能不会太好看。更好的做法是在项目启动阶段把 mapper 方法对应的注解扫描一遍缓存成 Map。Component public class DataScopeMetadataRegistry implements InitializingBean { private final MapString, DataScope cache new ConcurrentHashMap(); Override public void afterPropertiesSet() { // 扫描 Mapper 接口包下所有方法缓存方法全名到注解的映射 String[] packages {com.example.project.mapper}; for (String pkg : packages) { for (Class? mapperClass : scanClasses(pkg)) { Method[] methods mapperClass.getDeclaredMethods(); for (Method method : methods) { DataScope scope method.getAnnotation(DataScope.class); if (scope ! null) { String methodId mapperClass.getName() . method.getName(); cache.put(methodId, scope); } else { // 方法可能存在重载用参数类型列表做后缀 cache.put(buildMethodId(mapperClass, method), null); } } } } } }这样拦截器里的needProcess就变成一次 Map 查询耗时可以忽略。这个方法还有一个额外收益拦截器里不再需要 import 具体的 Mapper 类新增权限控制时只需要加注解不用改拦截器代码。这一点在多人协作的大项目里非常重要否则每个人加一个权限控制都要动核心拦截器冲突概率陡增。4.4 规则表达式的边界DISTINCT、GROUP BY 和 UNION 怎么办权限条件不是简单的「拼到 SQL 尾部就完事」遇到GROUP BY、DISTINCT、UNION时拼接位置必须跟着变化。最稳妥的判断逻辑是先用 JSqlParser 把 SQL 解析成Select对象找到它的where节点把条件追加到where表达式的尾部。如果原 SQL 没有 where就新创建一个。public String buildSql(String originalSql, DataScopeRule rule) { Select select (Select) CCJSqlParserUtil.parse(originalSql); PlainSelect plainSelect (PlainSelect) select.getSelectBody(); if (plainSelect.getWhere() null) { plainSelect.setWhere(new HexExpression(rule.toSqlCondition())); } else { AndExpression and new AndExpression( plainSelect.getWhere(), new HexExpression(rule.toSqlCondition())); plainSelect.setWhere(and); } return select.toString(); }这段代码的核心思想是把 SQL 当成结构化对象处理而不是字符串。JSqlParser 能自动识别子查询里的 where普通字符串拼接做不到这一点。代价是项目需要额外引入一个解析库但对数据权限这种对正确性要求极高的功能这个代价是值得的。后面避坑章节里的很多问题根源都是「把 SQL 当字符串处理」能早换解析库就早换。5. 数据权限避坑指南五个让权限条件悄悄失效的瞬间5.1 坑一分页插件与数据权限拦截器的执行顺序打架现象列表接口开了数据权限后明细数据确实被过滤了但总记录数没变。用户翻到最后一页时发现页数变少偏偏导出的文件里混进了越权数据。原因分页插件也是靠拦截器改 SQL 实现的它会把原 SQL 改写成SELECT COUNT(*)。如果数据权限拦截器在它之后执行权限条件会被拼到 count SQL 上但拼的位置不一定对更常见的是两个拦截器的顺序导致权限条件只作用在分页插件改完后的 SQL 上而 count 语句先被执行了总数自然不对。解决给两个拦截器显式声明顺序。Spring Boot 下用Order控制插件加载次序如果分页插件先执行数据权限拦截器再去处理时拿到的是分页插件改造后的 SQL此时再拼权限条件才正确。实际排查时第一步先打印最终执行 SQL确认权限条件出现在LIMIT之前且 count 语句里也有它。5.2 坑二字符串定位 WHERE 把条件拼进子查询现象所有单表查询都正常一到带子查询的复杂 SQL 就失效用户能看到范围外数据日志里也没报错。这类问题最阴险因为它不崩、不报错只会在数据权限测试时暴露。原因很多初版实现都像我第 3 章的简化代码一样用indexOf(where)定位位置。只要 SQL 里存在子查询比如WHERE amount (SELECT MAX(amount) FROM ...)第一个 where 往往属于子查询内部权限条件被拼到子查询的 where 上主表完全没有过滤。解决放弃字符串定位用 JSqlParser 做语法树操作。主查询的 where 和子查询的 where 在语法树上是两个独立节点权限条件永远只加在主查询节点上。已经在生产跑的旧代码先用一段 SQL 回归用例把风险测出来入参固定、数据固定断言最终 SQL 里权限列出现在主查询条件部分而不是子查询部分。5.3 坑三ThreadLocal 没清理线程池复用时用户 A 看到了用户 B 的数据现象压测或高并发环境下用户 A 登录后查询正常后续请求有时会查出用户 A 的数据更隐蔽的是用户 B 的请求偶尔混进来 A 的权限条件导致 B 只能看到 A 的数据。原因绝大多数服务都在用线程池处理请求。用户 A 的请求在线程 T 里执行后DataScopeContext没被 remove线程 T 被归还线程池下一个用户 B 的请求复用了线程 T从 ThreadLocal 里读到 A 的规则。解决在拦截器入口处先判断当前线程的规则与请求用户是否一致不一致就重新加载在 Filter 的 finally 里调用DataScopeContext.clear()。开发期最好配置线程池的removeOnCancelPolicy也不够最可靠的还是「谁设置、谁清理」原则。这条是数据权限最严重的线上隐患宁可查询返回空集也不能让规则串到其他用户身上。5.4 坑四权限列没有索引条件加上后查询直接慢十倍现象某报表查询原来 200ms加上数据权限条件后涨到 2 秒以上数据库 CPU 飙升。原因权限条件的核心是通过dept_id IN (...)或owner_id ?过滤如果这两列上没有索引数据库需要先做全表扫描再逐行过滤。如果还用了LIKE %父id%来查询下级部门加上前导百分号会让索引彻底失效。解决给权限列建组合索引比如(dept_id, create_time)或(owner_id, status)。对于大表把IN (子查询)改成EXISTS关联通常能省一次子查询的构建成本实测对 MySQL 5.7 以上有明显改善。另外权限条件中的值如果不是来自配置表而是来自外部输入还要注意 SQL 注入风险下面单独说。5.5 坑五Mapper 参数名被编译期擦除权限条件在动态 SQL 里直接不生效现象XML 里写if testdeptId ! null明明入参deptId传了值条件就是不进入 SQL数据权限的拼接逻辑也没执行。原因JDK 8 默认编译出的 class 文件不带方法参数名Mybatis 在运行时拿到的参数名是arg0、arg1。如果 Mapper 接口方法里没写Param(deptId)test表达式拿到的就是 null动态 SQL 判断短路权限条件被跳过。解决第一给 Mapper 方法参数加Param(deptId)显式命名第二在 Maven 的maven-compiler-plugin配置中加入-parameters参数让编译期保留参数名第三在拦截器日志里打印最终 SQL确认权限条件是否真的出现。这类问题在 Java 面试题里经常被包装成「mybatis 条件不生效」来问其实根源就是参数名解析规则。6. 验证与进阶用测试证明数据权限生效顺便绕开 SQL 注入数据权限这种东西靠肉眼 review 永远不够必须靠自动化测试。我的做法是给每个 Mapper 接口建一个权限测试类准备三个用户一个顶级管理员、一个部门经理、一个普通员工各自跑同一条查询断言返回结果集合符合预期。测试里最好加上 SQL 断言开启 Mybatis 的日志输出或者拿到拦截器改写后的最终 SQL用assertTrue(sql.contains(dept_id IN))验证条件确实拼接成功防止动态 SQL 静默短路。另一个值得做的进阶是把权限条件中的值改为参数绑定而不是直接拼字符串。之前示例代码里为了方便阅读直接把deptId拼进了 SQL生产环境必须改成#{}参数占位符否则一旦权限规则的值来自外部配置或入参就会变成 SQL 注入的入口。实现方式是在重建 BoundSql 时把权限条件里的值包装成新的ParameterMapping追加到参数映射列表中。这样即使权限范围内的部门 id 被恶意篡改也只会得到参数绑定错误而不会执行恶意 SQL。我现在的习惯是每次新增或调整 Mapper 的查询先跑一遍权限测试类再跑一遍性能基线确认加了权限条件后执行计划没有出现全表扫描。这个动作已经成了我所有 Mybatis 项目的固定项目。数据权限不是那种「写完就能放」的功能它需要持续维护但一旦把拦截器、元数据缓存和测试链路搭完整后面每一次加新表、新查询都只是加一个注解的事。希望帮到你。本文还有配套的精品资源点击获取