skip to content
Running Otter

精读 DDIA(三):数据模型与查询语言

/ 33 min read

DDIA 封面

The limits of my language mean the limits of my world.
——Wittgenstein

DDIA 第三章开篇引了维特根斯坦这句话。第一遍读觉得是文学修辞,读完整章再回头看,才明白这是全章的中心思想:你选什么数据模型,决定你能看见什么样的世界、能问出什么样的问题。

这一章覆盖了关系、文档、图、事件溯源、DataFrame 五种模型。但读成「五种模型的对比手册」就漏了真正的主线——一个更根本的问题:

当你面对真实世界,要把它装进数据库,你怎么选?

按这个主线重走一遍这一章,把读的过程里卡住、想清楚的几个点串进去。


一、先搞懂一件事:数据库里的「层」是什么

作者一上来不讲模型,先讲层次。这是后面所有讨论的地基。

一个数据应用,从你写的代码到磁盘上的字节,中间有四层抽象:

是什么谁定义
4. 硬件层电信号、磁场、光脉冲物理学
3. 存储引擎层数据怎么落到磁盘和内存数据库工程师
2. 数据库模型层数据被组织成什么形状数据库设计者
1. 应用层你代码里的对象、API、概念

第三章讲的就是第 2 层。关系、文档、图、事件日志、DataFrame——五种数据模型,全部活在第 2 层。

每一层「隐藏」下面那一层的复杂度,让上层可以独立工作。这是好抽象的标志——硬件工程师不用知道你在写 SQL,你也不用知道磁头在哪个磁道。

第 2 层和第 3 层不是一一对应

读到这里我也跟着惯性想「关系模型 = MySQL B-Tree 引擎」,其实没这么干净:

  • 关系模型可以建在 B-Tree(MySQL InnoDB)、LSM-Tree(TiDB)、列式存储(ClickHouse)上
  • 文档模型在 MongoDB 是一种引擎,在 PostgreSQL JSONB 又是另一种
  • 图模型可以建在专用图数据库上,也可以用关系数据库的递归 CTE 模拟

这就是为什么 DDIA 把「模型」和「引擎」分成两章——它们是两个独立的设计决策。第三章问「你怎么组织数据」,第四章才问「数据库怎么把它落到磁盘」。

那第 1 层呢

第 4 层物理学决定,第 3 层选项不多,第 2 层几种主流模型。只有第 1 层完全是你的选择,而且决定了上面所有层的形态。

这是这一章最深的那个问题,留到最后再回来讲。


二、声明式查询:为什么你应该爱 SQL

模型还没正式登场,作者先插了一节讲查询语言。看起来跑题,是全章的暗线。

主流的查询语言——SQL、Cypher、SPARQL、Datalog——都是声明式的。

声明式 vs 命令式的区别:

  • 声明式:你说「我想要什么」。比如 SQL:SELECT name FROM users WHERE age > 18。怎么找?用哪个索引?join 顺序?并行不并行?数据库自己决定。
  • 命令式:你说「按什么顺序做什么」。比如 Python:for user in users: if user.age > 18: print(user.name)。每一步都你写。

声明式的简洁只是表象,真正的好处是优化空间。

数据库可以自己选索引、自己重排 join 顺序、自己决定并行执行、自己根据统计信息优化——而你一行查询都不用改

这是过去 50 年关系数据库一直在收的复利:数据库引擎升级,你的查询自动变快。命令式代码享受不到——你写死了「先做 A 再做 B」,数据库再聪明也没用,执行顺序你已经替它决定了。

一个工业界的反例

声明式不是没有代价。

做 Spark 调优时我经常被反向打脸。Spark Catalyst 是个声明式优化器,但数据严重倾斜时,执行计划会自己飞掉——一个 task 跑两小时,其他 task 早就结束了。

代价就在这里:优化器看不见的事情,你也看不见。这时候你开始怀念命令式——能直接控制 partition 数、能手工 broadcast 小表、能强制走某种 join 算法。

声明式更好,前提是优化器对你的数据形态有合理的统计假设。这个前提一旦破掉,就要降级到命令式。Spark 同时提供 SQL 和 RDD,正是这个原因——后面 Spark 那一节会再展开。


