《Designing Data-Intensive Applications》(Martin Kleppmann, O’Reilly 2017) 第一部分「数据系统的基石」读书笔记。用我自己的话重新梳理每章的核心概念、关键权衡和例子。第一部分共四章,从单机视角打地基:先立起衡量系统的三把尺子,再依次深入数据模型、存储引擎、编码与演化。

本篇目录

  1. 第 1 章 · 可靠、可扩展、可维护的应用
  2. 第 2 章 · 数据模型与查询语言
  3. 第 3 章 · 存储与检索
  4. 第 4 章 · 编码与演化

第 1 章 · 可靠、可扩展、可维护的应用

第 1 章是全书的”地图”:它先定义什么叫”数据密集型应用”,再抛出贯穿全书的三个衡量标准——可靠性 (Reliability)、可扩展性 (Scalability)、可维护性 (Maintainability)。

为什么要先读这一章

后面的章节会深入到具体技术(复制、分区、事务、共识……),但如果不先建立”我到底在优化什么”的框架,很容易陷入”这个数据库比那个快”的表面比较。这一章给的就是一套判断标准:任何数据系统的设计决策,最终都可以放到”可靠 / 可扩展 / 可维护”这三把尺子下衡量。

💡 技术架构 tip:在真实工作里,工程师之间的争论(”用 MySQL 还是 Postgres”、”要不要上 Kafka”)之所以经常没有结论,往往是因为大家心里的尺子不一样——一个人在优化可靠性,另一个在优化可扩展性。先对齐”我们现在最缺哪把尺子”,讨论才有意义。

什么是”数据密集型”应用

作者把应用分成两类:

  • 计算密集型 (compute-intensive):瓶颈是 CPU 算力,比如科学计算、视频转码、大规模模拟。
  • 数据密集型 (data-intensive):瓶颈不是”算得快不快”,而是数据的量 (volume)、变化速度 (velocity)、复杂度 (complexity)。今天绝大多数业务系统(电商、社交、SaaS)都属于这一类——CPU 很少是瓶颈,真正难的是”怎么存这么多数据、怎么在数据变化时保持一致、怎么让不同数据源协同”。

一个典型的数据密集型应用,其实是由几种标准构件 (building blocks) 拼起来的:

构件 作用 你可能用过的例子
数据库 (Database) 存储数据,之后能再读出来 MySQL, PostgreSQL
缓存 (Cache) 记住昂贵操作的结果,加速读取 Redis, Memcached
搜索索引 (Search index) 支持关键词搜索 / 按条件过滤 Elasticsearch
流处理 (Stream processing) 把消息异步发给另一个进程处理 Kafka
批处理 (Batch processing) 定期处理大批累积的数据 Hadoop, Spark

💡 架构 tip:这里有个容易被忽视的洞察——“数据库”早已不是一个单一的东西。上面这些构件都在”管理数据”,只是各自擅长不同的访问模式。现代系统的真正工作,是把这些工具组合 (compose) 成满足业务需求的整体。而一旦你把多个工具拼起来,就产生了新问题:怎么保证缓存和数据库的数据一致?某个工具挂了怎么办?——这些正是后面章节要解决的,也正是”数据系统设计”这门学问存在的理由。

当你把这些构件组合起来时,你其实就成了一个新数据系统的设计者,需要对外提供正确、可靠的接口,哪怕内部某个组件出了问题。这就自然引出了三个关注点。


一、可靠性 (Reliability)

直觉定义

可靠 = 即使出了问题,系统依然继续正确工作。

“正确工作”包括:功能符合预期、性能在可接受范围、能防住未授权访问。而”出了问题”是关键——一个真正可靠的系统,不是”不出问题”,而是”出了问题也扛得住”。

关键区分:故障 (fault) vs 失效 (failure)

这两个词初学时特别容易混,但作者的区分非常重要:

  • 故障 (fault):系统中某个组件偏离了它应有的行为。比如一块硬盘坏了、某个进程崩了、网络丢包。
  • 失效 (failure):整个系统作为一个整体,不能再向用户提供服务了。

目标不是消灭故障,而是不让故障演变成失效。 我们把能做到这一点的系统叫做容错的 (fault-tolerant) 或有韧性的 (resilient)。

💡 思维方式 tip:这个区分会改变你写代码的方式。”消灭所有故障”是不可能的(硬件总会坏、人总会犯错),所以务实的工程目标是“假设故障一定会发生,设计让它被隔离、被容忍”。Netflix 的 Chaos Monkey 就是这个思路的极端体现——它会故意随机杀掉生产环境的进程,逼迫系统在平时就证明自己能容错,而不是等真出事时才发现扛不住。

作者把故障分成三大类,逐一来看。

1. 硬件故障 (Hardware faults)

硬盘会坏、内存会出错、电源会挂、网线会被拔。当你只有几台机器时这不常见,但当你有上万台机器时,“某处总有东西坏着”成了常态。

  • 传统做法:增加冗余 (redundancy)。比如硬盘做 RAID、服务器配双电源、数据中心备柴油发电机。单个组件坏了,冗余顶上,系统不失效。
  • 趋势变化:随着数据量和机器数暴涨,越来越多系统转向用软件容错来容忍整机丢失,而不是(或不只是)靠硬件冗余。好处是运维更灵活——比如可以一台台滚动重启来打补丁,而不需要整个系统停机(rolling upgrade)。

2. 软件错误 (Software errors)

硬件故障通常是随机、相互独立的(这块盘坏不影响那块盘)。但软件错误往往是系统性的 (systematic),更难对付:

  • 一个在特定输入下必崩的 bug(比如 2012 年 6 月 30 日闰秒导致大量 Linux 服务器同时卡死)。
  • 某个进程吃光了共享资源(CPU、内存、磁盘、带宽)。
  • 一个服务变慢 → 返回错误 → 拖垮依赖它的服务 → 级联失效 (cascading failure)。

