写分页接口时一行
PageHelper.startPage()搞定,但你有没有想过:分页插件是怎么在 SQL 执行前自动加上LIMIT和COUNT的?MyBatis 的拦截器链到底怎么工作的?MyBatis-Plus 的InnerInterceptor和原生 MyBatisInterceptor是什么关系?今天从底层机制到实战自定义,一次讲透。
一、从问题出发:分页插件做了什么?
sql
-- 你写的 SQL
SELECT * FROM t_order WHERE status = 'PAID' ORDER BY create_time DESC
-- 分页插件自动改写的 SQL
SELECT * FROM t_order WHERE status = 'PAID' ORDER BY create_time DESC LIMIT 10 OFFSET 0
-- 同时自动生成了 count 查询
SELECT COUNT(*) FROM t_order WHERE status = 'PAID'
一句话:分页插件在 SQL 执行前,拦截并改写了 Statement,加上了分页语法和 count 查询。
那它是怎么拦截的?
二、MyBatis 拦截器机制:一切的基础
2.1 四大可拦截对象
MyBatis 只允许拦截四种对象的方法:
scss
MyBatis 四大可拦截对象
│
├── 1. Executor
│ ├── update() ← INSERT/UPDATE/DELETE
│ ├── query() ← SELECT
│ ├── commit()
│ └── rollback()
│
├── 2. StatementHandler
│ ├── prepare() ← 创建 Statement(SQL 编译前)
│ ├── parameterize()← 设置参数
│ ├── query() ← 执行查询
│ └── update() ← 执行更新
│
├── 3. ParameterHandler
│ ├── setParameters() ← 设置 PreparedStatement 参数
│ └── getParameterObject()
│
└── 4. ResultSetHandler
├── handleResultSets() ← 处理结果集
└── handleOutputParameters()
| 对象 | 拦截时机 | 典型用途 |
|---|---|---|
| Executor | 执行前后 | 二级缓存、分页、慢 SQL 监控 |
| StatementHandler | SQL 编译前后 | SQL 改写、分页插件 |
| ParameterHandler | 参数设置 | 参数加密脱敏 |
| ResultSetHandler | 结果处理 | 结果映射增强、脱敏 |
2.2 自定义拦截器的本质
java
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare",
args = {Connection.class, Integer.class})
})
public class MyPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 拦截逻辑:在原方法执行前做处理
StatementHandler handler = (StatementHandler) invocation.getTarget();
String sql = handler.getBoundSql().getSql();
// 改写 SQL
String newSql = rewriteSql(sql);
// 通过反射设置新的 SQL
Field field = handler.getBoundSql().getClass()
.getDeclaredField("sql");
field.setAccessible(true);
field.set(handler.getBoundSql(), newSql);
// 继续执行原方法
return invocation.proceed();
}
}
2.3 拦截器链:责任链模式
scss
SQL 执行流程(经过拦截器链)
│
├── 1. MyBatis 创建 Executor
│
├── 2. 依次应用所有 Interceptor(Plugin.wrap 生成代理)
│ ├── Interceptor A (动态代理)
│ │ ├── Interceptor B (动态代理)
│ │ │ ├── Interceptor C (动态代理)
│ │ │ │ └── 真实 Executor
│ │ │ └── C.intercept()
│ │ └── B.intercept()
│ └── A.intercept()
│
└── 3. 最终执行 SQL
关键细节 :MyBatis 用 JDK 动态代理包装拦截器。每次 Plugin.wrap() 都创建一个新的代理对象,层层嵌套。
java
// MyBatis Plugin.java 源码简化版
public class Plugin implements InvocationHandler {
public static Object wrap(Object target, Interceptor interceptor) {
Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
if (signatureMap.containsKey(target.getClass())) {
return Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
new Plugin(target, interceptor, signatureMap)
);
}
return target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 如果当前方法在拦截范围内,调用 intercept()
// 否则直接调用原方法
if (signatureMap.get(method.getDeclaringClass()).contains(method)) {
return interceptor.intercept(new Invocation(target, method, args));
}
return method.invoke(target, args);
}
}
三、MyBatis-Plus 插件体系:在拦截器之上再抽象
3.1 为什么要再抽象?
原生 MyBatis 拦截器的问题:
sql
原生 Interceptor 的痛点
│
├── 1. 注册顺序 = 执行顺序,但多个插件间顺序很难控制
│
├── 2. 每个插件独立拦截,无法共享上下文
│ ├── 分页插件拦截了 query
│ ├── 乐观锁插件也拦截了 update
│ └── 两个插件互相不知道对方的存在
│
├── 3. 拦截点分散在四大对象上
│ └── 你需要在 Executor、StatementHandler 上各写一个 @Signature
│
└── 4. SQL 改写逻辑分散
└── 不同功能的 SQL 改写逻辑混在一个 intercept() 方法里
MyBatis-Plus 的解决方案:统一拦截点 + 插件链。
3.2 MybatisPlusInterceptor:统一入口
scss
MybatisPlusInterceptor 架构
│
├── MybatisPlusInterceptor(实现 MyBatis Interceptor)
│ ├── 拦截 Executor.query()
│ ├── 拦截 Executor.update()
│ └── 内部维护 List<InnerInterceptor>
│
└── InnerInterceptor(插件接口)
├── PaginationInnerInterceptor ← 分页
├── OptimisticLockerInnerInterceptor ← 乐观锁
├── BlockAttackInnerInterceptor ← 防全表更新删除
├── IllegalSQLInnerInterceptor ← SQL 注入防护
├── TenantLineInnerInterceptor ← 多租户
└── DataPermissionInterceptor ← 数据权限
java
// 注册示例
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1. 多租户插件
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(tenantLineHandler));
// 2. 数据权限插件
interceptor.addInnerInterceptor(new DataPermissionInterceptor(dataPermissionHandler));
// 3. 分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 4. 乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
// 5. 防全表更新删除
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
return interceptor;
}
注册顺序 = 执行顺序,这一点和原生 MyBatis 一致。
3.3 InnerInterceptor 接口
java
public interface InnerInterceptor {
// 查询前
default boolean beforeQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, BoundSql boundSql) {
return true; // 返回 false 则跳过原查询
}
// 查询后(处理结果)
default void afterQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, BoundSql boundSql) {}
// 更新前
default boolean beforeUpdate(Executor executor, MappedStatement ms,
Object parameter) {
return true;
}
// 更新后
default void afterUpdate(Executor executor, MappedStatement ms,
Object parameter) {}
// 准备 Statement 前
default void beforePrepare(StatementHandler sh, Connection connection,
Integer transactionTimeout) {}
// 获取 BoundSql
default void setProperties(Properties properties) {}
}
四、分页插件源码拆解
4.1 分页执行流程
ini
用户调用
│
├── 1. mapper.selectPage(page, wrapper)
│
├── 2. MybatisPlusInterceptor 拦截 Executor.query()
│ └── 遍历 InnerInterceptor 链
│ ├── TenantLineInnerInterceptor.beforeQuery() ← 加租户条件
│ ├── DataPermissionInterceptor.beforeQuery() ← 加权限条件
│ └── PaginationInnerInterceptor.beforeQuery() ← 分页处理
│
├── 3. PaginationInnerInterceptor 核心逻辑
│ ├── 3.1 判断是否需要分页(参数中有 IPage 对象?)
│ ├── 3.2 执行 COUNT 查询
│ │ ├── 改写 SQL 为 SELECT COUNT(*)
│ │ ├── 执行 count 查询
│ │ └── 设置 total 到 IPage
│ └── 3.3 改写原始 SQL 加 LIMIT/OFFSET
│
├── 4. 执行改写后的分页 SQL
│
└── 5. 将结果设置到 IPage 中
├── records = 查询结果列表
├── total = count 查询结果
├── size = 每页大小
└── current = 当前页码
4.2 SQL 改写核心
java
// 简化版分页 SQL 改写逻辑
public class PaginationInnerInterceptor extends JsqlParserSupport
implements InnerInterceptor {
@Override
public boolean beforeQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, BoundSql boundSql) {
// 1. 检查参数中是否有 IPage
IPage<?> page = findPage(parameter);
if (page == null) {
return true; // 不需要分页
}
// 2. 执行 COUNT 查询
if (page.isSearchCount()) {
long total = executeCount(executor, ms, parameter, boundSql);
page.setTotal(total);
if (total == 0) {
return false; // 没有数据,跳过查询
}
}
// 3. 改写 SQL 加 LIMIT
String originalSql = boundSql.getSql();
String paginationSql = originalSql + " LIMIT " + page.size()
+ " OFFSET " + (page.current() - 1) * page.size();
// 4. 替换 BoundSql 中的 SQL
resetBoundSql(boundSql, paginationSql);
return true;
}
}
实际实现比这复杂得多:MyBatis-Plus 使用 JSqlParser 解析 SQL,根据不同数据库方言生成不同的分页语法。
4.3 不同数据库的分页语法
sql
-- MySQL
SELECT * FROM t_order LIMIT 10 OFFSET 0
-- Oracle(12c 之前用 ROWNUM)
SELECT * FROM (
SELECT t.*, ROWNUM rn FROM t_order t WHERE ROWNUM <= 10
) WHERE rn > 0
-- PostgreSQL
SELECT * FROM t_order LIMIT 10 OFFSET 0
-- SQL Server
SELECT * FROM t_order ORDER BY id OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY
MyBatis-Plus 通过 DbType 枚举处理不同数据库方言:
java
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL);
// DbType.ORACLE, DbType.POSTGRE_SQL, DbType.SQL_SERVER2005 ...
五、自定义 InnerInterceptor:实战多租户数据隔离
5.1 需求
所有查询自动加上 tenant_id = ? 条件,业务代码完全不感知。
5.2 实现
java
public class TenantLineInnerInterceptor extends JsqlParserSupport
implements InnerInterceptor {
private TenantLineHandler handler;
@Override
public void beforeQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, BoundSql boundSql) {
// 使用 JSqlParser 解析 SQL,给所有表加上租户条件
Statement statement = CCJSqlParserUtil.parse(boundSql.getSql());
Select select = (Select) statement;
SelectBody selectBody = select.getSelectBody();
// 给 WHERE 条件追加 tenant_id = ?
if (selectBody instanceof PlainSelect) {
PlainSelect plainSelect = (PlainSelect) selectBody;
Expression tenantCondition = new EqualsTo(
new Column("tenant_id"),
new LongValue(handler.getTenantId())
);
if (plainSelect.getWhere() != null) {
plainSelect.setWhere(
new AndExpression(plainSelect.getWhere(), tenantCondition)
);
} else {
plainSelect.setWhere(tenantCondition);
}
}
// 替换 SQL
resetBoundSql(boundSql, select.toString());
}
}
效果:
sql
-- 你写的 SQL
SELECT * FROM t_order WHERE status = 'PAID'
-- 插件自动改写后的 SQL
SELECT * FROM t_order WHERE status = 'PAID' AND tenant_id = 10086
5.3 插件执行顺序的重要性
ini
场景:多租户 + 分页
│
├── 正确顺序:先租户 → 再分页
│ ├── 1. 加 tenant_id = 10086
│ ├── 2. 执行 COUNT(带租户条件)
│ ├── 3. 加 LIMIT(带租户条件)
│ └── 结果:分页正确,租户隔离正确
│
└── 错误顺序:先分页 → 再租户
├── 1. 执行 COUNT(不带租户条件!)
├── 2. 加 LIMIT(不带租户条件!)
├── 3. 加 tenant_id = 10086
└── 结果:COUNT 数字不对,分页数据可能跨租户
java
// 正确的注册顺序
interceptor.addInnerInterceptor(tenantInterceptor); // 先租户
interceptor.addInnerInterceptor(paginationInterceptor); // 再分页
六、防全表更新删除插件
java
// 防止全表 UPDATE / DELETE(没有 WHERE 条件)
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
sql
被拦截的 SQL 示例
│
├── DELETE FROM t_order; ← 无 WHERE,拦截!
├── UPDATE t_order SET status = 'X'; ← 无 WHERE,拦截!
└── DELETE FROM t_order WHERE id = 1; ← 有 WHERE,放行 ✅
源码逻辑 :JSqlParser 解析 SQL,检查 Delete 和 Update 语句是否有 Where 子句。
七、乐观锁插件
java
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
ini
乐观锁执行流程
│
├── 1. 查询时获取 version 值
│ SELECT id, name, version FROM t_product WHERE id = 1
│ → version = 3
│
├── 2. 更新时自动加上 version 条件
│ UPDATE t_product SET name = '新名称', version = 4
│ WHERE id = 1 AND version = 3
│ ↑ 自动加的
├── 3. 判断更新结果
│ ├── 影响行数 = 1 → 更新成功
│ └── 影响行数 = 0 → 版本冲突,更新失败
java
// 实体类
@Version
private Integer version;
一行注解,插件自动处理。
八、自定义插件实战:慢 SQL 检测
java
@Intercepts({
@Signature(type = StatementHandler.class, method = "query",
args = {Connection.class, Integer.class})
})
public class SlowSqlInterceptor implements Interceptor {
private long slowThreshold; // 慢 SQL 阈值(ms)
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = (StatementHandler) invocation.getTarget();
String sql = handler.getBoundSql().getSql();
long start = System.currentTimeMillis();
Object result = invocation.proceed();
long cost = System.currentTimeMillis() - start;
if (cost > slowThreshold) {
log.warn("慢 SQL 检测:cost={}ms, sql={}", cost, sql);
// 也可以发送到监控系统
}
return result;
}
}
但这有个问题:如果项目已经用了 MybatisPlusInterceptor,自定义 Interceptor 和 InnerInterceptor 的执行顺序怎么控制?
markdown
执行顺序规则
│
├── MyBatis 拦截器链的执行顺序 = Spring 容器中的注册顺序
│
├── MybatisPlusInterceptor 是一个 Interceptor
│ └── 它内部的 InnerInterceptor 链是自己的逻辑
│
└── 自定义 Interceptor 和 MybatisPlusInterceptor 是平级的
├── 如果你希望慢 SQL 检测在最外层
│ └── 先注册 SlowSqlInterceptor,再注册 MybatisPlusInterceptor
└── 如果你希望它在 MybatisPlusInterceptor 内部
└── 应该实现 InnerInterceptor,加到 MybatisPlusInterceptor 中
推荐做法:实现 InnerInterceptor,而不是单独写 Interceptor。
java
public class SlowSqlInnerInterceptor implements InnerInterceptor {
private long slowThreshold;
@Override
public void beforeQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, BoundSql boundSql) {
// 记录开始时间到 ThreadLocal
SlowSqlHolder.setStart(System.currentTimeMillis());
}
@Override
public void afterQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, BoundSql boundSql) {
long cost = System.currentTimeMillis() - SlowSqlHolder.getStart();
if (cost > slowThreshold) {
log.warn("慢 SQL:cost={}ms, sql={}", cost, boundSql.getSql());
}
SlowSqlHolder.clear();
}
}
九、InnerInterceptor 执行原理:委托模式
scss
MybatisPlusInterceptor.intercept() 源码简化
│
├── 拦截 Executor.query()
│ │
│ ├── for (InnerInterceptor inner : interceptors) {
│ │ if (!inner.beforeQuery(...)) {
│ │ return Collections.emptyList(); ← 某插件阻止查询
│ │ }
│ │ }
│ │
│ ├── result = invocation.proceed(); ← 执行真实查询
│ │
│ └── for (InnerInterceptor inner : interceptors) {
│ inner.afterQuery(...); ← 后置处理
│ }
│
└── 拦截 Executor.update() 类似
这就是"委托模式":MybatisPlusInterceptor 是唯一的外部拦截器,它把拦截逻辑委托给内部的一组 InnerInterceptor。
scss
对比
│
├── 原生 MyBatis
│ ├── 每个 Interceptor 都是独立的动态代理
│ ├── 代理嵌套层数 = Interceptor 数量
│ ├── 调试困难(代理套代理)
│ └── 顺序靠 Spring 容器注册顺序
│
└── MyBatis-Plus
├── 只有一个外部 Interceptor(MybatisPlusInterceptor)
├── 内部维护 InnerInterceptor 链
├── 前置/后置逻辑清晰
└── 顺序靠 addInnerInterceptor() 调用顺序
十、常见坑与最佳实践
坑 1:分页插件不生效
sql
原因排查清单
│
├── 1. 没注册 MybatisPlusInterceptor
│ └── 检查是否有 @Bean MybatisPlusInterceptor
│
├── 2. 没传 IPage 参数
│ ├── mapper.selectList(wrapper) ← 不分页
│ └── mapper.selectPage(page, wrapper) ← 分页
│
├── 3. 多数据源场景下 DbType 配置错误
│ └── PaginationInnerInterceptor(DbType.MYSQL)
│ 如果实际是 PostgreSQL,分页语法会出错
│
└── 4. 自定义 SQL(XML)中用了分页方言
└── XML 里手写了 LIMIT,插件又加了一层 → 双重 LIMIT
坑 2:多租户插件影响系统表
问题:租户条件加到了 sys_config 等系统表上
│
├── 原因:TenantLineInnerInterceptor 默认对所有表生效
│
└── 解决:配置忽略表
java
TenantLineInnerInterceptor tenantInterceptor =
new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
return new LongValue(10086);
}
@Override
public boolean ignoreTable(String tableName) {
// 系统表不加租户条件
return "sys_config".equals(tableName)
|| "sys_dict".equals(tableName);
}
});
坑 3:乐观锁和批量更新冲突
objectivec
问题:updateBatchById 批量更新时乐观锁不生效
│
├── 原因:批量更新是逐条执行,每条独立事务
│ 但版本号检查在每条 SQL 中独立完成
│
└── 解决:批量更新时手动处理版本号,或使用乐观锁 + CAS 重试
最佳实践 Checklist
| # | 实践 | 说明 |
|---|---|---|
| 1 | 插件注册顺序 | 租户 → 权限 → 分页 → 乐观锁 → 防全表 |
| 2 | 指定 DbType | 分页插件必须指定正确的数据库类型 |
| 3 | 配置忽略表 | 多租户插件要忽略系统表 |
| 4 | 优先 InnerInterceptor | 自定义插件实现 Inner 而非原生 Interceptor |
| 5 | 避免多重 LIMIT | 自定义 SQL 和分页插件别同时加 LIMIT |
| 6 | 监控慢 SQL | 用 InnerInterceptor 做 before/after 计时 |
| 7 | 分页查询合理设 limit | 避免深分页(offset 过大),考虑游标分页 |
十一、总结:插件机制全景图
bash
MyBatis-Plus 插件机制全景
│
├── 底层:MyBatis Interceptor
│ ├── JDK 动态代理
│ ├── 四大可拦截对象
│ └── 责任链模式
│
├── 中间层:MybatisPlusInterceptor
│ ├── 统一拦截入口
│ ├── InnerInterceptor 链
│ └── before/after 钩子
│
├── 内置插件
│ ├── PaginationInnerInterceptor ← 分页
│ ├── TenantLineInnerInterceptor ← 多租户
│ ├── DataPermissionInterceptor ← 数据权限
│ ├── OptimisticLockerInnerInterceptor ← 乐观锁
│ ├── BlockAttackInnerInterceptor ← 防全表
│ └── IllegalSQLInnerInterceptor ← SQL 安全
│
└── 自定义插件
├── 实现 InnerInterceptor
├── 注册到 MybatisPlusInterceptor
└── 注意顺序和边界条件
下一篇我们聊 Spring Boot 自动配置到底怎么做到的 ------不对,那个已经发了。下一篇是 Spring 事件机制与监听器模式:ApplicationEvent 到底怎么传播的?@EventListener 和 ApplicationListener 有什么区别?异步事件怎么配?------把 Spring 的"观察者模式"拆清楚。
本文是 Java 技术系列第 6 篇,系列目录: