MYBATIS · INTERCEPTOR · PLUGIN.WRAP · PAGEHELPER · MYBATIS-PLUS
插件是一层层套上去的,分页就是其中一层
MyBatis 的插件,就是给四个核心对象套上一层层 JDK 动态代理。分页插件也是其中一层:SQL 发出去之前,它先帮你查总数,再在 SQL 后面加上 LIMIT。
proceed() 交给里面一层。Interceptor你写的类。用 @Signature 说清楚拦哪个对象的哪个方法。
Plugin.wrap给对象套一层 JDK 动态代理。签名对得上才套,对不上原样返回。
org.apache.ibatis.plugin.PluginInterceptorChain按登记顺序一个个套。最后登记的在最外层,最先执行。
对象一造出来就套PageHelper · MyBatis-Plus也是一个插件。拦下查询,先 count、再改写成 LIMIT。
都靠 JSqlParser 改 SQLproceed() 就是往里走一层
JSqlParser 把 SQL 解析成语法树再改,比拼字符串可靠
RowBounds MyBatis 自带的分页,在内存里跳过
能插在哪:只有四个对象
MyBatis 在造这四种对象时,都会顺手调用一次 interceptorChain.pluginAll(…)。别的对象,插件碰不到。
query · update · commit · rollback查缓存、调度一次查询。分页插件拦在这里。
每开一个 SqlSession 造一次prepare · parameterize · query · update准备语句、执行。要改 SQL、统计慢 SQL,拦这里。
每执行一条 SQL 造一次setParameters填问号。参数加密可以拦这里。
跟着 StatementHandler 一起造handleResultSets把行变成对象。结果脱敏可以拦这里。
跟着 StatementHandler 一起造后三个每执行一条 SQL 就造一次,也就套一次。插件里别做重活,它会跟着每条 SQL 跑。
一个插件长什么样
一个统计查询耗时的插件。注解说要拦什么,intercept 里写要做什么,中间调一次 proceed()。
@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 TimerPlugin implements Interceptor {
@Override
public Object intercept(Invocation inv) throws Throwable { // ②
MappedStatement ms = (MappedStatement) inv.getArgs()[0];
long start = System.nanoTime();
try {
return inv.proceed(); // ③ 往里走一层
} finally {
log.info("{} 用时 {} ms", ms.getId(), (System.nanoTime() - start) / 1_000_000);
}
}
}
// ④ Spring Boot 里声明成 Bean,自动配置会把它装进 SqlSessionFactory
@Bean
TimerPlugin timerPlugin() { return new TimerPlugin(); }
StatementHandler h = (StatementHandler) inv.getTarget();
MetaObject meta = SystemMetaObject.forObject(h);
String sql = (String) meta.getValue("delegate.boundSql.sql");
meta.setValue("delegate.boundSql.sql", rewrite(sql)); // 用 JSqlParser 改
return inv.proceed();
套娃原理:三段源码
造对象时一层层套代理;调方法时,每层代理先看这个方法在不在自己的签名里,在就交给插件,不在就直接往里传。
// ① InterceptorChain:所有插件挨个套一层(Configuration.newExecutor 等方法里调用)
public Object pluginAll(Object target) {
for (Interceptor interceptor : interceptors) {
target = interceptor.plugin(target); // 默认就是 Plugin.wrap(target, this)
}
return target; // 最后登记的,套在最外面
}
// ② Plugin.wrap:签名里有这个接口,才套
public static Object wrap(Object target, Interceptor interceptor) {
Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor); // 读 @Signature
Class<?>[] interfaces = getAllInterfaces(target.getClass(), signatureMap);
if (interfaces.length > 0) {
return Proxy.newProxyInstance(target.getClass().getClassLoader(), interfaces,
new Plugin(target, interceptor, signatureMap)); // JDK 动态代理
}
return target; // 不相干,原样返回
}
// ③ Plugin.invoke:方法在签名里,才交给插件
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Set<Method> methods = signatureMap.get(method.getDeclaringClass());
if (methods != null && methods.contains(method)) {
return interceptor.intercept(new Invocation(target, method, args));
}
return method.invoke(target, args); // 不拦,直接往里传
}
试一试:登记顺序变了,套娃顺序就变了
项目里登记了四个插件:计时插件拦 Executor.query;审计插件只拦 Executor.update;SQL 日志插件只拦 StatementHandler;还有 PageHelper 的分页插件。调用一次 PageHelper.startPage(3, 10) 加 selectByStatus(1),看看会发生什么。
SqlLogPlugin 没套在 Executor 上:它的签名里只有 StatementHandler,Plugin.wrap 原样返回。它套在每次造出来的 StatementHandler 上。分页的五种做法,比一比
同一个需求:查 status = 1 的订单,每页 10 条。换个做法、换个页码,看发出去的 SQL 和数据库干的活有多少。
假设:t_order 里 status = 1 的有 1,000,000 行,有 (status, id) 联合索引,按 id 排序。
PageHelper 一次分页的经过
它靠一张贴在当前线程上的便签(ThreadLocal)知道“下一条查询要分页”。
PageHelper.startPage(3, 10) → ThreadLocalorderMapper.selectByStatus(1)PageInterceptor.interceptJSqlParser 改写MySqlDialect.getPageSqlPage<E> extends ArrayList<E>PageHelper.clearPage()SELECT id, order_no, amount FROM t_order WHERE status = ? ORDER BY id
SELECT count(0) FROM t_order WHERE status = ?ORDER BY 对计数没用,去掉了
SELECT id, order_no, amount FROM t_order WHERE status = ? ORDER BY id LIMIT ?, ?参数:1, 20, 10
和 MyBatis-Plus 的分页对比
| PageHelper | MyBatis-Plus | |
|---|---|---|
| 怎么说要分页 | PageHelper.startPage(),放进 ThreadLocal | 把 Page 对象当第一个参数传进去 |
| 拦在哪 | Executor.query | MybatisPlusInterceptor 拦 Executor 和 StatementHandler 的几个方法,分页交给其中的 PaginationInnerInterceptor |
| count 长什么样 | SELECT count(0) FROM … | SELECT COUNT(*) AS total FROM …,还会去掉用不上的 LEFT JOIN |
| 返回什么 | Page(就是个 ArrayList),常包成 PageInfo | IPage:records、total、pages |
| 要注意 | 只管紧跟着的下一条查询 | 要先注册拦截器 Bean,否则不分页 |
PageHelper.startPage(3, 10);
List<Order> list = orderMapper.selectByStatus(1);
PageInfo<Order> info = new PageInfo<>(list); // total、pages 都在里面
// 更保险的写法:查询包在 lambda 里,用完自动清掉
Page<Order> page = PageHelper.startPage(3, 10)
.doSelectPage(() -> orderMapper.selectByStatus(1));
@Bean
MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor i = new MybatisPlusInterceptor();
i.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return i;
}
Page<Order> page = orderMapper.selectPage(new Page<>(3, 10),
Wrappers.<Order>lambdaQuery().eq(Order::getStatus, 1));
越往后翻越慢:深分页
不管哪个分页插件,最后都是 LIMIT offset, size。翻到第 100000 页,数据库要先数过 999,990 行,再把它们丢掉。
SELECT id, order_no, amount FROM t_order
WHERE status = 1 AND id > #{lastId}
ORDER BY id
LIMIT 10;
-- 不管翻到哪,都只扫 10 行
-- 代价:只能下一页,不能直接跳到第 N 页
SELECT o.* FROM t_order o
JOIN (SELECT id FROM t_order
WHERE status = 1
ORDER BY id
LIMIT 999990, 10) t ON o.id = t.id;
-- 子查询只在索引里数,最后只回表 10 次
-- 还是要数过那些行,但每行便宜多了
count 也一样贵:每翻一页都要把 1,000,000 行数一遍。不需要总数时,关掉它:PageHelper.startPage(n, size, false),或者 MyBatis-Plus 里 page.setSearchCount(false)。
JDK 8 和 JDK 17:原理一样,依赖要换
插件机制是 MyBatis 3.5 本体的,两边完全一样。变的是分页插件的 starter,和它们依赖的 JSqlParser。
Spring Boot 2
JDK 8+- PageHelper 用 1.x 的
pagehelper-spring-boot-starter - MyBatis-Plus 用
mybatis-plus-boot-starter - MyBatis-Plus 3.5.9 起,分页插件要单独引入 JSqlParser 模块。JDK 8 引
mybatis-plus-jsqlparser-4.9
Spring Boot 3
JDK 17+- PageHelper 要用支持 Boot 3 的新版 starter(比如 2.x)。老版本的自动配置只登记在 spring.factories 里,Boot 3 不读,分页会悄悄失效,也不报错
- MyBatis-Plus 换成
mybatis-plus-spring-boot3-starter - 3.5.9 起引
mybatis-plus-jsqlparser,里面是 JSqlParser 5,要求 JDK 11+
常踩的坑
插件的坑多出在签名和顺序上,分页的坑多出在“到底分了哪一条查询”上。
便签只管紧跟着的下一条查询。中间先查了一下用户权限,被分页的就成了那条。让两者紧挨着,或者用 doSelectPage(() -> …)。
便签没被用掉,留在线程上,线程池把这个线程分给下一个请求时还在。在 finally 里调用 PageHelper.clearPage()。
Page 就是一个 ArrayList,转 JSON 时只剩元素。包成 PageInfo 或自己的分页对象再返回。
LIMIT 限制的是 JOIN 之后的行,一个订单有 3 条明细就占 3 行。先分页查主表的 id,再按 id 查明细。
没注册 MybatisPlusInterceptor 和 PaginationInnerInterceptor。3.5.9 以后还要引入 jsqlparser 模块。
@Signature 的 args 和接口方法对不上,多写或少写了一个参数类型。照着接口源码抄。
SqlSession 调的是 4 个参数的那个。Executor 内部再转调 6 参数版本时,是对象自己调自己,不经过代理。两个签名都写上。
它已经被别的插件套过一层,getTarget() 拿到的是代理。先顺着 h.target 一层层剥到真对象。
深分页。能只往后翻就按 id 翻;必须跳页,就用延迟关联。
不需要总数就关掉 count;需要的话,把 total 缓存一会儿,或者只在第一页查一次。