这类问题的危险在于:它常常同时击中所有节点,冗余救不了你(所有副本跑的是同一份有 bug 的代码)。没有速效药,只能靠:仔细考虑假设与交互、彻底测试、进程隔离、允许崩溃重启、生产环境持续监控。

3. 人为错误 (Human errors)

一个反直觉但被反复证实的事实:大部分线上事故是人配置错误造成的,而不是硬件。人是不可靠的。怎么办?作者给了几条务实建议:

  • 设计能减少犯错机会的接口:好的抽象、清晰的 API、合理的默认值。但别做过头——限制太死,人会想办法绕过,反而更糟。
  • 解耦”最容易犯错的地方”和”最容易出事的地方”:提供一个功能齐全的沙箱 / 预发环境 (sandbox),让人用真实数据实验但不影响真实用户。
  • 充分测试:单元测试、集成测试、手工测试全都要。
  • 让”从错误中恢复”变快:能快速回滚配置、灰度发布新代码(先给一小部分用户,出问题影响面小)、提供重算数据的工具。
  • 详尽的监控 (monitoring / telemetry):性能指标、错误率。出事时监控能告诉你发生了什么。
  • 好的管理与培训。

💡 工程文化 tip:注意最后几条其实不是”技术”,而是”流程和文化”。可靠性是社会技术 (socio-technical) 问题——再好的代码,配上”改配置不用 review、发布不能回滚”的流程,照样脆弱。这也是为什么大厂如此重视可回滚和灰度发布:它们承认人会犯错,于是把重点放在”犯错后多快能恢复”。

可靠性值多少钱?

不是所有系统都需要同等可靠。给核电站写软件和给一个内部小工具写软件,投入的容错成本天差地别。可靠性有成本(冗余硬件、开发时间),要在”值不值”之间权衡——但也别轻易牺牲,因为一次数据丢失或长时间宕机的代价,往往远超你以为的。


二、可扩展性 (Scalability)

它到底在问什么

可扩展性不是一个系统”是 / 否”的标签(”这系统可扩展”是句没意义的话),而是一个问题:

当负载增长时,我们有什么应对手段?

要回答它,得先能量化描述负载和量化描述性能。

第一步:描述负载 (load parameters)

用几个数字刻画当前系统的负载,选哪些数字取决于系统架构。例如:

  • Web 服务器:每秒请求数 (requests per second)
  • 数据库:读写比例
  • 聊天室:同时在线人数
  • 缓存:命中率

案例:Twitter 的 fan-out 问题

这是全书最经典的例子,非常值得吃透。Twitter 有两个主要操作:

  1. 发推 (post tweet):用户发一条推文(平均 ~4.6k 请求/秒,峰值 12k+)。
  2. 看时间线 (home timeline):用户刷首页,看自己关注的所有人的推文(~300k 请求/秒)。

注意:读操作比写操作多了差不多两个数量级。这个”读多写少”的特征,直接决定了架构选择。有两种实现思路:

方案一:读时计算 (fan-out on read)

发推时只把推文存进一张全局推文表。用户刷时间线时,实时查询:”找出我关注的所有人 → 找出他们的所有推文 → 按时间合并排序”。

  • 优点:发推极便宜(只写一行)。
  • 缺点:读时太贵。每次刷新都要做一次大 JOIN,而读请求量巨大。Twitter 一开始用这个方案,很快扛不住。

方案二:写时推送 (fan-out on write)

给每个用户维护一个时间线缓存(像一个收件箱)。有人发推时,就主动把这条推文推送 (fan-out) 到所有粉丝的缓存里。用户刷时间线时,直接读自己的缓存,快得像查一个简单列表。

  • 优点:读时极便宜(读多写少,把成本转移到较少的写上,划算)。
  • 缺点:发推变贵。如果你有大量粉丝,一条推文要写入海量缓存。Twitter 平均每人 75 个粉丝,一条推文平均要写 75 次;但对拥有几千万粉丝的名人,一条推文可能要写入 3000 万个缓存——而且用户还期望”发出后几秒内粉丝就能看到”。

💡 架构 tip:这里的核心洞察是——“负载参数”决定了架构。正因为 Twitter 是”读远多于写”,把成本从”读”转移到”写”才是划算的。如果反过来是写多读少,方案一才对。没有绝对更好的架构,只有更匹配负载特征的架构。 这是贯穿全书的思想。

Twitter 最终的方案:混合 (hybrid)。绝大多数普通用户用方案二(写时推送);而对超级大 V,单独用方案一(粉丝刷新时实时拉取大 V 的推文,再和推送来的普通推文合并)。这样既避免了给大 V 发推时的天量写放大,又保证了普通用户的读性能。

第二步:描述性能 (performance)

负载涨了以后,问两个问题:

  1. 负载参数增加、资源不变时,性能会怎样?
  2. 负载参数增加、想保持性能不变,需要增加多少资源?

要回答就得量化性能。这里有两个概念:

  • 吞吐量 (throughput):每秒能处理多少条记录 / 请求。批处理系统关心这个。
  • 响应时间 (response time):客户端从发出请求到收到响应的总时间。在线系统关心这个。

⚠️ 注意区分响应时间 (response time) 和延迟 (latency):响应时间是客户端看到的端到端总耗时(含网络排队、处理、返回);延迟通常特指”请求在被处理前等待的那段时间”。日常口语里常混用,但严谨讨论时值得分清。

关键:不要用平均值,要用百分位数 (percentiles)

响应时间不是一个固定值,而是一个分布——同样的请求,这次 10ms,下次可能 100ms。为什么?可能是后台任务、网络抖动、GC 停顿、缓存未命中、偶尔的大请求……