三、最经典的对决:关系模型 vs 文档模型

作者花了大半章讲这两种模型。我用一个具体场景串起来:怎么存一份 LinkedIn 简历?

场景:Obama 的简历

要存的信息:

  • 姓名:Barack Obama
  • 工作经历:总统(美国)、参议员(美国参议院)
  • 教育经历:哈佛大学(1988-1991)、哥伦比亚大学(1981-1983)
  • 联系方式:网站、X

留意一个事实:一个人有多段工作经历,一个工作经历只属于一个人。

1. 一对多 vs 多对一:同一根线,两个方向

我第一次读也被这两个词绕晕,后来发现它们是同一个关系从两端看。

用户 ─────── 工作经历
  • 从用户这端看:一个用户 → 多个工作经历,是一对多
  • 从工作经历这端看:多个工作经历 → 一个用户,是多对一

同一根线,看的方向不同。但分开讲是有理由的——它们的建模痛点不同:

  • 一对多(一个父 → 多个子):文档模型擅长,直接嵌套数组
  • 多对一(多个对象 → 同一个被引用对象):关系模型擅长,用外键 + join

2. 方案 A:关系模型怎么存

拆成多张表,用外键串起来:

users 表
user_id | first_name | last_name | headline
251 | Barack | Obama | Former President
positions 表(一行 = 一段工作经历)
user_id | job_title | organization
251 | President | USA
251 | US Senator | US Senate
education 表(一行 = 一段教育经历)
user_id | school | start | end
251 | Harvard University | 1988 | 1991
251 | Columbia University | 1981 | 1983

存一份简历写 3 张表,读一份简历 join 3 张表。

3. 方案 B:文档模型怎么存

一份 JSON 搞定:

{
"user_id": 251,
"first_name": "Barack",
"last_name": "Obama",
"headline": "Former President",
"positions": [
{"job_title": "President", "organization": "USA"},
{"job_title": "US Senator", "organization": "US Senate"}
],
"education": [
{"school": "Harvard", "start": 1988, "end": 1991},
{"school": "Columbia", "start": 1981, "end": 1983}
]
}

一份简历就是一个文档,读取没有 join。

4. 阻抗失配:这个词到底什么意思

阻抗失配(impedance mismatch)翻译成中文有点玄乎,意思其实非常具体:你应用代码里的对象,和数据库里存的形式,长得不一样

应用代码里的简历是这样的(伪代码):

class User:
user_id: int
first_name: str
positions: List[Position] # ← 列表
education: List[Education] # ← 列表

一个 User 对象直接持有一个 Position 列表,是一棵嵌套的树。

对比两种存储和这棵树的形状:

  • JSON 文档:形状一致。user.positions 在内存是列表,在 JSON 是数组。直接 dump 就能存,直接 load 就能用。
  • 关系表:形状对不上。内存里 user.positions 是列表,数据库里却是另一张表通过外键关联回来。存要拆三张表,读要 join 三张表再拼回对象。

这个「拆开 → 拼回」的过程就是阻抗失配。不是 bug,是模型不一致带来的必然摩擦。

这个词来自电子电路:两个电路阻抗不匹配,信号会反射、损耗。借到软件,意思是对象世界和关系表世界阻抗对不上,中间这层翻译永远有损耗。

5. ORM 是干什么的(以及它为什么不完美)

ORM(Hibernate、SQLAlchemy 这些)就是这层翻译的自动化工具。你定义 User 类,ORM 帮你拆表写入、join 读取、拼回对象。

听起来美好,但有个著名的坑——N+1 查询。

假设你要展示 100 个用户的简历列表:

  • 第 1 次查询:SELECT * FROM users LIMIT 100,拿回 100 条用户
  • 渲染每个用户时,ORM 发现 user.positions 没加载,自动发查询
  • 第 2 次:SELECT * FROM positions WHERE user_id = 1
  • 第 3 次:SELECT * FROM positions WHERE user_id = 2
  • 第 101 次:SELECT * FROM positions WHERE user_id = 100

