做过后台管理系统的同行大多碰过这类需求:销售只能看自己跟进的客户,部门主管能看整个部门的,区域负责人能看下面所有团队的。第一次接到这种需求,很多人的做法是在每个 Mapper 的 SQL 后面手写and create_by = #{userId},一开始只有三五条查询还能忍,等业务铺开到几十个 Mapper、上百条 SQL,改一次规则就得翻遍整个代码库,漏掉一处就是线上越权事故。MyBatis 数据权限这套东西,本质就是把「谁能看到哪些行」这件事从散落的业务 SQL 里抽出来,收敛到拦截器统一处理。我这几年在几个中后台项目里反复做过这个功能,从最早手写 where 到后来写插件、调 JSqlParser、跟分页插件打架,坑踩得比较全。下面把整条链路拆开讲,包括拦截点怎么选、SQL 怎么改写、规则怎么建模、上线前要验证哪些边界,尽量给你一套能直接拿去改的方案。
1. 行级数据过滤为什么会落到 MyBatis 拦截器上
1.1 从「每条 SQL 手写条件」到统一拦截的演化路径
先说清楚数据权限到底在管什么。系统里的权限一般分四层:菜单权限决定用户能不能进某个页面,按钮权限决定页面上哪些操作能点,字段权限决定某个列要不要脱敏或者干脆不返回,行级权限决定同一条 SQL 查出来的结果集里,哪些行能被这个用户看到。前三层大多在网关、前端或者序列化层就能解决,唯独行级权限绕不开数据库,因为它必须转化成 SQL 里的过滤条件。
最早的写法是在业务代码里拼 SQL。比如客户列表查询,Service 层判断当前用户角色,如果是普通销售,就往 QueryWrapper 里塞一个create_by = currentUserId;如果是主管,塞一个dept_id in (...)。这种做法在小规模下没毛病,逻辑直观,调试方便。问题出在规模上:同一个customer表会被列表页、导出、统计报表、下拉选择器、定时任务、消息推送等十几个地方查询,每一处都得记得加条件。只要有一处忘了加,越权就发生了,而且这类漏洞很难被测出来,因为开发自己用超管账号测,永远看不到问题。
第二阶段是用 AOP 在 Service 方法上切,配合自定义注解。这个思路比手写进业务代码强一点,但 AOP 只能拿到方法参数,拿不到最终执行的 SQL。你要么约定好每个查询方法都得传一个DataScopeParam参数,由 AOP 去改写它,要么就得在 Service 里做二次过滤——二次过滤是这条路线上最大的陷阱:先把全量数据查出来,再在内存里按权限筛。数据量一大直接 OOM 不说,分页的 total 和实际返回条数必然对不上,前端分页器会显示「共 1000 条」但翻到第二页就空了。
第三阶段才是 MyBatis 插件。插件工作在 SQL 真正发给数据库之前,此时 SQL 还是一棵抽象语法树,你可以精确地往 where 里插条件,改完之后分页、count、排序全部自然生效。代价是你要理解 MyBatis 的代理链,还要处理各种语法变形,初期投入比前两种都大。但只要跨过这道坎,后面加规则、加表、加场景都是改配置的事,边际成本极低。
我个人的判断标准很简单:需要做行级权限的表超过 3 张,或者查询入口超过 10 个,就上插件;否则老老实实手写,别给自己找麻烦。插件不是银弹,它把复杂度从业务层挪到了框架层,这个复杂度不会消失,只是换了个地方。
1.2 四类数据权限的落点划分:菜单、按钮、字段、行
很多项目一开始把四类权限混在一个模型里设计,结果表结构越滚越乱。我的习惯是彻底分开,因为它们的生命周期和变更频率完全不同。
| 权限类型 | 控制粒度 | 典型实现位置 | 变更频率 |
|---|---|---|---|
| 菜单权限 | 页面级 | 路由表 + 前端路由守卫 | 低,随版本发布 |
| 按钮权限 | 操作级 | 权限码 + 后端接口注解校验 | 中,随需求迭代 |
| 字段权限 | 列级 | 序列化层 / 视图层 | 中,涉及合规时高 |
| 行级权限 | 数据行 | MyBatis 拦截器改写 SQL | 高,业务随时调 |
字段权限值得多说一句:它跟行级权限经常被放在一起讨论,但实现路径完全不同。字段脱敏(手机号中间四位打码、身份证隐藏)适合放在序列化层,用 Jackson 的自定义序列化器或者注解处理;字段隐藏(某些角色看不到成本价这一列)适合在查询列上做文章,属于 SQL 改写的一部分,但改的是 select 的列而不是 where。这两件事别混在一个拦截器里做,会让代码逻辑变得难以维护。
行级权限的模型通常是一个「数据范围」的概念,常见取值就那几种:全部数据、本部门数据、本部门及以下数据、仅本人数据、自定义部门集合。这个枚举几乎每个中后台系统都长得差不多,后面第 4 节会详细讲怎么建模和装配。
2. MyBatis 插件链路拆解:拦截点选错,后面全是白费功夫
2.1 四大可拦截对象与 Executor.query 的两个重载
MyBatis 允许拦截四个核心对象:Executor(执行器,负责整体调度和缓存)、StatementHandler(语句处理器,负责与 JDBC 交互)、ParameterHandler(参数处理器)、ResultSetHandler(结果集处理器)。做数据权限,99% 的情况选Executor。
原因在于缓存。MyBatis 有一级缓存(SqlSession 级别)和二级缓存(Mapper 级别),命中缓存时根本不会走到StatementHandler。如果你的拦截器挂在StatementHandler上,缓存命中的查询就绕过了权限过滤——这意味着同一个 SqlSession 里,前一个用户查过的数据可能被后一个用户直接读到。这是个非常隐蔽的越权路径,后面第 5 节还会展开。Executor.query在缓存判断之前被调用,挂在这里才能保证每次查询都经过权限改写。
Executor.query有两个重载签名,都得拦:
@Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}) }) public class DataScopeInterceptor implements Interceptor { // ... }只拦四参数版本会漏掉一类调用:MyBatis 在二级缓存场景下,CachingExecutor会先算好CacheKey和BoundSql,再调用六参数版本。分页插件、延迟加载、嵌套查询都有可能走六参数重载。两个都拦上,逻辑里做一下兼容即可,成本很低。
Interceptor接口就三个方法:intercept(Invocation)是主逻辑,plugin(Object target)返回代理对象,setProperties(Properties)用来接配置。intercept里通过invocation.getArgs()拿参数数组,四参数版本里args[0]是MappedStatement,args[1]是参数对象;六参数版本里args[5]直接就是BoundSql。
这里有个容易忽略的点:MappedStatement.getId()是形如com.xxx.mapper.CustomerMapper.selectPage的字符串,通过它可以反查到 Mapper 接口上的方法,进而读到方法或类上的自定义注解。这个能力很关键,它让你可以在 Mapper 方法上标注「这条 SQL 不做数据权限」或者「这条 SQL 的部门字段叫org_id」,而不必在拦截器里写一堆if (ms.getId().contains("xxx"))的硬编码判断。
反射读注解的代码大概长这样:
private DataScope findAnnotation(MappedStatement ms) { String id = ms.getId(); int idx = id.lastIndexOf('.'); if (idx <= 0) return null; String className = id.substring(0, idx); String methodName = id.substring(idx + 1); try { Class<?> clazz = Class.forName(className); for (Method m : clazz.getDeclaredMethods()) { if (m.getName().equals(methodName) && m.isAnnotationPresent(DataScope.class)) { return m.getAnnotation(DataScope.class); } } return clazz.getAnnotation(DataScope.class); } catch (ClassNotFoundException e) { return null; } }注意Class.forName在高频调用下是有开销的,建议用ConcurrentHashMap按ms.getId()缓存注解查询结果。另外getDeclaredMethods在方法重载时会返回多个同名方法,严格来说还得比对参数类型,但数据权限场景一般不会在同一个 Mapper 里写同名重载,忽略这一点通常没事,知道了就好。
2.2 插件注册顺序、代理链包裹方向与分页插件的冲突根源
这是我要重点讲的一块,因为分页插件和数据权限插件的顺序搞反,是这类功能上线后最常见的 bug 来源,症状是:列表数据被正确过滤了,但分页器显示的总数还是全量。
先理清 MyBatis 的插件包裹机制。Configuration.newExecutor里会调用interceptorChain.pluginAll(executor),这个方法遍历所有注册的拦截器,对目标对象依次做plugin.wrap。先注册的拦截器包在最内层,后注册的包在最外层。执行时,最外层的intercept先跑,内部调用invocation.proceed()时才进入内层。
分页插件(比如常见的 PageHelper 类实现)的工作方式是:拦截Executor.query,检测到分页参数后,先根据当前 SQL 生成一条select count(*) from (...)去查总数,然后把原 SQL 改写成带limit的形式再执行一次。它内部执行 count 查询时,是重新走executor.query(...)这个代理对象的,因此会再次穿过内层插件。
结论推导一下就清楚了:数据权限插件必须比类 PageHelper 的分页插件先注册,也就是在内层。这样分页插件做 count 查询时,调用穿过内层,权限条件被加上,count 才是对的。如果顺序反了,分页插件在外层先跑,它拿到的BoundSql还没被改写,生成出来的 count SQL 是裸的,总数就全了。
在纯 Spring Boot 项目里,拦截器是通过ConfigurationCustomizer或者SqlSessionFactoryBean.setPlugins注册的,列表顺序就是你添加到列表的顺序。如果你用的是 MyBatis-Plus,它的MybatisPlusInterceptor内部维护了一个List<InnerInterceptor>,顺序由addInnerInterceptor的调用顺序决定,规则也一样——数据权限的 InnerInterceptor 要加在分页之前。
我遇过一次更麻烦的情况:项目里同时接了分页插件、多租户插件、数据权限插件,三个都在改 SQL,结果租户条件加在了权限条件外面,where (dept_id = 1) and (tenant_id = 'A')语法上没问题,但多租户插件是用字符串拼接实现的,它不解析 AST,直接在最外层套了个select * from (原SQL) t where t.tenant_id = ...,把权限条件一起包进了子查询。这种组合场景建议只保留一个基于 AST 的改写插件,其他的用别的方式实现,否则叠加顺序会变成玄学。
3. 用 JSqlParser 改写 SQL:一个可运行的拦截器骨架
3.1 依赖版本选择与注解设计
解析和改写 SQL 这件事,纯字符串处理基本没有好下场。你可能觉得sql + " and dept_id in (...)"就够了,但遇到union、order by后面还有limit、原本已经有where、where条件里带括号分组这些情况,字符串拼接会一步步把你逼疯。用 JSqlParser 把 SQL 解析成 AST,改完再序列化回去,虽然也有坑,但至少坑是可预期的。
版本选择上直接说结论:4.6 版本是目前兼容性最好的选择。5.x 重写了 AST 结构(SelectBody被移除,PlainSelect挂在Select下面),网络上的大部分示例代码在 5.x 上跑不通,会报NoSuchMethodError。如果你项目里已经有别的组件依赖了 5.x,那就要按 5.x 的 API 重写,别混用。具体差异后面会提。
先定义注解。注解挂在 Mapper 接口的方法上,用来声明这条 SQL 参与哪些表的权限过滤、过滤字段叫什么:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DataScope { /** 需要过滤的表别名,空表示取主表 */ String alias() default ""; /** 部门字段名 */ String deptColumn() default "dept_id"; /** 创建人字段名 */ String userColumn() default "create_by"; /** 是否跳过 */ boolean ignore() default false; }alias为空时取主表(from后面的第一张表),这是最常见的情况。如果 SQL 是 join 形态,主表别名可能是c,那就标注@DataScope(alias = "c")。
3.2 从 MappedStatement 到 BoundSql 的改写与参数回填
核心流程分四步:拿到原始 SQL、解析成 AST、往 where 里塞条件、把新 SQL 回填到BoundSql。
拿 SQL 的方式取决于拦截的是哪个重载。四参数版本需要自己调ms.getBoundSql(parameterObject),六参数版本直接从args[5]取。统一处理一下:
@Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0]; Object parameter = args[1]; BoundSql boundSql = args.length == 6 ? (BoundSql) args[5] : ms.getBoundSql(parameter); DataScope ds = findAnnotation(ms); if (ds != null && ds.ignore()) { return invocation.proceed(); } List<Long> scopeIds = DataScopeContext.get(); // 从上下文取权限范围 if (scopeIds == null) { return invocation.proceed(); // 超管或未登录,放行 } String original = boundSql.getSql(); String rewritten = SqlRewriter.rewrite(original, ds, scopeIds); if (rewritten == null || rewritten.equals(original)) { return invocation.proceed(); } BoundSql newBoundSql = new BoundSql( ms.getConfiguration(), rewritten, boundSql.getParameterMappings(), boundSql.getParameterObject()); // 关键:把原 BoundSql 的附加参数搬过去 for (ParameterMapping pm : boundSql.getParameterMappings()) { String prop = pm.getProperty(); if (boundSql.hasAdditionalParameter(prop)) { newBoundSql.setAdditionalParameter(prop, boundSql.getAdditionalParameter(prop)); } } if (args.length == 6) { args[5] = newBoundSql; } else { args[0] = copyMappedStatement(ms, newBoundSql); } return invocation.proceed(); }这里有两处必须解释清楚。
第一处是附加参数。BoundSql里除了parameterObject,还有一组additionalParameters,<foreach>生成的集合元素、动态 SQL 里塞进去的临时变量都存在这里。new BoundSql(...)的构造函数不会复制这个 Map,必须手动搬一遍。我见过有人漏了这一步,结果批量查询时#{}全部解析成 null,SQL 报「参数个数不匹配」,排查了大半天。
第二处是四参数版本的MappedStatement替换。BoundSql是在MappedStatement内部算出来的,四参数重载调用invocation.proceed()后,Executor会重新用ms.getBoundSql(parameter)算一遍,你改的那个newBoundSql根本没被用上。所以必须构造一个新的MappedStatement,把 SQL 源换掉。常见做法是通过MappedStatement.Builder重建,SqlSource换成一个返回你那个BoundSql的匿名实现:
SqlSource staticSource = new SqlSource() { @Override public BoundSql getBoundSql(Object parameterObject) { return newBoundSql; } }; MappedStatement newMs = new MappedStatement.Builder( ms.getConfiguration(), ms.getId(), staticSource, ms.getSqlCommandType()) .resource(ms.getResource()) .fetchSize(ms.getFetchSize()) .statementType(ms.getStatementType()) .keyGenerator(ms.getKeyGenerator()) .resultMaps(ms.getResultMaps()) .build();Builder上还有一堆可选项(timeout、keyProperty、resultSetType、databaseId等),严格来说都得复制过来,否则某些场景会出问题,比如主键回填失效。我一般写个工具方法把常用字段全搬一遍,宁可多写几行也别留隐患。
顺带说一句,还有一条更省事的路:反射直接改BoundSql的sql字段。BoundSql.sql是 private 的,但setAccessible(true)之后可以改。这种写法在很多开源实现里都能看到,好处是不用重建MappedStatement,additionalParameters也自动保留。坏处是依赖了 MyBatis 的内部字段名,升级版本时如果改名就会炸。如果你能锁死 MyBatis 版本(大部分项目都锁着),这条路更省心。
3.3 主表识别、JOIN、子查询与 UNION 的处理策略
SqlRewriter.rewrite是整个方案里逻辑最集中的部分。先看主干:
public static String rewrite(String sql, DataScope ds, List<Long> ids) { Statement stmt; try { stmt = CCJSqlParserUtil.parse(sql); } catch (JSQLParserException e) { // 解析失败不要抛异常打断业务,记日志后放行 log.warn("sql parse failed, skip data scope: {}", sql); return null; } if (!(stmt instanceof Select)) return null; Select select = (Select) stmt; SelectBody body = select.getSelectBody(); applyToSelectBody(body, ds, ids); return select.toString(); }几个关键决策点:
只处理 select。数据权限讨论的是「能看哪些数据」,update 和 delete 的越权防护应该走另一条线(比如在 Service 层校验目标记录归属),硬塞进同一个拦截器会让逻辑变得含混。当然,如果你的系统里存在「批量删除本部门数据」这种接口,那 delete 也得管,但那是另一个话题。
主表识别。PlainSelect.getFromItem()拿到的是FromItem,可能是Table,也可能是SubSelect(子查询当表用)。是Table才能加条件。别名优先取table.getAlias(),没有别名就用table.getName()。这里要小心 schema 前缀:db1.customer这种写法,getName()返回的就是db1.customer,拿它当别名去构造Column会生成db1.customer.dept_id,语义上不一定是错的,但很容易和别的表冲突。稳妥做法是取getName()里最后一个点之后的部分。
JOIN 的情况要分左右。如果权限表在主表上(from 后面的第一张表),条件加到 where 里完全没问题,语义是「先 join 再过滤」。但如果权限字段在被 left join 的右表上,把条件加到 where 会把左连接退化成内连接——因为 where 里对右表字段的判断会让null行被过滤掉。这种场景条件必须加到Join的on表达式里。实际项目里,权限字段 90% 在主表上,遇到右表的情况我一般建议直接改 SQL 结构,把权限表驱动出来,别在拦截器里搞太复杂的 join 重写。
子查询要不要递归处理。保守做法是不处理。where exists (select 1 from ...)里的子查询如果自己也查了一张带权限的表,理论上也该加条件,但实际上这种嵌套查询的语义千变万化,递归改写极易改错。我的做法是:拦截器只处理最外层的 from 主表,遇到复杂嵌套就抛日志,让开发在 Mapper 里显式处理。宁可漏一层让人发现,也别改错了让人查不出来。
UNION 的处理。SetOperationList里有多个SelectBody,要逐个applyToSelectBody。注意 union 之后的外层 where 是针对整个结果集的,条件加在每一段 union 分支里和加在最外层,语义可能不同(尤其带 order by limit 时)。默认按「每段都加」处理,这是最符合直觉的。
往PlainSelect里加条件的代码:
private static void addCondition(PlainSelect ps, String tableRef, String column, List<Long> ids) { String inClause = ids.stream() .filter(Objects::nonNull) .map(String::valueOf) .collect(Collectors.joining(",")); if (inClause.isEmpty()) inClause = "-1"; // 空集合用不存在的 id 兜底 Expression cond; try { cond = CCJSqlParserUtil.parseCondExpression( tableRef + "." + column + " in (" + inClause + ")"); } catch (JSQLParserException e) { throw new IllegalStateException(e); } Expression where = ps.getWhere(); ps.setWhere(where == null ? cond : new AndExpression(where, cond)); }注意这里我是把 ID 直接拼进 SQL 文本的,没有用?占位符。原因很实际:用占位符要同步往parameterMappings里插ParameterMapping,还要往additionalParameters里塞值,而parameterMappings是个List,顺序必须和 SQL 里?出现的位置严格对应。你的条件加在 where 里,而原 SQL 的 select 列、join on 里可能已经有?了,插入位置得精确计算——这套逻辑我写过一次,调试成本极高,而且一旦和分页插件、多租户插件叠加,位置就错乱了。
直接拼值的风险是 SQL 注入。但这里有个前提:拼进去的值必须是服务端产生的Long类型 ID 列表,绝对不能是前端传上来的字符串。上面代码里用String::valueOf转换Long,天然规避了注入。如果规则里的值可能是字符串(比如按租户编码过滤),那就必须做白名单校验或者转义,别图省事。这一点我在代码评审里卡过好几次,有人图方便把List<String>直接 join 进去,等于给系统开了个后门。
4. 数据范围的规则建模:本人、本部门、本部门及以下、自定义怎么表达
4.1 规则表结构与范围枚举的映射
权限条件不是凭空来的,得有个地方存「谁在什么角色下能看什么范围」。常见的建模是两张表:角色表上挂一个data_scope字段表示默认范围,再配一张role_dept表存自定义的部门集合。
data_scope用一个整型枚举,取值约定如下:
| 取值 | 含义 | 生成的 SQL 条件 |
|---|---|---|
| 1 | 全部数据 | 不加条件,直接放行 |
| 2 | 自定义部门 | dept_id in (自定义列表) |
| 3 | 本部门 | dept_id = 当前用户部门 |
| 4 | 本部门及以下 | dept_id in (当前部门 + 所有子部门) |
| 5 | 仅本人 | create_by = 当前用户ID |
「本部门及以下」是最费劲的一个,因为需要递归查部门树。两种做法:一是每次查询时递归查库(with recursive或者代码里循环查父级),开销大;二是把部门树的物化路径或者层级编码冗余到用户身上,查询时用like '前缀%'匹配。后者性能好但维护成本高,部门调整时要同步刷数据。中小规模的系统我推荐第一种,加上本地缓存(比如按部门 ID 缓存子部门列表,TTL 五分钟),实际压力完全可控。
这里有个经验点:「本部门及以下」算出来的部门 ID 集合可能有几百上千个,全塞进in里会生成一条巨长的 SQL。数据库解析长 SQL 本身就有开销,某些数据库对in列表长度还有硬限制。超过 500 个 ID 时我会改成dept_id in (select id from sys_dept where ancestors like '0,100,%')这种子查询形式,让数据库自己去走索引。具体阈值要压测,跟数据库类型、索引情况都有关,我给的是个经验值。
还有个容易被忽略的细节:权限范围的计算结果应该在一个请求内复用。一次列表查询会走 count 和数据两条 SQL,两条都得算一次部门树,白算一遍。用ThreadLocal在请求入口算一次,请求结束时清理,能省掉一半的重复计算。
4.2 上下文取值的三种来源与线程池传值的陷阱
拦截器需要知道「当前是谁」,这个信息有三个来源,各有各的坑。
第一种是 Spring Security 的SecurityContextHolder。干净、标准,但本质是ThreadLocal。一旦业务里用了@Async、线程池、CompletableFuture,子线程拿到的上下文是空的,权限条件直接变成「无条件」,等于给了一个越权入口。解决方案是用DelegatingSecurityContextExecutor包装线程池,或者手动在提交任务前把上下文拷进去。这一点必须在项目规范里写死,光靠代码评审挡不住。
第二种是业务自己维护的UserContext。通常在网关或者过滤器里解析 token,塞进ThreadLocal。灵活度高,可以放部门 ID、角色列表、数据范围枚举这些业务字段。风险跟第一种一样,而且如果你的过滤器是OncePerRequestFilter,在finally里必须remove(),否则线程复用时会串数据——Web 容器线程池复用极快,上一个用户的权限被下一个用户读到,这类事故排查起来极其痛苦。
第三种是显式传参。把权限范围作为方法参数一层层传下去,最后由 Mapper 的@Param接收,拦截器从parameterObject里取。完全没有线程安全问题,但侵入性极强,每个方法签名都要改,基本没人愿意这么干。
我的实践是第二种为主,配合三点加固:一是在finally里强制remove;二是提供一个DataScopeContext.callWith(...)的包装方法,供异步场景使用;三是在拦截器里做兜底判断——如果上下文为空且当前不是匿名接口,直接抛异常而不是放行。放行是最危险的选择,因为空上下文在开发环境可能只是因为没登录,在生产环境就可能是线程池丢值的信号。宁可让这次请求失败,也不要悄悄放出一条全量数据。
5. 上线前必须压测的边界场景:我踩过的六个坑
5.1 一级二级缓存导致的跨用户数据串号
前面提过拦截器要挂在Executor上,这里展开讲为什么。
MyBatis 一级缓存的作用域是SqlSession,在 Spring 集成环境下,如果没开事务,每次 Mapper 调用都是独立的SqlSession,一级缓存基本不生效,问题不大。但只要开了事务(@Transactional),整个事务期间共用一个SqlSession,一级缓存就真正生效了。此时如果同一个事务里先后用两个不同的权限上下文查询同一条 SQL,第二次查询会命中缓存,直接返回第一次的结果——权限条件根本没被执行。
这个场景在实际业务里并不罕见:一个定时任务遍历多个部门,每个部门查一次报表,如果整个任务包在一个事务里,第二个部门拿到的就是第一个部门的数据。或者一个接口内部先按当前用户查,再按某个指定用户查,也会串。
二级缓存更严重,它是Mapper级别的、跨SqlSession的,一旦开启,不同用户的查询结果会互相污染。做数据权限的系统,二级缓存基本应该关掉。真要开,CacheKey里必须包含权限上下文的指纹(比如当前用户 ID 和范围哈希),这就得改CacheKey的生成逻辑,成本不低。
我吃过一次一级缓存的亏:一个导出接口在事务里循环查了多个部门的数据,结果导出的 Excel 里全是第一个部门的数据,业务方以为是数据错了,查了一整天才定位到缓存。建议在拦截器里加一个开关,检测到当前有事务且权限范围发生变化时,主动clearLocalCache()。虽然会牺牲一点性能,但换来的是正确性。
5.2 IN 列表膨胀、多数据源与写操作是否纳入拦截
in列表膨胀前面提过,补充一点:如果权限范围是「仅本人」,条件就是create_by = 123,这个场景其实不需要in,生成等值条件效率更高,走索引也更稳。我一般会根据 ID 列表长度做分支:长度为 1 生成=,超过 1 生成in,超过阈值改子查询。这个优化看着小,但在百万级数据表上差别很明显。
多数据源是另一个雷区。项目里配了多个SqlSessionFactory,每个工厂有自己的Configuration和拦截器链。如果你只往主数据源的工厂上注册了拦截器,从库查询就完全绕过了权限。而且读写分离的场景下,主库写、从库读,读的那条路径默认是走从库工厂的。注册拦截器时要把所有SqlSessionFactory都覆盖到,或者用ConfigurationCustomizer这类全局机制统一注入。上线前一定要验证一遍从库查询的权限是否生效。
写操作要不要拦截,我的观点是默认不拦,但要有补偿机制。update 和 delete 走Executor.update,拦截它需要处理ParameterHandler的参数绑定,复杂度比 select 高不少。更实际的做法是在 Service 层做归属校验:删除前先按 ID 查一次,查到之后校验dept_id是否在权限范围内。这个校验逻辑可以做成一个通用工具方法,虽然要在每个写接口调一次,但逻辑清晰、易调试,比一个改 SQL 的拦截器好维护得多。
5.3 与租户隔离、逻辑删除、分页统计的叠加顺序
最后一个坑是多个「改 SQL」的组件叠加。现代中后台系统里,同时存在逻辑删除(deleted = 0)、多租户(tenant_id = 'A')、数据权限(dept_id in (...))三个条件的情况非常普遍。三个组件各自往 where 里塞条件,理论上用AndExpression拼接是安全的,因为它们都是 AST 节点,括号会自动处理。
但实际项目里,至少有一个组件是用字符串拼接实现的(比如某些租户插件直接往 SQL 尾部追加and tenant_id = 'x')。一旦混用,顺序就变得不可控:字符串拼接的插件如果执行在 AST 插件之前,它追加的条件会被 AST 解析进去,没问题;如果执行在之后,它可能追加在order by后面,直接语法错误。
我的建议是:三个条件统一在一个 AST 改写链路里完成,用一个拦截器,按固定的顺序依次追加条件。逻辑删除最简单,直接加and deleted = 0;租户隔离次之;数据权限最后。顺序固定了,就不会出现括号错乱、join 退化这类问题。
分页统计的问题前面讲过顺序,这里补充一个更细的点:某些分页插件的 count 优化会自动去掉order by,但如果你的 SQL 里有group by,count 会走select count(*) from (原SQL) t,此时子查询里的权限条件是跟着进去的,结果正确。但如果你的权限条件加在了having上(极少见),count 就可能不准。上线前的验证清单里,一定要包含「在 group by 查询上验证 total 与实际数据条数是否一致」,这一条能挡掉大部分分页相关的 bug。
我个人在实际操作中的体会是,数据权限这套东西最怕的不是实现难,而是「以为实现了」。每次上线前我都会跑一遍这个清单:列表分页 total 是否正确、导出是否和列表数据一致、从库查询是否生效、事务内多次查询是否串数据、异步任务里上下文是否丢失、超管账号是否真的能看全量、空权限用户是否看到 0 条而不是全量。这七条跑通,基本就能睡个安稳觉了。至于那些更复杂的嵌套查询和自定义 SQL,我宁可让开发在 Mapper 上标注@DataScope(ignore = true)然后手动处理,也不愿意让拦截器去做它看不懂的事情——一个静默改错的拦截器,比没有拦截器危险得多。