译者 | 刘汪洋
审校 | 重楼
分布式系统的复杂性是工程师和开发人员面临的重大挑战。随着系统的迭代,复杂性往往会增加,因此提前做好准备至关重要。接下来,我们将讨论你可能遇到的复杂性类型,以及在工作中应对这些复杂性的有效策略。
分布式系统与复杂性
在开发过程中,分布式系统是由多个相互连接并协同完成任务的计算机组成的网络。每台计算机或节点都有自己的本地内存和处理器,并运行各自的进程。然而,它们通过一个公共网络进行协调和集中管理。分布式系统具有高度的可靠性;单个组件的故障不会破坏整个网络的运作。
在集中式计算系统中,通常由一台具有单个处理器和内存的计算机负责解决问题。虽然集中式系统也包含多个节点,但所有节点都访问一个中央节点,这可能导致网络拥塞和速度减慢。集中式系统的一个显著缺点是存在单点故障的风险。
复杂性
复杂性可以从不同的角度进行定义,这里有两个主要定义值得注意。
在系统理论中,复杂性描述了系统中不同独立部分之间的相互作用和通信方式:它们如何相互定义、相互依赖,以及这些依赖关系的数量和性质。
从软件和技术的角度来看,复杂性指的是软件架构的细节,例如组件的数量和相互关系的复杂性。
单体架构
单体架构是集中式系统的一个典型代表。它通常表现为一个可部署和可执行的整体。例如,这种架构的组件可能包括一个用户界面以及位于同一位置的多个模块。
尽管单体架构是传统的软件构建方式,但它存在一些显著的缺点:
- 模块无法独立扩展
- 难以控制系统日益增长的复杂性
- 无法独立部署各模块
- 维护庞大的代码库十分困难
- 技术和供应商高度耦合
微服务架构
微服务架构是一种面向服务架构的变体,旨在将系统拆分为一组松散耦合的服务。例如,公司、账户、客户和用户界面这些功能模块,会作为独立进程部署在多个节点上。
虽然每个服务都有其独立的数据库,但这种做法有时可能不理想,甚至被视为反模式。
微服务架构具有以下优势:
- 横向扩展:可以对数据库和服务进行横向扩展。理论上,任何基础设施组件都可以通过克隆进行扩展,但这也需要解决许多挑战。
- 高可用性和容错性:由于多个副本的存在,可以采用一些技术来避免因崩溃、内存泄漏或停电等问题导致的停机。
- 地理分布:如果我们在美国、欧洲或亚洲有客户,为了提供最佳用户体验,我们需要将这些服务分布在全球,并采用复杂的数据复制技术。
- 技术选择自由:可以根据需求自由选择技术方案。
质量属性
任何系统都具有以下三个主要质量属性:
- 可靠性:系统在面对挑战时仍能正常运行,表现出容错性或弹性。即使当前系统运行可靠,也不能保证未来的可靠性。常见的性能下降原因包括负载增加,例如系统从 1 万并发用户扩展到 10 万,或从 100 万扩展到1000 万。
- 可扩展性:描述系统处理增加负载能力的术语。系统的可扩展性通常取决于其最薄弱的组件。
- 可维护性:指的是使工程和运维团队的工作变得更轻松。良好的稳定的抽象有助于减少复杂性,使系统更易于修改和适应新功能。
主要问题是什么?
“任何可能出错的事情都会出错,并且会在最糟糕的时候。” ——墨菲定律
不可靠的网络
网络的不可靠性有多种原因,例如:
- 请求可能会丢失。
- 请求可能在队列中等待,需要稍后传送。
- 远程节点可能已经故障(如崩溃或断电)。
- 远程节点可能暂时停止响应。
- 远程节点可能已处理请求,但响应在网络中丢失。
- 远程节点可能已处理请求,但响应被延迟,需要稍后传送。
策略:超时
最简单的解决方案是在调用方设置超时逻辑。例如,如果调用方在一段时间内没有收到响应,就会抛出错误并向用户显示错误信息。
策略:重试
在大规模系统中,我们不能因为每个网络问题都抛出异常,从而让用户不满或延迟系统的执行。因此,如果响应出现了问题,只需重试即可。但如果请求已被服务器处理,只是响应丢失了呢?在这种情况下,重试可能会导致严重后果,如多次下单、支付或交易等。
策略:幂等性
为避免这种情况,可以使用幂等性技术。
幂等性指多次执行相同操作的效果与执行一次相同。为了实现精准的“一次性语义”,可以在请求中附加幂等键。重试相同请求时,如果附带相同的幂等键,服务器会验证该键对应的请求是否已被处理,并直接返回之前的响应。这样,无论重试多少次,相同的键都不会对系统行为产生不良影响。
策略:断路器
断路器是另一种有效的模式,尤其适用于防止服务器过载和完全崩溃的情况。
断路器充当代理,以防止调用系统进入维护状态,可能会失败,或者正在严重失败。失败的原因可能有很多:内存泄漏、代码错误或外部依赖故障。在这种情况下,快速失败比冒着级联故障的风险要好得多。
并发和丢失写入
并发是分布式系统中最复杂的挑战之一。并发意味着多个计算同时进行。
那么,当试图同时从不同操作更新账户余额时会发生什么?如果没有防护机制,很可能会发生竞争条件,导致写入丢失和数据不一致。在这个例子中,两个操作试图同时更新账户余额。由于它们是并行运行的,最后一个完成的操作将获胜,从而导致严重问题。为避免这种问题,可以采用多种技术。
策略:快照隔离
ACID 是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)的缩写。所有流行的 SQL 数据库都实现了这些属性。
- 原子性 原子性确保事务中的所有操作要么全部执行,要么全部不执行。事务的原子性保证了系统在出现故障时,不会部分执行事务,从而避免数据的不一致性。
- 一致性 一致性确保事务在执行前后,数据库始终处于一致的状态。换句话说,事务执行完成后,数据库从一个有效状态转换到另一个有效状态,保持数据的完整性和正确性。
- 隔离性 隔离性确保并发执行的事务彼此独立,不会互相干扰。不同的事务之间的操作是隔离的,避免了数据竞争、脏读、不可重复读和幻读等问题。
- 持久性 持久性确保一旦事务提交,其结果将永久保存在数据库中,即使系统崩溃或发生故障,也不会丢失已提交的事务结果。
快照隔离的关键思想是数据库会跟踪已记录的版本,并且不会提交那些在当前事务之外已被修改的事务。
策略:CAS
大多数 NoSQL 数据库选择 BASE(基本可用、软状态、最终一致性)时不提供 ACID 属性,但广泛使用比较并设置(Compare and Set, CAS)。这种操作旨在避免更新丢失,仅在值自上次读取后未更改时才允许更新。如果当前值与之前读取的值不匹配,更新不会生效,必须重试读取-修改-写入循环。
例如,Cassandra 提供轻量级事务,允许使用各种 IF、IF NOT EXISTS 和 IF EXISTS 条件来避免并发问题。
策略:租约机制
另一种解决方案是租约机制。例如,当需要独占更新某个资源时,租约机制要求首先为资源获取一个带有到期时间的租约,然后进行更新,最后归还租约。
在发生故障的情况下,租约会自动到期,从而允许另一个线程访问资源。尽管这种技术非常有用,但存在进程暂停和时钟不同步的风险,这可能导致并行资源访问的问题。
双写问题
双写问题是在分布式系统中需要同步多个数据源或数据库时的一个常见挑战。例如,假设一个场景需要将新数据存储到数据库并发送消息到 Kafka。由于这两个操作不是原子的,因此在发布新消息时可能会失败。
如果在发送消息时尝试进行事务操作,情况会更加复杂。如果事务未能提交,外部系统可能已经收到实际上并未发生的更改信息。
策略:事务性发件箱
一种潜在的解决方案是实施事务性发件箱。这种方法是在与操作本身相同的事务中,将事件存储在 "OutboxEvents" 表中。由于过程的原子性,如果事务失败,将不会存储任何数据。
另一个必要组件是 Relay,它定期轮询 OutboxEvents 表并将消息发送到目标。这种方法可以实现至少一次交付保证。由于网络不可靠,所有消费者都必须具有幂等性,因此这并不是一个问题。
策略:Log Tailing
构建自定义事务性外发箱的另一种替代方案是利用数据库事务日志和自定义连接器直接从日志中读取并将更改发送到目标。
这种方法有其自身的优点和缺点。例如,它需要与数据库解决方案耦合,但允许在应用程序中编写更少的代码。
不可靠的时钟
时间跟踪是任何软件或基础设施的基本方面,因为它能够执行超时、到期和收集指标。然而,在分布式系统中,时钟的可靠性是一个重大挑战,因为时间的准确性取决于各个计算机的性能,而这些计算机的时钟可能快于或慢于其他时钟。
计算机使用的主要有两种类型的时钟:日历时钟和单调时钟。日历时钟根据特定日历返回日期和时间,通常与网络时间协议(NTP)同步。然而,延迟和网络问题可能会影响同步过程,导致时钟不同步。单调时钟则是连续推进的,适合测量持续时间。
然而,单调递增值是每台计算机独有的,限制了它们在多服务器之间日期和时间比较中的使用。实现高度准确的时钟同步是一项挑战性任务。在大多数情况下,这种解决方案的必要性并不明显。然而,在需要遵守法规的情况下,可以使用精确时间协议(PTP),但这将需要大量投入。
可用性和一致性
CAP 定理指出,任何分布式数据存储系统只能同时满足以下三个保证中的两个:一致性(Consistency)、可用性(Availability)和分区容错性(Partition Tolerance)。由于网络的不可靠性是一个无法显著改变的因素,在网络分区的情况下,必须在可用性和一致性之间进行选择。
考虑以下场景:两个客户端分别从不同的节点读取数据,一个从主节点读取,另一个从从节点读取。复制配置在 leader 更改后更新 follower 节点。但是,如果由于某种原因 leader 停止响应会发生什么?
这可能是由于崩溃、网络分区或其他问题。在高可用系统中,必须指定一个新的 leader ,但如何选择现有的 follower 节点呢?为了解决这个问题,必须采用一种分布式共识算法。然而,在深入探讨这种算法之前,有必要全面了解各种类型的一致性。
一致性类型
一致性保证主要分为两类:
- 弱一致性(最终一致性):意味着如果停止对领导者进行更改,数据将在一段时间后在所有从节点上同步。
- 强一致性:确保系统中的所有节点在同一时间看到相同的数据,无论它们访问的是哪个节点。
策略:分布式共识算法(例如 Raft)
回到 leader 崩溃的问题,需要选举一个新的 leader 。这个问题乍看之下很简单,但实际上在选择合适的方法时需要考虑许多条件和权衡。
根据 Raft 协议,如果 follower 在指定时间内没有收到来自 leader 的数据或心跳信号,则会开始新的 leader 选举过程。每个复制单元(单体写节点或多个分片)都与一组 Raft 日志和操作系统进程相关联,这些进程维护日志并将更改从 leader 复制到 follower 节点。
Raft 协议保证 follower 节点按照 leader 生成的顺序接收日志记录。当一半的 follower 节点确认收到提交记录并将其写入 Raft 日志时,用户事务便在 leader 上提交。
策略:从 leader 读取
一种可能的有效且简单的策略是由刚刚保存新数据的用户从 leader 读取,以避免复制延迟。
结论
从单体架构到微服务架构,每种方法都有其优点和挑战。虽然单体架构提供了简洁性,但它们通常在可扩展性和可维护性方面表现不佳,这推动开发人员向更加模块化和可扩展的微服务架构发展。
讨论的核心是复杂性的管理,这种复杂性以各种形式表现出来,从网络不可靠到并发问题再到双写问题。诸如超时、重试、幂等性和断路器等策略为减轻网络不可靠带来的风险提供了有效的工具,而快照隔离、比较并设置以及租约等技术则解决了并发和丢失写入的挑战。
此外,不可靠时钟的关键问题强调了在分布式系统中准确时间同步的重要性,从 NTP 同步到精确时间协议(PTP),都提供了相应的解决方案。此外,CAP 定理提醒我们在可用性和一致性之间固有的权衡,迫使我们深入了解诸如 Raft 之类的分布式共识算法。
总之,掌握分布式系统中的复杂性需要多方面的方法,需要理论与实践相结合。通过采用这些策略并不断适应不断变化的分布式计算领域,工程师和开发人员可以应对这些复杂性挑战,确保其系统在面对不断变化的挑战时的可靠性、可扩展性和可维护性。
译者介绍
刘汪洋,51CTO社区编辑,昵称:明明如月,一个拥有 5 年开发经验的某大厂高级 Java 工程师,拥有多个主流技术博客平台博客专家称号。
原文标题:Distributed Systems: Common Pitfalls and Complexity,作者:Aleksei Popov