专业书籍精读 · DDIA · 第 2 章
Designing Data-Intensive Applications · Ch 2 · Martin Kleppmann · 2017
你填的一份简历、你手机里的一整张朋友圈关系网、你昨天下的那笔订单——这些数据存进数据库时,到底长什么样?DDIA(《数据密集型应用设计》)第 2 章说:这不是个技术细节,而是软件设计里最要命的一层选择。你把数据想成什么形状,就决定了你的代码好不好写、哪些问题问起来轻松、哪些问起来要命。
你以为「用哪种数据库」是攒到最后才操心的琐事。其实反过来——它一开始就悄悄决定了你写代码有多顺。同一份「用户资料」,有人存得像一个装满东西的档案袋,有人存得像一摞用编号互相勾连的表格,有人存得像一张谁认识谁的关系网。选错了,你后面每天都在跟数据库较劲。
表格派(关系型):像一叠 Excel。一行一条记录,靠「编号」互相引用——就像图书馆用书号把「书」和「谁借了它」挂上钩。规整、不重复,但要拼出完整信息得来回翻好几张表。
档案袋派(文档型):把一个用户的所有信息——姓名、每段工作经历、学历、联系方式——整袋装在一起,要用时一把全掏出来。读一份资料只需一个动作,快;坏处是袋子之间想互相牵线就笨。
关系网派(图):重点根本不在「点」,而在「点跟点怎么连」。谁关注谁、谁跟谁是同事、从这站到那站怎么换乘——专为关系密如蛛网的数据而生,社交网络是它的主场。
你代码里的数据是「一团套着一团」的(一个人套着他的好几段经历),可老式表格数据库是「摊平的一格一格」。两边对不上,只好雇个「翻译」在中间来回倒腾、拆了装、装了拆——这份别扭有个正经名字叫「阻抗不匹配」。档案袋派的出现,很大程度就是想省掉这个翻译:数据本来什么形状,就照样装进去。
拿到数据还得会问。有两种问法。命令式是你亲自站进厨房,一步步指挥:先拿这个、再翻那个、循环比对……你得操心「怎么做」。声明式(比如 SQL)是你只管点菜报菜名——「我要住在北京、姓张的人」——至于怎么找,厨房(数据库)自己看着办。好处大了去:数据库能自作主张挑最快的路子、还能多开几个灶台一起炒(并行)。所以这几十年,声明式几乎完胜。
看你的数据是什么形状、你最常问什么问题:数据像一份份自成一体的档案(很少互相牵扯)→ 档案袋派;数据什么都能跟什么扯上关系(社交、推荐、路网)→ 关系网派;不偏不倚、关系规整 → 表格派。没有最好,只有最配。(一句实话:档案袋省了拼表的麻烦,可一旦数据之间开始大量互相引用,它立马露怯,你还得自己在代码里手动补上那些「拼接」。)
数据存成什么形状(表格 / 档案袋 / 关系网),是软件设计最深的一层选择——它决定你的代码顺不顺、问题好不好问。而且问数据要学会「只报菜名,别进厨房」(声明式),把「怎么找」交给数据库自己去优化。
想进到具体模型、查询语言和示意图? → 切到精读版
数据模型(data model)是软件设计里最深的一层抽象——它不只决定数据怎么存,更决定你能自然表达什么、什么问题好回答、代码好不好写。本章把三大数据模型摆上台面——关系(relational)、文档(document)、图(graph),讲清各自擅长的数据「形状」与彼此的取舍;再论证声明式查询(declarative,如 SQL)为何在可优化、可并行上碾压命令式查询。
JSON / XML;文档数据库(MongoDB、Couchbase)一份文档整存整取。region_id 代替反复写「北京市」),改一处即全局生效;反面是反规范化(denormalization)——为读得快而故意存冗余。本章是全书 Part I「数据系统的基石」的第 2 章。上承第 1 章立下的三把尺子(可靠 / 可扩展 / 可维护),下启第 3 章「存储与检索」——本章谈的是数据在应用眼里该是什么形状(逻辑模型),第 3 章谈同样这些数据落到磁盘上怎么存怎么找(物理实现)。对应到现实,本章就是在回答那个最常见的选型问题:我该用 PostgreSQL、MongoDB 还是 Neo4j?
几乎所有应用都要在两个世界之间搬运数据:一边是应用代码里的对象(嵌套的、有引用的、活的),一边是数据库里的持久结构。用什么模型来装这些数据,直接决定了这趟搬运顺不顺、哪些问题问得动。关系模型统治了大约 30 年(从 1970 年代到 2000 年代),可 2010 年前后 NoSQL 浪潮兴起,喊出「关系模型太死板、扩不动、schema 改起来痛」。于是问题变成:关系、文档、图,到底各自适合什么?选错了会怎样?——选错,你的代码里要么塞满别扭的手工拼装,要么为了一个简单查询绕上一大圈。这一章就是给你一套「按数据形状选模型」的判断力。
面向对象的代码里,一条「用户简历」天然是一棵树:一个人 → 多段工作经历 → 每段又有起止时间、公司;还挂着教育、联系方式。可关系模型只有平铺的行和列。要把这棵树塞进表,就得拆成好几张表、用外键串起来,读的时候再 join 回来——应用对象与关系表之间这种结构性别扭,就叫阻抗不匹配(impedance mismatch),业界为此发明了一大批 ORM 框架当翻译层。
文档模型的第一个卖点,就是消掉这层翻译:简历本就是一份自包含、带层级的记录,那就原样存成一份 JSON 文档。一对多的嵌套(一个人的多段经历)在文档里是天然的树结构,而且整份文档物理上连续存放——读一份简历只要一次读取,不必 join。DDIA 把这叫局部性(locality):需要的东西都在一块儿。
user_id 外键 join 回来。文档模型对一对多(一个人多段经历)如鱼得水。真正的分水岭在另外两种关系。看一个细节:简历里的「所在地区」「行业」,为什么专业做法是存一个 region_id、而不是直接写字符串「北京市」?因为用 ID 引用是规范化:地区名只存一份,改名(「上海市」→「上海」)时改一处即可、不会出现十种拼法、还能做统一的下拉选择和本地化。可一旦这么做,「地区 → 用很多人」就成了多对一关系——而多对一、多对多正是文档模型的软肋:文档数据库对 join 的支持很弱,你要么把关联数据冗余进每份文档(更新时噩梦),要么在应用代码里手写 join(把多次查询的结果自己拼起来)。
把场景再推一步:给简历加上「推荐信」,而推荐人本身也是平台用户、他的头像和当前头衔要实时显示——这就是多对多(人 ↔ 人)。这类「万物互连」的数据,文档模型会越用越拧巴。这也解释了为什么社交、推荐这类应用最后往往滑向图模型。
DDIA 特意翻了旧账,因为它照见今天。1970 年代 IBM 的 IMS 用层次模型(hierarchical model)——数据就是一棵大树,和今天的 JSON 文档惊人地像,也同样对多对多无能为力。当时的对手网状模型(CODASYL)用手动维护的指针连接记录,查询要程序员亲手沿着预设的「访问路径(access path)」一步步爬,改个查询就得重写一片代码,人称「指针的丛林」。关系模型(Codd, 1970)真正的胜利,是把访问路径交给「查询优化器(query optimizer)」自动决定——你只描述要什么,数据库自己找路。所以本章有个深刻提醒:文档数据库在「嵌套树、弱 join」这点上其实是层次模型的重生,历史不是直线,是螺旋。
这是本章第二条主线,也是 SQL 的灵魂。命令式(imperative)查询要你写清每一步:遍历列表、逐条比对、满足条件就塞进结果——你操心的是「怎么一步步算出来」,访问路径写死在代码里(老式 IMS/CODASYL 正是如此)。声明式(declarative,SQL)只让你描述「我要满足什么条件的结果、按什么排序」,至于用哪个索引、先过滤哪张表、要不要并行,全交给查询优化器。好处是根本性的:数据库能自由挑选(并随版本不断改进)最优路径而不惊动你的代码;且声明式不规定执行顺序,天然适合在多核 / 多机上并行——命令式的「先做这、再做那」反而把并行的手脚捆住了。DDIA 的类比很妙:网页里用 CSS(声明式)选中元素改样式,远比用 JavaScript 手动遍历 DOM(命令式)优雅、也不易错。
当多对多成为数据的主旋律——社交网络(人 ↔ 人)、网页链接、公路 / 铁路网、知识图谱——图模型最自然。它只有两样东西:顶点(vertex)=实体,边(edge)=关系,边可带标签和属性。最常见的是属性图(property graph),代表系统 Neo4j,配套的声明式查询语言 Cypher 让你直接「画」出要匹配的模式:(人)-[:LIVES_IN]->(城市)-[:IN]->(国家),一行就能问出「所有住在美国的人」,哪怕中间隔着任意多层。图的杀手锏正是任意深度的连接查询:同一个「查到 N 层关系」的问题,SQL 里得写笨重的递归公用表表达式(WITH RECURSIVE),图查询里却轻描淡写。此外还有一支三元组存储(triple-store)路线——把每条事实拆成 (主语, 谓语, 宾语)、用 SPARQL 查询,是语义网 / RDF 的底子。
本章的灵魂就是「按数据形状选模型」。先把三大模型摆在一张表里对比,再拆开两个最容易踩的维度。
表 1 · 三大数据模型:擅长什么、软肋在哪
| 关系 relational | 文档 document | 图 graph | |
|---|---|---|---|
| 数据形状 | 平铺的行 / 列,多张表 | 自包含、可嵌套的一棵树 | 顶点 + 边的网络 |
| 最擅长的关系 | 规整的多对一 / 多对多 | 一对多(嵌套)、自包含记录 | 任意的多对多、深层连接 |
| join / 关联 | 数据库原生 join,强 | 弱——常要应用层手写 join | 沿边遍历,天生为此而生 |
| schema | 写时模式(强约束) | 读时模式(灵活) | 灵活 |
| 读取局部性 | 相关数据分散,需 join | 整份一次读全(locality) | 看实现 |
| 代表系统 | PostgreSQL、MySQL | MongoDB、Couchbase | Neo4j、RDF/SPARQL |
| 查询语言 | SQL(声明式) | MongoDB 聚合管道 / MapReduce | Cypher、SPARQL(声明式) |
表 2 · 读时模式 vs 写时模式(文档 vs 关系的一大分歧)
| 读时模式 schema-on-read(文档) | 写时模式 schema-on-write(关系) | |
|---|---|---|
| 类比 | 动态类型(运行时才知结构) | 静态类型(编译期强制结构) |
| 加字段 | 直接写入新结构,老文档照旧 | 要 ALTER TABLE / 迁移脚本 |
| 优点 | 灵活、异构数据友好、演化快 | 结构有保证、字段可信、易做校验 |
| 代价 | 脏 / 乱结构留到读时才炸,隐性 bug | 改结构笨重(但现代库多为在线变更) |
| 适合 | 结构多变 / 由外部决定的数据 | 结构稳定、要强一致约束的核心数据 |
还有一对贯穿全章的取舍:规范化 vs 反规范化。规范化用 ID 引用、不留冗余,改一处全局生效,但读取要 join;反规范化把数据冗余进文档、读得飞快(locality),代价是一处修改要同步多份副本,容易不一致。这与第 1 章的「没有万能架构、只能按负载定制」一脉相承——读多写少、且总是整份取用 → 偏文档 / 反规范化;关系密而多变 → 偏关系 / 规范化;连接本身就是主业务 → 上图。
这一章是后端面试的高频考点:「文档库和关系库怎么选」「什么场景该上图数据库」「schemaless 真的没有 schema 吗」——本章给的正是标准答案的思维框架。而更重要的是,它点破了一个常被误解的事实:模型之争不是「谁取代谁」,而是「谁在收敛」。今天主流关系库都内建了 JSON 文档能力,主流文档库也补上了 join;三种模型正在互相吸收对方的长处。下面几家公司用公开可查的实践印证了本章的判断——每种模型都在真实系统里找到了自己的位置。
10 亿次读、数百万次写——用生产规模验证了「高度互连的多对多数据该用图模型」。Bronson et al.《TAO: Facebook's Distributed Data Store for the Social Graph》, USENIX ATC 2013 ↗json / jsonb 类型(jsonb 为二进制格式、可建索引、查询快),让你在一张关系表里直接塞文档——关系与文档融合的活样板。PostgreSQL 官方文档「JSON Types」↗jsonb 索引、NewSQL、原生图数据库都更成熟,文档与关系的边界比书里更模糊;但「按数据形状与查询模式选模型」「声明式优于命令式」的判断至今稳固。① 一句话:数据模型是最深的一层抽象——它决定你能自然表达什么、什么问题好回答、代码好不好写。
② 阻抗不匹配:代码里的对象是嵌套的树,关系库是平表,中间要 ORM 翻译;文档模型的头号卖点就是消掉这层翻译。
③ 文档模型擅长一对多(嵌套 + 局部性,一次读全),软肋是多对一 / 多对多(join 弱、要么冗余要么手写拼接)。
④ 历史回响:文档模型像 1970 年代层次模型的转世;关系模型真正的胜利,是把「访问路径」交给查询优化器自动决定。
⑤ 关系 vs 文档的两大分歧:schema(写时模式 vs 读时模式 ≈ 静态 vs 动态类型)与局部性(分散需 join vs 整份读全)。
⑥ 声明式(SQL)碾压命令式:只描述结果、把执行路径交给优化器,从而可自由优化、可并行;命令式把路径焊死在代码里。
⑦ 图模型为任意多对多、深层连接而生(属性图 + Cypher / 三元组 + SPARQL),社交、路网、知识图谱的主场。
⑧ 没有最好,只有最配:读多写少整份取用 → 文档;关系密而多变 → 关系;连接即业务 → 图;且三者正在互相收敛。