内容更新:2026年9月28日

本文目录
  1. 原始笔记
  2. 可靠性(Reliability)
  3. 可扩展性(Scalability)
  4. 可维护性(Maintainability)
  5. 常见问题
  6. 系统设计练习:酒店搜索变慢了
  7. 资料与版本说明

这份 DDIA 第 1 章笔记对应《Designing Data-Intensive Applications》第一版,重点是可靠性、可扩展性与可维护性。你可以用它复习故障与失效、Twitter 的历史扇出案例和 p95/p99 响应时间,再完成下方的系统设计练习。文中的演算与练习用于辅助理解,不能代替原书。

⚡ 速览要点
  • 故障(fault)vs 失效(failure)——故障是一个组件偏离规格;失效是系统无法提供约定的服务,即使它仍在运行。在故障变成失效之前设计去容忍它。
  • 按故障域设计冗余——单块磁盘可能独立故障,但共享电源、网络或固件会同时影响多个副本;软件缺陷也可能造成相关故障。
  • 用负载参数,而非模糊术语——在推理可扩展性前精确描述负载(请求/秒、扇出比)。
  • 观察响应时间分布——p50、p95、p99 展示不同用户体验,只看平均值会隐藏尾部。
  • 扩展实测瓶颈——升级单机可能更简单;分布式状态会增加协调和运维成本。
  • 可维护性的成本高于初始开发——从第一天起投资可运维性、简单性、可演化性;技术债会复利累积。
tldr

可靠性 = 容忍故障而不失效。可扩展性 = 随负载增长维持性能。可维护性 = 让系统易运维、易演化。这三个属性是 DDIA 其余部分写作的透镜。

原始笔记

本章总结所基于的手写学习笔记:

DDIA 第 1 章手写笔记
原始手写笔记——DDIA 第 1 章

可靠性(Reliability)

可靠的系统即使出错也继续正确工作。Kleppmann 在故障(fault)和失效(failure)之间划出鲜明区别:故障是一个组件偏离其规格;失效是系统整体停止提供所需服务。容错系统的目标是防止故障升级为失效。注意,刻意触发故障是有意义的——Netflix 的 Chaos Monkey 开创的混沌工程实践——正是为了证明容错机制真的有效、没有悄悄萎缩。

硬件故障

磁盘、内存、电源和网络都可能失效。在大规模集群中,单个组件的小概率故障会成为日常事件。冗余磁盘和电源可降低单机内的风险;跨故障域的副本则用于应对整台机器或机架不可用。

随着数据量和计算需求增长,应用开始在大量商用机器上运行。云平台(AWS、GCP、Azure)明确不保证单机可靠性;它们转而为虚拟机抢占和节点丢失而设计。现代答案是软件里的多机冗余:复制的数据库、负载均衡器后的无状态应用服务器,以及滚动升级——一次下线一个节点,使系统全程保持服务。硬件故障仍然相关,但现在由架构而非仅靠硬件开销处理。

关键区别

个体硬件故障可能近似独立,因此冗余能降低风险。但共享电源、机架、网络或缺陷固件的副本,也共享相同的故障风险。应把副本分散到合适的故障域并验证切换过程。运行相同代码的副本可能同时触发软件缺陷,复制本身不能修复系统性 bug。

软件故障(系统性故障)

软件缺陷可能造成相关故障:运行同一版本的副本,在相同输入下可能一起出错。硬件也存在共同原因导致的故障,因此两者的区分是分析起点,而非绝对规则。系统性故障的例子包括:

  • 一个软件 bug,在提供某个不寻常输入时同时让每个实例崩溃(如畸形日期、整数溢出)。
  • 一个失控进程,缓慢吃掉所有 CPU 或内存直到节点无响应。
  • 一个服务响应请求变慢,导致依赖服务级联超时、堆积连接并耗尽自己的线程池——一种在微服务图里传播的失效模式。
  • 一个依赖在版本升级后开始返回损坏或意外的响应,悄悄污染调用方的数据。

没有银弹。缓解靠工程纪律:设计时仔细推理假设和交互、单元/集成/端到端各级别的彻底自动化测试、进程隔离以限制爆炸半径、崩溃即重启策略(让它快速失败而非带病运行污染数据),以及持续监控并对意外的不变量违反告警。系统性故障常蛰伏数月直到特定条件触发它,这就是为什么即便一切看起来正常时,生产中监控不变量也很重要。

