资讯详情

Spring Boot员工分页查询实战:接口设计、分页插件与性能优化

📅 2026/10/21 2:59:53 | 华诺云谱 👁 阅读
Spring Boot员工分页查询实战:接口设计、分页插件与性能优化
后台系统里员工列表的分页查询说得夸张一点是每个 Java 后端新手都会碰到的“毕业设计级”功能。在苍穹外卖这类外卖后台管理系统中员工分页查询就是管理员页面上最常见的一个接口输入姓名做模糊搜索按启用/禁用状态过滤然后一页页翻看员工记录。功能本身不大但 Spring Boot 分层、MyBatis-Plus 分页插件、接口 DTO/VO 设计、前端分页组件联动这些知识点全都能从这个小功能里牵出来。适合刚学完 Spring Boot 想练手 CRUD 的同学也适合想系统梳理一遍列表查询接口写法的开发者参考。这篇文章我只聊一件事员工分页查询怎么做才对以及为什么这么做。我会把接口设计、代码链路、底层原理、前端对接、常见坑一次性讲透后面可直接照抄。1. 功能定位员工分页查询到底在解决什么问题很多人上来就写代码其实先把需求拆清楚后面能少走一半弯路。员工分页查询表面上是“查列表”背后要解决的问题至少有四个。1.1 拆解需求列表页背后的四个关键点第一个是数据量。员工表在运营一段时间后会积累几百上千条记录如果一次性全部返回前端渲染会卡网络响应会慢操作体验也差。分页的本质就是“分批给数据”每次只给当前页需要的那一小段。第二个是搜索条件。管理员不可能一页页去翻找某个员工他一定会输入姓名关键词来缩小范围。所以接口必须支持 name 模糊匹配不能只做全量分页。第三个是状态过滤。员工账号会有启用和禁用两种状态运营人员经常需要“只看已禁用员工”或者“只看正常在岗员工”这就要在查询条件里加入 status 字段作为精确过滤条件。第四个是排序稳定性。分页查询最怕排序字段不唯一。比如只按 create_time 倒序当两条记录的创建时间完全一样时翻页过程中数据可能会重复出现或被漏掉。这也是后面要专门讲的一个坑。1.2 技术栈与分层结构定位在苍穹外卖这种教学级外卖后台项目里技术栈一般是 Spring Boot MyBatis-Plus MySQL前端用 Vue 或类似框架。员工模块对应的表是 employee包含 id、name、username、password、phone、sex、id_number、status、create_time、update_time 等字段。这里有一条很实用的经验接口不要直接返回 Entity 实体类。employee 表里有密码字段有身份证号字段这些如果原样返回给前端就是严重的数据暴露问题。正确做法是分开 DTO 和 VO请求参数用 DTO返回展示数据用 VO实体类只留在 Service 和 Mapper 层内部用。提示实体类对应数据库表结构VO 对应前端页面需要展示的数据。凡是涉及敏感字段的表接口层一律用 VO 做隔离。技术分层上遵循标准的 Controller - Service - Mapper 三层。Controller 只接收参数和返回结果Service 负责业务逻辑和条件拼装Mapper 负责真正的数据库操作。分页查询虽然简单但也要严格走这套流程不要图省事把查询逻辑直接写进 Controller。2. 接口设计DTO、VO、返回值一个都不能少接口设计是分页查询最容易出彩也最容易出问题的环节。很多新手会直接把 Employee 实体类接收参数然后把 Page 对象原样返回前端拿到一坨用不到字段既不好维护也不安全。2.1 请求参数PageQueryDTO 如何体现“能少传就少传”分页查询的请求参数一般只需四个当前页码 page、每页大小 pageSize、员工姓名 name、员工状态 status。其中 page 和 pageSize 是必传项name 和 status 是可选过滤项。Data public class EmployeePageQueryDTO { private Integer page; private Integer pageSize; private String name; private Integer status; }这里选 Integer 而不是 int是因为 GET 请求可能缺参。如果缺参时用基础类型会自动赋为 0造成“默认查第 0 页”这种诡异现象。用包装类型可以明确接受 null然后在 Service 里做逻辑判断page 为空时取 1pageSize 为空时取 10。status 用 Integer 而不是 Boolean是因为员工状态一般用 0 和 1 表示但前端如果没有选择“全部”可能不传这个字段。如果用 Boolean 接收某些框架对 0/1 的解析会出现歧义Integer 最稳妥。名字字段直接叫 name不叫 employeeName。既然接口路径和模块已经明确了是员工模块参数命名越短越好前端拼接参数也更顺手。2.2 返回结构为什么是 total records 而不是只给列表分页接口需要让前端知道两件事当前页有哪些数据、总共有多少条数据。所以返回结构里至少要有 records 和 total。有的项目还会加 totalPage但我个人建议不需要前端也能算出来除非你有特殊展示需求。Data public class PageResult { private Long total; private List? records; }total 用 Long 而不是 Integer是因为 count 统计结果在 MyBatis 中通常就是 long而且从设计上限考虑员工数量理论上还是有超过二十亿的可能虽然很罕见但顺手就用了 Long。外层统一返回结构 Result包含 code、msg、data。前后端约定 code 为 1 时表示成功0 或其它值表示失败。这样前端只需要判断一个 code不用每个接口重新设计状态码。Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(1); r.setMsg(success); r.setData(data); return r; } }2.3 VO 设计展示层字段的安全隔离与脱敏EmployeeVO 只放前端真正需要的字段。比如 id、name、username、phone、sex、status、createTime。密码字段绝对不能出现身份证号在列表场景通常也不需要回传如果真的要看详情单独做一个详情接口并做脱敏处理。Data public class EmployeeVO { private Long id; private String name; private String username; private String phone; private String sex; private Integer status; private LocalDateTime createTime; }不要小看这一步。很多项目在分页查询里直接返回实体类等到安全审计时才发现密码哈希值、身份证号全被前端拿到这就是典型的设计失误。用 VO 隔离不是多写几个类的问题而是数据安全的基本素养。3. 核心链路Controller、Service、Mapper 一步步落地分页查询前面铺垫完了接下来是具体实现。我会分两条路线讲一条是 MyBatis-Plus 分页插件方式推荐日常开发使用一条是手写 SQL LIMIT 方式适合理解底层原理和教学演示。3.1 Controller 层只管收参数、返回结果Controller 层的代码很少但有一些细节值得注意。员工分页查询是 GET 请求参数通过 URL Query 传递所以 DTO 前面不需要加 RequestBody让 Spring 自动完成参数绑定即可。RestController RequestMapping(/admin/employee) public class EmployeeController { GetMapping(/page) public ResultPageResult page(EmployeePageQueryDTO queryDTO) { PageResult pageResult employeeService.pageQuery(queryDTO); return Result.success(pageResult); } }这里有两个细节。第一如果前端把 page 和 pageSize 传成字符串Spring 在绑定到 Integer 时如果格式不对会直接报 400所以前端传参时要注意数字类型。第二有些团队喜欢把 Service 返回值直接定为 Page 但这样会把 MyBatis-Plus 的 Page 对象暴露给上层。我更倾向于转成自己的 PageResult让 Controller 层不依赖具体持久层框架的类型。3.2 Service 层条件拼装与实体转 VOService 层是核心。这里要考虑空值处理、条件拼接、排序、实体转 VO 四件事。Service public class EmployeeServiceImpl implements EmployeeService { Autowired private EmployeeMapper employeeMapper; Override public PageResult pageQuery(EmployeePageQueryDTO dto) { int page dto.getPage() null ? 1 : dto.getPage(); int pageSize dto.getPageSize() null ? 10 : dto.getPageSize(); PageEmployee pageInfo new Page(page, pageSize); LambdaQueryWrapperEmployee wrapper new LambdaQueryWrapper(); wrapper.like(dto.getName() ! null !dto.getName().isEmpty(), Employee::getName, dto.getName()); wrapper.eq(dto.getStatus() ! null, Employee::getStatus, dto.getStatus()); wrapper.orderByDesc(Employee::getCreateTime); wrapper.orderByDesc(Employee::getId); PageEmployee employeePage employeeMapper.selectPage(pageInfo, wrapper); ListEmployeeVO records employeePage.getRecords().stream().map(item - { EmployeeVO vo new EmployeeVO(); BeanUtils.copyProperties(item, vo); return vo; }).collect(Collectors.toList()); PageResult pageResult new PageResult(); pageResult.setTotal(employeePage.getTotal()); pageResult.setRecords(records); return pageResult; } }重点说一下条件拼接的写法。lambdaQueryWrapper 的 like 和 eq 第一个参数是 boolean 条件只有条件为 true 时才拼接这条 SQL。这就是为什么 DTO 里的 name 和 status 要用包装类型可以用 null 判断来决定是否拼条件。name 为空时不能拼name like %null%status 为 null 时不能拼status null。排序这里我加了两层先按 create_time 倒序再按 id 倒序。这样能保证同一秒创建的多条记录也有一个唯一顺序翻页时不会乱。这个细节对分页稳定性非常重要。3.3 Mapper 方案一基于 MyBatis-Plus 分页插件实现MyBatis-Plus 的 BaseMapper 自带 selectPage 方法但这个方法必须依赖分页插件才能生效。如果你只调方法而不配置插件分页参数不会起作用查出来的可能是全量数据这也是最常见的“分页失灵”坑。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }配置完成后Mapper 接口不需要写任何分页代码直接继承 BaseMapper 即可。selectPage 接收两个参数Page 对象和 Wrapper 条件对象。插件在执行时自动帮我们完成 count 统计和 limit 拼接。Mapper public interface EmployeeMapper extends BaseMapperEmployee { }这种方式的好处是省心。你只需要关注业务条件分页 SQL 由框架自动处理而且它内部会解析原生 SQL把 count 查询和 limit 查询自动组装出来。3.4 Mapper 方案二手写 SQL 与 LIMIT如果你要在老项目里手写分页或者想彻底搞明白底层那就要写两条 SQL一条查列表一条查总条数。这里用 MyBatis 的动态 SQL 演示。select idpageQuery resultTypecom.example.entity.Employee select id, name, username, phone, sex, status, create_time, update_time from employee where if testname ! null and name ! and name like concat(%, #{name}, %) /if if teststatus ! null and status #{status} /if /where order by create_time desc, id desc limit #{offset}, #{pageSize} /select select idcountQuery resultTypelong select count(*) from employee where if testname ! null and name ! and name like concat(%, #{name}, %) /if if teststatus ! null and status #{status} /if /where /selectMapper 接口对应写法ListEmployee pageQuery(Param(name) String name, Param(status) Integer status, Param(offset) int offset, Param(pageSize) int pageSize); long countQuery(Param(name) String name, Param(status) Integer status);offset 的计算方式就是(page - 1) * pageSize。比如第 2 页、每页 10 条offset 就是(2-1)*1010也就是跳过前 10 条取第 11 到第 20 条。你会发现手写方式有一个很烦人的点查询条件和排序逻辑写了两次将来如果要加一个新过滤条件列表 SQL 和 count SQL 都要同步修改漏改一处就会出现“数据对不上总数”的诡异 bug。所以今天新项目我基本都推荐分页插件。3.5 两种方案对比选型要考虑什么维度MyBatis-Plus 分页插件手写 SQL LIMIT开发效率高无需写两条 SQL低列表和 count 都要维护可控性一般复杂 SQL 可能解析失败高SQL 完全由自己控制学习成本需要理解插件原理只需要懂 limit 语法适用场景常规 CRUD、标准列表查询定制化报表、超复杂 SQL我的建议是常规业务模块优先用分页插件碰到插件解析不了的复杂 SQL 再改手写。但无论是哪种方式都要理解 LIMIT 的语义和 offset 的计算因为这是分页的地基。4. 分页插件的底层逻辑与性能优化用插件不等于黑盒。很多问题排查到最后拼的都是对底层原理的理解比如 count 查询为什么不精确了、为什么翻页越来越慢。4.1 分页拦截器到底偷偷干了什么MyBatis-Plus 的 PaginationInnerInterceptor 本质上是 MyBatis 的一个内插器它会在 Executor 执行 query 之前拦截 SQL。拦截后它做两件事先根据当前 SQL 生成一条 count 语句执行用于拿总数再修改原 SQL在末尾追加 LIMIT 分页参数真正执行查询。count 语句的生成不是简单加个 count() 完事。它用到了 SQL 解析器把原 SQL 的 select 列替换成 count()去掉 order by尽量让 count 查询更快。这也是为什么插件能自动帮你完成 count但遇到特殊 SQL 时可能出现解析异常这时候就要手动关掉 count 自动生成或者直接手写分页。PageHelper 的原理也类似它通过 ThreadLocal 保存分页参数并在查询时拦截拼接 SQL。两者都能实现分页但 MP 分页插件把 Page 对象作为参数显式传入更符合直觉也不容易产生线程安全问题。4.2 COUNT 查询的优化空间分页接口一般会产生两条 SQLcount 和 list。count 查询在数据量少的时候毫秒级完成但表数据一旦到百万级count(*) 也会成为瓶颈。常见的优化手段有三种。第一给过滤字段和排序字段建合适的索引比如 status、name 这类过滤字段建立的普通索引可以让 count 和 where 都走索引扫描。第二如果业务允许不精确总数可以直接用分页插件里的近似 count 配置避免每次执行重 count。第三如果 where 条件特别复杂可以单独维护一张汇总表把常用条件对应的总数异步统计好。对于员工表这种最多几千条数据的场景这些优化先不用急但要知道有这些路子。我之前接过一个列表接口数据量涨到几十万条后接口越来越慢排查了半天就是 count 语句在扫全表。有时候优化不是提前做而是在性能问题出现时能精准定位。4.3 深度分页limit 大偏移是性能黑洞还有一个很容易被忽视的点limit 100000, 20 这种写法看着只取 20 条但数据库要先把前 100000 条全部扫描出来然后丢弃前 100000 条再返回 20 条。页码越深扫描量越大接口越慢。后台管理系统里员工列表的翻页深度通常不会太深一般翻到几十页就到头了。但如果类似订单列表这种数据量巨大的模块深度分页会变成灾难。解决办法是做“键集分页”记录上一页最后一条数据的排序字段值下一页用 where 条件筛掉已经看过的数据。select id, name, phone, status, create_time from employee where (create_time #{lastCreateTime}) or (create_time #{lastCreateTime} and id #{lastId}) order by create_time desc, id desc limit #{pageSize}这种写法跳过了 offset不管你翻到第几千页每次只扫描很小一段数据性能稳定很多。但缺点是不能直接跳转到任意页码只适合“加载更多”这种交互模式。员工分页查询用传统页码没问题但碰到大数据量列表时你要知道还有这条退路。5. 前端列表与分页组件的联动实现后端接口写好了前后端对接也是一个容易出现摩擦的环节。这里以 Vue Element UI 的 el-pagination 为例把常见的对接方式说清楚。5.1 请求参数和返回结构的对接前端请求接口时通过 params 对象把查询条件带过去。注意 GET 请求的 params 会被拼到 URL 后面所以像 page、pageSize、name、status 这些字段名要和后端 DTO 的属性名完全一致。export function listEmployee(params) { return request({ url: /admin/employee/page, method: get, params: params }) }页面里维护几个响应式变量page、pageSize、name、status、list、total。首次进入页面时调用一次查询点击搜索按钮时把 page 重置为 1 再查询切换页码时重新请求。handleQuery() { this.page 1 this.fetchList() }, handlePageChange(page) { this.page page this.fetchList() }, handleSizeChange(size) { this.pageSize size this.page 1 this.fetchList() }, fetchList() { this.loading true listEmployee({ page: this.page, pageSize: this.pageSize, name: this.name, status: this.status }).then(res { if (res.code 1) { this.list res.data.records this.total res.data.total } }).finally(() { this.loading false }) }这里有一个很关键的设计点修改每页大小后页码要重置为 1。否则你原来在第 5 页一页 10 条突然改成每页 20 条当前页码可能已经超出总页数前端会请求一个空数据页。这也是我在实际项目中踩过的坑。5.2 el-pagination 的常见坑与页面参数同步el-pagination 的 current-page 和 page-size 要与绑定的变量保持一致。如果 current-page 直接写死或者没有同步更新就会出现点击第 2 页后接口请求的还是第 1 页数据的问题。el-pagination :current-pagepage :page-sizepageSize :totaltotal :page-sizes[10, 20, 50] layouttotal, sizes, prev, pager, next, jumper current-changehandlePageChange size-changehandleSizeChange /还要注意总条数的类型。后端返回的 total 是 Long 类型JSON 序列化后到前端可能是数字字符串或数字。Element UI 对 total 的要求是 Number如果你发现分页组件显示“共 0 条”先检查一下 res.data.total 是否被当成字符串返回了。员工表数据量小这个坑不明显但大厂接口规范里一般会要求 Long 类型的 ID 序列化为字符串防止前端数字精度丢失。前端还有一个交互细节清空搜索条件时要把 name 和 status 重置同时重新从第 1 页开始查询否则会保留上一次的过滤条件导致“明明搜到了但列表是空的”这种让人一头雾水的问题。6. 实操中的常见问题与排查实战最后把我在实际写分页查询时遇到的问题集中整理一下。这些问题都很典型每一条背后都有真实踩坑经历。6.1 分页不生效数据一次全查出来最大的坑就是 MyBatis-Plus 的 selectPage 没有分页效果。很多人配置了 Page 参数结果接口把表里所有员工都返回了而且 total 还是 0。第一个排查点是分页插件是否注册。没有把 PaginationInnerInterceptor 加到 MybatisPlusInterceptor 里selectPage 就不具备分页能力。第二个排查点是数据库方言是否正确比如明明是 MySQL 却配成了 PostgreSQL 的方言。第三个排查点是自己手写了分页 SQL 但又传了 Page 对象导致分页逻辑被覆盖。注意分页插件是全局配置一旦某个 Mapper 里的 SQL 写了 limit再叠加插件可能出现重复 limit。所以手写 SQL 的路子要和插件方式在同一项目中保持一致不要混用。6.2 条件 name 为空时全表查询的边界处理这个问题很隐蔽。如果 name 传空字符串但后端使用了name ! null做判断空字符串也能通过判断导致拼接出name like %%看起来是没过滤实际上是对全表做了一次无意义的模糊匹配性能受损。正确处理是同时判断 null 和空字符串或者直接用StringUtils.hasText()。status 同理如果前端不传后端要用status ! null判断避免把“未传”误认为“传了 0”。0 是合法状态值传了 0 就要过滤禁用员工这点容易写错。我见过最离谱的一个 bug 是前端传了status0后端的判断却是if (status null || status 1)直接把合法的禁用状态过滤条件给吃了列表永远显示启用员工。排查了半天最终发现是判断逻辑的问题。6.3 排序不稳定导致翻页重复与缺失这个坑我反复提因为它的隐蔽性很强。如果排序字段只有 create_time而某一天新增了多条员工记录它们的创建时间很可能完全相同。MySQL 在排序值相同时返回顺序是不确定的上一页和下一页可能会出现同一条数据。解决方案就是我在 Service 层写的那样排序字段并列追加唯一字段用order by create_time desc, id desc。这样即使创建时间相同id 也能保证一个总顺序。另外如果要排序的字段上没索引数据量大了会出现 filesort接口会变慢。员工表上可以给 create_time 建索引搜索结果集小的时候排序效率会有明显提升。6.4 缓存、敏感字段与接口安全细节分页查询这个接口不太适合做 Redis 缓存。因为条件组合太多不同姓名、不同状态、不同页码缓存命中率很低还会带来数据不一致问题。如果员工新增或修改了之前的缓存页全部失效处理起来非常麻烦。所以这种列表接口一般直接查库就行除非数据量真的很大才有必要考虑用缓存加分页的组合方案。接口安全方面有两点必须提。第一是列表接口不要返回 password、id_number 这类敏感字段用 VO 隔离是最基本的做法。第二是 pageSize 要加一个上限限制防止有人传 pageSize10000 一次性把全表数据拉走。虽然员工表数据量小但这种行为会拖垮接口而且也是接口滥用的一种表现。在实际项目里我习惯在 Service 层做一步兜底如果 pageSize 超过 100就强制改成 100。前端分页最多给到 50后端上限 100怎么算都是合理的。6.5 常见问题速查表现象常见原因处理方式total 为 0 或分页不生效未配置分页插件添加 PaginationInnerInterceptor返回全量数据手写 SQL 与插件混用统一分页实现方式搜索姓名为空时很慢空字符串也拼了 like 条件用 StringUtils.hasText 判断状态过滤失效status0 被当成空值用 Integer 包装类型判断 status ! null翻页数据重复或缺失排序字段不唯一追加 id 作为唯一排序字段修改每页大小后空白页码未重置为 1size-change 时把 page 设为 1前端显示总数不对Long 被序列化为字符串统一前端类型处理或后端转 Number大页码接口很慢limit 大偏移考虑键集分页或限制最大页数个人实测下来员工分页查询真正花时间的往往不在写代码本身而是前后端字段对不上、条件判断写错、排序不稳定这类边界问题。把上面几张表的思路理清再遇到同类需求时基本可以一次性写对。最后再说个小经验像员工分页这种接口写完后一定要主动测几个边界场景——不传 name、不传 status、page 为空、pageSize 超上限、status 传 0。把这五种情况测完接口的稳定性就心里有底了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