MyBatis 拦截器
MyBatis 拦截器用于在 SQL 执行流程中插入自定义逻辑,例如分页、SQL 审计、数据权限、慢 SQL 记录、字段加密等。
为什么需要拦截器
有些逻辑不是某一个 Mapper 独有,而是所有 SQL 都可能需要。例如慢 SQL 日志、租户过滤、分页改写、数据权限。如果每个 Mapper 方法都手写这些逻辑,会大量重复,而且容易漏。
拦截器的价值是把横切逻辑放到 MyBatis 执行链路中统一处理。
如果滥用拦截器,会出现两个后果:
- SQL 被隐式改写,业务排查时看到的 Mapper SQL 和最终执行 SQL 不一致。
- 拦截器影响范围过大,一个 bug 可能影响所有查询。
可拦截对象
MyBatis 插件可以拦截四类核心对象:
| 对象 | 作用 |
|---|---|
| Executor | 执行 SQL,处理查询和更新 |
| StatementHandler | 创建和处理 Statement |
| ParameterHandler | 设置 SQL 参数 |
| ResultSetHandler | 处理结果集映射 |
执行流程
flowchart TD
A[Mapper 方法调用] --> B[Executor]
B --> C[StatementHandler]
C --> D[ParameterHandler 设置参数]
D --> E[JDBC 执行 SQL]
E --> F[ResultSetHandler 映射结果]
F --> G[返回 Java 对象]拦截器本质上是代理增强。它会包装目标对象,在目标方法执行前后加入自定义逻辑。
插件原理
MyBatis 插件通过动态代理包装核心对象。当调用被拦截的方法时,会先进入插件的 intercept 方法,插件可以在 invocation.proceed() 前后增加逻辑。
flowchart TD
A["原始 StatementHandler"] --> B["Plugin.wrap 生成代理"]
B --> C["调用 prepare"]
C --> D["进入 Interceptor.intercept"]
D --> E["执行自定义逻辑"]
E --> F["invocation.proceed"]示例结构
@Intercepts({
@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class}
)
})
public class SqlLogInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 执行前逻辑
Object result = invocation.proceed();
// 执行后逻辑
return result;
}
}常见用途
分页
在 SQL 执行前改写 SQL,自动追加 limit。
数据权限
根据当前用户的数据范围,给 SQL 自动追加部门、租户等过滤条件。
SQL 日志
记录 SQL、参数、耗时,帮助排查慢查询。
注意事项
- 拦截器会影响所有匹配 SQL,逻辑必须足够谨慎。
- 改写 SQL 时要考虑不同数据库方言。
- 不要在拦截器中执行复杂远程调用。
- 多个拦截器有执行顺序,顺序不同可能结果不同。
拦截器到底拦在哪里
MyBatis 执行一条 SQL 时,大致经过:
flowchart TD
A["Executor.query/update"] --> B["StatementHandler.prepare"]
B --> C["ParameterHandler.setParameters"]
C --> D["JDBC 执行"]
D --> E["ResultSetHandler.handleResultSets"]四个可拦截点分别适合不同事情:
| 拦截点 | 适合做什么 | 不适合做什么 |
|---|---|---|
Executor | 统计 SQL、审计入口、统一拦截查询更新 | 直接拼复杂 SQL |
StatementHandler | 分页改写、SQL 改写、拿 BoundSql | 读取业务结果 |
ParameterHandler | 参数加密、脱敏、参数审计 | 做远程鉴权 |
ResultSetHandler | 返回结果脱敏、字段处理 | 大量复杂业务组装 |
为什么分页插件常拦 StatementHandler?因为这时已经有最终 SQL,可以根据数据库方言追加 limit、offset 或包一层分页 SQL。
插件链执行顺序
多个插件会形成代理链。顺序不同,结果可能不同。
flowchart TD
A["原始 StatementHandler"] --> B["插件1包装"]
B --> C["插件2包装"]
C --> D["插件3包装"]
D --> E["调用 prepare"]
E --> F["插件3 intercept"]
F --> G["插件2 intercept"]
G --> H["插件1 intercept"]
H --> I["原始方法"]如果一个插件先做分页,另一个插件再做数据权限改写,和先数据权限再分页,最终 SQL 可能不同。生产中要明确插件顺序,并写集成测试覆盖关键 SQL。
BoundSql 和最终 SQL
插件经常需要拿到 BoundSql:
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql();
Object parameterObject = boundSql.getParameterObject();BoundSql 里包含:
- 最终 SQL 文本。
- 参数映射列表。
- 参数对象。
- 动态 SQL 生成的附加参数。
注意:日志里打印 SQL 时,不能只打印 SQL 文本,也要打印参数,否则 ? 很难排查。也不要把身份证、手机号、token、密码明文打到日志。
分页插件原理
分页插件通常做两件事:
- 生成 count SQL,查询总数。
- 改写原 SQL,追加分页语法。
flowchart TD
A["原始查询 SQL"] --> B["分页插件拦截 StatementHandler"]
B --> C["生成 count SQL"]
C --> D["查询总数"]
B --> E["按数据库方言改写分页 SQL"]
E --> F["执行分页查询"]
D --> G["组装 Page 对象"]
F --> GMySQL 示例:
select id, asset_code
from asset
where dept_id = ?
order by create_time desc
limit ?, ?风险:
- count SQL 对复杂 join、group by、distinct 可能很慢。
- 深分页仍然慢,插件不能改变数据库跳过大量行的事实。
- 不同数据库分页方言不同。
- SQL 改写错误会影响所有分页查询。
数据权限插件原理
数据权限插件可能给 SQL 自动追加部门或租户条件:
select id, asset_code
from asset
where status = ?改写为:
select id, asset_code
from asset
where status = ?
and dept_id in (?, ?, ?)这种能力很强,也很危险:
- SQL 解析不能靠简单字符串拼接,复杂 SQL 容易改错。
- 管理员、系统任务、导出任务可能有不同权限规则。
- count SQL、分页 SQL、子查询都要考虑。
- 权限条件要能被索引支持,否则所有查询变慢。
商业建议:
- 优先在业务查询条件中显式表达权限。
- 插件适合做兜底或统一补充,不适合承载全部复杂权限模型。
- 对被改写 SQL 做日志和测试。
慢 SQL 插件 Demo
@Intercepts({
@Signature(type = StatementHandler.class,
method = "query",
args = {Statement.class, ResultHandler.class}),
@Signature(type = StatementHandler.class,
method = "update",
args = {Statement.class})
})
public class SlowSqlInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.currentTimeMillis();
try {
return invocation.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
if (cost > 1000) {
Object target = invocation.getTarget();
if (target instanceof StatementHandler) {
StatementHandler handler = (StatementHandler) target;
BoundSql boundSql = handler.getBoundSql();
System.out.println("slow sql cost=" + cost + "ms, sql=" + boundSql.getSql());
}
}
}
}
}真实项目中不要只 System.out.println,应该接入日志框架、traceId、接口名、Mapper 方法名、脱敏后的参数和耗时。
参数加密和脱敏
ParameterHandler 可以在参数设置前处理字段,例如手机号加密。
风险在于:
- 加密后查询方式会变化,模糊查询可能不可用。
- 加密字段需要统一算法和密钥管理。
- 日志打印不能泄露明文。
- 插件处理范围要可控,不能误处理所有字符串。
更常见做法是:核心加密逻辑放在明确的 TypeHandler、领域服务或数据库加密方案中,拦截器只做统一审计或兜底。
商业场景:医疗资产平台数据权限
需求:普通用户只能看自己科室资产,院级管理员可以看全院资产。
方案一:业务显式传条件。
where hospital_id = #{hospitalId}
<if test="deptIds != null and deptIds.size() > 0">
and dept_id in
<foreach collection="deptIds" item="deptId" open="(" separator="," close=")">
#{deptId}
</foreach>
</if>方案二:插件统一追加。
适合多系统统一兜底,但必须解决:
- 哪些 Mapper 需要数据权限。
- 哪些 Mapper 排除,例如字典表。
- 管理员如何放行。
- 子查询和别名如何识别。
- 如何保证索引支持权限字段。
在真实商业系统里,数据权限插件很少是“简单追加字符串”能搞定的,通常需要 SQL 解析器、注解标记、白名单、测试用例和审计日志。
常见坑
| 问题 | 后果 | 建议 |
|---|---|---|
| 拦截器里远程调用 | SQL 执行链路变慢甚至阻塞 | 不做远程调用 |
| 简单字符串改 SQL | 复杂 SQL 被改坏 | 使用解析器或显式查询条件 |
| 插件顺序不清楚 | 分页、权限、租户结果异常 | 固定顺序并测试 |
| 打印敏感参数 | 泄露手机号、身份证、token | 日志脱敏 |
| count SQL 自动生成不准 | 分页总数慢或错误 | 复杂查询手写 count |
| 插件写业务逻辑 | 隐式行为难排查 | 业务逻辑放 Service |
线上排查流程
flowchart TD
A["SQL 行为异常"] --> B["确认是否启用插件"]
B --> C["打印原始 SQL 和改写后 SQL"]
C --> D{"是分页问题吗"}
D -- "是" --> E["检查 count SQL、limit、排序索引"]
D -- "否" --> F{"是权限/租户问题吗"}
F -- "是" --> G["检查追加条件、别名、白名单"]
F -- "否" --> H["检查插件顺序和参数处理"]
H --> I["拿最终 SQL 去数据库 EXPLAIN"]面试标准回答
MyBatis 拦截器是通过插件机制对 Executor、StatementHandler、ParameterHandler、ResultSetHandler 四类核心对象做动态代理,在目标方法执行前后插入逻辑。分页插件通常拦截 StatementHandler,拿到 BoundSql 后生成 count SQL 并按数据库方言改写分页 SQL;慢 SQL、审计、数据权限、租户隔离也可以通过插件实现。拦截器属于横切增强,不能滥用,尤其不能在里面写复杂业务逻辑或远程调用。改写 SQL 要考虑方言、插件顺序、参数脱敏、复杂 SQL 和执行计划。更多 ORM 内容可以回到 ORM 面试题。
