因WITH RECURSIVE仅返回扁平数据且无行为逻辑,无法支持审批、通知等分支操作;ORM级联易致N+1或全量加载浪费资源;应采用“一次查平表+内存构树”方式,配合环检测与缓存机制保障安全高效。

为什么不能直接用 SQL 的 WITH RECURSIVE 查完就完事
因为 WITH RECURSIVE 只返回扁平数据,不承载行为逻辑。你要对某个部门执行 calculateBudget(),而它的子部门中:前端组要走三级审批,后端组要触发钉钉通知,测试组要汇总每日缺陷率——这些动作无法塞进一条 SQL 的 SELECT 里。
更现实的问题是:ORM(比如 MyBatis 或 Hibernate)默认的 @OneToMany 级联加载,一查根节点就触发 N+1 查询,或一次性把全树拉下来却只用到前两层,内存和网络都浪费。
- 数据库递归查询适合「只读快照」,不适合「带状态、带分支逻辑的遍历」
- 组合模式不是替代 SQL,而是补足 SQL 做不了的事:统一调度、按需响应、避免重复计算
- 真正要优化的不是“怎么查得更快”,而是“查一次之后,怎么让后续操作不反复查”
必须用「一次查平表 + 内存构树」而非 ORM 级联
假设你有一张 department 表,含 id、name、parent_id、type(如 "team" / "dept" / "center")。别写 @OneToMany(mappedBy = "parent"),那会让每个节点都触发一次 SQL。
正确做法是:用一条 SQL 查出全部有效节点(加 WHERE 过滤掉已停用/测试部门),然后在 Java/C# 中构建树:
SELECT id, name, parent_id, type FROM department WHERE status = 'ACTIVE' ORDER BY parent_id, id
- 查完后用
Map<Long, DepartmentNode>缓存所有节点,再遍历一次,用parent_id挂载子节点到对应父节点的children列表中 -
DepartmentNode类里,getChildren()返回Collections.unmodifiableList(children),绝不返回null - 叶子节点(比如无下属的小组)的
getChildren()返回空ArrayList,不是null—— 否则每层递归都要if (node.getChildren() != null)
Component 接口里不要放 add()/remove() 方法
这是最容易被误抄的反模式。很多教程示例把 add(Component) 和 remove(Component) 放在 Component 接口里,导致 Leaf 类被迫实现并抛异常,破坏了接口语义。
真实业务中,叶子节点(如“UI 设计师”岗位)根本不可能添加子节点;强行暴露方法,只会让调用方在运行时才发现错误,且 IDE 无法静态检查。
- 把
add()、remove()、getChildCount()全部移到Composite实现类里 -
Component接口只保留行为方法(如execute()、calculate())和只读结构方法(如getChildren()、getName()) - 如果真需要动态增删,用单独的
TreeEditor服务封装,而不是污染通用接口
防环引用和重复计算是 runtime 最容易崩的点
组织架构里出现 A → B → C → A 这种环,不是理论风险,是真实发生过的线上事故。一旦 execute() 递归进去,JVM 直接栈溢出;或者某节点被多个父节点引用,calculateBudget() 被执行两次,预算翻倍。
- 构树阶段就要校验环:维护一个
Set<Long> visited,从每个根节点向下 DFS,遇到已访问 ID 就报错并记录路径 - 聚合类方法(如
sumSalary())必须支持缓存:在Composite节点里加private volatile BigDecimal cachedSum;,首次调用才计算,后续直接返回 - 不要在
getChildren()里查库或做重算——这个方法会被遍历逻辑高频调用,必须是 O(1) 的取值
实际落地时,最常被跳过的一步是「构树后的环检测」。它不难写,但没人愿意在开发阶段加;等到生产环境出现无限递归,排查成本远高于预防成本。

