人为错误

错误配置、误删或在错误环境执行迁移,都可能引发服务事故。不要把所有事故简单归结为单一的人为原因,而要设计能限制错误影响、支持快速恢复的工具与流程:

  • 设计良好的抽象,让错误的事难做——一个要求显式破坏性动作标志、而非默默接受危险命令的 API 面。
  • 沙箱和预发环境,工程师能在不碰生产数据的情况下实验和犯错。
  • 各级别自动化测试——单元、集成、端到端测试——使配置错误在发布前被抓住。
  • 简单快速的回滚——能在两分钟内回退的部署限制人为宕机的窗口。
  • 详尽的监控、指标和日志——若操作员犯错,你想在数秒而非数小时内检测到。监控也提供异常的早期预警,可在升级前纠正。
  • 好的运维手册和 runbook——on-call 工程师不该在凌晨 3 点遇到新故障时依赖部落知识。

容错设计模式

理解故障分类指导该用哪些设计模式。硬件故障呼唤复制和冗余。软件故障呼唤隔离边界、熔断器和渐进发布。人为错误呼唤流程护栏和可观测性。跨这三类,指导原则相同:预期故障会发生,设计系统去检测和遏制它们,并让恢复快速。

故障类型特征主要缓解
硬件故障既有个体故障,也有共享故障域RAID、复制、多 AZ、滚动升级
软件故障相关;能一次拖垮所有节点测试、进程隔离、渐进发布、监控
人为错误操作可能出错;需要限制影响范围沙箱、抽象、快速回滚、runbook

可扩展性(Scalability)

可扩展性不是你贴在系统上的一维标签。问题永远更具体:"若系统以这种特定方式增长,我们怎么应对?"用户增长 10 倍不同于数据量增长 10 倍,又不同于同样用户数下请求率增长 10 倍。你必须先用负载参数精确描述负载,再测量这些参数增加时性能如何变化,然后才决定如何处理增长。不定义负载就直接跳到"我们需要横向扩展"在系统设计面试里是个红旗。

描述负载:负载参数

负载参数是一个有意义地刻画系统负载的数字。参数的正确选择取决于系统架构和瓶颈的性质。跨不同系统的例子:

  • Web 服务器:每秒请求数,以及请求类型分布(读 vs 写、缓存 vs 未缓存)。
  • 数据库:读/写比;并发连接数;工作集相对可用内存的大小。
  • 聊天应用:同时活跃用户数;每条消息的扇出(每个发送者多少接收者)。
  • 搜索引擎:查询率;索引大小;新鲜度要求(新文档多快必须出现)。
  • 缓存:命中率——在请求总量不变、每次未命中都读取一次数据库的假设下,命中率从 99% 降为 95%,数据库读取比例从 1% 升为 5%,即 5 倍负载。

Twitter 扇出案例研究

Kleppmann 用 Twitter 的主页时间线作为权威扇出例子,因为它干净地说明负载参数的分布比其平均值更重要。问题:当你打开 Twitter,你看到你关注的每个人合并后的时间线。有两种根本不同的方式服务它。

方法 1——读时拉(请求时查询):当用户请求其主页时间线,系统查出他们关注的所有账号、取每个账号的近期推文、按时间戳排序合并结果并返回页面。发推 → 只往 tweets 表插一行。简单。问题是读成本:若你关注 400 个账号,时间线生成需要 400 次查找加一次归并排序。估算读取工作量时,应将每秒时间线请求数乘以每次需要查询的账号数。仅凭月活用户数不能推导每秒请求量。

方法 2——写时扇出(发推时推):当用户发推,系统立即把那条推文推进每个关注者预计算的主页时间线缓存。读时间线变成单次缓存查找——极便宜。成本在写时:发一条推触发 N 次缓存写,N 是发推者的关注者数。对大多数用户这没问题。对有 3000 万关注者的名人,单条推触发 3000 万次缓存写——一个能压垮系统的巨大写放大尖峰。

