数据密集型应用设计的三个基本问题

数据密集型应用设计的三个基本问题

  • 可靠性(Reliability): 系统在困境(adversity) (硬件故障、软件故障、人为错误)中仍可正常工作(正确完成功能,并能达到期望的性能⽔准)。
  • 可扩展性(Scalability): 有合理的办法应对系统的增长(数据量、流量、复杂性)。
  • 可维护性(Maintainability): 许多不同的人(工程师、运维)在不同的生命周期,都能高效地在系统上工作(使系统保持现有行为,并适应新的应用场景)。

可靠性

人们对可靠软件的典型期望包括:

  • 应用程序表现出用户所期望的功能。
  • 允许用户犯错,允许用户以出乎意料的方式使用软件。
  • 在预期的负载和数据量下,性能满足要求。
  • 系统能防止未经授权的访问和滥用。

故障(fault): 系统的一部分状态偏离其标准。

失效(failure): 系统作为⼀个整体停止向用户提供服务,故障的概率不可能降到零,因此最好设计容错机制以防因故障而导致失效。

容错(fault-tolerant)/韧性(resilient): 能预料并应对故障的系统特性。

容忍错误比阻⽌错误 (prevent error) 更重要。


常见的故障及其恢复方法

  1. 硬件故障
    • 增加单个硬件的冗余度,磁盘可以组建 RAID,服务器可以有双路电源和热插拔 CPU,数据中心可能有电池和柴油发电机作为后备电源,某个组件挂掉时冗余组件可以立刻接管等。
    • 引入软件容错机制。
    • 如果是在云平台上,优先考虑 灵活性(flexibility)弹性(elasticity) 而不是单机可靠性。
  2. 软件错误(危害更大 更不可控 连锁型更强)
    • 原因
      • (1) 接受特定的错误输入,导致所有应用服务器器实例崩溃的 BUG (2012年6月30日 Linux内核中的闰秒导致多应用挂掉)
      • (2) 失控进程会占用一些共享资源,包括 CPU 时间、内存、磁盘空间或网络带宽。
      • (3) 系统依赖的服务变慢,没有响应,或者开始返回错误的响应。
      • (4) 级联故障,⼀个组件中的小故障触发另一个组件中的故障,进而触发更多的故障。
    • 解决策略
      • (1) 仔细考虑系统中的假设和交互。
      • (2) 彻底的测试。
      • (3) 进程隔离。
      • (4) 允许进程崩溃并重启。
      • (5) 测量、监控并分析生产环境中的系统行为。
      • (6) 系统自检,并在出现差异(discrepancy)时报警。比如检查一个消息队列中,进入与发出的消息数量是否相等。
  3. 人为错误
    • 以最小化犯错机会的方式设计系统。
    • 将人们最容易犯错的地方与可能导致失效的地方解耦(decouple),最好是提供一个功能齐全的非生产环境沙箱(sandbox)。
    • 在各个层次进行彻底的测试,从单元测试、全系统集成测试到手动测试,自动化测试易于理解,已经被广泛使用,特别适合用来覆盖正常情况中少见的边缘场景(corner case)。
    • 允许从人为错误中简单快速地恢复,以最大限度地减少失效情况带来的影响。例如,快速回滚配置变更,分批发布新代码,并提供数据重算工具(以备旧的计算出错)。
    • 配置详细和明确的监控,比如性能指标和错误率。
    • 良好的管理实践与充分的培训。

可扩展性

例子: 推特业务负载策略变化 (具体信息截取自2022年11月发布数据)
推特的两个主要业务为:

  • (1) 发布推文: 用户可以向其粉丝发布新消息(平均 4.6k 请求/秒,峰值超过 12k 请求/秒)。
  • (2) 主页时间线: 用户可以查阅他们关注的人发布的推文(300k 请求/秒)

针对以上需求,有两种实现方式。

  • (1) 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。
    推特业务实现方式-1
  • (2) 为每个用户的主页时间线维护⼀个缓存,就像每个用户的推文收件箱。当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。 因此读取主页时间线的请求开销很小,因为结果已经提前计算好。
    推特业务实现方式-2

推特在最初的版本里使用了方法1,但是系统很难跟上主页时间线查询的负载,所以后来转向了方法2。方法2效果更好的原因在于:发推频率比查询主页时间线的频率几乎低了两个数量级,所以最好在写入时做更多的工作,而在读取时做更少的工作。
方法 2 的缺点在于发推现在需要大量的额外工作。平均来说,一条推文会发往约75个关注者,所以每秒4.6k的发推写入就变成了对主页时间线缓存每秒345k的写入。但是考虑到极端值: 一些用户有超过3000万的粉丝,这意味着⼀条推文就可能会导致主页时间线缓存的 3000 万次写入,而这些操作需要在 5 秒内完成!
推特最终策略是选择了两种方法的混合:大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户(即名流)会被排除在外。当用户读取主页时间线时,分别地获取出该用户所关注的每位名流的推文,再与用户的主页时间线缓存合并。

