DDIA · 数据模型与查询语言

Ch.3: 同一份数据三种形状 — 关系表 / JSON 文档 / 属性图; 写时模式 vs 读时模式; 事件溯源把"状态"变成"日志的投影"

同一份「用户 → 订单 → 商品」的三种形状 关系模型: 规范化拆表 + ID 引用 users: id=1, name=Ada orders: id=11, user_id=1, sku=A9 id=12, user_id=1, sku=B2 skus: A9 → 「机械键盘」, B2 → 「鼠标」 ✓ 强一致 · JOIN 灵活 · 多对多好办 ✗ 读取要组装 (shredding), 树形结构别扭 文档模型: JSON 自包含一棵树 { id: 1, name: "Ada", orders: [ {sku: A9, qty: 1}, {sku: B2, qty: 2} ] } ✓ 局部性好: 一次读全树 · schema-on-read ✗ 多对多要靠 ID 引用回表, 冗余会分叉 适合: 一对多树 (简历/商品详情/博客) 属性图: 顶点 + 边 双向遍历 Ada 订单11 键盘A9 同好群 bought contains member_of ✓ 变长路径一跳一跳走 · schema 自由 适合: 社交/推荐/欺诈环 事件溯源 + CQRS: 状态只是日志的投影 command 命令 「下单」先验证 有效才 fact 事件日志 append-only 唯一事实 projection 读模型视图 log: [Created{cart:[]}] → [ItemAdded{sku:A9}] → [ItemAdded{sku:B2}] replay → 状态 {A9:1, B2:2} ← 状态 = 按序重放事件 投影坏了? 删掉, 从日志头重放一遍即恢复 — 读模型永远可重建 删除难题: 日志只增不删 → crypto-shredding 删用户密钥即毁其数据 CQRS: 写侧一种表示 (日志), 读侧派生多个优化视图 — 读写模型彻底分离 schema 时机与局部性 schema-on-write (写时) 写入即强制结构 — 关系库 类比静态类型: 进门先验票 ✓ 数据干净 ✓ 变更需迁移 schema-on-read (读时) 读时才解释结构 — 文档库/湖 类比动态类型: 猜着解释 ✓ 字段演进自由 ✓ 脏数据风险 局部性 locality: 一次读全 vs 拆开再装 文档: 整棵树物理相邻 → 1 次读全 (简历页一屏全出) 关系: 一棵树拆 4 表 (shredding) → 4 次读再组装回去 正解: 只改树上一小片 → 文档整树重写就亏了 (1MB 文档改 1 字段) 大文档只取一个字段 = 局部性反向翻车 — 尺寸要有节制

三种形状怎么选

  • • 一对多树 + 整块读取 → 文档模型
  • • 多对多 + 任意查询 → 关系模型
  • • 变长路径/关系网 → 属性图
  • • 形状错了, 后面全是 impedance 补丁

schema 与局部性

  • • 写时模式 = 静态类型; 读时 = 动态类型
  • • 文档局部性好, 但大文档小改动是灾难
  • • 规范化省存储保一致, 反规范化省 JOIN
  • • 反规范化 = 派生数据, 一致性要设计

事件溯源世界观

  • • append-only 日志是唯一事实源
  • • 状态 = 按序重放事件的投影
  • • 读模型坏了删掉重放即可
  • • 删除权靠 crypto-shredding 兜底

💡 一句话理解

数据模型像整理房间的三种哲学: 关系模型是分类收纳 — 袜子、书、工具各进各柜, 找齐要跑三趟 (JOIN) 但绝不重复; 文档模型是把"常用的一整套"装进一个背包 — 出门一次拿全 (局部性), 但里面东西改一下得整包重装; 图模型是城市地图 — 每个地点连着路, 从任意点出发沿路多跳 (traversal)。而事件溯源干脆不存"房间现状", 只存流水账: 现状随时可以把账本从头"过"一遍推出来 — 这就是 CQRS 读模型永远可以推倒重建的底气。

🧠 必知必会 必考 & 必会

relational model
表/行/列 + 关系代数, Codd 1970; 自包含优化器让应用不关心存取路径 — 50 年仍是主导。
CREATE TABLE orders (
  id bigint primary key, user_id bigint, sku text
);
SELECT u.name, o.sku FROM orders o JOIN users u
  ON o.user_id = u.id WHERE u.name = 'Ada';