平均值 (mean/average) 会骗你:它掩盖了”有多少用户其实体验很糟”。更好的工具是百分位数:

  • p50(中位数 median):一半请求比它快。”典型用户”的体验。
  • p95 / p99 / p999:95% / 99% / 99.9% 的请求比它快。这些叫尾部延迟 (tail latencies)。

💡 业务 tip:为什么亚马逊盯着 p999(千分之一的最慢请求)而不是 p50?因为响应最慢的那批用户,往往正是数据最多、最有价值的用户(买得多、账户里东西多)。而且亚马逊做过实验:响应时间每慢 100ms,销售额掉约 1%;有研究说慢 1 秒,客户满意度掉 16%。所以尾部延迟直接关系到钱。但也不会无限追求 p9999——优化最后那 0.01% 成本太高、还容易被不可控的随机因素干扰,不划算。

尾部延迟放大 (tail latency amplification):这个坑很隐蔽。假设你渲染一个页面需要并行调用 100 个后端服务,只要任何一个慢,整个页面就慢。就算每个服务只有 1% 的概率慢(p99 才慢),一个页面要调 100 次,那么”至少踩中一次慢”的概率会高得吓人——单个服务的 p99,会变成整个页面的”常态”。所以在微服务架构里,尾部延迟会被链式放大。

💡 实操 tip:算 p99 别用”先算每台机器的 p99 再平均”——那在数学上是错的,无法合并。正确做法是保留原始的响应时间直方图 (histogram),用专门算法(如 forward decay、t-digest、HdrHistogram)来合并计算。另外测量要在客户端测,不然会漏掉”请求在队列里排队”的时间——队头阻塞 (head-of-line blocking):少数慢请求会堵住后面一堆本来很快的请求。

第三步:应对负载的方法

  • 纵向扩展 (scaling up / vertical):换更强的机器(更多 CPU、内存)。简单,但有物理上限,且贵。
  • 横向扩展 (scaling out / horizontal):把负载分散到很多台便宜机器上,也叫 shared-nothing 架构(各节点不共享内存/磁盘,只通过网络协作)。
  • 现实中通常是两者混合:用几台够强的机器,往往比一大堆小机器更简单便宜。
  • 弹性 (elastic):系统能在检测到负载升高时自动加机器。适合负载高度不可预测的场景;但自动扩展也更复杂、更容易出意外,很多时候手动扩容 + 简单系统反而更稳。

💡 架构 tip:作者特别提醒——不存在通用的、放之四海皆准的”可扩展架构”(有时被戏称为 magic scaling sauce)。一个系统怎么扩,取决于它的具体负载特征:是读多还是写多?数据量大还是请求量大?延迟要求多严?能容忍多旧的数据?……先搞清楚负载长什么样(甚至只是假设未来的负载),再谈架构。 早期创业公司尤其如此:为想象中的未来负载过度设计,是常见的浪费。


三、可维护性 (Maintainability)

一个残酷的事实:软件的大部分成本不在最初的开发,而在后续持续的维护——修 bug、保持运转、排查故障、适配新平台、加新功能、还技术债。

而大多数人都讨厌维护”遗留系统 (legacy system)”。作者提出,我们应该在设计时就为可维护性投资,具体拆成三个原则:

1. 可运维性 (Operability):让运维团队日子好过

好的系统应该让运维(Ops)团队容易保持它平稳运行。具体包括:提供好的监控、支持自动化和标准工具集成、避免对单台机器的依赖(能随时下线维护)、提供好的文档和清晰的运维模型、有合理的默认行为、能自我修复但也允许人工介入……

💡 tip:一句话概括——好的运维性 = 把重复枯燥的事自动化,让运维团队能把精力放在高价值的事情上。

2. 简单性 (Simplicity):控制复杂度

小项目代码可以简单直白;但项目一大,代码往往变成一团复杂 (complex) 的烂泥,谁都不敢碰。复杂度的表现:状态空间爆炸、模块间强耦合、混乱的依赖、命名不一致、为性能加的各种 hack、到处是特判……

关键概念——偶然复杂度 (accidental complexity):指不是问题本身固有的、纯粹由实现方式引入的复杂度(Moseley & Marks 的定义)。这部分是可以、也应该被消除的。

消除它最好的工具是抽象 (abstraction):把实现细节藏在一个干净的接口后面。比如高级语言把机器码、寄存器管理藏起来;SQL 把磁盘/内存数据结构、并发访问藏起来。好的抽象既让代码更简单(能复用),也让改动更安全。

💡 写代码 tip:注意”简单”不等于”功能少”。这里说的是“降低理解系统所需的心智负担 (mental effort)”。当你为了赶进度写下一个 hack、一个特判、一个 copy-paste 时,你就在往系统里加”偶然复杂度”。它当下省了你 5 分钟,但会让未来每一个读这段代码的人(包括半年后的你自己)多花时间,还更容易埋 bug。这也呼应了一条常见的工程原则:复用优先于复制。

3. 可演化性 (Evolvability):让改变容易

需求一定会变——你学到新东西、出现新用例、业务优先级变了、用户要新功能、法规变了、系统长大了要重构架构……

能否轻松应对这些变化,就是可演化性(在数据系统语境下也常叫 agility / 敏捷)。它和前面两点紧密相关:系统越简单、抽象越好,就越容易理解、也越容易安全地修改。所以简单性和好的抽象,最终都服务于”让系统能持续演化”。


本章小结

  • 数据密集型应用的难点不在算力,而在数据的量、速度、复杂度;它们由数据库、缓存、索引、流/批处理等标准构件组合而成。
  • 衡量一个数据系统好坏,有三把尺子:
