
如何设计对象数据库的数据模型:对象、属性与关系
对象数据库把程序中的对象及其引用关系作为持久化数据来管理。它适合表达具有身份、状态和复杂联系的领域实体,但“直接存对象”不等于不用设计模型:对象的边界、属性类型、关系方向、生命周期和并发规则,仍然需要明确。
本文先说明对象数据库适合解决什么问题,再介绍对象、属性与关系的建模方法。示例使用 Python 的 ZODB,展示如何定义持久化类、保存对象及引用关系,并说明代码中容易被忽略的持久化与安全边界。
一、对象数据库是什么
关系数据库以表、行和外键组织数据;对象数据库则以对象为基本单位。对象通常有稳定身份、若干属性,并可引用其他对象。数据库负责保存对象状态,使程序在之后能够重新取得这些对象。
这里说的“对象数据库”不是泛指所有能保存 JSON 的数据库。文档数据库通常以文档为主要存储单元,图数据库强调节点和边;它们也能表达对象模型,但存储语义和查询方式各不相同。本文以真正支持 Python 对象持久化的 ZODB 为例。不同产品的事务、索引、查询和并发能力不能直接套用。
二、什么时候适合使用对象数据库
- 领域对象本身复杂,且对象之间的引用是主要结构。例如设计工具中的图元、连接、图层和文档,或模拟系统中的实体与状态。
- 应用主要通过对象图读取和修改数据。常见操作是从一个根对象出发,沿引用访问一组关联对象,再提交一笔事务。
- 业务代码已经有稳定的对象模型。持久化对象可以减少手工在对象与表结构之间转换的工作,但不会自动解决模型演进和查询设计问题。
- 团队能够接受特定数据库的工具链和运行方式。对象数据库往往与应用语言、持久化机制或生态绑定较紧,迁移和跨语言访问可能不如常见关系数据库直接。
如果系统主要需要跨实体做复杂聚合查询、报表分析、强约束的关系型事务,或要由多种语言共同访问数据,关系数据库通常更合适。如果核心需求是沿网络结构遍历关系,可以评估图数据库。不要因为业务名词叫“对象”,就默认应该选对象数据库。
三、先定义对象边界:哪些东西值得持久化
建模的第一步不是给每个类加持久化标记,而是区分需要长期保存的领域状态和运行时临时状态。
用户、订单、文档、项目等具有业务身份、会在多次请求之间存在的实体,通常适合作为持久化对象。缓存、数据库连接、文件句柄、线程锁、临时计算结果等运行时资源通常不应直接进入持久化对象;它们可能无法序列化,也不应该在进程重启后作为旧资源继续使用。
可以先为每类对象回答以下问题:
- 它有没有独立的业务身份?如果改名或修改属性,它还是原来的那个实体吗?
- 它的生命周期由谁控制?创建、删除和归档分别意味着什么?
- 它会被哪些操作读取或修改?这些操作通常需要一起提交吗?
- 它会与其他对象如何关联?关联是否必须双向可达?
- 哪些状态只是缓存或派生结果,能够重新计算?
四、对象:身份、生命周期与行为
对象身份表示“这是哪一个实体”,不等同于对象当前的属性值。在 ZODB 中,持久化对象由数据库管理,程序可以通过根对象等入口取得对象引用。业务系统仍然经常需要自己的标识符,例如用户编号或订单编号,因为外部接口、日志和导入导出流程通常需要稳定、可读的业务键。
业务键应由应用明确管理。ZODB 的根映射可以按键查找对象,但这不自动构成数据库级的唯一约束。若两个请求可能同时创建相同业务键,必须通过应用的事务和冲突处理设计保证结果符合业务规则;不能仅依赖“先检查不存在,再写入”的非原子代码。
生命周期决定对象何时创建、被谁引用、何时删除或归档。删除根映射中的一个入口,不应被理解为通用的级联删除规则。一个对象可能仍被其他对象引用;对象是否真正可达、何时回收以及存储如何维护,应按所用存储和数据库机制确认。业务上的删除也常常应该是状态变更或软删除,而非立即移除对象。
行为可以放在对象方法中,但要谨慎控制副作用。方法可能修改多个持久化对象,调用方应清楚这些改动属于哪笔事务。将网络请求、发邮件等外部副作用塞进数据库事务,也会带来回滚后外部操作无法撤销的问题;这类流程通常需要单独设计。
五、属性:类型、默认值与可变对象
属性应尽量表达稳定、可验证的领域状态。类型和含义要明确,例如金额使用适当的定点数表示,而不是依赖二进制浮点数的精确相等;时间要明确时区策略;枚举状态要限制为受认可的取值。ZODB 不会替应用自动建立完整的业务校验规则,输入校验仍应在应用边界完成。
默认值要考虑历史对象。只在类中声明一个新属性,并不一定意味着旧对象已经拥有该属性。访问旧数据时,代码可能需要兼容处理,例如用 getattr 提供旧对象的默认行为,并在适当时机执行显式迁移。模型升级应先确定部署顺序、回滚策略和数据验证方式。
另一个关键点是可变容器的持久化追踪。普通 Python 列表或字典被赋给持久化对象后,随后对容器内部进行原地修改,未必会被持久化层自动观察到。常用办法包括:
- 使用 ZODB 提供的持久化容器,例如
PersistentList和PersistentMapping; - 修改后重新给父对象的属性赋值,以触发父对象状态变化;
- 按明确的持久化策略封装修改操作,并通过测试确认事务提交后的结果。
不要假定任意嵌套的 list、dict 或自定义对象都会自动跟踪内部变化。持久化追踪取决于对象类型及其组合方式。
六、关系:引用、集合与聚合边界
在对象数据库里,一个对象可以直接持有另一个持久化对象的引用,也可以持有对象集合。引用表达“我能找到谁”,但并不自动等于数据库中的外键约束。若关系必须存在、必须唯一或必须同时更新多个对象,仍要由事务和领域规则保证。
建模时常见的关系有三种:
- 单向引用:例如文章保存作者引用。若应用常从文章找到作者,这通常已足够。
- 双向引用:例如用户保存文章列表,同时文章保存作者引用。查询方便,但创建、删除和迁移都必须维护两端一致。
- 集合关系:对象持有若干成员对象。要确认集合的顺序、重复项、查找成本及成员生命周期,不能只把这些细节留给实现默认值。
选择单向还是双向,不应只为了“访问起来方便”。双向关系会增加状态同步工作。除非反向查询确实频繁且有合理维护方式,否则可以保留一个权威方向,需要反向查询时使用索引或专门的映射结构。
还要判断某个对象是聚合的一部分,还是独立实体。例如,地址可能只是用户的值对象,也可能需要独立身份、被多个业务对象引用。前者通常可以作为属性组合在拥有者中;后者更适合独立建模。这个判断影响生命周期、共享方式与更新边界。
七、ZODB 示例:用户与文章
下面的程序创建两个持久化类:用户对象维护文章集合,文章对象保存作者引用。它使用 ZODB 的 FileStorage 将数据写入本地文件,并在提交后重新打开数据库读取结果。
安装依赖:
python -m pip install ZODB
将以下内容保存为 zodb_model.py 后运行 python zodb_model.py。程序会在当前目录创建或使用 objects.fs:
from persistent import Persistent
from persistent.list import PersistentList
from persistent.mapping import PersistentMapping
from ZODB import DB
from ZODB.FileStorage import FileStorage
import transaction
class User(Persistent):
def __init__(self, user_id, name):
self.user_id = user_id
self.name = name
self.posts = PersistentList()
class Post(Persistent):
def __init__(self, post_id, title, author):
self.post_id = post_id
self.title = title
self.author = author
storage = FileStorage("objects.fs")
db = DB(storage)
try:
connection = db.open()
try:
root = connection.root()
# 每次运行重建示例数据,避免重复追加文章。
root["users"] = PersistentMapping()
root["posts"] = PersistentMapping()
user = User("u-1001", "林岚")
post = Post("p-2001", "对象模型入门", user)
user.posts.append(post)
root["users"][user.user_id] = user
root["posts"][post.post_id] = post
transaction.commit()
except Exception:
transaction.abort()
raise
finally:
connection.close()
# 重新打开连接,检查提交后的对象和关系。
connection = db.open()
try:
root = connection.root()
saved_user = root["users"]["u-1001"]
saved_post = root["posts"]["p-2001"]
assert saved_user.posts[0].post_id == "p-2001"
assert saved_post.author.user_id == "u-1001"
print(saved_user.name)
print(saved_user.posts[0].title)
print(saved_post.author.name)
finally:
connection.close()
finally:
db.close()
预期输出为:
林岚
对象模型入门
林岚
代码中的 root 是访问持久化对象的入口;root["users"] 和 root["posts"] 使用持久化映射。用户与文章之间保存的是对象引用,而不是仅保存一份名字字符串。PersistentList 让对文章集合的追加能够被持久化层追踪。transaction.commit() 提交当前事务;发生异常时调用 transaction.abort() 放弃本次未提交的更改。
示例为便于重复运行,每次都会重建根映射中的演示数据,不适合作为真实应用的初始化逻辑。实际系统通常需要单独的初始化、更新和删除流程,并处理业务键冲突、并发提交、事务重试与备份恢复。
另外,示例类定义在当前脚本中,足以演示同一进程中的写入和重新读取。长期部署时,持久化对象的类应放在稳定、可导入的模块中,并谨慎维护模块路径与类结构。修改类名或移动模块可能影响旧数据的读取;不同版本的对象兼容与迁移必须经过实际验证。
八、对象模型的查询与访问路径
对象数据库中的模型设计不能只看对象本身,还要看应用如何找到它。示例通过根映射按用户编号和文章编号取得对象,这种访问路径明确;若系统经常需要按邮箱、状态或时间范围查找对象,仅靠顺着引用遍历可能效率不足。
应把高频查询列出来,再决定保存哪些索引或映射。例如,按文章编号查文章可以维护编号到对象的映射;按用户查文章可以维护用户的文章集合。索引本身也是需要维护的数据:创建、删除、改键和迁移时必须保持一致。不要为了所有可能的查询都建立冗余关系,否则写入和修复成本会迅速增加。
还应估算对象图的访问范围。一次读取是否会沿引用遍历很大的集合?是否会意外触发大量对象加载?对象数据库不意味着整个关联图可以无成本地一次性读取。必要时应限制访问范围、拆分集合,或通过数据库提供的索引和查询机制取得目标对象。
九、事务、并发与一致性
持久化对象的修改通常应放在明确的事务范围中。涉及多个对象的业务操作,要说明哪些修改必须原子地提交。只提交一部分对象会让业务状态不一致,因此事务边界应按业务操作设计,而不是随意按代码函数切分。
并发下,两个连接可能读取同一对象后分别修改。ZODB 的具体并发冲突行为与存储实现及对象更新方式有关;不能把“提交成功”理解为所有业务冲突都已自动解决。应用要识别可能冲突的写入,捕获并按规则重试或返回冲突结果。重试操作必须能够安全地重新执行,尤其要避免重复扣款、重复发送外部请求等副作用。
事务通常只保护数据库自身的修改,不会自动把邮件服务、支付接口或其他数据库纳入同一个原子提交。需要跨系统一致性时,应另行设计可靠的消息、补偿或状态机流程。
十、安全边界:持久化不是权限系统
ZODB 提供对象持久化能力,不等同于应用级授权机制。它不会自动判断当前用户是否有权查看某个对象,也不会替业务校验对象属性是否允许修改。对象访问控制、租户隔离和输入校验要由应用层明确实现,并通过测试验证。
不要把可由不可信来源控制的文件当作可信数据库直接打开。ZODB 使用 Python 对象的持久化序列化机制;不可信数据在反序列化或对象载入过程中可能带来代码执行等安全风险。数据库文件、备份和传输过程应纳入访问控制,来源不可信的数据应先通过安全边界处理,不要直接加载为持久化对象。
还要区分“参数限制”和“安全保证”。ZODB 没有一个可适用于所有部署、所有对象和所有后端的通用对象大小上限可供本文承诺。具体资源限制、并发能力和性能边界应查阅所用版本、存储实现及部署配置的文档,并用实际负载测试。即使某个输入长度被限制,也不能据此认为对象数据可信或权限检查已完成。
十一、常见错误
- 把每个类都做成持久化对象。这会让临时数据、资源句柄和缓存进入持久化图,增加兼容与维护成本。先判断它是否有跨请求的业务身份和持久化需求。
- 把对象引用误当作完整约束。引用可以表达关联,但唯一性、必填和生命周期规则仍需应用保证。
- 不区分双向关系的维护成本。两端都保存引用时,新增、删除和数据迁移必须同步更新;不要为了少写一次查找就盲目冗余。
- 原地修改普通嵌套容器,却不确认是否被追踪。使用持久化容器或显式重新赋值,并测试提交后重新读取的结果。
- 只设计写入,不设计查找。如果没有明确入口和常用访问路径,系统可能需要昂贵的全量遍历。先列出实际查询,再设计索引结构。
- 随意修改持久化类结构。旧对象可能缺少新属性,模块移动或类名变化也可能影响载入。发布前设计兼容逻辑和迁移步骤。
- 把数据库文件当作普通、无风险的缓存文件。备份、权限、反序列化来源和恢复演练都属于生产运维的一部分。
- 把事务当成跨服务事务。数据库回滚不能撤销已经发出的邮件或已完成的外部支付调用,应将数据库写入与外部副作用分开设计。
十二、建模检查清单
在实现前,可以用以下清单检查模型是否足够清楚:
- 每个持久化对象的业务身份、所有者和生命周期是否明确?
- 哪些属性是必需的,哪些是可选的,默认值和类型是否稳定?
- 嵌套可变数据是否能被持久化层追踪?
- 关系是单向还是双向?谁负责维护?是否有循环引用或过大的对象图?
- 高频读取和修改路径是什么?是否需要索引或映射?
- 哪些修改属于同一事务?并发冲突如何处理?
- 旧对象如何兼容新代码?迁移如何验证和回滚?
- 对象数据从哪里载入,谁能访问,备份和恢复如何执行?
结语
设计对象数据库的数据模型,重点不是把程序类原样存起来,而是明确对象身份、属性语义、引用方向、生命周期和事务边界。对象引用能自然地表达领域关系,但也会让一致性、查询入口和数据演进更多依赖应用自身的设计。
先用真实访问场景验证模型,再决定哪些对象独立持久化、哪些数据嵌入对象、哪些关系需要索引。通过事务后重新读取、并发测试和升级演练验证行为,才能让对象模型不仅“看起来像业务”,也能在长期运行中保持可理解和可维护。



