读构建可扩展分布式系统:方法与实践15可扩展系统的基本要素

1. 可扩展系统的基本要素

1.1. 分布式系统在本质上就是复杂的,你必须考虑多种故障模式,并设计应对所有可能发生的情况的处理方式

1.2. 大规模应用程序需要协调大量的硬件和软件组件,共同实现低延迟和高吞吐量的能力

1.3. 面临的挑战是将所有活动部件组合成一个应用程序来运行,使其既能满足需求又不会耗费过多成本

  • 1.3.1. 新的编程抽象、平台模型和硬件让你更容易构建具有更高的性能、更好的可扩展性和更大的弹性的更复杂的系统

2. 自动化

2.1. 在构建大型系统时,工程师是相当昂贵但必不可少的资源

2.2. 需要部署频繁的更改来改善客户体验,并确保可靠和可扩展的操作

2.3. 在不停机的情况下每天有效地将数百个更改推送到已部署系统的能力是系统规模化的关键所在

2.4. 促进自动化的一组工具和实践体现在DevOps文化中

  • 2.4.1. DevOps包含一组面向从开发到部署各个级别过程的自动化实践和工具

  • 2.4.2. DevOps的核心是持续交付(CD)实践,由用于代码配置管理、自动化测试、部署和监控的复杂工具链提供支持

2.5. DevOps实践对于成功的可扩展系统至关重要

  • 2.5.1. 团队有责任设计、开发和运营他们的微服务,微服务通过良好定义的接口与系统的其余部分进行交互

  • 2.5.2. 借助自动化工具链,可以在微服务中独立部署本地更改和新功能,同时不干扰系统操作

  • 2.5.3. 自动化减少了协调开销,提高了生产力,缩短了发布周期

  • 2.5.4. 意味着你的工程投资将获得更大的回报

3. 可观测性

3.1. 你无法管理你无法衡量的东西

3.2. 由于有大量移动部件,所有部件都在可变负载条件下运行,容易出现不可预测的错误

3.3. 需要借助测量系统提供的健康状况和行为来观测系统状态

  • 3.3.1. 提供基础设施不断生成的细粒度指标和日志数据来捕获系统当前状态

  • 3.3.2. 分析聚合实时指标并采取行动,对指示实际或未决故障的警报做出反应

3.4. 可观测性的第一个基本要素是具有一个仪表化系统,它不断以指标和日志条目的形式发出系统遥测数据

  • 3.4.1. 可以来自操作系统、你在应用程序中使用的基础平台(例如,消息传递、数据库)以及你部署的应用程序代码

  • 3.4.2. 指标表示资源利用率以及系统各部分提供的延迟、响应时间和吞吐量

3.5. 代码检测是强制性的,你可以使用开源框架或专有解决方案

  • 3.5.1. 指标和日志条目形成了基于时间序列的连续的数据流,表征了你的应用程序随时间的行为

3.6. 捕获原始指标数据是可观测性系统推断并感知态势的先决条件

  • 3.6.1. 需要快速处理数据流,它才可能让系统及时采取行动

  • 3.6.2. 包括持续监控当前状态、探索历史数据以了解或诊断一些意外的系统行为,以及在超过阈值或发生故障时发送实时警报

3.7. Prometheus、Grafana和Graphite是目前广泛使用的技术,它们提供了适用于可观测性栈各个部分的开箱即用的解决方案

3.8. 可观测性是可扩展分布式系统的必要组成部分

4. 部署平台

4.1. 可扩展系统需要大规模、有弹性且可靠的计算和数据平台

4.2. 可以使用专为操作设计的脚本语言自动调用配置

  • 4.2.1. 基础架构即代码(IaC),也是DevOps的基本要素

4.3. 传统上,虚拟机是应用程序的部署单元

  • 4.3.1. 容器镜像支持将应用程序代码和依赖项打包到单个可部署单元中

  • 4.3.2. 与虚拟机相比,容器消耗的资源更少,因此可以在单个虚拟机上打包多个容器,更有效地利用硬件资源

4.4. 容器通常与集群管理平台(如Kubernetes或Apache Mesos)一起使用

  • 4.4.1. 容器编排平台为你提供API来控制容器的执行方式、时间和位置

  • 4.4.2. 平台允许你自动部署容器并支持使用自动缩放的不同系统负载,简化集群中在多个节点部署多个容器的管理工作