document model
JSON 自包含文档: 一对多树天然贴合、局部性好、schema-on-read; 代价是多对多要靠 ID 引用, 冗余容易分叉。
// 一份简历文档: 整树一次读全
{ "name": "Ada",
  "jobs": [ {"company": "X", "title": "SRE"} ],
  "skills": ["go", "sql"] }   // 局部性: 1 次读
impedance mismatch
面向对象与关系表之间的转换鸿沟: 一对多在对象里是 list, 在表里要拆表; ORM 掩盖它但掩盖不了它。
# ORM 里一行代码, SQL 里三张表:
user.orders.append(Order(sku="A9"))   # 对象思维
# 实际: INSERT orders + UPDATE 关联 — 两套模型
ORM 与 N+1
取 N 行再逐行补关联 = 1+N 次查询; 单条都很快, 页面却卡死。一条 JOIN 或预加载解决。
# 错: for u in users: u.orders.all()  # 1+N 次
# 对: User.objects.prefetch_related("orders")  # 2 次
schema-on-write / read
写时强制结构类比静态类型 (关系库), 读时才解释类比动态类型 (文档库/数据湖); 选哪个取决于"谁能保证数据质量"。
# 写时: INSERT 不合 schema 直接报错
# 读时: {"a":1} 与 {"a":1,"b":2} 都收, 读时解释
# 湖里读 3 年前的旧文件: reader 负责兼容
normalization
对人有意义的信息只存一份、用无语义 ID 引用: 改一处全局生效, 写快; 代价是读取要 JOIN。反规范化反之, 本质是派生数据。
-- 规范化: 商品名只存一份
UPDATE skus SET title='机械键盘Pro' WHERE id='A9';
-- 订单里只有 sku=A9, 无需更新任何订单行
property graph 与 traversal
顶点/边都可带任意属性、可双向遍历、无 schema 约束; 变长路径 (朋友的朋友的朋友) 是图查询的核心能力。
// Cypher: Ada 认识的人买过的商品
(ad:Person {name:'Ada'})-[:KNOWS*1..3]-(p:Person)
        -[:BOUGHT]->(item) RETURN item
Cypher vs 递归 CTE
同一个变长路径查询, Cypher 4 行, SQL WITH RECURSIVE 要 31 行 — 表达力差距是图库存在的理由, 不是"新潮"。
-- SQL 版思路: WITH RECURSIVE t AS (
--   base: SELECT ... WHERE pid = 起点
--   UNION ALL
--   step: JOIN edges ... WHERE depth < 3)   -- 共 31 行
triple store / RDF
(主语,谓语,宾语) 存一切: 属性也是谓语、边也是谓语, 模型极简; 查询语言 SPARQL, Cypher 的模式匹配借鉴自它。
# 三元组: 一切皆 (s, p, o)
(ada, bought, order11)  (order11, contains, a9)
(a9, title, "机械键盘")   # 属性也是谓语
event sourcing
append-only 事件日志为唯一事实来源, 状态 = 按序重放; 天然审计、时间旅行、可重放 — 代价是读要投影、schema 演进要小心。
events = [("created", []), ("add", "A9"), ("add", "B2")]
state = {}
for ev in events:            # 状态 = 重放日志
    if ev[0] == "add": state[ev[1]] = state.get(ev[1], 0) + 1
CQRS 与 projection
命令查询职责分离: 写侧日志一种表示, 读侧派生多个专用视图 (projection); 投影可删可重放 — 它是派生数据不是事实。
# 同一份日志, 三个投影:
replay(log, "cart_view")     # 购物车页
replay(log, "stats_view")    # 销量报表
replay(log, "search_idx")    # 搜索索引
crypto-shredding
append-only 系统满足"被遗忘权"的办法: 每用户数据用独立密钥加密, 删密钥 = 数据永久不可读 — 日志本身可以一字不改。
enc = encrypt(user_123_data, key="k123")  # 入日志
delete_key("k123")            # GDPR 删除完成
decrypt(enc)                  # → 永久不可解
GraphQL 边界
名字带 graph 但可建于任何库; 前端按需取 JSON 很爽, 但必须限制递归深度/复杂度, 否则一个查询就 DoS。
# 恶意查询: user{friends{friends{friends{...}}}}
# 防护: depth limit + complexity score + 超时
if depth > 5: reject("query too deep")
DataFrame 接口
带列类型的关系式批操作 (Pandas/Spark), 命令式 wrangling 友好; one-hot 把枚举展开成 0/1 列 — 关系数据进 ML 的桥。
df = pd.read_parquet("orders.parquet")
df["day"] = df.ts.dt.date
X = pd.get_dummies(df[["brand"]])   # one-hot 编码
# brand → brand_A9, brand_B2 … 列爆炸要控基数

