DDIA 第一部分《数据系统基础》导读:可靠性、数据模型、存储引擎与编码演化
DDIA 第一部分《数据系统基础》导读:可靠性、数据模型、存储引擎与编码演化
本文是《Designing Data-Intensive Applications》(DDIA)第一版中文翻译仓库中第一部分:数据系统基础的深度导读。第一部分共四章,讲解无论运行在单台机器还是跨多台机器集群上都适用的数据系统底层基础:第一章回答"可靠、可伸缩、可维护"到底意味着什么,第二章对比数据模型与查询语言,第三章深入存储引擎内部,第四章探讨数据编码与模式演化。读完本文,你将掌握数据系统设计的地基性概念框架,并了解如何在当前仓库中按章节逐层深入阅读原文。
第一部分在全书中的定位
第一部分是全书的地基。它面向的目标读者是数据密集型(data-intensive)应用的开发者——这类应用通常不缺少 CPU,瓶颈往往来自数据量、数据复杂度和数据变更速度。本部分的四个章节依次回答四个递进的问题:
- 第一章:可靠性、可伸缩性和可维护性——本书使用的术语和方法:"可靠性、可伸缩性和可维护性"这些词汇到底意味着什么?如何实现这些目标?
- 第二章:数据模型与查询语言——从程序员角度看,数据库之间最明显的区别是数据模型。不同的数据模型适用于不同的应用场景。
- 第三章:存储与检索——深入存储引擎内部,研究数据库如何在磁盘上摆放数据。不同的存储引擎针对不同的负载进行优化,选择合适的存储引擎对系统性能有巨大影响。
- 第四章:编码与演化——对比几种不同的数据编码(序列化)格式,特别研究它们在应用需求经常变化、模式需要随时间演变的场景中的表现。
需要说明的是,第一部分在仓库中存在两个版本:第一版(content/v1/)覆盖前四章,而第二版英文原稿(content/en/part-i.md)将第一部分扩展为五章,新增了"系统架构中的权衡"(第 1 章)与"定义非功能需求"(第 2 章)两章,原第一版的四章相应顺延为第 3~5 章。本文以第一版中文翻译(content/v1/)为主体。
第一部分之后,第二部分将专门讨论分布式数据系统中特有的问题(如复制、分区、事务、一致性与共识),第三部分则转向批处理、流处理与派生数据。理解第一部分的单机基础,是理解后续分布式难题的前提。
第一章:可靠性、可伸缩性和可维护性
第一章开篇点明了全书的基本立场:现今很多应用程序是数据密集型的,数据系统(数据库、缓存、搜索索引、流处理、批处理)是高度成功的抽象,但现实中没有"万能工具",不同的应用有不同需求,组合使用多个工具时尤其需要理解各工具的原理。本章由此提出贯穿全书的三个核心目标。
关于数据系统的思考
传统上我们习惯把数据库、消息队列、缓存划分为差异显著的类别,但近年来类别边界越来越模糊:数据存储可以被当成消息队列用(如 Redis),消息队列则带有类似数据库的持久化保证(如 Apache Kafka)。同时,越来越多应用有严格而广泛的要求,单个工具不足以满足全部需求,总体工作被拆分成一系列能被单个工具高效完成的任务,并通过应用代码缝合起来。当你把缓存(如 Memcached)、全文搜索(如 Elasticsearch)等组件与主数据库组合时,使缓存/索引与主数据库保持同步通常是应用代码的责任——此时你不仅是应用程序开发人员,也成了数据系统设计人员。这正是上图(图 1-1)所要传达的架构思想。
在此基础上,本章给出三个目标的精确定义:
- 可靠性(Reliability):系统在困境(如硬件故障、软件故障、人为错误)中仍可正常工作,正确完成功能并达到期望的性能水准。
- 可伸缩性(Scalability):有合理的办法应对系统的增长(数据量、流量、复杂性)。
- 可维护性(Maintainability):许多不同的人(工程师、运维)在不同的生命周期,都能高效地在系统上工作,使系统保持现有行为并适应新的应用场景。
可靠性:故障与容错
可靠性的反面是故障(fault)。注意它与**失效(failure)**的区分:故障是系统的一部分状态偏离标准,失效是系统作为一个整体停止向用户提供服务。故障的概率不可能降到零,因此要设计容错机制防止故障导致失效。容错讨论的是特定类型的错误——"如果整个地球都被黑洞吞噬"这类错误不在容错范围之内。
本章将故障分为三类,逐一分析应对手段:
- 硬件故障(hardware faults):硬盘崩溃、内存出错、机房断电、拔错网线。据书中引用的报告,硬盘的**平均无故障时间(MTTF)**约为 10 到 50 年,因此拥有 10000 个磁盘的存储集群平均每天会有 1 个磁盘出故障。传统对策是硬件冗余(RAID、双路电源、备用发电机),但随着数据规模增长和云平台(如 AWS)强调灵活性与弹性而非单机可靠性,硬件冗余已不足以单独支撑。
- 软件错误(systematic error):这类 Bug 跨节点相关、难以预料,往往比不相关的硬件故障造成更多系统失效。典型例子包括:接受特定错误输入导致所有实例崩溃(如 2012 年闰秒触发 Linux 内核 Bug)、失控进程耗尽共享资源、依赖服务变慢或返回错误响应、级联故障。应对手段是仔细审视假设与交互、彻底测试、进程隔离、允许崩溃重启、生产环境监控与运行时自检。
- 人为错误(human error):书中引用的一项大型互联网服务研究发现,运维配置错误是服务中断的首要原因,硬件故障仅占 10–25%。对策包括:设计最小化犯错机会的抽象与 API、提供非生产沙箱、各层次自动化测试、快速回滚与分批发布、详细遥测监控。
此外,容错系统还可以故意触发故障来检验容错机制——Netflix 的 Chaos Monkey 就是通过随机杀死进程来提高故障处理信心的代表。
可伸缩性:负载参数与性能描述
可伸缩性不是一维标签,讨论它必须回答"如果系统以特定方式增长,有哪些应对选项"。这需要两件工具:描述负载与描述性能。
描述负载使用负载参数(load parameters),其最佳选择取决于系统架构:可能是每秒请求数、读写比率、同时活跃用户数或缓存命中率。书中用推特 2012 年 11 月的数据作为贯穿案例:
- 发布推文:平均 4.6k 请求/秒,峰值超过 12k 请求/秒;
- 主页时间线:300k 请求/秒。
推特的伸缩性挑战不在于写入量,而在于扇出(fan-out)——每个用户关注很多人、也被很多人关注。实现主页时间线有两种典型方式:
- 发布时只写入全局推文集合,读取时联表查询并合并(对应一段 SQL:
SELECT tweets.*, users.* FROM tweets JOIN users ... JOIN follows ... WHERE follows.follower_id = current_user); - 为每个用户维护一个主页时间线缓存("每个用户的收件箱"),发布推文时扇出写入所有关注者的缓存,读取开销极小。
方法 2 效果好,因为发推频率比读时间线频率低近两个数量级;但平均每条推文要扇出给约 75 个关注者,峰值推文对应每秒 345k 次缓存写入,而个别拥有超 3000 万粉丝的用户,一条推文就触发 3000 万次写入。最终推特采用混合方案:普通用户走扇出缓存,名人用户的推文在读取时实时合并。每个用户粉丝数分布由此成为关键的负载参数——这个案例会在第十二章被重新讨论。
描述性能则要区分批处理与在线系统:Hadoop 类批处理关注吞吐量(throughput),在线系统关注响应时间(response time)。响应时间不等于延迟(latency)——后者指请求等待处理的持续时长,前者还包括网络与排队延迟。响应时间是分布而非单一数值,平均值会掩盖真实体验,因此实践中常用百分位点(percentiles):中位数(p50)代表"典型"体验,p95、p99、p999 用于捕捉异常值(尾延迟)。书中还提示,用户同时发出多个请求时,"至少一个请求比中位数慢"的概率远大于 50%,这也是 p99 在云服务与大规模系统中的重要性来源之一。
可维护性:可运维性、简单性与可演化性
可维护性最终关乎工程师与运维团队的生活质量,书中将其拆解为三个维度:
- 可运维性(Operability):良好的可操作性意味着对系统健康状态有良好可见性(监控、指标、日志),并有有效的管理手段。
- 简单性(Simplicity):通过良好的抽象降低复杂度,消除"意外的复杂度"(accidental complexity)。书中引用了 Rich Hickey "Simple Made Easy"、Brooks 的《人月神话》等思想作为支撑。
- 可演化性(Evolvability):数据系统层面的"敏捷性"。需求永远在变化,TDD 与重构等敏捷实践作用于代码规模,而数据系统层面需要的是让架构可随新事实、新场景、新法规平稳调整的能力。
第一章小结
第一章确立的方法论贯穿全书:可靠性意味着即使发生故障系统仍能正常工作;可伸缩性意味着在负载增长时保有性能策略(先定量描述负载与性能,再谈扩容);可维护性意味着通过抽象降低复杂度、通过可观测性保障可操作性。功能需求回答"系统应该做什么",非功能需求(安全、可靠、合规、可伸缩、兼容、可维护)回答"系统应当具有哪些通用属性"。
第二章:数据模型与查询语言
第二章讨论数据模型——数据建模是软件开发中影响最深远的部分,因为它不仅影响软件的编写方式,还影响我们的解题思路。数据模型是分层的:应用层用对象与 API 建模,通用层用 JSON/XML/关系表/图模型表示,数据库软件层决定如何在内存、磁盘、网络上表示字节。每一层通过明确的数据模型隐藏下一层的复杂性。
关系模型与文档模型
关系模型由 Edgar Codd 于 1970 年提出:数据组织成关系(表),每个关系是**元组(行)**的无序集合。它在 20 世纪 80 年代中期成为主流,并称霸约 25~30 年。此前的网状模型与层次模型、后来的对象数据库、XML 数据库都未持久胜出。今天互联网上大部分内容仍由关系数据库支撑。
关系模型的核心贡献是把存储实现细节隐藏在更简洁的接口之后。但面向对象编程语言与关系表之间存在阻抗不匹配(impedance mismatch):对象与表、行、列之间的转换需要样板代码,ORM(如 ActiveRecord、Hibernate)能减少样板代码但不能消除模型差异。书中以 LinkedIn 简历为例:first_name、last_name 等单值字段建模为 User 表的列,而多份工作经历、教育阶段与联系信息属于一对多关系,传统 SQL 需拆成多表外键引用;后续 SQL 标准加入了结构化类型与 XML/JSON 支持,第三种方案则是把 JSON/XML 存进文本列由应用自行解析(代价是数据库无法查询这些列的值)。
阻抗不匹配与 NoSQL 的兴起
"NoSQL"最初是 2009 年一个关于分布式非关系数据库开源聚会上的 Twitter 标签,后被追溯性重新解释为"不仅是 SQL(Not Only SQL)"。其兴起有四个驱动因素:对更高可伸缩性的需求(超大数据集、超高写入吞吐)、对免费开源软件的偏好、关系模型对某些特殊查询支持不佳、以及渴望更具动态性与表现力的数据模型。未来相当长时期内,关系数据库会与各类非关系数据库并存——这种想法被称为混合持久化(polyglot persistence)。
文档模型(MongoDB、RethinkDB、CouchDB 等)适合自我包含的数据文档、文档间关系稀少的场景,其**局部性(locality)**优势让一次读取即可取得整个文档;关系模型则适合多对多关系复杂、需要灵活联表的场景。书中还讨论了文档模型的模式灵活性(schema-on-read 与 schema-on-write)及其与关系模型的"多对一/多对多"处理能力对比。
数据查询语言:从 SQL 到 Datalog
本章依次审视了多类查询语言,并强调声明式(declarative)查询的价值——你只需描述想要的结果模式,由查询优化器决定执行计划,数据库因此可以在不影响应用的情况下优化执行方式。书中覆盖的语言包括:
- SQL:关系模型的标准查询语言。
- MapReduce:Google 提出的分布式批处理编程模型,介于声明式与命令式之间,MongoDB 曾用它做有限形式的聚合查询。
- MongoDB 聚合管道(aggregation pipeline):声明式子集的命令式扩展,用类似 Unix 管道的阶段式处理表达查询。
- Cypher:Neo4j 的属性图查询语言,用
(node)-[关系]->(node)的 ASCII 风格语法表达图模式匹配。 - SPARQL:RDF 三元组(主语-谓语-宾语)的查询语言,用于语义网与知识图谱。
- Datalog:基于逻辑规则的语言,规则可以组合与递归复用,书中用
within_recursive递归规则演示了如何推导"爱达荷州在北美"这类传递关系。
图数据模型
图模型用于"任意事物之间都可能存在潜在关联"的场景,与文档模型互为两端。书中对比了属性图(property graph)与三元组存储(triple-store)两种实现,并演示同一查询在 Cypher、SPARQL、Datalog 中的不同表达。图数据可以用关系数据库模拟,但结果往往很糟糕——这正是存在多种专门系统的原因。
第二章小结
数据模型的历史脉络是:最初的大树(层次模型)不利于多对多关系 → 关系模型解决该问题 → NoSQL 分化出文档数据库(自我包含、关系稀少)与图形数据库(万物皆关联)。三种模型今天都被广泛使用;文档与图数据库通常不强制约束存储数据的模式,区别只在于模式是明确的(写入时强制)还是隐含的(读取时处理)。每种模型都有各自的查询语言与框架,没有单一的万能解决方案。
第三章:存储与检索
第三章从数据库的视角回答第二章的问题:数据模型在底层是如何实现的?为什么选择存储引擎如此重要?因为存储引擎的内部机制决定了它在你的工作负载类型上的表现,理解底层机理才能正确选型与调参。
驱动数据库的数据结构
本章用两个 Bash 函数演示了"世界上最简单的数据库",这是理解后续一切索引结构的起点:
#!/bin/bash
db_set () {
echo "$1,$2" >> database
}
db_get () {
grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
}
db_set 将"键,值"追加到文件末尾(追加写、不覆盖旧值),db_get 用 grep 找到键最后一次出现的位置并返回。它的写入性能很好,但读取需要全表扫描——复杂度为 O(n)。由此引出索引的必要性:**索引(index)**是从主数据衍生的额外结构,它改变查询的复杂度,但任何索引都会减慢写入(因为每次写入都要更新索引)。
在此基础上,本章依次展开:
- 哈希索引(hash index):内存中维护键→字节偏移的映射,日志追加写入(如 Bitcask 的做法)。适合键值更新频繁的场景,但全部键须装入内存。
- SSTables 与 LSM 树:日志按键排序后形成排序字符串表(SSTable),配合内存中的 MemTable 与合并压缩(compaction),构成日志结构合并树(LSM-tree)。LevelDB、RocksDB、Cassandra、HBase、Lucene 等都属于这一派。它把随机写转换为顺序写,显著提升写入吞吐。
- B 树:**面向页面(page-oriented)**的就地更新学派代表,将磁盘视为可覆写的固定大小页面,通过分支因子约数百的树形结构保持平衡。所有主流关系数据库与许多非关系数据库都使用 B 树。它与 LSM 树在写放大(write amplification)与读放大上的权衡是本章的重点分析对象。
- 其他索引:包括次级索引、多列索引、全文索引等更复杂的结构,以及针对"所有数据放入内存"而优化的内存数据库。
事务处理还是分析?
**OLTP(事务处理)**与 **OLAP(在线分析)**的访问模式截然不同:
- OLTP 面向最终用户,请求量大,每个查询只访问少量记录,按键通过索引查找,硬盘查找时间是瓶颈。
- 数据仓库与分析系统查询量少但单个查询开销高昂,需要短时间内扫描数百万条记录,硬盘带宽(而非查找时间)是瓶颈。
OLTP 存储引擎的两大流派正是第一章之后反复出现的主题:日志结构学派(只追加、删除过时文件,如 Bitcask、SSTables、LSM 树、LevelDB、Cassandra、HBase、Lucene)与就地更新学派(把磁盘当作可覆写的页面集,B 树是典范)。日志结构引擎通过将随机写转换为顺序写换取更高写入吞吐。
列式存储
分析型负载在宽表中只读取少数列,行式存储会浪费带宽读取无关列。**列式存储(column-oriented storage)**按列而不是按行存储数据,配合压缩(如位图编码、游程编码)可以大幅减少扫描数据量。进一步优化包括:
- 排序顺序:对整行统一排序(如以
date_key为首要排序键、product_sk为次要排序键),使范围查询只扫描相关行,并让排序列产生连续重复值从而获得极佳压缩率。 - 多个排序顺序:C-Store 提出并被 Vertica 采用的思路——既然数据本来需要冗余备份,不如用不同排序方式存储多份,让查询优化器选择最合适的一份。
- 写入策略:列式存储难以就地更新,因此常借助 LSM 式思路——写操作先进入内存中的已排序结构,积累到阈值后与磁盘列文件合并批量写入(Vertica 的做法)。
- 矢量化处理(vectorized processing):将压缩列块直接放入 CPU 缓存,在无函数调用的紧循环中遍历,并利用 SIMD 指令,充分压榨 CPU 带宽。
- 物化视图与数据立方体(OLAP cube):把高频聚合(COUNT/SUM/AVG/MIN/MAX)预先物化。数据立方体按多个维度分组聚合,让"每个商店的总销售额"这类查询直接读取预计算结果;代价是失去查询原始数据的灵活性,因此大多数数据仓库保留原始数据、把聚合数据仅作为性能加速手段。
第三章小结
存储引擎分两大类:针对 OLTP 优化的(日志结构的 LSM 树派 vs 就地更新的 B 树派)与针对 OLAP 优化的(列式存储日益流行)。掌握存储引擎内部知识,能让你在选型时理解"哪种工具最适合我的工作负载",并在调整数据库参数时预判每个数值增减的后果——虽然不能让你成为调参专家,但足以让你读懂所选择数据库的文档。
第四章:编码与演化
第四章把视角从存储内部转向"数据如何穿越进程边界"。可演化性的关键前提是滚动升级(rolling upgrade):新版本服务逐步部署到少数节点而非一次性全量替换,从而允许不停机发布、并能在影响大量用户前检测回滚。滚动升级意味着系统内不同节点可能运行不同版本代码,因此所有在系统周围流动的数据都必须以同时提供向后兼容(新代码可读旧数据)与向前兼容(旧代码可读新数据)的方式编码。
编码数据的格式
本章按兼容性属性将编码格式分为三类:
- 编程语言特定的编码:如 Java 序列化、Ruby Marshal、Python pickle,局限于单一语言,往往无法提供前后向兼容,且存在反序列化安全风险(书中引用了 CWE-502 等资料)。
- 文本格式:JSON、XML、CSV 应用广泛,兼容性取决于如何使用;它们有可选模式语言(JSON Schema、XML Schema),对数字与二进制字符串的类型区分模糊,需要注意精度陷阱。
- 二进制模式驱动格式:Thrift、Protocol Buffers(Protobuf)、Avro。它们通过模式(schema)定义数据结构,具有清晰定义的向后/向前兼容语义,编码紧凑高效,可为静态类型语言生成代码和文档;缺点是读取数据前必须先解码(需要知道模式)。
书中特别对比了三种二进制格式处理模式演化的机制差异:Thrift 与 Protobuf 靠字段编号(field tag)与可选/必选标记实现兼容性演化;Avro 则靠写入者模式(writer schema)与读取者模式(reader schema)的按字段名匹配,且支持无字段编号的模式,天然适合动态生成模式(如 Hive 中的数据库 schema 演化)。此外还讨论了数据格式的"数据类型模糊性"问题:JSON/XML 中的数字精度、CSV 的二进制字符串转义等。
数据流的类型
编码发生在数据流动的各个环节,本章将其归纳为三种主要模式:
- 数据库中的数据流:写入进程编码、读取进程解码。数据库写入者可能先于读取者升级,因此"添加字段并保留旧字段"是保证兼容的常见做法;数据库迁移工具(如在线 schema 变更)也是本章讨论的话题。
- RPC 与 REST API:客户端编码请求、服务器解码并编码响应。REST 是公共 API 的主要风格;RPC 框架(Thrift、gRPC/Protobuf、Avro RPC、SOAP)侧重同一组织内服务间通信。跨组织边界时服务提供者无法控制客户端升级,因此需要长期保持兼容,甚至并排维护多个版本的服务 API(版本号可放在 URL、HTTP Accept 头或由服务端按 API 密钥管理)。
- 消息传递(异步):通过消息代理(Message Broker)(RabbitMQ、ActiveMQ、HornetQ、NATS、Apache Kafka 等)中转。相比直接 RPC,消息代理可充当缓冲区提高可靠性、自动重发消息、解耦收发双方、支持一对多广播;通信通常是单向异步的(fire-and-forget)。消息代理不执行特定数据模型,消息只是带元数据的字节序列,因此任何兼容性良好的编码格式都可用。
- 分布式 Actor 框架:将消息代理与 Actor 编程模型集成,跨节点伸缩应用。书中对比了三个流行框架的编码策略:Akka 默认用 Java 内置序列化(无前后向兼容,可换 Protobuf 获得滚动升级能力);Orleans 默认的自定义编码不支持滚动升级(需新集群迁移流量);Erlang OTP 的记录模式变更困难,滚动升级需要仔细规划。
第四章小结
编码细节不仅影响效率,更影响应用架构与部署选项。三类格式各有取舍:语言特定编码不兼容、文本格式普遍但类型模糊、二进制模式驱动格式紧凑高效且具备明确兼容语义(代价是需要解码)。结合数据库、RPC/REST、异步消息三种数据流模式,可以谨慎得出结论:向后/向前兼容与滚动升级在一定程度上是可以实现的——这正是"让应用的演化迅速、敏捷部署"的前提。
第一部分索引:快速导航
关联文档末尾给出了第一部分四章的完整索引,以下链接均指向仓库源文件,可直接按锚点跳转到对应小节:
各章末尾还附有完整的参考文献列表(第一章、第二章、第三章、第四章),适合追溯每个论断的原始出处。全书章节树与插图目录可在目录页统一查看。
后续:分布式数据系统
第一部分解决的是"单机视角"的地基问题。接下来第二部分将把注意力转向分布式数据系统的特有难题:复制与分区、事务与隔离级别、一致性与共识——这些正是第一章反复出现的权衡(如可靠性、可伸缩性)在跨机器场景下的深化。建议读者按"第一章建立目标 → 第二章选择模型 → 第三章理解实现 → 第四章保障演化"的顺序阅读,让第一部分的四章成为理解全书后半部分的稳定支点。