5. 数据湖

5.1. 随着时间的推移,你的系统将生成许多PB级或更多的数据

  • 5.1.1. 数据中的大部分很少被你的用户访问

5.2. 管理、组织和存储历史数据存储库是数据仓库、大数据和数据湖的领域范围所在

  • 5.2.1. 本质是以一种可以检索、查询和分析的形式来存储历史数据

5.3. 数据湖的特征是以异构格式存储和编目数据,从原生blob到JSON再到关系数据库提取数据

  • 5.3.1. 利用Apache Hadoop、Amazon S3或Microsoft Azure Data Lake等低成本对象存储

  • 5.3.2. 灵活的查询引擎支持数据的分析和转换

  • 5.3.3. 可以使用不同的存储类别,以本质上更长的检索时间换取更低的成本,继而优化成本

6. 并发系统

6.1. 分布式系统包括多个独立的代码片段,它们在不同位置的多个处理节点上并行或并发地执行

6.2. 任何分布式系统都是并发系统,即使每个节点一次只处理一个事件也是如此

  • 6.2.1. 在分布式系统中协调节点充满了风险

6.3. 编写软件来并发地执行多个操作,有助于优化单个节点上的处理能力和资源利用率,提高本地和系统范围的处理能力

6.4. 在过去的计算时代,每个CPU在任何时刻都只能执行一条机器指令

  • 6.4.1. 程序试图读取文件或在网络上发送消息时,它必须与CPU外围的硬件子系统(磁盘、网卡)进行交互

  • 6.4.2. 从硬盘读取数据大约需要10ms。在此期间,程序必须等待可供处理的数据

  • 6.4.3. Linux等操作系统可以在单个CPU上运行多个程序的方式

  • 6.4.4. 将软件明确地构造成具有多个可以并行执行的活动,在其他任务等待I/O时,操作系统可以安排有工作要做的任务

6.5. 使用多核芯片,可以在每个内核上并发执行具有多个并行活动的软件系统,最多可达到可用内核的数量

  • 6.5.1. 每种编程语言都有自己的线程机制

  • 6.5.2. 所有并发机制的底层语义都是相似的

  • 6.5.3. 主流使用的主要线程模型只有几个

6.6. 在过去50年里,并发模型一直是计算机科学中研究和探索较多的主题

  • 6.6.1. CSP(通信顺序进程)模型构成了Go并发特性的基础

  • 6.6.1.1. 在Go中,并发的单位是goroutine,goroutine使用无缓冲或缓冲通道发送消息来进行通信

  • 6.6.2. Erlang实现了并发的actor模型

  • 6.6.2.1. actor是没有共享状态的轻量级进程,通过向其他actor发送异步消息来进行通信

  • 6.6.2.2. actor使用邮箱或队列来缓冲消息,可以使用模式匹配来选择要处理的消息

  • 6.6.3. Node.js避开多线程,利用由事件循环管理的单线程非阻塞模型

  • 6.6.3.1. 该模型适用于频繁执行I/O请求的代码

  • 6.6.3.2. 如果你的代码需要执行CPU密集型操作,例如对大型列表进行排序,那么你只有一个线程

>  6.6.3.2.1. 这将阻止其他请求,直到排序完成

>  6.6.3.2.2. 这并非一种理想的情况

6.7. 在可扩展分布式系统的世界中,并发是无处不在的

6.8. 无论你使用的是C/C++中的pthreads库,还是受CSP启发的经典Go并发模型,需要避免的问题都是相同的

7. 线程

7.1. 默认情况下,每个软件进程都有一个执行线程,即操作系统在安排进程执行时所管理的线程

7.2. 线程本质上是我们构建可扩展分布式系统时用于数据处理和数据库平台的组件

7.3. 在许多情况下,你可能不会显式编写多线程代码

7.4. 许多平台还通过配置参数来调整其并发能力,这意味着要调整系统性能,你需要了解更改各种线程和线程池设置的影响

7.5. 线程执行顺序

  • 7.5.1. 从程序开发者的角度来看,执行顺序是不确定的(nondeterministic)

  • 7.5.2. 不确定性(nondeterminism)这个概念是理解多线程代码的基础

  • 7.5.3. 一旦调度程序允许一个线程在CPU上执行一段时间,它就可以在指定的时间段后中断该线程,并安排另一个线程运行

  • 7.5.3.1. 中断称为抢占

  • 7.5.4. 调度程序根据调度算法决定何时运行哪个线程,线程是独立且异步地运行,直到完成

  • 7.5.5. 无论线程执行的顺序如何(你无法控制)​,你的代码都应该产生正确的结果

