在 PostgreSQL 中建模组织结构:邻接表 vs 闭包表
通过比较邻接表和闭包表,在 PostgreSQL 中建模组织结构,并提供筛选、报表和权限检查的清晰示例。

组织结构图需要支持什么
组织结构图是一个关于谁向谁汇报,以及团队如何汇总到部门的地图。在 PostgreSQL 中建模组织结构,不只是把 manager_id 保存在每个人身上。你需要支持真实的工作场景:组织浏览、报表和访问规则。
大多数用户期望三件事是瞬时的:浏览组织、查找人员,以及把结果过滤到“我的范围”。他们也期望更新是安全的。当经理变更时,图表应在所有地方即时更新而不破坏报表或权限。
实践中,一个好的模型需要回答一些经常出现的问题:
- 这个人的指挥链(到最高层)是什么?
- 这个经理下有哪些人(直接下属和整个子树)?
- 人员如何分组到团队和部门以供仪表盘使用?
- 重组如何在不出错的情况下发生?
- 基于组织结构谁能看到什么?
问题比简单树更复杂,因为组织经常变化。团队在部门之间移动、经理互换组,有些视图并不是纯粹的“人向人汇报”。例如:一个人属于一个团队,而团队属于部门。权限又是另一层:组织的形状成了你的安全模型的一部分,而不仅仅是一张图。
一些术语有助于保持设计清晰:
- 节点(node)是层级中的一个项(一个人、一个团队或一个部门)。
- 父节点(parent)是直接在其上方的节点(经理,或拥有团队的部门)。
- 祖先(ancestor)是任意距离上方的节点(你的经理的经理)。
- 后代(descendant)是任意距离下方的节点(你下面的所有人)。
示例:如果销售部门移到一个新的副总下面,两件事应立即保持为真。仪表盘仍然能过滤“整个 Sales”,新 VP 的权限自动涵盖 Sales。
在选择表设计前需要做出的决策
在确定模式前,要明确你的应用每天必须回答什么。"谁向谁汇报?" 只是起点。许多组织结构还需要显示谁领导部门、谁批准团队的休假,以及谁能看到某份报表。
把你的界面和权限检查会问的具体问题写下来。如果你说不清问题,你最终会得到一个看起来正确但难以查询的模式。
影响一切的决策有:
- 哪些查询必须快速:直接经理、到 CEO 的链、某领导下的完整子树,还是“部门 X 的所有人”?
- 它是严格树状(一个经理)还是矩阵组织(多个经理或负责人)?
- 部门是与人员在同一层级的节点,还是作为单独属性存在(比如每个员工的
department_id)? - 有人可以属于多个团队吗(共享服务、小队)?
- 权限如何流动:向下、向上还是两者?
这些选择定义了“正确”数据的样子。如果 Alex 同时领导 Support 和 Onboarding,一个单一的 manager_id 或“每个团队只有一位负责人”的规则可能行不通。你可能需要一个连接表(负责人到团队),或者明确策略比如“一个主团队,外加虚线团队”。
部门又是一个分叉。如果把部门作为节点,你可以表示“部门 A 包含团队 B,团队 B 包含人员 C”。如果部门是分离的,你将用 department_id = X 来过滤,虽然起步更简单,但团队跨部门时会变得脆弱。
最后,用简单语言定义权限。"经理可以查看其下所有人的薪资,但不能查看同级" 是向下的规则。"任何人都能看到自己的管理链" 是向上的规则。早点决定这些会改变哪种层级模型更自然,以及哪种模型会迫使以后做昂贵查询。
邻接表:管理者和团队的简单模式
如果你想要最少的移动部件,邻接表是经典的起点。每个人保存一个指向其直接经理的指针,树通过跟随这些指针构建。
最小设置如下:
create table departments (
id bigserial primary key,
name text not null unique
);
create table teams (
id bigserial primary key,
department_id bigint not null references departments(id),
name text not null,
unique (department_id, name)
);
create table employees (
id bigserial primary key,
full_name text not null,
team_id bigint references teams(id),
manager_id bigint references employees(id)
);
你也可以跳过单独表,把 department_name 和 team_name 作为 employees 的列。这种做法起步更快,但更难保持整洁(拼写错误、重命名团队和不一致的报告)。单独表能让过滤和权限规则更容易一致地表达。
早点添加保护措施很重要。糟糕的层级数据以后修复很痛。至少要防止自我管理(manager_id <> id)。还要决定经理是否可以在同一团队或部门外,以及是否需要软删除或历史变更(用于审计汇报线)。
在邻接表中,大多数变更是简单写入:更改经理更新 employees.manager_id,移动团队更新 employees.team_id(通常同时更新经理)。问题在于一次小的写入可能带来很大下游影响。报表汇总会变化,任何“经理可以查看所有下属”的规则现在必须遵循新的链路。
这种简洁是邻接表最大的优势。其弱点出现在你频繁按“某经理下所有人”过滤时,因为通常每次都要依赖递归查询来遍历树。
邻接表:常见的过滤和报表查询
用邻接表时,许多有用的组织结构问题都变成递归查询。如果你这样在 PostgreSQL 中建模,这些模式会经常使用。
直接下属(一层)
最简单的情况是经理的直接团队:
SELECT id, full_name, title
FROM employees
WHERE manager_id = $1
ORDER BY full_name;
这既快速又易读,但只向下一层。
指挥链(向上)
要展示某人向谁汇报(经理、经理的经理等),使用递归 CTE:
WITH RECURSIVE chain AS (
SELECT id, full_name, manager_id, 0 AS depth
FROM employees
WHERE id = $1
UNION ALL
SELECT e.id, e.full_name, e.manager_id, c.depth + 1
FROM employees e
JOIN chain c ON e.id = c.manager_id
)
SELECT *
FROM chain
ORDER BY depth;
这支持审批、升级路径和经理面包屑导航。
完整子树(向下)
要获取某个领导下的所有人(所有层级),把递归方向翻转:
WITH RECURSIVE subtree AS (
SELECT id, full_name, manager_id, department_id, 0 AS depth
FROM employees
WHERE id = $1
UNION ALL
SELECT e.id, e.full_name, e.manager_id, e.department_id, s.depth + 1
FROM employees e
JOIN subtree s ON e.manager_id = s.id
)
SELECT *
FROM subtree
ORDER BY depth, full_name;
一个常见报表是“在领导 Y 之下且属于部门 X 的所有人”:
WITH RECURSIVE subtree AS (
SELECT id, department_id
FROM employees
WHERE id = $1
UNION ALL
SELECT e.id, e.department_id
FROM employees e
JOIN subtree s ON e.manager_id = s.id
)
SELECT e.*
FROM employees e
JOIN subtree s ON s.id = e.id
WHERE e.department_id = $2;
邻接表的查询在权限方面可能有风险,因为访问检查通常依赖完整路径(查看者是否是该人的某个祖先?)。如果某个端点忘记递归或在错误的位置应用过滤,你可能泄露行。还要注意循环和缺失经理等数据问题。一条坏记录可能会破坏递归或返回意外结果,因此权限查询需要防护和良好的约束。
闭包表:如何存储整个层级
闭包表存储每一对祖先-后代关系,而不仅仅是直接经理链接。你不必一步步走树,而是可以直接问:“谁在该领导之下?”并通过一次简单的连接得到完整答案。
通常保留两张表:一张节点表(人员或团队),一张层级路径表。
-- nodes
employees (
id bigserial primary key,
name text not null,
manager_id bigint null references employees(id)
)
-- closure
employee_closure (
ancestor_id bigint not null references employees(id),
descendant_id bigint not null references employees(id),
depth int not null,
primary key (ancestor_id, descendant_id)
)
闭包表存储像 (Alice, Bob) 这样的对,意为“Alice 是 Bob 的一个祖先”。它也保存一行 ancestor_id = descendant_id 且 depth = 0 的自我行。自我行乍看奇怪,但它让很多查询更简洁。
depth 告诉你两个节点相隔多远:depth = 1 是直接经理,depth = 2 是经理的经理,等等。当需要把直接下属与间接下属区分开时这点很重要。
主要好处是读取可预测且快速:
- 整个子树查找很快(某个主管下面的所有人)。
- 指挥链很简单(某人的所有上级)。
- 你可以用
depth区分直接关系和间接关系。
代价是在更新时的维护。如果 Bob 的经理从 Alice 变为 Dana,你必须为 Bob 及其下属子树重建闭包行。典型做法是:删除该子树的旧祖先路径,然后通过把 Dana 的祖先与 Bob 子树的每个节点组合来插入新路径并重新计算 depth。
闭包表:用于快速过滤的常见查询
闭包表预先存储每一对祖先-后代(通常为 org_closure(ancestor_id, descendant_id, depth))。这让组织过滤变得快速,因为大多数问题都变成一次连接。
列出某经理下面的所有人,只需一次连接并按 depth 过滤:
-- Descendants (everyone in the subtree)
SELECT e.*
FROM employees e
JOIN org_closure c
ON c.descendant_id = e.id
WHERE c.ancestor_id = :manager_id
AND c.depth > 0;
-- Direct reports only
SELECT e.*
FROM employees e
JOIN org_closure c
ON c.descendant_id = e.id
WHERE c.ancestor_id = :manager_id
AND c.depth = 1;
对于指挥链(某员工的所有祖先),把连接方向翻转:
SELECT m.*
FROM employees m
JOIN org_closure c
ON c.ancestor_id = m.id
WHERE c.descendant_id = :employee_id
AND c.depth > 0
ORDER BY c.depth;
过滤变得可预测。例如:“领导 X 下面所有人,但只在部门 Y”:
SELECT e.*
FROM employees e
JOIN org_closure c ON c.descendant_id = e.id
WHERE c.ancestor_id = :leader_id
AND e.department_id = :department_id;
因为层级是预计算的,计数也很直接(不需要递归)。这有助于仪表盘和按权限限定的统计,并且与分页和搜索配合良好,因为你可以在后代集合上直接应用 ORDER BY、LIMIT/OFFSET 和过滤。
每种模型如何影响权限和访问检查
一个常见的组织规则是:经理可以查看(有时编辑)其下的所有内容。你选择的模式决定了你需要多频繁计算“谁在谁之下”。
在邻接表中,权限检查通常需要递归。如果用户打开一个列出 200 个员工的页面,通常需要用递归 CTE 构建后代集合并据此过滤目标行。
在闭包表中,相同规则通常可以用一个简单的存在性测试来检查:“当前用户是否是该员工的祖先?”。如果是,就允许访问。
-- Closure table permission check (conceptual)
SELECT 1
FROM org_closure c
WHERE c.ancestor_id = :viewer_id
AND c.descendant_id = :employee_id
LIMIT 1;
当你引入行级安全(RLS)并让每个查询自动包含像“只返回查看者能看到的行”的规则时,这种简洁性很重要。用邻接表时,策略往往嵌入递归且更难调优;用闭包表时,策略常常是直接的 EXISTS (...) 检查。
边界情况常常破坏权限逻辑:
- 虚线汇报:某人实际上有两个经理。
- 助手与委托:访问不基于层级,因此需要存显式授权(通常带过期)。
- 临时访问:有时间界限的权限不应写入组织结构。
- 跨团队项目:应按项目成员身份授权,而不是管理链。
如果你在 AppMaster 中构建,闭包表通常能很好映射到可视化数据模型,并能让访问检查在 Web 与移动端保持简单。
权衡:速度、复杂性与维护
最大选择在于你优化什么:简单写入与小模式,还是针对“谁在某经理下”及权限检查的快速读取。
邻接表使表保持小且更新简单。代价体现在读取上:整棵子树通常需要递归。如果你的组织较小、UI 只加载少数层级,或者基于层级的过滤仅用于少数页面,这通常没问题。
闭包表将代价反转。读取变快,因为你可以用常规连接回答“所有后代”。但写操作更复杂:一次移动或重组可能需要插入和删除大量关系行。
在实际工作中,权衡通常看起来像这样:
- 读取性能:邻接需要递归;闭包大多是连接,随组织增长保持快速。
- 写复杂度:邻接更新一个
parent_id;闭包可能为一次移动更新许多行。 - 数据规模:邻接随人员/团队增长;闭包随关系增长(最坏情况下接近 N 平方,特别是深树)。
两个模型都需要索引,但目标不同:
- 邻接表:为父指针(
manager_id)建立索引,以及常用过滤列(如“active”标志)。 - 闭包表:为
(ancestor_id, descendant_id)建主键索引,并为常见查找建立单独的descendant_id索引。
一个简单规则:如果你很少按层级过滤且权限检查只是“经理查看直接下属”,邻接表通常足够。如果你经常运行“VP X 下面所有人”的报表、按部门树过滤或在许多界面实施层级权限,闭包表通常能通过额外维护换来长期收益。
逐步:从邻接表迁移到闭包表
你不必在第一天就选定一种模型。安全路径是保留邻接表(manager_id 或 parent_id),并旁边添加闭包表,然后逐步迁移读取。这降低了风险,同时让你验证新层级在真实查询和权限检查中的表现。
先创建闭包表(通常叫 org_closure),列包含 ancestor_id、descendant_id 和 depth。把它与现有的 employees 或 teams 分开,以便回填和验证而不影响当前功能。
一个实用的推广流程:
- 创建闭包表和索引,同时保留邻接表作为事实来源。
- 根据当前的经理关系回填闭包行,包括自我行(每个节点在
depth = 0时是自己的祖先)。 - 做抽样验证:挑一些经理,确认两种模型返回的下属集合一致。
- 先切换读取路径:报表、过滤和层级权限先从闭包表读取,再改变写入路径。
- 每次写操作都保持闭包表更新(重分配上级、入职、移动团队)。验证稳定后再弃用基于递归的查询。
验证时关注那些通常会破坏访问规则的情况:经理变更、顶层领导和没有经理的用户。
如果在 AppMaster 构建,你可以在添加闭包表且回填时保持旧端点运行,新增端点先读闭包表,结果一致后再切换。
破坏组织过滤或权限的常见错误
破坏组织功能的最快方式是让层级数据变得不一致。逐行看数据可能没问题,但小错误会导致错误的过滤、页面变慢或权限泄露。
一个经典问题是意外创建循环:A 管理 B,后来有人把 B 设置为管理 A(或通过 3-4 个人形成更长的环)。递归查询可能无限运行、返回重复行或超时。即便有闭包表,循环也会污染祖先/后代行。
另一个常见问题是闭包漂移:你改变了某人的经理,但只更新了直接关系而忘记重建该子树的闭包行。结果像“某 VP 下的所有人”的过滤会返回旧结构和新结构的混合。这难以发现,因为个人资料页依然看起来正确。
当没有明确规则将部门和汇报线分开时,组织结构也会变得混乱。部门通常是行政分组,而汇报线关乎经理。如果把它们视为同一棵树,你可能遇到诸如“部门移动”意外改变访问权限的奇怪行为。
权限失败最常见的情况是只查看直接经理。如果你允许访问的条件是 viewer is manager of employee,你会遗漏完整链条。结果要么过度阻止(跨级经理无法看到其组织),要么过度共享(临时直接经理获得访问)。
导致页面变慢的常见原因是在每次请求上运行递归过滤(每个收件箱、每个工单列表、每次员工搜索)。如果同一过滤在很多地方被使用,要么使用预计算路径(闭包表),要么缓存允许的员工 ID 集合。
一些实用防护措施:
- 在保存经理变更前用验证阻止环路。
- 决定“部门”意味着什么并与汇报线分离。
- 如果使用闭包表,在经理变更时重建后代行。
- 为完整链条编写权限规则,而不仅仅是直接经理。
- 对列表页面使用预计算的组织范围,而不是每次都重新计算递归。
如果在 AppMaster 构建管理面板,把“变更经理”当作敏感工作流:先验证、更新相关层级数据,然后再让变更影响过滤和访问。
上线前的快速检查
在把组织结构称为“完成”之前,确保你能用简单的话解释访问规则。如果有人问“谁能看到员工 X,为什么?”,你应该能指向一个规则和一个查询(或视图)来证明。
性能是下一个现实检查。用邻接表时,“显示某经理下所有人”是递归查询,其速度依赖深度和索引。用闭包表时,读取通常很快,但你必须信任写入路径在每次变更后保持表的正确性。
上线前的简短检查清单:
- 选一个员工并端到端追踪可见性:哪个链条授权访问,哪个角色拒绝访问。
- 用预期规模对经理子树查询进行基准测试(例如 5 层深和 50,000 名员工)。
- 阻止错误写入:用约束和事务检查防止环路、自我管理和孤立节点。
- 测试重组安全性:移动、合并、经理变更,并测试在中途失败时的回滚。
- 添加权限测试,断言对现实角色(HR、经理、团队负责人、支持)既有允许也有拒绝的情况。
一个实用的验证场景:支持人员只能查看分配给他们的部门内的员工,而经理可以查看其完整子树。如果你能在 PostgreSQL 中建模并用测试证明这两条规则,你就接近上线了。
如果你在 AppMaster 中内部构建,把这些检查做成自动化测试,覆盖返回组织列表和员工资料的端点,而不仅仅是数据库查询。
示例场景与下一步
想象一个公司有三个部门:Sales、Support 和 Engineering。每个部门有两个团队,每个团队有一个负责人。销售组 A 的负责人可以为其团队批准折扣,支持组 B 的负责人可以查看其部门的所有工单,工程副总可以查看 Engineering 下的所有内容。
接着发生重组:一个支持团队被移到 Sales 下面,并在 Sales 总监与两个团队负责人之间新增一位经理。第二天有人提出访问请求:“让 Jamie(一名 Sales 分析员)查看 Sales 部门的所有客户账户,但不能查看 Engineering 的。”
如果你用邻接表建模,模式简单,但应用层的工作会转移到查询和权限检查上。像“Sales 下所有人”这样的过滤通常需要递归。一旦加入审批(例如“只有链条内的经理可以批准”),重组后的边缘情况开始影响结果。
用闭包表,重组意味着更多写工作(更新祖先/后代行),但读取端变得直观。过滤和权限通常变成简单的连接:“该用户是该员工的祖先吗?”或“该团队在这个部门子树内吗?”。
这直接体现在界面设计上:按部门限定的人员选择器、路由到最接近请求者的经理的审批、部门仪表板的管理视图,以及能解释某个日期为何存在访问的审计记录。
下一步:
- 用简单的语言写出权限规则(谁能看什么,为什么)。
- 选择一个与最常见检查匹配的模型(快速读取 vs 更简单的写入)。
- 构建一个内部管理工具,让你端到端测试重组、访问请求和审批流程。
如果你想快速构建那些感知组织关系的管理面板和门户,AppMaster(appmaster.io)可能是实用的选择:它让你可视化建模基于 PostgreSQL 的数据,在可视化业务流程中实现审批逻辑,并从同一后端交付 Web 与原生移动应用。
常见问题
当你的组织较小、更新频繁且大多数界面只需直接下属或少数层级时,使用邻接表(adjacency list)。当你经常需要“某领导下的所有人”、按部门树过滤或在许多页面上强制层级权限时,使用闭包表(closure table),因为读取操作变成简单的连接,随着规模增长也更可预测。
以 employees(manager_id) 开始,并通过 WHERE manager_id = ? 获取直接下属。仅在真正需要完整祖先链或整棵子树(例如审批、“我的组织”过滤或跨级仪表盘)时再添加递归查询。
用检查阻止自我管理,例如 manager_id <> id,并在更新时验证不要把某人指派为其子树中的某个节点的经理。实际做法是在保存变更前检查祖先关系,这样一条环路就不会破坏递归或权限逻辑。
一个合理的默认是把部门视为行政分组,把汇报关系作为单独的经理树。这样“部门移动”不会意外改变某人的汇报对象,也能在汇报线与部门边界不一致时更清晰地过滤“所有 Sales 的人”。
通常在员工记录上保存一个主要汇报经理,同时把虚线关系单独保存,比如次要经理表或“团队负责人”映射。这能保持基本层级查询的简单性,同时支持项目访问或审批委派等特殊规则。
删除被移动员工子树的旧祖先路径,然后通过把新经理的所有祖先与子树中的每个节点组合来插入新路径并重新计算 depth。把这些操作放在事务中,以避免在变更中途失败时留下半更新的闭包表。
邻接表:为 employees(manager_id) 建索引,因为几乎每个组织查询都从这里开始,并为常用过滤(如 team_id 或 department_id)建立索引。闭包表:主键 (ancestor_id, descendant_id) 必需,另建 descendant_id 的索引以加速“谁能看到这条记录?”之类的检查。
一个常见模式是在闭包表上用 EXISTS:当查看者是目标员工的某个祖先时给予访问。这与行级安全(RLS)配合得很好,因为数据库可以将规则一致地应用到所有查询,而不用每个 API 端点都记住递归逻辑。
把历史显式记录在独立表中,记录经理变更和生效日期,而不是直接覆盖当前经理并丢失过去信息。这样可以在任何给定日期回答“谁向谁汇报”的问题,并让重组后的报表和审计保持一致。
保留现有的 manager_id 作为真实来源,在旁边创建闭包表并从当前树回填闭包行。先切换只读路径(过滤、仪表盘、权限检查)到闭包表,再让写操作同时更新两者,验证一致后再弃用递归查询。


