首页 / 资讯中心 / 文章详情

Spring Boot + Thymeleaf + PageHelper 服务端分页实战

Spring Boot + Thymeleaf + PageHelper 服务端分页实战 ★ FEATURED ARTICLE
1. 这套分页方案到底解决了什么问题做后台管理系统的人大概都有过这种体验列表页数据一多要么一次性全查出来卡到浏览器假死要么自己手写分页逻辑写到怀疑人生——页码计算、总条数、上一页下一页的边界判断稍不留神就出 bug。我这次接手的项目就是一个典型的中后台数据列表场景需求很朴素表格展示、分页翻页、每页条数可调前端不搞前后端分离那一套直接用服务端渲染把页面吐出来。技术选型上后端是 Spring Boot模板引擎用 Thymeleaf分页工具用 PageHelper 配合它的 PageInfo 封装。这套组合在服务端渲染的项目里算是相当成熟的搭配尤其适合那种不需要单独维护一套前端工程、页面逻辑相对固定的管理系统。标题里说的“前端引入式分页”指的是分页条不是靠前端框架动态渲染而是由后端把分页数据塞进模型Thymeleaf 模板里直接遍历生成页码链接用户点一下就走一次完整的请求-响应循环。这种方案能干什么一句话概括用最少的代码量把“查一页数据 渲染分页条 处理翻页参数”这条链路跑通并且页码、总数、边界状态全部由后端算好前端只负责展示。它解决的问题很具体——避免全量查询带来的内存和带宽浪费避免手写分页算法引入的边界错误同时让前后端职责清晰后端管数据切片模板管展示。适合谁来参考我觉得三类人最对口。第一类是刚接触 Spring Boot 服务端渲染、还在用List一把梭查全表的新手这套方案能让你快速建立分页的正确认知第二类是在维护老式 JSP/Thymeleaf 项目、需要给现有列表页补分页功能的中级开发者第三类是想搞清楚 PageHelper 和 PageInfo 到底怎么配合、参数怎么传、坑在哪里的同学。哪怕你后面要转前后端分离这套服务端分页的思维模型也是通用的只是把渲染环节换成接口返回 JSON 而已。我先把整体链路在脑子里过一遍方便后面拆细节用户访问列表页Controller 接收页码和每页条数两个参数调用 Service 层查询Service 里在真正执行 SQL 之前调用PageHelper.startPage(pageNum, pageSize)紧接着执行那条普通的查询语句PageHelper 通过拦截器自动改写 SQL 加上LIMIT查完之后把结果包装成PageInfoController 把PageInfo放进 ModelThymeleaf 模板遍历pageInfo.list渲染表格行再用pageInfo的页码属性渲染分页条。整条链路的关键在于startPage 必须紧挨着查询语句这个顺序错了分页就失效后面会重点讲。2. 整体设计与技术选型的取舍逻辑2.1 为什么是服务端分页而不是前端分页分页这件事本质上是在“数据切片发生在哪一层”上做选择。前端分页是把全量数据一次性给浏览器由 JavaScript 在本地做切片展示服务端分页是每次只取当前页需要的那一小段。两者的分水岭在于数据量级和实时性要求。我见过不少项目在数据量只有几百条的时候用前端分页确实简单一次查询全给前端翻页零延迟。但一旦数据涨到几万条全量查询的代价就藏不住了数据库要扫全表、网络要传全量、浏览器内存要扛住整个数组首屏加载时间肉眼可见地变长。更麻烦的是如果列表还带筛选条件前端分页的“总数”和“筛选结果”容易对不上逻辑会变得很绕。服务端分页的代价是每次翻页都要走一次请求但换来的是内存占用恒定、首屏只传一页数据、总数由数据库精确统计。对于中后台这种“数据会持续增长、但用户每次只看一屏”的场景服务端分页是更稳的选择。标题里“前端引入式”这个说法我理解强调的是分页条由模板渲染、翻页靠链接跳转而不是靠 AJAX 局部刷新这进一步降低了前端复杂度——不需要维护一套前端状态管理页面刷新即最新状态。2.2 PageHelper 与 PageInfo 的分工很多人把这两个东西混为一谈其实职责完全不同。PageHelper 是拦截器层面的工具它的核心动作是在 MyBatis 执行 SQL 前做一次“偷梁换柱”检测到startPage被调用过就把原 SQL 改写成带LIMIT offset, size的语句同时额外发一条COUNT查询算出总条数。PageInfo 是结果封装对象它把 PageHelper 查出来的Page对象本质是个 List但携带了分页元信息转换成模板友好的属性集合比如pageNum、pageSize、total、pages、hasPreviousPage、hasNextPage、isFirstPage、isLastPage这些。为什么要有 PageInfo 这一层因为 PageHelper 返回的Page对象虽然也带这些信息但它的属性命名和布尔判断对模板不够友好而且Page直接暴露给视图层会让模板和 MyBatis 插件产生耦合。PageInfo 做了一次干净的转换模板里只需要认 PageInfo 的字段将来换分页实现也不影响模板。这个分层思路值得学数据访问层的产物不要直接扔给视图层中间加一层视图模型转换。2.3 Thymeleaf 在分页渲染中的角色定位Thymeleaf 是服务端模板引擎它的分页渲染逻辑就是“拿到 PageInfo用th:each遍历数据行用th:href拼页码链接”。相比 JSPThymeleaf 的表达式更简洁th:if做边界判断、th:classappend做当前页高亮都很顺手。相比前端框架它不需要额外的构建工具和运行时模板文件改完刷新即生效调试成本低。这里有个设计取舍要说明分页条的页码链接是“全量渲染”还是“窗口渲染”。全量渲染就是把 1 到总页数全部生成链接简单但页数多了会撑爆页面窗口渲染是只显示当前页附近的几个页码加省略号。我在这个项目里选了窗口渲染因为后台数据动辄几十上百页全量渲染的 DOM 节点太多而且用户体验也不好。窗口逻辑放在模板里用 Thymeleaf 的算术表达式实现具体写法后面实操部分给。3. 核心细节解析与实操要点3.1 startPage 的调用时机是生死线这是 PageHelper 最容易踩的坑没有之一。PageHelper.startPage(pageNum, pageSize)的本质是往当前线程的ThreadLocal里塞一个分页参数然后 MyBatis 的拦截器在下一条 SQL 执行时读取这个参数并改写 SQL。这意味着两件事第一startPage 之后必须紧跟真正要分页的那条查询中间不能插入别的查询第二分页参数是线程绑定的异步或多线程场景要特别小心。我见过最典型的错误写法是这样的先调startPage然后做了一堆参数校验、调了另一个 Service 查了个字典表最后才执行列表查询。结果字典表那条 SQL 被分页了列表查询反而没分页数据全量返回。排查的时候看日志会发现 COUNT 语句查的是字典表非常迷惑。正确做法是把startPage放在紧贴查询语句的前一行中间不夹任何数据库操作。注意如果确实需要在分页查询前做校验或查辅助数据把这些操作全部挪到startPage之前完成或者干脆在 Controller 层做完再进 Service。3.2 页码与条数的参数校验不能省前端传来的pageNum和pageSize是用户可控的直接透传给 PageHelper 有风险。pageNum传 0 或负数PageHelper 会做一定兜底但不同版本行为不完全一致pageSize传个 100000等于变相全量查询把分页的意义抵消了。所以参数进来第一件事是校验和归一化。我的处理习惯是pageNum小于 1 时归一到 1pageSize设一个白名单比如只允许 10、20、50、100 这几个值传了别的就回落到默认 10。这样既防了恶意大值也让前端下拉框的选择和后端逻辑对齐。另外pageSize的上限一定要卡死我一般设 100超过就截断。这个上限不是拍脑袋是结合单页表格的可读性和单次查询的响应时间定的——一页超过 100 行用户滚动和阅读都累数据库单次返回的字段量也上去了。3.3 PageInfo 的常用属性要烂熟于心模板里渲染分页条靠的全是 PageInfo 的属性。我把最常用的几个列出来这些属性名在 Thymeleaf 表达式里直接可用属性名含义典型用途pageNum当前页码高亮当前页、拼链接pageSize每页条数条数下拉框回显total总记录数显示“共 X 条”pages总页数计算页码窗口范围hasPreviousPage是否有上一页上一页按钮禁用判断hasNextPage是否有下一页下一页按钮禁用判断isFirstPage是否首页首页按钮禁用判断isLastPage是否末页末页按钮禁用判断navigatePages导航页码数窗口大小默认 8navigatepageNums导航页码数组遍历生成页码链接navigatepageNums这个属性特别值得说它是 PageInfo 帮你算好的“当前页附近的页码数组”配合navigatePages使用直接遍历就能生成窗口化的页码省得自己在模板里算偏移量。不过它的窗口逻辑是固定以当前页为中心取前后各若干页首尾页的衔接处理不如手写灵活所以我在项目里还是手写了窗口计算navigatepageNums作为备选方案保留。3.4 查询语句本身要保持“干净”PageHelper 改写 SQL 的前提是它能识别出你的查询语句。这里有个隐含要求被分页的查询最好是一条简单的单表或常规联表查询不要在里面嵌套复杂的子查询、UNION 或者存储过程调用。PageHelper 的 COUNT 查询是把原 SQL 包一层SELECT COUNT(*) FROM (原SQL)来算总数的如果原 SQL 里有ORDER BY某些版本会自动去掉排序以提升 COUNT 性能但如果 SQL 结构太复杂COUNT 的准确性和性能都可能出问题。我的经验是分页查询尽量走“主表 必要关联”的简单结构复杂统计逻辑单独用一条查询算好再合并不要硬塞进分页 SQL 里。另外查询语句里不要写LIMITPageHelper 会自己加你手写了会冲突。还有ORDER BY要显式写上否则分页结果的顺序不稳定翻页时可能出现同一条数据在两页都出现或都不出现的情况——这不是 PageHelper 的 bug是数据库在没有明确排序时返回顺序不确定导致的。4. 实操过程与核心环节实现4.1 依赖引入与基础配置项目是 Maven 构建分页相关的依赖主要是 PageHelper 的 Spring Boot Starter。这里要注意版本匹配Starter 的版本要和 Spring Boot 大版本对齐否则可能出现自动配置不生效的问题。我用的组合是 Spring Boot 2.x 配 PageHelper Starter 1.4.x这个搭配比较稳。dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.6/version /dependency配置文件里把分页的默认参数设好这样即使某处忘了传参也有兜底pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSqlhelper-dialect指定数据库方言PageHelper 靠它生成对应数据库的 LIMIT 语法reasonable设为 true 是个很实用的开关它会在页码超出范围时自动纠正——比如总共 5 页用户传了第 10 页它会返回最后一页而不是空列表传了第 0 页会返回第一页。这个“合理化”行为对用户体验友好但要注意它和参数校验是互补关系不能因为开了它就不做前端参数校验。4.2 Controller 层的参数接收与模型装配Controller 的职责很清晰接参数、做归一化、调 Service、装模型、返回视图名。参数用RequestParam接收并给默认值这样用户直接访问列表页不带任何参数也能正常显示第一页。GetMapping(/list) public String list(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String keyword, Model model) { pageNum pageNum 1 ? 1 : pageNum; pageSize normalizePageSize(pageSize); PageInfoItemVO pageInfo itemService.queryByPage(pageNum, pageSize, keyword); model.addAttribute(pageInfo, pageInfo); model.addAttribute(keyword, keyword); model.addAttribute(pageSizeOptions, Arrays.asList(10, 20, 50, 100)); return item/list; }这里有个细节keyword这个筛选条件要原样回传到模型里因为分页条的每个链接都要带上它否则翻页之后筛选条件就丢了。这是服务端渲染分页的一个常见疏漏——翻页链接只拼了页码忘了拼其他查询参数导致用户翻到第二页发现筛选失效。我的做法是把所有查询条件统一放进模型模板里拼链接时全部带上。normalizePageSize方法就是前面说的白名单校验private Integer normalizePageSize(Integer pageSize) { ListInteger allowed Arrays.asList(10, 20, 50, 100); return allowed.contains(pageSize) ? pageSize : 10; }4.3 Service 层的分页查询写法Service 层是分页逻辑的核心startPage和查询语句的紧邻关系在这里体现。写法上我习惯把分页查询单独成一个方法方法内第一行就是startPage紧接着就是 Mapper 调用中间不夹任何逻辑。public PageInfoItemVO queryByPage(Integer pageNum, Integer pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); ListItemVO list itemMapper.selectByCondition(keyword); return new PageInfo(list); }new PageInfo(list)这一步是关键转换。传进去的list实际上是 PageHelper 返回的Page对象它实现了 List 接口PageInfo 的构造函数会识别出这个类型并从中提取分页元信息。如果你把list先做了stream().map()之类的转换再传给 PageInfo元信息就丢了total会变成当前页的条数分页条直接错乱。这个坑我踩过当时对列表做了个字段映射结果总数永远等于每页条数排查了半天才发现是转换破坏了 Page 类型。注意如果确实需要对结果做转换正确做法是先new PageInfo(原始list)拿到分页信息再对pageInfo.getList()做转换并 set 回去而不是在构造 PageInfo 之前转换。4.4 Thymeleaf 模板的数据行渲染模板部分分两块数据表格和分页条。数据行渲染比较直接th:each遍历pageInfo.list每行取字段。这里要注意空列表的处理pageInfo.list为空时给个“暂无数据”的提示行避免表格空荡荡。table thead trth编号/thth名称/thth状态/thth创建时间/th/tr /thead tbody tr th:eachitem : ${pageInfo.list} td th:text${item.id}/td td th:text${item.name}/td td th:text${item.statusText}/td td th:text${#dates.format(item.createTime, yyyy-MM-dd HH:mm)}/td /tr tr th:if${#lists.isEmpty(pageInfo.list)} td colspan4暂无数据/td /tr /tbody /table#lists.isEmpty是 Thymeleaf 内置的工具方法比写pageInfo.list.size() 0更语义化。日期格式化用#dates.format注意createTime得是java.util.Date类型如果是LocalDateTime要用#temporals.format这两个工具类别搞混混用会报模板解析异常。4.5 分页条的窗口化渲染实现分页条是这套方案里最需要动脑的部分。我采用窗口化渲染规则是始终显示首页和末页当前页前后各显示 2 页中间用省略号连接。这样无论总页数多少分页条的宽度都稳定在 7 到 9 个元素左右。先在 Controller 或模板里算出窗口的起止页码。我倾向在模板里用 Thymeleaf 表达式算减少后端负担div classpagination th:if${pageInfo.pages 0} span th:text共 ${pageInfo.total} 条第 ${pageInfo.pageNum} / ${pageInfo.pages} 页/span a th:if${pageInfo.hasPreviousPage} th:href{/item/list(pageNum${pageInfo.pageNum - 1}, pageSize${pageInfo.pageSize}, keyword${keyword})}上一页/a span th:unless${pageInfo.hasPreviousPage} classdisabled上一页/span a th:if${pageInfo.pageNum 3} th:href{/item/list(pageNum1, pageSize${pageInfo.pageSize}, keyword${keyword})}1/a span th:if${pageInfo.pageNum 4}.../span a th:eachp : ${#numbers.sequence( T(java.lang.Math).max(1, pageInfo.pageNum - 2), T(java.lang.Math).min(pageInfo.pages, pageInfo.pageNum 2))} th:href{/item/list(pageNum${p}, pageSize${pageInfo.pageSize}, keyword${keyword})} th:text${p} th:classappend${p pageInfo.pageNum} ? active : /a span th:if${pageInfo.pageNum pageInfo.pages - 3}.../span a th:if${pageInfo.pageNum pageInfo.pages - 2} th:href{/item/list(pageNum${pageInfo.pages}, pageSize${pageInfo.pageSize}, keyword${keyword})} th:text${pageInfo.pages}/a a th:if${pageInfo.hasNextPage} th:href{/item/list(pageNum${pageInfo.pageNum 1}, pageSize${pageInfo.pageSize}, keyword${keyword})}下一页/a span th:unless${pageInfo.hasNextPage} classdisabled下一页/span /div这段模板有几个技术点值得拆开说。{...}是 Thymeleaf 的链接表达式括号里是查询参数它会自动做 URL 编码keyword里有中文或特殊字符也不会出问题。#numbers.sequence(start, end)生成一个整数序列配合T(java.lang.Math).max/min把窗口范围夹在 1 到总页数之间避免出现 0 或超出总页数的页码。th:classappend做当前页高亮表达式返回布尔值true 时追加active类。省略号的显示条件要仔细推敲当前页大于 3 时前面才需要显示首页和省略号当前页小于总页数减 2 时后面才需要省略号和末页。这些阈值和窗口大小前后各 2 页是联动的改窗口大小就要同步改阈值否则会出现“省略号后面紧跟一个和窗口重复的页码”这种尴尬情况。4.6 每页条数切换与筛选条件保持每页条数切换用下拉框选中后触发页面跳转。这里有个体验细节切换条数时应该回到第一页因为原来的页码在新条数下可能已经超出范围。实现上用onchange拼 URL 跳转把pageNum固定为 1。select onchangelocation.href/item/list?pageNum1pageSize this.value keyword encodeURIComponent([[${keyword}]]) option th:eachsize : ${pageSizeOptions} th:value${size} th:text${size 条/页} th:selected${size pageInfo.pageSize}/option /select[[${keyword}]]是 Thymeleaf 的内联表达式在 JavaScript 里取值要用这种写法直接写${keyword}在 script 或事件属性里不会被解析。encodeURIComponent不能省否则 keyword 里有或空格会把 URL 参数截断。这个下拉框的写法虽然用了内联 JS但整体还是服务端渲染的思路没有引入前端框架。5. 常见问题与排查技巧实录5.1 分页失效、数据全量返回的排查路径分页失效是最高频的问题表现是页面显示所有数据、分页条显示总页数为 1。排查按这个顺序走第一确认startPage是否真的被调用了打断点或加日志看第二确认startPage和查询语句之间有没有夹别的数据库操作有的话挪走第三确认查询返回的list是不是被转换过再传给 PageInfo转换过就按前面说的方法调整第四确认 PageHelper 的依赖和配置是否生效看启动日志里有没有 PageHelper 的初始化信息。还有一种隐蔽情况项目里如果同时引入了多个 MyBatis 插件比如某些数据权限插件插件的执行顺序可能影响 PageHelper 的拦截。这种情况看 SQL 日志最直接——正常分页时日志里应该能看到一条 COUNT 语句和一条带 LIMIT 的查询语句如果只有查询没有 COUNT说明拦截器没生效。5.2 总数不对、翻页出现重复或遗漏数据总数不对通常和 COUNT 查询有关。如果查询语句里有GROUP BYPageHelper 的 COUNT 会把分组后的结果再包一层统计某些版本下算出来的总数是分组数而不是记录数需要手动指定 count 语句。配置里的params: countcountSql就是为这种情况准备的可以在 Mapper 里单独写一个 count 方法通过countSql参数指定。翻页重复或遗漏十有八九是排序不稳定。数据库在没有明确ORDER BY时两次查询的返回顺序可能不同导致同一条记录在翻页时漂移。解决办法很简单查询语句里加上确定性的排序字段最好用主键或唯一索引字段兜底比如ORDER BY create_time DESC, id DESC。这个细节在数据量小的时候不容易暴露数据一多就原形毕露。5.3 页码越界与空页处理用户手动改 URL 传个超大页码或者筛选后结果变少导致当前页超出范围都会出现空页。开了reasonable: true之后 PageHelper 会自动纠正到最后一页但模板上的分页条高亮和页码显示要跟着对。我的做法是在 Controller 里拿到 PageInfo 后判断一下如果pageInfo.getList()为空且pageInfo.getPageNum() 1重定向到第一页或最后一页避免用户看到空白页。还有一种情况是筛选条件变化后页码没重置。比如用户在第 5 页然后输入关键词搜索结果只有 2 页这时应该回到第 1 页。处理方式是在搜索表单提交时把pageNum固定为 1和条数切换的逻辑一致。5.4 常见问题速查表现象可能原因解决方向数据全量返回总页数为 1startPage 未生效或被其他查询消耗检查调用顺序确认紧邻查询总数等于每页条数list 被转换后传给 PageInfo先构造 PageInfo 再转换 list翻页数据重复或遗漏查询无确定性排序加 ORDER BY 主键兜底页码超出范围显示空白未开 reasonable 或未做重定向开配置项 Controller 兜底筛选后翻页条件丢失分页链接未带筛选参数模板链接补全所有查询参数COUNT 语句报错原 SQL 含 GROUP BY 或复杂子查询单独指定 countSql条数切换后页码错乱切换时未重置 pageNum切换链接固定 pageNum15.5 几个踩过坑才明白的实操心得第一个心得分页查询的 Service 方法不要做事务之外的副作用操作。我见过有人在分页方法里顺手更新了个访问计数结果那条 UPDATE 被 PageHelper 的拦截器误判虽然一般不会但把写操作和分页查询混在一个方法里排查问题时干扰太大。分页方法就老老实实只读。第二个心得PageInfo 的list属性在模板里遍历时不要在循环里再发起数据库查询。这是 N1 问题的变种模板里一个th:each里调 Service 方法查关联数据一页 20 条就是 20 次查询页面响应直接翻倍。关联数据要么在分页查询时用联表一次带出来要么单独批量查好放进模型。第三个心得分页条的链接用{...}表达式而不是手拼字符串。手拼字符串在参数有中文、空格、特殊符号时容易出编码问题{...}会自动处理 URL 编码省心很多。我早期手拼过一次keyword 里带了个翻页后筛选条件直接被截断用户反馈“搜索着搜索着条件就没了”查了半天才发现是 URL 拼接的锅。第四个心得开发阶段把 SQL 日志打开。PageHelper 是否生效、COUNT 语句长什么样、LIMIT 参数对不对看日志一目了然。配置里把 MyBatis 的日志级别调到 DEBUG配合 PageHelper 的日志分页链路的每一步都清清楚楚。上线前再调回 INFO避免日志量过大。6. 方案边界与后续可扩展方向这套“Spring Boot Thymeleaf PageHelper PageInfo”的组合优势在于轻量和直接但它有明确的适用边界。数据量在百万级以内、查询条件不算特别复杂、页面以服务端渲染为主的中后台用起来非常顺手。但如果列表需要支持多列排序、列筛选、行内编辑这些交互密集的功能服务端渲染的每次翻页都走完整请求体验上会不如前后端分离的方案。我在实际项目里给它留了扩展口子Service 层的分页方法签名保持稳定返回 PageInfo将来如果要转成接口返回 JSONController 换个返回类型就行Service 和 Mapper 完全不用动。模板里的分页条逻辑也可以抽成一个 Thymeleaf fragment多个列表页复用同一套分页 UI改一处全局生效。fragment 的传参用th:fragment和th:replace把 PageInfo 和基础 URL 作为参数传进去这样新增列表页时只需要写数据表格部分分页条直接引用。另外一个小优化方向是给分页链接加上“跳转到指定页”的输入框配合前面的窗口化渲染用户在大页数场景下能快速定位。实现上就是一个输入框加按钮点击时拼 URL 跳转页码做一下范围校验即可。这个功能不复杂但对几十页以上的列表体验提升明显值得加上。最后分享一个我在调试分页时常用的小技巧在模板的分页条旁边临时加一行调试信息把pageInfo的关键属性都打出来——pageNum、pageSize、total、pages、hasPreviousPage、hasNextPage。翻页时盯着这几个值看哪里不对一眼就能定位。调试完把这行删掉或者用th:if${false}关掉不影响正式页面。这个习惯帮我省了大量“猜哪里错了”的时间比反复看代码高效得多。
阅读完成 · 觉得有帮助?
咨询建站