分布式架构设计案例 (分布式架构和多体式架构)

前言

当后端服务并发高到一定程度时非常考验软件架构设计,除了尽可能提升单机性能外,拥有强大的可伸缩性(Scalability)至关重要。如何提升软件系统的Scalability呢?

最简单的思路是水平扩展:1个撑不住就来10个,10个撑不住就来100个,100个不行就1000个。

这个思路看似没什么问题,然而真正实现起来却很难,从入口层、应用层、缓存层、数据层,每一层都需要去做到可伸缩性,具体实现起来也有一定的复杂度,接下来和大家一起探讨系统设计的可伸缩性。

什么是可伸缩性

维基百科对“可伸缩性”的定义是系统、网络或流程处理不断增长的工作量的能力,或为适应这种增长而进行扩展的潜力。

百度百科对于“可伸缩性”的定义是信息系统不需要对本身进行大量修改,只需要通过增加软硬件资源使服务容量产生线性(理想情况下)增长的特性。

基于百度百科、维基百科的定义,可以延伸出来可伸缩性(可扩展性)是一种对软件系统计算处理能力的设计指标,高可伸缩性代表一种弹性,在系统扩展成长过程中,软件能够保证旺盛的生命力,通过很少的改动甚至只是硬件设备的添置,就能实现整个系统处理能力的线性增长,实现高吞吐量和低延迟高性能。

可伸缩性和纯粹性能调优有本质区别, 可伸缩性是高性能、低成本和可维护性等诸多因素的综合考量和平衡,可伸缩性讲究平滑线性的性能提升,更侧重于系统的水平伸缩,通过廉价的服务器实现分布式计算;而普通性能优化只是单台机器的性能指标优化。他们共同点都是根据应用系统特点在吞吐量和延迟之间进行一个侧重选择,当然水平伸缩分区后会带来CAP定理约束。

软件的可扩展性设计非常重要,但又比较难以掌握,业界试图通过云计算或高并发语言等方式节省开发者精力,但是,无论采取什么技术,如果应用系统内部是铁板一块,例如严重依赖数据库,系统达到一定访问规模,负载都集中到一两台数据库服务器上,这时进行分区扩展伸缩就比较困难,正如Hibernate框架创建人Gavin King所说:传统关系数据库是最不可扩展的。

延迟和吞吐量是衡量可伸缩性性的一对指标,我们希望获得低延迟和高吞吐量的系统架构。所谓低延迟,也就是用户能感受到的系统响应时间,比如一个网页在几秒内打开,越短表示延迟越低,而吞吐量表示同时有多少用户能够享受到这种低延迟,如果并发用户量很大时,用户感觉网页的打开速度很慢,这意味着系统架构的吞吐量有待提高。

可伸缩性设计

可伸缩性状态

水平扩展的难点在于有状态的服务是很难水平扩展的,而整个系统的瓶颈往往会出现在有状态的组件上,比如数据库和缓存服务。

将状态存储在单个服务器上的应用程序对于可伸缩性来说是致命的。例如,如果某个业务逻辑在一台服务器上运行,并且只在一台服务器上的缓存中存储用户会话信息(或其他数据),那么所有用户请求都必须使用同一个服务器节点,而不是集群中的任何一个节点。这就没办法添加新的节点来增强负载均衡以降低节点负载。缓存对于性能来说是一个很好的实践,但要小心规划、设计并使用,尤其不能干扰系统的水平伸缩能力。

因此对于整个后端分布式系统,要实现水平扩展能力,首先要把有状态的组件拆分出来或者添加代理原入口的路由层,这样水平扩展出来的N个实例之间才具有可替代性。

以典型的状态数据:登录Session为例。如果把Session简单存储在服务端内存中,连水平扩展成两个实例都做不到,可以用下面这几个办法来解决:

  1. 把Session从内存移到Redis缓存中,可以让服务端自身变成无状态的,但Session这个“状态”其实转移到Redis中了,如果并发高到一定程度还是要解决Redis的瓶颈;
  2. 把Session干掉,客户端登录后换取无状态但有过期时间的令牌,令牌拿到任何一个实例中都可以验证,常见的实现有JWT,确切的说是JWT中的JSON Web Signature (JWS);

再举一个有状态的示例,比如我们的应用服务器的IP,我们对外依赖三方的API,有IP白名单的限制,通常来说这类应用服务扩展也需要考虑,否则新增应用服务后无法访问依赖的三方API,通常这类问题的解决方案可以通过iptables或socks5 proxy解决。

所以要保证系统的可伸缩性,应用服务的无状态设计时一个大的前提。

基础设计

回到理论层面,如何才能做到近乎无穷的伸缩性呢?下面这张“Scale Cube”来自Marty Abbott的《The Art of Scalability》

图中有三个正交的维度,需要对每个维度都施以对应的方案,才能做到整个系统的可伸缩。

  • X轴,把1个实例变成N个实例,也就是最初的思路,无状态服务可以依靠基础设施平台的能力实现理论上无限的扩容,再加上负载均衡层来分配每个实例干的活;
  • Y轴,把单体系统变成N个子系统,需要构建领域模型,解构分割出耦合相对较小的多个服务,再以此将单体业务系统拆分落地成微服务系统;
  • Z轴,把整体数据变成N块数据,用数据分区分片的技术拆分数据,或者部署N个独立完整的系统处理不同区域的业务,来缓解“有状态数据”剧增的瓶颈。

