1.2 一个简单的例子:分布式架构的组成
1.1 节介绍了软件架构的演进历史,说明了软件架构是沿着高性能、高可用、可扩展、可伸缩、够安全的方向发展的,其中最重要的是高性能和高可用。在分布式时代,服务的分布式部署还带来了一系列其他问题,诸如服务的拆分、调用、协同以及分布式计算和存储。这里通过一个简单的分布式示例带大家了解分布式架构的特征和问题,然后针对发现的问题进行拆解,找到解决方案。首先介绍一下例子的业务流程,如图 1-11 所示,该图描述了从浏览商品,到下单、付款以及扣减库存,最后通知用户的整个过程。由图 1-12 可以看出,系统架构会根据以上业务提供对应服务:在用户通过商品服务浏览商品,通过订单服务下单,通过支付服务付款以后,会通知库存服务扣减相应的库存,订单完成以后,通知服务会向用户发送订单完成的消息。这是对业务背景的描述,接下来会以架构分层为主线,介绍技术实现以及在分布式架构中会遇到的问题。
图 1-11 一个简单例子的业务流程
1.2.1 架构概述与分层
为了完成上面的订单业务流程,将分布式系统分为了四层,如图 1-12 所示,下面从上到下依次介绍一下这四层。
•客户端:这是用户与系统之间的接口,用户在这里可以浏览商品信息,并且对商品下单。为了提升用户体验,会利用 HTTP 缓存手段将部分静态资源缓存下来,同时也可以将这部分静态资源缓存到 CDN 中,因为 CDN 服务器通常会让用户从比较近的网络节点获取静态数据。
•负载均衡器(以下称接入层):分布式应用会对业务进行拆分,并将拆分后的业务分别部署到不同系统中去,又或者使用服务器集群分担请求压力。假设我们的案例使用的是服务器集群,这里分别为华南和华中地区的用户设置两个应用服务器集群,那么负载均衡器可以通过用户 IP 将用户的请求路由到不同的服务器集群。另外,在负载均衡这一层,还可以进行流量控制和身份验证等操作。
•应用服务器(以下称应用层):这一层用于部署主要的应用服务,例如商品服务、订单服务、支付服务、库存服务和通知服务。这些服务既可以部署到同一台服务器上,也可以部署到不同服务器上。当负载均衡器将请求路由到应用服务器或者内网以后,会去找对应的服务,例如商品服务。当请求从外网进入内网后,需要通过 API 网关进行再次路由,特别是微服务架构中服务拆分得比较细,就更需要 API 网关了。API 网关还可以起到内网的负载均衡、协议转换、链式处理、异步请求等作用。由于应用服务(一个或者多个)部署在多个服务器上,这些服务器要想互相调用,就要考虑各服务间的通信、协调等问题,因此加入了服务注册中心、消息队列消息中心等组件。同时由于商品服务会经常被用户调用,因此加入了缓存机制。
•数据服务器(以下称存储层):由于商品信息比较多,所以对其进行分片操作,分别存放到商品表 1 和商品表 2 这两张表中来保证数据库的可用性。这里加入了主、备数据库的设计,这两个数据库服务器会进行同步,当主数据库服务器挂掉的时候,备数据库服务器就会接管它的一切。
图 1-12 订单业务架构图
1.2.2 客户端与 CDN
对于一个电商网站而言,商品信息是用户访问最多的内容。电商网站拥有海量商品,用户每次请求后,它都会返回诸如商品的基本参数、图片、价格和款式等信息。而商品一经发布,某些信息被修改的频率并不是很高,例如商品描述和图片。如果用户每次访问时都需要请求应用服务器,然后再访问数据库,那么效率是非常低的。在大多数情况下,用户访问时使用的协议是 HTTP,或者说用户使用浏览器访问电商网站时会发起 HTTP 请求,因此我们把每次的 HTTP 请求都缓存下来,就可以减小应用服务器的压力。加入缓存后的用户请求过程如图 1-13 所示,在用户第一次发出请求的时候,客户端缓存会判断是否存在缓存,如果不存在,则向应用服务器发出请求,此时应用服务器会提供数据给客户端,客户端接收数据之后将其放入缓存中。当用户第二次发出同样的请求时,客户端缓存依旧会判断是否存在缓存。由于上次请求时已经缓存了这部分数据,因此由客户端缓存提供数据,客户端接收数据以后返回给用户。备注:图中实线部分表示没有缓存的流程,虚线部分表示存在缓存的流程。
图 1-13 客户端通过发送 HTTP 请求缓存数据
一般信息的传递通过 HTTP 请求头来完成。目前比较常见的缓存方式有两种,分别是强制缓存和对比缓存。细节在这里暂不展开,第 8 章会介绍不同应用场景中缓存的具体用法。HTTP 缓存主要是对静态数据进行缓存,把从服务器拿到的数据缓存到客户端/浏览器中。如果在客户端和服务器之间再加上一层 CDN,就可以让 CDN 为应用服务器提供缓存,有了 CDN 缓存,就不用再请求应用服务器了。因此可以将商品的基本描述和图片等信息放到 CDN 中保存,还可以将一些前端通用控制类的 JavaScript 脚本也放在里面。需要注意的是,在更新商品信息(例如商品图片)时,需要同时更新 CDN 中的文件和 JavaScript 文件。
1.2.3 接入层
用户浏览商品的请求一般是一个 URL,这个 URL 被 DNS 服务器解析成服务器的 IP 地址,然后用户的请求通过这个 IP 地址访问应用服务器上的服务,这是早期的做法。如果读了 1.1 节的架构演进,应该就会知道,这样让应用服务器暴露在互联网上是非常危险的,为了解决这个问题,在应用服务器和客户端之间加入了反向代理服务器,它负责对外暴露服务的入口地址,保护内网的服务。从我们举的例子出发,用户浏览商品的请求有可能来自不同的网络,这些网络的访问速度不尽相同,比如有两个用户分别来自华中地区和华南地区,那么就可以针对这两个地区分别设置两个反向代理服务器提供服务。
上面说了反向代理服务器可以服务于不同网络的用户,同样,来自不同网络的用户也可以被负载均衡器路由到不同的应用服务器集群。浏览商品的请求经过负载均衡器的时候,是不知道会被路由到哪台应用服务器的。由于分布式部署,位于华中地区的集群服务器和华南地区的集群服务器都能提供商品信息的服务。此时负载均衡器可以协助路由工作,但其功能不仅仅局限于路由,它的主要功能是将大量的用户请求均衡地负载到后端的应用服务器上。特别是在有集群的情况下,负载均衡器有专门的算法可供选择。如果用户请求量再次加大,大到系统无法承受的地步,负载均衡器还可以起到限流作用,将那些系统无法处理的请求流量阻断在系统之外,如图 1-14 所示。
图 1-14 负载均衡
客户端请求服务器的过程可以分为如下四个步骤:
(1) 客户端向 DNS 服务器发出 URL 请求;
(2) DNS 服务器向客户端返回应用服务器入口的 IP 地址;
(3) 客户端向服务器发送请求;
(4) 负载均衡器接收请求以后,根据负载均衡算法找到对应的服务器,并将请求发送给此服务器。
1.2.4 应用层
这一层中包含具体的业务应用服务,例如商品服务、订单服务、支付服务、库存服务和通知服务。这些服务之间有的可以独立调用,例如商品服务,有的存在相互依赖,例如调用订单服务的时候会同时调用库存服务。接下来,我们从 API 网关、服务协同与通信、分布式互斥、分布式事务这 4 个方面来分析应用层的情况。
•API 网关
经过负载均衡之后的用户请求一般会直接调用应用服务,但是随着分布式的兴起,特别是微服务的广泛应用,服务被切割得非常精细,有可能分布在相同或者不同的服务器中,同一个服务也会被做水平扩展,也就是将同一个服务复制为多个,以面对高并发请求。比如商品服务被调用的次数是最多的,因此从高性能和可用性的角度来看,会做水平扩展,此时用户在请求商品服务的时候需要有个中介,以便将请求路由到对应的服务。当然,这个中介也会起到负载均衡和限流的作用。另外,由于存在很多服务之间的调用,并且这些服务存在技术异构性,因此为了消除技术异构性,可以在 API 网关中进行协议转换。现在,我们把 API 网关要做的事情总结为如下几条,并通过图 1-15 来介绍 API 网关的功能。
•内容为浏览商品的用户请求要想接触到商品服务,需经过 API 网关,由网关充当路由为其找到对应的服务。
•如果存在水平扩展的商品服务,API 网关需要起到负载均衡的作用。例如,用户请求商品服务时,同时存在着 2 个商品服务,此时 API 网关就需要帮助用户决定让哪个商品服务响应其请求。
•如果用户需要对商品进行下单操作,则 API 网关要对用户身份进行鉴权。
•一个服务调用其他服务的时候,如果两个服务使用的传输协议不一致,那么 API 网关需要对协议进行转换。例如,订单服务需要调用库存服务和支付服务完成业务需求,但订单服务与其他两个服务使用的是不同协议,此时 API 应负责做它们之间的协议转换。
•一旦大量用户同时请求浏览商品,其流量超出系统承受的范围,API 网关就需要完成限流操作。
•对于浏览商品的操作,API 网关系统要记录相应的日志。
图 1-15 API 网关的功能
如果把请求看成水流,API 网关就像一个控制水流的水阀。它控制水的流向、大小,调整不同蓄水池存储水流的方式,记录水流的信息。API 网关和负载均衡器在原理上是相同的,区别在于前者更多是在服务器内部服务之间实现,而后者是在互联网与服务器之间实现。
•服务协同与通信
用户在浏览完商品详情以后,会通过订单服务下单,然而订单服务又需要调用支付服务。订单服务和支付服务分别运行在不同的进程、容器甚至是服务器中,两者如何发现对方并进行通信呢?在没有进行服务切割和分布式部署的时候,一个模块调用另外一个模块需要在代码中耦合,在代码中描述调用条件,并且调用对应的方法或者模块。这些属于进程内调用,但是随着分布式和微服务的兴起,服务或者应用从原有的单进程切分到多个进程中,这些进程又运行在不同的容器或者服务器上。这时服务之间又该如何得知其他服务的存在以及进行调用呢?
下面以订单服务为例来介绍,具体如图 1-16 所示。假设订单服务需要调用支付服务为商品买单。订单服务和支付服务事先会在服务注册中心注册自己。订单服务在调用支付服务之前,会先去注册中心获取所有可用服务的调用列表,然后根据列表上的地址对支付服务进行调用。此时订单服务会对支付服务发起一个 RPC 调用,把需要传递的订单信息以序列化的方式打包,并经过网络协议传递给支付服务,支付服务在接收到信息以后,通过反序列化工具解析传递的内容,然后执行接下来的付款操作。再跟着图 1-16 将这个过程梳理一遍。
(1) 支付服务到服务注册中心注册自己。
(2) 订单服务从注册中心获取可用服务的列表。
(3) 订单服务在列表中找到支付服务的地址和访问方式,并调用支付服务。
图 1-16 服务注册、服务发现和服务调用
通常在支付完成以后,系统会通过短信或者 App 推送的方式通知用户,此时就需要调用通知服务。由于通知服务属于基础功能服务,与业务的关联性不强,会被其他业务系统调用,因此,将其单独部署,甚至作为单独的系统进行维护。支付系统在支付成功以后,会将成功的消息发送到消息队列,而通知服务会将该信息按照配置好的通知方式发送给用户,如图 1-17 所示。
图 1-17 通知服务通过消息队列通信
•分布式互斥
在上一部分中,订单服务只调用了支付服务,但是大家知道在下订单的同时会对库存进行扣减,因此订单服务也会调用库存服务。库存服务针对商品库存进行操作,如果有两个用户同时对同一商品下单,就会形成对同一商品库存同时进行扣减的情况,这当然是不行的。我们将此处的商品库存称作临界资源,扣减库存的动作称作竞态。如果是在进程内,可以理解为两个线程(两个用户请求)在争夺库存资源,最简单的解决办法就是在这个资源上加一把锁,如图 1-18 所示。当线程 B 访问的时候,让其持有这把锁,这样线程 A 就无法访问,并且进入等待队列。当线程 B 执行完库存扣减的操作以后,释放锁,由线程 A 持有锁,然后进行库存扣减操作。
图 1-18 多线程访问临界资源
由于分布式服务是分散部署的,而且可以实现水平扩展,因此问题发生了变化,原来是一个进程内的多线程对临界资源的竞态,现在变成应用系统中的多个服务(进程)对临界资源的竞态。接下来这种情况对库存服务进行了水平扩展,将其从原来的一个扩展成两个。库存服务 A 和库存服务 B 可能会同时扣减库存,这里通过 ZooKeeper 的 DataNode 保证两个进程的访问顺序,两个库存扣减进程会在 ZooKeeper 上建立顺序的 DataNode,节点的顺序就是访问资源的顺序,能够避免两个进程同时访问库存,起到了锁的作用。具体的做法是在 ZooKeeper 中建立一个 DataNode 节点起到锁(locker)的作用,通过在此节点下面建立子 DataNode 来保证访问资源的先后顺序,即便是两个服务同时申请新建子 DataNode 节点,也会按照先后顺序建立。图 1-19 中的具体步骤如下。
(1) 当库存服务 A 访问库存的时候,需要先申请锁,于是在 ZooKeeper 的 Locker 节点下面新建一个 DataNode1 节点,表明它可以扣减库存。
(2) 库存服务 B 在库存服务 A 后面申请库存的访问权限,由于其申请锁操作排在库存服务 A 后面,因此其按照次序建立的节点会排在 DataNode1 下面,名字为 DataNode2。
(3) 库存服务 A 在申请锁成功以后访问库存资源,并进行库存扣减。在此期间库存服务 B 一直处于等待状态,直到库存服务 A 完成扣减操作以后,ZooKeeper 中 Locker 下面的 DataNode1 节点被删除,库存资源被释放。
(4) DataNode1 被删除以后,DataNode2 成为序号最靠前的节点,库存服务 B 因此得到了对库存的访问权限,并且可以完成库存扣减操作。
图 1-19 多个库存服务访问库存资源
•分布式事务
下订单和扣减库存两个操作通常是同时完成的,如果库存为 0 则表示没有库存可以扣减,那么下订单的操作也将无法执行。如果把订单服务中的下订单操作和库存服务的扣减库存操作当作一个事务,那么由于这两个操作跨越了不同的应用(服务器),因此可以将这个事务视为分布式事务。类似的情况在分布式架构中比较常见。由于应用服务的分散性,操作也会分散,如果这些分散的操作共同完成一个事务,就需要进行特殊处理。一般做法是在订单服务和库存服务上建立一个事务协调器,用来协调两个服务的操作,保证两个操作能在一个事务中完成。事务协调器会分两个阶段来处理事务。
事务提交第一阶段如图 1-20 所示。
(1) 事务协调器分别向订单服务和库存服务发送“CanCommit?”消息,确定这两个服务是否准备好了。准备好的意思是订单服务已准备好添加订单记录以及库存服务已准备好扣减库存。
(2) 订单服务接收到消息以后,检查订单信息,并准备增加商品订单记录,同时将消息 Yes 回复给事务协调器。如果库存服务在准备过程中发现库存不足,就向事务协调器回复 No,意思是终止操作。
图 1-20 事务提交第一阶段
事务提交第二阶段如图 1-21 所示。
(1) 事务协调器接收到库存服务操作不成功的消息后,向订单服务和库存服务发送 DoAbort 消息,意思是放弃操作。订单服务在接收到此消息后,通过日志回滚增加商品订单的操作并释放相关资源。
(2) 这两个服务在完成相应操作后,向事务协调器发送 Committed 消息,表示完成撤销操作。
说明
倘若两个服务都准备好了,事务协调器就会发送执行的命令,两个服务会分别执行对应的操作,共同完成事务。
图 1-21 事务提交第二阶段
看到这里,有些朋友会说:“这不就是 2PC 吗?”是的,这是一种简单的处理分布式事务的方式,这里我们只做一个引子。在 4.3 节中,还会介绍 ACID、CAP、TCC 等处理分布式事务的方法。
1.2.5 存储层
存储层用来存放业务数据。和单体应用不同的是,分布式存储会将数据分别放置在不同的数据表、数据库和服务器上面。如果说单体应用是通过直接访问数据库,针对某张数据表的方式来获取数据,那么分布式数据库获取数据的方式就要复杂一些。这里先看一个例子,理论和实践方法会在第 6 章中详细说明。
•分布式存储
电商系统中商品信息的数据量比较大,为了提高访问效率,通常会将数据分片存放,被拆分以后的商品表会分布到不同的数据库或者服务器中。例如,商品表中有 1000 条数据,我们将它分成两张表来存储,将商品 ID 为 1~500 的记录分配到商品表 1,ID 为 501~1000 的记录分配到商品表 2。假设 ID 是顺序增长的,查询商品时会传入其 ID。如果按上述那样划分数据,在查询商品信息的时候就需要从两张表中获取数据,这两张表可能存储在相同数据库中,也可能存储在两个数据库中。如果存储在两个数据库中,那这两个数据库既可能存在于同一台服务器上,也可能存在于两台不同的服务器上,因此,还需要在代码中建立两个数据库的连接,分别做两次查询,这样的效率会很低。此时可以加入 MyCat 数据库中间件,它的作用是解决由数据分片带来的数据路由、SQL 解析等问题。如图 1-22 所示,当接收到商品查询请求之后,MyCat 对 SQL 进行解析,获得需要获取的商品表的信息。假设 SQL 中传入的商品 ID 是 100,100 在 1~500 的范围内,通过数据分片的路由规则可以知道,返回的商品信息需要从“数据库服务器 1”中的“商品表 1”中获取。
图 1-22 MyCat 实现商品表分片
以此类推,也可以定义其他的数据库分片规则,例如根据区域、品类等。
•读写分离与主从同步
针对商品表的数据量比较大这一点,对其进行了分片操作。同样商品表被读取的机会也比较大,更新的机会相对较小,对此可以设置读写分离。还是分析上面商品表的例子,由于这里主要讲读写分离和主从同步,因此只针对“商品表 1”进行操作。如图 1-23 所示,配置主节点 writeHost 来负责写入操作,配置从节点 readHost 来负责读取操作。图中处于上方的 MyCat 数据库中间件会通过 ZooKeeper 定期向两个节点服务器发起心跳检测(虚线部分)。图 1-23 中实线部分描述的信息有:MyCat 开启读写分离模式之后,中间件接收到商品读/写的请求时,会通过 SQL 解析,将写入请求的 DML(Data Manipulation Language)SQL 发送到 writeHost 服务器上,将读取请求的 Select SQL 发送到 readHost 服务器上。writeHost 在完成写入信息以后,会和 readHost 进行数据同步,也就是主从复制。由于存在心跳检测机制,当 writeHost 挂掉时,如果在默认N次心跳检测后(N可以配置)仍旧没有恢复,MyCat 就会发起选举,选举出一台服务器成为新的 writeHost。它会接替之前的 writeHost,负责处理写入数据的数据同步。当之前的 writeHost 恢复以后,会成为从节点 readHost 并且接收来自新 writeHost 的数据同步。
图 1-23 读写分离与主从复制