Ch.3: 同一份数据三种形状 — 关系表 / JSON 文档 / 属性图; 写时模式 vs 读时模式; 事件溯源把"状态"变成"日志的投影"
数据模型像整理房间的三种哲学: 关系模型是分类收纳 — 袜子、书、工具各进各柜, 找齐要跑三趟 (JOIN) 但绝不重复; 文档模型是把"常用的一整套"装进一个背包 — 出门一次拿全 (局部性), 但里面东西改一下得整包重装; 图模型是城市地图 — 每个地点连着路, 从任意点出发沿路多跳 (traversal)。而事件溯源干脆不存"房间现状", 只存流水账: 现状随时可以把账本从头"过"一遍推出来 — 这就是 CQRS 读模型永远可以推倒重建的底气。
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';
// 一份简历文档: 整树一次读全 { "name": "Ada", "jobs": [ {"company": "X", "title": "SRE"} ], "skills": ["go", "sql"] } // 局部性: 1 次读
# ORM 里一行代码, SQL 里三张表: user.orders.append(Order(sku="A9")) # 对象思维 # 实际: INSERT orders + UPDATE 关联 — 两套模型
# 错: for u in users: u.orders.all() # 1+N 次 # 对: User.objects.prefetch_related("orders") # 2 次
# 写时: INSERT 不合 schema 直接报错 # 读时: {"a":1} 与 {"a":1,"b":2} 都收, 读时解释 # 湖里读 3 年前的旧文件: reader 负责兼容
-- 规范化: 商品名只存一份 UPDATE skus SET title='机械键盘Pro' WHERE id='A9'; -- 订单里只有 sku=A9, 无需更新任何订单行
// Cypher: Ada 认识的人买过的商品 (ad:Person {name:'Ada'})-[:KNOWS*1..3]-(p:Person) -[:BOUGHT]->(item) RETURN item
WITH RECURSIVE 要 31 行 — 表达力差距是图库存在的理由, 不是"新潮"。 -- SQL 版思路: WITH RECURSIVE t AS ( -- base: SELECT ... WHERE pid = 起点 -- UNION ALL -- step: JOIN edges ... WHERE depth < 3) -- 共 31 行
# 三元组: 一切皆 (s, p, o) (ada, bought, order11) (order11, contains, a9) (a9, title, "机械键盘") # 属性也是谓语
events = [("created", []), ("add", "A9"), ("add", "B2")] state = {} for ev in events: # 状态 = 重放日志 if ev[0] == "add": state[ev[1]] = state.get(ev[1], 0) + 1
# 同一份日志, 三个投影: replay(log, "cart_view") # 购物车页 replay(log, "stats_view") # 销量报表 replay(log, "search_idx") # 搜索索引
enc = encrypt(user_123_data, key="k123") # 入日志 delete_key("k123") # GDPR 删除完成 decrypt(enc) # → 永久不可解
# 恶意查询: user{friends{friends{friends{...}}}} # 防护: depth limit + complexity score + 超时 if depth > 5: reject("query too deep")
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 … 列爆炸要控基数
商品详情页要 标题+规格+评价+库存+图片 一次出 — 关系库要 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 次点查组装
前提: 详情树是一对多且整页一起读 — 局部性红利才能兑现。
财务要"按品类 × 门店 × 月"任意切 — 文档模型预组合做不到任意性。
-- 财务对账: 任意维度组合, 关系代数的主场 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; -- 明天换成 按 周 × 品牌? 同一条模式
裁决线: 查询维度不可预知 → 关系/分析库; 读取模式固定 → 文档。
"朋友的朋友买过什么"是典型变长路径 — 图查询一步到位。
// 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 行 + 小心死循环防环
数据形状是"关系网"时, 模型表达力就是生产力。
订单列表页 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 直接报警 (呼应总纲页)。
商品列表要显示评论数 — 每次都 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 是派生数据, 丢了可重算
心法: 反规范化值 = 缓存 — 问自己"它坏了怎么发现、怎么重建"。
购物车用事件日志实现: 天然审计 (改过什么全知道)、可回放、可时间旅行。
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 先验证), 无效请求不入日志。
搜索索引与库不一致修不干净 — 干脆重放日志重建, 十分钟搞定。
# 读模型损坏/需求变更: 从日志头重建投影 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 有版本字段, 重放器认得所有历史版本。
事件日志只增不删, 用户要求删除怎么办 — 删钥匙, 不改日志。
# 写入: 每用户独立密钥, 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 必须全部过密文字段 — 明文混进日志就前功尽弃。
埋点加了个新字段, 三年前的旧文件没有它 — 读时模式天然兼容。
# 旧文件 (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 漂移要靠注册表约束 (见编码页)。
恶意构造 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 永远给图遍历加闸。
# 错: 把所有买家嵌进商品文档 # 对: orders 表 + product_id 引用
# 错: product.reviews = [10万条] # 对: reviews 集合 + product_id 索引
# 错: 循环里访问 o.user.name # 对: select_related / prefetch_related
# 错: {"uid": "abc"} 也入库 # 对: 关键字段类型必校验, 其余读时容忍
# 错: 维度套 3 层雪花 # 对: 常用维度冗余进事实宽表
# 错: 两处独立 UPDATE # 对: 同一事务 / 变更日志统一驱动
# 错: 每次二度人脉都跑递归 CTE # 对: Cypher MATCH -[:KNOWS*1..3]-
# 错: 为 GraphQL 强行迁库 # 对: resolver 挂在现有库上
# 错: log.append(rejected_order) # 对: validate(cmd) → pass 才成 fact
# 错: fold() 假设事件永远长一样 # 对: {"v":2, ...} 分版本 fold
# 错: UPDATE 读模型表修数 # 对: truncate + replay_from(0)
# 错: PII 明文进只增日志 # 对: PII 密文 + 独立 key_id
# 错: {"phone":"138..."} 直接入日志 # 对: encrypt(pii, per_user_key)
# 错: 改库存 → 整个商品文档 UPDATE # 对: 库存独立文档/行 + 引用
# 错: timeline 只回 post_id 列表 # 对: batch_get(posts, ids) 水合后返回
# 错: get_dummies(df.user_id) # 对: feature_hashing / 目标编码
# 错: 业务系统硬上 SPARQL # 对: 属性图 + Cypher, 导出 RDF 共享
# 错: event.id = LAST_INSERT_ID()+1 # 对: uuidv7 + 日志分区位序
# 错: 改 3 个文档各 save, 无事务 # 对: multi-doc transaction / saga
# 错: -[:KNOWS*]- 无限跳 # 对: -[:KNOWS*1..4]- + LIMIT