在应用系统渐进式的演化过程中,最重要的技术手段就是使用服务器集群,通过不断地向集群中添加服务器来增强整个集群的处理能力。基于上述的三个维度的可伸缩性,通常可伸缩性的设计有以下两种方案思路:

  • 对不同功能进行拆分部署管理:可以分为横向拆分和纵向拆分
    • 纵向拆分:统一功能的不同流程进行拆分,例如将应用从上到下拆分为【具体产品模块】【可复用业务】【基础服务】【数据存储】
    • 横向拆分:对同一层的不同业务进行拆分。例如电商业务通常可以拆分为【商品管理】【订单管理】【店铺管理】等
  • 对同一功能进行集群部署
    • 集群的伸缩性可分为应用服务器的伸缩设计和数据服务器的伸缩设计,数据服务器的伸缩设计又可以分为缓存服务器的伸缩性设计和存储服务器的伸缩性设计。

入口层设计

首先,应用服务器要设计成无状态的,这样每次用户请求都可以发送到集群中任意一台服务器上去处理。当新增服务器时,能迅速分摊已有服务器的压力。

应用服务器集群的伸缩性主要是通过负载均衡来实现的。负载均衡装置能感知和配置服务器数量,能将用户请求按一定的规则分发到不同的服务器去处理,以下是几种负载均衡的实现:

1.HTTP 重定向负载均衡

利用一台服务器作为 HTTP 重定向服务器,请求到达的时候计算真实的 web 服务器地址,然后响应 302 重定向给用户。

优点是实现简单。缺点是用户访问需要两次请求才能到达服务器,性能较差;重定向服务器有可能成为系统的瓶颈,整个集群的伸缩性规模有限;使用 302 重定向,会影响 seo 排名。因此实际中这种方式不多见。

2.DNS 域名解析负载均衡

在 DNS 服务器中配置多个 A 记录(可以理解为域名的指向 IP)。每次域名解析请求都会根据负载均衡算法计算返回集群中某个服务器的地址,从而利用 DNS 来实现负载均衡。

优点是将负载均衡的工作交给 DNS,省去管理负载均衡服务器的维护成本,同时大部分 DNS 还支持返回离用户最近的服务器地址,改善用户访问性能。缺点是当下架服务器后,修改 A 记录 可能需要一段时间才能生效,导致部分用户访问失败;而且 DNS 负载均衡的控制权不在自己手里,没办法做更多的改善和管理。

事实上大型网站总是会使用 DNS 来做第一级负载均衡,不过返回给用户的是另一组内部负载均衡服务器的地址,在内部负载均衡服务器上再做第二级负载均衡。

3.反向代理负载均衡

分布式系统架构设计模式,分布式架构和多体式架构

在 web 服务器集群的前面,部署一台反向代理服务器,负责连接用户和 web 服务器。用户的请求通过反向代理服务器转发到集群中的某个服务器,web 服务器的处理结果也是通过反向代理服务器返回给用户,主流的web反向代理服务器有Nginx、Apache。

因此 web 服务器只需要使用内部 IP 地址,而反向代理服务器则需要配置双网卡以及内部外部两套 IP 地址。

优点是部署简单。缺点是需要经过转发,反向代理服务器可能行为性能瓶颈。

4.IP 负载均衡

Load Balancer通过修改报头的源/目的ip地址和端口号,来达到“欺骗”真实服务器的目的。将源ip地址修改为当前BL(或者直接将BL设置为网关服务器),将目的ip地址修改为真实服务器(RS)。当RS处理后返回给LB时,LB再将ip修改还原后返回。由于IP负载均衡过程中往往需要修改端口号。端口属于传输层内容。所以虽然原书认为其属于网络层,但我认为更应该属于四层负载均衡,LVS的NAT模式即是如此。LVS还加入了IP隧道模式(TUN),其不再“欺骗”RS,而采取将原报文封装到新包中,通过IP隧道技术发给RS,其相对于DR模式的优势在于不要求网段相同,但缺点在于由于采用了隧道技术而使得运维变得比较麻烦。

LVS的NAT(网络地址转换)模式:

分布式系统架构设计模式,分布式架构和多体式架构

LVS的TUN(IP隧道)模式:

分布式系统架构设计模式,分布式架构和多体式架构

5.数据链路层负载均衡

分布式系统架构设计模式,分布式架构和多体式架构

这是目前大型网站中使用最广的一种负载均衡手段。

在数据链路层直接修改 mac地址,web 服务器直接返回数据给用户。通过配置 web 服务器集群的虚拟 IP 地址和负载均衡服务器的 IP 地址一致,实现负载均衡数据分发时不需要修改 IP 地址。

负载均衡服务器的实现可以分为两个部分:

  1. 根据负载均衡算法,计算得到 web 服务器集群中的某一台服务器地址。
  2. 将用户的请求发送到该服务器上。

下面说下常见的几种负载均衡算法:

1.轮询

每台服务器需要处理的请求数目都相同,适合于所有服务器硬件都相同的场景。

