
1. 分页方案怎么选三种主流实现的底层差异分页查询这个事在Spring Boot项目里几乎是无处不在的。后台管理系统要分页前台列表要分页导出报表也有变相的分页需求。我刚接触Spring Boot的时候第一反应是“这不就是SQL里加个LIMIT嘛”真到自己动手做的时候才发现一个简简单单的分页背后牵扯的坑一点不比业务逻辑少。先说明概念。分页查询在实际开发中其实分两种逻辑分页和物理分页。逻辑分页是把数据一次性查出来然后在内存里做截取比如直接list.subList(start, end)这种方案数据量小的时候没关系数据过万之后内存压力和GC问题马上凸显。物理分页是数据库原生支持的分页方式MySQL的LIMIT offset, size、PostgreSQL的LIMIT ... OFFSET ...这类方式只从库里取需要的那一小段性能和扩展性都靠谱得多。企业级项目里我们讨论的分页查询一般默认就是指物理分页。真正有选择空间的是物理分页的实现方式。我在实际项目里接触过三种主流做法各有各的适用场景很多人上来就问“哪个好”其实没有绝对的好坏只有合不合适。1.1 底层原理三种分页实现方式的工作机制第一种原生SQL手写分页。这种方式是最直观的写一条带LIMIT的查询语句再写一条对应的COUNT统计语句每次查询都要手写两条SQL。看似繁琐但好处是SQL完全可控Oracle那种不支持LIMIT的方言也能靠ROWNUM解决对SQL优化能力强的团队来说这条路的性能和可控性上限是最高的。第二种PageHelper分页插件。这是国内使用最广的方案底层原理是拦截器机制。PageHelper基于MyBatis的拦截器在运行时拦截你执行的SQL自动帮你改造给查询SQL追加LIMIT语句同时自动生成一条COUNT查询。开发的时候调用PageHelper.startPage(pageNum, pageSize)之后紧接着执行的那条Mapper查询就会被拦截并分页整个过程对业务代码的侵入性极小。第三种MyBatis-Plus自带的分页插件。MyBatis-Plus在Spring Boot项目里的存在感非常高它内置了PaginationInnerInterceptor用法上比PageHelper更贴近ORM风格。你不需要调用类似startPage的静态方法而是显式构造一个Page对象作为参数传给Mapper方法框架会解析这个参数自动完成分页SQL的拼接。从源码层面说MyBatis-Plus也是通过MyBatis的拦截器机制实现的但它在 SQL 解析上做的更“智能”一些对多表查询、嵌套查询的处理比PageHelper更稳。1.2 横向对比与选型建议根据项目阶段做取舍这里直接给一张对比表把我实际踩过坑之后的结论写清楚。对比维度PageHelper插件MyBatis-Plus分页手写SQL分页接入成本低依赖配置即可低但需要依赖MP完整体系高需要写countquery两条SQL侵入性极低一行代码开启分页低构造Page对象传参无框架侵入SQL完全自控多表关联有坑嵌套collection会误判相对稳定完全可控SQL优化空间小插件生成的SQL难干预中等最大适合场景快速交付、中小型项目已经在用MyBatis-Plus的团队性能敏感、复杂查询多的项目选型逻辑其实很简单。如果项目已经用了MyBatis-Plus直接用它的分页插件就完了没必要再引入PageHelper两套拦截器共存拦截顺序处理不好会非常难受。如果项目是纯MyBatis环境又想要快速落地PageHelper是一个成熟且经过大量实践验证的方案。如果你的查询非常复杂涉及多层嵌套、动态SQL组合、极端性能要求那就别偷懒手写分页SQL反而最省心。从我的个人经历来看中小型项目我几乎无脑选PageHelper因为它在“写代码的效率”和“可维护性”之间平衡得最好。到了对慢查询零容忍的大型报表系统我会倾向于手写SQL做精细控制。没有银弹只有权衡。2. PageHelper接入与配置从依赖到完整分页封装PageHelper在我做过的几十个Spring Boot项目里算是出场率最高的分页组件。很多人觉得它只是一个依赖、一个startPage调用的事但实际上配置不对的话轻则分页不生效重则SQL被拦截错乱排查起来相当痛苦。2.1 依赖选型与版本兼容Spring Boot 2.x和3.x的差异先说依赖。Spring Boot 2.x 的项目直接引入pagehelper-spring-boot-starter就行dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency但如果你用的是Spring Boot 3.x情况就不一样了。Spring Boot 3.0 基于 Jakarta EE 9底层依赖包名从javax改成了jakarta早期的PageHelper版本不支持。我建议用1.4.7 及以上版本这个版本开始支持Spring Boot 3。如果你在项目中遇到ClassNotFoundException: javax.servlet.Filter之类的报错八成就是版本不兼容。另外提醒一点PageHelper 1.4.x 之后分页插件不再自动拦截MyBatis的所有方法。这是为了避免误拦截无意识地影响查询性能。所以在配置MyBatis的时候要在Configuration类里显式装配拦截器这样插件才会生效Configuration public class MybatisConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }等等这段代码是MyBatis-Plus的配置。PageHelper的自动装配方式稍有不同但思路一致——确保启动时插件被注册到SqlSessionFactory中。如果用了Spring Boot 3.x加上PageHelper 1.4.7通常你只要引入starter它就会自动装配好不需要额外手工注册。但如果发现插件不工作第一怀疑点就是starter没有被正确加载。2.2 核心配置与调用规范startPage必须紧跟查询PageHelper的参数在application.yml里配置我习惯的标准配置如下pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql auto-runtime-dialect: true这几个配置项各有讲究。helper-dialect指定数据库方言如果项目可能切换数据库可以用auto-runtime-dialect: true让它自动探测。reasonable是一个非常实用的参数开启后当页码小于1时自动回退到第一页当页码大于总页数时自动查询最后一页。这个配置对前端传参不规范的场景尤其好用能避免很多无意义的空列表问题。support-methods-arguments开启后支持从Mapper方法参数里读取pageNum和pageSize字段省得每次手动调startPage。用法上大家都会背了但我要强调一个极其关键的细节PageHelper.startPage()之后必须紧跟下一行MyBatis查询方法不能夹带任何其他SQL操作。因为这个方法只是往本地线程变量ThreadLocal里存了几个参数真正生效要等下一个查询被拦截的时候。一旦中间插了另一条查询那条无辜的SQL就会被分页逻辑吞噬轻则多一条count查询重则直接拼接错误SQL导致报错。以下是标准写法public PageResultUserVO listUsers(String keyword, int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); ListUserVO users userMapper.selectUserList(keyword); PageInfoUserVO pageInfo new PageInfo(users); return PageResult.of(pageInfo); }为什么不直接返回List而是要包一层PageInfo因为分页查询除了数据列表前端通常还需要知道总数、总页数、当前页码这些元数据。PageInfo对象会把Page里记录的total、pageNum、pageSize、pages等属性统一提取出来。如果你直接把List序列化返回前端拿不到总数还得靠额外接口去count反而多一次网络请求。2.3 分页结果封装统一返回结构避坑很多项目会把分页结果封装成一个统一的PageResultT类这属于老生常谈的规范操作了。我踩过一次坑一开始图省事直接返回PageInfo结果前端拿到的JSON字段名是pageNum、total、list这些结构还算稳定。但后来需求变了要加一个“是否有下一页”的字段前端改一处后端改四处维护成本就上来了。所以我后来都建议项目里统一定义一个轻量DTOData public class PageResultT { private ListT list; private long total; private int pageNum; private int pageSize; private int pages; private boolean hasNext; public static T PageResultT of(PageInfoT pageInfo) { PageResultT result new PageResult(); result.setList(pageInfo.getList()); result.setTotal(pageInfo.getTotal()); result.setPageNum(pageInfo.getPageNum()); result.setPageSize(pageInfo.getPageSize()); result.setPages(pageInfo.getPages()); result.setHasNext(pageInfo.isHasNextPage()); return result; } }有人说这不就是多写几个字段的事吗对但就是这种小细节决定了你后续接口交接的时候别人能不能半小时内看懂你的分页结构。分页是所有列表接口的通用壳子把壳子打磨好长远收益非常高。2.4 PageHelper实战最常见的五个坑第一本地线程变量泄漏。startPage存的是ThreadLocal如果查询方法抛出异常或者查询没有被执行线程池里的线程复用后下一次查询可能会异常分页。解决方案是在finally里调用PageHelper.clearPage()或者依赖reasonable配置减少异常分支。说实话新版PageHelper在异常场景处理上已经优化很多但多线程环境下仍然推荐你写完后显式清理这属于防御性编程的习惯。第二COUNT查询被多重JOIN拖垮。PageHelper自动生成的countSQL会把所有表JOIN都保留下来。如果一个分页查询关联了四五张表count语句的代价可能比数据查询还高。解决办法是配置countSuffix或自己写count查询替换但更常用的做法是优化关联表结构尽量减少不必要的JOIN。第三嵌套结果映射冲突。如果你的Mapper用了collection做一对多嵌套映射PageHelper会对结果集做内存分页而不只是SQL层面的LIMIT这时总数往往是对的但数据会被截错。遇到这种查询建议要么拆分成两步查要么改用Page包装后再手动处理列表。第四多数据源时方言错乱。配置了auto-runtime-dialect: true之后插件会自动从数据库连接里探测方言但如果事务连接切换时机不对会出现方言识别错误。多数据源项目里建议显式指定helper-dialect或者按数据源分别配置SqlSessionFactory。第五缓存了旧查询语句。MyBatis的二级缓存和PageHelper一起用容易出现缓存了上一次分页参数的问题。排查了半天最后发现是二级缓存捣的鬼所以分页查询一般只开一级缓存就好。3. 手写分页SQL与MyBatis-Plus方案PageHelper用久了你会发现它是个黑盒。黑盒能帮你高效开发但也会让你在遇到复杂的性能问题时无从下手。所以不管你是不是用PageHelper我都建议你掌握手写分页SQL的能力这是基本功。3.1 手写LIMIT分页的完整实现两段SQL自行控制手写分页最朴素的做法就是写两个Mapper方法一个查列表一个查总数。我把一个典型的用户管理分页的Mapper列出来供你参考。select idselectUserPage resultTypecom.example.vo.UserVO SELECT id, username, email, status, create_time FROM sys_user where if testkeyword ! null and keyword ! AND (username LIKE CONCAT(%, #{keyword}, %) OR email LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /select select idcountUserPage resultTypelong SELECT COUNT(*) FROM sys_user where if testkeyword ! null and keyword ! AND (username LIKE CONCAT(%, #{keyword}, %) OR email LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND status #{status} /if /where /select这里有个容易被忽略的点LIMIT后面第一个参数是偏移量不是页码。所以Service层要做一次换算int offset (pageNum - 1) * pageSize;我见过不少新手直接传页码进去结果第一页正常第二页就少数据或者错乱。这个换算逻辑必须放在Service层统一处理不要散落在Controller里。注意上面两条SQL的动态条件是完全一样的。这就带来了维护负担——一旦业务加了筛选条件两处都得改。我建议用sql标签抽取公共条件片段sql iduserQueryCondition where if testkeyword ! null and keyword ! AND (username LIKE CONCAT(%, #{keyword}, %) OR email LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND status #{status} /if /where /sql这样两个查询里直接include refiduserQueryCondition/引用即可既避免了重复又保证了列表和总数统计的条件一致性。如果出现总数和数据条数对不上的情况基本都是条件不一致导致的这类问题用这个技巧可以从根源上规避。3.2 多表关联分页的count优化思路手写SQL分页最大的优势在复杂查询里体现得非常彻底。比如你要分页查询“订单列表”同时需要带出用户昵称、商品名称这时候如果还在DAO层手写JOINcount语句会把所有JOIN表全部扫一遍。优化思路一般是两种。第一种减少不必要的JOIN。如果只是要展示用户昵称不一定非要在查询主表的时候JOIN用户表。你可以先查订单分页数据只查订单主表字段拿到订单ID集合后再用WHERE user_id IN (...)批量查出用户信息在内存中做组装。这样count语句只需要统计订单主表性能会好很多。第二种子查询标量化。MySQL 5.7支持derived_merge但有时候优化器没有把派生表合并得很理想。你可以用相关子查询直接取用户昵称SELECT o.id, o.order_no, (SELECT u.nickname FROM sys_user u WHERE u.id o.user_id) AS nickname FROM order_info o ORDER BY o.create_time DESC LIMIT #{offset}, #{pageSize}这种写法虽然看起来有点“土”但在一些特定场景下它比JOIN更可控而且count语句完全不需要关联用户表。如果你的数据库是MySQL 8.0可以考虑用窗口函数但为了兼容性和团队认知统一性前期我还是推荐保守写法。3.3 MyBatis-Plus分页插件的配置与用法如果你的项目里已经用了MyBatis-Plus那么分页功能其实已经被设计好了不需要PageHelper。用法上第一步是引入依赖dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency第二步装配分页拦截器。这里特别强调不装配拦截器的话分页不生效或者只能查出一页数据。很多新手会栽在这里因为代码没报错但数据就是只有第一页。配置类如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }第三步在Service层直接使用分页查询public PageUserVO pageUsers(int pageNum, int pageSize) { PageUserVO page new Page(pageNum, pageSize); LambdaQueryWrapperUser wrapper Wrappers.UserlambdaQuery() .like(StringUtils.hasText(keyword), User::getUsername, keyword) .eq(status ! null, User::getStatus, status) .orderByDesc(User::getCreateTime); return userMapper.selectPage(page, wrapper); }这里返回的就是Page对象前端可以直接从records、total、current、size等字段里取值。相比PageHelperMyBatis-Plus的分页更“显式”你不需要操心startPage和查询位置的关系因为分页参数是通过方法参数显式传递的在线程安全方面天然更友好。我用MyBatis-Plus分页也踩过一个坑自定义SQL的分页。如果你在Mapper XML里写了自定义SQLMyBatis-Plus拦截器只有在SQL语句符合它解析规则的情况下才能自动分页。比如你写了SELECT * FROM user u LEFT JOIN role r ON ...这样的SQL部分复杂场景下拦截器会解析失败或者生成的countSQL不准。这时候就需要手动在XML里写分页语句或者给方法注释上IPage参数配合手写LIMIT。我的经验是简单的CRUD用MP自带方法复杂的报表查询老实手写SQL两者结合而不是非此即彼。4. 分页查询性能优化与实战排查分页查询写对了不一定性能好。很多时候我要的不是“能翻页”的结果而是“翻页很快”的体验。分页性能问题中最典型的就是深分页问题。4.1 深分页问题的根源与对策先说深分页是什么意思。比如你要查第50000页的数据每页20条SQL会变成LIMIT 999980, 20。MySQL执行这条SQL时并不是真的只取20条而是先扫描前999980条数据然后把它们全部丢弃再取接下来的20条。偏移量越大扫描量越大性能自然急剧下降。解决深分页的经典方案有三种按推荐程度排序。第一种子查询延迟关联。利用覆盖索引快速定位主键然后再关联回原表取数据SELECT u.* FROM sys_user u INNER JOIN ( SELECT id FROM sys_user ORDER BY create_time DESC LIMIT 999980, 20 ) tmp ON u.id tmp.id这种方式让内层子查询只扫描主键索引不涉及到SELECT *的全行数据回表性能提升非常显著。第二种记录上一页最后一条记录的ID用WHERE id lastId来翻页而不是用OFFSET。这种也叫keyset分页SELECT * FROM sys_user WHERE id #{lastId} ORDER BY id ASC LIMIT 20前端把上一次返回列表的最后一条记录的ID传回来后端用这个ID做游标。这种方式对于数据流式加载的场景比如“加载更多”按钮非常合适。缺点是不支持随机跳页只适合逐页顺序浏览。第三种限制可访问的最大页码数。比如超过200页以后就不允许用户直接翻最后一页只允许搜索或者按条件筛选。虽然看起来是产品层面的设计但在真实业务里往往是最实用的。用户真的需要翻到第50000页看数据吗大概率不需要更大的可能是他在盲目浏览。4.2 分页查询优化的六个关键点这六个点是我在各种项目里反复验证过的经验基本覆盖了最常见的慢查询场景。第一永远不要SELECT *。分页查询关注的是展示列你只需要返回前端真正需要的字段。SELECT *会拉取所有字段尤其是TEXT、BLOB这类大字段在深分页时内存压力成倍增长。第二排序字段必须有索引。ORDER BY 的字段没有索引MySQL需要做filesort一旦数据量大排序的消耗甚至超过查询本身。所以分页查询的排序字段要么是主键要么有二级索引。没有索引的排序字段在百万级表上分页就是灾难。第三使用覆盖索引。如果你只需要查询少数几个字段尽量让索引覆盖这些字段这样MySQL可以直接从索引里取数据不需要回表。单位时间能处理的行数会高一个数量级。第四count查询要轻量。如果分页接口的count语句很重即使数据查询只要几十毫秒整个接口的响应时间也会被拖到秒级。可以在不影响核心功能的前提下减少count条件中不必要的LIKE模糊搜索或者改用精确条件。第五避免在分页查询里做分组。GROUP BY加LIMIT的组合很危险MySQL会先分组全部数据再切分页。如果一个分组查询必须要分页考虑先用子查询把分组结果算出来再在外面分页。第六热点数据可以下沉到Redis或搜索引擎。如果分页数据是热门列表同一页会被大量用户同时访问比如文章列表、商品列表你完全可以缓存分页结果或把数据同步到Elasticsearch来支撑复杂筛选。这已经超出了SQL优化的范畴但它是架构层面真正一劳永逸的解法。4.3 常见问题速查表我自己在排障过程中积累了一些高频问题这里整理成速查表遇到问题可以按图索骥。现象可能原因解决方案分页不生效返回全部数据没引入依赖或没有拦截器配置检查starter依赖确认分页插件已装配第一页正常第二页数据重复/缺失offset换算错误检查(pageNum - 1) * pageSize公式count查询特别慢JOIN太多、条件重复执行简化countSQL减少不必要的表关联总页数正确但当前页数据错乱排序字段不唯一排序字段追加主键如ORDER BY create_time DESC, id DESCpageNum传0返回空列表reasonable未开启配置reasonable: true或service层纠正页码并发下单时分页数据串页ThreadLocal污染确保每次查询后清理PageHelper context4.4 深度排查如何定位分页慢的根因当你遇到一个分页接口特别慢不要急着去看代码先通过几个方法快速定位。第一步开启慢查询日志看SQL真实执行时间。MySQL里执行SHOW VARIABLES LIKE slow_query_log%确认开关再把long_query_time调整到1秒以内。第二步拿到真实SQL。如果用了PageHelper可以在日志里输出SQL语句。PageHelper生成的SQL是什么样的、是不是带上了LIMIT、count语句的JOIN情况一目了然。如果SQL复杂直接在Navicat里跑一遍EXPLAIN看执行计划重点关注type字段的值。ALL意味着全表扫描index意味着扫描了整棵索引树const、ref才是正常的表现。第三步用EXPLAIN分析索引利用情况。查看key字段是否真的用到了期望的索引。有时候你建了索引但因为LIKE %keyword%的前导百分号索引根本用不上这时候就要考虑改用全文索引或搜索引擎来支撑这类搜索场景。第四步对比数据查询和count查询各自的耗时。前端感知到的“分页接口慢”往往是由count语句贡献的。分别跑一下两条SQL就能知道优化重心在哪里。排查逻辑和调试业务Bug完全不同。业务Bug是一步一步改代码性能问题需要你用数据分析的眼光去找瓶颈。我习惯把耗时分布拆开而不是盯着一个总的接口时间看这样效率高很多。5. 大数据量下的分页替代方案游标查询与流式处理有些场景连keyset分页都不够看那就是数据量特别大比如千万级的日志表、订单历史表。这时候用传统的“总页数当前页”模式无论你怎么优化都是注定要卡的。针对这类场景我通常会考虑两种更工程化的方案。5.1 可滚动游标与流式查询前面讨论的所有分页方式都属于“客户端分页服务端物理分页”的组合本质上是每次查固定大小的一页。而流式查询/游标查询的思路完全不同它一次性打开一个数据库游标然后逐条读取。在JPA的ScrollableResults里你可以一边查询一边消费数据库端保持连接分批把数据取到内存。在MyBatis里对应的是游标查询比如try (CursorUser cursor userMapper.selectCursorList()) { cursor.forEach(user - { // 逐条处理用户数据 }); }这种做法的好处是无论表有多大JVM内存占用都能稳定在一个可控水平。代价是查询期间要一直占用数据库连接所以必须在事务范围内执行用完要立刻关闭游标。我记得有一次做报表导出表格里有80万行数据需要分批写入Excel。如果一次性查出来内存瞬间暴涨到几个GB结果JVM直接OOM。改成游标流式查询之后每一批只取500行写入完毕再取下一批整个过程内存占用稳定在200MB以内。相对其他方案面对大规模数据这个方案在稳定性上确实无可替代。5.2 Keyset分页在API设计中的落地面向移动端的场景里分页体验通常是“下拉刷新、上拉加载更多”这种场景和keyset分页简直完美契合。服务端接口设计很简单前端请求列表时传一个cursor参数它的值是上一页最后一条记录的排序字段值。public ListOrderVO listOrders(Long cursor, int pageSize) { LambdaQueryWrapperOrder wrapper Wrappers.OrderlambdaQuery() .lt(cursor ! null, Order::getId, cursor) .last(ORDER BY id DESC LIMIT pageSize); ListOrder orders orderMapper.selectList(wrapper); return convertToVO(orders); }这种设计的好处是响应时间极其稳定。不管用户翻了10页还是1000页SQL始终是WHERE id 上一页位置 ORDER BY id DESC LIMIT XX数据库永远走索引扫描那几行响应时间不会因深页而退化。代价是失去了“总记录数”这个概念。如果业务必须显示“共X条”你要么维护一个总数字段做近似展示要么在数据量变大时选择其他方案。对于新闻推送、订单流、消息列表这类无限滚动的场景而言用户并不在乎总共多少条他们只关心下一条什么时候加载出来。5.3 表分区与归档从数据模型层面做优化最后如果你发现某个分页查询怎么优化都慢那可能不是查询写法的锅而是数据模型的问题。千万级以上的大表应该考虑分区或归档策略。MySQL 8.0支持RANGE、LIST、HASH等多种分区方式把“历史订单表”按月份分区后查询时会自动裁剪掉无关分区性能会大幅提升。归档策略则是把一年前的数据迁移到独立的历史库业务库保持轻量化。对于大多数业务系统来说“年深日久数据全挤在一张表里”往往是分页性能的根源。这类问题的解法不在SQL层而在架构层需要和业务方对齐数据保留策略让表结构走向更健康的状态。我做过一个库存流水系统单表数据涨到4000万行之后按月份做RANGE分区。之后的流水查询SQL基本在100毫秒内返回不需要改任何业务代码。这个例子给我的触动挺大的——有时候优化SQL不如优化表结构。6. 我的实测经验写分页时这几件事我一定会做实战项目做多了我慢慢养成了几个和分页查询有关的小习惯。这些习惯不算什么惊天动地的技巧但确实帮我省掉了很多事后救火的麻烦。第一写每一个分页接口的时候强制要求排序字段带主键。比如ORDER BY create_time DESC, id DESC。这样做的原因是当存在相同create_time的多条记录时如果不加主键做唯一排序MySQL返回的数据顺序可能不一致翻页时会出现同一条数据重复出现在两页里。这是前端最容易反馈的“翻页错乱”问题之一根因几乎都是排序字段不唯一。第二定义分页参数上限。PageSize不是一个任意传的数字我会在系统里统一限制上限比如最多100条。如果不做限制有人不小心传了pageSize10000等待你的就是一次高耗时查询和数据库连接占满。实现起来很简单加一个拦截器或者在Controller里做校验均可。第三每次分页查询后把状态清理干净。如果用了PageHelper我会在完成查询后调用PageHelper.clearPage()尤其在复杂调用链里。这个操作在单线程环境没事但一旦有线程池参与就可能是导致数据混乱的元凶。防御性编程能省很多晚上的值班电话。第四上线前把主要分页SQL都跑一遍EXPLAIN。这不一定能发现所有问题但能让你在看执行计划的时候提前意识到某条SQL在数据量增长到一定程度之后可能会变慢。提前建好索引好过线上告警后半夜爬起来加索引。分页查询看起来简单但我见过太多团队在最基础的环节上翻车原因都是以为它太简单了不值得额外关注。实际上从SQL写法、索引设计、拦截器机制到接口规范每一层都有它的讲究。把这些问题在项目初期就处理干净后面的迭代速度会顺畅很多。