Skip to content

组织结构怎样建模:知识层、有效期与无环 ​

组织架构第一版是一棵树:公司下面是事业部,事业部下面是部门,部门下面是团队,每一层一个类,parent 字段指向上级。半年后,团队要同时属于一个行政部门和一个职能部门;部门开始嵌套部门;HR 要查「这个团队 3 月份归谁管」。每个需求都意味着改代码和表结构。

组织结构是一个典型的「看起来简单、变化方向很多」的模型。层级的种类会变,同一个组织可能同时处在几条线上,关系会随时间调整,还要保证不出现环。把这些全部写进类层次,每次组织调整都变成一次发版。

分析模式里有一个处理这类问题的思路:把模型分成两层。知识层描述「什么样的关系可以成立」,实例层记录「实际存在哪些关系」。本文用一个组织结构的例子把这两层落到 PostgreSQL 18 上,并验证有效期和无环约束在并发下的表现。实验见文末。

知识层:允许的上下级类型(结构线)(DIVISION, COMPANY, LINE) (DEPARTMENT, DIVISION, LINE) (DEPARTMENT, DEPARTMENT, LINE)(TEAM, DEPARTMENT, LINE) (TEAM, DEPARTMENT, FUNCTION)  加一行即新增规则;删一行后存量 1 条不合法挂接前校验实例层:组织与带有效期的关系HQDIV-RDEPT-ADEPT-CTEAM-XDEPT-B至 7/17/1 起职能线同一条线上上级的有效期不重叠:排他约束两个事务并发加 D1 → D2、D2 → D1READ COMMITTED,先查再插两个都提交:D1、D2 处在环里SERIALIZABLE1 个成功,1 个序列化失败(需重试)先 FOR UPDATE 锁住结构线1 个成功,后到的检查出会成环顺序执行时,检查总是有效
图 1 · 实测(PostgreSQL 18.6):知识层是数据,加一行就能新增一种挂法,删一行后存量里留下 1 条按新规则不合法的关系。实例层的关系属于某条结构线,并带有效期:TEAM-X 在行政线上 7 月 1 日从 DEPT-A 调到 DEPT-C,同时在职能线上挂在 DEPT-B 下。两个管理员并发加 D1 → D2 与 D2 → D1,各自检查都通过,READ COMMITTED 下形成环。

一、从需求里分出两类知识 ​

把需求按「它描述的是规则还是事实」分开:

需求类别
团队只能挂在部门下;部门可以挂在事业部或另一个部门下规则:哪类组织可以挂在哪类组织下
有行政线和职能线两套结构规则:有哪些结构线
研发一组现在属于平台部事实:一条具体的上下级关系
研发一组 7 月 1 日起调到基础设施部事实,并且带时间
任何一条结构线上都不能出现环跨越所有事实的约束

第一版把规则写成了代码:Team 类的 parent 字段类型是 Department,所以「团队只能挂在部门下」由编译器保证。代价是规则一变就要改类型。两层模型把规则也变成数据:

sql
-- 知识层
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 是实例层的对象。挂接一个组织时,实例层去问知识层「这样挂合法吗」:

text
TEAM-X 挂在 DEPT-A 下(行政线):成功
DEPT-B 挂在 TEAM-X 下(行政线):被拒绝(知识层不允许 DEPT-B 挂在 TEAM-X 下(LINE))
TEAM-Y 直接挂在 DIV-R 下:被拒绝
在知识层加一行 (TEAM, DIVISION, LINE),不改代码,再挂一次:成功

新的组织形式只需要在知识层加一行数据。

二、改知识层不是改配置,是一次迁移 ​

规则变成数据之后,修改它看起来只是改一行记录,于是很容易被当成普通配置来对待。但规则收紧时,已有的事实不会自动跟着变:

text
随后删掉这条规则:新的挂接被拒绝,但已经存在、按当前规则不合法的关系有 1 条(TEAM-Y → DIV-R):改知识层是一次数据迁移

删掉规则只挡住了以后的挂接,TEAM-Y 仍然直接挂在事业部下。依赖「所有关系都符合规则」的代码(比如按层级汇总预算)现在会遇到一条它没预料到的数据。

所以知识层的修改要像迁移一样处理:修改前查出会变得不合法的实例,决定是先调整实例、还是让旧关系按旧规则继续有效(这时规则需要生效日期);修改有审批和记录,谁在什么时候改了什么规则。把规则从代码移到数据,省掉的是发版,不是评审。

这也是两层模型的适用条件:规则确实会由业务方频繁调整。如果组织层级多年不变,三个类加一个 parent 字段更直观,编译器还能帮忙检查。为了「将来可能会变」提前做成知识层,换来的是每一处读取都要先查规则,以及上面这种只有运行时才能发现的不一致。

