致远互联实习复盘:一条SQL替代300次循环查询,组织架构选择器从5秒降到300毫秒

前言

入职致远互联的第二周,我在测试审批流程时发现一个"难忍"的问题:组织架构选择器,点下去要等 5 秒。

这个组件不归我负责。但浏览器转圈 5 秒这件事,对任何一个开发者来说都像一根刺------它意味着某处一定有一个糟糕的 SQL 或循环在静静消耗用户的时间。

我做了三件事------打开控制台确认慢在后端、开启 MyBatis SQL 日志抓到 300 次循环查询、用一条 SQL 把 2000 条员工数据关联完成并把响应压到 300 毫秒。本文完整复盘这次排查优化全过程。

本文核心问题:

  1. 怎么发现这个性能问题的?用的是什么排查手段?
  2. MyBatis N+1 问题是什么?如何通过日志定位?
  3. 为什么循环查询会这么慢?真正的瓶颈是数据量还是 I/O 次数?
  4. JOIN 查询为什么比循环查询快?底层原理是什么?
  5. 当前数据量下为什么选 JOIN?数据量增长到几十万时怎么演进?
  6. 改写 SQL 后如何用内存组装树形结构?
  7. 优化后效果如何?怎么验证?
  8. 这次实习经历让我学到了什么?

一、问题发现------"这个树形控件怎么这么慢?"

疑问:一个部门选择器而已,为什么会引起你的注意?

我在测试自己负责模块的审批流程时,需要选择审批人。点击组织架构选择器后,页面卡了 3-5 秒才弹出树形结构,部门层级大概 4 级,部门数 300+,关联的员工总数在 2000 人左右。

直觉告诉我这不正常。部门加员工才两千多条数据,没有理由这么慢。

排查第一步:确认慢在哪个环节。

打开浏览器开发者工具 → Network 面板 → 刷新页面后再次打开选择器:

  • 接口 /api/organization/tree?deptId=root
  • 响应时间:4.2 秒
  • 响应大小:约 200KB(数据量本身并不大)

结论:慢在后端接口,不是网络或前端渲染问题。


二、定位根因------MyBatis 打印的 SQL 出卖了一切

疑问:怎么知道是 MyBatis 的问题?

2.1 开启 SQL 日志

在开发环境的 application-dev.yml 中配置:

yaml 复制代码
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

重启服务后再次请求接口,控制台的输出让我瞬间明白了问题所在:

sql 复制代码
-- 第1条SQL:查根部门下的子部门
SELECT id, name, parent_id FROM sys_dept WHERE parent_id = 'root';
-- 返回 15 条记录

-- 然后每条记录又发出一条SQL...
SELECT id, name, parent_id FROM sys_dept WHERE parent_id = 'dept_001';
SELECT id, name, parent_id FROM sys_dept WHERE parent_id = 'dept_002';
-- ... 300 多个部门,发了 300 多次 SQL!
-- 最终拿到 2000 多个员工的关联数据

2.2 这就是经典的 N+1 问题

复制代码
N+1 问题的特征:
1. 先发一条 SQL 查父级列表(1 次)
2. 再对列表中的每一条记录发一条 SQL 查子级(N 次)
3. 如果有多级嵌套,就变成 N×M×K... 次

以 4 级部门、每级平均 8 个子部门为例:
  第 1 级:1 条 SQL
  第 2 级:8 条 SQL
  第 3 级:64 条 SQL
  第 4 级:512 条 SQL
  总共:585 条 SQL × 每次耗时 5ms ≈ 2.9 秒

本场景中:300+ 次 SQL × 每次 5-10ms ≈ 2-4 秒
核心矛盾:查询次数是 300 次(部门数),但结果集只有 2000 条(员工数)。
真正的瓶颈不是数据量大,而是 I/O 次数太多。

打个比方:你去图书馆查所有部门的员工名单。管理员递给你一张部门列表,你一个一个部门问,跑了 300 多趟才拿齐 2000 人的名册。而正确的做法是:管理员一次把部门-员工关联表给你,你坐在座位上自己分类,压力大时扩展多找人来做。

2.3 找到代码中的"凶手"

java 复制代码
@Service
public class OrganizationService {

    // ❌ 原来的代码:递归查子部门(N+1 元凶)
    public List<DeptTreeNode> getDeptTree(String parentId) {
        List<SysDept> depts = deptMapper.selectByParentId(parentId);  // 1 次查询

        List<DeptTreeNode> tree = new ArrayList<>();
        for (SysDept dept : depts) {
            DeptTreeNode node = convertToNode(dept);
            // 递归调用自己 → 再次发SQL → 下一层又递归调用...
            node.setChildren(getDeptTree(dept.getId()));
            tree.add(node);
        }
        return tree;
    }
}

根因总结:代码用递归遍历每一层部门,每次递归都发一条 SQL。300 多个部门发了 300 多次 SQL,每次 SQL 网络往返 + 数据库查询耗时 5-10ms,累计起来就 3-5 秒。


三、优化方案------一次 JOIN 查询 + 内存组装树

疑问:怎么优化?既然 N+1 问题是"查太多 SQL",那减少到一条 SQL 不就行了吗?