尺子 一句话 核心手段
可靠性 出了故障也不失效 区分 fault/failure;容忍硬件、软件、人为三类故障;重点是”出错后快速恢复”
可扩展性 负载涨了有招应对 先量化负载(如 Twitter fan-out)和性能(用百分位数而非平均值,盯尾部延迟);再选匹配负载的架构
可维护性 让后人(和未来的你)好过 可运维(自动化)、简单(消除偶然复杂度、善用抽象)、可演化(让改变容易)
  • 贯穿全章、也贯穿全书的核心思想:没有万能架构,只有匹配具体负载和需求的架构。 一切都是权衡 (trade-off)。

留给自己的思考题

  1. 我现在负责/接触的系统,最缺的是哪把尺子?为什么?
  2. 我最近写的代码里,有没有在制造”偶然复杂度”?
  3. 如果要量化我的系统的”负载参数”,我会选哪几个数字?
  4. 我的系统里,”人为错误”有哪些可以通过更好的接口或流程来预防?

第 2 章 · 数据模型与查询语言

数据模型可能是软件开发里最重要的抽象——它不只决定数据怎么存,更决定你怎么思考你要解决的问题。这一章比较三大数据模型(关系型、文档型、图型)和它们对应的查询语言,核心问题是:我的数据里”关系”长什么样?

为什么数据模型这么重要

每一层软件都建立在下一层的抽象之上:应用开发者用对象/数据结构表达现实 → 存储时转成 JSON/表/图 → 数据库工程师用字节表示它 → 硬件用电信号表示字节。每一层通过隐藏下层的复杂度,让不同的人能协作。 数据模型就是这些抽象里影响最深远的一层:它既限定了”能做什么”,也悄悄塑造了你”怎么想问题”。


一、关系模型 vs 文档模型

关系模型 (Relational):屹立不倒的老将

关系模型 1970 年由 Edgar Codd 提出:数据组织成关系 (relation,即表 table),每个关系是一堆元组 (tuple,即行 row) 的无序集合。它最初是为商业数据处理(交易、批处理)设计的,后来证明极其通用——网购、社交、游戏……几乎都能套上去。SQL 主导了三十多年。

NoSQL 的崛起

2010 年前后 “NoSQL” 兴起(名字其实是个失败的推特话题标签,后来被重新解释为 “Not Only SQL”)。推动力有几个:

  • 需要比关系型更好的超大规模写吞吐和横向扩展;
  • 偏好开源、免费;
  • 关系模型不擅长的一些特殊查询;
  • 对关系模型 schema 限制的不满,想要更动态、更有表达力的模型。

作者的判断:未来是混合持久化 (polyglot persistence)——关系型和各种非关系型并存,各用其所长。

对象-关系不匹配 (Impedance Mismatch)

今天大多数应用用面向对象语言写,但数据存在关系表里,两者之间需要一层笨拙的翻译层 (ORM)。对象是嵌套的、有层次的;表是扁平的、要拆成多张 + 外键。这种别扭叫阻抗不匹配 (impedance mismatch)(借自电子学的术语)。

举个例子:一份简历。一个人有一个姓名,但有多段工作经历、多个教育经历、多项联系方式——典型的一对多 (one-to-many) 关系。

  • 关系型做法:拆成 users、positions、education 等多张表,用 user_id 外键关联。查一份完整简历要 JOIN 多张表(或多次查询)。
  • 文档型做法:整份简历就是一个 JSON 文档,工作经历、教育都作为嵌套数组直接放在里面。

💡 架构 tip:对”一对多、且这些子项总是随主体一起被读取”的数据(简历、一篇博客带评论、一份订单带明细行),文档模型天然贴合——数据的树形结构和 JSON 的嵌套结构一一对应,一次读取就能拿到全部相关数据,不用 JOIN。这也带来了局部性 (locality) 优势:相关数据物理上存在一起,读取快。

但是……多对一和多对多怎么办?

现实数据里到处是多对一 (many-to-one) 和多对多 (many-to-many) 关系。比如简历里的”居住城市”和”毕业学校”:

  • 如果存成纯文本字符串,很多人填”东京”“Tokyo”“东京都”,不一致、难统计。
  • 更好的做法是做成标准化 (normalized) 的独立实体,用 ID 引用——城市表、学校表各一张,简历里只存 ID。好处:显示名统一、改名只改一处、支持本地化、方便搜索推荐。

一旦你这么做,就出现了多对一(多份简历指向同一个城市)——而文档模型对 JOIN 支持很弱。如果数据库不支持 JOIN,你就得在应用代码里手动模拟 JOIN(多次查询再拼装),既啰嗦又低效。

再往下,多对多、乃至图状关系(”谁推荐了谁”“谁认识谁”)会越来越多。这时关系模型的 JOIN 和外键就显出价值了。

到底选哪个?

作者给出的判断标准非常清晰:

你的数据关系 更适合的模型
一对多、树形、子项随主体一起读 文档模型(局部性好、schema 灵活)
大量多对一、多对多、需要 JOIN 关系模型
高度互联、关系本身是重点 图模型(下面讲)

💡 决策 tip:没有”文档 vs 关系”的普适胜者,只有”数据里关系的形态”决定的更优解。 一个残酷的现实是:如果你选了文档模型,后来发现数据的互联程度越来越高,代码会越来越难写——手动 JOIN、多次查询、维护一致性的负担会不断累积。所以选型前先问:我这些数据未来的”连接度”会怎样变化?

Schema-on-read vs Schema-on-write

  • 写时模式 (schema-on-write):关系型的做法,schema 显式定义、写入时强制校验(类似静态类型检查)。
  • 读时模式 (schema-on-read):文档型常见,数据结构隐含,读取时才解释(类似动态类型)。

