ch2-定义非功能性需求

第 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):

  1. 请求队列过长 → 响应时间大增
  2. 客户端超时 → 重发请求
  3. 请求率进一步增加 → 问题恶化
  4. 即使负载降低,系统可能仍保持过载状态直到重启

这种现象称为亚稳态故障(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/p99995%/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),比不相关的硬件故障更难预料,导致更多系统故障。

类型示例
同时故障 bug2012 年闰秒导致许多 Java 应用同时挂起;特定型号 SSD 在 32,768 小时后突然全部故障
资源耗尽处理大请求时内存耗尽被 OS 杀死;客户端库 bug 导致请求量远超预期
依赖服务问题依赖服务变慢、无响应或返回损坏响应
涌现行为系统间交互产生单独测试时未出现的行为
级联故障一个组件问题导致另一组件过载变慢,进而拖垮更多组件

应对: 仔细思考假设和交互、全面测试、进程隔离、允许崩溃重启、避免重试风暴等反馈循环、生产环境监控分析。

人员与可靠性

人类设计和构建软件系统,运维人员也是人类。人类的优势是创造性和适应性,但也导致不可预测性,有时会导致失误。

研究发现: 大型互联网服务中,运维人员的配置变更是故障主因,而硬件故障仅占 10-25%。

无责文化: 责备”人为错误”适得其反。“人为错误”不是事故原因,而是社会技术系统问题的症状。无责事后分析(Blameless Postmortems) 鼓励相关人员分享完整细节,无需担心惩罚,让组织学习如何预防类似问题。

可靠性有多重要?

可靠性不仅适用于核电站和空中交通管制——日常应用也期望可靠工作。

后果示例
生产力损失业务应用 bug
法律风险数据报告错误
收入损失电商网站中断
声誉损害服务中断
灾难性后果数据永久丢失或损坏

案例:英国邮政局 Horizon 丑闻 1999-2019 年,数百名邮政支局经理因会计软件显示账户短缺而被判盗窃或欺诈罪。最终发现许多短缺是软件 bug 导致,大量定罪被推翻。这是英国历史上最大的司法误判之一,源于法律假定计算机运行正确。


可扩展性

即使系统今天可靠运行,未来未必如此。常见退化原因是负载增加:并发用户从 1 万增至 10 万,或数据量从 100 万增至 1000 万。

可扩展性: 系统应对增加负载的能力。

常见误解: “你又不是 Google 或 Amazon,别操心扩展性,直接用关系数据库就行。“——这是否适用取决于应用类型。

初创环境: 首要工程目标通常是保持系统简单灵活,便于根据客户需求修改功能。此时担心假设性的未来扩展性是过早优化,可能锁定不灵活的设计。

描述负载

首先需要简洁描述系统当前负载,然后才能讨论增长问题。通常是吞吐量度量:每秒请求数、每天新增数据 GB 数、每小时购物车结账数。有时关注变量峰值,如同时在线用户数。

其他统计特征: 读写比、缓存命中率、每用户数据项数(如粉丝数)。平均情况重要,或瓶颈由少数极端情况主导,取决于应用细节。

负载增加的两种视角:

  1. 增加负载但保持资源不变,性能如何变化?
  2. 增加负载,需增加多少资源才能保持性能不变?

扩展架构

架构名称特点优缺点
垂直扩展纵向扩展/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 中的使用。可扩展性是密切相关概念:确保负载增长时性能保持不变。我们看到了可扩展性的一般原则,如将任务分解为可独立运行的较小部分,后续章节将深入探讨可扩展性技术细节。

为实现可靠性,可使用容错技术,允许系统在部分组件(硬盘、机器或其他服务)故障时继续提供服务。我们看到了可能发生的硬件故障示例,并将其与通常高度相关的软件故障区分开。实现可靠性的另一方面是建立对人类失误的韧性,我们介绍了无责事后分析作为从事故中学习的技巧。

最后,我们考察了可维护性的多个方面,包括支持运维团队工作、管理复杂性、使应用功能随时间演进更容易。如何实现这些没有简单答案,但有帮助的一点是使用良好理解的构建块来构建应用,这些构建块提供有用的抽象。本书其余部分将介绍在实践中证明有价值的构建块选择。