本章介绍的 Twitter 历史方案是混合:对绝大多数用户(关注者数适中)写时扇出,对一小组高关注账号(名人、新闻媒体)读时拉。大多数时间线被预计算;少量名人推文在读时拼接进来。关键洞见:要推理的正确负载参数不是"平均发推率"而是每用户关注者数的分布——具体是高关注账号的长尾,它使朴素的写时扇出不可行。

设计洞见

Twitter 的例子教一个通用教训:推理可扩展性时,总要问负载参数的分布,而非仅平均。一个优雅处理平均负载的系统可能在尾部崩溃。正确的架构在中位数情况和 99 分位情况间常常不同。

描述性能:响应时间 vs 延迟

测量性能前,值得厘清常被混淆的术语。延迟(latency)是请求等待被处理的时间——它在系统接手前在队列里坐着的时间。响应时间(response time)是客户端从发请求到收到完整响应所测量的;它包括网络时间、排队时间和处理时间。实践中,"延迟"被宽松地用来指响应时间,Kleppmann 在对话语境里互换使用,在要紧时才精确。

对在线服务,响应时间是对用户要紧的性能指标。对批处理系统,吞吐量(每秒或每小时处理的记录数)通常更相关。它们需要不同的优化策略:最小化响应时间常意味着优先处理单个请求;最大化吞吐量常意味着批量工作并完全饱和资源,接受更高的每请求延迟。

为什么只看平均值不够

只看平均值会隐藏分布差异。一种服务的所有请求都耗时 200 ms;另一种有 90% 耗时 50 ms、10% 耗时 1,550 ms。两者平均都是 200 ms:0.9 × 50 + 0.1 × 1550 = 200,但典型体验和尾部体验不同。平均值仍可有用,应同时观察百分位和整体分布。

百分位含义为何要紧典型用途
p50(中位数)一半请求比这快代表典型用户体验基线健康检查
p95100 个里 95 个更快展示尾部行为而无极端离群常见 SLO 目标
p99100 个里 99 个更快展示最慢 1% 的边界对客户的 SLA 承诺
p9991000 个里 999 个更快展示最慢 0.1% 的边界,不是最大值样本足够时分析尾部

当一个用户请求等待多个下游调用时,尾延迟会被放大。假设每次调用独立地有 5% 概率超过 200 ms,而页面必须等待全部 20 次调用,那么至少一次慢调用的概率为 1 − 0.95^20 ≈ 64.2%,并非每次都会发生。独立性和相同概率都是假设;共享瓶颈可能使调用相关,因此应直接测量端到端响应时间。

一个延迟 SLO 可以要求:在指定时间窗口内,99% 的有效读请求于 200 ms 内完成。还要明确请求范围、测量位置和错误处理方式。SLO 也可以描述可用性、正确性或数据新鲜度,并不只能是延迟百分位。SLA 则规定未达到约定目标时的合同后果。可参阅 Google 对 SLI、SLO 与 SLA 的说明。

队头阻塞与负载测试

队头阻塞(head-of-line blocking)是尾延迟的一个微妙但重要的放大器。服务器并发处理请求,但只到其线程池或连接上限。当少量慢请求占据那些线程,后续请求在它们后面排队、经历高延迟,即便服务器本身平均上并未过载。慢请求阻塞了队头,拖住一切。这个效应意味着即便单个请求的 p95 慢,也能在页面级产生高得多的端到端延迟,因为多个请求可能撞到慢请求后面的队列。

同时测量客户端与服务内部的耗时,避免遗漏网络和排队延迟。闭环压测中,客户端会等待响应后再发请求,因此服务变慢也会降低施加的负载,可能掩盖积压。如果真实请求的到达不依赖响应时间,应按对应到达率建模,并计入停顿期间本应到达的请求。压测模型要与真实客户端行为相符。

应对负载的方法