🏭 生产实战 real world

场景 1 · 商品详情选文档模型: 一屏数据一次读全

商品详情页要 标题+规格+评价+库存+图片 一次出 — 关系库要 5 次点查, 文档一次读全。

// product 文档: 详情页整树自包含
{ "_id": "A9",
  "title": "机械键盘 Pro",
  "specs": [{"k":"轴", "v":"红轴"}, {"k":"灯", "v":"RGB"}],
  "reviews_summary": {"count": 1823, "avg": 4.7}
}
// db.products.findOne({_id:"A9"})  → 1 次读, 页面全量数据
// 对比关系库: products+specs+reviews+images 4~5 次点查组装

前提: 详情树是一对多且整页一起读 — 局部性红利才能兑现。

场景 2 · 订单对账必须关系模型: 任意维度 JOIN

财务要"按品类 × 门店 × 月"任意切 — 文档模型预组合做不到任意性。

-- 财务对账: 任意维度组合, 关系代数的主场
SELECT p.category, s.city, sum(f.amount) AS gmv
FROM fact_sales f
JOIN dim_product p ON f.product_id = p.id
JOIN dim_store   s ON f.store_id   = s.id
WHERE f.ts >= '2026-07-01' AND f.status = 'paid'
GROUP BY 1, 2;   -- 明天换成 按 周 × 品牌? 同一条模式

裁决线: 查询维度不可预知 → 关系/分析库; 读取模式固定 → 文档。

场景 3 · 社交二度人脉: Cypher 4 行 vs 递归 SQL 31 行

"朋友的朋友买过什么"是典型变长路径 — 图查询一步到位。

// Neo4j Cypher: 1~3 跳朋友, 排除本人, 统计商品热度
MATCH (me:Person {id: $uid})
      -[:KNOWS*1..3]-(friend:Person)
      -[:BOUGHT]->(item:Product)
WHERE me <> friend
RETURN item.title, count(*) AS heat
ORDER BY heat DESC LIMIT 10;
// SQL WITH RECURSIVE 同语义: 31 行 + 小心死循环防环

数据形状是"关系网"时, 模型表达力就是生产力。

场景 4 · ORM N+1 现形记: 一页列表 201 次查询

订单列表页 200 条, 每条再查一次用户 — 单条 2ms 全绿, 页面 400ms。

# 症状: request 期间 SQL 计数 = 201
orders = Order.objects.all()[:200]
for o in orders:
    print(o.user.name)        # 每次触发一条 SQL!

# 修复: 预加载, 2 条 SQL 搞定
orders = (Order.objects
          .select_related("user")        # FK → JOIN
          .prefetch_related("items"))[:200]
# 效果: 页面 400ms → 25ms; SQL 数进回归门禁

防线: 中间件统计 queries/request > 20 直接报警 (呼应总纲页)。

场景 5 · 反规范化计数字段的正确维护姿势

商品列表要显示评论数 — 每次都 COUNT 太贵, 冗余一列; 但必须同事务维护。

-- 错: 两处各写各的, 数字悄悄分叉
UPDATE products SET review_count = review_count + 1;  -- 事务 A
INSERT INTO reviews ...;                               -- 另一个事务!

-- 对: 同一事务里原子维护 (或 CDC 增量更新)
BEGIN;
  INSERT INTO reviews(product_id, body) VALUES('A9', '好');
  UPDATE products SET review_count = review_count + 1
   WHERE id = 'A9';
COMMIT;   -- review_count 是派生数据, 丢了可重算

心法: 反规范化值 = 缓存 — 问自己"它坏了怎么发现、怎么重建"。

场景 6 · 事件溯源购物车: 状态 = 重放事件

购物车用事件日志实现: 天然审计 (改过什么全知道)、可回放、可时间旅行。

log = [
  ("cart_created", {"user": 1}),
  ("item_added",   {"sku": "A9"}),
  ("item_added",   {"sku": "A9"}),
  ("item_removed", {"sku": "B2"}),
]

def fold(state, ev):         # 纯函数: 状态转移
    t, d = ev
    if t == "item_added":    state[d["sku"]] = state.get(d["sku"], 0) + 1
    if t == "item_removed":  state.pop(d["sku"], None)
    return state

