ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis-Plus分页避坑指南:配置、页码0/1与防刷策略

MyBatis-Plus分页避坑指南:配置、页码0/1与防刷策略 你先想象一个场景线上突然报警某个列表接口的平均响应时间从 30ms 涨到了 3 秒。你登录数据库一看慢查询日志满屏都是同一种 SQLselect * from user_order where status 1 order by create_time desc limit 1999990, 10不用怀疑你的分页接口正在被脚本刷。偏移量被拉到 200 万行MySQL 要扫描并丢弃 199 万多条记录才能返回那 10 条数据不慢才怪。这个场景我遇到过不止一次。很多人用 MyBatis-Plus 分页觉得不就是new Page(1, 10)然后page()返回结果吗实际上从配置方式、页码语义到接口防护每一个环节都有看不见的坑。今天这篇就把 MyBatis-Plus 分页从配置到防刷完整拆一遍重点聊聊三个事分页插件到底怎么配才对、页码 0 和 1 之争是怎么产生的、以及分页接口被刷时怎么防。1. 分页配置方式为什么你配了插件还是不生效1.1 依赖引入starter 版本和 Spring Boot 版本要匹配先看依赖。大多数项目用的是mybatis-plus-boot-starter但这里有一个很多人踩过的版本坑如果你的项目是Spring Boot 2.x用mybatis-plus-boot-starter没问题。如果你的项目是Spring Boot 3.x也就是 JDK 17 那套不能再引入mybatis-plus-boot-starter要用mybatis-plus-spring-boot3-starter否则启动时会出现一堆类加载异常或者 Bean 注入失败。另外MyBatis-Plus 自身版本也经历过一次重要的 API 迁移3.4.0 之前分页插件是独立的PaginationInterceptor类3.4.0 之后包括 3.5.x统一改成了MybatisPlusInterceptorPaginationInnerInterceptor的组合方式。老类虽然还保留着但已经标记废弃而且在新版本中的行为和新拦截器不完全一致。如果你项目里用的是 3.5.x 的 jar却还在网上抄 3.3.x 的PaginationInterceptor配置分页功能虽然能跑但后续升级会遇到麻烦。1.2 拦截器注册MybatisPlusInterceptor 是硬门槛这是分页失效的第一大原因也是我最想强调的一点MyBatis-Plus 的分页插件默认是不生效的你必须显式把它注册到 MybatisPlusInterceptor 里。现在标准的配置方式如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这段配置看起来简单但有一个关键细节必须把MybatisPlusInterceptor声明为 Spring 管理的 Bean。我见过有人在 Service 层new MybatisPlusInterceptor()然后往里面 addInnerInterceptor用是能用的但因为实例没有交给 Spring 容器管理导致 MyBatis 框架根本感知不到这个拦截器分页 SQL 根本没有被改写Page对象返回的records是全表数据total是 0。这里顺便说一个判断分页是否生效的小技巧当你调用page()方法返回结果时如果total为 0而records却有几百上千条基本可以确定是拦截器没生效。正常情况下total会被 MyBatis-Plus 自动执行 count 查询并回填如果你看到 total 始终等于 0第一件事去检查拦截器有没有注册成功而不是去看 SQL 写的对不对。1.3 方言配置和多数据源场景下的注意点new PaginationInnerInterceptor(DbType.MYSQL)这行代码里DbType是数据库方言类型。为什么要手动指定因为分页 SQL 在不同数据库下语法完全不同MySQL 是limit offset, size。Oracle 12c 之前是三层嵌套rownum。PostgreSQL 是limit size offset offset。SQL Server 则要OFFSET ... ROWS FETCH NEXT ... ROWS ONLY。MyBatis-Plus 会依据你配置的方言生成对应的分页 SQL。如果不手动指定它也支持自动识别在无参构造new PaginationInnerInterceptor()时会通过数据库连接元数据判断类型。但自动识别在某些代理数据源、多数据源切换的场景下偶尔会判断错误所以我强烈建议在配置里就明确指定 DbType宁可多写一行也别让它在线上环境猜。多数据源场景还要特别注意如果你配置了多个数据源比如主库和从库分页插件本质上是对 MyBatis 的 Executor 做拦截属于全局配置通常在主数据源配置里注册一次即可。但在使用DS动态数据源切换时分页方言的识别容易出岔子这时候手动指定 DbType 就更加重要了。2. 页码 0/1 之争从 LIMIT 语义说起2.1 两种完全不同的第一页习惯分页接口最隐蔽的坑其实是页码从 0 开始还是从 1 开始。这看起来只是习惯问题实际上会直接影响 SQL 的偏移量甚至导致接口返回错误的数据。常见的两种约定前端组件习惯从 1 开始比如el-pagination默认的current-page就是 1用户看到的第一页是第 1 页。后端接口习惯从 0 开始一些老系统、内部 RPC 接口、或者对接过 ES、MongoDB 的开发者习惯用 0 表示第一条数据的位置第一页就是page0。这两套习惯一旦混用分页就乱了。MyBatis-Plus 的Page类设计是current 从 1 开始也就是说如果你要查第一页应该写new Page(1, 10)。但问题是它并没有在构造器里做严格的参数校验你传new Page(0, 10)它也能正常执行只是生成的 SQL 是limit 0, 10而第一页limit 0, 10查出来的数据和limit 0, 10一模一样。所以从数据结果上看page0和page1返回的数据是相同的这就造成了一种假象页码 0 和 1 都行反正查到的都是第一页。2.2 new Page(0, 10) 到底查了什么既然返回数据一样那为什么还要纠正这个错误问题出在分页元数据上。当你用new Page(0, 10)查询后返回的Page对象里current仍然是 0。如果你在业务代码里做这样的判断if (page.getCurrent() page.getPages()) { // 超出总页数返回空列表 }或者前端拿返回值里的current去渲染当前页码、计算上一页/下一页的可用状态就会出现明明有下一页却判断错误、或者页码显示为 0 的诡异现象。更麻烦的是另一种反向错误。有些后端开发为了适配老接口在 Controller 层统一做了current - 1的转换比如前端传 1 表示第一页后端转成 0 传给 Page。这种设计下如果某个客户端直接传了 0经过0 - 1之后变成 -1生成的 SQL 可能是limit -10, 10这在 MySQL 中是一个非法参数不同版本可能报错也可能被当成 0 处理行为完全不可控。所以我要给一个明确的建议全项目统一约定 current 从 1 开始。前端第一页传 1后端直接把这个值交给Page不要做任何current - 1的转换。如果你一定要兼容老接口也请在 Controller 入口统一做一次转换并且对转换前的原始参数做 1的校验。2.3 超大页码才是真正的灾难页码 0/1 之争如果只是传错了一页数据那还好说更严重的问题是分页参数被恶意放大。想象一下有个攻击者看到你的分页接口是GET /api/v1/orders?page1size10他直接把 page 改成 2147483647Integer 的最大值size 改成 10。你的后端如果没有做任何参数校验Page构造器会照单全收生成的 SQL 是limit 21474836470, 10这个 SQL 扔给 MySQL数据库会尝试扫描 2147 亿行数据再丢弃前面 2147483647 行最终大概率是直接把数据库连接池打满或者把 CPU 跑满。这是分页接口被刷最经典的手段不是高频请求把你打垮而是单个请求就直接把数据库拖死。所以页码参数校验不是可选项而是必选项。后面防刷部分我会详细讲怎么做。2.4 自定义 SQL 分页的页码坑除了IService.page()这种内置方法很多项目的复杂查询会写在 Mapper 接口里比如Mapper public interface OrderMapper { IPageOrderVO selectOrderPage(Page? page, Param(status) Integer status); }配套的 XML 里写select idselectOrderPage resultTypecom.example.vo.OrderVO select * from t_order where status #{status} /select这里有两个容易踩的坑。第一个是Page 参数必须放在方法参数的第一位或能被 MyBatis-Plus 正确识别的位置虽然插件是按参数类型查找 Page 对象的但为了稳妥起见我都习惯把 Page 写在第一位避免某些代理场景下识别异常。第二个是返回值要声明成 IPage如果你声明成ListOrderVO再把 Page 传进去分页 SQL 虽然会执行但返回的列表没有 total 信息后续还要再手动 count非常麻烦。自定义 SQL 分页还有一个隐蔽问题如果你的查询里有group by或者distinctMyBatis-Plus 自动生成的 count 语句可能不准确。比如select user_id, count(*) from t_order group by user_id自动 count 会变成select count(*) from (select user_id, count(*) from t_order group by user_id)如果原 SQL 本身就对聚合结果做了过滤count 统计就会出问题。遇到这种场景就选择手动 count或者用 Page 构造器关闭自动 countPageOrderVO page new Page(1, 10); page.setSearchCount(false);然后自己手工查 count再把 total 塞进 Page 对象。3. 防刷三板斧校验、限流、缓存3.1 先搞清楚分页接口为什么容易被刷分页接口几乎是所有后台系统里最脆弱的接口类型。原因有三条参数可枚举页码 1、size 改大一点就能不断拉取新数据脚本成本极低。每条请求都是真实查询分页接口没办法像静态资源那样做 CDN 缓存每一次调用都对应一次真实的数据库查询请求一多就直接打到数据库。攻击隐蔽分页接口不像登录接口有频率限制的习惯很多系统的分页接口裸奔多年直到某一天数据库 IO 被打爆才被发现。明白了这三个原因就能推导出防刷的核心思路要么让恶意参数进不了 SQL要么让高频请求进不了后端要么让重复查询落进缓存。3.2 第一道防线拦截器层的 maxLimit 和 overflowMyBatis-Plus 的分页插件自带两个非常实用的防护参数很多人根本没用过。第一个是setMaxLimit。它限制了单页最大条数PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(100L);设置了之后就算前端传size10000插件也会强制把 size 改写成 100从源头杜绝一次性拉全表的行为。第二个是overflow。这个参数控制页码超出总页数时的处理方式paginationInterceptor.setOverflow(true);默认情况下如果请求的页码超过了总页数插件不会纠正还是会把原始页码拼进 SQL。setOverflow(true)之后页码一旦超出总页数会自动改写成最后一页的页码避免生成超大偏移量的 SQL。这两个参数配合使用已经能挡住绝大多数脚本的粗糙攻击。但注意拦截器只解决恶意参数的问题解决不了正常参数高频重复请求的问题所以后面还需要第二道和第三道防线。3.3 第二道防线基于 Redis 的滑动窗口限流分页接口的限流我推荐滑动窗口限流而不是简单的计数器限流。原因很简单计数器限流在窗口边界存在流量突刺问题滑动窗口限流可以更平滑地限制单位时间内的请求数量。落地方式可以用 Redis Lua 脚本保证判断和计数的原子性。核心逻辑是以用户 ID 或 IP 作为限流 key。比如限制 10 秒内最多请求 20 次。每次请求来时用 Lua 脚本统计当前时间窗口内的请求总数超过阈值就拒绝。这里给一个可以直接用的 Lua 脚本原型local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, window) end if current limit then return 0 end return 1Java 端的调用逻辑大致是public boolean tryAcquire(String key, int limit, int windowSeconds) { DefaultRedisScriptLong script new DefaultRedisScript(LUA_SCRIPT, Long.class); Long result redisTemplate.execute(script, Collections.singletonList(rate:limit: key), String.valueOf(limit), String.valueOf(windowSeconds)); return result ! null result 1L; }实际使用时我通常会对同一个用户 ID 和同一个 IP 分别做一次限流双维度叠加。尤其是分页接口用户维度防止单人写脚本拉全量数据IP 维度防止恶意者换号攻击。这里还要注意一点分页接口的限流阈值不要设得太激进。列表页是正常用户高频访问的页面一个人翻 10 页每页刷新一次就是 10 次请求。如果阈值设成 5 秒 10 次正常用户可能误伤。我的经验值是从 10 秒 30 次开始调结合业务实际情况放宽或收紧。3.4 第三道防线短 TTL 缓存 查询结果收敛限流能挡住恶意高频请求但同一时刻大量正常用户查询相同的数据一样会给数据库压力。这时候就要上缓存。分页接口的缓存有一个特殊性数据实时性要求通常比详情页低但比静态资源高。所以缓存时间不能太长一般建议 30 秒到 5 分钟视业务而定。缓存的 key 设计要非常小心必须包含所有影响查询结果的参数String cacheKey page:order: status : current : size;如果查询条件里有时间范围、关键词、排序字段都要拼到 key 里否则就会出现数据错乱。缓存里存什么直接存完整的分页结果 JSON包括 records、total、current、size、pages。这样命中缓存时根本不需要再去数据库查询一劳永逸。但是缓存方案有一个风险缓存穿透。如果攻击者故意请求一个不存在的页码组合比如 page99999、size100Redis 里没有请求还是会打到数据库。解决办法是对于查询结果为空的情况也缓存一个空结果TTL 设置短一些比如 10 秒。顺带说一句如果业务上明确不允许用户翻到太深的页码我强烈建议在接口层做一个硬限制只允许查询前 100 页的数据超过直接返回空列表或者提示用户使用筛选条件。绝大多数真实业务场景中用户翻到第 100 页之后的行为是极少数的与其让数据库去扛深分页的代价不如在产品交互上直接把这条路堵死。3.5 深分页场景的替代方案游标分页如果业务上确实需要支持大量数据的逐页遍历比如运营后台导出、数据同步任务这时候传统的 limit offset 分页无论如何优化都扛不住要换一种思路游标分页keyset pagination。核心思想是不记录页码而是记录上一次查询结果中最后一条记录的排序字段值。比如按id倒序排列select * from t_order where id #{lastId} order by id desc limit 10;第一页先查where id is null拿到最大的 10 条下一页把当前页的最小 id 作为lastId传入就能高效地翻到下一批数据。这种方式的索引利用率极高即使数据量到了几千万也能保持稳定的查询性能。不过游标分页和 MyBatis-Plus 的内置 Page 模型不太兼容通常需要手写 Mapper SQL 或者单独封装一套游标分页组件。我的建议是用户端交互网页、App统一用传统分页并限制最大页码内部批处理场景用游标分页两条路分开走。4. 常见问题与排查实录4.1 分页失效的五类典型原因我整理了实际排查中遇到最多的分页失效场景按出现频率排序失效现象典型原因排查思路Page 返回全表数据total 为 0分页插件未注册为 Spring Bean或拦截器配置类根本没被扫描到检查Configuration类所在包是否被主启动类扫描分页 SQL 没有 LIMIT 关键字引入的 MyBatis-Plus 版本过老用的是旧版 PaginationInterceptor或自定义 SQL 写法不规范打开 SQL 日志确认执行语句里有没有 LIMITtotal 始终等于 0但 records 分页正常自定义 SQL 返回类型写成了 List而不是 IPage把返回值改成 IPagePage 参数放到方法参数第一位分页只在部分数据源生效多数据源环境下另一种方言识别失败在 PaginationInnerInterceptor 中手动指定 DbType传 page1 查不到第一页数据前端从 1 开始后端做了 current-1 转换导致实际查的是第二页统一约定 current 从 1 开始删除 current-1 转换遇到分页失效问题我建议的排查顺序是先看日志里实际打印的 SQL 有没有 LIMIT没有就查拦截器配置有 LIMIT 但数据不对就查页码语义和参数转换SQL 和参数都对但 total 不对就查 count 相关的 SQL 和返回值类型。按照这个顺序走大部分问题都能在 5 分钟内定位。4.2 total 统计不准确的几个隐蔽场景自动 count 是 MyBatis-Plus 分页的默认行为但有几个场景下它会算错或者算得很慢。第一个是多表连接查询。如果业务查询里有三张表 join自动生成的 count SQL 会对连接结果做 count如果连接的关联关系不是一对一的count 结果就会比真实数据量大得多而且 count 本身也很慢。这种情况建议用setSearchCount(false)关闭自动 count自己手写一个精确的 count SQL。第二个是带有 group by 的聚合分页。前面已经提到group by 场景下自动生成的 count SQL 需要包一层子查询才有意义MyBatis-Plus 在某些版本里处理得并不好。第三个是查询条件里包含随机数或当前时间比如where date(create_time) curdate()。这种情况 count 和列表查询两次执行的时间点不同数据又刚好在临界区会导致 total 和 records 对不上。这不是 bug是业务设计的问题建议把查询条件参数化前端传入固定的时间范围而非数据库函数。4.3 Oracle 和其他方言下的分页差异虽然 MyBatis-Plus 帮你屏蔽了大部分方言差异但如果你在 Oracle 上做分页有几个底层行为值得了解。Oracle 12c 之前没有limit语法经典分页写法是三层嵌套select * from ( select t.*, rownum rn from ( select * from t_order order by create_time desc ) t where rownum 20 ) where rn 10;这种写法里最内层的排序结果必须先用 rownum 限制上界再在外层过滤下界否则拿不到正确的分页结果。MyBatis-Plus 遇到 Oracle 方言时会自动生成这样的 SQL代价是 SQL 本身比较重执行效率比 MySQL 的 limit 低一些。Oracle 12c 之后支持了标准的OFFSET ... FETCH NEXT ... ROWS ONLY写法MyBatis-Plus 也会自动适配。其他数据库也有一些差异比如 SQL Server 的OFFSET FETCH要求必须有ORDER BY子句如果你的排序条件是 null 或者省略了 order by生成的 SQL 可能会直接报错。这些都是方言差异的坑我都建议在分页插件里统一指定 DbType让框架去处理这些细节。4.4 慢 SQL 排查和性能急救分页接口变慢90% 都出在深分页上面。判断一个分页 SQL 是不是深分页问题最直接的办法是拿同样的 SQL 去执行EXPLAINexplain select * from t_order where status 1 order by create_time desc limit 100000, 10;重点看rows字段如果 MySQL 预估需要扫描的行数超过了十万行说明这个分页查询正在做大量无效扫描。深分页慢的根本原因在于MySQL 需要先扫描并排序 offset size 行数据然后把前面的 offset 行全部丢弃。即使你有完美的索引也免不了这个扫描过程所以深分页的问题是结构性的不是加个索引就能解决的。如果说深分页的 SQL 已经上线但不能立刻停机改造可以做一个急救操作把排序字段和筛选条件做成联合索引让数据库用索引来执行排序而不是 filesort。比如上面的 SQL 是where status 1 order by create_time desc那联合索引就建(status, create_time)。这能从表面缓解一部分性能问题但治根还是得靠限页码和游标分页。最后分享两个真实的项目经验第一个经验有一次上线前压测我发现某列表接口在 page1 时响应只要 20ms但 page1000 时直接飙到 1.8 秒。排查后确认不是索引问题就是深分页的结构性代价。后来我让产品把前端交互从页码跳转改成了上一页/下一页 回到顶部的模式在拦截器里把最大允许页码限制在 100压测数据直接从 1.8 秒降到了 30ms 以内。分页接口设计很多时候是产品决策问题不只是技术问题。第二个经验我接手过一个老系统前端分页组件传的 page 从 0 开始后端为了兼容在 Service 层做page.setCurrent(page.getCurrent() 1)结果某个新来的同事没注意这个约定在另一个接口里又减了一次。一个页码加了减、减了加最后查出来的数据总是错位。从那以后我给自己定了一个规矩所有分页接口的入参 DTO 全部用 Min(1) 校验页码用统一的分页请求对象禁止在业务代码里二次修改 current 的值。分页这件事把配置弄清楚只是入门把页码语义统一、把接口防刷做扎实才算真正掌握。希望这篇能帮你少踩几个坑。
返回列表