读时模式在这些情况下有优势:数据结构五花八门、类型由外部系统决定、你无法控制数据格式。它让加字段更容易(老文档不用改,读到缺失字段就用默认值)。但代价是校验和一致性得靠应用代码保证。

两者在融合

现代数据库在互相靠拢:关系型(PostgreSQL、MySQL)早就支持 JSON 列和文档式操作;很多文档数据库也开始支持 JOIN 或引用。所以”关系 vs 文档”的对立在实践中越来越模糊——理解底层的权衡,比纠结产品标签更重要。


二、查询语言:声明式 vs 命令式

两种范式

  • 命令式 (imperative):一步步告诉计算机怎么做(循环、条件、变量赋值)。大多数编程语言是这样。
  • 声明式 (declarative):只描述你想要什么结果,不管怎么实现。SQL 是典型。

比如”找出所有海洋动物”:命令式要写循环遍历、逐个判断、往结果里加;声明式(SQL)只写 WHERE family = 'Sharks'。

💡 核心洞察 tip:声明式的最大价值不在于”写起来短”,而在于它把”怎么做”的自由留给了数据库。因为你没规定执行步骤,查询优化器 (query optimizer) 就可以自由决定用哪个索引、以什么顺序、怎么并行——而且底层引擎升级后,你的查询不用改就能变快。命令式代码把执行顺序写死了,天生难以自动并行化;而声明式查询因为不依赖特定执行顺序,更容易分散到多核、多机上跑。这就是为什么在”数据密集 + 多核并行”的时代,声明式重新变得重要。

MapReduce:介于两者之间

MapReduce(由 Google 推广)是一种半声明式的批处理编程模型,用于在大量机器上处理海量数据。你提供两个纯函数——map(对每条记录做转换/提取)和 reduce(把相同 key 的结果聚合)。它比纯 SQL 底层、比纯命令式受限(要求函数无副作用),这个约束正是它能大规模并行的前提。不过后来像 MongoDB 也提供了更声明式的聚合管道 (aggregation pipeline) 来替代裸写 MapReduce。


三、图模型:当”关系”本身成为主角

当多对多关系无处不在、且是数据的核心时,关系模型也开始吃力,这时图 (graph) 更自然。图由两种东西组成:

  • 顶点 (vertices / nodes):实体。比如人、地点、事件。
  • 边 (edges / relationships):连接两个顶点的关系。比如”住在”“认识”“喜欢”。

典型场景:社交网络(人与人)、Web 图(网页与超链接)、路网/铁路网(地点与路线)。图的威力在于——它能自然表达”任意长度的路径”,比如”从 A 出发,能不能通过任意多层关系到达 B”(SQL 里这种递归查询非常笨拙)。

作者介绍了两种图数据模型和它们的查询语言:

1. 属性图 (Property Graph)

每个顶点和每条边都能挂任意的键值对属性 (properties)。代表数据库:Neo4j。查询语言 Cypher(声明式),能优雅表达”沿着某种关系走任意多步”这类路径查询。作者的例子:在社交数据里查”所有从美国移居到欧洲的人”,Cypher 写起来比等价 SQL 简洁得多——因为 SQL 需要 WITH RECURSIVE 递归 CTE,非常繁琐。

2. 三元组存储 (Triple-Store) 与 RDF

把所有数据表示成 (主语, 谓语, 宾语) 的三元组,比如 (小明, 住在, 东京)。它源自语义网 (Semantic Web) 的理想。数据格式是 RDF,查询语言是 SPARQL。

3. Datalog

一种更古老、更基础的图/逻辑查询语言,用规则 (rules) 来推导。它是很多现代查询语言的理论基础,思路是:定义一组规则,让数据库据此递归地推导出所有满足条件的结果。

💡 选型 tip:图模型和文档模型某种意义上是两个极端——文档模型假设数据是自包含、少互联的树;图模型假设万物皆可互联。选图数据库的信号很明确:当你发现自己在关系库里反复写递归 JOIN、或者业务问题本身就是”找路径/找连接”(推荐、反欺诈、知识图谱、社交关系)时,图模型会让代码和查询都清爽得多。


本章小结

  • 数据模型是塑造”你怎么想问题”的顶层抽象。历史上数据从层次模型 → 关系模型 → 现在的百家争鸣。
  • 三大模型,按”数据里关系的形态”来选:
模型 适合 短板 代表
关系型 多对一、多对多、需要 JOIN 对象-关系阻抗不匹配 MySQL, PostgreSQL
文档型 一对多、树形、子项一起读 JOIN 弱、互联数据难处理 MongoDB
图型 高度互联、路径查询 学习曲线、生态较小 Neo4j (Cypher), RDF (SPARQL)
  • 查询语言:声明式(SQL、Cypher)把”怎么执行”交给数据库,从而能自动优化、自动并行;命令式把步骤写死,难并行。
  • 一以贯之的思想:先看清数据的关系形态,再选模型。而且现代数据库正在互相融合,理解底层权衡比记产品标签重要。

留给自己的思考题

  1. 我手头的项目,数据关系更接近”树”还是”网”?现在的选型合适吗?
  2. 我有没有在应用代码里手写过”模拟 JOIN”?那是不是选型不当的信号?
  3. 哪些业务问题其实是”找路径/找连接”,本可以用图模型更优雅地解决?

第 3 章 · 存储与检索

这一章钻进数据库内部,回答一个问题:数据库到底怎么在磁盘上存数据、又怎么再找回来?理解这些不是为了自己造数据库,而是为了在选型和调优时,知道某个数据库为什么在你的负载下快或慢。核心分两条线:面向事务的存储引擎(LSM-tree vs B-tree)和面向分析的列式存储。

从一个最简单的”数据库”说起

