前言
入职致远互联的第二周,我在测试审批流程时发现一个"难忍"的问题:组织架构选择器,点下去要等 5 秒。
这个组件不归我负责。但浏览器转圈 5 秒这件事,对任何一个开发者来说都像一根刺------它意味着某处一定有一个糟糕的 SQL 或循环在静静消耗用户的时间。
我做了三件事------打开控制台确认慢在后端、开启 MyBatis SQL 日志抓到 300 次循环查询、用一条 SQL 把 2000 条员工数据关联完成并把响应压到 300 毫秒。本文完整复盘这次排查优化全过程。
本文核心问题:
- 怎么发现这个性能问题的?用的是什么排查手段?
- MyBatis N+1 问题是什么?如何通过日志定位?
- 为什么循环查询会这么慢?真正的瓶颈是数据量还是 I/O 次数?
- JOIN 查询为什么比循环查询快?底层原理是什么?
- 当前数据量下为什么选 JOIN?数据量增长到几十万时怎么演进?
- 改写 SQL 后如何用内存组装树形结构?
- 优化后效果如何?怎么验证?
- 这次实习经历让我学到了什么?
一、问题发现------"这个树形控件怎么这么慢?"
疑问:一个部门选择器而已,为什么会引起你的注意?
我在测试自己负责模块的审批流程时,需要选择审批人。点击组织架构选择器后,页面卡了 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_id和dept_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 关于优化思路
优化的核心不是闷头写代码,而是先定位瓶颈在哪:
- 是不是后端慢?→ Network 面板看接口 RT
- 是不是数据库慢?→ 开启 SQL 日志看执行次数和耗时
- 找到瓶颈后,再思考用什么方案
技术选型的原则:能解决当前问题的最简方案就是最好的方案。好的方案一定留好了向下一阶段平滑过渡的扩展口子。
5.3 关于主动性
这个优化不是我分配到的任务。我的思考是:
- 在完成分配需求之余,关注一下系统里"不合理的地方"
- 优化不需要多大的改动,有时只是一条 SQL 的重写
- 实习生最容易被记住的,不是"完成了交代的任务",而是"做了没人交代但有意义的事"
总结
- 通过开启 MyBatis SQL 日志,发现了组织架构选择器的 N+1 问题:300 多个部门发了 300 多次 SQL,但实际数据量只有 2000 条员工记录。瓶颈在 I/O 次数,不在数据量
- 方案选择是核心:基于当前 2000 条数据量,选用了一次 JOIN 查询 + 内存组装方案。如果数据量增长到几十万,JOIN 产生的临时表过大时,可以平滑迁移到单表查询 + 索引 + 应用层组装方案
- 优化后 SQL 执行次数从 300+ 次降到 1 次,接口响应时间从 3-5 秒降到 300ms 以内