两年前我作为一名拥有后台开发经验的移动端软件工程师入职 Uber,并负责 APP 端支付功能的开发以及重构。后来我进入了工程师管理团队,并独立带领一个团队。由于我的团队负责很多后端支付相关的系统,因此我有更多的机会接触整个支付系统的后端知识。
来 Uber 工作之前,我几乎没有分布式系统的工作经验。我的背景是一个传统的计算机科学学位和十年的全栈软件开发。然而,虽然我能够画架构图并讨论折中方案,但我对分布式的相关概念(一致性,可用性或者幂等性)了解的并不多。
在本文中,我总结了一些我认为在构建大规模,高可用分布式系统(为 Uber 提供底层支持的支付系统)时必须学习和应用的概念。这是一个每秒负载高达数千个请求的系统,其中关键的支付功能必须能够正确的工作,即使整个系统的某些部分出现故障。本文会是一个完整的清单吗?应该不会。但如果我早点知道这些概念的话,我的工作和生活会轻松很多。因此,就让我们开始来了解诸如 SLA,一致性,数据持久化,消息持久化,幂等性以及其他一些我在工作中需要学习的东西吧。
SLA
对大型系统来说,每天需要处理百万级别的事件,因此必可避免的会出现问题。在深入设计一个系统前,我发现最重要的事情是确定什么是一个健康的系统。系统的健康度应该是可衡量的,常用的方法是 SLA:服务等级协议(Service Level Agreements)。我见过的一些最常见的 SLA 有:
- 可用性:服务正常运行时间的百分比。虽然拥有一个 100% 可用的系统的想法很诱人,但实现这个目标是非常困难的,而且费用高昂。即使像 VISA 信用卡网络,Gmail 或者互联网提供商这样的大型和关键系统也达不到 100% 的可用性,多年来,它们也会停机几秒钟,几分钟或者几小时。对于许多系统来说,四个九的可用性(99.99%,即大约每年有 50 分钟的停机时间)就被认为是高可用的,通常为了达到这个水平就要花费不少的工作。
- 准确性:表示在系统中是否允许某些数据不准确或者丢失?如果是,可接受的百分比是多少?对于我从事的支付系统,准确性要求是 100%,这意味着不允许丢失任何数据。
- 负载能力:系统预期能够支持多少负载?这通常以每秒请求数来表示。
- 延迟率:系统应该在多长时间内做出响应?95% 的请求和 99% 的请求的响应时间是多少?系统通常有大量的噪声请求,因此,P95 和 P99 的响应时间对现实系统而言更加实用。
构建大型支付系统时 SLA 为什么很重要呢?我们建立一个新系统,并用来取代现有的系统。为了确保我们构建了正确的系统,需要保证新系统比旧系统更好。这时我们就可以使用 SLA 来定义期望值。可用性是最高要求之一。一旦确定了可用性目标,我们就需要在设计架构时为了满足这一目标作出折中的选择。
水平扩展和垂直扩展
假设使用新系统的业务不断增长,负载会随着不断增加。在某个时间点,现有的配置将无法支持更多的负载,需要增加更多的系统容量。这时有两种最常用的扩展策略:水平扩展和垂直扩展。
水平扩展指的是向系统中增加更多的机器/节点,以增加系统总体容量。水平扩展是最流行的分布式系统扩容方法,尤其是向集群中添加(虚拟)机器通常简单到只需要在网页上点击一下按钮。
垂直扩展基本上就是通过购买配置更强大的机器来实现的,无非是给(虚拟)机器增加更多的处理器内核,更多的内存等。对于分布式系统而言,垂直扩展通常不那么流行,因为它比水平扩展费用更高。然而,一些重要的站点,例如 StackOverflow,已经成功的实现垂直扩展以满足系统需求。
为什么构建大规模的支付系统时,系统扩展策略至关重要呢?我们很早就决定建立一个可水平扩展的系统。虽然在某些情况下垂直扩展是可能的,但由于我们的支付系统已经处于预估的负载,我们对单台昂贵的大型机在今天这种情况下能否支撑它持悲观态度,更不用说将来了。我们团队中也有工程师曾经在大型支付供应商工作过,他们曾试图在当时能够买到的大型机上进行系统的垂直扩展,但以失败告终。
一致性
任何系统的可用性都是很重要的。分布式系统通常建立在具有较低可用性的机器上。假设我们的目标是建立一个有 99.999% 可用性的系统(大约每年 5 分钟时间不可用)。我们使用的机器/节点有平均 99.9% 的可用性(大约每年 8 小时时间不可用)。一个简单的达到我们目标可用性的方法是把一批机器/节点添加到一个集群中。即使集群中一些节点出现故障,也有其他的节点可用,系统总体的可用性将比单个节点的可用性更高。
一致性在高可用系统中是一个关键问题。如果集群中所有节点同时看到并返回相同的数据,则系统是一致的。回到之前的模型,我们通过添加一组节点来获得更高的可用性,这时确保系统保持一致性并不是一件微不足道的事情。为了确保每个节点具有相同的数据,它们需要互相发送消息,以保持之间的数据同步。但是发送到对方的消息可能无法到达,它们可能丢失,或者有些节点可能不可用。
一致性是我花费了大量时间去理解的一个概念。有几种一致性模型,在分布式系统中最常用的是强一致性,弱一致性和最终一致性这三种。Hackernoon 网站上这篇最终一致性和强一致性的对比文章对这些模型之间的权衡给出了很好的实用的概述。通常一致性越弱,系统可能越快,但它也更有可能不会返回最新的数据集。
为什么构建一个大型支付系统时一致性很重要呢?系统中的数据需要保持一致,但到底多一致呢?对于系统中某些部分,只有强一致性的数据才行。例如知道用户付款操作是否已经开始是需要以强一致性的方式存储下来的。对于其他不是关键业务的部分来说,最终一致性被认为是合理的权衡。一个好的例子是列出最近交易这个功能,这种可以以最终一致性方式实现(也就是说,最近一次交易可能只会在一段时间后才在集群中某些节点中显示出来,作为回报,查询操作将以较低的延迟或者耗费较少资源的方式返回)。
数据持久化
持久化意味着一旦数据成功添加到数据存储中,那么将来它将是一直可用的。即使系统中某些节点离线,崩溃或者数据损坏,也不影响数据的可用性。
不同的分布式数据库具有不同的持久化。 有些支持机器/节点级别的持久化,有些在群集级别支持此功能,有些则不支持该功能。 某种形式的复制通常用于增加持久化: 如果数据存储在多个节点上,如果一个或多个节点发生故障,数据仍然是可用的。这是一篇关于为什么在分布式系统中实现数据持久化是具有挑战性的好文章。
为什么构建大型支付系统时数据持久化很重要呢?对于系统的大部分功能来说,是不允许数据丢失的,因为数据是非常关键的,例如支付功能。我们构建的分布式数据存储需要支持集群级别的数据持久化:这样即使集群中有实例崩溃,已完成的交易依然会被持久化。目前大多数分布式数据存储服务,如 Cassandra,MongoDB,HDFS 或 Dynamodb 都支持不同级别的数据持久化,并且都可以通过配置提供集群级别的持久化。
消息持久化
分布式系统中的节点负责执行计算,存储数据和相互间发送消息。消息发送的一个关键特性是消息的可靠性。对于业务关键性系统,通常要求消息零丢失。
对于分布式系统,消息传递通常由某些分布式消息服务完成,例如 RabbitMQ,Kafka 等。这些消息服务可以支持(或者通过配置支持)不同级别的消息传递可靠性。
消息持久化意味着当正在处理消息的节点发送某些故障时,消息仍将在故障解决后继续进行处理。消息持久化通常用于消息队列级别。对于持久化的消息队列,如果队列(或节点)在消息发送后脱机了,它将在重新联机后仍然能够收到消息。关于这个主题的更多信息可以看下这篇文章。
为什么构建大型支付系统时消息持久化至关重要呢?因为我们系统存在不能丢失的消息,例如消费者为他们的乘车付款的消息。这意味着我们使用的消息系统必须是无损的:每条消息都必须传递一次。但是构建一个每条消息只传递一次的系统,和构建一个每条消息至少传递一次的系统,这两者复杂度是不同的。我们决定实现一个消息至少传递一次的持久化消息系统,并选择一个消息总线,并将在此基础上构建它(我们最终选择了 Kafka,为此案例配置了消息无损的集群)。
幂等性
分布式系统往往存在出错的可能性,例如连接中断或请求超时等。客户端通常会重试这些请求。幂等系统能够确保无论特定请求执行多少次,该请求的实际执行只发生一次。一个很好的例子就是付款,如果客户端发出付款的请求,请求成功但客户端超时了,客户端可能会重试相同的请求。对于幂等系统,付费的人不会被两次扣款,对于非幂等系统,则会发生两次扣款操作。
设计幂等的分布式系统需要某种分布式锁定策略。这是一些早期分布式系统概念发挥作用的地方。假设我们打算通过乐观锁来实现幂等性,以避免并发更新。为了获得乐观锁,系统必须是强一致性的,这样在操作时,我们可以使用某种版本控制来检查是否已经有另外一个操作正在进行。
根据系统的约束和操作类型,有多种方法可以实现幂等性。设计幂等方法是一个很好的挑战,Ben Nadel 在文章中介绍了他使用的不同策略,包括分布式锁或者数据库约束。当设计分布式系统时,幂等性可能是最容易被忽视的部分之一。我就遇到过这样的场景,我的团队因为没有确保某些关键操作的正确幂等性而饱受煎熬。
为什么构建大型支付系统时幂等性很重要呢?最重要的是:避免双重收费或双重退款。 鉴于我们的消息系统至少有一次无损传递,我们需要假设所有消息可能多次传递,但系统需要确保幂等性。 我们选择通过版本控制和乐观锁来处理这个问题,让实现幂等行为的系统使用强一致性存储作为其数据源。
分片和 Quorom
分布式系统通常必须比单个节点存储更多的数据。那么如何在一定数量的机器上存储大量数据呢?最常见的技术是使用分片。数据通过某种类型的散列算法进行水平分区,并分配给某个分区。虽然很多分布式数据库在底层已经实现了分片,但分片是一个值得进一步了解的有趣的领域,尤其是关于重新分片。Foursquare 的系统在 2010 年有 17 小时的停机时间,就是因为遇到了一个分片边缘案例,关于根本原因有一个很好的剖析。
许多分布式系统具有跨多个节点复制的数据或者计算。为了确保以一致的方式执行这些操作,定义了基于投票的方法,其中一定数量的节点需要获得相同的结果,以使操作成功,这称为 Quorum。
为什么在构建 Uber 的支付系统时,quorum 和 分片很重要呢?这两个都是非常常用的基本概念。我在研究如何配置 Cassandra 副本时遇到了这个概念。Cassandra(以及其他分布式系统)使用 quorum 和 本地 quorum 来确保集群之间的一致性。作为一个有趣的副作用,在我们的一些会议上,当有足够的人在房间里时,有人会问:“我们可以开始吗?我们有法定人数(quorum)吗?”
Actor 模型
描述编程实践的常用词汇,如变量,接口,调用方法等,都假设在单机系统上。在讨论分布式系统时,我们需要使用一套不同的方法。描述这些系统的常用方法是遵循 actor 模型,它从通信的角度考虑代码。这种模型很流行,因为它与我们想到的心理模型相匹配,例如,描述人们在组织中的沟通方式。另一个描述分布式系统的流行方式是 CSP:通信顺序流程。
Actor 模型基于 actors 之间相互发送消息,并对它们作出响应。每个 actor 可以做一组有限的事情:创建其他 actor,向其他 actor 发送消息或者决定如何处理下一条消息。通过一些简单的规则,可以很好的描述复杂的分布式系统,在 actor 崩溃后,这些系统也可以自我修复。简单的总结,我推荐 Brian Storti 写的十分钟了解 actor 模型一文。很多编程语言都实现了 actor 函数库或者框架。例如在 Uber,我们在一些系统中使用 Akka 工具包。
为什么构建大型分布式系统时 actor 模型很重要呢?我们有很多工程师在一起开发系统,其中很多人有分布式的经验。我们决定遵循一个标准的分布式模型,而不是我们自己提出一个分布式模型概念,从而可能导致重新发明轮子。
响应式架构
在构建大型分布式系统时,目标通常是弹性可扩展。可能这是一个支付系统,或者是另外一个高负载系统,但这样做的模式可能是类似的。业内人士一直在发现和分享这些情况下能够良好运行的最佳实践,而其中响应式架构在这个领域是一种流行且广泛应用的模式。
开始使用响应式架构前,我建议您阅读 Reactive Manifesto 并观看有关该主题的 12 分钟视频。
为什么构建大型支付系统时,响应式架构很重要呢?Akka,我们用于构建大部分新支付系统的工具包,就深受响应式架构的影响。我们在开发这个系统的很多工程师也熟悉响应式的最佳实践。遵循响应式原则:建立一个响应的,弹性的且基于消息驱动的系统,因此这对我们来说非常自然的。我发现它的好处在于拥有一个可信赖的模型,并检查进度是否处于正确的轨道上,我将继续使用这个模型来构建以后的系统。
总结
我很幸运的参与了对 Uber 的支付系统这样一个高可扩展,分布式且关键的系统的重建。通过在这种环境中工作,我学到了很多以前没有使用过的分布式概念。通过本文的总结,希望能够有助于其他人开始或者继续对分布式系统的学习。
本文重点关注这些系统的设计和架构,关于在高负载系统之间构建,部署和迁移以及可靠的操作它们,还有很多东西要说。但所有这些都是另一篇文章的主题了。
译者简介:顾浩鑫,《Android 高级进阶》 和 《Android 高级进阶(源码剖析篇)》作者。