2.加权轮询

依照服务器的性能不同给每台服务器都配置一个权重,按照权重来轮询。

3.随机

请求随机分配,成本较低,许多场合下都很实用。可以实现加权随机。

4.最少连接

记录服务器正在处理的请求数,将最新的请求分配给最少连接的服务器上。同样也能实现加权最少连接。

5.来源 IP 计算 hash

使用来源 IP 计算 hash 得到服务器地址。

适合于有状态的应用服务器,实现来源同一个 IP 地址的请求,都分发到同一个应用服务器来处理。

业务层设计

业务层的伸缩性如何实现?与做高可用时的解决方案一样,要实现业务层的伸缩性,保证无状态是很好的手段。此外,加机器继续水平部署即可。

缓存层设计

和所有服务器都部署相同应用的应用服务器集群不同,分布式缓存服务器集群中不同服务器中缓存的数据各不相同,缓存访问请求不可以在缓存服务器集群中的任意一台处理,必须先找到缓存有需要数据的服务器,然后才能访问。这个特点会严重制约分布式缓存集群的伸缩性设计,因为新上线的缓存服务器没有缓存任何数据,而已下线的缓存服务器还缓存着网站的许多热点数据。

必须让新上线的缓存服务器对整个分布式缓存集群影响最小,也就是说新加入缓存服务器后应使整个缓存服务器集群中已经缓存的数据尽可能还被访问到,这是分布式缓存集群伸缩性设计的最主要目标。

Memcache 设计得比较早,导致在伸缩性、高可用方面的考虑得不太周到。

Redis 在这方面有不少改进, 早期Twemproxy 是一种Redis代理,但它不支持集群的动态伸缩,而codis(豌豆荚公司开发的一个分布式 Redis 解决方案)则支持动态的增减Redis节点;另外,官方的redis 3.0开始支持cluster。

路由算法

在分布式缓存服务器集群中,对于服务器集群的管理,路由算法至关重要,和负载均衡算法一样,决定着究竟该访问集群中的哪台服务器。

余数Hash算法

余数Hash是最简单的一种路由算法:用服务器数目除以缓存数据KEY的Hash值,余数为服务器列表下标编号。由于HashCode具有随机性,因此使用余数Hash路由算法可保证缓存数据在整个服务器集群中比较均衡地分布。

对余数Hash路由算法稍加改进,就可以实现和负载均衡算法中加权负载均衡一样的加权路由。事实上,如果不需要考虑缓存服务器集群伸缩性,余数Hash几乎可以满足绝大多数的缓存路由需求。

但是,当分布式缓存集群需要扩容的时候,会出现严重的问题。很容易就可以计算出,如果由3台服务器扩容至4台服务器,大约有75%(3/4)被缓存了的数据不能正确命中,随着服务器集群规模的增大,这个比例线性上升。当100台服务器的集群中加入一台新服务器,不能命中的概率是99%(N/(N+1))。

一种解决办法是在网站访问量最少的时候扩容缓存服务器集群,这时候对数据库的负载冲击最小。然后通过模拟请求的方法逐渐预热缓存,使缓存服务器中的数据重新分布。但是这种方案对业务场景有要求,还需要选择特定的时段,要求较为严苛。

一致性Hash算法

一致性Hash算法通过一个叫作一致性Hash环的数据结构实现KEY到缓存服务器的Hash映射,如图所示。

具体算法过程为:先构造一个长度为0~2³²的整数环(这个环被称作一致性Hash环),根据节点名称的Hash值(其分布范围同样为0~2³²)将缓存服务器节点放置在这个Hash环上。然后根据需要缓存的数据的KEY值计算得到其Hash值(其分布范围也同样为0~2³²),然后在Hash环上顺时针查找距离这个KEY的Hash值最近的缓存服务器节点,完成KEY到服务器的Hash映射查找。

当缓存服务器集群需要扩容的时候,只需要将新加入的节点名称(NODE3)的Hash值放入一致性Hash环中,由于KEY是顺时针查找距离其最近的节点,因此新加入的节点只影响整个环中的一小段,如图6中深色一段。

分布式系统架构设计模式,分布式架构和多体式架构

加入新节点NODE3后,原来的KEY大部分还能继续计算到原来的节点,只有KEY3、KEY0从原来的NODE1重新计算到NODE3。这样就能保证大部分被缓存的数据还可以继续命中。

具体应用中,这个长度为2³²的一致性Hash环通常使用二叉查找树实现,Hash查找过程实际上是在二叉查找树中查找不小于查找数的最小数值。当然这个二叉树的最右边叶子节点和最左边的叶子节点相连接,构成环。

但是,上述算法还存在一个小小的问题。假设原本3台服务器的负载大致相等,新加入的节点NODE3只分担了节点NODE1的部分负载,这就意味着NODE0和NODE2缓存数据量和负载压力比NODE1与NODE3的大,从概率上来说大约是2倍。这种结果显然不是我们想要的。

解决办法也很简单,计算机的任何问题都可以通过增加一个虚拟层来解决。解决上述一致性Hash算法带来的负载不均衡问题,也可以通过使用虚拟层的手段:将每台物理缓存服务器虚拟为一组虚拟缓存服务器,将虚拟服务器的Hash值放置在Hash环上,KEY在环上先找到虚拟服务器节点,再得到物理服务器的信息。