100 个用户,101 次数据库往返。直接写 SQL 你会一次 join 完;ORM「帮你封装」的代价,是替你做了一个糟糕的决策。

这就是阻抗失配的具体痛感:你以为在操作对象,实际在操作数据库,两边的成本模型对不上。

6. 那是不是文档模型就更好

文档模型在多对一上有大问题。

假设 1000 个用户都住在 “Greater Seattle Area”。

天真做法:每个用户文档里直接写字符串 "region": "Greater Seattle Area"

  • 西雅图改名,要扫 1000 份文档去改
  • 想统计每个地区的用户数,group by 一个字符串字段——拼写不一致就完蛋
  • 想存西雅图的元数据(经纬度、人口、所属州),没地方放

正确做法:存一张 regions 表,文档里只放 "region_id": 91

regions 表
region_id | name | state | population
91 | Greater Seattle Area | WA | 4_000_000

但这就需要 join 了——文档模型不擅长 join。MongoDB 后来加了 $lookup,用起来比 SQL 别扭得多。

一个粗糙但好用的判断口诀:

  • 数据是树(一对多为主) → 用文档,匹配应用对象,失配最小
  • 数据是网(多对多为主) → 用关系,join 自如
  • 多个对象指向同一个东西(多对一) → 关系,因为需要规范化

7. 数仓里的范式选择

数仓的建模直接对应大数据日常面对的问题。三种主流 schema:

  • 星型模型:中心是事实表(fact_sales,记录「发生了什么」),四周是维度表(dim_productdim_storedim_date,记录上下文)。事实表用外键引用维度表。
  • 雪花模型:维度表进一步规范化。比如 dim_product 不直接存品牌字符串,而是引用 dim_brand。规范化更彻底,但 join 更深。
  • OBT(One Big Table):把维度合并进事实表,一张大宽表。多花存储,换查询速度。

作者点出关键一句:数仓里反规范化的代价不大,因为数据基本不更新。OLTP 反规范化最怕「更新一致性」——同一份数据存多份,改的时候要全部改对。但数仓里数据是历史日志,基本不改,不用担心问题。

我做的电信数仓 ADS 层就是 OBT 路线:把 dim_user、dim_region、dim_package 全合进事实表,一张表 100+ 列。每次跑 ADS 要扫几亿行,但 ClickHouse 列存 + 压缩,实际扫的字节量小得多,查询毫秒返回。

Kimball 派会说 OBT 不可维护——维度变化时整张宽表要重刷。我们的对策是 ADS 不手写,从 DWD/DWS 按调度自动重建。这其实预告了下面「事件溯源」那节的思路。

8. Schema-on-write vs Schema-on-read

文档数据库常被叫做 “schemaless”,作者说这个词不准:没有 schema 的代码不存在。读数据时你心里一定假设了某种结构,只是这个 schema 没被数据库强制。

更准的对应是:

  • schema-on-write(关系库):写入时强制 schema,改 schema 要做 migration(ALTER TABLE)。类似静态类型语言。
  • schema-on-read(文档库):写入随便,读取时由应用代码处理可能的多种结构。类似动态类型。

一个生活类比

想象在公司报销:

  • Schema-on-write:提交报销单时,系统强制你填发票号、金额、日期、项目。漏一个,提交不了。错误在写入时被拦。
  • Schema-on-read:你随便交一张照片、一段文字、一个 PDF。等财务审核时才检查「这能算报销吗」。

写入时拦截 vs 读取时处理,各有适用场景。

这是公认的区分吗

大数据圈和学术界这是公认术语,Hadoop 圈尤其常用,Martin Fowler 这种业内权威也用。后端应用开发圈则没这么流行——他们用更具体的说法。

也有人觉得这个二分太简化:文档库现在也能加 schema 校验,关系库的 JSONB 列本身就是 schema-on-read。更准确的说法是 schema 在哪一层被强制——数据库层,还是应用层。作为入门心智模型,这个二分仍然好用。

什么时候用哪个

  • 数据基本同质、字段稳定 → schema-on-write,数据库帮你守门
  • 数据来自外部系统,字段经常变 → schema-on-read,代码自己处理