如何描述系统性能

  1. 吞吐量(throughput): 每秒可以处理的记录数量,或者在特定规模数据集上运行作业的总时间,一般针对于 Hadoop 这样的批处理系统。
  2. 响应时间(response time): 即客户端发送请求到接收响应之间的时间,针对于线上系统。(注意和延迟相区分)
    • 不断重复发送同样的请求,每次得到的响应时间也都会略有不同,因此我们需要将响应时间视为一个可以测量的数值分布(distribution),而不是单个数值。
    • 通常使用百分位点(percentiles)而非算数平均值(arithmetic mean)来描述整体分布,因为平均值对于极端值的敏感度太高,无法真实反映有多少用户经历了高延迟。
    • p50/p95/p99/p999: 意味着 50%, 95%, 99%, 99.9%的请求响应时间要比该阈值快
    • 尾部延迟(tail latencies): 响应时间的⾼百分位点,非常重要,因为直接影响用户的服务体验。
    • 百分位点通常用于 服务级别目标(SLO, service level objectives)服务级别协议(SLA, service level agreements) ,即定义服务预期性能和可用性的合同。
  3. 服务级别协议(SLA, service level agreements): SLA 可能会声明,如果服务响应时间的中位数小于 200 毫秒,且 99.9 百分位点低于 1 秒,则认为服务工作正常(如果响应时间更长,就认为服务不达标)。这些指标为客户设定了期望值,并允许客户在 SLA 未达标的情况下要求退款。
  4. 延迟(latency): 响应时间是客户所看到的,除了实际处理请求的时间(服务时间 service time)之外,还包括网络延迟和排队延迟。延迟是某个请求等待处理的持续时长,在此期间它处于休眠(latent)状态以等待下游服务的响应。

排队延迟(queueing delay) 通常占了高百分位点处响应时间的很大一部分。由于服务器只能并行处理少量的事务(受其 CPU 核数等因素的限制),所以只要有少量缓慢的请求就能阻碍后续请求的处理,这种效应有时被称为 头部阻塞(head-of-line blocking)。即使后续请求在服务器上处理非常迅速,由于需要等待先前请求完成,客户端最终看到的是缓慢的总体响应时间。因为存在这种效应,测量客户端的响应时间非常重要。也就是说,在进行压力测试时,产生负载的客户端要独立于响应时间不断发送请求,如果客户端在发送下一个请求之前等待先前的请求完成,这种行为会产生人为排队的效果,使得测试时的队列比现实情况更短,使测量结果产生偏差。

应对负载的方法

  1. 纵向扩展(scaling up)/ 垂直扩展(vertical scaling): 转向更强大的机器。
  2. 横向扩展(scaling out) / 水平扩展(horizontal scaling): 将负载分布到多台小机器上。
  3. 弹性(elastic): 在检测到负载增加时自动增加计算资源。

在早期创业公司或非正式产品中,通常支持产品快速迭代的能力,要比可扩展至未来的假想负载要重要的多。

可维护性

软件的大部分开销并不在最初的开发阶段,而是在持续的维护阶段。

为了避免自己的软件系统成为遗留(legacy)系统,在设计之初就应该尽量考虑减少维护期间的痛苦。为此,我们需要特别关注软件系统的三个设计原则:

  1. 良好的可操作性(Operability): 方便后续运维团队保持系统平稳运行
    • 通过良好的监控,提供对系统内部状态和运行时行为的可见性(visibility)。
    • 为自动化提供良好支持,将系统与标准化工具相集成。
    • 避免依赖单台机器(在整个系统继续不间断运行的情况下允许机器停机维护)。
    • 提供良好的文档和易于理解的操作模型(“如果做 X,会发生 Y”)。
    • 提供良好的默认行为,但需要时也允许管理员自由覆盖默认值。
    • 有条件时进行自我修复,但需要时也允许管理员⼿手动控制系统状态。
    • 行为可预测,最大限度减少意外。
  2. 简单性(Simplicity): 从系统中消除尽可能多的复杂度(complexity),使新工程师也能轻松理解系统。
    • 管理复杂度(complexity),例如:状态空间激增、模块间紧密耦合、纠结的依赖关系、不一致的命名和术语、解决性能问题的 Hack、需要绕开的特例等。
    • 用于消除额外复杂度的最好工具之一是抽象(abstraction)
      • 一个好的抽象可以将大量实现细节隐藏在一个干净,简单易懂的外观下面。
      • 一个好的抽象也可以广泛用用于各类不同应用。
      • 比起重复造很多轮子,重用抽象不仅更有效率,而且有助于开发高质量的软件。
      • 抽象组件的质量改进将使所有使用它的应用受益。
  3. 可演化性(evolability)/ 可扩展性(extensibility)/ 可修改性(modifiability)/可塑性(plasticity): 使工程师在未来能轻松地对系统进行更改,当需求变化时为新应用场景做适配。
    • 拥抱变化,因为系统的需求总是处于常态的变化中,比如了解了新的事实、出现意想不到的应用场景、业务优先级发生变化、用户要求新功能、新平台取代旧平台、法律律或监管要求发生变化、系统增长迫使架构变化等。
    • 在组织流程方面,敏捷(agile) 工作模式为适应变化提供了⼀个框架。敏捷社区还开发了对在频繁变化的环境中开发软件很有帮助的技术工具和模式,如测试驱动开发(TDD, test-driven development)和重构(refactoring) 。