这样新加入物理服务器节点时,是将一组虚拟节点加入环中,如果虚拟节点的数目足够多,这组虚拟节点将会影响同样多数目的已经在环上存在的虚拟节点,这些已经存在的虚拟节点又对应不同的物理节点。最终的结果是:新加入一台缓存服务器,将会较为均匀地影响原来集群中已经存在的所有服务器。如图所示。

分布式系统架构设计模式,分布式架构和多体式架构

显然每个物理节点对应的虚拟节点越多,各个物理节点之间的负载越均衡,新加入物理服务器对原有的物理服务器的影响越保持一致,但是太多又会影响性能。那么在实践中,一台物理服务器虚拟为多少个虚拟服务器节点合适呢?一般说来,经验值是150,当然根据集群规模和负载均衡的精度需求,这个值应该根据具体情况具体对待。

最后要适当地使用缓存。这里给出的建议不一定普遍适用,因为缓存是否高效极大地依赖于用例的细节。说到底,要在存储约束、对可用性的需求、对陈旧数据的容忍程度等条件下最大化缓存的命中率,这才是一个高效的缓存系统的最终目标。经验证明,要平衡众多因素是极其困难的,即使暂时达到目标,情况也极可能 随着时间而改变。

最适合缓存的是很少改变、以读为主的数据——比如元数据、配置信息和静态数据。对于这类数据积极地缓存这种类型的数据,并且结合使用“推”和“ 拉”两种方法保持系统在一定程度上的更新同步。减少对相同数据的重复请求能达到非常显著的效果。频繁变更、读写兼有的数据很难有效地缓存。通常情况下不对请求间短暂存在的会话数据作任何缓存。也不在应用层缓存共享的业务对象,比如商品和用户数据。可以适当的牺牲缓存这些数据的潜在利益,换取可用性和正确性。

好东西也会过犹不及。为缓存分配的内存越多,能用来服务单个请求的内存就越少。应用层常常有内存不足的压力,因此这是非常现实的权衡。更重要的一 点,当你开始依赖于缓存,那么主要系统就只需要满足缓存未命中时的处理要求,自然而然你就会想到可以削减主要系统。但当你这样做之后,系统就完全离不开缓存了。现在主要系统没办法直接应付全部流量,也就是说网站的可用性取决于缓存能否100%正常运行——潜在的危局。哪怕是例行的操作,比如重新配置缓存资源、把缓存移动到别的机器、冷启动缓存服务器,都有可能引发严重的问题。

做得好,缓存系统能让可伸缩性的曲线向下弯曲,也就是比线性增长还要好——后续请求从缓存中取数据比从主存储取数据成本低廉。反过来,缓存做得不好会引入相当多额外的经常耗费,也会妨碍到可用性。

数据存储层设计

数据存储服务器集群的伸缩性对数据的持久性和可用性提出了更高的要求,因为数据存储服务器在任何情况下都必须保证数据的可用性和正确性。

NOSQL数据库设计

NoSQL主要是指非关系的、分布式的数据库设计模式。一般而言,NoSQL数据库产品都放弃了关系数据库的两大重要基础:以关系代数为基础的结构化查询语言(SQL)和事物一致性保证(ACID),NOSQL天然具备分布式,而强化了高可用性和可伸缩性。目前应用最广泛的是有Redis、Apache HBase等,Redis支持主从、集群模式,能够很好的进行扩展伸缩,这里以Hbase为例进行说明:

HBase的伸缩性主要通过可分裂的HRegion及可伸缩的分布式文件系统HDFS实现,

HBase架构图:

分布式系统架构设计模式,分布式架构和多体式架构

如果所示,HBase中,数据以HRegion为单位进行组织,HRegionServer是一个物理实例,每个HRegionServer可以存储多个HRegion,每个HRegion存储一段[key1, key2)的区间数据,当一个HRegion写入的数据过多达到阈值时,HRegion会分裂成两个,HRegion会在集群中迁移,以保证HRegionServer的负载均衡。

应用程序在访问HBase数据时,首先通过zookeeper拿到主HMaster的地址,然后输入Key通过HMaster查询出key所在的HRegionServer,然后从HRegion中读取数据。

更多关于NOSQL的相关介绍,可以参考我之前的文章:

NOSQL

孔凡勇,公众号:互联网技术集中营万字长文:深入解读NOSQL

SQL数据库设计

系型数据库的伸缩一般采用的是分库分表,其就需要一个中间层来实现分库分表逻辑的透明化;尽量避免使用分布式事务,或者采用事务补偿机制。比如一个请求会对A数据库和B数据库进行跨库操作。那么当A执行成功后,执行B失败时,则将A也进行回滚。以此实现事务补偿。所谓的事务补偿即在事务链中,有一个正向操作必然也带有一个回滚的负向操作。

传统关系型数据库的可伸缩性设计通常有两种方案:

客户端程序库方式

该方式在客户端安装程序库,通过客户端程序库直接访问数据,该方式的优点是性能高,缺点是对应用有侵入。访问示意图如下:

典型客户端程序库中间件如阿里的TDDL,本文以TDDL为例,介绍客户端程序库方式的数据库访问中间件的工作原理。

TDDL采用了客户端库(即Java.jar包)形式,在Jar包中封装了分库分表的逻辑,部署在ibatis、mybatis或者其他ORM框架之下、JDBC Driver之上,是JDBC或持久框架层与底层JDBC Driver之间的交互桥梁。

TDDL的逻辑架构分为三层:Matrix 、Group、Atom。Matrix层负责分库分表路由,SQL语句的解释、优化和执行,事务的管理规则的管理,各个子表查询出来结果集的Merge等;Group层负责数据库读写分离、主备切换、权重的选择、数据保护等功能;Atom层是真正和物理数据库交互,提供数据库配置动态修改能力,包括动态创建,添加,减少数据源等。

当client向数据库发送一条SQL的执行语句时,会优先传递给Matrix层。由Martix 解释 SQL语句、优化,并根据查询条件将SQL路由到各个group;各个group根据权重选择其中一个Atom进行查询;各个Atom再将结果返回给Matrix,Matrix将结果合并返回给client。:

数据库代理服务中间件

数据库代理服务中间件部署在客户端与数据库服务器之间,对于客户端而言,它就像数据库服务器,而对于数据库服务器而言,它就像客户端。因所有数据库服务请求都需要经过数据库代理服务中间件,所以,中间件不仅可以记录所有的数据库操作,修改客户端发过来的语句,还可以对数据库的操作进行优化,实现其他如读写分离之类的能力

分布式系统架构设计模式,分布式架构和多体式架构

该方式应用程序不需要任何修改,只需把链接指向代理服务器,由代理服务器访问数据库,该方式的优点对应用没有侵入,缺点是性能低。

典型的数据库代理服务中间件有MySQL代理、Cobar、MyCAT、TDSQL等,本文以MySQL代理为例,介绍客户端程序库方式的数据库访问中间件的工作原理。

MySQL代理是MySQL官方提供的MySQL数据库代理服务中间件,其数据查询过程如下:1、 数据库代理服务中间件收到客户端发送的SQL查询语句;2、 代理服务中间件对SQL语句进行解析,得到要查询的相关信息,如表名CUSTOMER;3、 代理服务中间件查询配置信息,获得表CUSTOMER的存储位置信息,如数据库A、B、C;4、 同时根据解析的情况进行判断,如数据库A、B、C上都可能存在需要查询的数据,则将语句同时发送给数据库A、B、C。此时,如有必要,还可以对SQL语句进行修改。5、 数据库A、B、C执行收到的SQL语句,然后将查询结果发送给数据库中间件。6、 中间件收到数据库A、B、C的结果后,将所有的结果汇总起来,根据查询语句的要求,将结果进行合并。7、 中间件将最后的结果返回给客户端

不同的数据库代理服务中间件,根据其支持的协议,可代理的数据库不同,如Cobar只能代理MySQL数据库,而MyCAT除了可代理MySQL外,还可代理其他关系型数据库吗,如Oracle、SQL Server等。

分布数据库

对于分布式数据库,天然具备分布式,在系统设计方面不需要考虑太多,但在技术选型方面需要考虑各种分布式数据库的设计原理,这里有OceanBase、TiDB为例进行说明。

分布式数据计算最主要有两种形式,一种是分区SHARDING,一种是全局一致性HASH,根据这种分布式数据计算的种类不同,衍生出存算分离和存算一体这两种分布式数据库架构。进一步再分出一系列子类别,比如对等模式,代理模式,外挂模式等。

存算一体SHARDING模式的分布式数据库最为典型的是Oceanbase。下面是OB的架构图:

每个Observer是一个计算存储一体化的独立服务,带有SQL引擎,事务引擎和存储引擎,并存储一部分分片数据(分区)。OB采用的是对等模式,客户端连接到任何一个OBSERVER都可以全功能使用数据库。OBSERVER会自动产生分布式执行计划,并将算子分发到其他参与协同计算的OBSERVER上,完成分布式计算。管理整个集群的RootService只在部分Observer中存在,并全局只有一个是主的,其他都是备的。实际上每个OBSERVER就组成了一个完整的数据库子集,如果所有的数据都存储于一个OBSERVER上,我们的访问方式类似于一个集中式数据库。

这种架构下的数据分区采用了高可用,一个数据写入主副本(Leader)后会自动同步到N个备副本,OB采用Paxos分区选举算法来实现高可用。其备副本可以用于只读或者弱一致性读(当副本数据存在延迟时),从而更为充分的利用多副本的资源。实际上不同的分布数据库的主备副本的使用方式存在较大的差异,有些分布式数据库的备副本平时是不能提供读写的,只能用于高可用。

基于SHARDING存储需要分布在各个SHARDING分区中的分区表要有一个SHARDING KEY,根据SHARDING KEY来做分片处理。SHARDING模式虽然应对一般的数据库操作是没问题了,不过如果一条复杂的查询语句中没有关于SHARDING KEY的过滤条件,那么就没办法根据SHARDING KEY去做分区裁剪,必须把这个算子分发到集群中存储这张表的分区的所有OBSERVER上,这种情况被称为SHARDING数据库的读放大。

