Course Builder个人学习工作台 · 本地模式
DDIA 第二版:从数据模型到分布式系统设计

03 · 关系与文档:让模型匹配更新边界

目标:根据关系和更新需求决定哪些数据应嵌入、引用或保存快照。

重复的是事实,还是历史

假设活动由一个组织者举办,用户可参加多个活动。活动与报名是一对多,用户与活动是多对多。关系模型把这些事实分成表,用键表达关联;文档模型可以把一组通常一起读取的数据放进一个文档。两种表示都能有效工作,关键在于什么会独立更新、单个集合会增长多大,以及查询需要跨越哪些关系。

如果把组织者当前名称复制进所有活动,改名时要同步许多副本。规范化让一个事实只有一个权威表示,用组织者 ID 引用;查询通过连接(join)把两侧匹配起来。反规范化减少查询时的组合工作,却增加维护副本的责任。

然而,“报名当时的活动标题”本来就是历史事实,应该保留当时的值。它与活动当前标题不同,不一定是错误。建模前应先说清楚字段的时间含义。

两种标题同时存在

保存为 data_models.py,预测组织者改名后哪一列会变化。sqlite3.connect(":memory:") 建立只在本次运行中存在的数据库;executescript 运行完整 SQL 脚本。

import sqlite3

db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE organizer(id TEXT PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE activity(id TEXT PRIMARY KEY, organizer_id TEXT, title TEXT);
CREATE TABLE registration(id TEXT PRIMARY KEY, activity_id TEXT, title_at_booking TEXT);
INSERT INTO organizer VALUES ('o1', '北城读书会');
INSERT INTO activity VALUES ('a1', 'o1', '数据库夜谈');
INSERT INTO registration VALUES ('r1', 'a1', '数据库夜谈');
UPDATE organizer SET name = '北城技术社' WHERE id = 'o1';
UPDATE activity SET title = '数据系统夜谈' WHERE id = 'a1';
""")
row = db.execute("""
SELECT o.name, a.title, r.title_at_booking
FROM registration r
JOIN activity a ON r.activity_id = a.id
JOIN organizer o ON a.organizer_id = o.id
""").fetchone()
assert row == ('北城技术社', '数据系统夜谈', '数据库夜谈')
print(row)
db.close()
uv run --python 3.12 --no-project data_models.py

预期输出:

('北城技术社', '数据系统夜谈', '数据库夜谈')

两个 JOIN 用 ID 连接,而不是用可能变化的名字连接。历史标题没有被更新,因为它描述过去。这是刻意保存的快照,不应在后台“修复”为新标题。本例暂不加入外键约束,重点是表示方式;真实模型还应约束引用有效性。

引导修改:① 再插入一条同活动报名;② 将 fetchone() 改为 fetchall();③ 应得到两行,各自历史标题由插入值决定,而当前组织者名称相同。

什么时候嵌入

活动的地址通常作为整体读取、更新且大小有界,可以作为嵌入对象。把所有报名嵌入活动文档,则会让热门活动形成不断增长、多人同时修改的大对象。文档没有显式模式并不代表没有结构约定,读取它的代码仍在假设字段类型和含义。

分析系统另有模型:一条报名事实连接活动、日期、城市等维度,形成星型结构;维度进一步拆表就是雪花结构。这样的表示便于按多个维度分组,不意味着在线写入也要采用同一结构。我们在第 7、20 课分别处理读取成本与分析计算。

独立练习:订单需要显示“当时的活动地址”,活动页面则显示最新地址。设计字段及更新规则,说明为什么仅保存 activity_id 不够。

参考模型

活动保存 current_address;报名保存 activity_id 和 address_at_booking。报名创建时复制地址,后续普通地址编辑不改历史快照。如果活动搬迁需要通知报名者,这是另一项业务动作,应记录通知和变更含义,不能悄悄重写历史。检查条件:活动地址改为 B 后,活动页为 B、报名凭证仍为 A;明确确认搬迁的展示可以另外标注 B。

连接结果为零行时,先检查两个 ID 是否匹配。结果倍增时,检查是否把多对多关系误认为一对一,不能靠 DISTINCT 掩盖建模错误。在 design.md 添加三张表、主键,以及至少一个明确标成历史快照的字段。

参考资料