三、同一个组织在几条线上 ​

text
TEAM-X 在职能线上挂到 DEPT-B:成功;TEAM-X 的上级:FUNCTION=DEPT-B, LINE=DEPT-A

「上级」只在一条结构线里有意义。没有结构线这个概念时,一个组织只能有一个 parent,第二条线只能用另一个字段(functional_parent)或者另一张表表达,每加一条线就改一次结构。把结构线作为知识层的一部分,关系就带上了它所在的线;问「谁是 TEAM-X 的上级」时,必须说清是哪条线。

结构线也决定了责任的方向:行政线上的上级负责考勤和编制,职能线上的上级负责技术规范。把「负责什么」也放到知识层(每条线上的上级承担哪些责任),就是分析模式里「责任关系」的思路。是否要走到这一步,同样取决于需求:只有两条固定的线时,一个枚举就够了。

四、关系有有效期 ​

组织调整是常态,而且经常要回答历史问题:某个季度的成本该算到哪个部门。所以关系不是覆盖式地改 parent,而是带有效期:

text
不结束旧关系,直接新增 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 下。汇总下属、计算审批链的代码遇到环会无限循环。

挂接前检查「新上级是不是自己的下级」,顺序执行时有效:

text
顺序执行:D1 挂到 D2 下 成功;D2 再挂到 D1 下 被拒绝(会形成环:D1 已经在 D2 之下)

两个管理员同时调整时:

text
两个事务并发加 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 的可串行化快照隔离检测到两个事务互相读到了对方要写的范围,让其中一个失败。代码不需要额外的锁,但失败的事务要重试,而且所有参与的事务都要用这个隔离级别。
  • 锁住约束的范围:无环是一条结构线上的约束,所以挂接前锁住这条结构线(知识层里的那一行),同一条线上的修改就排队执行。这和聚合的思路一致:约束的范围就是加锁的单位。代价是同一条线上的调整全部串行,对组织调整这种低频操作通常可以接受。

三种做法放在一起比较:

并发加边D1D2事务 A:D1 → D2事务 B:D2 → D1每个事务开始时都只看到自己那条边处理方式结果READ COMMITTED各自先查再插提交 2 个2 个组织处在环里SERIALIZABLE代码不加锁提交 1 个另一个序列化失败,需重试锁住结构线 LINESELECT … FOR UPDATE,再检查提交 1 个另一个检查时发现成环无环没有声明式约束可用:要么所有参与的事务都用 SERIALIZABLE,要么约束的范围(结构线)就是加锁的单位
图 2 · 实测(PostgreSQL 18.6):两个管理员同时加 D1 → D2 与 D2 → D1。READ COMMITTED 下各自先查再插,两条边都提交,行政线上 2 个组织处在环里;SERIALIZABLE 让其中一个以序列化失败退出,需要重试;挂接前锁住结构线这一行,后到的事务排队,检查时看到了对方的边

按有效期讨论无环会更复杂:两条关系只有在有效期相交时才可能构成环。实验里的关系都从同一天开始、没有结束日期,没有覆盖这种情况;真实实现里,环检查要在新关系的有效期范围内进行。

六、测试要证明什么 ​

  • 知识层:允许的挂接成功、不允许的被拒绝;加一条规则后原来被拒绝的挂接成功。
  • 规则迁移:收紧规则前,能查出将会不合法的存量实例。
  • 有效期:调动前后各取一天查询上级;不截止旧关系就新增被拒绝。
  • 无环:顺序的环被拒绝;并发的环用闩锁让两个事务都在检查之后、插入之前交错,断言最终没有环。只有顺序测试时,这条约束看起来是成立的。

七、检查清单 ​

  • 先把需求分成规则和事实;规则确实会被业务方调整时,才把它放进知识层。
  • 知识层的修改按迁移处理:先查出受影响的存量实例,有审批和记录。
  • 「上级」总是相对于一条结构线。
  • 关系带有效期,调动是一个截止旧关系、建立新关系的动作;「当前」是查询参数。
  • 同一线上上级不重叠交给排他约束;无环靠 SERIALIZABLE 加重试,或锁住结构线。
  • 不要为了可能的变化提前建通用的参与方和责任模型,先用当前需求检验它是否必要。

配套实验

  • codesphere-labs/ddd/organization-structure:PostgreSQL 18.6、JDK 25.0.4.1:按知识层校验挂接、只改数据新增规则、收紧规则后的存量、两条结构线、有效期与按日期查询、并发加边在 READ COMMITTED、SERIALIZABLE 和锁住结构线下的结果(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权