拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java员工管理分页查询优化:从索引到Redis缓存的完整实践

Java开发 员工管理 分页查询这个标题乍一看是个再普通不过的CRUD功能但如果你真在正规项目里做过员工管理模块就会知道——分页查询做得好不好直接决定这个系统在真实数据量下能不能用。我见过太多项目开发环境几十条数据测不出毛病等上了生产、员工表攒到几十万上百万行随便翻几页就开始卡用户一投诉最后背锅的还是写代码的人。所以我打算把员工管理分页查询这件事从头到尾拆一遍不只是给你一套能跑的代码更重要的是把这些年在生产环境里踩过的坑、做过的取舍、验证过的优化方案一次性讲清楚。尤其是你作为后端开发在面试中常被追问的分页慢、如何用Redis优化这类问题这篇也能给你一份实战里证明过的答案。1. 需求边界员工管理分页查询比你想象的复杂很多人一听到员工管理分页查询脑子里立刻冒出的是SELECT * FROM employee LIMIT 10 OFFSET 20这样的句子。这个直觉本身没错但它只回答了怎么查的最基础一层真正的复杂度出在查什么按什么查返回什么这些需求定义上。1.1 分页参数不只是页码和条数员工管理模块的分页接口正常情况下至少需要这几类参数分页控制参数pageNum当前页码、pageSize每页条数。这两个是最基础的有些团队习惯命名成current、size本质一样。过滤条件参数姓名模糊匹配、工号精确匹配、部门ID、岗位状态、入职日期区间。这些在真实的查询页面里几乎不可能缺席。排序参数sortField、sortOrder或者固定写死排序规则。参数设计上最大的坑是** pageSize 不设上限**。用户在前端可以手动改请求参数把pageSize改成10000你后端如果没做校验一次查询就把几十万员工数据全部返回。看起来只是返回数据多了一点真实情况是数据库的IO、网络带宽、应用服务器的内存、JSON序列化耗时全部被打满系统性能直接被拖垮。我惯用的做法是DTO层直接校验public class EmployeePageQuery { NotNull(message 页码不能为空) Min(value 1, message 页码从1开始) private Integer pageNum; NotNull(message 每页条数不能为空) Min(value 1, message 每页至少1条) Max(value 100, message 每页最多100条) private Integer pageSize; private String name; private String empNo; private Long deptId; private Integer status; DateTimeFormat(pattern yyyy-MM-dd) private LocalDate beginDate; DateTimeFormat(pattern yyyy-MM-dd) private LocalDate endDate; }1.2 返回体怎么设计才够用返回体除了当前页的记录列表还应该包含total总数、pages总页数、current当前页码、size每页条数。这四个字段是分页接口的标准配置前端做分页组件、页码跳转、数据统计都要用到。有个细节值得说总条数total的统计方式。有的项目为了省性能用LIMIT pageNum*pageSize1这种技巧只查多一条来判断是否有下一页不查总条数。这在数据量小的时候没问题但在员工管理这种需要精确显示共XX条记录的场景total是必须精确返回的所以count查询省不掉。2. 数据访问层实现为什么我会选MyBatis-Plus分页插件Java后端做员工管理分页技术选型上基本绕不开MyBatis或MyBatis-Plus。我近几年的项目里只要不是公司强行规定用JPA我几乎无脑选MyBatis-Plus。核心原因是它的分页插件足够成熟把分页的底层细节封装得相当干净。2.1 分页插件的工作原理你觉得MyBatis-Plus分页插件是做了什么神操作才帮你实现了分页其实它内部就是一个MyBatis拦截器在SQL执行前拦截下来做两件事生成并执行count查询把原始SQL改写成count语句查总数。改写原SQL为分页SQL根据数据库方言拼接LIMIT/OFFSETMySQL或ROWNUMOracle语法。配置分页插件也很简单Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); // 设置每页最大条数超过就报错或按最大条数查 paginationInterceptor.setMaxLimit(100L); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }注意setMaxLimit这个配置它和我在DTO里的Max注解是双重保险。即使有人绕过接口直接调用Mapper分页插件也会限制单页最大条数。2.2 业务层代码和Service层的封装ServiceImpl里调用分页代码非常简洁public PageResultEmployeeVO pageQuery(EmployeePageQuery query) { PageEmployee page new Page(query.getPageNum(), query.getPageSize()); LambdaQueryWrapperEmployee wrapper Wrappers.lambdaQuery(); wrapper.like(StringUtils.isNotBlank(query.getName()), Employee::getName, query.getName()) .eq(StringUtils.isNotBlank(query.getEmpNo()), Employee::getEmpNo, query.getEmpNo()) .eq(query.getDeptId() ! null, Employee::getDeptId, query.getDeptId()) .eq(query.getStatus() ! null, Employee::getStatus, query.getStatus()) .between(query.getBeginDate() ! null query.getEndDate() ! null, Employee::getHireDate, query.getBeginDate(), query.getEndDate()) .orderByDesc(Employee::getCreateTime); PageEmployee result page(page, wrapper); // 转成VO隐藏数据库字段细节 ListEmployeeVO voList result.getRecords().stream() .map(employee - convertToVO(employee)) .collect(Collectors.toList()); return PageResult.of(voList, result.getTotal(), result.getCurrent(), result.getSize()); }这段代码看起来简陋但包含一个非常重要且容易被忽视的设计查询条件用StringUtils.isNotBlank和条件判断来动态拼接而不是写死在SQL里。这样做既避免了大量if拼SQL的丑陋代码又保证了参数为空时不会误过滤数据。2.3 count查询的隐藏性能问题分页插件确实方便但早期版本有个问题count查询没做优化。比如你的查询里有LEFT JOINcount语句会把JOIN也带上统计总数时其实完全可以简化。MyBatis-Plus后面的版本做了自动优化但如果你用的是PageHelper或者自己写count一定要关注这个点。如果员工表查询涉及多表JOIN比如关联部门表查部门名称实际业务中我会这样做列表查询JOIN查出部门名称等展示字段。count查询只统计主表不JOIN。手动处理时可以重写count查询或者拆成两条SQL。MyBatis-Plus里也可以维护一个专门的count Mapper方法并在分页查询时通过optimizeCountSql参数控制。3. 深分页的性能黑洞翻到第100页时到底发生了什么在员工数据量小的时候LIMIT分页看起来完美无瑕。但一旦表数据量上来了一个经典问题就会出现页码越翻越往后查询越来越慢直到某一个临界点接口直接超时。3.1 为什么OFFSET越大越慢看一下这条SQLSELECT id, emp_no, name, dept_id FROM employee ORDER BY create_time DESC LIMIT 10 OFFSET 99990;MySQL执行这条查询时会扫描全部100000行数据然后丢弃前99990行最后只返回10行。前99990行的扫描、排序、临时存储工作全部是白白浪费的。你从第1页翻到第10页可能感觉不到什么因为每页才多扫几十行。但第10000页呢数据库需要扫描并排序的可能是几十万行。我在一个真实项目里见过员工表数据量到80万行时翻到第3000页的单次查询耗时从50ms飙升到7秒。这在员工管理这种场景下是个非常现实的问题。很多老系统的管理后台前端分页组件带了一个跳转到指定页码的输入框用户习惯性输入一个很大的数字然后系统就卡死了。3.2 针对员工管理的务实解法限制可访问的页码范围你可能会看到网上很多文章推游标分页keyset分页但我想先说一个最务实的方案如果你的产品不需要翻到几百页以后直接在代码里限制最大页码。我负责的员工管理项目里产品经理确认了操作习惯之后我们在后端做了个规则单次查询最多允许访问前100页。第100页之后的数据用户无法通过常规翻页看到如果确实有需求必须使用搜索条件缩小范围。这个方案很粗暴但它极其有效。从业务上来讲一个管理员找员工要么用姓名/工号搜索要么翻前几页几乎没有人会真的翻到第3000页去人肉找一条数据。如果你确实需要支持深分页那我推荐游标分页。基于排序字段通常是主键ID来做条件过滤而不是用OFFSETSELECT id, emp_no, name, dept_id FROM employee WHERE create_time #{lastCreateTime} OR (create_time #{lastCreateTime} AND id #{lastId}) ORDER BY create_time DESC, id DESC LIMIT 10;这个思路的精髓是通过上一页最后一条记录的排序值直接定位下一页的起始位置彻底避免了OFFSET偏移导致的无效扫描。但代价是前端分页组件没法做跳转到指定页码了因为游标是连续的不支持下标定位。员工管理这类后台系统的正确姿势通常是组合拳正常情况下用传统页码分页限制最大页码配合索引优化对于导出全部数据或后台定时任务批量扫描这类场景用游标分页或基于ID区间分段查询。3.3 索引设计是分页查询的地基逻辑再优化索引不对也是白搭。员工表分页查询的索引设计我认为至少要考虑这几点排序字段必须建索引。如果你固定按create_time DESC排序就建idx_create_time或者放到联合索引里。过滤条件多的表优先建联合索引。比如经常按dept_id status筛选就建(dept_id, status)联合索引比两个单列索引的效果好得多。覆盖索引值得考虑。如果列表页只需要展示id、emp_no、name、dept_id、status、create_time这些字段可以建一个覆盖索引把这些列都放进去查询时MySQL只需要扫描索引而不回表性能提升非常明显。索引不是越多越好但分页查询的排序字段和过滤字段上没有索引那连优化的基础都没有。4. 用Redis优化分页查询缓存方案的整体设计与落地细节热搜词里分页查询慢怎么用redis优化被频繁搜到说明很多人在真实项目中遇到了这个痛点。我在这里分享一下我在员工管理模块里验证过的Redis缓存分页方案以及容易踩的坑。4.1 缓存方案设计不是所有页码都值得缓存很多人一听到用Redis优化分页第一反应就是把每个页码的查询结果都缓存到Redis里。这个思路本身没错但要注意一个核心原则缓存必须解决热数据的高频访问问题而不是试图用内存扛住全量数据。员工管理系统的访问特征是什么绝大多数情况下用户进入页面看的是第1页然后按条件筛选。第1页被访问的频率远高于第2页、第3页。基于这个特征我的策略是只缓存前N页的查询结果通常N10或者N20根据实际访问统计调整。第N页之后不缓存直接查数据库。同事按部门和姓名等常见筛选条件组合出的前几页结果如果发现命中率很高也会进入缓存。这么做的好处是Redis内存消耗可控不会缓存大量几乎不会被访问的深分页数据同时又精准解决了高频访问的慢查询问题。4.2 缓存Key的粒度设计与失效策略缓存key的设计直接决定了缓存命中率。我的做法是把筛选条件和分页参数全部拼进去employee:page:{pageNum}:{pageSize}:{name}:{empNo}:{deptId}:{status}:{sortField}:{sortOrder}由于参数组合很多key数量会爆炸所以需要给缓存设置合理的过期时间。注意不要用固定的过期时间比如所有人都设置60秒过期万一大量key同时过期就劝退了Redis。我会在基础过期时间上加上随机偏移量让过期时间分散long baseTimeout 60L; long randomTimeout ThreadLocalRandom.current().nextLong(10L, 60L); redisTemplate.expire(key, Duration.ofSeconds(baseTimeout randomTimeout));4.3 缓存读写的具体实现用Spring Boot操作Redis读写逻辑比较简单关键是缓存击穿的防护。当热点key过期瞬间大量请求打到数据库很容易把数据库压垮。我用的是互斥锁方案代码大概长这样public PageResultEmployeeVO pageQueryWithCache(EmployeePageQuery query) { String cacheKey buildCacheKey(query); String cachedData redisTemplate.opsForValue().get(cacheKey); if (StringUtils.isNotBlank(cachedData)) { return JSON.parseObject(cachedData, new TypeReferencePageResultEmployeeVO() {}); } // 缓存未命中先尝试获取分布式锁 String lockKey cacheKey :LOCK; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(5)); if (!locked) { // 拿不到锁说明别的线程正在重建缓存短暂休眠后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return pageQueryWithCache(query); } try { // 为了防止当前线程二次查缓存这里再查一次 cachedData redisTemplate.opsForValue().get(cacheKey); if (StringUtils.isNotBlank(cachedData)) { return JSON.parseObject(cachedData, new TypeReferencePageResultEmployeeVO() {}); } PageResultEmployeeVO result pageQuery(query); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result), Duration.ofSeconds(60)); return result; } finally { redisTemplate.delete(lockKey); } }这段代码需要特别注意两个细节一是加锁之后要再次查缓存防止另一个线程已经重建好了当前线程还在傻等二是锁的过期时间不能太长否则重建缓存的服务异常退出锁会一直占着后续请求全部卡死。4.4 缓存与数据库的一致性新增员工时怎么办员工管理是典型的写多读多场景员工新增、改资料、离职都算写操作。分页结果的缓存一致性只能靠主动失效来保证没有别的讨巧办法。我的处理方式是在写操作后删除相关缓存而不是更新缓存。因为分页查询的key太多了你很难知道一条数据会出现在哪些页码的缓存里删除相关缓存比精确更新要简单得多不过是牺牲一次重建代价而已Transactional public void updateEmployee(EmployeeUpdateDTO dto) { employeeMapper.updateById(dto); // 删除该员工可能涉及的分页缓存按命名约定批量删除 SetString keys redisTemplate.keys(employee:page:*); if (CollectionUtils.isNotEmpty(keys)) { redisTemplate.delete(keys); } }有人会担心keys命令在大数据量下会阻塞Redis这确实是生产环境的一个隐患。严谨的方案是维护一个分页缓存key集合或者把key按固定规则分片存放用scan命令代替keys。如果你们公司Redis是单机部署且缓存key数量不大用keys也问题不大但我还是建议用scan保险。另外还需要注意缓存里存的是total和records的快照。假设缓存过期前有人新增了员工缓存里的total会比实际少一条。在员工管理这种场景下这个误差在几十秒的缓存生命周期内是可以接受的。如果产品经理要求总数必须实时精确那就不适合做分页结果缓存只能退回到缓存单条员工详情或者干脆不缓存。5. 生产环境中分页查询的异常排查一次真实的性能问题定位2023年我在维护一个老的后台管理项目时接到过这样一个工单员工列表接口连续几天每天下午3点准时变慢从平均200ms涨到2秒以上重启后恢复但第二天又复发。这个时间规律让我瞬间警觉先怀疑是不是别的定时任务在抢数据库资源。排查链路的每一步都是大家以后会反复用到的思路。5.1 排查链路第一步定位慢SQL先去数据库打开慢查询日志把慢查询阈值设成1秒专门盯这一个接口SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;半天之后慢查询日志里果然出现了一个SQL模式反复出现SELECT ... FROM employee WHERE dept_id 1001 AND status 1 ORDER BY create_time DESC LIMIT 10 OFFSET 99990;当时看到这个OFFSET数值我心里大致有了判断——深分页老问题。5.2 排查链路第二步确认索引状态在MySQL里执行EXPLAIN输出结果让人眼前一亮type: ref possible_keys: idx_dept_status key: idx_dept_status rows: 120000type是ref说明用了索引但rows扫描了12万行问题很明显联合索引(dept_id, status)把过滤后的结果12万行全部找出来然后排序、跳过99990行才返回目标数据。深分页的OFFSET问题在索引设计正确的情况下依然存在因为索引只帮你缩小了过滤范围却没有解决跳过大位移的问题。5.3 排查链路第三步为什么每天下午3点准时变慢这不是技术问题是用户习惯问题。下午3点是行政和人事集中处理员工入离职的高峰时段系统里的操作记录多某几个部门的查询频率突增深分页的请求量上来了慢查询就跟着凸显出来。找到根因后解决方案就很清晰了确认产品经理没有跳转到任意页的强需求之后我给分页接口加了最大页码限制100页另外让前端把跳转页输入框改成筛选后浏览前100页的模式并加上姓名/工号搜索引导。上线后第二天同一时刻接口耗时直接从2.3秒回落到130ms。5.4 另一次排查排序字段导致的慢查询还有一次员工列表接口慢看监控发现是order by emp_no desc拖的。员工工号设计成了varchar类型虽然建了索引但MySQL对varchar排序不走索引因为排序规则是字符串级别和索引的字节序不一致。后来查了一下是SQL里用了ORDER BY CAST(emp_no AS UNSIGNED) DESC直接导致索引失效。解决办法也简单工号字段在业务里本质上是数值型编号但历史数据里有字母后缀没法简单改成bigint。最终的方案是新增一列emp_no_sort专门存放工号转成的数字写入时计算好排序时直接用这一列。5.5 排查过程中的心得两轮排查下来我最大的体会是分页查询慢绝大多数情况并不是代码写得不对而是SQL执行方式和数据分布不匹配。遇到慢查询先看执行计划先确认是不是深分页再考虑Redis缓存。不同问题有不同解法缓存不是万能药有时索引调整或业务逻辑约束能解决的问题比上缓存更干净、更持久。6. 代码与工程层面的三个额外建议分页查询除了SQL和缓存层面的优化代码层面也容易出问题。6.1 统一返回值避免各个接口分页响应结构不一致我见过一个项目里三个接口分别返回了不同的分页结构有的是Page有的是PageInfo有的是自定义的Map前端对接时痛苦不堪。建议团队里统一定义一个分页响应类比如Data public class PageResultT { private ListT records; private Long total; private Long pages; private Long current; private Long size; }所有分页接口都返回这个类型前端只需要对接一次就可以复用。这也是我在第一节末尾提到的PageResult.of的由来。6.2 SQL注入防范排序字段白名单分页接口里的sortField参数很容易被攻击者利用因为它会直接拼到ORDER BY子句中。MyBatis的#{}参数只能防止值注入不能防止列名和排序方向注入。比如你把sortField直接拼到SQL里用户传一个id; DROP TABLE employee虽然现在的数据库权限通常不允许但依然是很危险的漏洞。我的做法是排序字段白名单校验private static final SetString SORT_FIELD_WHITE_LIST new HashSet(Arrays.asList(createTime, empNo, name, hireDate)); public String validateSortField(String sortField) { if (!SORT_FIELD_WHITE_LIST.contains(sortField)) { return createTime; } return sortField; }排序方向sortOrder同理只允许asc或desc其他一律抛参数异常。6.3 大数据量导出场景别用分页查询硬扛后台系统的员工列表经常附带导出Excel功能。不少新人的第一版实现思路是查第1页、查第2页……循环直到全部查完。这个方案在数据量小的时候能用数据量上了两万就慢得像蜗牛。正确的导出思路是写一个专门的流式查询方法使用游标或者分批按ID区间扫描直接面向全量数据集而不是复用页面的分页接口。后端每查出一批就写入Excel内存占用始终可控。这个思路和分页查询的深分页优化是一脉相承的能通过条件缩小范围就缩小范围能避免大偏移量扫描就避免偏移量扫描。7. 一次压测后的再思考分页缓存到底该不该上最后再聊一个被反复问起的问题既然缓存分页效果不错那所有分页查询都套上缓存不就行了真别这么干。我在做压测对比的时候发现缓存不是免费的。原因有四个分页查询的key粒度极细员工表有十几个筛选条件排列组合下来缓存命中率并不像想象中那么高。每次写操作都要失效相关缓存写多读少的场景下缓存不仅没省多少DB压力反而增加了一层缓存维护的复杂度和Redis开销。缓存中的数据是快照任何分页场景对数据实时性有硬要求比如财务台账缓存方案就不适用。多实例部署时本地缓存如Caffeine的一致性更难维护而Redis分布式缓存又引入了网络开销和序列化开销。所以我的最终判断是员工管理分页查询优先做索引优化和深分页方案缓存只作为兜底手段并且只缓存验证过的高频访问组合。比如某个公司的组织架构只有几十个部门每个部门的员工查询条件相对固定那按deptId pageNum维度缓存前几页就是很划算的买卖。反之如果筛选条件自由度很高缓存命中率就会很低缓存反而成了累赘。从技术选型的角度看先优化SQL、再优化索引、最后考虑加缓存这个顺序放在任何时候都不会过时。员工管理分页查询的每一个点——从参数校验、SQL优化、索引设计、深分页规避到Redis缓存方案取舍——都值得你动手验证一遍。实践下来你会发现这些经验不仅在员工管理模块通用换到订单管理、流水查询逻辑同样是通的。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门