编程 大用户表、外键和连锁重构:用 ROSD 把系统拆成资源实例

2026-09-16 00:04:25

大用户表、外键和连锁重构:用 ROSD 把系统拆成资源实例

作者:顾宇浩 · 原文:ROSD:以资源为中心的系统设计

引言

长期以来,基于关系型数据库的关系模型是业务数据建模的主流范式。需求快速迭代时,它的僵化会暴露出来:

  • 开发初期就要确定完整表结构,后续需求变更往往伴随复杂的库表重构;
  • 表间通过外键、关联查询形成强耦合,数据结构像“铁板一块”,单表或字段修改容易引发连锁式代码调整与逻辑风险。

针对这些问题,本文提出以资源为中心的系统设计(Resource Oriented System Design,ROSD):把系统状态抽象为各类资源实例的集合,通过赋予资源独立生命周期、采用弱引用关联等策略,从根源上处理耦合与迭代问题。

系统层级

系统层级可以这样看:

  • 业务系统
  • 语言运行时系统
  • 操作系统
  • 硬件设备系统

本文主要面向业务系统设计,但思想可以推广到各层。

将事物抽象为“资源”

ROSD 可以理解为 OOP 思想在宏观系统设计上的应用。类 ≈ 资源类型;对象(实例)≈ 资源实例。

系统的所有功能像对象封装方法一样按资源组织;整个系统状态 = 全部资源实例状态的集合;系统所有行为 = 各类资源上的操作原语。业务层代码把各种需求规约为资源操作原语,但系统的根本能力边界不变。

系统设计本质 = 定义一组资源类型和其上的操作原语;设计师的工作 = 把“基于功能的需求描述”转化成“基于资源的系统设计”。面向功能的设计往往越做越乱,功能零散、难抽象共性。

必须要强调:ROSD 是系统设计思想,与到底用什么数据库无关。ROSD 不是“必须用非关系型数据库”,也完全可以用 Postgres 承载一个 ROSD 业务系统,只是数据层代码写起来可能有所不便。

ROSD 与关系模型

以“用户”为例:A 部门只需要身份验证,B 部门需要地址、手机号、邮箱,未来还有 C、D 部门。

关系模型天然要求一开始就构建一个全局共用的大用户表,否则难以描述主键外键关联;所有人都要修改它,这张表最终变成几十上百个字段,任一部门需求变更都可能引发整表重构,高度耦合、极其僵化。关系模型要求系统一次性设计完整,无法容忍“先跑起来再逐步改良”的渐进式迭代。

ROSD 视角下,A 部门的用户与 B 部门的用户是两个完全独立的资源,它们之间可以存在某种引用关系(相同用户 ID 或手机号),但关联不强制、不要求统一表结构、不引入外键与级联约束。两个部门独立设计、独立迭代、独立部署。业务成熟后再按需合并、抽象、统一,是渐进式优化,不破坏原系统。

ROSD 允许:

  • 不为未来不确定的设计提前买单;
  • 不为未实现的功能提前承担结构成本;
  • 按需增加资源、先实现局部;
  • 将系统解耦为各部,各自独立演进资源模型,最后自然收敛。

资源的操作原语

所有操作原语可统一描述为:

X:T.F(A) -> R

其中 X 是资源实例标识,T 是资源类型,F 是操作原语,A 是参数,R 是返回结果。与 OOP 相同,系统所有操作原语是一个有限集,设计时完全确定;T.F 构成操作原语标识符。

与 OOP 的关键区别:操作原语需要跨系统工作,系统间只能交换纯数据,所以 X、A、R 都必须是可序列化的数据,不能是 OOP 中那种“引用”。

操作原语可以实现为系统内在状态,也可以封装外部系统功能,对操作者透明。操作者可以是人,也可以是外部系统。

CIDE 分类

  • C(Create)创建;
  • I(Interact)交互;
  • D(Delete)删除;
  • E(Enumerate)枚举。

信息系统会把 I 再分读写(Retrieve/Update),但信息系统只是 ROSD 的无外部副作用特例;一般情况下,看似只读的操作实现也可能涉及写,所以没必要拆分 I。

