ROSD:以资源为中心的系统设计
引言
长期以来,基于关系型数据库的关系模型始终是业务数据建模的主流范式,主导了数十年间各类业务系统的设计与实现。然而在工程实践中,其固有设计约束在需求快速迭代的现代业务场景下日益凸显僵化短板:一方面,关系模型要求开发初期即确定完整的表结构,后续业务需求的变更往往需要伴随复杂的库表重构,开发与维护成本高昂;另一方面,表间通过外键关联、关联查询形成强耦合依赖,数据结构呈现“铁板一块”的特性,单一表结构或字段的修改极易引发连锁式代码调整与逻辑风险。这些问题导致关系模型在应对需求持续演化、快速迭代的现代系统时,灵活性与扩展性已难以满足工程实践需求。
针对上述困境,本文提出以资源为中心的系统设计(Resource Oriented System Design,ROSD)。ROSD 作为一套系统设计哲学与方法论,核心目标是为弱耦合、高灵活、可扩展的系统设计提供统一的架构指导,其将系统状态抽象为各类资源实例的集合,通过赋予资源独立生命周期、采用弱引用关联等设计策略,从根源上破解传统关系模型带来的耦合难题与迭代困境。本文结合实践经验,系统介绍ROSD的核心思想与设计原则,总结常见设计抉择与工程落地要点。
按照从宏观到微观、从上层到下层的顺序,本文简单地将计算机系统分如下层级:(应用)业务系统 -> 语言运行时系统 -> 操作系统 -> 硬件设备系统。本文讨论主要面向业务系统的设计,但因为各种系统都要在各个层次上管理资源,其思想理应推广至各层面。
将事物抽象为“资源”
ROSD 与 OOP
ROSD 可以理解为面向对象编程(OOP)思想在宏观系统设计上的应用。OOP 已经在广泛的软件工程实践中被证明可以有效应对代码复杂度:对象灵活、可大可小、易于扩展,非常适合对现实问题进行建模。OOP 能够实现高内聚、低耦合的设计目标:在新增功能时只需要新增一组类和对象,不会破坏原有结构,这是其强大的地方。ROSD 和 OOP 的关系,可以理解为是一种拔高和泛化:
- OOP 中的 类 ≈ ROSD 中的 资源类型
- OOP 中的 对象(实例) ≈ ROSD 中的 资源实例
因此,系统的设计思路也随之统一:把系统内的所有功能,就像对象封装方法一样,按照资源进行组织。整个系统的状态,就是全部资源实例状态的集合;系统的所有行为,就是各类资源上的操作原语。然后,我们再在此之上再构建复杂的业务层代码,将各种千奇百怪的需求规约为资源的操作原语。但是,无论这种业务代码有多复杂,系统的根本能力边界却并不会变化。
所以,这时的系统设计,其本质上就是:定义一组资源类型和其上的操作原语。而这时的系统设计师的工作,也就变成了:将“基于功能等的需求描述”,转化成“基于资源的系统设计”。面向功能的设计往往越做越乱,功能零散、缺乏结构、难以抽象共性,最终项目和系统都变成一团浆糊。而面向资源的设计,则先梳理出系统中有哪些资源,再把功能挂载到对应资源上。最终,系统实现了真正的模块化与解耦,这就是 ROSD 最核心的设计思想。
“以资源为中心”为设计提供了一套稳定的思考框架与准则。面对杂乱繁多、表述各异的需求时,我们不会再无从下手、被零散功能牵着思路走,而是有普适的规范可以遵循。遵照这套思路进行设计,一方面能贴近实际编码与实现落地,为具体开发提供清晰指引;另一方面更重要的是,它能让系统具备良好的扩展性,从容应对后续的功能变更。同时,这套统一的设计理念也为团队协作、多模块协同与不同设计者之间的沟通,奠定了共同的概念基石与讨论框架,大家基于同一套资源设计逻辑开展工作,各自负责的模块能够自然兼容、平稳共存,避免因设计思路不一致带来耦合冲突。
这种转变带来的影响是深远的:项目代码结构变得清晰,如同 OOP 一样具备高内聚、低耦合的特性;运行时系统的结构也天然解耦,新增功能只需新增资源,不必牵扯旧业务的修改。相比之下,关系模型就显得僵硬:它要你去思考系统中到底有哪些实体和关系,并且要使用主键、外键等消除数据冗余,最终将所有实体连结成一整块。这在需求未定、概念模糊的雏形期时,难以一步到位地给出整个数据模型的设计;而且由于模型的各部分紧紧耦合在一起,在之后的迭代中总是难免导致数据库以及代码库的大面积重构。
但这里必须强调一点:ROSD 是系统设计思想,与到底用什么数据库无关。正如不是关系型的数据建模和系统设计就一定得用关系型数据库一样,也不是说 ROSD 就一定不能用关系型数据库。ROSD 是一种系统设计层面的哲学和方法论,它指导的是系统本身的设计,而不是用什么数据库实现——你完全可以选用 Postgres 作为一个 ROSD 业务系统的数据库,只是在具体的数据层代码编写上可能有所不便而已。
ROSD 与关系模型
为了更直观地说明 ROSD 是如何在 OOP 基础上进行泛化与拔高,并与关系型模型形成鲜明对比,我们可以用一个非常典型的场景来举例——不同业务部门对“用户”实体的差异化需求。
在一个复杂系统中,“用户”往往不是一个单一、统一的概念,而是被多个业务模块或部门所共享。不同部门对它的需求完全不同:
- A 部门可能只需要用户做一个简单的身份验证;
- B 部门则需要用户地址、手机号、邮箱等更多联系信息;
- 未来还可能有 C 部门、D 部门……每一个部门对“用户”所需要的字段、逻辑、约束都可能独立变化。
关系型模型天然地要求我们必须一开始就构建一个全局共用的大用户表,否则难以描述主键、外键等表间的关联关系。由于所有人都要修改它,这张表后面会变成几十、上百个字段。而任何一个部门的需求变更,都可能引发对整张表的重构,导致整体设计高度耦合、极其僵化。总的来说,关系模型要求系统一次性设计完整,无法容忍“先让系统跑起来、再逐步改良”的渐进式迭代,也无法满足业务在快速演化环境下的自然发展节奏。
而在 ROSD 的视角下,这种情况的处理方式完全不同。我们不会强迫所有业务部门共享同一个“用户资源”,而是认为:A 部门的用户与 B 部门的用户,是两个完全独立的资源。它们之间可以存在某种引用关系,比如相同的用户 ID 或手机号,但这种关联不是强制的,也不要求统一表结构,也不会引入外键、级联等强耦合约束。两个部门可以完全独立地设计自己的“用户资源”,独立迭代、独立开发、独立部署,互不干扰。
从系统层面看,它们就像两套独立的系统,最后只通过一种弱引用方式相互识别,整个结构不仅稳定,而且高度可扩展。当业务成熟之后,我们再按需对这些资源进行合并、抽象、统一,也完全是渐进式的优化工作,不会破坏原有系统。
总的来说,ROSD 允许我们:
- 不为未来不确定的设计提前买单
- 不为未实现的功能提前承担结构成本
- 按需要增加资源,先实现局部,而不是一次性构建完整系统
- 将系统解耦为各部,每部可以独立演进自己的资源模型,最后自然收敛
这种设计方式让系统的扩展性、稳定性与协作效率大幅提升,也让整个系统能够以一种柔性、渐进的方式持续演进。
资源的操作原语
ROSD 中的操作原语可以类比于 OOP 中的对象方法,所有操作原语都可以套用到如下统一形式描述:
\[X:T.F(A) \rightarrow R\]其中 $X$ 是资源实例的标识,$T$ 是资源类型,$F$ 是操作原语,$A$ 是传入参数,$R$ 是返回结果。
与 OOP 相同,系统所有的操作原语是一个有限集,在设计时完全确定下来。$T.F$ 就构成操作原语的标识符,唯一表达集合中的元素。
与 OOP 的关键区别在于:如上图所示,由于操作原语需要跨系统间工作,不同系统间只能交换纯数据,所以 $X$、$A$、$R$ 也都必须是可序列化的数据,而不能像 OOP 中那样可以是某种“引用”。
操作原语可以实现为系统的内在状态,也可以是对其它外部系统的功能封装,这都对操作者透明。操作者可以是人,也可以是其它外部系统,对系统而言没有分别。可以说,这种结构足以建模几乎所有的现实中场景。
CID 分类
我们将操作原语分为三种:
- C(Create):创建操作
- I(Interact):交互操作
- D(Delete):删除操作
信息系统中还会将 I 操作再分读写(Retrieve/Update),但信息系统只是 ROSD 的一个无外部副作用的特例。一般情况下,因为操作原语可能是对外部系统的封装,即使是看上去只读的操作,其实现也可能涉及到写,所以没必要再拆分 I 操作。
对于 D 操作,常见以下几种设计:
$X:T.D() \rightarrow bool$
删除资源实例 $X$,返回操作是否成功,也即 $X$ 在删除前是否存在。
$X:T.D() \rightarrow \bot$
删除资源实例 $X$,没有返回结果。这种系统要么保证在资源不存在时无操作,要么要求操作者保证资源存在(例如 C 语言的
free函数)。后者通常在业务场景中不成立,因为此时不能对外部输入做任何假设,必须得完全校验所有数据。$X:T.D() \rightarrow t_X$
删除资源实例 $X$,同时把 $X$ 的状态返回。这种设计只能在纯信息系统中采用,在一般的系统中,$X$ 的状态信息通常是与系统的实现有关的,在离开系统内的上下文后就无意义了,因此不能返回给系统外部。
对于 C 操作,常见以下几种设计:
$T.C(A) \rightarrow X$
以参数 $A$ 创建一个资源实例,返回新实例的标识 $X$。这时在 C 操作执行前,操作者并不知道 $X$ 的实际值,我们称这种 $X$ 为“后验标识符”。
$X:T.C(A) \rightarrow bool$
创建资源实例,但不同之处在于直接指定了新实例的标识 $X$。由于 $X$ 可能已存在,往往必须返回操作是否成功,也即 $X$ 在创建前是否存在。这种 $X$ 就称为“先验标识符”。
$X’:T’.F(A) \rightarrow X$
资源实例 $X$ 被一个另外的资源实例 $X’$(可能是不同类型 $T’$)上的操作原语 $F$ 间接创建了出来。这在真实场景中很常见,反映了业务逻辑的复杂结构。
系统中的其它绝大多数操作原语都是 I 操作,它们根据业务需求设计,也因此没有什么共性好讨论的。
自发过程的资源化
通过操作原语只允许资源被动地响应外界的操作,真实场景中,系统能够自发地主动做一些事情,是一个极为普遍、非常基础的需求。因此本节我们重点讨论:如何将一个自发过程,抽象为资源。
这里所说的“过程”,是指一段独立的执行逻辑,比如一个函数、一段处理流程。过程本身通常是瞬时的、暂态的,例如函数在调用时启动,在运行结束后,结果返回,栈帧就被自动释放销毁了。而另一方面,资源则是长期存在、可被标识、可被管理的实体。我们的目标,就是把这种一次性、暂态的过程,变成可以被系统管理、拥有生命周期的资源。
最典型、最具代表性的场景就是定时任务与异步作业。定时任务是那些要求在指定时间点执行,或者周期性运行的过程;异步作业是那些在操作原语完成后,仍然在系统内(后台)继续运行的过程。两者本质上都是一种自发运行的工作流,都需要将一段逻辑与某个资源关联起来,甚至让这段逻辑本身成为一种资源。
要实现这种自发过程的资源化,系统中必须设置一个的任务队列模块。一般来说,整个系统只需要一个全局的任务队列与定时器,作为统一的驱动源。借助这一基础设施,我们就可以把原本需要自发运行的逻辑,改造为可管理的资源。此时,所谓“过程资源”,本质上就是:一个任务函数 + 与其绑定的调用参数。
这样一来,它就天然具备了 CID 语义:
- 创建(Create):并不是直接执行过程,而是向任务队列中提交一条任务;
- 交互(Interact):修改任务的执行时间、调整参数、变更执行策略、重新调度等;
- 删除(Delete):取消任务,将其从队列中移除,终止其后续执行。
过程资源的操作粒度
自发过程,是资源主动发起、自主运行的行为;而操作原语,是外界对资源发起的被动操作。二者存在内在矛盾,协调二者的关系,是系统设计中不可回避的关键问题。这里我们需要明确一条核心设计约束与原则:正在运行中的过程,往往是不可被中止的。
这种不可终止性,一方面,从编程语言层面来看,多数语言并不提供“在函数或代码片段执行中途强行结束”的机制,一段逻辑一旦启动,便会按照既定流程执行,无法被外部操作随意中止;另一方面,即便底层的过程载体允许中止,例如独立的 OS 进程,这种中止也往往不符合系统设计的预期——我们无法预知被中止的进程到底运行到哪一步了,因此这种中止操作一定会给系统引入未定义行为,而且往往会导致与该过程关联的各类资源陷入状态不一致的困境,进而引发整个系统的异常。
由此可见,“正在运行中的过程不可中止”,不只是单纯源于实现的限制,更是过程的固有属性。所以我们在做系统设计的时候,必须一并考虑过程资源的操作粒度:所有过程都必须预先考虑好能在哪些点上被操作。对于一次性的短时过程,这种粒度可能就是“开始前”和“结束后”,这时可以设计“取消”、“回滚”这种操作原语,但不能设计“中止”这种操作原语,因为过程中并没有中止点。
对于一个可能无限循环的过程,在过程中设计中止点就是极为有必要的了,否则过程就会永远运行下去,这也是一种资源泄露。中止点将过程拆分为子过程组成的工作流,必须保证在任何可能的运行路径上,单个子过程的运行都要能在有限的时间内结束【过程有限原则】,否则过程资源就有陷入不可操作困境的风险,这是系统设计的严重漏洞。
作为一个具体的例子,假设我们需要系统周期性地循环运行一个 check 任务,不能像下面这样写代码:
1
2
3
4
def task():
while True:
sleep(1)
check()
这样的 task 在执行期间将无法被安全地终止、修改或调度,必须要使用异步递归式的结构:
1
2
3
def task():
check()
task_queue.submit(task, now() + 1)
这样,task 本身不包含循环,而是在执行完毕的最后一步,会重新将自己提交回任务队列。即由任务队列完成循环的闭环,而不是在过程内部硬编码循环。如此,过程既可以周期性地运行,又在运行间隔中保持了资源的 CID 可操作性。
原子性和一致性保证
在 ROSD 中,系统的整体状态就是所有资源实例状态的集合,即:
\[S = \{T^1_0, T^1_1, T^1_2, ..., T^2_0, T^2_1, T^2_2, ..., T^3_0, T^3_1, T^3_2, ...\}\]系统在设计时必须考虑到被多用户并发操作的问题,这种考虑不是实现层面的,而是设计层面的,即系统应该在多用户并发操作时,应该对他们体现出一种什么样的行为。这种行为就核心体现为对操作原子性与状态一致性的承诺和保证。
对于底层或微观的系统,全局原子性的设计是可行的,此时本质上并不允许真正的并发操作,所有的外部操作都被一把全局锁串行化,按某种全局一致的顺序执行。但这种方案牺牲了并发能力,无法支撑宏观系统,尤其是真实应用业务系统的需求。
而 ROSD 指出了一种天然的实用、普适、清晰的粒度层级:只保证单个资源实例上的操作原子性和状态一致性【资源原子化原则】。这种设计约定:
- 在任一时刻点上,一个资源实例 $T^x_y$ 上只允许有一个操作在进行;
- 因此同一个资源实例 $T^x_y$ 上的所有操作原语,都是互斥执行、顺序一致的;
- 同一资源实例 $T^x_y$ 上的并发操作可以排队等待,或者拒绝服务,由具体的需求而定;
- 不同的资源实例 $T^x_y$, $T^z_w$ 之间的操作是并发的,没有原子性和一致性保证。
这一思路与面向对象中的管程(Monitor)思想十分接近,只不过它被提升到了系统设计层面,而不是编程语言层面的同步机制。
“单个资源实例”的粒度是否过粗,是否应该允许同一个资源实例上的并发操作?我们的回答是“否”:因为资源这一概念本身具备高度灵活的伸缩性,可大可小、可粗可细。
如果你发现某个“大资源”内部的多个操作需要并行化,这往往不是设计规则的问题,而是资源划分粒度过大的信号。正确的做法不是妥协设计,而是将这个大资源合理拆分为多个更小、相互关联的细粒度资源【粒度拆分原则】。拆分之后,每个小资源依然可以遵循“单资源内部原子化、多资源之间允许并发”的原则。不同资源之间不做跨实例的原子性保证,既保证了系统整体的并发能力,又维持了设计规则的统一与简洁。
先验/后验标识符
ROSD 中,外界对系统的所有操作都必须通过标识符 $X$ 定位目标资源实例。我们要进一步指出,在系统设计中,标识符不仅暴露给外部使用,也通常会是系统内部资源间相互引用的方式。例如一个项目需要记录其所属的用户是谁,那么就会需要在项目资源中存储用户资源的标识符。绝大多数情况下,另外设计一套系统内的标识符机制,以对外界隐藏系统内部的实现,都是没必要的。遵循奥卡姆剃刀原则,系统内直接复用系统外的标识符是最简洁、最一致的选择。
垂悬引用问题
系统内普遍存在的资源间相互引用,会直接引发一个 ROSD 无法回避的核心问题——垂悬引用。由于每个资源实例的 CID 操作都是独立的,这就意味着系统必然存在这么一些可能的状态:当一个资源实例被删除后,那些原本引用它的资源中残留了指向已不存在实例的标识符,也就是所谓的垂悬引用。
垂悬引用的产生不仅是因为 ROSD 不提供跨资源实例的 CID 原子性和一致性保证,更是一个其固有的设计约束:资源类型间的依赖天然是单向的【单向依赖原则】。例如,系统新功能带来的新资源可以依赖已存在的旧资源,但旧资源在设计之初可能完全不能预知未来会被谁引用。新资源将来也会变成旧资源,因此总之只能默认假定系统里的所有资源实例都不知道自己被哪些资源实例引用了。而且注意到,这种单向依赖本身正是 ROSD 允许系统渐进式扩展的关键,是它区别于关系型一体式设计的优势,而非缺陷。
我们可以为系统设计一个双向引用追踪系统,来像关系型里一样,当一个资源实例被删除时,将关联的资源实例一并删除或引用置空。但这会存在两个关键问题:第一,这种双向引用系统不仅实现困难,而且在系统运行时一定也会引发巨大计算、存储的开销;第二,也许更致命的是,这意味着从设计上,一个资源的删除可能波及到其它资源的修改,由此便破坏了每个资源实例的操作独立性。尤其是,在考虑到资源上的原子性和一致性保证问题后,这种级联的修改极有可能引发死锁、修改回退等一系列棘手的问题。所以,追踪双向引用在一般情况下绝对不是一个好的解决方案。
与之相对地,ROSD 推荐采用一条极为关键的设计原则:系统必须对无效标识符具备鲁棒性【无效鲁棒原则】。任何操作原语在执行时,都必须主动检查所有使用到的标识符是否有效,并妥善处理失效情况。这也就意味着,系统内部所有资源间的引用,本质上都是弱引用,即使引用双方都在系统内。而且,这种标识符有效性检查必然是可以实现的,因为系统要对所有的外部操作加以检查,其中就一定会检查标识符的有效性。
资源间普遍的弱引用还有一重要作用,就是它赋予了系统一个重要性质:由于不存在强引用,任何资源实例都可以被独立、强制地创建与删除,不受其他资源的牵连与阻塞【独立管控原则】。这一性质对管理型系统至关重要。它保证了系统在资源管理层面的确定可回收性:删除一个资源,系统就应当真正释放它所占用的物理资源;而不是因为某处隐藏的引用导致资源无法彻底清理,形成隐性残留与泄漏。
作为一个反例,与之形成对比的是传统文件系统中的硬链接机制。由于硬链接构成强引用,即便用户执行了删除操作,仍然可能存在其他链接使文件实体不会被真正释放。这种设计之所以在 OS 层面可行,是因为这个层面的大部分资源还不具备物理上的特殊性:文件只与一些存储空间相关联,磁盘上的两块扇区在使用上并没什么什么差别。但在更宏观的业务系统层面,每个资源实例往往都可能会关联一些独一的物理对象,例如一个人、一个组织、一个公司。这时若出现“想删删不掉”的问题,就不只是泄露一点计算资源的事了,更有可能引发严重的现实后果。因此,ROSD 的弱引用+垂悬引用鲁棒设计,是系统可控、可靠、可治理的有效保障。
先验标识符的错误引用问题
如果允许外界指定资源实例的标识符,上述的垂悬引用会进一步招致更为严重的错误引用问题:如果外部系统创建同名的新资源,这个新资源可能会错误地继承旧资源的垂悬引用。这种继承在实际业务中极易引发权限泄露的严重风险。例如,一个旧用户被删除,但其关联的项目没被删除时,新创建的同名用户刚一进系统就会直接看到此前用户的旧项目,相当于无需授权就获得了旧用户的所有数据,这显然是不可接受的。
一种常见的避免这一问题的机制是假删除。假删除并非真正将资源记录从系统状态(如数据库)中移除,而是为资源添加一个状态标识,如 is_deleted 字段。所谓删除操作仅将 is_deleted 设为 true 。所有的资源操作原语也必须同步修改,只在校验 is_deleted 为 false 时再执行。由于旧资源并未真正被删除,同名资源的创建自然会被阻止。
但假删除存在明显的局限性:它本质上是一种禁用机制,而非真正的删除。删除操作的核心语义之一,就是要释放原资源的标识符,使得其能被重新创建。这要求对外隐藏“标识符曾被使用”的事实,而上述的假删除无法实现这一点,这样的系统一般是不符合数据合规的要求的。若想阻止外界通过尝试判定标识符是否已被使用,就必须再设计额外的标识符机制允许外部可见标识符的复用(如添加后缀、时间戳等),但这其实就是说系统内的标识符已经不再是先验的了,与我们一开始的假设相悖。
除此之外,先验标识符本身还存在信息泄露风险:外界通过尝试枚举常见的标识符,来获取系统内的资源名录,这在某些场景下可能会导致用户隐私风险。综上所述,将资源标识符设计为先验的,是一种极不推荐的设计方案。其导致的权限泄露、信息泄露等问题,是设计原理层面的固有缺陷,不可能通过技术手段彻底规避。因此在一般的系统中都不建议采用先验标识符的设计。
一个典型例子,就是邮箱地址。邮箱账号通常由用户自行指定,属于典型的先验标识符。正因如此,只要我知道某个人常用的命名风格,就可以通过尝试注册的方式,试探该邮箱是否已被占用,从而判断对方是否在对应系统中存在账号。当今的邮箱系统早已饱受垃圾邮件的问题困扰,与该设计缺陷不无关联。在更多私有属性的业务系统中,资源是否存在本身就是敏感信息,这种信息泄露就不再是无关紧要的小问题,而是必须严肃对待的安全隐患。
后验标识符的幂等操作问题
既然不能采用先验标识符,那就只能采用由系统自主生成的后验标识符。这时,外界在创建操作之前,无法指定、也无法预知新资源的标识符会是什么。由于标识符的值完全有系统自主决定,系统可以通过保证标识符生成的时空唯一性来完全解决垂悬引用和错误引用问题。
后验标识符会天然导致创建操作无法实现幂等:每一次创建请求都会生成一个全新的资源实例,而不会复用、覆盖或判断是否已存在对应资源。这在不稳定的通信环境下可能会引发资源泄露问题。如果资源创建已经在系统内部成功完成,但由于网络错误等问题,新标识符未能成功送达外界。这时,外界并不知道资源已被创建,也没有简单、直接的方式去判断“自己想要创建的资源是否已经存在”。于是,调用方最自然的选择就是重试,再次发起创建请求。对外界而言,这通常不会影响功能使用,它可以用新创建的资源继续工作。但对系统内部来说,就会多出了一个外界并不需要、也不知道其存在的冗余资源,它长期滞留、无人管理,最终成为系统垃圾。
这个问题并不算致命,但会持续造成系统资源浪费,长期积累后会影响系统整洁度与运行效率。由此,我们提出一条重要的设计原则:系统中的所有资源,都必须具备完全可枚举能力【完全可枚举原则】。
也就是说,系统必须提供某种方式,能够遍历当前存在的所有资源。这里的遍历并不要求在某一时刻拿到绝对完整、强一致的资源快照——在并发系统中,这既难以实现,也没有太大意义,因为资源目录在下一瞬间就可能再次变化。我们只要求系统能够以最终一致的方式,遍历、检索到所有存在的资源实例即可。
这一原则,虽然无法完全避免创建重试的资源泄露问题,但至少可以保证这类泄漏是可发现、可治理的。后续通过定期巡检、垃圾回收脚本或人工核查,就能识别出系统中无人使用、无主的闲置资源,并对其进行销毁与释放,从而维持系统状态的干净与可控。
通用方案:后验标识符 + 唯一辅键
在分别讨论了单独先验/后验标识符的问题之后,我们可以提出一套通用、稳健、适用于绝大多数业务系统的设计方案。这套方案既能规避先验标识符的固有缺陷,又能在一定程度上弥补后验标识符的不足,是 ROSD 推荐的默认标识符设计。
其核心思路是:
- 资源的资源的主标识符仍然是后验的,由系统内部生成,外界不可指定、无法预测。
- 增设一额外的、具备唯一性约束的“辅键”字段,用于提供幂等性。
辅键可以理解为资源的“名称”或“外部别名”,但它不具备主标识符那样的不可变性,只是资源上的一个带索引的普通可写字段。我们推荐使用分布式唯一 ID 算法(如 UUID4)生成主标识,这类标识符可以保证时空唯一性,并且完全随机,外部难以猜测和提前预知。
以上设计可以一举解决多个问题:
创建操作可以实现幂等。
外部在创建时传入相同的辅键,系统就能判断是否已存在对应资源,避免重复创建,从而应对网络超时、重试等异常场景。
资源支持别名和重命名。
由于资源的相互引用,如果使用纯先验标识符,名称一旦确定便难以改动;如果使用纯后验标识符,外界又难以记忆和识别资源。现在,用作资源名称的辅键,可以像其他字段一样自由修改。
主标识天然具备隐私安全性。
因为完全随机且不可预测,外部无法通过枚举、猜测等方式探知系统内存在哪些资源,避免了主标识层面的信息泄露。由于主标识本身具备隐蔽性,这本身也可确保资源在系统用户间的安全性,可以在权限管理之外起到补充防护的作用。
由于辅键带有唯一性约束,通过同名创建探测资源存在的漏洞仍然可能存在。为此,可以有多种系统设计方案再进一步修补:
时间有效期
只在创建操作的一段时间内保证辅键唯一性,用于支持幂等重试;超过有效期后,系统不再强制该辅键全局唯一,允许重复使用。
资源命名空间
对于间接创建的资源,可以给标识符添加上级资源的前缀(例如用户 ID),形成独立的命名空间。命名空间内辅键的唯一性得到保障,而不同命名空间互不可见。
随机化辅键
对于极端看重隐私安全的场景,外界可以使用随机字符串作为辅键。但此时辅键就无法再起到资源名称的作用,因此这时系统必须再添加一个额外字段来作为资源别名。
应当根据具体情况的安全性、可用性、实现复杂度选择合适的上述修补方案。至此,这套后验标识符 + 唯一辅键的设计,基本解决了前述的全部核心问题,能够做为一般情况下的默认设计选择。
实现上的考量和建议
使用键值数据库
ROSD 推荐使用键值数据库(如 Redis 等)存储系统状态,这类数据库非常契合单资源实例的访问模式,延迟低、性能好。在这一结构下:
- 资源的后验主标识符作为数据库的键(Key);
- 资源本身的状态数据作为对应的值(Value)。
大部分对资源的操作都是通过标识符直接定位,不存在性能问题。但一些情况可能涉及到数据库的遍历,例如我们之前设计的辅键,要支持反向查询主标识符的话,在没有索引的情况下就得遍历整个数据库,可能会导致性能瓶颈。
为了规避这一性能瓶颈,我们可以额外维护一个“辅键 → 主标识符”的反向映射缓存。但这时就会带来了 NoSQL 中非常典型的,冗余数据的一致性问题:反向索引缓存与主资源库之间必须保持一致,否则会出现查询不到、找到错误标识符等异常。
这类一致性问题不需要分布式事务等复杂的机制,只需要遵循 ROSD 本身的设计即可:只需要在资源的所有操作原语间保证一致性。由于有且仅有操作原语会直接操作数据库,不存在其他旁路写入路径,所以我们只需要在撰写原语时谨慎一些,注意维护缓存与主数据的一致性。由于我们已经假定原语的执行是原子的(要么完全成功,要么完全失败),因此在正常工作的系统中,不会存在缓存不一致的问题。
当出现系统崩溃等极端情况时,缓存与主库可能出现不一致。为此,我们再通过一条核心原则来兜底:主资源的状态是唯一权威的状态源【源状态权威原则】。系统应该提供一个兜底操作来根据主资源列表的状态重建全部缓存,从而彻底修复不一致问题。这类操作仅在异常恢复时使用,不影响日常运行性能。
“上字”型代码架构
我们可以自然地从前述设计中推导出一种形如“上字”的代码组织结构。如图所示,系统代码主要由三部分组成:
原语层
这是整个系统最核心的部分,它定义了系统的各种资源和资源上的操作原语。操作原语们是唯一直接访问资源状态数据库、访问外部系统的代码。所有对资源的状态修改、外部调用,都在这里收口、实现并保证一致性。原语层在系统设计定型后一般较少变动,是整个系统代码中对稳定性和可靠性要求最高的部分。
业务层
所有籍由组合操作原语实现的复杂逻辑和业务流程都应当被剥离到业务层。业务层不直接读写数据库、不直接调用外部服务,只通过原语层完成功能。相比于原语层,业务层的逻辑不规则,且变化更为频繁,适应业务快速迭代的需求。将其与稳定的原语层分离,可以保证底层核心代码在迭代中基本保持不变,增强系统的稳定性。
接入层
接入层负责将各类外部输入转化为系统内部可处理的运行时数据,并调用业务层提供的能力。系统可能有许多形式多样的接入层,例如面向人类用户的 UI 界面、面向机器的 HTTP/gRPC 等 API 入口等等。不同下游系统、不同编程语言、不同客户端,都通过接入层接入,最终统一调用业务层。
整体来看,“上字”型架构体现了 ROSD 的核心思想:底层收口、稳定下沉;上层灵活、变化上浮;中间通过操作原语严格分界,既保证了系统的一致性与鲁棒性,又具备极强的扩展性与可维护性。
RESTful API 参考设计
ROSD 系统很适合 RESTful 风格的 API,本节以用户资源 user 为例给出一个设计参考。
我们将资源标识符嵌入在 URL 模式中,例如 /user/123 表示 ID 为 123 的用户;使用嵌套的路径来表示资源的层级关系,例如 /user/123/file/456 表示 ID 为 123 的用户的文件 456。使用形如 /user/<uid> 的表达来描述这种 URL 模式。
RESTful API 通常使用五种 HTTP 请求方法来操作资源:GET、POST、DELETE、PUT、PATCH。我们将 POST 用于 C 操作、DELETE 用于 D 操作、其它方法都用于 I 操作。下面的设计中,POST、DELETE 被设计成批量化的,可以一次请求创建或删除多个资源。
POST /user批量创建用户该 API 的非幂等版本请求体为一个 JSON 数组,每个元素创建所需的信息,例如:
1 2 3 4 5 6 7 8 9 10 11 12
[ { "name": "Alice", "age": 20, "password": "123456" }, { "name": "Bob", "age": 21, "password": "654321" } ]
响应体为一个 JSON 数组,给出所创建资源的标识符:
1 2 3 4
[ "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" ]
该 API 的幂等版本请求体为一个 JSON 对象,键为资源辅键,值同上:
1 2 3 4 5 6 7 8 9 10 11 12
{ "alice": { "name": "Alice", "age": 20, "password": "123456" }, "bob": { "name": "Bob", "age": 21, "password": "654321" } }
响应体为一个 JSON 对象,键为资源辅键,值给出所创建资源的标识符:
1 2 3 4
{ "alice": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "bob": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" }
DELETE /user批量删除用户请求体为一个 JSON 数组,每个元素为要删除的用户 ID,例如:
1 2 3 4
[ "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" ]
响应体为一个布尔数组,反映每个元素是否删除成功:
1
[true, false]
GET /user列出所有用户请求体为空,响应体为一个 JSON 字典,给出所有用户的简要信息:
1 2 3 4 5 6 7 8 9 10
{ "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": { "name": "Alice", "age": 20 }, "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy": { "name": "Bob", "age": 21 } }
GET /user/<uid>获取用户信息请求体为空(
GET请求体必须为空),响应体为一个 JSON 对象,表示用户的详细信息,例如GET /user/xxxxxxxx-xxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx返回:1 2 3 4 5 6 7 8 9
{ "name": "Alice", "age": 20, "status": "active", "files": ["2000", "2001"], "created-at": "2020-01-01T00:00:00Z", "last-login": "2020-01-01T00:00:00Z", "last-modified": "2020-01-01T00:00:00Z" }
哪些信息算是“简要信息”,哪些信息算是“详细信息”,根据实际业务场景斟酌。其关键考虑是:用户列表可能会很长,因此每项信息必须要简短;而单个用户则可以包含更多信息而不用担心长度的问题。
PUT /user/<uid>更新用户信息请求体为一个 JSON 对象,表示要更新的信息,例如:
1 2 3 4
{ "name": "Alice", "age": 21 }
响应体可以设计为
null或者是更新后的用户数据。PUT /user/<uid>/password修改用户密码密码属于敏感数据,不便于在 PUT 中修改,因此可以设计为一个独立的 API —— 这样也会方便系统对敏感操作的监控。请求体为一个 JSON 对象,表示要修改的密码,例如:
1 2 3 4
{ "old": "123456", "new": "654321" }
响应体可以设计为
null。PATCH /user/<uid>/notice发送通知给用户发送通知不是纯数据操作,因此使用
PATCH方法。请求体为一个 JSON 对象,表示通知的内容,例如:1 2 3 4
{ "title": "Hello", "content": "World" }
响应体可以设计为
null。
设计案例:“配额”是一种资源
下面我们用一个完整、直观的实例,展示如何运用 ROSD 思想优雅地扩展系统功能——为用户增加资源配额限制。
假设系统已经具备成熟的用户管理能力,现在需要新增配额管控:限制每个用户可使用的各类资源总量,并监控已用额度。
方案 A:直接扩展用户资源
这是一个最直观的思路:在已有的用户资源上新增若干字段,分别记录每种受控资源的:
- 总配额上限
- 已使用量
- 剩余可用量
但这种做法存在明显缺陷:用户是系统底层的核心资源,牵一发而动全身。直接修改用户结构,可能会导致用户相关的大量代码修改,波及范围广、风险高。这进一步会导致需要对数据库进行结构变更,可能涉及数据迁移、停机风险。
整个过程中,一旦出现问题,就会影响整个用户体系的稳定运行。由此可见,新功能与原有逻辑高度耦合,不符合渐进式迭代的设计理念。
方案 B:抽象为新的独立资源
按照 ROSD 思想,我们可以将配额本身,设计为一种独立的资源,每个“配额资源”只需要包含四个信息:
- 资源名称:什么类型的资源;
- 归属信息:该配额属于哪个用户;
- 总量约束:该类资源的最大可用配额;
- 使用状态:当前已使用多少。
通过这种设计,系统扩展呈现出明显优势:
完全不侵入原有用户体系
配额是独立资源,不修改用户表、不改动用户逻辑、不影响现有代码。新增功能与原有系统彻底解耦,不会对存量用户造成任何副作用。
天然兼容旧行为
在没有为用户创建配额资源之前,该用户不受任何配额限制,与系统升级前的行为完全一致。只有在显式创建配额后,限制才会生效,实现了按需启用、平滑升级。
支持灵活、多维度的配额策略
可以为同一个用户创建多个配额资源,分别管控不同类型的资源。多个配额之间可以采用“任一超限即拒绝”的策略,实现细粒度、可组合的限制规则,扩展性极强。
监控逻辑可通过过程资源化实现
配额检查、超限判定、告警通知等逻辑,不必嵌入业务流程,而是可以封装为一个自治的后台任务,再通过过程资源化将其变成周期性执行的作业。系统定期扫描配额状态,发现超限时自动发送邮件或站内通知,整个逻辑自治、可插拔、不侵入主流程。
从这一案例我们可以看到,ROSD 实现了不破坏、不侵入、可渐进、可扩展的系统功能迭代。不需要重构底层、不需要迁移数据、不需要冒险修改核心模块,只需要新增一类资源,就能安全、优雅地把复杂功能接入系统,这正是 ROSD 设计思想在真实工程中的最佳实践。
总结
本文介绍了 ROSD,其将 OOP 理念泛化至宏观系统设计层面,以资源类型与资源实例统一建模系统状态与行为,通过 CID 操作原语规范资源交互,并将自发过程转化为可管理的资源实体以实现系统自治。该设计遵循资源原子化、单向依赖与弱引用等原则,采用后验标识符搭配唯一辅键的标识符方案,兼顾系统安全性、幂等性与可扩展性,同时依托键值存储与“上字”分层架构实现高内聚、低耦合的系统结构。相较于传统关系模型,ROSD 支持渐进式迭代与柔性扩展,能够在不侵入原有模块的前提下高效扩展系统功能,为复杂业务系统提供了稳定、通用且易于工程落地的设计范式。
参考文献
资源导向思想的发展可追溯至多个经典理论与实践:Ken Thompson、Dennis Ritchie 于 20 世纪 70-80 年代提出 Unix“一切皆文件”理念,将各类系统实体抽象为统一资源并定义标准接口;Tim Berners-Lee 在 1989 年发明 WWW 时,提出“一切皆资源”并以 URI 作为唯一标识;Roy Fielding 在 2000 年博士论文《Architectural Styles and the Design of Network-based Software Architectures》中正式提出 REST 架构风格,将资源模型规范化;2000 年代惠普实验室提出 Resource-Oriented Computing(ROC)理论,深化资源导向计算模型;2000年代后期至2010年代,Redis、Cassandra 等 NoSQL 键值数据库的兴起,为资源模型提供了天然适配的存储基础设施,而Paul Prescod、Stefan Tilkov 等人于 2004 年左右提出的 Resource-Oriented Architecture(ROA),进一步拓展了资源导向思想的应用边界。我提出 ROSD,是直接受到 K8S API 启发,也算是对它的理解和总结。