LDC 每一个在蚂蚁带过的人应该都知道这个架构,也会被几个名词所震惊吧,这个技术我记得之前网上都搜不到相关资料,以前在京东工作的时候,那会是17年,京东在做多活,那会觉得好牛逼的样子,架构师们都不知道这个是什么东西。
本文不打算讨论具体到代码级的分析,而是尝试用最简单的描述来说明其中最大快人心的原理。我想关心分布式系统设计的人都曾被下面这些问题所困扰过:
如果你对这些感兴趣,不妨看一场赤裸裸的论述,拒绝使用晦涩难懂的词汇,直面最本质的逻辑。
LDC(logic data center)是相对于传统的(Internet Data Center-IDC)提出的,逻辑数据中心所表达的中心思想是无论物理结构如何的分布,整个数据中心在逻辑上是协同和统一的。这句话暗含的是强大的体系设计,分布式系统的挑战就在于整体协同工作(可用性,分区容忍性)和统一(一致性)。单元化是大型互联网系统的必然选择趋势,举个最最通俗的例子来说明单元化。我们总是说 TPS 很难提升,确实任何一家互联网公司(比如淘宝、携程、新浪)它的交易 TPS 顶多以十万计量(平均水平),很难往上串了。
因为数据库存储层瓶颈的存在再多水平扩展的服务器都无法绕开,而从整个互联网的视角看,全世界电商的交易 TPS 可以轻松上亿。这个例子带给我们一些思考:为啥几家互联网公司的 TPS 之和可以那么大,服务的用户数规模也极为吓人,而单个互联网公司的 TPS 却很难提升?究其本质,每家互联网公司都是一个独立的大型单元,他们各自服务自己的用户互不干扰。这就是单元化的基本特性,任何一家互联网公司,其想要成倍的扩大自己系统的服务能力,都必然会走向单元化之路。
它的本质是分治,我们把广大的用户分为若干部分,同时把系统复制多份,每一份都独立部署,每一份系统都服务特定的一群用户。以淘宝举例,这样之后,就会有很多个淘宝系统分别为不同的用户服务,每个淘宝系统都做到十万 TPS 的话,N 个这样的系统就可以轻松做到 N*十万的 TPS 了。LDC 实现的关键就在于单元化系统架构设计,所以在蚂蚁内部,LDC 和单元化是不分家的,这也是很多同学比较困扰的地方,看似没啥关系,实则是单元化体系设计成就了 LDC。
小结: 分库分表解决的最大痛点是数据库单点瓶颈,这个瓶颈的产生是由现代二进制数据存储体系决定的(即 I/O 速度)。
单元化只是分库分表后系统部署的一种方式,这种部署模式在灾备方面也发挥了极大的优势。
系统架构演化史
几乎任何规模的互联网公司,都有自己的系统架构迭代和更新,大致的演化路径都大同小异。
最早一般为了业务快速上线,所有功能都会放到一个应用里,系统架构如下图所示:
这样的架构显然是有问题的,单机有着明显的单点效应,单机的容量和性能都是很局限的,而使用中小型机会带来大量的浪费。
随着业务发展,这个矛盾逐渐转变为主要矛盾,因此工程师们采用了以下架构:
这是整个公司第一次触碰到分布式,也就是对某个应用进行了水平扩容,它将多个微机的计算能力团结了起来,可以完胜同等价格的中小型机器。慢慢的,大家发现,应用服务器 CPU 都很正常了,但是还是有很多慢请求,究其原因,是因为单点数据库带来了性能瓶颈。
于是程序员们决定使用主从结构的数据库集群,如下图所示:
其中大部分读操作可以直接访问从库,从而减轻主库的压力。然而这种方式还是无法解决写瓶颈,写依旧需要主库来处理,当业务量量级再次增高时,写已经变成刻不容缓的待处理瓶颈。
这时候,分库分表方案出现了:
分库分表不仅可以对相同的库进行拆分,还可以对相同的表进行拆分,对表进行拆分的方式叫做水平拆分。不同功能的表放到不同的库里,一般对应的是垂直拆分(按照业务功能进行拆分),此时一般还对应了微服务化。这种方法做到极致基本能支撑 TPS 在万级甚至更高的访问量了。然而随着相同应用扩展的越多,每个数据库的链接数也巨量增长,这让数据库本身的资源成为了瓶颈。这个问题产生的本质是全量数据无差别的分享了所有的应用资源,比如 A 用户的请求在负载均衡的分配下可能分配到任意一个应用服务器上,因而所有应用全部都要链接 A 用户所在的分库,数据库连接数就变成笛卡尔乘积了。在本质点说,这种模式的资源隔离性还不够彻底。要解决这个问题,就需要把识别用户分库的逻辑往上层移动,从数据库层移动到路由网关层。这样一来,从应用服务器 a 进来的来自 A 客户的所有请求必然落库到 DB-A,因此 a 也不用链接其他的数据库实例了,这样一个单元化的雏形就诞生了。
思考一下,应用间其实也存在交互(比如 A 转账给 B),也就意味着,应用不需要链接其他的数据库了,但是还需要链接其他应用。
如果是常见的 RPC 框架如 Dubbo 等,使用的是 TCP/IP 协议,那么等同于把之前与数据库建立的链接,换成与其他应用之间的链接了。
为啥这样就消除瓶颈了呢?首先由于合理的设计,应用间的数据交互并不巨量,其次应用间的交互可以共享 TCP 链接,比如 A->B 之间的 Socket 链接可以被 A 中的多个线程复用。
而一般的数据库如 MySQL 则不行,所以 MySQL 才需要数据库链接池。
如上图所示,但我们把整套系统打包为单元化时,每一类的数据从进单元开始就注定在这个单元被消化,由于这种彻底的隔离性,整个单元可以轻松的部署到任意机房而依然能保证逻辑上的统一。
下图为一个三地五机房的部署方式:
蚂蚁单元化架构实践
蚂蚁支付宝应该是国内最大的支付工具,其在双 11 等活动日当日的支付 TPS 可达几十万级,未来这个数字可能会更大,这决定了蚂蚁单元化架构从容量要求上看必然从单机房走向多机房。
另一方面,异地灾备也决定了这些 IDC 机房必须是异地部署的。整体上支付宝也采用了三地五中心(IDC 机房)来保障系统的可用性。
跟上文中描述的有所不同的是,支付宝将单元分成了三类(也称 CRG 架构):
“写读时间差现象”是蚂蚁架构师们根据实践统计总结的,他们发现大部分情况下,一个数据被写入后,都会过足够长的时间后才会被访问。
生活中这种例子很常见,我们办完银行卡后可能很久才会存第一笔钱;我们创建微博账号后,可能想半天才会发微博;我们下载创建淘宝账号后,可能得浏览好几分钟才会下单买东西。
当然了这些例子中的时间差远远超过了系统同步时间。一般来说异地的延时在 100ms 以内,所以只要满足某地 CZone 写入数据后 100ms 以后才用这个数据,这样的数据和服务就适合放到 CZone 中。
相信大家看到这都会问:为啥分这三种单元?其实其背后对应的是不同性质的数据,而服务不过是对数据的操作集。下面我们来根据数据性质的不同来解释支付宝的 CRG 架构。当下几乎所有互联网公司的分库分表规则都是根据用户 ID 来制定的。
而围绕用户来看整个系统的数据可以分为以下两类:
用户间共享型数据: 这种类型的数据又分两类。一类共享型数据是像账号、个人博客等可能会被所有用户请求访问的用户数据。
比如 A 向 B 转账,A 给 B 发消息,这时候需要确认 B 账号是否存在;又比如 A 想看 B 的个人博客之类的。
另外一类是用户无关型数据,像商品、系统配置(汇率、优惠政策)、财务统计等这些非用户纬度的数据,很难说跟具体的某一类用户挂钩,可能涉及到所有用户。
比如商品,假设按商品所在地来存放商品数据(这需要双维度分库分表),那么上海的用户仍然需要访问杭州的商品。
这就又构成跨地跨 Zone 访问了,还是达不到单元化的理想状态,而且双维度分库分表会给整个 LDC 运维带来复杂度提升。
注:网上和支付宝内部有另外一些分法,比如流水型和状态性,有时候还会分为三类:流水型、状态型和配置型。
个人觉得这些分法虽然尝试去更高层次的抽象数据分类,但实际上边界很模糊,适得其反。
直观的类比,我们可以很轻易的将上述两类数据对应的服务划分为 RZone 和 GZone,RZone 包含的就是分库分表后负责固定客户群体的服务,GZone 则包含了用户间共享的公共数据对应的服务。
到这里为止,一切都很完美,这也是主流的单元化话题了。对比支付宝的 CRG 架构,我们一眼就发现少了 C(City Zone),CZone 确实是蚂蚁在单元化实践领域的一个创新点。再来分析下 GZone,GZone 之所以只能单地部署,是因为其数据要求被所有用户共享,无法分库分表,而多地部署会带来由异地延时引起的不一致。
比如实时风控系统,如果多地部署,某个 RZone 直接读取本地的话,很容易读取到旧的风控状态,这是很危险的。这时蚂蚁架构师们问了自己一个问题——难道所有数据受不了延时么?这个问题像是打开了新世界的大门,通过对 RZone 已有业务的分析,架构师们发现 80% 甚至更高的场景下,数据更新后都不要求立马被读取到。也就是上文提到的”写读时间差现象”,那么这就好办了,对于这类数据,我们允许每个地区的 RZone 服务直接访问本地,为了给这些 RZone 提供这些数据的本地访问能力,蚂蚁架构师设计出了 CZone。在 CZone 的场景下,写请求一般从 GZone 写入公共数据所在库,然后同步到整个 OB 集群,然后由 CZone 提供读取服务。比如支付宝的会员服务就是如此。
即便架构师们设计了完美的 CRG,但即便在蚂蚁的实际应用中,各个系统仍然存在不合理的 CRG 分类,尤其是 CG 不分的现象很常见。
流量挑拨技术探秘简介
单元化后,异地多活只是多地部署而已。比如上海的两个单元为 ID 范围为 [00~19],[40~59] 的用户服务。
而杭州的两个单元为 ID 为 [20~39]和[60,79]的用户服务,这样上海和杭州就是异地双活的。
支付宝对单元化的基本要求是每个单元都具备服务所有用户的能力,即——具体的那个单元服务哪些用户是可以动态配置的。所以异地双活的这些单元还充当了彼此的备份。
发现工作中冷备热备已经被用的很乱了。最早冷备是指数据库在备份数据时需要关闭后进行备份(也叫离线备份),防止数据备份过程中又修改了,不需要关闭即在运行过程中进行数据备份的方式叫做热备(也叫在线备份)。也不知道从哪一天开始,冷备在主备系统里代表了这台备用机器是关闭状态的,只有主服务器挂了之后,备服务器才会被启动。
而相同的热备变成了备服务器也是启动的,只是没有流量而已,一旦主服务器挂了之后,流量自动打到备服务器上。本文不打算用第二种理解,因为感觉有点野。
为了做到每个单元访问哪些用户变成可配置,支付宝要求单元化管理系统具备流量到单元的可配置以及单元到 DB 的可配置能力。
如下图所示:
其中 Spanner 是蚂蚁基于 Nginx 自研的反向代理网关,也很好理解,有些请求我们希望在反向代理层就被转发至其他 IDC 的 Spanner 而无需进入后端服务,如图箭头 2 所示。那么对于应该在本 IDC 处理的请求,就直接映射到对应的 RZ 即可,如图箭头 1。进入后端服务后,理论上如果请求只是读取用户流水型数据,那么一般不会再进行路由了。然而,对于有些场景来说,A 用户的一个请求可能关联了对 B 用户数据的访问,比如 A 转账给 B,A 扣完钱后要调用账务系统去增加 B 的余额。这时候就涉及到再次的路由,同样有两个结果:跳转到其他 IDC(如图箭头 3)或是跳转到本 IDC 的其他 RZone(如图箭头 4)。
RZone 到 DB 数据分区的访问这是事先配置好的,上图中 RZ 和 DB 数据分区的关系为:
RZ0* --> a
RZ1* --> b
RZ2* --> c
RZ3* --> d
下面我们举个例子来说明整个流量挑拨的过程,假设 C 用户所属的数据分区是 c,而 C 用户在杭州访问了 http://cashier.alipay.com(随便编的)。
大家应该发现问题所在了,如果再来一个这样的请求,岂不是每次都要跨地域进行调用和返回体传递?确实是存在这样的问题的,对于这种问题,支付宝架构师们决定继续把决策逻辑往用户终端推移。比如,每个 IDC 机房都会有自己的域名(真实情况可能不是这样命名的):
那么请求从 IDC-1 涮过一遍返回时会将前端请求跳转到 http://cashieridc-2.alipay.com 去(如果是 App,只需要替换 rest 调用的接口域名),后面所有用户的行为都会在这个域名上发生,就避免了走一遍 IDC-1 带来的延时。
支付宝灾备机制
流量挑拨是灾备切换的基础和前提条件,发生灾难后的通用方法就是把陷入灾难的单元的流量重新打到正常的单元上去,这个流量切换的过程俗称切流。支付宝 LDC 架构下的灾备有三个层次:
同机房单元间灾备
灾难发生可能性相对最高(但其实也很小)。对 LDC 来说,最小的灾难就是某个单元由于一些原因(局部插座断开、线路老化、人为操作失误)宕机了。从上节里的图中可以看到每组 RZ 都有 A,B 两个单元,这就是用来做同机房灾备的,并且 AB 之间也是双活双备的。正常情况下 AB 两个单元共同分担所有的请求,一旦 A 单元挂了,B 单元将自动承担 A 单元的流量份额。这个灾备方案是默认的。
同城机房间灾备
灾难发生可能性相对更小。这种灾难发生的原因一般是机房电线网线被挖断,或者机房维护人员操作失误导致的。在这种情况下,就需要人工的制定流量挑拨(切流)方案了。下面我们举例说明这个过程,如下图所示为上海的两个 IDC 机房。
整个切流配置过程分两步,首先需要将陷入灾难的机房中 RZone 对应的数据分区的访问权配置进行修改。
假设我们的方案是由 IDC-2 机房的 RZ2 和 RZ3 分别接管 IDC-1 中的 RZ0 和 RZ1。那么首先要做的是把数据分区 a,b 对应的访问权从 RZ0 和 RZ1 收回,分配给 RZ2 和 RZ3。
即将(如上图所示为初始映射):
RZ0* --> a
RZ1* --> b
RZ2* --> c
RZ3* --> d
变为:
RZ0* --> /
RZ1* --> /
RZ2* --> a
RZ2* --> c
RZ3* --> b
RZ3* --> d
然后再修改用户 ID 和 RZ 之间的映射配置。假设之前为:
[00-24] --> RZ0A(50%),RZOB(50%)
[25-49] --> RZ1A(50%),RZ1B(50%)
[50-74] --> RZ2A(50%),RZ2B(50%)
[75-99] --> RZ3A(50%),RZ3B(50%)
那么按照灾备方案的要求,这个映射配置将变为:
[00-24] --> RZ2A(50%),RZ2B(50%)
[25-49] --> RZ3A(50%),RZ3B(50%)
[50-74] --> RZ2A(50%),RZ2B(50%)
[75-99] --> RZ3A(50%),RZ3B(50%)
这样之后,所有流量将会被打到 IDC-2 中,期间部分已经向 IDC-1 发起请求的用户会收到失败并重试的提示。实际情况中,整个过程并不是灾难发生后再去做的,整个切换的流程会以预案配置的形式事先准备好,推送给每个流量挑拨客户端(集成到了所有的服务和 Spanner 中)。
这里可以思考下,为何先切数据库映射,再切流量呢?这是因为如果先切流量,意味着大量注定失败的请求会被打到新的正常单元上去,从而影响系统的稳定性(数据库还没准备好)。
异地机房间灾备
这个基本上跟同城机房间灾备一致(这也是单元化的优点),不再赘述。