D 操作常见设计:

X:T.D() -> bool

删除资源实例 X,返回删除前是否存在。

X:T.D() -> ⊥

无返回,要么保证资源不存在时无操作,要么要求调用方保证存在(如 C 的 free);后者在业务场景通常不成立,因为不能对外部输入做任何假设。

X:T.D() -> t_X

删除并返回 X 的状态,只能在纯信息系统采用。一般系统中 X 的状态与实现有关,离开上下文无意义。

C 操作常见设计:

T.C(A) -> X

用参数 A 创建,返回新实例标识 X,操作者事先不知 X 的值,称为“后验标识符”。

X:T.C(A) -> bool

直接指定新实例标识 X;X 可能已存在,因此往往必须返回创建前是否存在,称为“先验标识符”。

X':T'.F(A) -> X

资源实例 X 被另一资源实例 X' 上的操作原语 F 间接创建出来,真实场景常见。

E 操作形如:

T.E() -> {X ...}

一般只遍历资源 ID 而非完整资源对象(完整状态可能很大很复杂)。E 通常不能保证完全性——系统只允许你遍历,但不承诺不多不少看到所有资源,需按实际情况判断(如停机维护时可认为完全)。

声明式操作

声明式操作直接“位置式”指定期望的最终状态,而非“增量式”指定状态转换过程。好处是简洁、直接、全面;坏处是每次刷新完整状态,大部分域不变时有性能问题。

因此从原理上决定了,声明式接口只能作为命令式操作的补充,不能完全平替。良设计的系统应同时提供两种接口:一般情况上下游用命令式高效交互,低频问题情况下用声明式同步状态。

自发过程的资源化

操作原语只允许资源被动响应外界操作,但系统自发主动做事是普遍基础需求。目标是把瞬时的过程(函数、处理流程)变成长期存在、可被标识管理的资源。最典型场景:定时任务与异步作业。

实现需要在系统中设置任务队列模块,一般整个系统只需一个全局任务队列与定时器作为统一驱动源。过程资源本质 = 一个任务函数 + 与其绑定的调用参数,天然具备 CID 语义:

  • 创建:不是直接执行过程,而是向任务队列提交一条任务;
  • 交互:修改执行时间、调整参数、变更执行策略、重新调度;
  • 删除:取消任务,从队列移除。

过程资源的操作粒度

核心约束:正在运行中的过程,往往不可被中止。

一方面,多数语言不提供“在函数执行中途强行结束”的机制;另一方面,即便载体允许(如独立 OS 进程),我们也无法预知进程运行到哪一步,中止一定引入未定义行为,且常导致关联资源状态不一致。

所以设计时必须考虑过程资源的操作粒度:所有过程都必须预先考虑好能在哪些点上被操作。对一次性短时过程,粒度可能就是“开始前”和“结束后”,可设计“取消”“回滚”,但不能设计“中止”。

对可能无限循环的过程,过程中设计中止点极为必要,否则过程永远运行下去也是一种资源泄露。中止点将过程拆为子过程组成的工作流,必须保证在任何可能的运行路径上,单个子过程的运行都要能在有限时间内结束【过程有限原则】。不这么定,任务无法回收,队列和资源状态都无法收敛。

反例(不可安全终止/修改/调度):

def task():
    while True:
        sleep(1)
        check()

正确写法(异步递归式,由任务队列完成循环闭环):

def task():
    check()
    task_queue.submit(task, now() + 1)

原子性和一致性保证

系统整体状态 = 所有资源实例状态的集合。设计时必须考虑多用户并发时对外体现什么行为,即对操作原子性与状态一致性的承诺。

全局原子性对底层/微观系统可行(全局锁串行化所有外部操作),但牺牲并发能力,无法支撑宏观业务系统。

