周末比较忙,找了一篇之前旧文,希望能加深大家对互联网微服务架构的理解程度。
本文主要针对微服务的架构发展由来,发展的原因,集群部署,做了统一的介绍。(原文出自国外大神对亚马逊架构发展,及uber公司的技术发展的介绍进行一定的节选得出)
单体式应用的不足
敏捷开发和部署举步维艰,其中最主要问题就是这个应用太复杂,以至于任何单个开发者都不可能搞懂它。因此,修正bug和正确的添加新功能变的非常困难,应用的启动时间很耗时,复杂而巨大的单体式应用也不利于持续性开发
因为所有模块都运行在一个进程中,任何一个模块中的一个bug,比如内存泄露,将会有可能弄垮整个进程。除此之外,因为所有应用实例都是唯一的,这个bug将会影响到整个应用的可靠性。 最后,单体式应用使得采用新架构和语言非常困难。
微处理架构——处理复杂事物
将应用分解为小的、互相连接的微服务。 一个微服务一般完成某个特定的功能,比如下单管理、客户管理等等。所有服务都是采用异步的,基于消息的通讯。API Gateway负责负载均衡、缓存、访问控制、API 计费监控等等任务,可以通过NGINX方便实现。运行时,行程管理服务由多个服务实例构成。每一个服务实例都是一个Docker容器。为了保证高可用,这些容器一般都运行在多个云VM上。服务实例前是一层诸如NGINX的负载均衡器,他们负责在各个实例间分发请求。负载均衡器也同时处理其它请求,例如缓存、权限控制、API统计和监控。 这种微服务架构模式深刻影响了应用和数据库之间的关系,不像传统多个服务共享一个数据库,微服务架构每个服务都有自己的数据库。另外,这种思路也影响到了企业级数据模式。同时,这种模式意味着多份数据,但是,如果你想获得微服务带来的好处,每个服务独有一个数据库是必须的,因为这种架构需要这种松耦合。另外,每种服务可以用更适合自己的数据库类型,也被称作多语言一致性架构。
微服务架构的好处
微服务架构模式有很多好处。首先,通过分解巨大单体式应用为多个服务方法解决了复杂性问题每个服务都有一个用RPC-或者消息驱动API定义清楚的边界。微服务架构模式给采用单体式编码方式很难实现的功能提供了模块化的解决方案,由此,单个服务很容易开发、理解和维护。 第二,这种架构使得每个服务都可以有专门开发团队来开发。甚至于,因为服务都是相对简单,即使用现在技术重写以前代码也不是很困难的事情。 第三,微服务架构模式是每个微服务独立的部署。开发者不再需要协调其它服务部署对本服务的影响。
举个例子:
亚马逊Android客户端
购物车服务 -- 购物车中的物品数
下单服务 -- 下单历史
分类服务 -- 基本产品信息,如名字、图片和价格
评论服务 -- 用户评论
库存服务 -- 低库存警告
快递服务 -- 快递选项、截止时间、来自不同快递API的成本计算
推荐服务 -- 推荐产品
实现一个API Gateway
如此多的服务互相关联 调用,需要创建一个支持同步、非阻塞I/O的API Gateway。在JVM上,采用基于NIO技术的框架,如Netty,Vertx,Spring Reactor或者JBoss Undertow。
服务调用
一个基于微服务的应用是一个分布式系统,并且必须采用线程间通信的机制。有两种线程间通信的方法。一种是采用异步机制,基于消息的方法。这类的实现方法有JMS和AMQP。另外的,例如Zeromq属于服务间直接通信。还有一种线程间通信采用同步机制,例如Thrift和HTTP。事实上一个系统会同时采用同步和异步两种机制。
服务发现
API Gateway需要知道每一个微服务的IP和端口。在传统应用中,你可能会硬编码这些地址,但是在现在云基础的微服务应用中,这将是个简单的问题。基础服务通常会采用静态地址,可以采用操作系统环境变量来指定。但是,探测应用服务的地址就没那么容易了。应用服务通常动态分配地址和端口。同样的,由于扩展或者升级,服务的实例也会动态的改变。因此,API Gateway需要采用系统的服务发现机制,要么采用服务端发现,要么是客户端发现。
处理部分失败
一个需要考虑的问题就是部分失败。这个问题发生在分布式系统中当一个服务调用另外一个服务超时或者不可用的情况。API Gateway不应该被阻断并处于无限期等待下游服务的状态。Netfilix提供了一个比较好的解决方案,具体的应对措施包括: • 网络超时:当等待响应时,不要无限期的阻塞,而是采用超时策略。使用超时策略可以确保资源不会无限期的占用。 • 限制请求的次数:可以为客户端对某特定服务的请求设置一个访问上限。如果请求已达上限,就要立刻终止请求服务。 • 断路器模式(Circuit Breaker Pattern):记录成功和失败请求的数量。如果失效率超过一个阈值,触发断路器使得后续的请求立刻失败。如果大量的请求失败,就可能是这个服务不可用,再发请求也无意义。在一个失效期后,客户端可以再试,如果成功,关闭此断路器。 • 提供回滚:当一个请求失败后可以进行回滚逻辑。例如,返回缓存数据或者一个系统默认值。 Netflix Hystrix是一个实现相关模式的开源库。如果使用JVM,推荐考虑使用Hystrix。而如果使用非JVM环境,你可以使用类似功能的库。新旧版本一起运行:REST,一种解决方案是把版本号嵌入到URL中。每个服务都可能同时处理多个版本的API。或者,你可以部署多个实例,每个实例负责处理一个版本的请求。 同步模式:客户端请求需要服务端即时响应,甚至可能由于等待而阻塞。 http 异步模式:客户端请求不会阻塞进程,服务端的响应可以是非即时的。 消息队列
交互模式
客户端和服务器之间有很多的交互模式 两种维度:一对一/多 同步/异步
• 一对一:每个客户端请求有一个服务实例来响应。 • 一对多:每个客户端请求有多个服务实例来响应 第二个维度是这些交互式。
同步还是异步: • 同步模式:客户端请求需要服务端即时响应,甚至可能由于等待而阻塞。 • 异步模式:客户端请求不会阻塞进程,服务端的响应可以是非即时的。
一对多的交互模式有以下几种方式:
• 发布/ 订阅模式:客户端发布通知消息,被零个或者多个感兴趣的服务消费。
• 发布/异步响应模式:客户端发布请求消息,然后等待从感兴趣服务发回的响应。
异步的,基于消息通信
当使用基于异步交换消息的进程通信方式时,一个客户端通过向服务端发送消息提交请求。如果服务端需要回复,则会发送另外一个独立的消息给客户端。因为通信是异步的,客户端不会因为等待而阻塞,一个消息由头部(元数据例如发送方)和消息体构成。消息通过channel发送,任何数量的生产者都可以发送消息到channel,同样的,任何数量的消费者都可以从渠道中接受数据。
有两类channel,点对点 和 发布/订阅。点对点channel会把消息准确的发送到某个从channel读取消息的消费者,服务端使用点对点来实现之前提到的一对一交互模式;而发布/订阅则把消息投送到所有从channel读取数据的消费者,服务端使用发布/订阅channel来实现上面提到的一对多交互模式。
打车软件如何使用发布/订阅:
行程管理服务在发布-订阅channel内创建一个行程消息,并通知调度服务有一个新的行程请求,调度服务发现一个可用的司机然后向发布-订阅channel写入司机建议消息(Driver Proposed message)来通知其他服务。
有大量开源消息系统可选,比如RabbitMQ、Apache Kafka、Apache ActiveMQ和NSQ。它们都支持某种形式的消息和channel,并且都是可靠的、高性能和可扩展的;然而,它们的消息模型完全不同。 使用消息机制有很多优点: • 解耦客户端和服务端:客户端只需要将消息发送到正确的channel。客户端完全不需要了解具体的服务实例,更不需要一个发现机制来确定服务实例的位置。 • Message Buffering:在一个同步请求/响应协议中,例如HTTP,所有的客户端和服务端必须在交互期间保持可用。而在消息模式中,消息broker将所有写入channel的消息按照队列方式管理,直到被消费者处理。也就是说,在线商店可以接受客户订单,即使下单系统很慢或者不可用,只要保持下单消息进入队列就好了。 • 弹性客户端-服务端交互:消息机制支持以上说的所有交互模式。 • 直接进程间通信:基于RPC机制,试图唤醒远程服务看起来跟唤醒本地服务一样。然而,因为物理定律和部分失败可能性,他们实际上非常不同。消息使得这些不同非常明确,开发者不会出现问题。 然而,消息机制也有自己的缺点: • 额外的操作复杂性:消息系统需要单独安装、配置和部署。消息broker(代理)必须高可用,否则系统可靠性将会受到影响。 • 实现基于请求/响应交互模式的复杂性:请求/响应交互模式需要完成额外的工作。
同步的,基于请求/响应的IPC
当使用一个同步的,基于请求/响应的IPC机制,客户端向服务端发送一个请求,服务端处理请求,返回响应。最常见的两个协议是REST和Thrift。首先我们来看下REST。 REST 现在很流行使用RESTful风格的API。REST是基于HTTP协议的。另外,REST是一个资源,一般代表一个业务对象,比如一个客户或者一个产品,或者一组商业对象。REST使用HTTP语法协议来修改资源,一般通过URL来实现。举个例子,GET请求返回一个资源的简单信息,响应格式通常是XML或者JSON对象格式。POST请求会创建一个新资源,PUT请求更新一个资源。
客户端发现模式
当使用客户端发现模式时,客户端负责决定相应服务实例的网络位置,并且对请求实现负载均衡。客户端从一个服务注册服务中查询,其中是所有可用服务实例的库。客户端使用负载均衡算法从多个服务实例中选择出一个,然后发出请求。
服务实例的网络位置是在启动时注册到服务注册表中,并且在服务终止时从注册表中删除。服务实例注册信息一般是使用心跳机制来定期刷新的。 Netflix OSS提供了一种非常棒的客户端发现模式。Netflix Eureka是一个服务注册表,为服务实例注册管理和查询可用实例提供了REST API接口。Netflix Ribbon是一种IPC客户端,与Eureka合同工作实现对请求的负载均衡。我们会在后面详细讨论Eureka。 客户端发现模式也是优缺点分明。这种模式相对比较直接,而且除了服务注册表,没有其它改变的因素。除此之外,因为客户端知道可用服务注册表信息,因此客户端可以通过使用哈希一致性(hashing consistently)变得更加聪明,更加有效的负载均衡。 而这种模式一个最大的缺点是需要针对不同的编程语言注册不同的服务,在客户端需要为每种语言开发不同的服务发现逻辑。
服务端发现模式
客户端通过负载均衡器向某个服务提出请求,负载均衡器向服务注册表发出请求,将每个请求转发往可用的服务实例。跟客户端发现一样,服务实例在服务注册表中注册或者注销。 AWS Elastic Load Balancer(ELB)是一种服务端发现路由的例子,ELB一般用于均衡从网络来的访问流量,也可以使用ELB来均衡VPC内部的流量。客户端使用DNS,通过ELB发出请求(HTTP或者TCP)。ELB负载均衡器负责在注册的EC2实例或者ECS容器之间均衡负载,并不存在一个分离的服务注册表,而EC2实例和ECS实例也向ELB注册.HTTP服务和类似NGINX和NGINX Plus的负载均衡器都可以作为服务端发现均衡器。例如,这篇博文就描述如何使用Consul Template来动态配置NGINX反向代理。Consul Template是周期性从存放在Consul Template注册表中配置数据重建配置文件的工具。当文件发生变化时,会运行一个命令。在如上博客中,Consul Template产生了一个nginx.conf文件,用于配置反向代理,然后运行一个命令,告诉NGINX重新调入配置文件。更复杂的例子可以用HTTP API或者DNS动态重新配置NGINX Plus。 某些部署环境,例如Kubernetes和Marathon在集群每个节点上运行一个代理,此代理作为服务端发现负载均衡器。为了向服务发出请求,客户端使用主机IP地址和分配的端口通过代理将请求路由出去。代理将次请求透明的转发到集群中可用的服务实例。 服务端发现模式也有优缺点。最大的优点是客户端无需关注发现的细节,客户端只需要简单的向负载均衡器发送请求,实际上减少了编程语言框架需要完成的发现逻辑。
服务注册表
服务注册表是服务发现很重要的部分,它是包含服务实例网络地址的数据库。服务注册表需要高可用而且随时更新。客户端可以缓存从服务注册表获得的网络地址。Netflix Eureka是一个服务注册表很好地例子,提供了REST API注册和请求服务实例。 服务实例使用POST请求注册网络地址,每30秒必须使用PUT方法更新注册表,使用HTTP DELETE请求或者实例超时来注销。可以想见,客户端可以使用HTTP GET请求接受注册服务实例信息。 Netflix通过在每个AWS EC2域运行一个或者多个Eureka服务实现高可用性,每个Eureka服务器都运行在拥有弹性IP地址的EC2实例上。DNS TEXT记录用于存储Eureka集群配置,其中存放从可用域到一系列Eureka服务器网络地址的列表。当Eureka服务启动时,向DNS请求接受Eureka集群配置,确认同伴位置,给自己分配一个未被使用的弹性IP地址。 Eureka客户端—服务和服务客户端—向DNS请求发现Eureka服务的网络地址,客户端首选使用同一域内的服务。然而,如果没有可用服务,客户端会使用另外一个可用域的Eureka服务。 另外一些服务注册表例子包括:
etcd – 是一个高可用,分布式的,一致性的,键值表,用于共享配置和服务发现。两个著名案例包括Kubernetes和Cloud Foundry。
consul – 是一个用于发现和配置的服务。提供了一个API允许客户端注册和发现服务。Consul可以用于健康检查来判断服务可用性。
Apache ZooKeeper – 是一个广泛使用,为分布式应用提供高性能整合的服务。Apache ZooKeeper最初是Hadoop的子项目,现在已经变成顶级项目。
。一种方式是服务实例自己注册,也叫自注册模式(self-registration pattern);另外一种方式是为其它系统提供服务实例管理的,也叫第三方注册模式(third party registration pattern)。
自注册模式:
服务实例负责在服务注册表中注册和注销。另外,如果需要的话,一个服务实例也要发送心跳来保证注册信息不会过时
Eureka客户端负责处理服务实例的注册和注销。
微服务和分布式数据管理问题
单体式应用一般都会有一个关系型数据库,由此带来的好处是应用可以使用 ACID transactions,可以带来一些重要的操作特性:
原子性 – 任何改变都是原子性的
一致性 – 数据库状态一直是一致性的
隔离性 – 即使交易并发执行,看起来也是串行的
Durable – 一旦交易提交了就不可回滚
应用可以简化为:开始一个交易,改变(插入,删除,更新)很多行,然后提交这些交易。 使用关系型数据库带来另外一个优势在于提供SQL(功能强大,可声明的,表转化的查询语言)支持。用户可以非常容易通过查询将多个表的数据组合起来,RDBMS查询调度器决定最佳实现方式,用户不需要担心例如如何访问数据库等底层问题。另外,因为所有应用的数据都在一个数据库中,很容易去查询。 然而,对于微服务架构来说,数据访问变得非常复杂,这是因为数据都是微服务私有的,唯一可访问的方式就是通过API。这种打包数据访问方式使得微服务之间松耦合,并且彼此之间独立。如果多个服务访问同一个数据,schema会更新访问时间,并在所有服务之间进行协调。 更甚于,不同的微服务经常使用不同的数据库。应用会产生各种不同数据,关系型数据库并不一定是最佳选择。某些场景,某个NoSQL数据库可能提供更方便的数据模型,提供更加的性能和可扩展性。例如,某个产生和查询字符串的应用采用例如Elasticsearch的字符搜索引擎。同样的,某个产生社交图片数据的应用可以采用图片数据库,例如,Neo4j;因此,基于微服务的应用一般都使用SQL和NoSQL结合的数据库,也就是被称为polyglot persistence的方法。 分区的,polyglot-persistent架构用于存储数据有许多优势,包括松耦合服务和更佳性能和可扩展性。然而,随之而来的则是分布式数据管理带来的挑战。 第一个挑战在于如何完成一笔交易的同时保持多个服务之间数据一致性。之所以会有这个问题,我们以一个在线B2B商店为例,客户服务维护包括客户的各种信息,例如credit lines。订单服务管理订单,需要验证某个新订单与客户的信用限制没有冲突。在单一式应用中,订单服务只需要使用ACID交易就可以检查可用信用和创建订单。
事件驱动架构
对许多应用来说,这个解决方案就是使用事件驱动架构(event-driven architecture)。在这种架构中,当某件重要事情发生时,微服务会发布一个事件,例如更新一个业务实体。当订阅这些事件的微服务接收此事件时,就可以更新自己的业务实体,也可能会引发更多的时间发布。 可以使用事件来实现跨多服务的业务交易。交易一般由一系列步骤构成,每一步骤都由一个更新业务实体的微服务和发布激活下一步骤的事件构成。
事件驱动架构也是既有优点也有缺点,此架构可以使得交易跨多个服务且提供最终一致性,并且可以使应用维护最终视图;而缺点在于编程模式比ACID交易模式更加复杂:为了从应用层级失效中恢复,还需要完成补偿性交易,例如,如果信用检查不成功则必须取消订单;另外,应用必须应对不一致的数据,这是因为临时(in-flight)交易造成的改变是可见的,另外当应用读取未更新的最终视图时也会遇见数据不一致问题。另外一个缺点在于订阅者必须检测和忽略冗余事件。
原子操作Achieving Atomicity
事件驱动架构还会碰到数据库更新和发布事件原子性问题。例如,订单服务必须向ORDER表插入一行,然后发布Order Created event,这两个操作需要原子性。如果更新数据库后,服务瘫了(crashes)造成事件未能发布,系统变成不一致状态。确保原子操作的标准方式是使用一个分布式交易,其中包括数据库和消息代理。
使用本地交易发布事件
获得原子性的一个方法是对发布事件应用采用multi-step process involving only local transactions,技巧在于一个EVENT表,此表在存储业务实体数据库中起到消息列表功能。应用发起一个(本地)数据库交易,更新业务实体状态,向EVENT表中插入一个事件,然后提交此次交易。另外一个独立应用进程或者线程查询此EVENT表,向消息代理发布事件,然后使用本地交易标志此事件为已发布,
挖掘数据库交易日志
另外一种不需要2PC而获得线程或者进程发布事件原子性的方式就是挖掘数据库交易或者提交日志。应用更新数据库,在数据库交易日志中产生变化,交易日志挖掘进程或者线程读这些交易日志,将日志发布给消息代理。
使用事件源
Event sourcing (事件源)通过使用根本不同的事件中心方式来获得不需2PC的原子性,保证业务实体的一致性。 这种应用保存业务实体一系列状态改变事件,而不是存储实体现在的状态。事件是长期保存在事件数据库中,提供API添加和获取实体事件。事件存储跟之前描述的消息代理类似,提供API来订阅事件。事件存储将事件递送到所有感兴趣的订阅者,事件存储是事件驱动微服务架构的基干。 事件源方法有很多优点:解决了事件驱动架构关键问题,使得只要有状态变化就可以可靠地发布事件,也就解决了微服务架构中数据一致性问题。
第一个挑战就是如何在多服务之间维护业务交易一致性;第二个挑战是如何从多服务环境中获取一致性数据。 最佳解决办法是采用事件驱动架构。其中碰到的一个挑战是如何原子性的更新状态和发布事件。有几种方法可以解决此问题,包括将数据库视为消息队列、交易日志挖掘和事件源。
微服务部署策略
部署一个单体式应用意味运行大型应用的多个副本,典型的提供若干个(N)服务器(物理或者虚拟),运行若干个(M)个应用实例。部署单体式应用不会很直接,但是肯定比部署微服务应用简单些。 一个微服务应用由上百个服务构成,服务可以采用不同语言和框架分别写就。每个服务都是一个单一应用,可以有自己的部署、资源、扩展和监控需求。例如,可以根据服务需求运行若干个服务实例,除此之外,每个实例必须有自己的CPU,内存和I/O资源。
单主机多服务实例模式
部署微服务的一种方法就是单主机多服务实例模式,使用这种模式,需要提供若干台物理或者虚拟机,每台机器上运行多个服务实例。例如,需要在Apache Tomcat Server上部署一个Java服务实例作为web应用。一个Node.js服务实例可能有一个父进程和若干个子进程构成。 另外一个参数定义同一进程组内有多少服务实例运行。例如,可以在同一个Apache Tomcat Server上运行多个Java web应用,或者在同一个OSGI容器内运行多个OSGI捆绑实例。 单主机多服务实例模式也是优缺点并存。主要优点在于资源利用有效性。多服务实例共享服务器和操作系统,如果进程组运行多个服务实例效率会更高,例如,多个web应用共享同一个Apache Tomcat Server和JVM。 另一个优点在于部署服务实例很快。只需将服务拷贝到主机并启动它。如果服务用Java写的,只需要拷贝JAR或者WAR文件即可。对于其它语言,例如Node.js或者Ruby,需要拷贝源码。也就是说网络负载很低。 因为没有太多负载,启动服务很快。如果服务是自包含的进程,只需要启动就可以;否则,如果是运行在容器进程组中的某个服务实例,则需要动态部署进容器中,或者重启容器。 除了上述优点外,单主机多服务实例也有缺陷。其中一个主要缺点是服务实例间很少或者没有隔离,除非每个服务实例是独立进程。如果想精确监控每个服务实例资源使用,就不能限制每个实例资源使用。因此有可能造成某个糟糕的服务实例占用了主机的所有内存或者CPU。 同一进程内多服务实例没有隔离。所有实例有可能,例如,共享同一个JVM heap。某个糟糕服务实例很容易攻击同一进程中其它服务;更甚至于,有可能无法监控每个服务实例使用的资源情况。 另一个严重问题在于运维团队必须知道如何部署的详细步骤。服务可以用不同语言和框架写成,因此开发团队肯定有很多需要跟运维团队沟通事项。其中复杂性增加了部署过程中出错的可能性。
迁移到微服务综述
策略1——停止挖掘
Law of Holes是说当自己进洞就应该停止挖掘。对于单体式应用不可管理时这是最佳建议。换句话说,应该停止让单体式应用继续变大,也就是说当开发新功能时不应该为旧单体应用添加新代码,最佳方法应该是将新功能开发成独立微服务。
策略2——将前端和后端分离
减小单体式应用复杂度的策略是讲表现层和业务逻辑、数据访问层分开。典型的企业应用至少有三个不同元素构成:
表现层——处理HTTP请求,要么响应一个RESTAPI请求,要么是提供一个基于HTML的图形接口。对于一个复杂用户接口应用,表现层经常是代码重要的部分。
业务逻辑层——完成业务逻辑的应用核心
数据访问层——访问基础元素,例如数据库和消息代理
在表现层与业务数据访问层之间有清晰的隔离。业务层有由若干方面组成的粗粒度(coarse-grained)的API,内部包含了业务逻辑元素。API是可以将单体业务分割成两个更小应用的天然边界,其中一个应用是表现层,另外一个是业务和数据访问逻辑。
策略3——抽出服务
第三种迁移策略就是从单体应用中抽取出某些模块成为独立微服务。
例如,将内存数据库抽取出来成为一个微服务会非常有用,可以将其部署在大内存主机上。同样的,将对计算资源很敏感的算法应用抽取出来也是非常有益的,这种服务可以被部署在有很多CPU的主机上。通过将资源消耗模块转换成微服务,可以使得应用易于扩展。 查找现有粗粒度边界来决定哪个模块应该被抽取,也是很有益的,这使得移植工作更容易和简单。例如,只与其他应用异步同步消息的模块就是一个明显边界,可以很简单容易地将其转换为微服务。 如何抽取模块 抽取模块第一步就是定义好模块和单体应用之间粗粒度接口,由于单体应用需要微服务的数据,
迁移第一步就是定义一套粗粒度APIs,第一个接口应该是被X模块使用的内部接口,用于激活Z模块;第二个接口是被Z模块使用的外部接口,用于激活Y模块。 迁移第二步就是将模块转换成独立服务。内部和外部接口都使用基于IPC机制的代码,一般都会将Z模块整合成一个微服务基础框架,来出来割接过程中的问题,例如服务发现。 抽取完模块,也就可以开发、部署和扩展另外一个服务,此服务独立于单体应用和其它服务。可以从头写代码实现服务;这种情况下,将服务和单体应用整合的API代码成为容灾层,在两种域模型之间进行翻译工作。每抽取一个服务,就朝着微服务方向前进一步。随着时间推移,单体应用将会越来越简单,用户就可以增加更多独立的微服务。