第 1 章为整本书设定词汇。Kleppmann 主张现代应用是数据密集型而非计算密集型——真正的挑战是数据的体量、复杂度和速度。三个属性定义一个好的数据系统:它必须可靠、可扩展、可维护。本章用具体例子拆解每一个。
- 故障(fault)vs 失效(failure)——故障是一个组件偏离规格;失效是整个系统宕掉。在故障变成失效之前设计去容忍它。
- 硬件故障独立,软件 bug 相关——一个节点二进制里的 bug 很可能在每个节点上都有,使软件故障危险得多。
- 用负载参数,而非模糊术语——在推理可扩展性前精确描述负载(请求/秒、扇出比)。
- 尾延迟胜过平均值——用 p95/p99;你最慢的用户往往最活跃。SLO 和 SLA 用百分位定义。
- 横向 > 纵向扩展——一旦撞上成本/天花板曲线,shared-nothing 的商用硬件胜过单机 scale-up。
- 可维护性的成本高于初始开发——从第一天起投资可运维性、简单性、可演化性;技术债会复利累积。
可靠性 = 容忍故障而不失效。可扩展性 = 随负载增长维持性能。可维护性 = 让系统易运维、易演化。这三个属性是 DDIA 其余部分写作的透镜。
原始笔记
本章总结所基于的手写学习笔记:
可靠性(Reliability)
可靠的系统即使出错也继续正确工作。Kleppmann 在故障(fault)和失效(failure)之间划出鲜明区别:故障是一个组件偏离其规格;失效是系统整体停止提供所需服务。容错系统的目标是防止故障升级为失效。注意,刻意触发故障是有意义的——Netflix 的 Chaos Monkey 开创的混沌工程实践——正是为了证明容错机制真的有效、没有悄悄萎缩。
硬件故障
硬盘崩溃、内存出坏位、电网闪断、有人拔错线缆。单块硬盘的平均无故障时间约 10–50 年,但在 1 万块硬盘的集群里你预期每天丢一块。直到不久前,标准应对是硬件级冗余——RAID 阵列、双电源、热插拔 CPU、柴油发电机。当单台机器是部署单元时这些足够。
随着数据量和计算需求增长,应用开始在大量商用机器上运行。云平台(AWS、GCP、Azure)明确不保证单机可靠性;它们转而为虚拟机抢占和节点丢失而设计。现代答案是软件里的多机冗余:复制的数据库、负载均衡器后的无状态应用服务器,以及滚动升级——一次下线一个节点,使系统全程保持服务。硬件故障仍然相关,但现在由架构而非仅靠硬件开销处理。
硬件故障在统计上是独立的——机架 A 里的坏盘对机架 B 的盘毫无暗示。这意味着跨机器的 RAID 和复制真正降低风险。软件 bug 是相关的——若 bug 存在于你的二进制里,它同时存在于每个节点。再多复制也救不了一个让所有副本崩溃的软件故障。
软件故障(系统性故障)
软件 bug 比硬件故障危险得多,正是因为那种相关性。一个节点二进制里的 bug 很可能也在所有其他节点上,意味着它能同时拖垮整个集群——磁盘故障从不会这样。Kleppmann 给出几个现实中系统性故障的经典例子:
- 一个软件 bug,在提供某个不寻常输入时同时让每个实例崩溃(如畸形日期、整数溢出)。
- 一个失控进程,缓慢吃掉所有 CPU 或内存直到节点无响应。
- 一个服务响应请求变慢,导致依赖服务级联超时、堆积连接并耗尽自己的线程池——一种在微服务图里传播的失效模式。
- 一个依赖在版本升级后开始返回损坏或意外的响应,悄悄污染调用方的数据。
没有银弹。缓解靠工程纪律:设计时仔细推理假设和交互、单元/集成/端到端各级别的彻底自动化测试、进程隔离以限制爆炸半径、崩溃即重启策略(让它快速失败而非带病运行污染数据),以及持续监控并对意外的不变量违反告警。系统性故障常蛰伏数月直到特定条件触发它,这就是为什么即便一切看起来正常时,生产中监控不变量也很重要。
人为错误
大规模互联网服务宕机的事后分析一致地将操作员失误识别为头号原因——配错的部署、误删、错误的数据库连接串、应用到错误环境的 schema 迁移。人既是这些系统的设计者也是操作者,且不可避免地会犯错。好的系统设计接受这点并减少爆炸半径:
- 设计良好的抽象,让错误的事难做——一个要求显式破坏性动作标志、而非默默接受危险命令的 API 面。
- 沙箱和预发环境,工程师能在不碰生产数据的情况下实验和犯错。
- 各级别自动化测试——单元、集成、端到端测试——使配置错误在发布前被抓住。
- 简单快速的回滚——能在两分钟内回退的部署限制人为宕机的窗口。
- 详尽的监控、指标和日志——若操作员犯错,你想在数秒而非数小时内检测到。监控也提供异常的早期预警,可在升级前纠正。
- 好的运维手册和 runbook——on-call 工程师不该在凌晨 3 点遇到新故障时依赖部落知识。
容错设计模式
理解故障分类指导该用哪些设计模式。硬件故障呼唤复制和冗余。软件故障呼唤隔离边界、熔断器和渐进发布。人为错误呼唤流程护栏和可观测性。跨这三类,指导原则相同:预期故障会发生,设计系统去检测和遏制它们,并让恢复快速。
| 故障类型 | 特征 | 主要缓解 |
|---|---|---|
| 硬件故障 | 独立失败;规模化下统计可预测 | RAID、复制、多 AZ、滚动升级 |
| 软件故障 | 相关;能一次拖垮所有节点 | 测试、进程隔离、渐进发布、监控 |
| 人为错误 | 宕机的头号原因;难消除 | 沙箱、抽象、快速回滚、runbook |
可扩展性(Scalability)
可扩展性不是你贴在系统上的一维标签。问题永远更具体:"若系统以这种特定方式增长,我们怎么应对?"用户增长 10 倍不同于数据量增长 10 倍,又不同于同样用户数下请求率增长 10 倍。你必须先用负载参数精确描述负载,再测量这些参数增加时性能如何变化,然后才决定如何处理增长。不定义负载就直接跳到"我们需要横向扩展"在系统设计面试里是个红旗。
描述负载:负载参数
负载参数是一个有意义地刻画系统负载的数字。参数的正确选择取决于系统架构和瓶颈的性质。跨不同系统的例子:
- Web 服务器:每秒请求数,以及请求类型分布(读 vs 写、缓存 vs 未缓存)。
- 数据库:读/写比;并发连接数;工作集相对可用内存的大小。
- 聊天应用:同时活跃用户数;每条消息的扇出(每个发送者多少接收者)。
- 搜索引擎:查询率;索引大小;新鲜度要求(新文档多快必须出现)。
- 缓存:命中率——99% 与 95% 的缓存命中率是 5 倍的 DB 负载差异,而非 4% 的差异。
Twitter 扇出案例研究
Kleppmann 用 Twitter 的主页时间线作为权威扇出例子,因为它干净地说明负载参数的分布比其平均值更重要。问题:当你打开 Twitter,你看到你关注的每个人合并后的时间线。有两种根本不同的方式服务它。
方法 1——读时拉(请求时查询):当用户请求其主页时间线,系统查出他们关注的所有账号、取每个账号的近期推文、按时间戳排序合并结果并返回页面。发推 → 只往 tweets 表插一行。简单。问题是读成本:若你关注 400 个账号,时间线生成需要 400 次查找加一次归并排序。在 Twitter 的规模(书写作时 3 亿月活),这是每秒数亿次昂贵的扇出读。
方法 2——写时扇出(发推时推):当用户发推,系统立即把那条推文推进每个关注者预计算的主页时间线缓存。读时间线变成单次缓存查找——极便宜。成本在写时:发一条推触发 N 次缓存写,N 是发推者的关注者数。对大多数用户这没问题。对有 3000 万关注者的名人,单条推触发 3000 万次缓存写——一个能压垮系统的巨大写放大尖峰。
Twitter 的实际解法是混合:对绝大多数用户(关注者数适中)写时扇出,对一小组高关注账号(名人、新闻媒体)读时拉。大多数时间线被预计算;少量名人推文在读时拼接进来。关键洞见:要推理的正确负载参数不是"平均发推率"而是每用户关注者数的分布——具体是高关注账号的长尾,它使朴素的写时扇出不可行。
Twitter 的例子教一个通用教训:推理可扩展性时,总要问负载参数的分布,而非仅平均。一个优雅处理平均负载的系统可能在尾部崩溃。正确的架构在中位数情况和 99 分位情况间常常不同。
描述性能:响应时间 vs 延迟
测量性能前,值得厘清常被混淆的术语。延迟(latency)是请求等待被处理的时间——它在系统接手前在队列里坐着的时间。响应时间(response time)是客户端从发请求到收到完整响应所测量的;它包括网络时间、排队时间和处理时间。实践中,"延迟"被宽松地用来指响应时间,Kleppmann 在对话语境里互换使用,在要紧时才精确。
对在线服务,响应时间是对用户要紧的性能指标。对批处理系统,吞吐量(每秒或每小时处理的记录数)通常更相关。它们需要不同的优化策略:最小化响应时间常意味着优先处理单个请求;最大化吞吐量常意味着批量工作并完全饱和资源,接受更高的每请求延迟。
为什么平均值是错误的指标
平均(mean)响应时间几乎总是错误的指标。它对偏斜分布的算术敏感:少量很慢的请求戏剧性地抬高平均值,但它们在统计上消失其中——你无法判断 200 ms 的平均值代表一个每个请求都 200 ms 的系统,还是一个 90% 取 50 ms、10% 取 1.5 s 的系统。两者平均值相同。只有百分位让你看出区别。
| 百分位 | 含义 | 为何要紧 | 典型用途 |
|---|---|---|---|
| p50(中位数) | 一半请求比这快 | 代表典型用户体验 | 基线健康检查 |
| p95 | 100 个里 95 个更快 | 展示尾部行为而无极端离群 | 常见 SLO 目标 |
| p99 | 100 个里 99 个更快 | 捕获高价值用户体验 | 对客户的 SLA 承诺 |
| p999 | 1000 个里 999 个更快 | 绝对最差的非异常体验 | Amazon 内部服务目标 |
高百分位延迟——常称尾延迟(tail latency)——出于两个原因不成比例地要紧。第一,体验最慢的用户往往是你最活跃、最高价值的用户:他们有更多数据(更长历史、更多连接、购物车里更多商品),所以每个查询触及更多行、耗时更长。优化中位数而忽略尾部是为容易、低价值的情况优化。第二,现代应用并行做许多后端调用来组装一个页面;端到端响应时间受最慢的那个调用而非平均限制。若二十个后端调用里只有一个慢(一个 p95 事件),而页面渲染需要 20 个并行调用,那么平均每个页面渲染都会被那个 p95 离群拖延。
服务级目标(SLO)和服务级协议(SLA)正是因此用百分位定义。一个典型 SLO 可能写:"面向用户的读请求的 p99 必须低于 200 ms,在滚动 5 分钟窗口上测量。"SLA 是这个的合同版本,带违约罚则条款。两者若用平均值表述都毫无意义。
队头阻塞与负载测试
队头阻塞(head-of-line blocking)是尾延迟的一个微妙但重要的放大器。服务器并发处理请求,但只到其线程池或连接上限。当少量慢请求占据那些线程,后续请求在它们后面排队、经历高延迟,即便服务器本身平均上并未过载。慢请求阻塞了队头,拖住一切。这个效应意味着即便单个请求的 p95 慢,也能在页面级产生高得多的端到端延迟,因为多个请求可能撞到慢请求后面的队列。
在客户端而非仅服务端测量响应时间,对捕获队头阻塞至关重要。服务端 p99 的 50 ms 能与客户端 p99 的 2 s 并存,若请求在排队。跑负载测试时,以固定速率发请求(不等每个完成才发下一个)很关键——否则你只测低负载下的性能,而非服务器真正并发受压时的行为。
应对负载的方法
一旦你描述了负载参数并测量了性能,你面对如何扩展的问题。两种基本策略是纵向和横向扩展,但真正的决策更微妙:
- 纵向扩展(scale up)——换更强的机器:更多 CPU、更多 RAM、更快磁盘。优势是简单;你不改架构。劣势是强力机器比商用机非线性地更贵,且有硬天花板——单节点上你只能买这么多 CPU。纵向扩展对 shared-nothing 水平分区在架构上复杂的数据库工作得好。
- 横向扩展(scale out, shared-nothing)——把负载分散到更多商用机器。每台机器拥有数据子集或处理请求子集。更复杂去构建和运维,但天花板高得多、成本随负载更线性地扩展。无状态服务(如 HTTP API 处理器)自然横向扩展;有状态服务(如数据库)需要谨慎的分区设计。
- 弹性 vs 手动扩展——弹性系统检测负载增加并自动开新实例(云环境的自动扩缩)。手动扩展需要人判断何时加容量。弹性扩展适合高度可变的负载(如突发新闻时流量飙升的新闻站);手动扩展更简单,对可预测、缓慢增长的负载足够。
纵向 vs 横向扩展的选择不纯是技术性的——它涉及运维复杂度、成本曲线和负载性质。一个从第一天就设计成横向可扩展的系统,对一个本可在单 Postgres 实例上跑数年的初创公司常是过度工程。务实做法:从简单开始,用真实数据识别实际瓶颈,扩展那个特定瓶颈而非猜测未来负载。
可维护性(Maintainability)
软件成本的大部分不在初始开发而在持续维护:修 bug、保持系统运行、调查故障、适配新平台、偿还技术债、加新功能。维护非自己构建系统的工程师花大量时间对抗本不必要、本可用更好的前期设计选择避免的复杂度。Kleppmann 识别可维护性的三个设计原则,各针对持续成本的一个不同轴。
可运维性:让运维生活轻松
运维团队负责保持系统 7×24 运行。他们不是代码作者——他们是东西坏时凌晨 3 点被呼叫的人。一个系统可运维,若它帮他们高效干活并最小化日常任务和事件响应的认知负荷。
具体地,好的可运维性意味着:
- 对运行时行为的可见性——通过 Prometheus/StatsD 暴露的丰富指标、在日志聚合系统(如 Elasticsearch、Splunk)里可查询的结构化日志,以及让工程师看到单个请求跨数十个服务的完整因果链的分布式追踪。没有可观测性,运维是猜测。
- 标准工具接口——与现有监控、部署、告警框架集成的系统无需自定义一次性工具来运维。运维者应能套用其他系统的技能和 runbook。
- 无单机依赖——系统应容忍滚动重启、机器替换和打补丁而不下线服务。这需要无状态应用层和复制的有状态层。
- 好的默认行为带覆盖——对 95% 情况有效的安全默认,对另外 5% 有显式覆盖机制。运维者绝不该为理解系统为何意外行为而钻进源码。
- 可预测行为——惊喜是 on-call 的敌人。一个偶尔"无明显原因"停顿 30 秒的系统(如 stop-the-world GC 暂停、后台 compaction 作业)比一个延迟特征可预测的难运维得多。
- 好的文档——架构图、常见故障模式的 runbook、每个配置旋钮及其作用的解释。过时的文档几乎比没有更糟,因为它制造虚假信心。
简单性:管理复杂度
Kleppmann 直言:管理复杂度是大规模软件开发的最大单一挑战。复杂度不只关乎代码行数——它关乎理解系统到能做出正确变更所需的认知负荷。复杂系统是这样一个:每个变更都需要理解大量不明显的交互;在一处加功能悄悄破坏看似无关的东西;每个工程师维护一个私有且略不同的心智模型,导致边界处微妙的 bug。
Kleppmann 区分两种复杂度。本质复杂度(essential)是问题域固有的——一个处理每个司法辖区规则的税务计算引擎有不可约的复杂度,因为问题本身复杂。偶然复杂度(accidental)由实现引入、非问题所需——不必要的状态、无关组件间的紧耦合、混乱命名、深嵌套条件逻辑,或在每个使用点泄露内部的抽象。目标是最小化偶然复杂度,同时接受本质复杂度不可避免。
管理复杂度的主要工具是好的抽象:一个把实现细节藏在简单接口后的干净概念边界。SQL 是 Kleppmann 的经典例子。SQL 查询背后是复杂的 B-tree 结构、buffer pool、事务日志、查询优化器和锁管理器——SQL 作者都不需要理解。抽象让一个干净概念(行的表,声明式查询)在数千应用里使用,而无需每个开发者重新发明或理解存储层。当抽象好,它们显著减少每个工程师必须放在脑中的心智表面积。
简单性不意味着更少功能或降低能力。它意味着系统行为从其接口可理解、可预测。一个系统可以既功能丰富又简单,若功能干净组合且抽象连贯。简单性的敌人不是功能——而是隐形耦合、隐藏状态和泄露抽象。
可演化性:让变更容易
软件需求不是静态的。业务转向、新法规到来、性能特征改变,看似重要的功能结果没人用而意料外的用例出现。一个不能安全变更而需英雄式努力的系统是个负担——它要么随技术债累积而僵化,要么以巨大代价被重写。可演化性(也叫可扩展性或可修改性)是让变更可处理的系统属性。
在单个文件和函数的小尺度,敏捷实践应对可演化性:测试驱动开发确保重构前有安全网;持续重构让局部代码结构不至于钙化;短反馈循环快速抓住回归。这些对大系统必要但不充分。
在架构尺度,可演化性需要让独立关注点分离的设计决策。若加一个新功能需要理解和修改十个不同服务,因为它们都紧耦合,系统就不可演化。若给数据加一种新编码格式需要同时更新每个消费者,因为没有 schema 演化机制,系统就不可演化。这就是为什么 DDIA 后续章节如此重度聚焦于 schema 演化策略(Avro、Protocol Buffers、Thrift)、向后/向前兼容,以及解耦数据生产者与消费者的流处理架构——所有这些都是在系统设计层面买可演化性的工具。
| 可维护性支柱 | 核心问题 | 关键工具与实践 |
|---|---|---|
| 可运维性 | 运维能否不靠英雄式努力保持它运行? | 指标、日志、追踪、runbook、滚动部署、可预测行为 |
| 简单性 | 新工程师能否快速理解这个系统? | 好抽象、最小耦合、干净接口、避免偶然复杂度 |
| 可演化性 | 我们能否随时间安全变更这个系统? | schema 演化、松耦合、向后兼容、TDD、重构文化 |
三个属性的相互作用
可靠性、可扩展性、可维护性不是独立的——它们相互作用且常彼此权衡。一个通过加缓存层和异步处理优先可扩展性的系统变得更难运维和推理,降低可维护性。一个通过大量冗余和容错设计优先可靠性的系统变得更复杂、因而更难演化。工程师的任务不是孤立地最大化任一属性,而是为手头问题找到正确平衡,考虑团队规模、系统预期增长轨迹和失败的成本。
Kleppmann 的框架——可靠性、可扩展性、可维护性——被刻意用作 DDIA 其余部分的组织透镜。当他引入一个新技术(复制、分区、事务、流处理),他总回到这三个问题:这帮助系统更可靠、更可扩展,还是更可维护?答案很少是"同时三者皆是"——每个技术有一个主要目标和次要成本,理解那些成本是工程成熟度的标志。
用负载参数(而非"很多流量"这类模糊术语)精确描述系统行为。测量尾延迟(p95/p99),而非平均——你最慢的用户往往最有价值。为你语境里最可能的故障类型设计:硬件故障独立(复制有帮助),软件 bug 相关(测试和进程隔离是主要防御),人为错误居首(可运维性和回滚能力是答案)。并从第一天起投资简单性和可演化性——糟糕可维护性的成本在系统生命周期里比几乎任何其他技术债复利得更快。
故障和失效的区别是什么?故障是一个组件偏离其规格;失效是整个系统停止提供所需服务。容错系统防止故障级联成失效——刻意触发故障(混沌工程)是你验证机制有效的方式。
为什么用 p99 而非平均延迟?平均隐藏尾部行为。最慢的 1% 用户往往是你最高价值的客户(最多数据、最活跃)。SLA 正因此用百分位写。还有:一个由 20 个并行调用组成、每个 p95 = 200 ms 的页面,统计上几乎每次渲染都有一个慢调用——每调用的平均延迟与页面延迟无关。
写时扇出 vs 读时——Twitter 怎么解决?写时扇出在写时预计算主页时间线(读快,对高关注账号昂贵)。Twitter 用混合:普通用户写时扇出,名人读时拉。关键负载参数是关注者数的分布,而非平均发推率。
什么是队头阻塞?慢请求占据服务器线程,导致后续请求在它们后面排队。即便大多数请求快,一个 p95 慢请求能阻塞队列并抬高所有并发请求的端到端延迟。客户端(而非服务端)延迟测量能捕获这个。