ROSD 给出实用、普适、清晰的粒度层级:只保证单个资源实例上的操作原子性和状态一致性【资源原子化原则】。

  • 任一时刻,一个资源实例上只允许一个操作;
  • 同一资源实例上的所有操作原语互斥执行、顺序一致;
  • 同一实例上的并发操作可排队等待或拒绝服务,由需求定;
  • 不同资源实例之间的操作是并发的,没有原子性和一致性保证。

思路接近 OOP 的管程(Monitor),只是提升到系统设计层面,而非语言同步机制。

“单个资源实例”粒度是否过粗?回答是否:资源概念本身可大可小、可粗可细。若发现某个大资源内部多个操作需要并行化,往往不是规则问题,而是资源划分粒度过大的信号。正确做法是把大资源合理拆分为多个更小、相互关联的细粒度资源【粒度拆分原则】。不同资源之间不做跨实例原子性保证。

不采用全局原子性,是因为它牺牲并发,撑不起宏观业务系统;不做粒度拆分,则会把资源划分问题误判成规则问题,最后仍然卡在单个大资源上。

先验/后验标识符

外界所有操作都必须通过标识符 X 定位目标资源。标识符不仅暴露给外部,也通常是系统内部资源间相互引用的方式(如项目资源里存用户标识符)。绝大多数情况下,另设一套系统内标识符机制以隐藏内部实现是没必要的;按奥卡姆剃刀,系统内直接复用系统外标识符最简洁一致。

垂悬引用问题

每个资源实例的 CIDE 都是独立的,因此系统必然存在:某资源实例被删除后,引用它的资源中残留指向已不存在实例的标识符。

垂悬引用不仅是缺少跨实例原子性保证所致,更是固有设计约束:资源类型间的依赖天然是单向的【单向依赖原则】。新功能带来的新资源可依赖旧资源,但旧资源设计之初无法预知未来被谁引用。新资源将来也会变旧,因此只能默认假定所有资源实例都不知道自己被谁引用。这种单向依赖正是 ROSD 支持渐进式扩展的关键,是优势而非缺陷。不这么定,旧资源就要预知未来所有依赖,系统重新回到一次性完整设计。

设计双向引用追踪系统(删除时级联删除或置空)有两个问题:实现困难且运行时计算存储开销巨大;更致命的是,从设计上删除一个资源会波及其它资源的修改,破坏每个资源实例的操作独立性,在原子性保证下极易引发死锁与修改回退。所以一般不是好方案。

ROSD 推荐:系统必须对无效标识符具备鲁棒性【无效鲁棒原则】。任何操作原语执行时,都必须主动检查所有用到的标识符是否有效,并妥善处理失效。这也意味着系统内所有资源间引用本质上都是弱引用,即使双方都在系统内。而且这种检查必然可实现:系统要检查所有外部操作,其中一定会检查标识符有效性。

普遍弱引用还带来重要性质:由于不存在强引用,任何资源实例都可被独立、强制地创建与删除,不受其他资源牵连阻塞【独立管控原则】,保证系统在资源管理层面的确定可回收性——删除一个资源就应真正释放其物理资源,而不是因隐藏引用导致无法彻底清理、形成隐性残留与泄漏。不检查无效标识符,垂悬引用会造成操作崩溃或错误;不允许独立管控,删除会被隐藏引用阻塞,资源无法真正回收。

反例对比:传统文件系统的硬链接构成强引用,用户删除后文件实体仍可能不被释放。这在 OS 层面之所以可行,是因为该层资源大多不具备物理特殊性(文件只关联存储空间,磁盘扇区使用上无差别)。但在宏观业务系统层面,每个资源实例往往关联独一的物理对象(一个人、一个组织、一个公司),“想删删不掉”就不只是泄露计算资源,可能引发严重现实后果。

先验标识符的错误引用问题

若允许外界指定标识符,垂悬引用会进一步招致错误引用:外部系统创建同名新资源时,新资源可能错误继承旧资源的垂悬引用。例如旧用户被删除但关联项目未删除,新创建的同名用户一进系统就直接看到旧用户的项目,相当于无需授权获得旧用户所有数据。

