ddia-part1
《Designing Data-Intensive Applications》(Martin Kleppmann, O’Reilly 2017) 第一部分「数据系统的基石」读书笔记。用我自己的话重新梳理每章的核心概念、关键权衡和例子。第一部分共四章,从单机视角打地基:先立起衡量系统的三把尺子,再依次深入数据模型、存储引擎、编码与演化。
本篇目录
- 第 1 章 · 可靠、可扩展、可维护的应用
- 第 2 章 · 数据模型与查询语言
- 第 3 章 · 存储与检索
- 第 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 有两个主要操作:
- 发推 (post tweet):用户发一条推文(平均 ~4.6k 请求/秒,峰值 12k+)。
- 看时间线 (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)
负载涨了以后,问两个问题:
- 负载参数增加、资源不变时,性能会怎样?
- 负载参数增加、想保持性能不变,需要增加多少资源?
要回答就得量化性能。这里有两个概念:
- 吞吐量 (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)。
留给自己的思考题
- 我现在负责/接触的系统,最缺的是哪把尺子?为什么?
- 我最近写的代码里,有没有在制造”偶然复杂度”?
- 如果要量化我的系统的”负载参数”,我会选哪几个数字?
- 我的系统里,”人为错误”有哪些可以通过更好的接口或流程来预防?
第 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)把”怎么执行”交给数据库,从而能自动优化、自动并行;命令式把步骤写死,难并行。
- 一以贯之的思想:先看清数据的关系形态,再选模型。而且现代数据库正在互相融合,理解底层权衡比记产品标签重要。
留给自己的思考题
- 我手头的项目,数据关系更接近”树”还是”网”?现在的选型合适吗?
- 我有没有在应用代码里手写过”模拟 JOIN”?那是不是选型不当的信号?
- 哪些业务问题其实是”找路径/找连接”,本可以用图模型更优雅地解决?
第 3 章 · 存储与检索
这一章钻进数据库内部,回答一个问题:数据库到底怎么在磁盘上存数据、又怎么再找回来?理解这些不是为了自己造数据库,而是为了在选型和调优时,知道某个数据库为什么在你的负载下快或慢。核心分两条线:面向事务的存储引擎(LSM-tree vs B-tree)和面向分析的列式存储。
从一个最简单的”数据库”说起
作者用两行 shell 函数演示了世界上最简单的数据库:
- 写:把
key,value追加 (append) 到一个文件末尾。 - 读:从头到尾扫文件,取该 key 最后一次出现的值。
这个玩具揭示了两个深刻的道理:
- 写非常快——因为”追加到文件末尾”是磁盘最高效的操作(顺序写)。很多真实数据库内部也是只追加 (append-only) 的。
- 读非常慢——
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)。排序带来三个好处:
- 合并段更高效:像归并排序 (merge sort) 一样把多个有序段合并,顺序读写,很快。
- 索引可以稀疏:不用给每个 key 都建索引,内存里只需记录部分 key 的位置(稀疏索引),中间的 key 扫一小段即可。
- 支持范围查询。
完整的 LSM-Tree (Log-Structured Merge-Tree) 就是这么工作的:
- 写入先进内存里的有序结构(memtable,通常是红黑树/跳表)。
- memtable 满了就作为一个新 SSTable 刷到磁盘(此时已排好序)。
- 读取时:先查 memtable → 再查最新的 SSTable → 依次往旧的查。
- 后台不断做压缩合并,清理旧数据。
代表: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 大幅减少。它还带来两个叠加优势:
- 列压缩 (column compression):同一列的值类型相同、重复度高,压缩率极好。比如位图编码 (bitmap encoding):某列只有少数几个不同取值时(如”国家”),用位图表示,又省空间又能让
WHERE country IN (...)这类过滤变成飞快的位运算。 - 向量化处理 (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 是两种相反的负载,催生了独立的数据仓库和列式存储——通过”只读需要的列 + 列压缩 + 向量化”把分析查询提速几个数量级。
- 贯穿本章的思想:存储布局必须匹配访问模式。没有最好的存储引擎,只有最匹配你负载的。
留给自己的思考题
- 我常用的数据库用的是 LSM 还是 B-tree?它的默认取舍适合我的读写比例吗?
- 我有没有在一个 OLTP 库上跑大范围分析查询、拖慢了线上?该不该分离出数仓?
- 我建的索引,是真的匹配查询模式,还是”顺手都建上”反而拖慢了写入?
第 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 揭示的”网络不可靠、远程调用充满不确定性”,正是第二部分分布式数据要正面处理的核心难题。
留给自己的思考题
- 我的系统升级时,有没有认真想过”旧代码读到新数据会怎样”(向前兼容)?
- 我内部服务间还在用 JSON 吗?值不值得迁到 Protobuf/Avro?
- 哪些同步的 RPC 调用,其实更适合改成异步消息(解耦 + 削峰)?
- 我有没有”读-改-写”时丢掉未知字段的风险?