另外一方面,在OB数据库中创建一张表的时候需要考虑采用哪种模式,是创建为分区表还是普通的表,如果创建表的时候不指定是分区表,那么这张表只会被创建在一个OBSERVER中,无法在集群内多节点横向扩展。另外如果有多张表要进行JOIN,如果要JOIN的数据分别属于不同的OBSERVER管理,那么这种JOIN是跨网络的,其性能也会受到一定的影响。为了解决这个问题,OB提供了TABLE GROUP的功能,可以让分区属性类似的分区表或者经常JOIN的单表的数据存放在相同的OBSERVER中,从而避免上面所说的多表JOIN的性能问题。这种模式对于数据库用户来说似乎变得麻烦了,不过就像开车一样,如果要能够发挥出车辆的最大性能,手动模式可能是最好的。

既然存算一体的SHARDING模式有这种缺陷,那么能不能采用存算分离的方案呢?大家来看TIDB的架构:

分布式系统架构设计模式,分布式架构和多体式架构

TIDB是采用完全的存算分离的,计算引擎TIDB和存储引擎TIKV是分离的,因此TIDB不存在存算一体的SHARDING分布式数据库的这种读放大的问题。似乎TIDB要技高一筹,完美的解决了OB等SHARDING数据库的问题,不过事实上没有那么简单。

首先是那就是计算节点的本地缓冲问题。因为大型分布式计算环境下实施缓冲区融合成本极高,因此每个TIDB节点只能有本地缓冲,不能有全局缓冲。因此在TIDB的SQL引擎上是没有全局DB CACHE的,TIDB的数据缓冲只能建立在TIKV和TIFLASH上。SQL引擎->DB CACHE->存储路径->数据存储介质这种传统数据库的数据读取模式变成了SQL引擎->本地局部缓冲->存储路径(网络)->存储节点缓冲->存储介质这种模式。这种模式对于一些小型的需要超低延时的OLTP业务并不友好。为了解决这个问题,采用此类架构的数据库系统,就必须采用更快的存储介质和低延时的网络架构,从而解决缺乏全局DB CACHE支持的问题。当然这个问题也并不是不能解决的,通过更为细致的算子下推,采用类似ORACLE的SMART SCAN等技术,可以解决大部分问题。

存算分离也是一个双刃剑,消除大多数SHARDING方式存储数据的弊端也是有代价的。所有的读写操作都必须经过网络,不像OB那样,如果是本地数据读写不需要经过网络。因此每个单一的读写操作,TiDB都必须承受网络延时的放大。不过TiDB的这种延时放大是稳定的,不会像OB那样,同一条SQL,时快时慢(经过网络肯定要比读取本地数据慢一些)。

TiDB的完全存算分离的架构,很好的解决了Sharding架构的读放大问题,这种架构避免了不必要的读放大,但是也让数据读写的平均延时因网络而受到了一定的影响,得失之间也是有所取舍的。TiDB也在通过计算引擎的优化来减少其负面影响。比如对于不变更的历史数据,TiDB也引入了计算节点本地读缓冲机制来提升性能,同时通过算子尽可能早的向TiKV和TiFlash下推来利用分布式数据库的并发能力来提升性能。

另外很重要的一点是TiDB的存算分离架构用增加网络延时的性能牺牲换来了用户使用的简化,我们基本上可以像使用集中式数据库一样来使用TiDB,建表的时候想建分区表就建分区表,想建普通表就建普通表。全局索引,本地索引也和集中式数据库一样简单。这对于一些应用水平不高的用户来说是十分重要的。因为大部分分布式数据库上的性能问题并不是数据库本身的问题引起的,而是因为应用开发商不合理的设计数据结构,以及不合理的表分区与数据分布引起的。

因为采用截然不同的架构,OB和TiDB在不同的应用场景下的表现会有所不同。对于一些简单的小交易为主的业务来说,因为OB数据库的SHARDING架构,受益于各级缓冲,在这些小交易的延时方面,可能OB较为有优势。而对于一些经常有大型写事务的场景,因为TiDB可以从计算节点更加快速的下推算子而比OB更有优势,而OB必须将算子分解到各个OBSERVER上,交给OBSERVER再往下写数据,其效率肯定是有所损失的。

一些大型的表扫描和多表关联查询也是如此,因为两种不同的架构,在性能上也会表现的不同。当执行计划类似的情况下,TiDB的分布式执行计划被分解的粒度更细,并直接下推到TiKV和TiFlash上,而OB需要将算子分别推送给其他OBSERVER,再由OBSERVER下推,在交互上也多了一层。这是架构的差异导致的直接差异,享受好处的同时肯定也需要承受一些缺陷。

总而言之,你的企业应用十分复杂,数据量十分大。那么在你从类似OB/TiDB这样不同架构的分布式数据库中做选择的时候,一定要把你比较复杂的SQL拿出来做些POC测试,再来完成你的选择。因为对于大多数应用来说,这些分布式数据库在架构上的缺陷还是不会造成太大的影响的,而如果某些SQL因为执行计划无法优化而导致你必须改写应用就比较麻烦了。

更多关于分布式数据库的信息,可以参考我之前的文章:

分布式数据库

孔凡勇,公众号:互联网技术集中营浅析分布式数据库

可伸缩性设计实践

1.按功能垂直分割

相关的功能部分应该合在一起,不相关的功能部分应该分割开来——不管你把它叫做SOA还是微服务。而且,不相关的功能之间耦合程度越松散,就越能灵活地独立伸缩其中的一部分。

在编码层次,我们无时不刻都在运用这条原则。JAR文件、包、Bundle等等,都是用来隔离和抽象功能的机制。

2.水平切分

按功能分割对我们的帮助很大,但单凭它还不足以得到完全可伸缩的架构。即使将功能一一解耦,单项功能的资源需求随着时间增长,仍然有可能超出单一系统的能力。我们常常提醒自己,“没有分割就没有伸缩”。在单项功能内部,我们需要能把工作负载分解成许多我们有能力驾驭的小单元,让每个单元都能维持良好的性能价格比。这就是水平分割出场的时候了。

在应用层次,由将各种交互都设计成无状态的,所以水平分割是轻而易举之事。用标准的负载均衡服务器来路由进入的流量。所有应用服务器都是均等的,而且任何服务器都不会维持事务性的状态,因此负载均衡可以任意选择应用服务器。如果需要更多处理能力,只需要简单地增加新的应用服务器。

数据库层次的问题比较有挑战性,原因是数据天生就是有状态的,可以采用分库分表或者分布式数据库。

3.避免分布式事务

看到这里,你可能在疑惑按功能划分数据和水平划分数据的实践如何满足事务要求。毕竟,几乎任何有意义的操作都要更新一个以上的实体——立即就可以举出用户和商品的例子。正统的广为人知的答案是:建立跨资源的分布式事务,用两段式提交来保证要么所有资源全都更新,要么全都不更新。很不幸,这种悲观方案的成本很可观。伸缩、性能和响应延迟都受到协调成本的反面影响,随着依赖的资源数量和客户数量的上升,这些指标都会以几何级数恶化。可用性亦受到限制,因为所有依赖的资源都必须就位。实用主义的答案是,对于不相关的系统,放宽对它们的跨系统事务的保证。

左右逢源是办不到的。保证跨多个系统或分区之间的即时的一致性,通常既无必要,也不现实。Inktomi的EricBrewer十年前提出的CAP公理是这样说的:分布式系统的三项重要指标——一致性(Consistency)、可用性(Availability)和 分区耐受性(Partition-tolerance)——在任意时刻,只有两项能同时成立。对于高流量的网站来说,我们必须选择分区耐受性,因为它是实现可伸缩的根本。对于24x7运行的网站,选择可用性也是理所当然的。于是只好放弃即时一致性(immediate consistency)。

对于分布式事务问题,我们可以考虑最终一致性方案,我们会将作用于同一个数据库的若干语句*绑捆**成单个事务性的操作。而对于绝大部分操作,单条语句是自动提交的。虽然我们故意放宽正统的ACID属性,以致不能在所有地方保证即时一致 性,但现实的结果是大部分系统在绝大部分时间都是可用的。当然我们也采用了一些技术来帮助系统达到最终的一致性(eventualconsistency):周密调整数据库操作的次序、异步恢复事件,以及数据核对(reconciliation)或者集中决算(settlement batches)。具体选择哪种技术要根据特定用例对一致性的需求来决定。

对于架构师和系统的设计者来说,关键是要明白一致性并非“有”和“没有”的单选题。现实中大多数的用例都不要求即时一致性。正如我们经常根据成本和其他压力因素来权衡可用性的高低,一致性也同样可以量体裁衣,根据特定操作的需要而保证适当程度的一致性。

4.用异步策略解耦程序

提高可伸缩性的另一项关键措施是积极地采取异步策略。如果组件A同步调用组件B,那么A和B就是紧密耦合的,而紧耦合的系统其可伸缩性特征是各部分必须共同进退——要伸缩A必须同时伸缩B。同步调用的组件在可用性方面也面临着同样的问题。我们回到最基本的逻辑:如果A推出B,那么非B推出非A。也就是说,若B不可用,则A也不可用。如果反过来A和B的联系是异步的,不管是通过队列、多播消息、批处理还是什么其他手段,它们就可以分别地伸缩。而且此时A和B的可用性特征是相互独立的——即使B受困或者死掉,A仍然能够继续前进。

整个基础设施从上到下都应该贯彻这项原则。即使在单个组件内部也可通过SEDA(分阶段的事件驱动架构,StagedEvent-DrivenArchitecture)等技术实现异步性,同时保持一个易于理解的编程模型。组件之间也遵守同样的原则——尽可能避免同步带来的耦合。在多数情况下,两个组件在任何事件中都不会有直接的业务联系。在所有的层次,把过程分解为阶段(stages or phases),然后将它们异步地连接起来,这是伸缩的关键。

5.将过程转变为异步的流

用异步的原则解耦程序,尽可能将过程变为异步的。对于要求快速响应的系统,这样做可以从根本上减少请求者所经历的响应延迟。对于网站或者交易系统,牺牲数据或执行的延迟时间(完成全部工作的实践)来换取用户的延迟时间(用户得到响应的时间)是值得的。活动跟踪、单据开付、决算和报表等处理过程显然都 应该属于后台活动。主要用例过程中常常有很多步骤可以进一部分解成异步运行。任何可以晚点再做的事情都应该晚点再做。