作者用两行 shell 函数演示了世界上最简单的数据库:

  • 写:把 key,value 追加 (append) 到一个文件末尾。
  • 读:从头到尾扫文件,取该 key 最后一次出现的值。

这个玩具揭示了两个深刻的道理:

  1. 写非常快——因为”追加到文件末尾”是磁盘最高效的操作(顺序写)。很多真实数据库内部也是只追加 (append-only) 的。
  2. 读非常慢——O(n) 全表扫描。要让读变快,就需要索引 (index)。

💡 核心权衡 tip:这里就藏着贯穿本章的根本矛盾——索引是”额外维护的数据结构”,它加速读,但拖慢写(每次写都要顺带更新索引)。所以没有”该不该建索引”的标准答案,只有”根据你的读写比例来权衡”。这也是数据库设计里最普遍的 trade-off。


一、面向事务的存储:两大家族

家族一:日志结构 (Log-Structured) —— LSM-Tree

起点:哈希索引 (Hash Index)

在内存里维护一个哈希表:key → 该 key 在数据文件里的字节偏移量。写入时追加数据 + 更新哈希表;读取时查哈希表拿到偏移量,一次磁盘寻址读出。Bitcask(Riak 的默认引擎)就是这么做的。

  • 限制:所有 key 必须能装进内存;范围查询(”查 key 从 1000 到 2000”)效率差。
  • 怎么防止文件无限增长:把日志切成段 (segments),后台做压缩 (compaction)——扔掉每个 key 的旧值只留最新的,再把多个段合并。

升级:SSTable 与 LSM-Tree

如果要求每个段里的 key-value 按 key 排序,这种文件就叫 SSTable (Sorted String Table)。排序带来三个好处:

  1. 合并段更高效:像归并排序 (merge sort) 一样把多个有序段合并,顺序读写,很快。
  2. 索引可以稀疏:不用给每个 key 都建索引,内存里只需记录部分 key 的位置(稀疏索引),中间的 key 扫一小段即可。
  3. 支持范围查询。

完整的 LSM-Tree (Log-Structured Merge-Tree) 就是这么工作的:

  1. 写入先进内存里的有序结构(memtable,通常是红黑树/跳表)。
  2. memtable 满了就作为一个新 SSTable 刷到磁盘(此时已排好序)。
  3. 读取时:先查 memtable → 再查最新的 SSTable → 依次往旧的查。
  4. 后台不断做压缩合并,清理旧数据。

代表:LevelDB、RocksDB、Cassandra、HBase。

💡 优化 tip:LSM 读一个”不存在的 key”要查遍所有层,很慢。业界用 布隆过滤器 (Bloom filter) 解决——一个内存里的概率性数据结构,能快速回答”这个 key 肯定不存在 / 可能存在”,从而避免大量无谓的磁盘查找。这是个非常经典、值得记住的工程技巧。

家族二:页面结构 (Page-Oriented) —— B-Tree

B-tree 是绝大多数关系数据库(以及很多非关系型)的标准索引,比 LSM 古老得多、也更主流。

它把数据库拆成固定大小的页 (page,通常 4KB),一页可以引用其他页,构成一棵树。查找从根页开始,逐层向下,直到叶子页找到值。树很”扁”(分支因子几百),几百万条记录也只需 3~4 层。

  • 关键区别:LSM 是追加、从不原地改;B-tree 是原地覆写 (update in place) 磁盘上的页。
  • 怎么保证崩溃不丢数据:改页之前先写一份 预写日志 (WAL, write-ahead log),崩溃后靠它恢复到一致状态。

LSM-Tree vs B-Tree:怎么选?

维度 LSM-Tree B-Tree
写 通常更快(顺序写,写放大较低) 较慢(原地改 + WAL,一次写落两次盘)
读 可能较慢(要查多层 + 压缩干扰) 更快、更可预测(一个 key 只在一处)
磁盘空间 压缩得更紧、碎片少 页可能有空隙
成熟度/事务 相对年轻 极成熟,事务/锁支持完善

💡 调优 tip:一个关键概念是写放大 (write amplification)——一次逻辑写,实际造成了多少次物理磁盘写。B-tree 因为要写 WAL + 覆写页,写放大较高;LSM 顺序写更省,但压缩 (compaction) 会抢占磁盘带宽,在写入高峰时可能拖慢前台读写,甚至压缩跟不上导致磁盘写满。经验法则:写密集 → 倾向 LSM;读密集、要稳定低延迟 → 倾向 B-tree。 但两者差距在缩小,别把它当铁律。

其他索引话题

  • 聚簇索引 vs 非聚簇:索引里是直接存整行数据(聚簇 clustered),还是只存指向行的引用(非聚簇)?前者读快省一次跳转,后者省空间。覆盖索引 (covering index) 是折中——索引里带上部分列,某些查询直接从索引就能答完,不用回表。
  • 多列索引 (multi-column):比如地理位置查询要同时按经纬度过滤,需要特殊的多维索引(如 R-tree)。
  • 模糊索引:全文搜索(Lucene)能处理拼写错误、近义词。
  • 内存数据库 (in-memory):数据主要放内存(Redis、Memcached)。它快的真正原因常常不是”省了读磁盘”,而是省掉了”把内存数据结构编码成适合写磁盘的格式”这一步开销。

二、面向分析的存储:OLTP vs OLAP

数据库有两种截然不同的使用场景,它们的存储需求完全相反:

  OLTP(在线事务处理) OLAP(在线分析处理)
典型操作 按 key 读写少量记录 扫描海量记录、做聚合
例子 下单、查用户资料 “上季度各门店总销售额”
读取模式 每次碰少量行、但很多行的很多列 每次碰海量行、但只需少数几列
谁在用 终端用户、面向业务 分析师、面向决策
数据规模 GB~TB TB~PB

