第 2 章 定义非功能性需求
“互联网做得如此出色,以至于大多数人将其视为像太平洋一样的自然资源,而非人造产物。上一次出现如此规模且几乎零失误的技术是什么时候?”
—— Alan Kay,Dr Dobb’s Journal 访谈(2012 年)
早期版本读者须知
通过早期发布版电子书,您可以在作者写作过程中获取最新的原始内容——这样您就能在这些书籍正式发布之前,抢先掌握相关技术。
本章将是最终成书的第 2 章。本书的 GitHub 仓库地址为:https://github.com/ept/ddia2-feedback
如果您希望积极参与本草稿的审阅和评论,请在 GitHub 上联系我们。
功能性需求与非功能性需求
在构建应用程序时,您会被一系列需求所驱动。列表最顶端的通常是应用程序必须提供的功能性需求:需要哪些界面和按钮,每个操作应该执行什么功能,以实现软件的目标。
此外,您可能还有一些非功能性需求:例如,应用应该快速、可靠、安全、合规且易于维护。这些需求可能没有明确书面记录,因为它们看似显而易见,但它们与功能性需求同等重要——一个慢得难以忍受或不可靠的应用,实际上等同于不存在。
许多非功能性需求(如安全性)超出了本书的范围。但有一些非功能性需求是我们需要考虑的,本章将帮助您为自己的系统明确这些需求:
- 如何定义和衡量系统性能(见”描述性能”)
- 服务可靠性的含义——即即使在出现问题时仍能正确运行(见”可靠性与容错”)
- 通过有效添加计算能力使系统具备可扩展性(见”可扩展性”)
- 降低系统长期维护难度(见”可维护性”)
本章引入的术语在后续章节中也将派上用场,届时我们将深入探讨数据密集型系统的实现细节。然而,抽象定义可能相当枯燥;为了让概念更具体,我们将以社交网络服务的工作方式作为案例研究开始本章,为性能和可扩展性提供实际示例。
案例研究:社交网络主页时间线
假设您被要求实现一个类似 X(原 Twitter)风格的社交网络,用户可以发布消息并关注其他用户。这将是对此类服务实际工作方式的极大简化,但有助于说明大规模系统中出现的一些问题。
假设条件:
- 用户每天发布 5 亿条帖子,平均每秒 5,700 条
- 峰值可达每秒 150,000 条
- 平均用户关注 200 人,拥有 200 个粉丝
- (范围很广:大多数人只有少量粉丝,而少数名人如奥巴马拥有超过 1 亿粉丝)
用户、帖子与关注关系的表示
假设我们将所有数据存储在关系数据库中,如图 2-1 所示。我们有用户表、帖子表和关注关系表。
图2-1:简单的社交网络关系型模式,用户可以关注彼此
主要读取操作:主页时间线
显示用户关注的人的最近帖子(为简化,我们忽略广告、推荐帖子等扩展功能)。
SELECT posts.*, users.* FROM posts
JOIN follows ON posts.sender_id = follows.followee_id
JOIN users ON posts.sender_id = users.id
WHERE follows.follower_id = current_user
ORDER BY posts.timestamp DESC
LIMIT 1000
执行过程: 数据库使用 follows 表找到 current_user 关注的所有人,查找这些人的最近帖子,按时间戳排序获取关注者中最新的 1,000 条帖子。
时效性要求: 用户发布后,希望在 5 秒内让粉丝看到。
朴素方案的问题:
- 用户客户端每 5 秒轮询一次查询
- 假设 1,000 万用户同时在线 → 每秒 200 万次查询
- 查询成本高昂:关注 200 人需要获取 200 人的最近帖子列表并合并
- 每秒 200 万次时间线查询 = 数据库每秒 4 亿次发送者帖子查找
- 极端用户(关注数万人)查询极其昂贵
时间线的物化与更新
优化方案:
| 方面 | 原方案 | 优化方案 |
|---|---|---|
| 推送方式 | 客户端轮询 | 服务器主动推送 |
| 查询方式 | 实时计算 | 预计算结果,从缓存读取 |
实现方式: 为每个用户存储其主页时间线数据结构(关注者的最近帖子)。用户发帖时,查找所有粉丝,将帖子插入每个粉丝的时间线——就像投递邮件到信箱。
扇出(Fan-out): 当一个初始请求导致多个下游请求时,描述请求数量增长倍数的术语。
图2-2:扇出:将新帖子投递给发帖用户的每个粉丝
计算对比:
- 发帖率:5,700 条/秒
- 平均粉丝数:200
- 时间线写入:约 100 万次/秒
- vs. 原方案的 4 亿次/秒查找 → 显著节省
负载峰值处理: 可以排队处理时间线投递,暂时接受帖子显示延迟,但时间线加载保持快速(从缓存读取)。
物化(Materialization): 预计算和更新查询结果的过程。时间线缓存是物化视图的示例。
极端情况处理:
| 场景 | 问题 | 解决方案 |
|---|---|---|
| 用户关注大量活跃账号 | 时间线写入率高 | 可丢弃部分写入,仅显示样本 |
| 名人账号(百万粉丝)发帖 | 需写入百万时间线 | 单独存储名人帖子,读取时合并 |
描述性能
软件性能讨论通常考虑两类主要指标:
响应时间(Response Time)
从用户发出请求到收到响应的经过时间。单位为秒(或毫秒、微秒)。
吞吐量(Throughput)
系统每秒处理的请求数或数据量。对于给定的硬件资源配置,存在最大可处理吞吐量。单位为”每秒某物”。
案例研究中的对应:
- 吞吐量指标:“每秒帖子数”、“每秒时间线写入数”
- 响应时间指标:“加载主页时间线的时间”、“帖子投递给粉丝的时间”
吞吐量与响应时间的关系
图2-3:当服务吞吐量接近容量时,由于排队,响应时间急剧增加
排队效应: 高负载系统上,请求到达时 CPU 可能正处理早期请求,新请求需要等待。吞吐量接近硬件极限时,排队延迟急剧增加。
过载系统的恢复困境
系统接近过载时,可能进入恶性循环,效率降低导致更加过载:
重试风暴(Retry Storm):
- 请求队列过长 → 响应时间大增
- 客户端超时 → 重发请求
- 请求率进一步增加 → 问题恶化
- 即使负载降低,系统可能仍保持过载状态直到重启
这种现象称为亚稳态故障(Metastable Failure)。
防护机制:
| 机制 | 作用 |
|---|---|
| 指数退避(Exponential Backoff) | 增加并重试间隔随机化 |
| 熔断器(Circuit Breaker) | 暂时停止向近期出错或超时的服务发送请求 |
| 令牌桶算法(Token Bucket) | 限流 |
| 负载削减(Load Shedding) | 服务器主动拒绝接近过载的请求 |
| 背压(Backpressure) | 通知客户端减速 |
延迟与响应时间
注意: “延迟”和”响应时间”有时互换使用,但本书有特定区分。
图2-4:响应时间、服务时间、网络延迟和排队延迟
| 术语 | 定义 |
|---|---|
| 响应时间 | 客户端看到的总时间,包含系统内所有延迟 |
| 服务时间 | 服务主动处理用户请求的持续时间 |
| 排队延迟 | 请求等待处理的时间(等 CPU、网络缓冲等) |
| 延迟(Latency) | 请求未被主动处理的时间统称,特别是网络延迟 |
响应时间变化因素: 上下文切换、网络丢包与 TCP 重传、GC 暂停、缺页中断、服务器机架机械振动等。
队头阻塞(Head-of-Line Blocking): 服务器并行处理能力有限,少量慢请求会阻塞后续请求处理。
平均值、中位数与百分位数
图2-5:说明均值和百分位数:服务100个请求样本的响应时间
| 指标 | 说明 | 用途 |
|---|---|---|
| 平均值(Mean) | 算术平均:总响应时间 ÷ 请求数 | 估算吞吐量限制 |
| 中位数(p50) | 排序后中间点:50%请求更快,50%更慢 | 了解典型用户体验 |
| p95/p99/p999 | 95%/99%/99.9%请求快于该阈值 | 了解异常值严重程度 |
尾部延迟(Tail Latencies): 高百分位响应时间,直接影响用户体验。
Amazon 案例: 内部服务响应时间要求以 p999 定义,尽管只影响 1/1000 请求。因为最慢请求的用户往往是账户数据最多、购买最多的最有价值客户。
优化权衡: p9999 优化成本过高,收益递减,因易受随机事件影响。
响应时间对用户体验的影响
| 研究 | 发现 |
|---|---|
| Google 2006 | 搜索从 400ms 降至 900ms,流量和收入下降 20% |
| Google 2009 | 延迟增加 400ms,日搜索量仅减少 0.6% |
| Bing 2009 | 加载时间增加 2 秒,广告收入减少 4.3% |
| Akamai 2017 | 响应时间增加 100ms,电商转化率降低 7% |
| Yahoo | 快搜索比慢搜索(差 1.25 秒以上)点击率高 20-30% |
注意: 部分研究存在方法论问题(如最快页面可能是 404 错误页),需谨慎解读。
响应时间指标的使用
尾部延迟放大(Tail Latency Amplification): 后端服务被多次调用时,即使并行调用,最终用户请求仍需等待最慢的调用完成。少量慢后端调用会导致更高比例的最终用户请求变慢。
图2-6:服务请求需要多个后端调用时,单个慢后端请求即可拖慢整个最终用户请求
SLO 与 SLA:
- SLO(服务等级目标): 定义服务预期性能和可用性,如中位数响应时间<200ms,p99<1s,99.9%有效请求返回非错误响应
- SLA(服务等级协议): 合同,规定未达 SLO 的后果(如退款)
计算百分位数
需求: 为监控仪表板添加响应时间百分位数,需高效持续计算(如滚动 10 分钟窗口,每分钟计算中位数和各百分位数)。
实现方式:
- 简单实现:保存窗口内所有响应时间列表,每分钟排序
- 高效算法:HdrHistogram、t-digest、OpenHistogram、DDSketch 等
警告: 平均百分位数在数学上无意义——正确聚合方式是添加直方图。
可靠性与容错
软件可靠性的典型期望:
- 执行用户预期的功能
- 容忍用户错误或意外使用方式
- 在预期负载和数据量下性能足够好
- 防止未授权访问和滥用
可靠性定义: 大致意味着”即使出问题,仍能继续正确运行”。
故障与失效
| 术语 | 定义 | 示例 |
|---|---|---|
| 故障(Fault) | 系统某部分停止正常工作 | 单个硬盘故障、单台机器崩溃、外部服务中断 |
| 失效(Failure) | 系统整体停止向用户提供所需服务 | 未达 SLO |
层级关系: 故障和失效是同一事物的不同层级。单硬盘失效,对多硬盘系统只是故障。
容错(Fault Tolerance)
定义: 尽管发生某些故障,仍能继续提供所需服务。
单点故障(SPOF): 系统无法容忍某部分故障,该部分故障会导致整个系统失效。
案例研究中的容错: 扇出过程中,更新时间线物化的机器崩溃。需确保另一台机器可接管,不遗漏帖子,不重复投递——即恰好一次语义(第 12 章详述)。
容错限制: 限于特定数量和类型的故障(如最多 2 个硬盘同时故障,3 个节点中最多 1 个崩溃)。无法容忍任意数量故障(如所有节点崩溃)。
故障注入(Fault Injection): 故意触发故障(如随机杀死进程),确保容错机制持续得到锻炼和测试。混沌工程(Chaos Engineering) 即通过故意注入故障来提高对容错机制的信心。
硬件故障与软件故障
硬件故障
| 组件 | 故障率 | 说明 |
|---|---|---|
| 机械硬盘 | 每年 2-5% | 10,000 盘集群平均每天 1 个故障 |
| SSD | 每年 0.5-1% | 不可纠正错误约每年每盘 1 次,高于机械硬盘 |
| 电源、RAID 控制器、内存模块 | 较低 | 但仍有故障 |
| CPU 核心 | 约 1/1000 | 偶尔计算错误,可能由制造缺陷导致 |
| 内存数据 | >1%/年 | 即使使用 ECC,不可纠正错误导致崩溃 |
大规模故障: 整个数据中心可能不可用(停电、网络配置错误)或被永久摧毁(火灾、洪水、地震)。太阳风暴可能损坏电网和海底电缆。
冗余应对: RAID 配置、双电源、热插拔 CPU、备用发电机等。最有效时组件故障独立,但实际上常存在相关性。
云系统趋势: 较少关注单台机器可靠性,而是通过软件层容忍故障节点来实现高可用。使用可用区(Availability Zones) 标识物理共置资源。
软件故障
软件故障往往高度相关(多节点运行相同软件,有相同 bug),比不相关的硬件故障更难预料,导致更多系统故障。
| 类型 | 示例 |
|---|---|
| 同时故障 bug | 2012 年闰秒导致许多 Java 应用同时挂起;特定型号 SSD 在 32,768 小时后突然全部故障 |
| 资源耗尽 | 处理大请求时内存耗尽被 OS 杀死;客户端库 bug 导致请求量远超预期 |
| 依赖服务问题 | 依赖服务变慢、无响应或返回损坏响应 |
| 涌现行为 | 系统间交互产生单独测试时未出现的行为 |
| 级联故障 | 一个组件问题导致另一组件过载变慢,进而拖垮更多组件 |
应对: 仔细思考假设和交互、全面测试、进程隔离、允许崩溃重启、避免重试风暴等反馈循环、生产环境监控分析。
人员与可靠性
人类设计和构建软件系统,运维人员也是人类。人类的优势是创造性和适应性,但也导致不可预测性,有时会导致失误。
研究发现: 大型互联网服务中,运维人员的配置变更是故障主因,而硬件故障仅占 10-25%。
无责文化: 责备”人为错误”适得其反。“人为错误”不是事故原因,而是社会技术系统问题的症状。无责事后分析(Blameless Postmortems) 鼓励相关人员分享完整细节,无需担心惩罚,让组织学习如何预防类似问题。
可靠性有多重要?
可靠性不仅适用于核电站和空中交通管制——日常应用也期望可靠工作。
| 后果 | 示例 |
|---|---|
| 生产力损失 | 业务应用 bug |
| 法律风险 | 数据报告错误 |
| 收入损失 | 电商网站中断 |
| 声誉损害 | 服务中断 |
| 灾难性后果 | 数据永久丢失或损坏 |
案例:英国邮政局 Horizon 丑闻 1999-2019 年,数百名邮政支局经理因会计软件显示账户短缺而被判盗窃或欺诈罪。最终发现许多短缺是软件 bug 导致,大量定罪被推翻。这是英国历史上最大的司法误判之一,源于法律假定计算机运行正确。
可扩展性
即使系统今天可靠运行,未来未必如此。常见退化原因是负载增加:并发用户从 1 万增至 10 万,或数据量从 100 万增至 1000 万。
可扩展性: 系统应对增加负载的能力。
常见误解: “你又不是 Google 或 Amazon,别操心扩展性,直接用关系数据库就行。“——这是否适用取决于应用类型。
初创环境: 首要工程目标通常是保持系统简单灵活,便于根据客户需求修改功能。此时担心假设性的未来扩展性是过早优化,可能锁定不灵活的设计。
描述负载
首先需要简洁描述系统当前负载,然后才能讨论增长问题。通常是吞吐量度量:每秒请求数、每天新增数据 GB 数、每小时购物车结账数。有时关注变量峰值,如同时在线用户数。
其他统计特征: 读写比、缓存命中率、每用户数据项数(如粉丝数)。平均情况重要,或瓶颈由少数极端情况主导,取决于应用细节。
负载增加的两种视角:
- 增加负载但保持资源不变,性能如何变化?
- 增加负载,需增加多少资源才能保持性能不变?
扩展架构
| 架构 | 名称 | 特点 | 优缺点 |
|---|---|---|---|
| 垂直扩展 | 纵向扩展/Scale Up | 迁移到更强大机器 | 成本超线性增长,存在瓶颈 |
| 共享内存 | Shared-Memory | 多线程访问同一内存 | 同上 |
| 共享磁盘 | Shared-Disk | 多机器独立 CPU/内存,共享磁盘阵列 | 传统用于本地数据仓库,竞争和锁开销限制扩展性 |
| 共享无 | 横向扩展/Scale Out | 分布式多节点,各自 CPU/内存/磁盘,软件层协调 | 潜在线性扩展,最佳性价比,易调整资源,更高容错;需显式分片,分布式系统复杂性 |
云原生数据库: 部分使用存储与计算分离的架构,多计算节点共享存储服务,避免旧系统扩展问题。
可扩展性原则
大规模系统架构通常高度特定于应用——不存在通用的”魔法扩展酱”。
关键原则:
- 将系统分解为可独立运行的较小组件(微服务、分片、流处理、共享无架构的基础)
- 不要过度复杂化:单机数据库能胜任就优先于复杂分布式设置
- 负载可预测时,手动扩展可能比自动扩展更少运维意外
- 五个服务的系统比五十个简单
- 好的架构通常是务实的方法混合
可维护性
软件不会像机械物体那样磨损或材料疲劳。但应用需求经常变化,运行环境变化(依赖和底层平台),且有 bug 需要修复。
维护成本: 软件大部分成本不在初始开发,而在持续维护——修复 bug、保持运行、调查故障、适配新平台、修改新用例、偿还技术债、添加新功能。
维护困难: 长期运行的系统可能使用过时技术(如大型机和 COBOL),机构知识随人员流失而丢失,需修复他人错误,且系统与支撑的人类组织交织,维护既是技术问题也是人员问题。
我们今天创建的每个系统,只要有价值长期存活,终有一天会成为遗留系统。
可维护性的三个维度
| 维度 | 目标 | 实现方式 |
|---|---|---|
| 可操作性(Operability) | 让组织轻松保持系统平稳运行 | 监控、文档、默认行为、自愈、可预测行为 |
| 简单性(Simplicity) | 让新工程师易于理解系统 | 使用良好理解的一致模式和结构,避免不必要复杂性 |
| 可演进性(Evolvability) | 让工程师未来易于修改系统 | 适应未预见用例,随需求变化扩展 |
可操作性:让运维工作轻松
“好的运维通常可以绕过坏(或不完整)软件的限制,但好软件无法在糟糕的运维下可靠运行。”
自动化的双刃剑: 大规模系统中手动维护成本过高,自动化必不可少。但边缘情况(如罕见故障场景)仍需运维团队手动干预。自动化出错时,排查比手动系统更难。
良好可操作性的特征:
- 监控工具可检查关键指标,可观测性工具提供运行时行为洞察
- 不依赖单台机器(可停机维护而系统整体持续运行)
- 良好文档和易理解的运维模型(“如果我做 X,Y 会发生”)
- 良好默认行为,但管理员可覆盖默认
- 适当自愈,但管理员可手动控制系统状态
- 可预测行为,最小化意外
简单性:管理复杂性
小软件项目可以有简洁优雅的代码,但项目变大后往往变得复杂难懂。大泥球(Big Ball of Mud) 描述陷入复杂性的软件项目。
复杂性降低维护性,增加变更引入 bug 的风险。减少复杂性显著提高可维护性,简单性应是我们构建系统的关键目标。
本质复杂性与偶然复杂性: 前者内在于问题域,后者源于工具限制。但界限随工具演进变化。
抽象: 管理复杂性的最佳工具。良好抽象隐藏大量实现细节,提供简洁易理解的 facade,且可广泛用于不同应用。
示例: 高级编程语言隐藏机器码、CPU 寄存器、系统调用;SQL 隐藏复杂磁盘和内存数据结构、并发请求、崩溃后不一致性。
可演进性:让变更容易
系统需求极不可能永远不变。更可能不断变化:学到新事实、出现未预见用例、业务优先级变化、用户请求新功能、新平台替代旧平台、法律监管要求变化、系统增长迫使架构变更等。
敏捷工作模式提供适应变化的框架。测试驱动开发(TDD) 和重构等技术有助于频繁变化环境下的软件开发。
可演进性(Evolvability): 指数据系统层面的敏捷性。松散耦合的简单系统通常比紧耦合的复杂系统更易修改。
最小化不可逆性: 大型系统中变更困难的主要因素之一是某些操作不可逆。例如数据库迁移:如无法在新系统出问题时切回旧系统,风险远高于可轻松回退的情况。
小结
本章我们考察了非功能性需求的几个示例:性能、可靠性、可扩展性和可维护性。通过这些主题,我们也介绍了全书需要的原则和术语。我们从社交网络主页时间线的实现案例开始,说明了大规模系统中出现的挑战。
我们讨论了如何衡量性能(如使用响应时间百分位数)、系统负载(如使用吞吐量指标),以及它们在 SLA 中的使用。可扩展性是密切相关概念:确保负载增长时性能保持不变。我们看到了可扩展性的一般原则,如将任务分解为可独立运行的较小部分,后续章节将深入探讨可扩展性技术细节。
为实现可靠性,可使用容错技术,允许系统在部分组件(硬盘、机器或其他服务)故障时继续提供服务。我们看到了可能发生的硬件故障示例,并将其与通常高度相关的软件故障区分开。实现可靠性的另一方面是建立对人类失误的韧性,我们介绍了无责事后分析作为从事故中学习的技巧。
最后,我们考察了可维护性的多个方面,包括支持运维团队工作、管理复杂性、使应用功能随时间演进更容易。如何实现这些没有简单答案,但有帮助的一点是使用良好理解的构建块来构建应用,这些构建块提供有用的抽象。本书其余部分将介绍在实践中证明有价值的构建块选择。