一种常见避免机制是假删除:不真正移除记录,只把 is_deleted 置 true,所有操作原语同步改为仅在 is_deleted 为 false 时执行。但假删除本质是禁用机制而非真正删除。删除操作的核心语义之一是释放标识符使其可被重新创建,这要求对外隐藏“标识符曾被使用”的事实,假删除无法实现,这样的系统一般不符合数据合规要求。若想阻止外界探测标识符是否被使用,就必须再设计额外机制允许外部可见标识符复用(加后缀、时间戳等),但这实际上意味着系统内标识符已不再是先验的。

先验标识符还有信息泄露风险:外界枚举常见标识符即可获取系统内资源名录。因此一般来说不建议采用先验标识符设计。典型例子是邮箱地址:账号由用户指定属先验标识符,只要知道对方命名风格就能通过尝试注册试探是否已存在账号;当今邮箱系统饱受垃圾邮件困扰与该设计缺陷不无关联。在更多私有属性的业务系统中,资源是否存在本身就是敏感信息。

先验标识符的问题是原理层面的,靠假删除、后缀、时间戳不能技术规避——这些做法要么违背删除语义,要么已经把系统内标识符变成非先验。

后验标识符的幂等操作问题

采用系统自主生成的后验标识符,外界事前无法指定也无法预知。系统可通过保证标识符生成的时空唯一性,完全解决垂悬引用和错误引用问题。

但后验标识符天然导致创建操作无法幂等:每次创建都生成全新实例。在不稳定通信环境下可能引发资源泄露:创建已在系统内成功,但新标识符因网络错误未送达外界,外界不知已创建,也没有简单方式判断是否已存在,自然选择重试,于是系统内多出一个外界不需要也不知道的冗余资源,长期滞留成为系统垃圾。

此问题不致命但持续浪费资源,因此提出:系统中的所有资源都必须具备完全可枚举能力【完全可枚举原则】。系统必须提供某种方式遍历当前存在的所有资源。不要求某一时刻拿到绝对完整、强一致的快照(并发系统中既难实现也无太大意义),只要求以最终一致的方式遍历检索到所有存在的资源实例即可。这可保证创建重试导致的泄漏可发现、可治理:通过定期巡检、垃圾回收脚本或人工核查识别无主闲置资源并销毁释放。

不能完全枚举,创建重试产生的冗余资源就发现不了,也就无法清理。

通用方案:后验标识符 + 唯一辅键

核心思路:主标识符仍为后验,由系统内部生成,外界不可指定、无法预测;增设一个具备唯一性约束的“辅键”字段,用于提供幂等性。辅键可理解为资源的“名称”或“外部别名”,但不具备主标识符那样的不可变性,只是资源上带索引的普通可写字段。推荐用分布式唯一 ID 算法(如 UUID4)生成主标识,保证时空唯一且完全随机,外部难以猜测预知。

该设计同时解决多个问题:

  • 创建操作可实现幂等:外部传入相同辅键,系统判断是否已存在对应资源,避免重复创建,应对网络超时重试;
  • 资源支持别名和重命名:纯先验标识符名称一旦确定难以改动,纯后验标识符外界难以记忆识别,用作名称的辅键可像其他字段一样自由修改;
  • 主标识天然具备隐私安全性:完全随机不可预测,外部无法枚举猜测探知系统内存在哪些资源,在权限管理之外起补充防护。

由于辅键带唯一性约束,通过同名创建探测资源存在的漏洞仍可能存在,可进一步修补:

  • 时间有效期:只在创建操作一段时间内保证辅键唯一性以支持幂等重试;超过有效期不再强制全局唯一,允许重复使用。
  • 资源命名空间:对间接创建的资源,给标识符添加上级资源前缀(如用户 ID)形成独立命名空间,命名空间内辅键唯一性得到保障,不同命名空间互不可见。
  • 随机化辅键:极端看重隐私安全的场景,外界可使用随机字符串作为辅键;此时辅键无法再充当资源名称,系统必须再添加额外字段作为资源别名。

应当根据具体情况的安全性、可用性等要求选择。

推荐文章

程序员茄子在线接单