cart = {}
for ev in log: cart = fold(cart, ev)
# cart == {"A9": 2} — 任何时刻状态都可这样重算

铁律: 日志只存有效事实 (command 先验证), 无效请求不入日志。

场景 7 · CQRS 读模型重建: 投影坏了删掉重放

搜索索引与库不一致修不干净 — 干脆重放日志重建, 十分钟搞定。

# 读模型损坏/需求变更: 从日志头重建投影
def rebuild(log, sink, fold):
    sink.truncate()                      # 1) 清空读模型
    for ev in log.replay_from(0):       # 2) 从头重放
        sink.apply(fold(ev))
    sink.switch_traffic()                # 3) 双写校验后切流

rebuild(order_log, search_index, index_fold)
# 1000 万事件重放 8 分钟 — 读模型是派生的底气

前置条件: 事件 schema 有版本字段, 重放器认得所有历史版本。

场景 8 · GDPR 被遗忘权: crypto-shredding 落地

事件日志只增不删, 用户要求删除怎么办 — 删钥匙, 不改日志。

# 写入: 每用户独立密钥, PII 加密后入日志
key = kms.create_key(user_id=123)
event = {
  "type": "order_placed",
  "pii": encrypt_b64({"name": "Ada", "phone": "..."}, key),
  "key_id": key.id,            # 日志只记 key_id
}
# 删除权行使:
kms.destroy_key(key_id)       # 密钥销毁 → 历史事件永久不可解
# 日志完整性/审计性 100% 保留, 合规达成

设计前提: PII 必须全部过密文字段 — 明文混进日志就前功尽弃。

场景 9 · schema-on-read: 湖里的旧文件也能读出新字段

埋点加了个新字段, 三年前的旧文件没有它 — 读时模式天然兼容。

# 旧文件 (2024): {"event":"pv", "uid":7}
# 新文件 (2026): {"event":"pv", "uid":7, "ab":"v2"}

def read(raw):                # reader schema 负责解释
    d = json.loads(raw)
    return {
        "event": d.get("event"),
        "uid":   d.get("uid"),
        "ab":    d.get("ab", "unknown"),   # 缺字段给默认
    }
# 新旧文件统一读出 — 写入端当年不用改

代价自查: 每个读方都要健壮解释 — schema 漂移要靠注册表约束 (见编码页)。

场景 10 · GraphQL 递归深度限制: 一个查询防 DoS

恶意构造 friends 套 friends 的查询, 一发请求打爆图遍历 — 必须设深度与复杂度闸。

# 恶意: query { user(id:1){ friends{ friends{
#        friends{ friends{ friends{ id } } } } } } }

# 防护中间件: 深度 + 复杂度 + 页大小三闸
def guard(ast):
    d = max_depth(ast)
    if d > 5:          abort("too deep")
    c = complexity(ast)             # 每层 ×fan-out 估算
    if c > 10_000:     abort("too complex")
# 配套: 按用户限流 + 解析器超时 1s