7.6. 线程的状态

  • 7.6.1. 多线程系统有一个系统调度程序来决定何时运行哪些线程

  • 7.6.1.1. 执行最高优先级的线程

7.7. 线程池

  • 7.7.1. 许多多线程系统需要创建和管理一组执行相似任务的线程

  • 7.7.2. 线程集合为线程池

  • 7.7.2.1. 线程池包含多个工作线程,它们通常执行相似的任务,并以一个集合进行管理

  • 7.7.3. 如果系统以不受约束的方式创建线程,最终会耗尽内存,导致系统崩溃

7.8. 同步屏障

  • 7.8.1. CountDownLatch是一个简单的同步屏障器

  • 7.8.1.1. 它是一次性工具,初始化值无法重置

8. 线程引入的问题

8.1. 并发编程的基本问题是如何协调多个线程的执行,无论它们以何种顺序执行,都会产生正确的结果

8.2. 鉴于线程可以不确定地被启动和抢占,任何中等复杂的程序本质上都有无数种执行顺序

  • 8.2.1. 这些系统是不容易测试的

8.3. 所有并发程序都需要避免两个基本问题,即竞态条件和死锁

8.4. 竞态条件

  • 8.4.1. 如果每个线程都只做自己的事情并且完全独立,执行顺序就不是问题了

  • 8.4.2. 完全独立的线程并不是大多数多线程系统的行为方式

  • 8.4.3. 线程可以使用共享的数据结构来协调它们的工作并在线程之间传递状态

  • 8.4.4. 竞态条件是隐秘的、狡猾的错误,因为它们通常很少见,而且很难被发现,大多数时候结果都是正确的

  • 8.4.4.1. 相同的代码,偶尔会出现不同的结果

  • 8.4.4.2. 关键是识别和保护临界区

  • 8.4.4.3. 临界区是更新共享数据结构的一段代码,如果它被多个线程访问,则必须以原子方式执行

  • 8.4.4.4. 你应该使临界区代码尽可能少,将序列化代码减到最少

8.5. 死锁

  • 8.5.1. 如果我们不小心编写过多限制不确定性的代码,则又会导致程序停止运行,并且永远不会继续执行,术语称其为死锁

  • 8.5.2. 当两个或多个线程永远被阻塞,没有一个可以继续执行时,就会发生死锁

  • 8.5.2.1. 当线程需要独占共享资源集,以不同的顺序获取锁时,就会发生这种情况

  • 8.5.3. 死锁,也称为致命拥抱,会导致程序停止

  • 8.5.4. 可以在软件的阻塞操作上使用超时来实现

  • 8.5.4.1. 在超时到期后,一个线程释放临界区并重试,让其他被阻塞的线程有机会继续执行

  • 8.5.4.2. 阻塞线程会损害性能,设置超时值也不是精确的做法

  • 8.5.5. 对于循环等待死锁,可以在共享资源上施加资源分配协议来解决,这样线程就不会总是以相同的顺序请求资源了

9. 线程间的协调

9.1. 很多时候,我们需要不同角色的线程来协调它们的活动,继而解决问题

9.2. 打印问题就是典型的生产者-消费者的例子

  • 9.2.1. 与一切现实的资源一样,缓冲区的容量也是有限的

  • 9.2.2. 轮询,或忙等待

  • 9.2.3. 更好的解决方案是让生产者和消费者阻塞,直到其期望的操作(分别为put或get)成功

  • 9.2.4. 阻塞的线程不消耗资源,这是一个有效的解决方案

10. 线程安全集合

10.1. java.util包中的集合并不是线程安全的

  • 10.1.1. 为了加快单线程程序的执行速度,该集合不是线程安全的

10.2. 在多线程代码中使用线程安全集合总是更安全

  • 10.2.1. ConcurrentHashMap的迭代器是弱一致性

  • 10.2.2. 如果你需要一个在被多个线程更新时始终反映当前hashmap状态的迭代器,就要付出性能代价,ConcurrentHashMap不是正确的选择