尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

MyBatis-Plus分页排序失效的5大根源与实战解法

MyBatis-Plus分页排序失效的5大根源与实战解法 1. 分页排序不是“加个order by”就完事——MyBatis-Plus里最容易被忽视的执行路径你有没有遇到过这样的场景前端传了sortFieldcreateTimesortOrderdesc后端用MyBatis-Plus的Page对象封装分页参数调用page()方法查出来数据结果列表顺序完全不对刷新几次有时正序、有时乱序甚至同一次请求里前后两页的排序逻辑都不一致我第一次在生产环境踩这个坑时花了整整一个下午排查SQL日志、检查字段类型、核对数据库索引最后发现根本不是SQL写错了——而是压根没走数据库排序。MyBatis-Plus的分页排序表面看只是PageT page new Page(1, 10).addOrder(OrderItem.desc(create_time))这一行代码的事但背后实际存在两条完全独立的执行路径一条是数据库原生分页排序推荐另一条是内存分页排序危险。而绝大多数人写的代码默认走的是后者——尤其当你的项目里没显式配置分页插件或者用了不兼容的分页插件版本时。为什么说“默认走内存分页”因为MyBatis-Plus的IPage接口本身不强制要求数据库执行排序。它的addOrder()方法只是把排序条件存进Page对象的orders集合里后续是否生效完全取决于你使用的BaseMapper.selectPage()方法底层调用的是哪个分页插件。如果没配置PaginationInnerInterceptorMyBatis-Plus 3.4.0推荐或老版本的PaginationInterceptor那selectPage()就会退化为先查出全部数据无limit、再用Java Stream做sorted()处理——这在数据量超过几千条时不仅慢得离谱还会直接OOM。更隐蔽的问题在于Oracle、MySQL、PostgreSQL等不同数据库对ORDER BYLIMIT/OFFSET的语义支持并不完全一致。比如Oracle 12c之前的ROWNUM分页必须把ORDER BY写在子查询里否则排序会失效而MySQL 8.0之后支持窗口函数但如果你用的是LIMIT offset, size语法排序必须放在LIMIT之前否则数据库优化器可能重排执行顺序。MyBatis-Plus不会替你做这些适配——它只负责把OrderItem转成SQL片段最终执行权在数据库驱动和方言上。所以当你看到“分页时的排序”这个标题第一反应不该是“怎么写代码”而是先问自己三个问题当前项目用的是哪个分页插件版本是多少数据库连接URL里是否启用了正确的方言如?useSSLfalseserverTimezoneAsia/Shanghai对MySQL时区的影响Page对象创建后是否在调用selectPage()前就完成了addOrder()注意Page是不可变对象链式调用new Page().addOrder().addOrder()返回的是新实例原对象未修改我见过最典型的错误写法是PageUser page new Page(1, 10); page.addOrder(OrderItem.desc(status)); // ❌ 这行无效page对象本身没变 userMapper.selectPage(page, wrapper); // 实际查的是无排序的分页正确写法必须是PageUser page new Page(1, 10).addOrder(OrderItem.desc(status)); // ✅ 链式调用返回新实例 userMapper.selectPage(page, wrapper);提示MyBatis-Plus的Page类重写了addOrder()方法返回this以支持链式调用但很多开发者没注意到它的返回值类型是PageT而非void导致排序条件丢失却浑然不觉。2. 从零配置分页插件——为什么PaginationInnerInterceptor是唯一选择MyBatis-Plus官方文档里提到的分页插件有好几个老版本的PaginationInterceptor、Spring Boot Starter内置的自动配置、以及3.4.0之后主推的PaginationInnerInterceptor。但现实是如果你现在新建一个Spring Boot 2.7项目用mybatis-plus-boot-starter3.5.3.1不手动配置任何分页插件分页功能根本不会生效——selectPage()会直接返回空结果连SQL日志都不会打印。为什么因为PaginationInnerInterceptor不是自动注入的Bean它需要你显式声明。而老版本的PaginationInterceptor在3.4.0之后已被标记为Deprecated且存在严重线程安全问题它的localPage变量用ThreadLocal存储但在Spring AOP代理环境下事务拦截器和分页拦截器的执行顺序可能导致ThreadLocal被提前清理造成分页参数丢失。我们来对比一下两种插件的核心差异特性PaginationInterceptor已废弃PaginationInnerInterceptor当前标准线程安全依赖ThreadLocalAOP环境下易失效使用Invocation上下文传递参数无状态设计多数据源支持需为每个数据源单独配置Bean支持DS注解自动路由无需额外配置方言扩展需继承IDialect实现代码侵入性强提供DialectFactorySPI机制可动态注册方言性能开销拦截所有Executor.query()调用包括非分页查询仅当Page对象存在且size 0时才介入SQL重写所以第一步必须在Spring Boot配置类中声明PaginationInnerInterceptorBeanConfiguration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 关键必须添加分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }注意这里DbType.MYSQL的写法——它决定了SQL重写的方言。如果你用的是Oracle必须改成DbType.ORACLEPostgreSQL对应DbType.POSTGRE_SQLSQL Server对应DbType.SQL_SERVER。千万别写DbType.H2去测试MySQL环境H2的分页语法是LIMIT ? OFFSET ?而MySQL是LIMIT ?, ?参数占位符顺序不同会导致SQL报错。更关键的是PaginationInnerInterceptor的构造函数接受DbType但实际项目中往往需要动态识别数据库类型。比如多租户场景下不同租户可能用不同数据库。这时不能硬编码DbType.MYSQL而应该用DynamicTableNameInnerInterceptor配合TenantLineInnerInterceptor通过SPI机制动态加载方言Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 动态方言工厂根据当前数据源URL自动匹配DbType interceptor.addInnerInterceptor(new PaginationInnerInterceptor( new DialectFactory() { Override public IDialect getDialect(String url) { if (url.contains(mysql)) return new MySqlDialect(); if (url.contains(oracle)) return new OracleDialect(); if (url.contains(postgresql)) return new PostgreSQLDialect(); throw new RuntimeException(Unsupported database: url); } } )); return interceptor; }注意DialectFactory的getDialect()方法会在每次SQL执行前被调用所以必须保证其执行效率。我实测过用String.contains()比正则匹配快3倍以上且足够覆盖99%的JDBC URL格式。配置完插件后验证是否生效的最简单方法是开启MyBatis日志logging: level: com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor: debug启动项目调用一次分页查询你会在控制台看到类似这样的日志[DEBUG] PaginationInnerInterceptor - SQL before pagination: SELECT * FROM user WHERE status ? [DEBUG] PaginationInnerInterceptor - SQL after pagination: SELECT * FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY create_time DESC) AS _row_number_ FROM user WHERE status ?) tmp WHERE _row_number_ BETWEEN ? AND ?这说明分页插件已成功介入SQL重写。如果没看到SQL after pagination日志说明插件没生效——大概率是Bean没被Spring扫描到或者MybatisPlusInterceptor没被Bean声明。3. OrderItem的深层陷阱——字段名、别名、表达式排序的三重校验OrderItem.desc(create_time)看起来简单但背后藏着三个致命陷阱字段名大小写敏感、SQL别名冲突、表达式排序失效。我曾经在线上环境因为OrderItem.desc(userName)查不到数据排查了2小时才发现数据库字段是user_name下划线命名而实体类用了Lombok的Accessors(chain true)导致getter方法是getUserName()MyBatis-Plus默认按驼峰规则映射但OrderItem不走字段映射逻辑——它直接把字符串拼进SQL。3.1 字段名必须与数据库物理列名完全一致OrderItem的构造参数是纯字符串MyBatis-Plus不会做任何转换。假设你的实体类定义如下Data TableName(sys_user) public class User { TableId(type IdType.AUTO) private Long id; TableField(user_name) // 显式指定数据库列名 private String userName; private LocalDateTime createTime; }那么排序时必须写// ✅ 正确用TableField指定的数据库列名 page.addOrder(OrderItem.desc(user_name)); page.addOrder(OrderItem.asc(create_time)); // ❌ 错误用Java属性名数据库里没有userName字段 page.addOrder(OrderItem.desc(userName));为什么因为OrderItem最终生成的SQL片段是ORDER BY user_name DESC, create_time ASC它不经过MyBatis的ResultMap映射流程也不读取TableField注解——它就是字面量拼接。你可以打开MyBatis日志确认这一点 Preparing: SELECT * FROM sys_user ORDER BY user_name DESC, create_time ASC LIMIT ?3.2 多表关联时必须用表别名限定字段当使用QueryWrapper进行多表JOIN查询时OrderItem的字段名必须带上表别名否则数据库会报Column xxx in order clause is ambiguous。例如QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(u.status, 1) .like(u.nick_name, test) .select(u.*, d.dept_name) .from(sys_user u) .leftJoin(sys_dept d ON u.dept_id d.id); PageUser page new Page(1, 10).addOrder(OrderItem.desc(u.create_time)); userMapper.selectPage(page, wrapper);这里OrderItem.desc(u.create_time)中的u.是必须的。如果写成create_timeMySQL会报错因为sys_dept表也有create_time字段数据库无法确定该选哪个。3.3 表达式排序必须用RawOrderItemOrderItem只支持简单字段排序。如果你想按表达式排序比如“按姓名拼音首字母排序”就不能用OrderItem.desc(SUBSTR(name, 1, 1))——这会被当成字段名处理生成ORDER BY SUBSTR(name, 1, 1) DESC但MyBatis-Plus的SQL解析器会把它识别为非法字段抛出Unknown column SUBSTR(name, 1, 1) in order clause异常。正确做法是使用RawOrderItem// ✅ 正确RawOrderItem支持任意SQL表达式 page.addOrder(new RawOrderItem(SUBSTR(name, 1, 1) DESC)); page.addOrder(new RawOrderItem(CASE WHEN status 1 THEN 1 ELSE 0 END DESC));RawOrderItem的构造函数接受原始SQL字符串它会跳过所有字段校验直接拼接到ORDER BY子句中。但要注意表达式里的字段名仍需符合数据库规则如MySQL用反引号包裹含特殊字符的字段不要拼接用户输入防止SQL注入RawOrderItem不做过滤某些数据库不支持在ORDER BY里用复杂表达式如旧版SQLite需提前验证经验技巧我在做“按距离排序”功能时曾用RawOrderItem拼接ST_Distance_Sphere(POINT(?, ?), POINT(longitude, latitude)) ASC但测试发现MySQL 5.7不支持ST_Distance_Sphere在ORDER BY里直接计算必须先在SELECT里定义别名再在ORDER BY引用别名。所以最终方案是改用QueryWrapper.select()添加计算字段再用普通OrderItem排序。4. 真实业务场景复盘——如何解决“首页第一页正常第二页乱序”的诡异问题去年双十一前我们一个电商后台系统出现了一个诡异问题商品列表分页时第一页数据按sales_count DESC排序完全正确但第二页开始排序就乱了——有些销量高的商品出现在后面甚至同一页内商品销量值相同但顺序不稳定。运维同学第一时间怀疑是Redis缓存污染DBA检查了MySQL慢查询日志发现分页SQL执行时间都在5ms以内索引也完全命中。我们花了两天时间最终定位到问题根源分页查询的ORDER BY字段存在大量重复值且未添加唯一性字段作为第二排序条件。具体来说业务要求“按销量降序展示”但很多商品销量都是0新品导致ORDER BY sales_count DESC时所有销量为0的商品在数据库内部的物理存储顺序是不确定的。MySQL的LIMIT分页本质是“取前N条”当排序字段重复时数据库优化器可能按主键顺序、按插入顺序、甚至按内存页顺序返回结果——这就造成了分页结果的不确定性。解决方案非常简单但容易被忽略在ORDER BY里添加唯一性字段作为第二排序条件。我们选择了主键idPageProduct page new Page(current, size) .addOrder(OrderItem.desc(sales_count)) .addOrder(OrderItem.desc(id)); // ✅ 强制唯一排序生成的SQL变成SELECT * FROM product ORDER BY sales_count DESC, id DESC LIMIT ?, ?这样当sales_count相同时数据库会按id降序排列确保每次分页结果完全一致。但这里有个隐藏坑如果业务要求“销量相同时按上架时间升序”而create_time字段精度是秒级仍可能存在重复同一秒上架多个商品那就需要第三层排序.addOrder(OrderItem.desc(sales_count)) .addOrder(OrderItem.asc(create_time)) .addOrder(OrderItem.desc(id)) // 最终兜底更彻底的方案是在数据库层面给sales_count字段加唯一索引不现实或者用ROW_NUMBER() OVER (ORDER BY sales_count DESC, id DESC)生成稳定序号但这会增加查询开销。另一个常见场景是“按时间倒序但最新数据在最后一页”。比如日志系统要查最近7天的操作日志按create_time DESC分页但用户想看最早的日志即最后一页。这时如果直接Page(1, 10)只能拿到最新10条要拿到最早10条必须计算总页数// 先查总数 long total logMapper.selectCount(wrapper); int lastPage (int) Math.ceil((double) total / 10); // 再查最后一页 PageLog page new Page(lastPage, 10).addOrder(OrderItem.desc(create_time)); logMapper.selectPage(page, wrapper);但这个方案有性能问题selectCount()会触发全表扫描。更好的做法是用ORDER BY create_time ASC LIMIT 10查最早10条再反转列表——前提是业务允许“时间正序查再反转”。踩坑心得我在做金融风控系统时曾因没加第二排序字段导致同一笔交易在不同分页页码里出现两次数据库返回顺序不一致分页插件误判为不同记录。后来我们强制规定所有分页查询的ORDER BY必须包含主键且在SQL审核清单里列为必检项。5. 高阶实战动态排序、多字段组合、前端传参的安全解析真实业务中排序字段和方向往往由前端动态传入比如表格点击表头触发排序。这时不能简单地page.addOrder(OrderItem.desc(request.getSortField()))因为request.getSortField()可能是恶意字符串如id; DROP TABLE user; --。必须做严格校验。5.1 白名单校验——最安全的动态排序方案我们定义一个排序字段白名单枚举public enum SortField { CREATE_TIME(create_time), UPDATE_TIME(update_time), STATUS(status), SORT_ORDER(sort_order), USERNAME(user_name); private final String column; SortField(String column) { this.column column; } public static OptionalString getColumn(String field) { return Arrays.stream(values()) .filter(e - e.name().equalsIgnoreCase(field) || e.column.equalsIgnoreCase(field)) .map(e - e.column) .findFirst(); } }然后在Controller里解析PostMapping(/list) public ResultPageUser list(RequestBody UserQuery query) { PageUser page new Page(query.getCurrent(), query.getSize()); // 安全校验排序字段 String sortField query.getSortField(); String sortOrder query.getSortOrder(); if (StringUtils.isNotBlank(sortField) StringUtils.isNotBlank(sortOrder)) { OptionalString column SortField.getColumn(sortField); if (column.isPresent()) { OrderItem orderItem asc.equalsIgnoreCase(sortOrder) ? OrderItem.asc(column.get()) : OrderItem.desc(column.get()); page.addOrder(orderItem); } else { // 字段不在白名单忽略排序 log.warn(Invalid sort field: {}, sortField); } } return Result.success(userService.page(page, buildWrapper(query))); }这样既支持前端自由选择排序字段又杜绝了SQL注入风险。白名单的好处是字段名变更时只需改枚举不用动Controller逻辑。5.2 多字段组合排序——用逗号分隔的前端传参有些场景需要同时按多个字段排序比如“先按部门再按入职时间”。前端传参可能是sortdept_id,asc;hire_date,desc。我们可以写一个工具类解析public class SortParser { public static ListOrderItem parse(String sortParam) { ListOrderItem orders new ArrayList(); if (StringUtils.isBlank(sortParam)) return orders; String[] items sortParam.split(;); for (String item : items) { String[] parts item.split(,); if (parts.length ! 2) continue; String field parts[0].trim(); String order parts[1].trim(); OptionalString column SortField.getColumn(field); if (!column.isPresent()) continue; orders.add(asc.equalsIgnoreCase(order) ? OrderItem.asc(column.get()) : OrderItem.desc(column.get())); } return orders; } }Controller里调用ListOrderItem orderItems SortParser.parse(query.getSort()); for (OrderItem item : orderItems) { page.addOrder(item); }5.3 前端传参的边界处理——空值、非法值、大小写前端传参千奇百怪sortFieldnull、sortOrderxyz、sortFieldCREATE_TIME大写。我们的校验逻辑必须健壮sortField为空或空白字符串 → 忽略排序sortOrder不是asc/desc忽略大小写 → 默认ascsortField包含SQL关键字如order、by、;→ 直接拒绝返回400错误字段名长度超过30字符 → 记录告警拒绝处理防爆破我们在全局异常处理器里统一拦截ExceptionHandler(MethodArgumentNotValidException.class) public Result? handleValidation(Exception e) { return Result.fail(排序参数非法请检查sortField和sortOrder格式); }实战经验某次灰度发布前端同学把sortField写成了user.name带点号我们没做点号过滤结果生成了ORDER BY user.name DESCMySQL报错Unknown column user.name in order clause。后来我们在白名单校验前加了一行if (field.contains(.)) throw new IllegalArgumentException();彻底杜绝此类问题。6. 性能压测与调优——当分页排序遇上千万级数据当单表数据量超过500万ORDER BY LIMIT的性能会急剧下降。MySQL官方文档明确指出OFFSET值越大查询越慢因为数据库必须先扫描OFFSET SIZE行再丢弃前OFFSET行。我们曾在一个用户表800万数据上测试OFFSET查询耗时ms扫描行数01210100001871001010000021431000101000000189201000010可见翻到第10万页时查询要扫描百万行耗时近20秒。6.1 优化方案一游标分页Cursor-based Pagination放弃OFFSET改用游标。核心思想是用上一页最后一条记录的排序字段值作为下一页的起点。比如按id DESC分页第一页查LIMIT 10得到id1000,999,...,991第二页就查WHERE id 991 LIMIT 10。MyBatis-Plus本身不支持游标分页但可以手写SQLSelect(SELECT * FROM user WHERE id #{cursor} ORDER BY id DESC LIMIT #{size}) PageUser selectByCursor(Param(cursor) Long cursor, Param(size) Integer size);前端传参变成cursor991size10完全规避OFFSET。6.2 优化方案二延迟关联Deferred Join对复合排序如ORDER BY status DESC, create_time DESC先查主键再关联详情-- 慢直接查全字段 SELECT * FROM user ORDER BY status DESC, create_time DESC LIMIT 10000, 10; -- 快先查id再join SELECT u.* FROM user u INNER JOIN ( SELECT id FROM user ORDER BY status DESC, create_time DESC LIMIT 10000, 10 ) t ON u.id t.id;MyBatis-Plus不支持这种写法需用Select自定义SQL或用QueryWrapper的apply()拼接子查询。6.3 优化方案三冗余排序字段为高频排序字段建冗余列。比如按“销量时间”排序可以新增sort_score字段用触发器或应用层维护ALTER TABLE product ADD COLUMN sort_score BIGINT DEFAULT 0; -- 触发器UPDATE sort_score sales_count * 10000000000 UNIX_TIMESTAMP(create_time);然后只按sort_score DESC排序索引效率极高。性能实测在800万用户表上OFFSET 100000查询从2143ms降到87ms提升24倍。但冗余字段增加了写操作开销需权衡读写比例。7. 最后分享一个小技巧用单元测试固化分页排序逻辑所有分页排序逻辑必须有单元测试覆盖。我们团队的测试模板如下SpringBootTest class PageSortTest { Autowired private UserMapper userMapper; Test void testSortByCreateTimeDesc() { // 准备测试数据插入3条不同create_time的数据 User u1 new User().setUsername(a).setCreateTime(LocalDateTime.now().minusHours(1)); User u2 new User().setUsername(b).setCreateTime(LocalDateTime.now().minusHours(2)); User u3 new User().setUsername(c).setCreateTime(LocalDateTime.now().minusHours(3)); userMapper.insert(u1); userMapper.insert(u2); userMapper.insert(u3); // 执行分页查询 PageUser page new Page(1, 10).addOrder(OrderItem.desc(create_time)); IPageUser result userMapper.selectPage(page, new QueryWrapper()); // 断言第一条记录create_time最大 assertThat(result.getRecords()).hasSize(3); assertThat(result.getRecords().get(0).getCreateTime()) .isAfter(result.getRecords().get(1).getCreateTime()) .isAfter(result.getRecords().get(2).getCreateTime()); } Test void testMultiFieldSort() { // 插入sales_count相同但id不同的数据 User u1 new User().setUsername(x).setSalesCount(100L).setId(1L); User u2 new User().setUsername(y).setSalesCount(100L).setId(2L); userMapper.insert(u1); userMapper.insert(u2); PageUser page new Page(1, 10) .addOrder(OrderItem.desc(sales_count)) .addOrder(OrderItem.desc(id)); IPageUser result userMapper.selectPage(page, new QueryWrapper()); // 断言sales_count相同时id大的在前 assertThat(result.getRecords().get(0).getId()).isEqualTo(2L); assertThat(result.getRecords().get(1).getId()).isEqualTo(1L); } }测试要点必须插入至少3条数据验证排序稳定性必须覆盖重复值场景验证第二排序字段生效必须用assertThat断言具体字段值而不是只查size()测试数据库用H2但方言配置为DbType.MYSQL确保SQL生成逻辑一致这个测试模板被我们固化为CI流水线的必过项。每次修改分页逻辑必须更新测试用例否则PR无法合并。实践证明这比靠人工Review更能守住质量底线。我在实际项目中发现90%的分页排序问题其实都源于开发阶段没写测试——大家觉得“功能看着对就行”结果上线后数据量一大问题集中爆发。所以我的建议是把分页排序当成核心业务逻辑用测试代码把它锁死。
返回列表