跳到主要内容

记录一次N+1查询问题优化实战

·1488 字·3 分钟

记录一次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等能力
  • 监控和日志要细致,方便定位性能瓶颈

这次问题解决后,接口响应速度提升了一个数量级!

Anarkh
作者
Anarkh
博学之 审问之 慎思之 明辨之 笃行之