该文章为ai润色,原文笔记在最后。该文章仅为个人记录所写。
1. 层级多,不代表需要分表
组织树的层级表示父子关系有多深,节点数量才表示有多少条记录。几十层的树可能只有几百个节点,三层的树也可能有几十万个节点。
因此,不能根据层级数量直接决定分表。对于数据规模可控的组织结构,可以先考虑用一张表存储,再根据查询需求设计字段和索引。
分表还会增加跨表查询、数据迁移和维护的复杂度。如果把不同层级拆到不同表里,查询整棵树时也可能需要访问多张表。设计时需要确认这些成本能否换来实际收益。
阿里巴巴公开的《Java 开发手册》建表规约中,给出了“单表超过 500 万行或容量超过 2GB”时才推荐考虑分库分表的建议。我把它作为避免过早拆分的参考,而不是数据库到这个数值就一定变慢的界线。
实际判断还要结合查询耗时、并发量、索引、硬件资源、数据增长速度和备份恢复要求。分表也不能简单理解为“把 B+ 树控制在三层”,它需要解决具体的容量或访问瓶颈。
2. 用哪些字段表达组织树?
一个基本做法是用id标识节点,用parent_id表示直接父节点。需要频繁查询子树、筛选层级或调整展示顺序时,再考虑增加路径、深度和排序字段。
下面是假设的组织结构,约定根节点深度为 1,路径包含当前节点,并以/分隔:
| id | 名称 | parent_id | path | depth | sort_order |
|---|---|---|---|---|---|
| 1 | 公司 | NULL | /1/ | 1 | 1 |
| 12 | 技术部 | 1 | /1/12/ | 2 | 1 |
| 35 | 后端组 | 12 | /1/12/35/ | 3 | 1 |
| 36 | 前端组 | 12 | /1/12/36/ | 3 | 2 |
父节点:表达直接关系
parent_id回答的是“这个节点直接属于谁”。查技术部的直接子节点,可以按父节点筛选:
SELECT id, name FROM organization WHERE parent_id = 12 ORDER BY sort_order, id;
这条查询只查直接子节点,不会自动查出所有后代。
路径:方便查一整棵子树
path记录从根节点到当前节点的完整路线。按上面的约定,查询技术部及其所有后代,可以写成:
SELECT id, name, path FROM organization WHERE path LIKE '/1/12/%';
这个条件也会匹配技术部自身的/1/12/。如果只需要后代节点,还要排除当前节点。分隔符则能避免把节点12与节点123的路径混淆。
路径让子树查询更直接,但也增加了维护成本:移动一个部门时,它和所有后代的路径都可能需要更新,并且要防止节点挂到自己的后代下面形成环。
深度:方便筛选某一层
depth表示节点处于第几层。例如,按本文约定,depth = 3表示第三层节点。
单独保存深度,能够让层级筛选更直接;是否需要索引,则取决于实际查询和数据分布。不能仅凭“达到五层”就断言必须增加这个字段。
深度属于冗余信息。节点移动后,需要同步更新受影响节点的深度,避免它与父子关系、路径不一致。仅有深度字段,也不能自动保证跨节点的层级规则正确。
排序值:控制同一父节点下的顺序
sort_order用于调整兄弟节点的展示顺序,例如让后端组排在前端组前面。这里约定数值越小越靠前,数值相同时再按id排序。
路径、深度和排序值各有用途,不需要因为“树比较深”就一次加齐。应先明确经常执行哪些查询,以及哪些信息需要单独维护。
3. 查询组织树,可以先考虑哪些方式?
一次查询,内存组装。如果本次需要加载的节点数量和返回数据量可控,可以一次查出相关节点,在 Java 中按parent_id建立父子关系。这样能够避免逐个节点查询子节点产生的 N+1 查询。是否适合全量加载,要看实际节点数、内存和请求规模,不能只看树是否超过三层。
递归 CTE。MySQL 8.0 及之后的版本支持WITH RECURSIVE,可以在一条 SQL 中沿父子关系递归查询。这是一种表达树形查询的方式,性能仍要结合索引、递归深度和返回结果量判断。递归查询也需要考虑终止条件和深度限制。
路径前缀查询。如果经常查询整棵子树,并且可以接受移动节点时更新路径的成本,可以考虑使用路径字段。
这几种方式解决的问题不同。我的理解是,先选能表达当前业务需求的简单方案,再通过实际查询观察是否需要优化。
4. LIKE 前缀匹配与索引
| 条件 | 含义 | 对普通 B-tree 索引范围定位的影响 |
|---|---|---|
LIKE 'abc%' | 前缀匹配,通配符在末尾 | 具备利用索引做范围定位的条件 |
LIKE '%abc' | 后缀匹配,通配符在开头 | 通常不能依靠这个条件做前缀范围定位 |
LIKE '%abc%' | 包含匹配 | 通常不能依靠这个条件做前缀范围定位 |
MySQL 文档说明,常量模式不以通配符开头的LIKE条件,可以作为 B-tree 索引的范围条件。但“具备条件”不代表优化器一定选择该索引,仍要结合索引定义、查询条件和执行计划判断。
所以,我不再把所有模糊查询都称为“索引失效”。例如,path LIKE '/1/12/%'属于前缀匹配,但具体执行方式仍应使用EXPLAIN查看。
5. 什么时候考虑缓存?
如果同一份组织树被频繁读取,更新相对较少,可以考虑把查询结果缓存到 Redis。Redis 常用于缓存数据库数据的副本,减少重复读取。
缓存命中率并不会因为“组织变动少”就自动很高,还与访问是否集中、过期时间和缓存容量有关。新增、删除或移动节点后,也需要考虑旧缓存何时失效、怎样更新,以及业务能否接受短暂的旧数据。
因此,我会先确认是否确实存在重复查询的压力,再决定是否增加缓存,而不是把 Redis 当成组织树设计的必选项。
原文内容如下(原文内容可能有错误,仅供参考):
数据库表设计
组织树是否分表?
绝大多数业务场景下都不需要分表。因为组织树数据通常读多写少,总量可控
如果分表,那么需要大量联表查询降低查询效率
何时需要分表?
分表的判断依据是数据量而不是层级
分表是为了在数据量多的情况下降低B+树索引的高度以减少磁盘IO。
并且层级多不代表数据量多,几十个层级可能只有几百条数据,而三层层级也可能有几十万条数据
阿里巴巴开发手册中,单表超500万行或单表数据大于2G时才推荐分表
机械硬盘时代,B+树最好控制在3层,3层是比较理想的磁盘IO量
而具体能够存多少由数据库每行大小计算得出,同时考虑到数据备份(单表数据量过大备份困难)
组织树优化方式
表结构优化
使用祖先路径/层级码划分:将层级划分,使用.区分,存储从根节点到当前节点的完整路径。查询时可使用LIKE模糊查询
模糊查询与索引:前缀匹配,当使用右模糊查询时,可以前缀匹配到索引。而使用左模糊/全模糊时才会造成索引失效,所以使用祖先路径/层级码划分时,可使用左模糊查询层级下的组织数据
层级深度:记录层级深度,查询时可快速根据层级深度查询第n层级的所有数据
排序字段:专门控制同层级、同父节点下的节点展示顺序的独立字段。可以设定排序靠前优先展示。
使用建议:当层级极浅(树的总层级≤3),数据量极小(总节点数≤1000),变动频率极低,查询逻辑简单时,可以只使用祖先路径。 当满足以下任意一条时,冗余层级深度和排序字符能带来显著的性能和维护收益:
层级较深:树的总层级≥5层,每次解析长路径字符串计算深度会产生明显的性能开销,独立的
level字段可以直接通过索引快速筛选某一层的所有节点。数据量较大:总节点数≥10万条,无法全量加载到内存,必须依赖数据库索引直接完成层级筛选和排序,避免全表扫描。
排序规则灵活:需要频繁调整同层级节点的展示顺序,比如把某个部门置顶、调整小组优先级,独立的
sort_order字段可以直接修改数值完成排序,不需要修改整条祖先路径。业务校验严格:有强制的层级规则,比如“所有三级节点不能直接挂在一级节点下”,独立的
level字段可以在数据库层面快速做约束校验,不需要解析路径字符串。
总结:祖先路径是全链路的字符串标识,记录从根节点到当前节点的完整路径。用于快速定位整颗子树。层级深度是一个纯数字的单属性值,用于判断当前节点在数的第几层。排序字符专门用于控制同层级、同父节点下子节点的排序顺序。
查询优化
一次性加载 + 内存组装:对于不超过3级的树,数据量通常不大。可以一次性查出所有相关节点,在 Java应用层内存中构建树形结构。这种方式避免了 N+1 查询问题,性能极高 。
使用 CTE(公用表表达式):如果数据库支持(如 MySQL 8.0+),可以使用
WITH RECURSIVE进行高效的递归查询,无需分表也能处理复杂的树形逻辑 。缓存存储
将组织树结构缓存到 Redis 中。由于组织变动不频繁,缓存命中率极高,能大幅减轻数据库压力。