我们 ODS 层就是经典 schema-on-read。上游来自电信侧十几个系统,字段经常加、改名、改类型。如果在 ODS 强制 schema,每次上游一动我们就要发 DDL,根本来不及。实际做法是 ODS 用文本/半结构化方式落,DWD 才做「schema 显化」——抽字段、强类型、加约束。用分层把两种 schema 哲学合在一个仓库里。

9. 关系和文档在收敛

最后一节作者点了一个有趣趋势:

  • 主流关系库(PostgreSQL、MySQL)都加了 JSON 类型和 JSON 索引
  • 主流文档库(MongoDB)都加了 join、二级索引、声明式查询语言

最妙的是,作者翻出 Codd 1970 年的原始论文,指出 Codd 当年就提了 “nonsimple domains”——一个字段的值可以是嵌套关系。30 年后 SQL 才把 JSON 加回来。历史不是直线,是螺旋


四、当关系也搞不定的时候:图模型

如果数据的主导关系是多对多,且关系本身是查询的主体,关系和文档都会显得别扭。这时图模型登场。

图由两类对象组成:

  • 顶点(vertex):人、网页、地点
  • 边(edge):关注、链接、位于、是朋友

最重要的洞察是图的顶点不必同质。Facebook 用一张图同时存人、地点、活动、评论、签到——边定义了它们之间所有的关系。这种异质性,关系模型很难优雅表达。

为什么图查询不适合 SQL

核心难点是变长路径遍历。

查询:「找所有出生在美国、现住欧洲的人」。出生地可能直接是国家,也可能是 城市 → 州 → 国家 多级嵌套。遍历深度是变化的。

Cypher 写法核心就一行:

(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (:Location {name:'United States'})

*0.. 是关键——「沿着 WITHIN 边走零次或多次」。

SQL 写法用递归 CTE(WITH RECURSIVE)。书里完整列出来,比 Cypher 长七倍,要嵌套四个 CTE,看好一会儿才能看明白

这就是图查询语言的优势:遍历深度可变时,声明式图语言一行能说清楚的事,SQL 要写一坨。

我们做电信侧客户关系网络分析时遇到过。客户 A 通话 B、B 又通话 C,要查「两跳之内的关联客户」。我们用 Hive 自关联两次硬做,SQL 80 行,跑得还慢。同事推荐过 Neo4j,但整个数仓栈在 Hadoop 上,迁出去成本太高,没用。

图模型在数据天然成图、查询深度可变时,优势是数量级的;但生态绑定经常让你用不上。


五、写和读分家:事件溯源与 CQRS

这一节是这一章里我最喜欢的一节,也最贴近做实时数据栈的人。

一个反直觉的提问

前面所有模型——关系、文档、图——都有一个隐含假设:写入的形态和读取的形态是同一个。你存表就读表,存文档就读文档。

作者抛了个反问:

如果只追求写得快、表达力强,完全不考虑查询效率,你会怎么建模?

答案是事件日志。每次状态变化追加一条不可变的事件,永远不更新、不删除,只追加。

然后从这条日志派生出针对查询优化的「物化视图」——需要什么形态就派生什么形态。需要关系视图就派生关系视图,需要图视图就派生图视图,需要缓存就派生缓存。

这就是 Event Sourcing(用事件作为唯一真相源)+ CQRS(写入和读取走不同的模型)。

为什么这个思路这么强

作者列了七条优势,我觉得最关键的三条:

  1. 意图清晰:事件名(用过去时,如 “BookingCancelled”)比「某行某列被改成 false」更能传达发生了什么。
  2. 可重建:物化视图可以删掉重建。代码有 bug?改完代码,重放事件,视图自动正确。这是其他模型给不了的能力
  3. 多视图:同一份事件流可以派生关系视图、文档视图、缓存视图——每个视图针对一类查询优化。

代价

也有真问题:

  • 外部副作用:事件包含汇率、价格,重放时再调外部接口拿汇率会拿到不同值——破坏「重放一致」。要么把外部信息内嵌到事件里,要么提供「按时间点查询」。
  • GDPR 删除:事件不可变 vs 用户有权要求删除个人数据,直接冲突。
  • 重放的副作用:重新构建视图时不希望再发一遍确认邮件。

这一节让我重新理解了自己的工作。

我们的 Flink + Kafka 实时栈本质就是 Event Sourcing。Kafka 是事件日志,Flink 在上面跑各种物化视图——一个写 ClickHouse 给即时查询,一个写 Hive 做 T+1 报表,一个写 Redis 给在线推荐。同一份事件,三种视图,各自优化。

理解了这个分工才明白:Lambda 架构的「实时层」和「批量层」长得不一样但能融合——它们是同一个事件流的两种物化。


六、第五种模型:DataFrame 和矩阵

最后一节作者讲的模型 OLTP 世界很少用,但分析和 ML 世界主导:DataFrame。

DataFrame 一眼看上去像关系表(Pandas、Spark DataFrame、R DataFrame),都支持过滤、分组、聚合、join。但有两个本质差异:

  1. 命令式而非声明式:数据科学家通过一连串「操作」逐步整形数据,而不是写一个一次性 SQL。这匹配数据探索的工作流——你不知道最终要查什么,边走边调。
  2. 超越关系:DataFrame 经常被用来把数据变形成矩阵或多维数组,因为 ML 算法的输入需要矩阵。一个 user × movie 的评分矩阵——稀疏,可能上千列,关系数据库存不下,DataFrame 和 NumPy 能轻松处理。

七、把书和工具对照起来:Spark 在哪里

Spark 一会儿 SQL、一会儿 DataFrame、一会儿 Streaming、一会儿 MLlib——这些到底对应 DDIA 讲的哪些模型?我自己也是绕了一圈才理清楚。

把两个维度分开看:

  • 数据模型(第三章):数据被组织成什么形状
  • 操作风格:你用什么方式去操作数据

是两个独立的维度。

Spark 接口数据模型操作风格
Spark SQL关系声明式
DataFrame API关系命令式链式(底层仍声明式)
Dataset API关系 + 类型化对象命令式 + 强类型
RDD通用集合真正的命令式
Structured Streaming关系 + 事件流声明式
MLlib矩阵/数组命令式
GraphX / GraphFrames命令式遍历

一个引擎,横跨了 DDIA 第三章里几乎所有的数据模型。

一个最容易混淆的对比:Spark SQL vs DataFrame API

# Spark SQL
spark.sql("SELECT user_id, COUNT(*) FROM events WHERE country='CN' GROUP BY user_id")
# DataFrame API
df.filter(col("country") == "CN").groupBy("user_id").count()

底层编译成同一个 Catalyst 执行计划,性能完全一样。这俩不是数据模型的差别,只是写法风格——一个像写 SQL,一个像写代码。两个都是声明式,说的都是「做什么」而不是「怎么做」。

RDD 是真的不一样

RDD 是真命令式——你直接控制每一步转换,绕开 Catalyst 优化器。对应 DDIA 里的语境,更像「自己写算法」:失去声明式的优化红利,拿到完全的控制权。

这就是为什么 Spark 官方现在不推 RDD,除非你有特殊理由——比如优化器在你这个场景下做错了决策。回想前面讲的 Spark 数据倾斜,那种时候 RDD 反而救命。

Structured Streaming:第五节那个思路的工业化

Structured Streaming 把「流」和「表」看作同一个东西的两种视图——这正是事件溯源 + CQRS 的核心思想。一份事件流,既可以当无穷追加的表查(批查询),也可以当实时事件处理(增量查询)。

Spark 的 readStreamread API 几乎一样,就是这个哲学的体现:流即表,表即流。

一句话总结

DDIA 第三章在讨论「数据形状」;Spark 的几个接口在讨论「数据形状 + 操作风格的组合」。Spark 厉害在它把多种数据模型(关系/事件流/图/矩阵)塞进一个引擎,但这不消除模型本身的差异——用 SQL 查关系数据快,用 SQL 做矩阵乘法就难受。

工具的多样,本质是承认真实世界的复杂度本来就不能用一种抽象覆盖


八、回到那个最深的问题:第一层抽象

四层架构里,只有第一层没有标准答案:

  • 第 4 层(硬件)→ 物理学决定
  • 第 3 层(存储引擎)→ 工程权衡决定,选项有限
  • 第 2 层(数据模型)→ 几种主流,各有适用场景
  • 第 1 层(应用对象)→ 完全是你的选择,而且决定了上面所有层的形态

第一层的抽象,本质是回答两个问题:

  1. 现实世界里,我把什么东西当作「实体」?
  2. 这些实体之间,我把什么东西当作「关系」?

这两个问题的答案,直接决定第二层选什么模型。

同一个现实世界,可以有完全不同的第一层抽象

举个具体例子。一家电商公司,要存「用户买了一件商品」这件事。可以这样切:

抽象 A:把「用户」和「订单」当实体,把「购买」当关系。 → 第二层用关系模型,users 表 + orders 表 + order_items 表。

抽象 B:把「订单」当实体,用户信息和商品信息嵌进去当属性。 → 第二层用文档模型,每个订单是一个 JSON。

抽象 C:把「购买行为」当事件,实体是事件本身。 → 第二层用事件日志,Kafka topic 一直追加。

抽象 D:把「用户」和「商品」当顶点,「购买」当边。 → 第二层用图模型,跑推荐算法。

四种抽象描述的是同一件物理世界里发生的事,但它们能问出的问题完全不同:

  • A 适合问「这个用户的所有订单」,但不容易问「用户行为的时序变化」
  • B 适合问「这一单的完整信息」,但不容易问「这个用户买过什么」
  • C 适合问「过去 30 天的购买趋势」,也容易重建任何派生视图
  • D 适合问「买了 X 的人还买了什么」,但简单 OLTP 反而别扭

元问题就一句话

我把现实世界的什么东西,当作我系统里的「原子单位」?

订单是原子,还是订单里的每一行(line item)是原子?用户的当前状态是原子,还是用户的每一次状态变化是原子?一段对话是原子,还是每一条消息是原子?

这个选择一旦定下来,后面所有的模型选择、查询设计、性能权衡,都被决定了。

一个具体的例子:数仓里的粒度问题

做数仓时我反复遇到这个问题:「DWD 表的一行,代表的是现实世界中的哪一个事件?」

如果你说「代表一次通话」,粒度清晰,后面建模顺。如果你说「代表用户某天的综合行为」——你其实没在 DWD 层,已经在做 DWS 的事了。

粒度的混乱,本质是第一层抽象的混乱——没想清楚现实里的「原子单位」是什么。

好的工程师在干什么

他们大部分时间不是在写 SQL、调参数、修 bug——这些都是表象。真正在做的事是反复验证自己第一层的抽象有没有错。

  • 看到一个奇怪的数据现象 → 怀疑是不是抽象漏了什么实体
  • 看到一个新需求难做 → 怀疑是不是抽象选错了粒度
  • 看到一段代码越改越乱 → 怀疑是不是抽象和数据形状不匹配

第一层抽象一旦错了,下面三层再怎么优化都是白搭——你在用错的语言描述世界,世界不会回应你


九、收尾:一份提问清单

这一章读完,带走的不应该是定义,而是问问题的方式。

下次面对一个数据建模任务,试着问自己:

关于实体和粒度(第一层)

  • 这张表/这个文档/这个事件,代表现实世界的什么?
  • 如果改变粒度,会更顺还是更乱?

关于关系(决定第二层选什么)

  • 主导关系是一对多、多对一还是多对多?
  • 是树状,还是网状,还是事件流?

关于读写权衡

  • 这个数据是读多还是写多?
  • 一致性多重要?能不能容忍最终一致?

关于演化

  • 字段以后会不会变?变得快还是慢?
  • 这个 schema 是该数据库强制,还是应用代码守?

关于工具

  • 我手头的工具(Spark、ClickHouse、Kafka)对应到哪种数据模型?
  • 我是不是在用错的工具表达对的模型?

回到维特根斯坦那句话——语言决定世界的边界。多学一种数据模型,就是把世界的边界往外推一点。模型不是工具,是看世界的角度。