3.1 核心思路

复制代码
优化前:循环发 SQL,数据库负责"分段查"
  查根 → 遍历结果 → 每个子再查一次 → 遍历结果 → ...

优化后:一次 JOIN 查询完成所有关联,应用层负责"组装树"
  一条 JOIN SQL 拿到全部 2000 条员工数据 → Java 内存中分组构建树

为什么选 JOIN 方案? 核心考量就是数据量。当前组织架构关联出来总共 2000 条数据,一次 JOIN 查询把所有父子关系一次性关联完成,单次查询耗时约 10ms。而原来发 300 多次 SQL,光网络往返就吃掉 2 秒以上。JOIN 在这个场景下足够好且足够简单。

3.2 JOIN 方案的适用边界与技术演进

当前场景为什么选 JOIN?

方案 SQL 次数 数据库负担 当前适用性
循环查询(原方案) 300+ 次 ❌ 慢
JOIN 查询 1 次 ✅ 最佳
递归 CTE 1 次 中等 🟡 过度设计

2000 条数据的规模,JOIN 产生的临时表极小,数据库完全无感。在这个阶段,JOIN 是最简单也最有效的方案。

大数据量下 JOIN 的局限性

一个问题,"数据量涨到几十万怎么办",我的回答是:JOIN 方案有它的适用边界,这个边界我很清楚。

数据规模 JOIN 表现 原因
<5000 条 ✅ 最优 临时表小,一次查询干净利落
5000-5 万 🟡 可接受 需加索引配合,临时表仍可控
>5 万 ❌ 需切换方案 联表临时表过大,挤占数据库内存

阿里巴巴 Java 开发手册明确指出:超过三个表的 JOIN 需要审批。核心原因就是大数据量下联表的临时表会占用数据库大量内存,在高并发场景下会把数据库拖慢。

未来的演进方案

如果员工数增长到几十万,我会切换到单表查询 + 应用层组装的方案:

java 复制代码
// 当数据量增长到几十万时的演进方案
// 只做单表查询,数据库不用建临时表,压力转移到应用层
@Select("SELECT id, dept_name, parent_id, sort_order " +
        "FROM sys_dept WHERE enabled = 1 " +
        "ORDER BY parent_id, sort_order")
List<SysDept> selectAllEnabled();

同样是 1 条 SQL,但没有任何联表操作。数据库只负责"查",不负责"算"。所有的树形结构组装都在应用层内存中完成,压力转移到更容易水平扩展的应用服务器上。

方案选择的工程思维

复制代码
当前数据量 ──→ JOIN 查询 ──→ 最优解
  (2000 条)       (1 条 SQL, 临时表极小)

数据量增长 ──→ 单表查询 + parent_id 索引 + 内存组装
  (几十万)      (1 条 SQL, 无临时表, 压力在应用层)

核心原则不变:用最少的 SQL 获取数据
实现方式变化:根据数据规模动态调整,两个方案之间平滑过渡

当前用 JOIN,不是因为我不知道它的边界,恰恰是因为我知道它的边界------在 2000 条的小数据量下,JOIN 是比复杂方案更好的选择。 真正的工程能力,是知道什么时候用哪个方案,以及什么时候该切换。

3.3 实现代码

java 复制代码
@Mapper
public interface SysDeptMapper {

    // ❌ 原来:按父 ID 查(导致 N+1)
    @Select("SELECT * FROM sys_dept WHERE parent_id = #{parentId}")
    List<SysDept> selectByParentId(String parentId);

    // ✅ 优化后:一次 JOIN 查询替代 300 次循环查询
    @Select("SELECT d.id, d.dept_name, d.parent_id, d.sort_order, " +
            "e.id as emp_id, e.name as emp_name " +
            "FROM sys_dept d " +
            "LEFT JOIN sys_employee e ON d.id = e.dept_id " +
            "WHERE d.enabled = 1 " +
            "ORDER BY d.parent_id, d.sort_order")
    List<DeptEmpVo> selectAllWithEmployee();
}

当前阶段为什么这么简单就够了?

  • 2000 条数据,一次 JOIN 查询 10ms 完成,临时表开销可忽略不计
  • 后续数据量增长了,给 parent_iddept_id 加个索引就能平滑过渡

3.4 内存中组装树形结构

java 复制代码
@Service
public class OrganizationService {

    public List<DeptTreeNode> getDeptTreeOptimized() {
        // 1. 一条 JOIN SQL 查出所有部门与员工
        List<DeptEmpVo> allData = deptMapper.selectAllWithEmployee();

        // 2. 按 parentId 分组部门,建立映射(纯内存操作)
        Map<String, List<DeptEmpVo>> parentIdMap = allData.stream()
            .collect(Collectors.groupingBy(
                d -> d.getParentId() == null ? "root" : d.getParentId()
            ));

        // 3. 递归构建树(纯内存操作,无数据库访问)
        return buildTree("root", parentIdMap);
    }

