前言
面试官:用过pagehelper做分页把,你说一下pagehelper的分页实现原理。额...此时你只能说我不知道。如果你事先看了我接下来的这篇文章,相信你一定也把这个面试题答得很好。
认识Mybatis插件(拦截器)
SpringMVC的拦截器相信大家都清楚,作用是在Controller执行前或执行后拦截器请求从而实现对请求做增强,比如:登录检查等。在mybais中也有拦截器(当然它应该叫插件,我觉得叫拦截器更贴切),它拦截的是发向数据库的请求,达到增强Mybatis的目的,道理都差不多。
下面的文字是Mybatis官方文档对拦截器的描述
MyBatis 允许你在映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:
• Executor(update, query, flushStatements, commit, rollback,getTransaction, close,isClosed)
• ParameterHandler(getParameterObject, setParameters)
• ResultSetHandler(handleResultSets, handleOutputParameters)
• StatementHandler(prepare, parameterize, batch, update, query)
通过 MyBatis 提供的强大机制,使用插件是非常简单的,只需实现 Interceptor 接口,并指定想要拦截的方法签名即可
这句话告诉我们,Mybatis的拦截器会对四个类进行拦截,Executor执行器,ParameterHandler参数处理器,ResultSetHandler结果处理器,StatementHandler 语句处理器。待会儿带大家去看相关的源码。
PageHelper的使用和源码解析
PageHelper是一个分页插件,使用它我们在做分页查询的时候不需要编写查询总条数的SQL,以及查询列表的SQL不需要指定Limit 。我们只需要把分页条件交给PageHelper,它就可以帮我们自动生成查询总条数的SQL,以及在查询列表的SQL后面自动加上 Limit。PageHelper 就是一个Mybatis的拦截器。
下面我们来简单使用一下PageHelper,首先需要导入PageHelper的依赖
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper</artifactId>
<version>4.1.4</version>
</dependency>
第二步,在mybatis-config.xml配置插件 PageHelper
<plugins>
<plugin interceptor="com.github.pagehelper.PageHelper">
<!-- 指定方言,Mysql数据库 -->
<property name="dialect" value="mysql"/>
</plugin>
</plugins>
第三步,编写查询列表的SQL
<select id="selectList" resultMap="resultMap">
select * from student
</select>
第四步,使用PageHelper查询结果
@Test
public void testInterceptor() throws IOException {
//设置分页信息
Page page = PageHelper.startPage(1, 10, true);
//================================================================================
//加载配置
InputStream inputStream= Resources.getResourceAsStream("mybatis-config.xml");
//创建一个sqlSessionFactory
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
//创建SqlSession
SqlSession sqlSession = sqlSessionFactory.openSession();
//Page pageInfo = (Page)sqlSession.selectList("cn.whale.mapper.StudentMapper.selectList");
StudentMapper mapper = sqlSession.getMapper(StudentMapper.class);
//==================================================================================
//结果以Page来接收
Page pageInfo = (Page) mapper.selectList();
System.out.println(pageInfo.getTotal());
pageInfo.getResult().stream().forEach( System.out::println);
}
测试结果如下
看到这个结果是不是觉得很有意思,我并没有编写select count(0) from student
这条查询总条数的SQL,但是该SQL被执行了,同时还给查询列表的SQL增加了limit 分页条件。
接下来我们分析一下PageHelper的实现原理,首先看一下 com.github.pagehelper.PageHelper
这个类com.github.pagehelper.PageHelper
@Intercepts({
@Signature(
//拦截器Executor的query方法
type = Executor.class,
method = "query",
//方法的参数,拦截拥有这四个参数的query方法
args = {
MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
)})
public class PageHelper implements Interceptor {
//....省略....
}
首先 PageHelper 实现了 Interceptor接口,Mybatis的拦截器都需要实现该接口。类上通过@Intercepts指定了只需要拦截Executor执行器的 “query” 方法。
拦截器接口源码如下
public interface Interceptor {
//拦截器方法,拦截器的核心方法
Object intercept(Invocation invocation) throws Throwable;
//该方法的参数 target就是拦截器的目标类,plugin方法中需要对target做增强
Object plugin(Object target);
//设置属性,Properties是mybatis-config.xml对拦截器配置中的属性
void setProperties(Properties properties);
}
我们看一下 PageHelper 的三个方法,先看plugin方法 :com.github.pagehelper.PageHelper#plugin
public Object plugin(Object target) {
//如果是Executor就对target做增强,否则不做任何处理,直接返回target
if (target instanceof Executor) {
//把target交给Plugin.wrap ,this就是PageHelper拦截器类
return Plugin.wrap(target, this);
} else {
return target;
}
}
如果target是Executor就对target做增强,否则不做任何处理,直接返回target,看一下 Plugin.wrap做了什么
public class Plugin implements InvocationHandler {
//原生对象
private final Object target;
//拦截器
private final Interceptor interceptor;
//方法
private final Map<Class<?>, Set<Method>> signatureMap;
private Plugin(Object target, Interceptor interceptor, Map<Class<?>, Set<Method>> signatureMap) {
this.target = target;
this.interceptor = interceptor;
this.signatureMap = signatureMap;
}
//对target做增强,因为pageHelper只是拦截Executor,所以target是 Executor比如CacheingExector, interceptor是拦截器类即:PageHelper
public static Object wrap(Object target, Interceptor interceptor) {
//1.拿到拦截器类上的@Intercepts 指定的方法签名@Signature,即:要拦截器什么方法比如:query方法
Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
//拿到目标类的class,比如:org.apache.ibatis.executor.CachingExecutor
Class<?> type = target.getClass();
Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
if (interfaces.length > 0) {
//2.使用JDK动态代理为target生成代理类
return Proxy.newProxyInstance(
type.getClassLoader(),
interfaces,
//3.Plugin是一个 InvocationHandler
new Plugin(target, interceptor, signatureMap));
}
return target;
}
//对象执行会被invoke方法拦截到 ,比如在执行CachingExecutor#query方法的时候会被invoke方法拦截
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
//4.拿到执行到Method
Set<Method> methods = signatureMap.get(method.getDeclaringClass());
//5.判断当前方法是否包含在要拦截的方法中
if (methods != null && methods.contains(method)) {
//6.如果方法需要被拦截,调用拦截器的intercept方法,也就是PageHelper的intercept方法
//把拦截的原生对象,方法对象,参数对象封装成Invocation
return interceptor.intercept(new Invocation(target, method, args));
}
//7.如果方法没有被拦截,就直接调用
return method.invoke(target, args);
} catch (Exception e) {
throw ExceptionUtil.unwrapThrowable(e);
}
}
有学习过JDK动态代理的同学是能够看得懂上面的代码,使用Proxy为被拦截的对象生成代理类,并解析拦截器上的@Intercepts的@Signature属性来得到要拦截的方法。
当对象方法被执行就会被Plugin#invoke拦截到,Plugin是一个InvocationHandler,其中的invoke会拦截器对象方法的调用。在invoke方法中判断了方法是否需要拦截,如果需要调用interceptor.intercept对原生对象做增强。如果方法不需要拦截就直接调用方法,不走拦截器。
接下来代码走到 com.github.pagehelper.PageHelper#intercept
//Invocation 是对方法调用的封装,其中包括:target类 ;method ;arg参数
public Object intercept(Invocation invocation) throws Throwable {
//自动获取方言
if (autoRuntimeDialect) {
SqlUtil sqlUtil = getSqlUtil(invocation);
return sqlUtil.processPage(invocation);
} else {
//自动获取dialect,如果没有setProperties或setSqlUtilConfig,也可以正常进行
if (autoDialect) {
initSqlUtil(invocation);
}
//1.重点看这里,使用SqlUtil处理分页
return sqlUtil.processPage(invocation);
}
}
//处理分页
public Object processPage(Invocation invocation) throws Throwable {
try {
//2.处理分页
Object result = _processPage(invocation);
return result;
} finally {
//处理分页数据,分页数据是保存在SqlUtil中的 ThreadLocal<Page>中
clearLocalPage();
}
}
private Object _processPage(Invocation invocation) throws Throwable {
//方法参数
final Object[] args = invocation.getArgs();
Page page = null;
//支持方法参数时,会先尝试获取Page
if (supportMethodsArguments) {
page = getPage(args);
}
//分页信息,里面包括了pageNum和pageSize.默认是0到Integer的最大值
RowBounds rowBounds = (RowBounds) args[2];
//支持方法参数时,如果page == null就说明没有分页条件,不需要分页查询
//是否支持接口参数来传递分页参数,默认false。就是不支持我们自己传入分页参数
if ((supportMethodsArguments && page == null)
//当不支持分页参数时,判断LocalPage和RowBounds判断是否需要分页
|| (!supportMethodsArguments && SqlUtil.getLocalPage() == null && rowBounds == RowBounds.DEFAULT)) {
return invocation.proceed();
} else {
//不支持分页参数时,page==null,这里需要获取
if (!supportMethodsArguments && page == null) {
//【重点】我们自己没有传入分页对象,所以代码会走这里
page = getPage(args);
}
//【重点】 处理分页查询,该方法中会先查询count,再查询list
return doProcessPage(invocation, page, args);
}
}
在 PageHelper#intercept 拦截方法中调用了 SqlUtil.processPage 来处理分页,该方法中会判断是否支持接口传入分页对象来分页,默认是supportMethodsArguments=false。代码会来到 com.github.pagehelper.SqlUtil#getPage 获取分页对象Page
public Page getPage(Object[] args) {
//【重要】 这里是拿到分页信息,使用的一个ThreadLocal<Page>来存储的
Page page = getLocalPage();
...省略...
//分页合理化
if (page.getReasonable() == null) {
page.setReasonable(reasonable);
}
//当设置为true的时候,如果pagesize设置为0(或RowBounds的limit=0),就不执行分页,返回全部结果
if (page.getPageSizeZero() == null) {
page.setPageSizeZero(pageSizeZero);
}
return page;
}
在SqlUtil#getPage中,如果我们自己没有在方法中传入分页对象,那么会从 com.github.pagehelper.SqlUtil#LOCAL_PAGE 获取分页对象,它是一个ThreadLocal<Page>
, 那这个分页对象是在什么时候保存进去的呢?就是在我们最开始测试代码中执行Page page = PageHelper.startPage(1, 10, true);
的时候,就会把pageNum和pageSize封装成 Page对象存储到SqlUtil中的一个ThreadLocal<Page>
中。
拿到Page分页对象后,代码来到com.github.pagehelper.SqlUtil#doProcessPage ,处理分页查询,该方法中会先查询count,再查询list
private Page doProcessPage(Invocation invocation, Page page, Object[] args) throws Throwable {
//保存RowBounds状态
RowBounds rowBounds = (RowBounds) args[2];
//获取原始的ms
MappedStatement ms = (MappedStatement) args[0];
//判断并处理为PageSqlSource
if (!isPageSqlSource(ms)) {
//[重要]1.处理MappedStatment,因为要生成一个count语句所以该方法中会
//新建count查询和分页查询的MappedStatement ,id会加上_COUNT,比如:
//cn.whale.StudentMapper.selectList会变成cn.whale.StudentMapper.selectList_COUNT
processMappedStatement(ms);
}
//设置当前的parser,后面每次使用前都会set,ThreadLocal的值不会产生不良影响
//【重要】 给 PageSqlSource设置parse。PageSqlSource表示从 XML 文件或注释读取的映射语句的内容
//PageSqlSource中有一个 ThreadLocal<Parser> ,Parser是pagehelper提供的SQL解析器比如:MysqlParser
((PageSqlSource)ms.getSqlSource()).setParser(parser);
try {
//忽略RowBounds-否则会进行Mybatis自带的内存分页
args[2] = RowBounds.DEFAULT;
//如果只进行排序 或 pageSizeZero的判断
if (isQueryOnly(page)) {
return doQueryOnly(page, invocation);
}
//简单的通过total的值来判断是否进行count查询
if (page.isCount()) {
page.setCountSignal(Boolean.TRUE);
//替换MS
args[0] = msCountMap.get(ms.getId());
//【重要】查询总数
Object result = invocation.proceed();
//还原ms
args[0] = ms;
//【重要】设置总数
page.setTotal((Integer) ((List) result).get(0));
if (page.getTotal() == 0) {
return page;
}
} else {
page.setTotal(-1l);
}
//pageSize>0的时候执行分页查询,pageSize<=0的时候不执行相当于可能只返回了一个count
if (page.getPageSize() > 0 &&
((rowBounds == RowBounds.DEFAULT && page.getPageNum() > 0)
|| rowBounds != RowBounds.DEFAULT)) {
//将参数中的MappedStatement替换为新的qs
page.setCountSignal(null);
//拿到原本的SQL
BoundSql boundSql = ms.getBoundSql(args[1]);
//设置分页,信息,啊page总的开始也和每页条数设置到args[1]
args[1] = parser.setPageParameter(ms, args[1], boundSql, page);
page.setCountSignal(Boolean.FALSE);
//【重要】执行分页查询
Object result = invocation.proceed();
//【重要】得到处理结果
page.addAll((List) result);
}
} finally {
((PageSqlSource)ms.getSqlSource()).removeParser();
}
//返回结果
return page;
}
方法稍微比较复杂,总共做了这些事情
- processMappedStatement(ms); 新建count查询和分页查询的MappedStatement
((PageSqlSource)ms.getSqlSource()).setParser(parser); 给 PageSqlSource设置parse。PageSqlSource表示从 XML 文件或注释读取的映射语句的内容,PageSqlSource中有一个
ThreadLocal<Parser>
,Parser是Pagehelper提供的SQL解析器比如:MysqlParser
public class MysqlParser extends AbstractParser { @Override public String getPageSql(String sql) { StringBuilder sqlBuilder = new StringBuilder(sql.length() + 14); sqlBuilder.append(sql); //处理SQL的分页条件【重要重要重要】 sqlBuilder.append(" limit ?,?"); return sqlBuilder.toString(); }
在MysqlParser中处理了分页SQL,那么对于查询Count Sql是在哪儿生成的呢?是在com.github.pagehelper.parser.
SqlParser#getSimpleCountSql
中public String getSimpleCountSql(final String sql) { isSupportedSql(sql); StringBuilder stringBuilder = new StringBuilder(sql.length() + 40); stringBuilder.append("select count(0) from ("); stringBuilder.append(sql); stringBuilder.append(") tmp_count"); return stringBuilder.toString(); }
SqlParser存储在MysqlParser的父类AbstractParser中,他们的继承体系如下。
- Object result = invocation.proceed(); 查询总条数,然后把总条数设置给结果对象 page。该方法会触发
CachingExecutor#query
的调用,方法中会调用 PageSqlSource#getBoundSql 去生成查询count的SQL,而底层最终会调用SqlParser#getSimpleCountSql
解析查询count的SQL,然后执行count查询。 然后就是执行分页查询,一样是执行 invocation.proceed() ,在Page中有个countSignal信号来标记是该生成count的sql还是分页sql, 这一次依然会执行CachingExecutor#query,因为countSignal信号的改变,这次会触发
MysqlParser#getPageSql
拼接分页的SQL最后把条数和列表转到Page中返回,Page继承了ArrayList,对中总条数和数据列表进行了封装
public class Page<E> extends ArrayList<E> { private static final long serialVersionUID = 1L; /** * 页码,从1开始 */ private int pageNum; /** * 页面大小 */ private int pageSize; /** * 起始行 */ private int startRow; /** * 末行 */ private int endRow; /** * 总数 */ private long total; /** * 总页数 */ private int pages; /** * 包含count查询 */ private boolean count; //列表就是自己 public List<E> getResult() { return this; }
内容比较多,还是要稍微总结一下
- 首先我们通过 PageHelper.startPage(1, 10, true) 来指定分页信息,该分页会被封装成Page对象,保存到SqlUtil#LOCAL_PAGE 一个ThreadLocal总,方便后面查询的时候使用
- PageHelper提供了拦截器类 com.github.pagehelper.PageHelper 它是 Interceptor的子类 ,它通过 @Intercepts(@Signature 注解来指定只需要拦截器 Executor 的 query 方法。
- 在PageHelper中的plugin方法中为原生类也就是 Executor 生成代理。代理类通过Plugin#wrap来做增强,Plugin实现了InvocationHandler(JDK动态代理) ,复写了invoke方法。当 Executor被执行的时候会被invoke方法拦截。
- 在Plugin#invoke方法中会判断当前方法是否满足拦截条件(query方法),如果需要拦截就会执行 PageHelper#intercept方法去执行查询。
- 在PageHelper#intercept方法中会从SqlUtil#LOCAL_PAGE中拿到Page分页对象,然后会根据MappedStatement生成count和list的MappedStatement
- 接着会给MappedStatement的PageSqlSource设置MysqlParser解析器,在MysqlParser#getPageSql 中负责给SQL增加分页条件 limit。在MysqlParser的父类AbstractParser中包含了一个SqlParser ,它提供了getSimpleCountSql方法负责拼接查询count的sql。
- 当PageHelper#intercept 分页拦截器方法被执行,会先执行查询count的SQL,底层调用了SqlParser#getSimpleCountSql 来生成count的SQL。然后会执行查询list的SQL,底层调用了MysqlParser#getPageSql来获取给list增加分页limit。
- 最后把count和list封装到Page对象返回,Page本身继承了ArrayList。
Mybatis中的拦截器执行流程
上面我们详细分析了PageHelper的执行原理,现在我们来分析一下Mybatis是如何执行拦截器的。在第一章我们知道,mybatis-config.xml中配置的<plugin interceptor="com.github.pagehelper.PageHelper">
在执行SqlSessionFactoryBuilder.builder的时候,会通过XMLConfigBuilder解析<Plugins/>
,然后添加到Configuration中的InterceptorChain拦截器链中 ,见:org.apache.ibatis.builder.xml.XMLConfigBuilder#pluginElement
private void pluginElement(XNode parent) throws Exception {
if (parent != null) {
for (XNode child : parent.getChildren()) {
//拦截器
String interceptor = child.getStringAttribute("interceptor");
Properties properties = child.getChildrenAsProperties();
//实例化拦截器
Interceptor interceptorInstance = (Interceptor) resolveClass(interceptor).newInstance();
//设置属性
interceptorInstance.setProperties(properties);
//添加到拦截器链
configuration.addInterceptor(interceptorInstance);
}
}
}
上面我们有介绍过,拦截器可以拦截Executor 执行器;ParameterHandler 参数处理器;ResultSetHandler 结果处理器;StatementHandler 语句执行器。在创建这四种对象的时候会把对象交给 interceptor.plugin 方法做处理
。我们一个一个看,首先是在创建 Executor的时候Configuration#newExecutor
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
executorType = executorType == null ? defaultExecutorType : executorType;
executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
Executor executor;
if (ExecutorType.BATCH == executorType) {
executor = new BatchExecutor(this, transaction);
} else if (ExecutorType.REUSE == executorType) {
executor = new ReuseExecutor(this, transaction);
} else {
executor = new SimpleExecutor(this, transaction);
}
if (cacheEnabled) {
executor = new CachingExecutor(executor);
}
//重点,把CachingExecutor 交给 interceptorChain去处理
executor = (Executor) interceptorChain.pluginAll(executor);
return executor;
}
下面是pluginAll方法的代码org.apache.ibatis.plugin.InterceptorChain#pluginAll
public class InterceptorChain {
private final List<Interceptor> interceptors = new ArrayList<Interceptor>();
public Object pluginAll(Object target) {
for (Interceptor interceptor : interceptors) {
//把要拦截的对象交给interceptor.plugin
target = interceptor.plugin(target);
}
return target;
}
...省略...
比如对于PageHelper来说plugin方法就是通过代理的方式对target做增强。该方法有四个地方被调用,分别对应了四个对象的创建
下面是其他三个对象的创建方法,都在Configuration中
public ParameterHandler newParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
ParameterHandler parameterHandler = mappedStatement.getLang().createParameterHandler(mappedStatement, parameterObject, boundSql);
//交给拦截器去做处理
parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler);
return parameterHandler;
}
public ResultSetHandler newResultSetHandler(Executor executor, MappedStatement mappedStatement, RowBounds rowBounds, ParameterHandler parameterHandler,
ResultHandler resultHandler, BoundSql boundSql) {
ResultSetHandler resultSetHandler = new DefaultResultSetHandler(executor, mappedStatement, parameterHandler, resultHandler, boundSql, rowBounds);
//交给拦截器去做处理
resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler);
return resultSetHandler;
}
public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql);
//交给拦截器去做处理
statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);
return statementHandler;
}
那么拦截器是在什么时候执行intercept方法的呢?因为在拦截器的interceptor.plugin方法中我们为要拦截的对象做了代理,参考PageHelper。以CachingExecutor 为例,当CachingExecutor#query被调用就会触发 Plugin#invoke
,因为它本身是一个InvocationHandler
。在invoke方法中会真正触发interceptor.intercept
方法的调用。
定义拦截器做参数增强
我们这里来做一个案例,自定义拦截器给SQL传入一个条件参数把默认的参数替换掉,我的SQL如下
<select id="selectByUsername" resultMap="resultMap">
select * from student where username = #{username}
</select>
我的测试代码如下
List<Student> students =
sqlSession.selectList("cn.whale.mapper.StudentMapper.selectByUsername","ls");
students.stream().forEach( System.out::println);
记住我上面的参数传入的是“ls” , 然后定义拦截器
//拦截StatementHandler的prepare方法
@Intercepts(value = {
@Signature(type = StatementHandler.class, method = "prepare", args = {
Connection.class , Integer.class})})
public class MyInterceptor implements Interceptor {
private Properties properties;
@Override
public Object intercept(Invocation invocation) throws Throwable {
//获取statementHandler
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
//获取绑定sql
BoundSql boundSql = statementHandler.getBoundSql();
//获取参数
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
//筛选动态list参数,并加密后重新赋值
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
if (parameterMapping.getMode() != ParameterMode.OUT) {
String propertyName = parameterMapping.getProperty();
System.out.println("参数名 = "+propertyName);
Object parameterObject = boundSql.getParameterObject();
System.out.println("原本的参数 = "+parameterObject);
//替换参数值
parameterObject = properties.getProperty("username");
System.out.println("替换后的参数 = "+parameterObject);
boundSql.setAdditionalParameter(propertyName,parameterObject );
}
}
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
//只是对ParameterHandler做增强
if (target instanceof StatementHandler) {
return Plugin.wrap(target, this);
} else {
return target;
}
}
@Override
public void setProperties(Properties properties) {
//加载配置
this.properties = properties;
}
}
解释一下这个拦截器
- 拦截器上拦截的是StatementHandler 的 prepare 方法,拦截该方法可以对参数做修改
- 先在 plugin方法中我做了判断,只对 StatementHandler 做拦截,然后使用 Plugin.wrap 对 StatementHandler生成代理。
- 在 setProperties 方法中我们把拦截器的配置属性保存给Properties成员变量
- 在 intercept 方法中,我们拿到 boundSql,把Properties中的username值覆盖给boundSql中的参数值,完成参数的替换
测试效果如下
说在最后
其实我个人并不是很喜欢pagehelper这种分页插件,虽然可以自动生成分页sql来减少我们的工作量,但是它从某种程度上来说会影响性能。一是生成SQL需要时间,二是它自动生成的查询count的sql是根据查询list的sql来的,比如:查询list的sql关联了10张表,那么查询count的sql也会关联10张表。可能这并不是最优的SQL,所以我还是喜欢自己写分页查询自己控制SQL。
文章结束,喜欢就给我去点个五星好评吧。2021一路有你,2022我们继续加油!你的肯定是我最大的动力