还有一个同等重要的方面认识到的人不多:异步性可以从根本上降低基础设施的成本。同步地执行操作迫使你必须按照负载的峰值来配备基础设施——即使在任务最重的那一天里任务最重的那一秒,设施也必须有能力立即完成处理。而将昂贵的处理过程转变为异步的流,基础设施就不需要按照峰值来配备,只需要满足平均负载。而且也不需要立即处理所有的请求,异步队列可以将处理任务分摊到较长的时间里,因而起到削峰的作用。系统的负载变化越大,曲线越多尖峰,就越能从 异步处理中得益。

6.虚拟化所有层次

虚拟化和抽象化无所不在,计算机科学里有一句老话:所有问题都可以通过增加一个间接层次来解决。操作系统是对硬件的抽象,而许多现代语言所用的虚拟机又是对操作系统的抽象。对象-关系映射层抽象了数据库。负载均衡器和虚拟IP抽象了网络终端。当我们通过分割数据和程序来提高基础设施的可伸缩性,为各种分割增加额外的虚拟层次就成为重中之重。

在阿里期间,研发团队不需要过多关注数据库与中间件,基础设施团队都对其进行了虚拟化,对于数据库虚拟化,应用与逻辑数据库交互,逻辑数据库再按照配置映射到某个特定的物理机器和数据库实例。应用也抽象于执行数据分割的路由逻辑,路由逻辑会把特定的记录(如用户XYZ)分配到指定的分区。这两类抽象都是在我们自己开发的O/R层上实现的。

搜索引擎同样是虚拟化的。为了得到搜索结果,一个聚合器组件会在多个分区上执行并行的查询,但这个高度分割的搜索网格在客户看来只是单一的逻辑索引。

以上种种措施并不只是为了程序员的方便,运营上的灵活性也是一大动机。硬件和软件系统都会故障,请求需要重新路由。组件、机器、分区都会不时增减、移动。明智地运用虚拟化,可使高层的设施对以上变化难得糊涂,你也就有了腾挪的余地。虚拟化使基础设施的伸缩成为可能,因为它使伸缩变成可管理的。

7.适当地使用缓存

最后要适当地使用缓存。这里给出的建议不一定普遍适用,因为缓存是否高效极大地依赖于用例的细节。说到底,要在存储约束、对可用性的需求、对陈旧数据的容忍程度等条件下最大化缓存的命中率,这才是一个高效的缓存系统的最终目标。经验证明,要平衡众多因素是极其困难的,即使暂时达到目标,情况也极可能 随着时间而改变。

最适合缓存的是很少改变、以读为主的数据——比如元数据、配置信息和静态数据。对于这类数据应该积极地缓存这种类型的数据,并且结合使用“推”和“拉”两种方法保持系统在一定程度上的更新同步。减少对相同数据的重复请求能达到非常显著的效果。频繁变更、读写兼有的数据很难有效地缓存。通常情况下对请求间短暂存在的会话数据不作任何缓存,也不在应用层缓存共享的业务对象,比如商品和用户数据。我们有意地牺牲缓存这些数据的潜在利益,换取可用性和正确性。

好东西也会过犹不及。为缓存分配的内存越多,能用来服务单个请求的内存就越少。应用层常常有内存不足的压力,因此这是非常现实的权衡。更重要的一点,当你开始依赖于缓存,那么主要系统就只需要满足缓存未命中时的处理要求,自然而然你就会想到可以削减主要系统。但当你这样做之后,系统就完全离不开缓存了。现在主要系统没办法直接应付全部流量,也就是说网站的可用性取决于缓存能否100%正常运行——潜在的危局。哪怕是例行的操作,比如重新配置缓存资源、把缓存移动到别的机器、冷启动缓存服务器,都有可能引发严重的问题。

做得好,缓存系统能让可伸缩性的曲线向下弯曲,也就是比线性增长还要好——后续请求从缓存中取数据比从主存储取数据成本低廉。反过来,缓存做得不好会引入相当多额外的经常耗费,也会妨碍到可用性。我还没见过哪个系统没机会让缓存大展拳脚的,关键是要根据具体情况找到适当缓存策略。

总结

总之,我们可以在入口层、业务层面、缓存层和数据库层四个层面,使用刚才介绍的方法和技术实现系统高可用和可伸缩性。具体为:在入口层用心跳来做到高可用,用平行部署来伸缩;在业务层做到服务无状态;在缓存层,可以减小一些粒度,以方便实现高可用,使用一致性Hash将有助于实现缓存层的伸缩性;数据库层的主从模式能解决高可用问题,拆分和滚动能解决可伸缩问题,当然也可以使用分不是数据库。

分布式系统架构设计模式,分布式架构和多体式架构

可伸缩性有时候被叫做“非功能性需求”,言下之意是它与功能无关,也就比较不重要。这么说简直错到了极点。我的观点是,可伸缩性是功能的先决条件——优先级为0的需求,比一切需求的优先级都高。