
1. 问题引入一个看似简单却频繁踩坑的分页统计问题最近在项目里做代码Review又看到一个老生常谈的问题使用MyBatis-Plus的IPage进行分页查询时返回的total总数不对。开发同学信誓旦旦地说SQL在数据库里执行结果是对的但程序返回的分页对象里总记录数要么是0要么是一个奇怪的数字跟数据库里COUNT(*)的结果完全对不上。这问题我见过太多次了从新手到有一定经验的开发者都可能栽跟头。分页是后端开发中最基础的功能之一但正是这种基础功能一旦出问题往往是因为一些隐蔽的细节没处理好。今天我就结合最近遇到的几个典型案例把MyBatis-Plus分页total不正确的根因和解决方案彻底讲透。很多人以为用了MyBatis-Plus分页就是Page对象一传selectPage方法一调就万事大吉了。实际上MyBatis-Plus的分页插件PaginationInnerInterceptor在背后做了不少工作特别是那个total的获取它并不是魔法变出来的而是通过拦截你的查询SQL动态生成一条COUNT语句去执行的。这个过程里任何一个环节配置不当或者理解有偏差都会导致最终的总数出错。更麻烦的是有些错误在单表简单查询时不会暴露一旦查询变得复杂涉及多表关联、子查询或者特定的数据库方言时问题就冒出来了。所以这个问题不能简单地归咎于“框架有BUG”绝大多数时候是我们对框架的机制理解不到位。2. 理解MyBatis-Plus分页插件的工作原理与total生成机制要解决问题必须先理解原理。MyBatis-Plus的分页功能核心是PaginationInnerInterceptor这个拦截器。当你执行一个分页查询方法如baseMapper.selectPage(page, queryWrapper)时拦截器会进行一系列操作。2.1 SQL拦截与改写流程首先拦截器会识别出需要分页的查询语句。然后它会做两件主要的事情生成COUNT查询为了得到总记录数total拦截器会基于你的原始查询SQL动态生成一条对应的COUNT(*)查询语句。注意这里不是简单地在原SQL前加SELECT COUNT(*)而是需要进行一定程度的解析和改写以确保COUNT查询的语义正确。例如你的原SQL可能是SELECT u.*, d.dept_name FROM user u LEFT JOIN department d ON u.dept_id d.id WHERE u.status 1拦截器需要将其改写成SELECT COUNT(*) FROM user u LEFT JOIN department d ON u.dept_id d.id WHERE u.status 1。这个改写过程是关键。生成分页查询根据不同的数据库方言如MySQL, Oracle, PostgreSQL拦截器会将你的原始SQL改写成对应数据库的分页语法。比如MySQL是LIMIT ?, ?Oracle是使用ROWNUM。total的值就是执行第1步生成的COUNT查询所返回的结果。所以如果total不对问题一定出在COUNT查询的生成或执行环节。2.2setOptimizeCountSql配置的深层影响在PaginationInnerInterceptor的配置中有一个非常重要的方法setOptimizeCountSql(boolean optimizeCountSql)。它的默认值是true。这个配置项直接影响COUNT SQL的生成逻辑。当optimizeCountSql true默认拦截器会尝试“优化”COUNT查询。它会尝试移除原SQL中的ORDER BY子句因为排序对计数没有影响。更重要的是在一些特定情况下它可能会尝试进行更复杂的优化。但是这种自动优化是“启发式”的它基于拦截器对SQL的解析。如果SQL比较复杂例如包含复杂的子查询、UNION、或某些特定的函数拦截器的解析器可能无法准确理解SQL结构导致生成的COUNT SQL语义错误从而返回错误的计数结果。这是导致total不正确的一个非常常见的原因。当optimizeCountSql false拦截器将不会尝试优化COUNT SQL。它会采用一种相对“笨”但安全的方式将你的整个原始查询作为一个子查询在外面套一层SELECT COUNT(*) FROM ( ... )。例如SELECT COUNT(*) FROM (你的原始SQL) tmp_count。这种方式生成的COUNT SQL几乎总能保证语义正确因为它没有改变你原始查询的任何逻辑。但缺点是如果原始查询非常复杂或结果集很大这种方式的性能可能会有损耗因为数据库需要先完整执行一遍子查询尽管可能不取数据但执行计划可能更重。很多人在遇到total错误时盲目搜索解决方案看到“把optimizeCountSql设为false”就照做问题虽然可能解决但并没有理解为什么。实际上你应该先判断你的SQL是否属于“复杂查询”如果是那么关闭优化很可能是正确的选择。2.3 数据库方言与COUNT查询的兼容性另一个潜在问题是数据库方言。MyBatis-Plus需要知道你在用什么数据库才能正确地生成分页SQL和COUNT SQL。虽然大多数情况下框架能自动检测但在一些边缘场景或使用较老版本的驱动时可能会出现方言识别错误导致生成的SQL语法不符合当前数据库从而执行失败或结果错误。例如为Oracle生成的COUNT查询可能包含了MySQL的语法。3. 实战排查total不正确的常见场景与根因分析下面我们通过几个具体的场景来看看total到底是怎么“丢”的。3.1 场景一使用了GROUP BY或DISTINCT的聚合查询这是最经典的错误场景之一。假设我们有如下查询想按部门统计用户数并分页显示QueryWrapperUser wrapper new QueryWrapper(); wrapper.select(“dept_id, COUNT(*) as user_count”); wrapper.groupBy(“dept_id”); PageUser page new Page(1, 10); IPageMapString, Object result userMapper.selectMapsPage(page, wrapper);问题现象result.getTotal()返回的值可能不等于分组后的行数而是等于user表的总记录数或者是一个其他错误的值。根因分析当optimizeCountSql为默认的true时拦截器生成的COUNT SQL可能是SELECT COUNT(*) FROM user GROUP BY dept_id。这条SQL的执行结果是什么在MySQL中SELECT COUNT(*) FROM (一个GROUP BY查询)返回的是分组后的行数这看起来是对的不这里有个陷阱。拦截器生成的COUNT语句可能不会保留SELECT列表中的聚合函数和GROUP BY。一个更可能生成的、错误的COUNT SQL是SELECT COUNT(*) FROM user。它完全忽略了GROUP BY直接对user表进行计数结果自然就是表的总行数而不是分组后的数量。解决方案关闭COUNT SQL优化这是最直接的方法。配置PaginationInnerInterceptor时调用setOptimizeCountSql(false)。这样生成的SQL会是SELECT COUNT(*) FROM (SELECT dept_id, COUNT(*) as user_count FROM user GROUP BY dept_id) tmp_count此时数据库会先执行子查询分组聚合然后对外层结果计数得到的就是正确的分组行数。手动指定COUNT查询对于极度复杂的SQL或者对性能有极致要求的情况你可以放弃使用拦截器自动生成COUNT而是自己在XML中或使用Select注解编写一个专门的COUNT查询。在Service层先调用这个COUNT查询获取总数再调用分页数据查询。这给了你最大的控制权。注意方案1关闭优化在数据量巨大且分组聚合开销很大时可能会有性能问题因为数据库需要物化整个子查询的结果集。你需要根据实际情况权衡。3.2 场景二多表关联查询JOIN且存在一对多关系假设查询用户列表并关联查询用户所属的部门名称一个部门有多个用户。QueryWrapperUser wrapper new QueryWrapper(); wrapper.select(“u.*, d.name as dept_name”); wrapper.eq(“u.status”, 1); wrapper.leftJoin(“department d ON u.dept_id d.id”); PageUser page new Page(1, 10); IPageUser result userMapper.selectPage(page, wrapper); // 假设有对应的XML/注解SQL问题现象result.getTotal()返回的数量可能远大于实际的用户数量。根因分析当用户表和部门表进行LEFT JOIN时如果采用默认的COUNT优化生成的SQL可能是SELECT COUNT(*) FROM user u LEFT JOIN department d ON u.dept_id d.id WHERE u.status 1。这条SQL的计数单位是什么是连接后产生的笛卡尔积的行数。如果一个部门下有5个用户那么这5个用户记录在连接结果中部门信息是相同的但确实是5行。所以COUNT(*)得到的是用户数这看起来是对的等等这里有一个更隐蔽的坑。如果user表和department表之间存在一对多关系并且你的查询逻辑或数据导致了重复计数那么COUNT(*)就会大于实际用户数。更常见的问题是如果SQL写得不严谨或者拦截器在优化时处理SELECT列表出错可能会导致计数错误。但在这个简单例子中一对一的关联通常不会出错。然而在更复杂的多对多关联中错误就非常普遍。更深层的问题即使在这个简单场景下COUNT(*)的性能也可能不是最优的。因为JOIN操作本身有开销而计数我们其实只关心user表的行数。一个更好的实践是使用COUNT(u.id)并且确保索引有效。MyBatis-Plus的应对在optimizeCountSqltrue时拦截器会尝试进行优化。它会分析SELECT后的列如果发现查询的是u.*用户表的所有列它可能会尝试将COUNT查询优化为SELECT COUNT(*) FROM user u WHERE u.status 1即去掉JOIN因为JOIN部门表只是为了获取dept_name列而这些列在COUNT中不需要。但是这种优化依赖于拦截器能正确解析SQL。如果SQL是通过Select注解或XML文件编写的复杂语句拦截器可能无法准确识别主表导致优化失败或错误。解决方案检查并确认SQL首先确保你的关联查询本身是正确且高效的。对于计数思考是否真的需要关联。如果不需要可以在业务层分开处理。考虑关闭优化如果关联查询复杂且total持续出错将optimizeCountSql设为false是最稳妥的。让COUNT查询包含完整的JOIN逻辑虽然可能慢一点但能保证结果正确。使用DISTINCT如果确实存在一对多关系导致计数重复例如查询每个用户及其所有标签你需要在COUNT查询中使用DISTINCT。自动生成的COUNT很难做到这一点。此时要么关闭优化并在原始查询中处理好去重要么就使用手动COUNT查询。3.3 场景三在XML或注解中使用自定义的复杂SQL当你将复杂的SQL写在XML映射文件或Select注解中时MyBatis-Plus的分页拦截器仍然会工作但它处理这些“原生”SQL的能力是有限的。!-- UserMapper.xml -- select idselectComplexPage resultType... SELECT u.id, u.name, (SELECT COUNT(*) FROM order o WHERE o.user_id u.id) as order_count, d.name as dept_name FROM user u LEFT JOIN department d ON u.dept_id d.id WHERE u.status #{status} if testname ! null and name ! ‘’ AND u.name LIKE CONCAT(‘%’, #{name}, ‘%’) /if ORDER BY u.create_time DESC /select在Mapper接口中配合IPage参数使用IPageUserVO selectComplexPage(IPageUserVO page, Param(“status”) Integer status, Param(“name”) String name);问题现象调用此方法后传入的IPage对象中的total不正确或者分页条件LIMIT没有生效。根因分析total不正确拦截器需要解析这条复杂的SQL包含子查询、动态条件if。optimizeCountSql的优化逻辑很可能在这里“卡住”。它可能无法正确识别哪里是应该被COUNT的主体可能会错误地移除子查询或者无法处理动态SQL片段导致生成的COUNT SQL语法错误或语义错误。分页未生效这通常是另一个问题但也相关。拦截器需要将你的原始SQL包装成分页格式。如果SQL非常复杂例如包含多个UNION拦截器可能无法确定在哪里插入LIMIT子句导致分页失败返回所有数据。解决方案为复杂SQL关闭优化这是首要建议。在配置分页插件时针对这类在XML/注解中定义的、结构复杂的查询方法最安全的方式是全局或按需关闭optimizeCountSql。Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(); // 针对复杂SQL关闭优化是稳妥的选择 paginationInnerInterceptor.setOptimizeCountSql(false); // 可以设置数据库方言确保分页语法正确 paginationInnerInterceptor.setDbType(DbType.MYSQL); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; }手动分页如果连关闭优化后自动生成的SELECT COUNT(*) FROM (…)性能都无法接受或者SQL特性导致嵌套子查询计数方式不可用某些数据库对嵌套层数有限制那么就需要退回到手动分页。即在Service层显式执行两条SQL第一条是手动编写的、优化的COUNT查询第二条是包含了分页参数LIMIT的数据查询。然后将结果手动设置到Page对象中。这虽然麻烦但提供了最高的灵活性和可控性。使用InterceptorIgnore注解如果你某个方法完全不想被分页拦截器处理比如它是一个导出全部数据的方法可以在Mapper接口的方法上使用InterceptorIgnore(tenantLine “true”, paginationInner “true”)来忽略分页拦截。但这对解决total问题没有直接帮助主要用于控制拦截范围。3.4 场景四逻辑删除与多租户等插件的影响MyBatis-Plus的插件是链式执行的。如果你的配置中除了分页插件PaginationInnerInterceptor还配置了逻辑删除插件InnerInterceptor或多租户插件TenantLineInnerInterceptor那么执行顺序就变得重要。问题现象total数量与在数据库客户端中执行COUNT(*)且加上逻辑删除条件的结果不一致。根因分析假设查询未显式添加逻辑删除条件deleted0但配置了逻辑删除插件。插件执行顺序可能是逻辑删除插件 - 分页插件。逻辑删除插件会先修改原始SQL在所有查询条件中自动加上AND deleted0。然后分页插件拿到已经被修改过的SQL再去生成COUNT查询。 所以最终执行的COUNT查询是包含deleted0的结果是正确的。但是如果顺序反了或者开发者在查询条件中手动添加了关于deleted字段的歧义条件例如deleted1就可能导致插件间冲突最终COUNT查询的条件不符合预期。解决方案检查插件配置顺序确保逻辑删除、多租户等“数据过滤”型插件在拦截器链中先于分页插件添加。这样分页插件计算总数时基于的已经是过滤后的数据范围。interceptor.addInnerInterceptor(new TenantLineInnerInterceptor()); // 多租户第一 interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 攻击阻断 interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页最后或倒数避免手动干扰插件逻辑如果你使用了逻辑删除插件就不要再在QueryWrapper中手动添加eq(“deleted”, 0)除非你有特殊理由。手动添加可能会造成条件重复或冲突。调试在开发环境可以开启MyBatis的SQL日志mybatis-plus.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl观察最终执行的SQL语句确认COUNT查询的条件是否正确包含了逻辑删除等过滤条件。4. 系统性的解决方案与最佳实践配置经过上面的分析我们知道total问题多半出在COUNT SQL的生成环节。下面给出一个从配置到编码的完整解决方案。4.1 分页插件的推荐配置在你的Spring Boot配置类中这样配置分页插件能规避大部分常见问题Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 1. 添加其他插件如多租户、动态表名、逻辑删除自动填充等如果有 // interceptor.addInnerInterceptor(new TenantLineInnerInterceptor()); // 2. 添加分页插件并详细配置 PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(); // 设置数据库方言非常重要 paginationInnerInterceptor.setDbType(DbType.MYSQL); // 根据实际情况调整如DbType.ORACLE, DbType.POSTGRE_SQL // 设置请求的页面大于最大页后操作。true调回到首页false继续请求。默认false paginationInnerInterceptor.setOverflow(false); // 设置最大单页限制数量-1表示不受限制。防止恶意全表查询。 paginationInnerInterceptor.setMaxLimit(1000L); // 【核心配置】是否优化COUNT SQL。对于绝大多数项目建议先设为false以保证正确性。 paginationInnerInterceptor.setOptimizeCountSql(false); // 【可选】针对特定数据库的优化。例如当optimizeCountSql为true时对JsqlParser的优化配置。 // paginationInnerInterceptor.setProperties(...); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }关键点setOptimizeCountSql(false)。对于一个新项目或者一个已经出现total问题的项目我强烈建议先将此值设为false。这能立即解决因SQL优化错误导致的total不准问题。在确保所有分页功能正确运行后如果你确实关心COUNT查询的性能并且经过测试发现某些简单SQL在optimizeCountSqltrue时也能正确工作你可以考虑为这些特定的、简单的查询场景开启优化但这通常意味着你需要维护两套配置或更复杂的逻辑不推荐。正确性永远优先于性能。4.2 编写分页查询的代码规范明确主表在构建QueryWrapper时尽量让查询的主体明确。如果是单表查询直接用new QueryWrapperEntity()。如果是关联查询在select语句中明确主表别名这有助于拦截器在优化时识别。避免在分页查询中使用select *特别是在关联查询时。明确写出需要的字段例如select u.id, u.name, d.dept_name。这不仅能减少网络传输有时也能让COUNT优化更准确因为它知道哪些字段来自主表。对于极度复杂的查询使用手动分页不要试图让框架去做它不擅长的事情。如果SQL包含多个UNION、WITH子句CTE、窗口函数等老老实实在Service层写两个方法一个countByCondition一个selectByConditionWithPage。代码更清晰也更好维护。Service public class UserServiceImpl { public PageVOUserVO getComplexUserPage(PageParam param) { // 1. 手动查询总数 Long total userMapper.countComplexUsers(param.toCountQuery()); // 2. 手动查询分页数据 ListUserVO records userMapper.selectComplexUsers(param.toDataQuery()); // 3. 组装返回结果 PageVOUserVO pageVO new PageVO(); pageVO.setRecords(records); pageVO.setTotal(total); pageVO.setCurrent(param.getCurrent()); pageVO.setSize(param.getSize()); return pageVO; } }始终验证在开发阶段对于重要的分页接口一定要对比接口返回的total和你在数据库客户端中执行相应COUNT语句的结果是否一致。养成这个习惯能提前发现很多问题。4.3 高级场景自定义Count查询与性能优化当你关闭optimizeCountSql后对于数据量非常大的表SELECT COUNT(*) FROM (你的完整SQL) tmp_count这种写法可能会成为性能瓶颈因为子查询可能很重。此时你可以考虑自定义Count查询。MyBatis-Plus的Page对象有一个setSearchCount(false)方法可以告诉插件不要自动执行COUNT查询。Mapper public interface UserMapper extends BaseMapperUser { // 自定义的数据查询方法 IPageUserVO selectCustomPage(IPageUserVO page, Param(“query”) QueryParam param); // 自定义的COUNT查询方法方法名约定为 selectCustomPage_COUNT Long selectCustomPage_COUNT(Param(“query”) QueryParam param); }在XML中select id“selectCustomPage” resultType“...” SELECT ... FROM ... WHERE ... !-- 你的复杂查询可以包含分页参数 #{page.records} 等但通常这里不写LIMIT由拦截器加 -- /select select id“selectCustomPage_COUNT” resultType“java.lang.Long” SELECT COUNT(*) FROM ... WHERE ... !-- 你精心优化的、高效的COUNT查询可以去掉不必要的JOIN和ORDER BY -- /select在Service中public IPageUserVO getCustomPage(PageUserVO page, QueryParam param) { // 先查询总数 page.setSearchCount(false); // 关键禁用自动COUNT Long total userMapper.selectCustomPage_COUNT(param); page.setTotal(total); // 再查询数据 IPageUserVO result userMapper.selectCustomPage(page, param); // 因为page和result是同一个对象或者手动将数据set回去 return result; }这种方式分离了数据查询和计数查询让你可以为COUNT查询单独优化例如使用覆盖索引、简化查询条件等从而大幅提升性能。5. 诊断与调试当问题发生时如何快速定位即使遵循了最佳实践偶尔还是可能遇到问题。下面是一个系统的排查清单开启SQL日志这是最重要的第一步。在application.yml中配置mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl控制台会打印出MyBatis执行的所有SQL包括拦截器生成的COUNT SQL和分页SQL。仔细对比自动生成的COUNT SQL是否符合你的预期它的FROM和WHERE部分是否正确将它复制到数据库客户端中执行结果是否正确检查插件配置确认PaginationInnerInterceptor是否已成功添加到拦截器链中。检查optimizeCountSql、dbType等配置值。检查其他插件干扰确认是否有其他拦截器逻辑删除、多租户、数据权限在分页插件之前执行并检查它们是否修改了SQL导致分页插件拿到的原始SQL已经“面目全非”。简化查询如果是一个复杂查询出的问题尝试逐步简化它。先去掉JOIN再去掉GROUP BY再去掉子查询……直到total变正确。这样能帮你定位是SQL的哪一部分导致了拦截器解析失败。升级版本如果你使用的是较旧的MyBatis-Plus版本比如3.4.0之前分页插件在某些边缘场景的解析上可能存在已知问题。尝试升级到最新的稳定版如3.5.6很多历史BUG已被修复。查阅官方文档与IssuesMyBatis-Plus的GitHub仓库Issues里积累了大量的实际问题。用关键词如“分页 total 错误”、“optimizeCountSql”、“count join”进行搜索很可能找到与你相似的问题和解决方案。通过以上从原理到实践从配置到调试的完整梳理相信你再遇到MyBatis-Plus分页total不正确的问题时已经能够胸有成竹快速定位并解决了。记住框架是工具理解其工作原理和边界才能用得顺手避免踩坑。