前阵子帮朋友梳理外卖管理后台项目(就是大家常说的苍穹外卖)里的员工分页查询功能,做完一圈下来才发现,这个看起来“就是查个列表”的接口,其实串联了一整套前后端约定、分页插件原理、动态SQL写法,还有联调期各种让人抓狂的细节。这篇把我在实际项目里怎么把员工分页查询从零做到可上线的完整过程写出来:从接口参数怎么定义、PageHelper到底帮我们做了什么,到Mapper的XML里怎么把name模糊查询和status过滤写成不出错的动态SQL,再到前端表格联调时数据对不上的排查经验。打算自己实现这个功能、或者做完总被测试提bug的同学,可以直接照着这个思路走一遍。
1. 先理清需求:这个分页接口要同时满足三件事
1.1 分页参数、查询条件和响应结构怎么定
苍穹外卖这类管理后台的员工管理页面,核心诉求其实很朴素:要能在几百上千条员工记录里快速翻页,支持按员工姓名搜索,还要能根据账号状态做筛选。最初接到这个任务的时候,很容易只想着“写一个SELECT * FROM employee LIMIT ...”,但真正动手前必须把接口的入参和出参约定死,不然后端写完了前端根本没法接。
我这边定的请求参数是四个,全部通过GET请求传:
page:当前页码,从1开始,默认值1;pageSize:每页条数,我习惯默认10,最大限制100;name:员工姓名,非必填,需要做模糊匹配;status:账号状态,非必填,1为启用,0为禁用。
响应结构我用了项目统一的Result<T>外壳,里面的数据体叫PageResult<T>,包含两个字段:total和records。很多新手在这里会犯一个逻辑错误,以为分页查询就是“把查出来的List塞给前端,前端自己分成一页页”,实际上后端必须在一开始就算清楚总条数,因为前端表格的页码条要靠total渲染,这也是为什么分页查询不能简单用一条LIMITSQL搞定——你得一条SELECT COUNT(*)和一条带LIMIT的查询搭配使用。
MySQL原生的分页写法是这个思路:
-- 第一条:数总数 SELECT COUNT(*) FROM employee WHERE name LIKE CONCAT('%', #{name}, '%') AND status = 1; -- 第二条:取当前页数据 SELECT * FROM employee WHERE name LIKE CONCAT('%', #{name}, '%') AND status = 1 ORDER BY create_time DESC LIMIT 0, 10;两条SQL共用同一个WHERE条件,才能保证“当前页数据量”和“总条数”是同一个查询口径下的结果。如果这两条SQL的条件不一致,就会出现第一页显示10条、总条数却是1000条这种诡异现象。这里先记住这个结论,后面联调环节会遇到它的实战版本。
1.2 模糊查询还是精确查询:name和status在SQL里的待遇完全不同
在SQL层面,name和status的处理方式差异很大。name是模糊查询,SQL里要用LIKE才能匹配“名字里包含某个关键字”的员工;status是筛选条件,是精确等值判断。这两个字段在做动态SQL时都要加<if>判断,但判断逻辑的写法不同:
name要判断是否为null且去除首尾空格后是否为'';status只需要判断是否为null,因为它是Integer类型,不存在“空白字符串”的概念。
为什么name要处理空格?因为用户在搜索框里输入“张 三”(中间或后面带了空格),或者前端传参时把搜索表单里的空字符串也传了过来,如果不做trim或判空处理,SQL就会变成WHERE name LIKE '%%',等于全表匹配,直接把所有员工查出来,分页就直接失去了意义。这条我在实际项目里遇到过,前端搜索框输入一个空格就导致了整个列表数据错乱。
1.3 员工列表不能把密码和完整手机号直接抛给前端
这一点是最容易被忽略的。员工表employee里存了password字段,存放的是加密后的密码哈希值,还有完整的手机号phone、身份证号id_number。分页查询接口如果直接拿着List<Employee>返回给前端,相当于把密码哈希和敏感证件信息也暴露在了浏览器网络面板里,谁都能打开F12看到,安全隐患非常大。
正确的做法是建一个EmployeeVO视图对象,只暴露界面需要的字段:id、username、name、phone、sex、idNumber、status、createTime、updateTime。phone还要做脱敏处理,只显示前三位和后四位,中间四位用****遮住。这个操作在分页查询的Service层做掉,不要在实体类里做局部的getter改造,那样会污染其他业务接口。
2. Controller到Service:把握住查询链路里容易翻车的两个点
2.1 Controller层的参数解析与统一返回
Controller是入口,它负责把HTTP请求里的查询参数绑定到业务对象上。苍穹外卖项目里管理端接口统一挂/admin前缀,员工管理模块是/admin/employee,所以分页查询接口的完整路径就是GET /admin/employee/page。
我的写法是这样:
@RestController @RequestMapping("/admin/employee") public class EmployeeController { @Autowired private EmployeeService employeeService; @GetMapping("/page") public Result<PageResult<EmployeeVO>> page( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String name, @RequestParam(required = false) Integer status) { PageQueryDTO pageQueryDTO = PageQueryDTO.builder() .page(page) .pageSize(pageSize) .name(name) .status(status) .build(); PageResult<EmployeeVO> pageResult = employeeService.pageQuery(pageQueryDTO); return Result.success(pageResult); } }两点说明。第一,@RequestParam(required = false)表示这个参数不是必填的,前端没传name时这个值就是null,这是为了配合后面Mapper XML里的<if test="name != null">;第二,分页参数我封装到了PageQueryDTO,而不是散着传进Service,因为后面可能会有更多查询条件加进来(比如账号创建时间范围、部门ID),到时候直接往DTO里加字段就行,方法签名不用动。
这里有个经验之谈:分页查询的pageSize一定要做上限限制。前端翻车或者恶意请求可能传pageSize=999999,如果后端不做保护,一条SQL查出来几十万条数据,内存和网络都会被打爆。我通常会在Service层加一个判断,如果pageSize大于100就直接赋值为100。
2.2 PageHelper.startPage的魔法与ThreadLocal原理
有了接口和DTO,接下来就是Service层。很多人习惯在Service里手写LIMIT,但苍穹外卖这类项目通常会引入分页插件,最主流的就是 MyBatis 的 PageHelper。为什么不用手写?因为手写LIMIT意味着要人工管理两条SQL(count和list),而这两条SQL的动态条件还完全一样,复制粘贴的时候一旦漏改一个条件,总数和列表就对不上。PageHelper 的startPage能在查询方法执行前自动把 count 和 limit 都处理好,从开发效率上说,这是最优选。
但startPage的原理值得讲透,不然出了问题根本无从查。它内部用了一个ThreadLocal存储分页参数,当 MyBatis 执行查询时,PageHelper 的拦截器会拦截要执行的SQL,再基于这个ThreadLocal里的页码和每页条数分别生成并执行count查询与带LIMIT的查询。这个机制有两个关键含义:
startPage调用之后,必须紧接着执行你要分页的那条Mapper查询,中间不要再执行任何其他SQL,否则分页参数会被其他查询消费掉;- 查询完成后 PageHelper 会清理
ThreadLocal,但如果分页查询抛出异常,某些版本可能不会正确清理,下一次查询就可能串页,所以尽量把项目里的 PageHelper 升级到较新版本。
Service层的核心代码:
public PageResult<EmployeeVO> pageQuery(PageQueryDTO queryDTO) { // 开启分页,注意:next line must be the first query PageHelper.startPage(queryDTO.getPage(), queryDTO.getPageSize()); List<Employee> employees = employeeMapper.pageQuery(queryDTO); // PageHelper 会把查询结果包装成 Page 对象 Page<Employee> page = (Page<Employee>) employees; List<EmployeeVO> voList = page.getResult().stream() .map(employee -> { EmployeeVO vo = new EmployeeVO(); BeanUtils.copyProperties(employee, vo); vo.setPhone(desensitizePhone(employee.getPhone())); vo.setIdNumber(desensitizeIdCard(employee.getIdNumber())); return vo; }) .collect(Collectors.toList()); return new PageResult<>(voList, page.getTotal()); }注意一个细节:PageHelper.startPage只对它的下一条SELECT语句生效,而且方法返回值就是List<Employee>本身,真实类型是com.github.pagehelper.Page。所以我在代码里直接拿到page.getTotal(),这个值就是count查询算出来的总条数,不需要自己再调一次count。这也是我坚持用插件而不是手写SQL的原因之一——total是插件在底层自动补齐的,天然和list查询保持同一个查询条件。
2.3 Service层的实体转VO:为什么非要绕一层
有同学可能会问:既然 PageHelper 已经把Page<Employee>返回了,直接把这个对象塞给前端不就行了?不行,回到我们1.3节说的脱敏问题。Employee实体类对应数据库表,里面所有字段都齐,包括password。如果直接返回实体,密码哈希就会跟着响应体一起到浏览器。虽然哈希值不能被直接还原成明文,但它属于典型的敏感信息泄露,安全扫描一抓一个准。
用BeanUtils.copyProperties把同名字段复制到VO是一种很省力的写法。不过要注意,BeanUtils在不同包下行为有些差异,我习惯用的是 Spring 的org.springframework.beans.BeanUtils,按属性名匹配,性能也够用。脱敏方法要单独写,比如手机号脱敏就一行正则的事:
private String desensitizePhone(String phone) { if (phone == null || phone.length() != 11) { return phone; } return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2"); }这种“实体查询 + VO返回”的组合不仅在分页查询里用,整个苍穹外卖路线的管理端接口都推荐这样设计,它让数据库表结构、内部业务模型、外部接口模型三者解耦,后端后来加字段改表结构,只要VO不变,前端就不会受影响。
3. Mapper层与XML动态SQL:分页查询的决胜点
3.1 接口定义与XML的字段列表
分页查询最后真正执行SQL的地方在Mapper。接口方法我定义成:
public interface EmployeeMapper { List<Employee> pageQuery(PageQueryDTO queryDTO); }这里我特意把 DTO 整体作为参数传进去,而不是@Param("name") @Param("status")一个个传,原因是参数多了以后代码会变得很难维护,而且Mapper XML里的<if>判断也会更啰嗦。用一个DTO,XML里直接用属性名。
对应的XML如下:
<select id="pageQuery" resultType="com.example.entity.Employee"> SELECT id, username, name, phone, sex, id_number, status, create_time, update_time FROM employee <where> <if test="name != null and name.trim() != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY create_time DESC, id DESC </select>注意我SELECT的字段列表里刻意没有写password,这是第二层防护。即便哪一天代码被人仓促改成了SELECT *,上面还有VO这一层保险;反过来即使VO漏了脱敏,这里也先挡了一道。两层防护叠起来,安全才靠谱。
3.2 和 的正确组合方式
动态SQL是这个XML的灵魂。<where>标签有一个很重要的特性:它会自动去掉第一个条件前面的AND或OR。也就是说当所有条件都不传时,生成的SQL是:
SELECT ... FROM employee ORDER BY create_time DESC, id DESC而不是带上一个悬空的WHERE导致语法错误。
<if>里name的判断是重点。我写了name != null and name.trim() != '',这是我在实战里吃过大亏之后学乖了。最早我跟着教程写的是name != null and name != '',结果前端传了个name=" "(一个空格),这个判断为true,SQL变成了AND name LIKE '% %',把名字里带空格的字全筛出来了,总数和列表看着都正常,但数据就是不对。加了trim()之后,这种空字符串才不会进条件。
LIKE CONCAT('%', #{name}, '%')这个写法同样有讲究。我见过有人直接写成LIKE '%${name}%',这是典型的SQL注入漏洞写法——${}是字符串拼接,用户输入的内容会被直接拼进SQL。而#{}是预编译参数,MyBatis会把它转义成?,再用JDBC的预编译占位符执行。加上CONCAT('%', #{name}, '%')是因为'%' #{name} '%'这种写法拼接出来会有多余的引号问题,很容易看晕。安全永远是第一位的,这一点没有商量余地。
3.3 ORDER BY的稳定性:没排序,分页就是薛定谔的数据
我在XML最后固定加了两级排序:
ORDER BY create_time DESC, id DESC为什么要id DESC兜底?因为create_time是时间类型,如果两条员工记录的创建时间完全相同,只有create_time DESC时,这两行的相对顺序在MySQL里是不确定的。数据库查询没有保证返回顺序稳定这一说,同样的LIMIT在不同的会话、不同的执行计划下,翻到第二页可能跟第一页出现重复数据,或者漏掉一条本该显示的记录。
加了id DESC这个唯一字段做二级排序后,每一行在结果集里的位置就是恒定的。这个坑很隐蔽,测试环境数据量小基本看不出来,但生产环境并发插入数据多的时候,用户会经常反馈“翻页翻着翻着看到一条刚才看过的记录”,原因几乎都出在排序不唯一上。
顺便说一句,ORDER BY字段如果没有索引,数据量大之后会额外产生文件排序filesort。后面在调优时该给create_time和可能用于筛选的status字段加合适的索引,才能保证分页接口在千条数据级别下依旧响应很快。
4. 前端联调:表格和分页组件怎么接到一块
4.1 数据模型和接口调用
后端接口返回的PageResult<EmployeeVO>结构是固定的:{ "total": 100, "records": [ ... ] }。前端在用 Vue + Element UI 的苍穹外卖类后台里,表格用el-table,分页条用el-pagination,两者的数据桥梁就是我们自己维护的响应式字段。
我前端页面的核心数据模型是这样的:
export default { data() { return { searchForm: { name: '', status: null }, employeeList: [], pagination: { page: 1, pageSize: 10, total: 0 } } }, methods: { async loadData() { const params = { page: this.pagination.page, pageSize: this.pagination.pageSize, name: this.searchForm.name || undefined, status: this.searchForm.status ?? undefined } const res = await getEmployeePage(params) this.employeeList = res.data.records this.pagination.total = res.data.total } } }这里有一个我用|| undefined和?? undefined的统一原则:空字符串和null都不要传给后端,因为后端Controller的@RequestParam(required = false)收到null才会自动跳过该查询条件。如果前端把name=""完整传过去,Spring不会因为空字符串报错,但后端XML里如果判空逻辑没写好,就容易出现全查的情况。不如前端先做一层清洗,clean data in, clean data out。
4.2 搜索时把页码重置成1的隐藏逻辑
这是联调阶段最容易出的业务bug:用户浏览到第5页之后,在搜索框输入姓名点“查询”,结果列表还是第5页的数据。为什么?因为搜索行为本身改变的是查询条件——后端会根据新的条件重新计算总条数,原来第5页的语义在新结果集里完全不存在,所以搜索时必须把pagination.page强制重置为1。
正确写法:
handleSearch() { this.pagination.page = 1 this.loadData() }, handleReset() { this.searchForm.name = '' this.searchForm.status = null this.pagination.page = 1 this.loadData() }搜索按钮和重置按钮都要做同一件事:清空或重新设置查询条件后,把当前页码拉回第一页。这个逻辑看起来不起眼,但每次漏掉都会让产品经理亲切地把你叫过去“聊聊”分页查询为什么不对。
4.3 表格里的脱敏列与状态开关
员工列表表格里的手机号列,直接展示后端脱敏后的结果就行:138****1234。但有一个体验细节要注意:不能把原始手机号自己存在前端变量里再脱敏展示,那等于脱敏白做了——浏览器内存里还是有完整数据,懂点技术的人都能从Vue的data里翻出来。安全链条里“前端不该碰的数据就不要拿到前端”,后端返回什么就展示什么。
启用/禁用员工的status列,我用的el-switch绑定员工的启用状态。请求新状态之后,先调PUT /admin/employee/status/{id}修改状态,成功后重新调用一次loadData()刷新列表,而不是在前端本地改status字段。本地改的结果就是用户刷新页面后看到的状态又“穿越”回去了,因为后端数据库根本没变。
5. 联调期最常踩的五个坑以及完整排查链路
5.1 列表渲染出来了但total一直是0
这个现象我见过不止一次:后端明明返回了百条数据,前端表格也有数据,但底部分页条显示“共0条”。第一步先打开开发者工具看Network面板,确认后端响应体里的total到底是什么值。
如果响应体里total就是0,问题基本在后端。查一下Service层是不是写成了:
return new PageResult<>(voList, page.getTotal());还是写成了:
return new PageResult<>(voList, 0L);后者属于手误,属于一眼能看到的bug。但如果响应体里压根没有total字段,那就要看返回对象的字段名是不是叫totalCount或者其他名字,跟前端访问的res.data.total对不上,自然就是undefined,渲染成0。这种前后端字段不对齐的坑在联调期极其常见,我的排查思路是:永远先抓Response JSON,再抓前端取值字段,两边对照后再去查后端的PageResult构造方法有没有填全。
5.2 搜“张”能查到,搜“张 ”查不到:空白字符清洗
用户的搜索框输入习惯很难控制,姓名后跟一个空格是常态。后端XML虽然写了<if test="name != null and name.trim() != ''">,但括号里#{name}用的还是name,不是name.trim()。于是SQL变成:
AND name LIKE CONCAT('%', '张 ', '%')这里的张末尾带空格,数据库里的“张伟”并不会以空格结尾,所以查不出来。
修复方案有两种。一是在Service层对DTO里的name统一做trim(),再放行给Mapper;二是XML里把#{name}改成#{name.trim()}。这两种都能解决问题,但更稳的是在Controller或Service入口统一处理入参,因为参数一旦脏了流到SQL里,各种诡异问题都会出现,不只分页查询一个接口受影响。
5.3 PageHelper分页失效:startPage和查询之间夹了别的SQL
这是PageHelper最经典的误用场景。有人图方便,在Service里先执行了别的查询,再调用startPage,代码大概长这样:
// 错误写法 List<Department> departments = departmentMapper.selectAll(); PageHelper.startPage(page, pageSize); List<Employee> employees = employeeMapper.pageQuery(queryDTO);后果是分页参数没有作用在employeeMapper.pageQuery上,因为departments先被查了,ThreadLocal里的参数被它消费了,导致员工查询返回的是全量数据。排查这个坑时,先把Mapper里的分页查询单独拿出来手动执行一遍,再回头看执行日志里有没有对employee表发出count查询。如果没有count查询,基本就是startPage位置放错了。正确做法是让startPage和pageQuery之间没有任何其他SQL,连日志打印中间都不要穿插查询。
还有一种情况是同一线程内startPage被调用两次。写复杂业务时,一个Service方法里要分页查询多个列表,第二段业务忘记用户已经开过分页,又调了一次startPage,前一次分页参数被覆盖掉,结果两个查询都被影响。务必养成习惯:一段事务代码里只保留一个活跃的分页上下文。
5.4 count很慢:别让count去扫全表
分页查询性能瓶颈通常不在列表查询,反而在count查询。PageHelper自动生成的count SQL会把你SELECT的字段替换成count(0),它的性能取决于WHERE条件里的字段是否有索引。在员工分页查询场景,最容易出现两个问题:
- 如果
status是低区分度字段,它单独走索引往往不如全表扫描,数据库优化器可能选择不走; - 如果
name用了LIKE '%张%',这种前置通配符写法的模糊查询是走不了索引的,这是MySQL索引机制的硬限制。
对分页查询这种轻量级列表接口,我在生产库里通常这么处理:
- 给
status建普通索引,给create_time建普通索引,索引覆盖ORDER BY; name模糊查询走不上索引,但如果数据量在几万条以内,全表扫也没多大压力;- 真要优化高频的姓名搜索,考虑引入全文索引,但那属于进阶话题,小项目不必过度设计。
5.5 深分页隐患:page越大查询越慢
当用户一直翻页,page=10000&pageSize=10时,MySQL生成的SQL可能是:
LIMIT 100000, 10这个SQL不是只读10条,而是先扫描并丢弃前100000行,才返回后面的10行。所以页码越深,扫描范围越大,响应时间会线性上涨。员工数量少还好,真到了几十万条,深分页会把数据库拖得很难看。
缓解办法有几种,最简单的是把分页改成“游标式”,基于上次查询的最后一条ID往下取。但在苍穹外卖这种会接口上,员工量一般不会大到这个程度,所以我更推荐先做好ORDER BY索引,并控制最大可跳转页数,超出某个阈值就提示用户使用搜索条件缩小范围,而不是无脑开放全量翻页。
6. 分页查询还能往外延伸哪些工程化能力
6.1 通用分页参数与返回对象抽取
员工分页查询做完之后,紧接着会有菜品分页、套餐分页、订单分页,如果每个模块都各自定义一套分页DTO和返回对象,代码会迅速长成满屏重复胶水。趁早抽公共结构,收益极高。
我这边抽出来的三个通用件是:PageQueryDTO(基础分页参数)、PageResult<T>(通用分页响应)、PageHelperUtil(统一设置pageSize上限和默认值的工具方法)。员工、菜品、订单所有分页接口都用同一套,前端拿到返回值的结构也始终不变,联调成本大幅下降。
6.2 权限校验与操作日志
员工分页查询是管理端接口,理论上只允许已登录的管理员访问。苍穹外卖这类项目里通常有JWT拦截器或Spring Security配置,我在配置权限时专门确认了/admin/employee/page必须携带有效Token。除此之外,员工列表里的禁用、启用操作属于敏感行为,最好在切面里记录操作人、操作时间、操作前后的状态变化,方便后续审计。分页查询本身可以不记日志,但状态修改这类写操作必须留痕。
6.3 后续扩展方向:导出、缓存与数据权限
员工分页查询的基础版本做完之后,最常见的扩展是“导出当前查询条件下的员工列表”。如果直接复用分页SQL而把pageSize调成一个大值,前面提到的深分页问题就会立刻爆炸。我的建议是单独写一个导出专用查询,支持游标式迭代拉取,再拼接成Excel,而不是复用分页逻辑。
至于缓存的引入,要谨慎。分页查询天然依赖条件组合,缓存命中率不高,而且员工数据的正确性远比响应速度重要。除非是首页统计那种固定的热门查询,否则我一般不会对分页接口做Redis缓存,避免“改了个员工状态,列表数据还是旧的”这种一致性灾难。
最后再分享一个经验。分页查询接口上线之后,别急着写下一块业务,先在测试环境敲几条EXPLAIN看执行计划,确认count和主查询都合理走索引,再把接口用压测工具快速压一遍,尤其是“姓名模糊搜索 + 状态筛选 + 大页码”这种组合场景。别看功能简单,真正让它稳定、安全、可维护,靠的从来都是这些不起眼的细节。