一旦你描述了负载参数并测量了性能,你面对如何扩展的问题。两种基本策略是纵向和横向扩展,但真正的决策更微妙:

  • 纵向扩展(scale up)——换更强的机器:更多 CPU、更多 RAM、更快磁盘。优势是简单;你不改架构。劣势是强力机器比商用机非线性地更贵,且有硬天花板——单节点上你只能买这么多 CPU。纵向扩展对 shared-nothing 水平分区在架构上复杂的数据库工作得好。
  • 横向扩展(scale out, shared-nothing)——把负载分散到更多商用机器。每台机器拥有数据子集或处理请求子集。更复杂去构建和运维,但天花板高得多、成本随负载更线性地扩展。无状态服务(如 HTTP API 处理器)自然横向扩展;有状态服务(如数据库)需要谨慎的分区设计。
  • 弹性 vs 手动扩展——弹性系统检测负载增加并自动开新实例(云环境的自动扩缩)。手动扩展需要人判断何时加容量。弹性扩展适合高度可变的负载(如突发新闻时流量飙升的新闻站);手动扩展更简单,对可预测、缓慢增长的负载足够。

纵向 vs 横向扩展的选择不纯是技术性的——它涉及运维复杂度、成本曲线和负载性质。一个从第一天就设计成横向可扩展的系统,对一个本可在单 Postgres 实例上跑数年的初创公司常是过度工程。务实做法:从简单开始,用真实数据识别实际瓶颈,扩展那个特定瓶颈而非猜测未来负载。

可维护性(Maintainability)

软件成本的大部分不在初始开发而在持续维护:修 bug、保持系统运行、调查故障、适配新平台、偿还技术债、加新功能。维护非自己构建系统的工程师花大量时间对抗本不必要、本可用更好的前期设计选择避免的复杂度。Kleppmann 识别可维护性的三个设计原则,各针对持续成本的一个不同轴。

可运维性:让运维生活轻松

负责运行系统的工程师可能也是代码作者,也可能属于独立团队。无论组织如何分工,可运维性都意味着让日常维护和事故响应可理解、可重复执行。

具体地,好的可运维性意味着:

  • 对运行时行为的可见性——通过 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 其余部分的组织透镜。当他引入一个新技术(复制、分区、事务、流处理),他总回到这三个问题:这帮助系统更可靠、更可扩展,还是更可维护?答案很少是"同时三者皆是"——每个技术有一个主要目标和次要成本,理解那些成本是工程成熟度的标志。

总结

先定义可测量的负载与服务要求,再识别故障域、观察响应时间分布,选择能够解决瓶颈的最小改动。评估方案时,也应计入运维、恢复与后续变更成本。

常见问题

故障和失效有什么区别?

故障是组件偏离其规格;失效是系统无法提供约定服务。失效可以表现为错误结果或无法接受的延迟,不只是整个系统宕机。

为什么除了平均响应时间,还要测量 p99?

平均值会隐藏分布。p99 描述尾部阈值,但不是最大值。尤其当用户操作需要等待多个下游调用时,应直接测量用户侧完整操作的响应时间。

DDIA 的 Twitter 扇出案例如何工作?

书中的历史案例对普通账号预计算时间线,对高关注账号在读取时合并。它说明关注者数量分布如何影响写放大,不代表 Twitter 当前的实现。

读完 DDIA 第 1 章,下一步学什么?

按顺序可继续第 2 章的数据模型。练习可靠性可读第 5 章的复制;练习扩展可结合第 6 章的分区和分片指南。先把权衡应用到具体服务,再选择组件。

系统设计练习:酒店搜索变慢了

假设酒店搜索接口每秒接收 800 个请求,每次搜索需要查询 20 个供应商。增加服务器前,先区分供应商等待、本地排队和数据库访问时间。

  1. 可靠性:供应商不可用时,搜索能否返回明确标注的部分结果?预订确认仍需检查权威库存,不能承诺不存在的房间。
  2. 可扩展性:若没有缓存或合批,这个例子最多产生每秒 16,000 次供应商请求。增加 worker 前,先测量供应商限流与并发约束。
  3. 可维护性:为供应商调用设置超时,记录请求 ID 和错误类别,并让部分结果策略可观测、可测试。

继续练习:酒店预订系统设计、DDIA 复制笔记、如何选择分片键。把三个属性落到具体设计决策上。

资料与版本说明

本文按第一版第 1 章组织。练习和概率计算是本站补充的解释性例子。原章见 O’Reilly 的 DDIA 第 1 章,监控实践可参考 Google SRE:监控分布式系统。

← 返回
DDIA