组织结构怎样建模:知识层、有效期与无环
组织架构第一版是一棵树:公司下面是事业部,事业部下面是部门,部门下面是团队,每一层一个类,
parent字段指向上级。半年后,团队要同时属于一个行政部门和一个职能部门;部门开始嵌套部门;HR 要查「这个团队 3 月份归谁管」。每个需求都意味着改代码和表结构。
组织结构是一个典型的「看起来简单、变化方向很多」的模型。层级的种类会变,同一个组织可能同时处在几条线上,关系会随时间调整,还要保证不出现环。把这些全部写进类层次,每次组织调整都变成一次发版。
分析模式里有一个处理这类问题的思路:把模型分成两层。知识层描述「什么样的关系可以成立」,实例层记录「实际存在哪些关系」。本文用一个组织结构的例子把这两层落到 PostgreSQL 18 上,并验证有效期和无环约束在并发下的表现。实验见文末。
一、从需求里分出两类知识
把需求按「它描述的是规则还是事实」分开:
| 需求 | 类别 |
|---|---|
| 团队只能挂在部门下;部门可以挂在事业部或另一个部门下 | 规则:哪类组织可以挂在哪类组织下 |
| 有行政线和职能线两套结构 | 规则:有哪些结构线 |
| 研发一组现在属于平台部 | 事实:一条具体的上下级关系 |
| 研发一组 7 月 1 日起调到基础设施部 | 事实,并且带时间 |
| 任何一条结构线上都不能出现环 | 跨越所有事实的约束 |
第一版把规则写成了代码:Team 类的 parent 字段类型是 Department,所以「团队只能挂在部门下」由编译器保证。代价是规则一变就要改类型。两层模型把规则也变成数据:
-- 知识层
CREATE TABLE structure (code TEXT PRIMARY KEY); -- LINE(行政线)、FUNCTION(职能线)
CREATE TABLE allowed_parent (child_type TEXT, parent_type TEXT, structure TEXT REFERENCES structure,
PRIMARY KEY (child_type, parent_type, structure));
-- 实例层
CREATE TABLE org_unit (id TEXT PRIMARY KEY, type TEXT NOT NULL);
CREATE TABLE org_link (child TEXT REFERENCES org_unit, parent TEXT REFERENCES org_unit,
structure TEXT REFERENCES structure, valid DATERANGE NOT NULL,
EXCLUDE USING gist (child WITH =, structure WITH =, valid WITH &&));在领域模型里,OrganizationType 和 Structure 是知识层的对象,Organization 和 OrganizationLink 是实例层的对象。挂接一个组织时,实例层去问知识层「这样挂合法吗」:
TEAM-X 挂在 DEPT-A 下(行政线):成功
DEPT-B 挂在 TEAM-X 下(行政线):被拒绝(知识层不允许 DEPT-B 挂在 TEAM-X 下(LINE))
TEAM-Y 直接挂在 DIV-R 下:被拒绝
在知识层加一行 (TEAM, DIVISION, LINE),不改代码,再挂一次:成功新的组织形式只需要在知识层加一行数据。
二、改知识层不是改配置,是一次迁移
规则变成数据之后,修改它看起来只是改一行记录,于是很容易被当成普通配置来对待。但规则收紧时,已有的事实不会自动跟着变:
随后删掉这条规则:新的挂接被拒绝,但已经存在、按当前规则不合法的关系有 1 条(TEAM-Y → DIV-R):改知识层是一次数据迁移删掉规则只挡住了以后的挂接,TEAM-Y 仍然直接挂在事业部下。依赖「所有关系都符合规则」的代码(比如按层级汇总预算)现在会遇到一条它没预料到的数据。
所以知识层的修改要像迁移一样处理:修改前查出会变得不合法的实例,决定是先调整实例、还是让旧关系按旧规则继续有效(这时规则需要生效日期);修改有审批和记录,谁在什么时候改了什么规则。把规则从代码移到数据,省掉的是发版,不是评审。
这也是两层模型的适用条件:规则确实会由业务方频繁调整。如果组织层级多年不变,三个类加一个 parent 字段更直观,编译器还能帮忙检查。为了「将来可能会变」提前做成知识层,换来的是每一处读取都要先查规则,以及上面这种只有运行时才能发现的不一致。
三、同一个组织在几条线上
TEAM-X 在职能线上挂到 DEPT-B:成功;TEAM-X 的上级:FUNCTION=DEPT-B, LINE=DEPT-A「上级」只在一条结构线里有意义。没有结构线这个概念时,一个组织只能有一个 parent,第二条线只能用另一个字段(functional_parent)或者另一张表表达,每加一条线就改一次结构。把结构线作为知识层的一部分,关系就带上了它所在的线;问「谁是 TEAM-X 的上级」时,必须说清是哪条线。
结构线也决定了责任的方向:行政线上的上级负责考勤和编制,职能线上的上级负责技术规范。把「负责什么」也放到知识层(每条线上的上级承担哪些责任),就是分析模式里「责任关系」的思路。是否要走到这一步,同样取决于需求:只有两条固定的线时,一个枚举就够了。
四、关系有有效期
组织调整是常态,而且经常要回答历史问题:某个季度的成本该算到哪个部门。所以关系不是覆盖式地改 parent,而是带有效期:
不结束旧关系,直接新增 TEAM-X → DEPT-C(7 月 1 日起):被拒绝(conflicting key value violates exclusion constraint "org_link_child_structure_valid_excl")
先把旧关系截止到 7 月 1 日再新增:成功
TEAM-X 在行政线上的上级:6 月 15 日 DEPT-A,8 月 1 日 DEPT-C「同一条线上,一个组织在任一天最多有一个上级」是一个区间不重叠的约束,用排他约束交给数据库,原理和并发下的表现见「同一时段不能重叠」守在哪里。调动在模型里是一个动作 transfer(team, newParent, effectiveFrom),它在一个事务里截止旧关系、建立新关系,而不是让调用方分两步操作。
有效期也让「当前」变成了一个参数:查询上级、汇总下属、检查无环,都要指定日期。
五、无环:一个跨越所有事实的约束
部门可以挂在部门下以后,就可能出现环:D1 挂在 D2 下,D2 又挂在 D1 下。汇总下属、计算审批链的代码遇到环会无限循环。
挂接前检查「新上级是不是自己的下级」,顺序执行时有效:
顺序执行:D1 挂到 D2 下 成功;D2 再挂到 D1 下 被拒绝(会形成环:D1 已经在 D2 之下)两个管理员同时调整时:
两个事务并发加 D1 → D2 与 D2 → D1,各自先查再插(READ COMMITTED):提交成功 2 个;行政线上处在环里的组织 2 个
同样的两个事务用 SERIALIZABLE:提交成功 1 个,另一个失败(could not serialize access due to read/write dependencies among transactions);处在环里的组织 0 个
检查前先 SELECT … FOR UPDATE 锁住结构线 LINE(READ COMMITTED):提交成功 1 个,另一个失败(会形成环);处在环里的组织 0 个每个事务单独看都没有形成环,两个合起来形成了。这和区间重叠是同一类问题:约束跨越多行,每个事务只看到了自己开始时的状态。区别在于,无环没有对应的声明式约束可用,只能靠事务的隔离或锁。
两种可行的做法:
- SERIALIZABLE:PostgreSQL 的可串行化快照隔离检测到两个事务互相读到了对方要写的范围,让其中一个失败。代码不需要额外的锁,但失败的事务要重试,而且所有参与的事务都要用这个隔离级别。
- 锁住约束的范围:无环是一条结构线上的约束,所以挂接前锁住这条结构线(知识层里的那一行),同一条线上的修改就排队执行。这和聚合的思路一致:约束的范围就是加锁的单位。代价是同一条线上的调整全部串行,对组织调整这种低频操作通常可以接受。
三种做法放在一起比较:
按有效期讨论无环会更复杂:两条关系只有在有效期相交时才可能构成环。实验里的关系都从同一天开始、没有结束日期,没有覆盖这种情况;真实实现里,环检查要在新关系的有效期范围内进行。
六、测试要证明什么
- 知识层:允许的挂接成功、不允许的被拒绝;加一条规则后原来被拒绝的挂接成功。
- 规则迁移:收紧规则前,能查出将会不合法的存量实例。
- 有效期:调动前后各取一天查询上级;不截止旧关系就新增被拒绝。
- 无环:顺序的环被拒绝;并发的环用闩锁让两个事务都在检查之后、插入之前交错,断言最终没有环。只有顺序测试时,这条约束看起来是成立的。
七、检查清单
- 先把需求分成规则和事实;规则确实会被业务方调整时,才把它放进知识层。
- 知识层的修改按迁移处理:先查出受影响的存量实例,有审批和记录。
- 「上级」总是相对于一条结构线。
- 关系带有效期,调动是一个截止旧关系、建立新关系的动作;「当前」是查询参数。
- 同一线上上级不重叠交给排他约束;无环靠 SERIALIZABLE 加重试,或锁住结构线。
- 不要为了可能的变化提前建通用的参与方和责任模型,先用当前需求检验它是否必要。
配套实验
- codesphere-labs/ddd/organization-structure:PostgreSQL 18.6、JDK 25.0.4.1:按知识层校验挂接、只改数据新增规则、收紧规则后的存量、两条结构线、有效期与按日期查询、并发加边在 READ COMMITTED、SERIALIZABLE 和锁住结构线下的结果(验证记录)
参考资料
- Martin Fowler,《Analysis Patterns: Reusable Object Models》,Accountability 一章(组织层级、责任关系与知识层)
- PostgreSQL 18 文档:Serializable Isolation Level
- PostgreSQL 18 文档:WITH Queries(递归查询)