记录一次N+1查询问题优化实战(Spring Boot分页接口超时的排查与优化) #
问题描述 #
前几天在做后台管理系统开发时,遇到了一个让人头疼的性能问题。场景很简单,就是机构管理的分页查询接口 /org/page
,前端同事反馈说页面经常加载不出来,Chrome开发者工具里显示请求被 canceled,耗时正好30秒。说实话,刚开始我还挺纳闷的,分页查询而已,怎么会慢到超时?
这个接口对业务影响挺大,毕竟是后台核心功能,不能掉链子。于是我开始了漫长的排查之路。
排查过程 #
初步判断 #
一开始我以为是数据库数据量太大,或者SQL写得不够优雅。于是我直接把后端日志和SQL抓出来,跑到数据库里执行了一下:
SELECT COUNT(*)
FROM sys_user u
LEFT JOIN place_school_info p ON u.org_id = p.id
LEFT JOIN sys_org o ON p.org_id = o.id
WHERE u.type = 2 AND o.tier LIKE '1000100001%' AND u.belong_type = 1;
结果只有1018条数据,说实话,这点数据根本不至于让接口超时。于是我又看了下分页参数,前端传的是 pageSize=10,理论上只查10条,怎么也不该慢。
日志分析 #
我继续翻后端日志,发现每次分页查询后,紧接着会有大量“按账号查找用户XXX”的日志,数量和数据量完全对得上。也就是说,查出来1018条后,后端又对每条数据做了额外的数据库查询。
这下我有点明白了,典型的N+1查询问题。分页查出来N条数据,循环里又查了N次数据库,甚至还不止一处。
代码定位 #
我直接定位到 OrgController.java
的 getPage方法,代码大致是这样:
@PostMapping("/page")
public ResponseEntity getPage(@RequestBody OrgQueryDTO dto, @RequestParam Integer pageNo, @RequestParam Integer pageSize) {
Page<VOrgManagerUserEntity> page = orgService.getPage(dto, pageNo, pageSize);
List<OrgDTO> list = new ArrayList<>();
Map<String, String> map = new HashMap<>();
try {
for (VOrgManagerUserEntity entity : page.getContent()) {
OrgDTO orgDTO = new OrgDTO();
BeanUtils.copyProperties(entity, orgDTO);
orgDTO.setOrgParentId(entity.getParentId());
// 查询父机构名称
if (StringUtils.isNotBlank(entity.getParentId())) {
if (!map.containsKey(entity.getParentId())) {
SysOrgEntity sysOrgEntity = orgService.get(entity.getParentId());
orgDTO.setOrgParentName(sysOrgEntity.getName());
map.put(entity.getParentId(), sysOrgEntity.getName());
} else {
orgDTO.setOrgParentName(map.get(entity.getParentId()));
}
}
// 查询用户数
orgDTO.setUserCount(userService.countByOrg(entity.getId()));
// 查询角色
List<SysRoleOrgEntity> roles = orgService.getRoles(entity.getId());
List<String> roleIds = roles.stream().map(v -> v.getRoleId()).collect(Collectors.toList());
orgDTO.setRoleIds(roleIds);
list.add(orgDTO);
}
} catch (Exception e) {
throw new BusinessException("数据异常");
}
PageCustom<OrgDTO> pageCustom = new PageCustom(pageNo, pageSize, page.getTotalElements(), page.getTotalPages(), list);
return ResponseEntity.ok(pageCustom);
}
一眼看过去,for循环里有三处数据库查询,分页查出来多少条,这里就查多少次,难怪慢。
解决方案 #
尝试优化 #
说实话,刚开始我还想偷懒,想着能不能加个缓存或者异步处理,后来一想,这种N+1问题还是得从根本上解决。于是我决定把所有需要的数据一次性查出来,循环里只做内存操作。
优化后的代码 #
核心思路就是:先收集所有需要的ID,然后批量查父机构、用户数、角色,最后循环组装。
@PostMapping("/page")
public ResponseEntity getPage(@RequestBody OrgQueryDTO dto, @RequestParam Integer pageNo, @RequestParam Integer pageSize) {
Page<VOrgManagerUserEntity> page = orgService.getPage(dto, pageNo, pageSize);
List<OrgDTO> list = new ArrayList<>();
List<VOrgManagerUserEntity> content = page.getContent();
if (CollectionUtils.isEmpty(content)) {
return ResponseEntity.ok(new PageCustom<>(pageNo, pageSize, 0, 0, list));
}
// 收集所有ID
List<String> orgIds = content.stream().map(VOrgManagerUserEntity::getId).collect(Collectors.toList());
List<String> parentIds = content.stream()
.map(VOrgManagerUserEntity::getParentId)
.filter(StringUtils::isNotBlank)
.distinct()
.collect(Collectors.toList());
// 批量查父机构
Map<String, String> parentOrgNameMap = new HashMap<>();
if (!CollectionUtils.isEmpty(parentIds)) {
List<SysOrgEntity> parentOrgs = orgService.getListByIds(parentIds);
parentOrgNameMap = parentOrgs.stream().collect(Collectors.toMap(SysOrgEntity::getId, SysOrgEntity::getName));
}
// 批量查用户数
Map<String, Long> userCountMap = userService.countByOrgIds(orgIds);
// 批量查角色
Map<String, List<SysRoleOrgEntity>> rolesMap = orgService.getRolesByOrgIds(orgIds);
try {
for (VOrgManagerUserEntity entity : content) {
OrgDTO orgDTO = new OrgDTO();
BeanUtils.copyProperties(entity, orgDTO);
orgDTO.setOrgParentId(entity.getParentId());
if (StringUtils.isNotBlank(entity.getParentId())) {
orgDTO.setOrgParentName(parentOrgNameMap.get(entity.getParentId()));
}
orgDTO.setUserCount(userCountMap.getOrDefault(entity.getId(), 0L).intValue());
List<SysRoleOrgEntity> roles = rolesMap.getOrDefault(entity.getId(), Collections.emptyList());
List<String> roleIds = roles.stream().map(SysRoleOrgEntity::getRoleId).collect(Collectors.toList());
orgDTO.setRoleIds(roleIds);
list.add(orgDTO);
}
} catch (Exception e) {
log.error("机构分页数据处理异常", e);
throw new BusinessException("数据异常");
}
PageCustom<OrgDTO> pageCustom = new PageCustom(pageNo, pageSize, page.getTotalElements(), page.getTotalPages(), list);
return ResponseEntity.ok(pageCustom);
}
为什么有效 #
这样一来,数据库只需要查4次(主查询+父机构+用户数+角色),和分页数量无关。原来查10条数据要30次数据库,现在只要4次,性能提升不是一点点。
经验总结 #
说实话,这次排查让我对N+1查询有了更深刻的认识。很多时候,问题不是出在SQL本身,而是出在代码的调用方式。ORM和分层架构很容易让人掉进这个坑,尤其是for循环里调用数据库的时候。
根本原因:循环里查数据库,数据量一大就炸。
如何避免:
- 代码review时多留意for循环里的数据库操作
- 能批量查就批量查,能一次查出来就别分多次
- 业务复杂时,先查ID再查详情,分步批量处理
最佳实践:
- Service层多提供批量接口
- 充分利用数据库的IN、GROUP BY、JOIN等能力
- 监控和日志要细致,方便定位性能瓶颈
这次问题解决后,接口响应速度提升了一个数量级!