因为两者负载冲突,公司通常把分析查询放到独立的数据仓库 (data warehouse) 上跑,通过 ETL(抽取-转换-加载)从各业务库把数据搬过来。数据仓库常用星型模型 (star schema):中心一张巨大的事实表 (fact table)(每行一个事件,如一次销售),周围环绕多张维度表 (dimension table)(商品、门店、时间、客户)。

列式存储 (Column-Oriented Storage):分析场景的杀手锏

关键观察:分析查询常常是”扫几十亿行,但只用其中 3~4 列”。而传统的行式存储把一行的所有列挨着存,你要读那 3 列,却不得不把每行全部上百列都从磁盘捞进来——极大浪费。

列式存储反过来:把每一列的所有值连续存在一起。这样只读你需要的列,I/O 大幅减少。它还带来两个叠加优势:

  1. 列压缩 (column compression):同一列的值类型相同、重复度高,压缩率极好。比如位图编码 (bitmap encoding):某列只有少数几个不同取值时(如”国家”),用位图表示,又省空间又能让 WHERE country IN (...) 这类过滤变成飞快的位运算。
  2. 向量化处理 (vectorized processing):压缩后的列数据能整块塞进 CPU 缓存,用 SIMD 指令批量处理,充分榨干 CPU。

💡 架构 tip:这是”存储布局要匹配访问模式“的完美案例。同样的数据,行存对 OLTP 友好(一次取一整行)、列存对 OLAP 友好(一次取整列做聚合)。这也解释了为什么会有专门的分析型数据库/数仓(如 ClickHouse、BigQuery、Redshift)——它们不是”更快的 MySQL”,而是为完全不同的访问模式重新设计了存储。选错了,性能可能差几个数量级。

列式存储的代价是写入较难(插一行要改所有列文件),所以常配合 LSM 思路批量写入。此外数仓还会用物化视图 (materialized view) / 数据立方体 (data cube) 预先算好常用聚合,用空间换查询速度。


本章小结

  • 一切从”append 快、随机读慢 → 所以要索引 → 但索引拖慢写”这个根本权衡展开。
  • OLTP 存储引擎两大家族:
    • LSM-Tree(日志结构、只追加、后台压缩):写快、空间省,代表 RocksDB/Cassandra。配布隆过滤器优化读。
    • B-Tree(页结构、原地覆写、WAL 保护):读稳定、事务成熟,是关系库主流。
    • 选择看读写比例;关注写放大和压缩带来的影响。
  • OLTP vs OLAP 是两种相反的负载,催生了独立的数据仓库和列式存储——通过”只读需要的列 + 列压缩 + 向量化”把分析查询提速几个数量级。
  • 贯穿本章的思想:存储布局必须匹配访问模式。没有最好的存储引擎,只有最匹配你负载的。

留给自己的思考题

  1. 我常用的数据库用的是 LSM 还是 B-tree?它的默认取舍适合我的读写比例吗?
  2. 我有没有在一个 OLTP 库上跑大范围分析查询、拖慢了线上?该不该分离出数仓?
  3. 我建的索引,是真的匹配查询模式,还是”顺手都建上”反而拖慢了写入?

第 4 章 · 编码与演化

应用会不断变化,需求会变、schema 会变、代码会升级。但升级不是一瞬间完成的——新旧代码、新旧数据格式会共存一段时间。这一章讲两件事:数据在内存对象和字节序列之间怎么编码 (encoding),以及怎么让系统在演化过程中保持新旧兼容。这是第一部分的收尾,也是通往分布式系统的桥梁。

核心问题:升级从来不是原子的

现实中的升级是渐进的:

  • 服务端:为了不停机,用滚动升级 (rolling upgrade)——一次升级一部分节点,其余照常服务。所以同一时刻,新旧版本代码在同时运行。
  • 客户端:用户可能好几天都不更新 App。

结论:新旧代码、新旧数据格式必然共存。 为了让系统在这种混乱中不出错,我们需要两个方向的兼容性:

  • 向后兼容 (backward compatibility):新代码能读旧数据。(一般不难,新代码知道旧格式长什么样。)
  • 向前兼容 (forward compatibility):旧代码能读新数据。(更难——旧代码得学会忽略它看不懂的新增内容。)

💡 记忆 tip:这两个词特别容易混。记住参照物是”代码的新旧“:向”后”看 = 新代码回头兼容老数据;向”前”看 = 老代码提前兼容将来的新数据。整章的编码格式好不好,很大程度就看它对这两个兼容性支持得如何。


一、几种编码格式

“编码”(也叫序列化 serialization / marshalling)= 把内存里的对象(有指针、有引用)转成能写文件或走网络的字节序列;反过来叫解码 (decoding / 反序列化)。

1. 语言内置的序列化(别用)

Java 的 Serializable、Python 的 pickle 等。方便,但坑很多:绑死语言(Java 序列化的东西 Python 读不了)、有安全漏洞(反序列化任意字节可被用来远程执行代码)、版本兼容差、性能烂。作者的建议很直接:除了临时用途,不要用它做持久化或跨系统通信。

2. 文本格式:JSON、XML、CSV

优点是人类可读、跨语言、生态好,所以是跨组织交换数据的通用选择。但有一堆细节问题:

  • 数字有歧义:XML/CSV 分不清数字和字符串;JSON 分不清整数和浮点,且大整数会丢精度(超过 2^53 时,很多语言的 JSON 解析会出错——Twitter 的推文 ID 就踩过这个坑,被迫同时返回数字和字符串两种)。
  • 不支持二进制(得先 Base64,膨胀 33%)。
  • schema 支持可选且复杂。

够用,但对性能和空间敏感的内部通信不理想。

3. 二进制编码:Thrift、Protocol Buffers、Avro