    private List<DeptTreeNode> buildTree(String parentId,
                                          Map<String, List<DeptEmpVo>> map) {
        List<DeptEmpVo> children = map.getOrDefault(parentId, Collections.emptyList());

        List<DeptTreeNode> tree = new ArrayList<>();
        for (DeptEmpVo item : children) {
            DeptTreeNode node = new DeptTreeNode();
            node.setId(item.getId());
            // 部门名称或员工名称
            node.setName(item.getDeptName() != null ? item.getDeptName() : item.getEmpName());
            // 递归构建子树(纯内存递归,不发 SQL)
            node.setChildren(buildTree(item.getId(), map));
            tree.add(node);
        }
        return tree;
    }
}

为什么这样快?

复制代码
优化前:每次递归发一条 SQL
  buildTree("root")
    → SQL: SELECT * FROM sys_dept WHERE parent_id='root'    [5ms 网络+查询]
    → 遍历 15 个子部门
      → buildTree("dept_001")
        → SQL: SELECT * FROM sys_dept WHERE parent_id='dept_001'  [5ms]
        → ... 总计 300 多次 I/O

优化后:一次 JOIN + 纯内存递归
  → SQL: LEFT JOIN 查询拿到全部 2000 条                    [10ms]
  → buildTree("root", 内存 Map)
    → 直接从 Map 中取,无网络 I/O                         [<0.1ms]
    → 遍历,递归取...                                    [纯 CPU 操作]

Collectors.groupingBy 将 2000 条扁平数据按 parentId 聚合成父子关系的速查表:给定任意 parentId,O(1) 时间内找到它所有子节点,递归构建树的过程完全在内存中完成,不再产生任何数据库访问。


四、优化效果------用数据说话

疑问:优化后效果怎么样?

4.1 前后对比

指标 优化前 优化后 提升
接口响应时间 3-5 秒 <300ms 提升 90%+
SQL 执行次数 300+ 次 1 次 减少 99.7%
数据库连接占用 持续占用 瞬时占用 ---
内存开销 几乎无 约 200KB 可忽略

4.2 验证方式

  • 功能验证:手动验证各级部门、不同层级深度的组织架构,确认数据完整、层级正确
  • 性能验证:通过浏览器 Network 面板多次测试,确保响应时间稳定在 300ms 以内
  • 兼容性验证:确认优化不影响审批流程、角色管理等其他依赖此组件的模块
  • 回归验证:确认原有审批流程、角色管理等功能不受影响,老用例全部通过

五、复盘------这次实习经历教会我的

5.1 关于 N+1 问题

N+1 问题不是课本上的理论,而是真实项目中的性能杀手。它的特征非常明显:

  • 数据量不大,但接口很慢
  • SQL 日志里看到大量相似查询只差一个参数
  • 代码里通常有循环 + 数据库查询的组合

排查手段:开启 SQL 日志永远是最直接的方法。

5.2 关于优化思路

优化的核心不是闷头写代码,而是先定位瓶颈在哪

  1. 是不是后端慢?→ Network 面板看接口 RT
  2. 是不是数据库慢?→ 开启 SQL 日志看执行次数和耗时
  3. 找到瓶颈后,再思考用什么方案

技术选型的原则:能解决当前问题的最简方案就是最好的方案。好的方案一定留好了向下一阶段平滑过渡的扩展口子。

5.3 关于主动性

这个优化不是我分配到的任务。我的思考是:

  • 在完成分配需求之余,关注一下系统里"不合理的地方"
  • 优化不需要多大的改动,有时只是一条 SQL 的重写
  • 实习生最容易被记住的,不是"完成了交代的任务",而是"做了没人交代但有意义的事"

总结

  • 通过开启 MyBatis SQL 日志,发现了组织架构选择器的 N+1 问题:300 多个部门发了 300 多次 SQL,但实际数据量只有 2000 条员工记录。瓶颈在 I/O 次数,不在数据量
  • 方案选择是核心:基于当前 2000 条数据量,选用了一次 JOIN 查询 + 内存组装方案。如果数据量增长到几十万,JOIN 产生的临时表过大时,可以平滑迁移到单表查询 + 索引 + 应用层组装方案
  • 优化后 SQL 执行次数从 300+ 次降到 1 次,接口响应时间从 3-5 秒降到 300ms 以内
相关推荐
vooy pktc3 小时前
Spring Security 官网文档学习
java·学习·spring
钰衡大师3 小时前
Activiti 7 工作流技术文档
java·数据库·spring boot
jvvz afqh3 小时前
mysql用户名怎么看
数据库·mysql
dvjr cloi3 小时前
Spring Framework 中文官方文档
java·后端·spring
研究点啥好呢3 小时前
滴滴Go后端开发工程师面试题精选:10道高频考题+答案解析
java·开发语言·golang
ictI CABL3 小时前
SpringBoot3.3.0集成Knife4j4.5.0实战
java
傻瓜搬砖人3 小时前
SpringMVC的请求
java·前端·javascript·spring
亚历克斯神3 小时前
Java 开发者 2026 成长路线图:从初级到架构师
java·spring·微服务
佛系彭哥3 小时前
用飞算JavaAI做项目:在线图书借阅平台设计与实现
java·飞算javaai炫技赛