教训: 递归表达力 = 递归风险, 对外 API 永远给图遍历加闸。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 多对多硬塞进文档 — 商品被无数订单引用, JSON 嵌套根本装不下. 原因: 只看了"一对多". 正解: 多对多回关系/图模型。
# 错: 把所有买家嵌进商品文档
# 对: orders 表 + product_id 引用
坑 2 · 文档无限膨胀 — 评论全嵌进商品, 文档 50MB, 改一条评论整树重写. 原因: 引用与嵌套选错. 正解: 高频子列表拆成独立集合。
# 错: product.reviews = [10万条]
# 对: reviews 集合 + product_id 索引
坑 3 · ORM 掩盖 N+1 — 代码漂亮, 页面 200 次查询. 原因: 懒加载静默触发. 正解: 预加载 + 请求级 SQL 计数。
# 错: 循环里访问 o.user.name
# 对: select_related / prefetch_related
坑 4 · schema-on-read 无校验 — 脏数据全收, 读方各自崩溃. 原因: 把"自由"当"不设防". 正解: 入口轻校验 + schema 注册表。
# 错: {"uid": "abc"} 也入库
# 对: 关键字段类型必校验, 其余读时容忍
坑 5 · 分析场景教条规范化 — 分析师写 6 层 JOIN, 谁也不想用. 原因: OLTP 习惯带进 OLAP. 正解: 数仓宽表/OBT。
# 错: 维度套 3 层雪花
# 对: 常用维度冗余进事实宽表
坑 6 · 反规范化双写分叉 — count 列与明细各写各的. 原因: 忘了它是派生数据. 正解: 同事务或 CDC 增量维护。
# 错: 两处独立 UPDATE
# 对: 同一事务 / 变更日志统一驱动
坑 7 · 图查询硬用关系库递归 — WITH RECURSIVE 31 行还容易死循环. 原因: 工具与形状错配. 正解: 变长路径重的负载上图库。
# 错: 每次二度人脉都跑递归 CTE
# 对: Cypher MATCH -[:KNOWS*1..3]-
坑 8 · 混淆 GraphQL 与图数据库 — 以为"GraphQL 所以要 Neo4j". 原因: 名字误导. 正解: GraphQL 是 API 层, 底座任意。
# 错: 为 GraphQL 强行迁库
# 对: resolver 挂在现有库上
坑 9 · 无效命令进事件日志 — 日志被"被拒的请求"污染, 重放状态错乱. 原因: 命令/事实不分. 正解: command 先验证, 只 append 事实。
# 错: log.append(rejected_order)
# 对: validate(cmd) → pass 才成 fact
坑 10 · 事件 schema 无版本 — 加字段/改语义后, 老事件重放直接崩. 原因: 投影只认最新格式. 正解: 事件带 version + 投影兼容全版本。
# 错: fold() 假设事件永远长一样
# 对: {"v":2, ...} 分版本 fold
坑 11 · 读模型当事实修修补补 — 投影错了就手工改数据, 越修越歪. 原因: 忘了它可重建. 正解: 删掉重放日志重建。
# 错: UPDATE 读模型表修数
# 对: truncate + replay_from(0)
坑 12 · append-only 没有删除策略 — 上线即违反 GDPR. 原因: 只想到审计红利. 正解: 设计期就定 crypto-shredding 方案。
# 错: PII 明文进只增日志
# 对: PII 密文 + 独立 key_id
坑 13 · 事件含明文 PII — 日志/副本/备份处处是隐私, 删无可删. 原因: 密文化没做在写入时. 正解: 写入时加密, 密钥按用户隔离。
# 错: {"phone":"138..."} 直接入日志
# 对: encrypt(pii, per_user_key)
坑 14 · 局部性反向使用 — 1MB 文档只改一个字段也整树重写. 原因: 只记了"一次读全". 正解: 大文档只读时用局部性; 高频小改走引用。
# 错: 改库存 → 整个商品文档 UPDATE
# 对: 库存独立文档/行 + 引用
坑 15 · 时间线只存 ID 忘了水合 — 读取时没补全正文, 前端全是占位. 原因: 漏了 hydration 步骤. 正解: 读路径明确"ID→内容"应用层 join。
# 错: timeline 只回 post_id 列表
# 对: batch_get(posts, ids) 水合后返回
坑 16 · 高基数 one-hot 爆列 — user_id 直接展开 100 万列. 原因: 编码无脑套. 正解: 高基数用 embedding/分桶/hashing。
# 错: get_dummies(df.user_id)
# 对: feature_hashing / 目标编码
坑 17 · 三元组库当万能图 — RDF 灵活但工具链与运维成本高. 原因: 标准"看起来正规". 正解: 内部系统属性图优先, RDF 留给知识交换。
# 错: 业务系统硬上 SPARQL
# 对: 属性图 + Cypher, 导出 RDF 共享
坑 18 · 事件 ID 用自增 — 多写入者必然撞号, 重放顺序错乱. 原因: 单机思维. 正解: 全局唯一 UUID + 因果时间戳/全序广播定序。
# 错: event.id = LAST_INSERT_ID()+1
# 对: uuidv7 + 日志分区位序
坑 19 · 文档库跨文档"事务"靠祈祷 — 老版本无多文档原子性, 中途宕机留半截. 原因: 假设"都支持 ACID". 正解: 单文档原子内聚, 跨文档用事务 API 或事件补偿。
# 错: 改 3 个文档各 save, 无事务
# 对: multi-doc transaction / saga
坑 20 · 图遍历无界 — 变长路径不限深度, 一个查询把图引擎拖死. 原因: 路径没上限. 正解: 路径长度上限 + 超时 + 结果截断。
# 错: -[:KNOWS*]-   无限跳
# 对: -[:KNOWS*1..4]- + LIMIT