当数据只在你自己组织内部流转时,可以用更紧凑、更快的基于 schema 的二进制格式。

Thrift(Facebook)和 Protocol Buffers(Google) 的核心思想:

  • 你先写一个 schema (IDL) 定义字段和类型。
  • 用代码生成器生成各语言的类。
  • 编码时不存字段名,只存字段的数字标签 (field tag) + 类型 + 值——所以非常紧凑。

它们怎么实现兼容演化? 全靠字段标签号:

  • 只要标签号不变,字段改名没关系(名字不进编码)。
  • 加新字段:给它一个新标签号,并且必须是 optional(或给默认值)。旧代码读到不认识的标签号就跳过它 → 向前兼容;新代码读旧数据时新字段缺失,用默认值 → 向后兼容。
  • 删字段:只能删 optional 字段,且那个标签号永不复用。

Avro(源自 Hadoop) 走了另一条路,更精巧:

  • 编码里完全不含字段标签或字段名,只有值,所以最紧凑。
  • 靠区分写入方 schema (writer’s schema) 和读取方 schema (reader’s schema) 来对齐:解码时把两个 schema 放一起做模式解析 (schema resolution)——按名字匹配字段,写入方有而读取方没有的字段被忽略,读取方有而写入方没有的用默认值填。
  • 这个设计让 Avro 特别适合动态生成 schema 和大数据文件(一个大文件开头写一次 writer schema,后面全是紧凑的值)。

💡 工程 tip:这些二进制格式的价值不只是”更小更快”,更在于schema 本身就是一份永远最新的文档,而且能被工具校验、能安全地演化。很多团队从 JSON 迁到 Protobuf/Avro,图的正是”schema 强制 + 兼容性有保障”,而不只是省字节。选型上:需要跨很多语言、字段较稳定 → Protobuf/Thrift;大数据管道、schema 常变或动态生成 → Avro。


二、数据流动的三种方式

编码是为了让数据在进程之间流动。作者归纳了三种主要的数据流 (dataflow) 模式,每种都要考虑兼容性。

1. 通过数据库

一个进程写入数据库,(也许很久以后)另一个进程读出来。这里的关键洞察是:数据的寿命常常比代码长得多(”data outlives code”)。五年前写进库的数据,今天用新代码去读,就要求向后兼容;而如果新代码写入了新字段,旧代码去读又要求向前兼容。

⚠️ 一个隐蔽的坑:旧代码读取了含有新字段的记录、修改后又写回,可能会悄悄丢掉它看不懂的新字段。所以在数据库这种”读-改-写”场景里,保留未知字段尤其重要。

2. 通过服务:REST 与 RPC

进程通过网络同步调用:客户端发请求,服务端返响应。这是面向服务架构 (SOA) / 微服务的基础。

  • REST:一种基于 HTTP 的设计哲学(用 URL 表示资源、用 HTTP 方法表示操作),简单、通用、对外 API 的主流。
  • RPC (远程过程调用):让”调远程服务”看起来像”调本地函数”。历史上的 SOAP 很笨重;现代的 gRPC(基于 Protobuf)等则轻量高效。

💡 认知 tip:RPC 有个根本的”谎言”——它假装远程调用和本地函数调用一样,但两者本质不同:网络会超时、会丢包、会不可预测地慢,远程机器可能挂掉,请求可能到了但响应丢了(你不知道到底执行没执行)。 本地函数调用要么成功要么抛异常,干脆利落;远程调用则充满不确定性。理解这个差异,是从”写单机程序”迈向”写分布式系统”的第一课——也正是本书第二部分要展开的主题。

服务的兼容性:因为客户端和服务端会独立升级,请求/响应格式必须能演化。通常要求请求向后兼容、响应向前兼容(新老版本混跑)。

3. 通过异步消息传递

介于数据库和 RPC 之间:发送方把消息投递给一个消息中间件 (message broker)(如 Kafka、RabbitMQ),接收方之后从中取出处理。发送方不等回复(异步)。

好处很多:

  • 解耦:发送方和接收方不用同时在线,也不用知道对方地址。
  • 削峰填谷:接收方挂了或忙不过来,消息先在 broker 里排队,不会丢。
  • 一对多:一条消息可以广播给多个消费者。

还有一种相关模型是 Actor 模型(如 Akka、Erlang):把并发逻辑封装成一个个”actor”,彼此只通过发消息通信,天然适合分布式。


本章小结(也是第一部分的收尾)

  • 升级不是原子的,新旧代码/数据必然共存,所以任何长期系统都要同时保证:
    • 向后兼容(新代码读旧数据)和向前兼容(旧代码读新数据)。
  • 编码格式:
    • 语言内置序列化——方便但有安全/兼容/绑定语言的坑,别用于持久化或跨系统。
    • JSON/XML/CSV——通用可读,适合跨组织;但有数字精度、二进制、schema 等问题。
    • Thrift/Protobuf/Avro——紧凑高效、schema 驱动、对兼容演化支持好,适合内部通信与存储。
  • 三种数据流,各有兼容性考量:
    • 数据库(数据比代码活得久)、服务 REST/RPC(同步、要面对网络的不确定性)、异步消息(解耦、缓冲、一对多)。
  • 承上启下:RPC 揭示的”网络不可靠、远程调用充满不确定性”,正是第二部分分布式数据要正面处理的核心难题。

留给自己的思考题

  1. 我的系统升级时,有没有认真想过”旧代码读到新数据会怎样”(向前兼容)?
  2. 我内部服务间还在用 JSON 吗?值不值得迁到 Protobuf/Avro?
  3. 哪些同步的 RPC 调用,其实更适合改成异步消息(解耦 + 削峰)?
  4. 我有没有”读-改-写”时丢掉未知字段的风险?