微服务知识点总结

Sat, Nov 6, 2021 阅读时间 2 分钟

微服务的演进过程

单体应用

单体应用时代有两个主流流派:

  • LAMP架构:Linux系统 + Apache/Nginx服务器 + MySQL数据库 + 一种脚本语言,比如PHP,Python,Ruby,Perl等。特点是轻量级,价格低,跨平台,并且十分易于开发。
  • MVC架构:Model-View-Controller,例如Spring + IBatis/Hibernate + Tomcat。它有较低的耦合性,可重用性高,且部署快速的特点。

单体应用架构的优点:

  1. 模块之间进程内访问,效率很高。
  2. 对小团队来说,技术难度低,不用考虑服务治理等问题。

单体应用架构的缺点:

  1. 部署效率低下,一点点代码的修改,都要整个应用都编译,打包,测试和重新发布。
  2. 大规模团队的协作成本高,每次变更都要整个团队成员都参与。
  3. 系统可用性差,模块之间缺失隔离能力,一个模块出问题,可能会导致整个应用不可用。
  4. 性能差,性能完全取决于单个服务器的性能,不具备scale out的能力。

总结来说,不管是哪种单体架构模式,都只能适用于小型项目的开发,或者不会有什么变动的业务。一旦业务扩增,单体应用拓展和维护的困难就会大大提高。而现如今大部分的公司业务都变得越来越复杂,并且经常变动,因此服务化微服务逐渐成为了主流。

服务化和微服务

服务化就是把单体应用拆分成多个服务模块,模块与模块间原本的进程内调用,都改为远程RPC调用。服务化可以解决单体应用膨胀,团队开发耦合度高,协作效率低下的问题。

随着Docker为首的容器化技术的成熟和DevOps文化的兴起,微服务开始被开发团队熟知。微服务和服务化的区别主要体现在以下几点:

  1. 服务拆分粒度更细,只要一个模块依赖的资源与其他模块能解耦开,那就可以拆分为一个微服务。

  2. 每个服务都是独立进程,可以独立部署在一台物理机或虚拟机上,或者也可以仅通过进程隔离开。

  3. 各个服务独立维护,一个微服务可以交给一个个人或者小团队负责开发、测试、发布和运维,并对整个生命周期负责。

  4. 服务治理的要求更高,由于微服务数量的扩增,系统间调用的复杂性和不确定性都大大增加,需要一个统一的服务治理平台对整个系统进行管理。

微服务并不是银弹,它只是比较适合当前主流的业务场景。而对于一个新开启的项目,往往第一阶段是快速开发和验证想法,证明思路是否可行,这个过程往往采用单体应用更加合适。当度过了第一阶段,确认了可行性并开始增加更多特性,才需要进行服务化甚至微服务的拆分。拆分的方式可以采用纵向拆分横向拆分,纵向就是对业务的维度进行拆分,横向拆分往往是公共的功能,也需要按照维度进行拆分,可能被多个微服务进行调用的基础服务,往往需要横向拆分。或者采用比较先进的DDD(领域驱动设计)的方式对微服务进行拆分。

服务化拆分需要考虑的问题

  1. 服务如何定义

服务定义,就是RPC接口的定义,是采用RESTful还是gRPC,如果是Java应用就可以选择Dubbo,不管选择什么框架,都需要对服务的接口进行定义,RESTful可能就是一个swagger文档,如果是gRPC,那就是proto文件的定义。

  1. 服务如何发布

就是服务发现,服务的提供者如果暴露自己的地址,调用者又怎么查询自己想要调用的服务的地址,往往需要一个注册中心进行调度,比如Consul或者Eureka。

  1. 服务如果监控

服务监控,对于一个服务,可能比较关心它的QPS,平均响应时间,P999(99.9%的请求在多少毫秒内)等数据,需要一种监控方案,能覆盖业务埋点,数据收集,数据整理和展示的全链路功能,比如我比较常使用的Prometheus + Grafana的组合。

  1. 服务如何治理

关于隔离限流降级熔断等的治理手段,当一个服务出现问题时,如何让依赖的服务通过性能阈值,及时的返回错误,保证错误不扩增到全局。

  1. 故障如何定位

就是故障跟踪,多个服务之间互相调用,调用链可能很复杂,当出现问题时如何快速的确认故障点,需要一种方式对每个请求进行标记,串联起整个调用路径,从而精确的定位故障。

微服务的一般架构

  1. 服务提供者,按照一定格式向注册中心(如Consul,Eureka等)注册服务。

  2. 服务消费者,请求注册中心,查询想要调用的服务的地址,然后以约定的方式调用远程服务

  3. 请求的耗时、调用量和成功率等信息都会被监控系统记录下

  4. 调用的链路信息也会被记录,用于故障诊断和问题追踪

  5. 如果一次调用失败,可以通过重试、限流等手段保证成功率和系统的可用性

基本组件介绍

注册中心

如上所说的服务定义,每个服务都要对自己做描述,服务名、调用方式等信息,而这些信息需要由一个统一的注册中心管理,每个服务把自己的信息注册到注册中心,并从注册中心拿到自己需要的所有其他服务的地址和调用方式信息。

一般的工作流程

  1. 提供者服务启动时,根据一定格式对自己的服务进行描述,并注册到注册中心
  2. 消费者服务启动时,根据自己需要调用的服务,向注册中心订阅这些服务
  3. 注册中心会把每个服务订阅的地址列表发给服务消费者
  4. 当有服务发生变化时,注册中心还要将该服务的变更信息发送给所有订阅了该服务的消费者

注册中心的实现方式

  • API

    注册中心必须提供以下几个基本的API:

    服务注册接口、服务注销接口、心跳汇报接口、服务订阅接口、服务变更查询接口。

    和几个后台管理的API:

    服务查询接口、服务修改接口。

  • 高可用部署

    注册中心时所有服务之间调用的桥梁,必须要保证高可用,所以一般采用集群部署,并通过分布式一致性协议来保证集群中不同节点间的数据一致性。所以说,注册中心本质上是一个分布式强一致性的数据库,所以也有的公司使用ZooKeeper或Etcd当作注册中心。

  • 保护机制

    为了防止在测试环境的节点注册到了正式环境,可以造成意外后果,所以注册中心最好有一个保护机制,防止此类事件的发生。比如可以设置一个白名单,只有名单内的服务才能向注册中心注册,正式环境和测试环境的节点可以通过不同的内网网段来区分,这样避免这个问题。

静态注册中心

注册中心的心跳机制可以让注册中心识别出故障的服务提供者,但其实心跳机制可以直接用在消费者端,毕竟是服务消费者在直接访问服务提供者,这样做反而更合理。并且由于网络的复杂,注册中心的心跳机制也未必可靠。

静态注册中心就是让心跳机制完全由服务消费者完成,注册中心完全是在服务初始化时填入自己的信息即可,当服务发生变化时,注册中心不会自动进行变更。它的作用完全就是一个存数据的工具,后期只需要一段时间,或者在新加节点时由运维人员手动维护即可,在服务变更不频繁时,这种方案也是可行的。

注册中心的选型

目前开源的功能较丰富的注册中心按照注册方式主要可以分为两种:应用内注册和应用外注册

  • 应用内注册

    典型应用,Eureka

    如上图所示,每个微服务都要在应用内添加Eureka的Client进行服务的注册和订阅。然后Eureka会有一个Server的集群,实现了服务信息注册,存储和查询等功能。

  • 应用外注册

    典型应用,Consul

    如上图所示,consul采用服务外实现服务注册和发现,并且针对的是容器化的服务,服务外主要有三个组件:

    • Consul:注册中心的服务端,存储服务注册的信息,提供注册和发现服务功能。
    • Registrator:一个开源的第三方服务管理器项目,通过监听服务的Docker容器是否存活,来进行服务的注册和销毁。
    • Consul Template:定时从Consul获取最新的服务列表并刷新LB配置(如Nginx的upstream)

可以看到,应用内的注册中心需要多个服务都是同一套技术体系,因为要使用特定语言的SDK,而应用外的注册中心则是不侵入性的。

关于数据一致性:

Tips:CAP理论

同时满足一致性(Consistency)、可用性(Acailability)、分区容错性(Partition Tolerance)是不可能的。
节点越多,分区容错性越高,但是数据一致性就越难保证。如果要保证数据一致性,那就会有可用性的问题。

由于CAP不能同时满足,所以注册中心分成两种

  • CP型:牺牲可用性来保证数据强一致性,比如zookeeper、etcd、consul

  • AP型:牺牲一致性来保证可用性,比如Eureka

    (AC是就是单机,不考虑)

对于注册中心来说,最重要的功能是服务的注册和发现,当网络有问题时,可用性的要求要远高于数据一致性,因为数据不一致的情况,服务列表的某些节点实际不可用时,我们可以通过一些服务治理手段,如重试,快速失败等解决,因此AP型的注册中心一般更合适些

服务框架

一个大系统的各个微服务当然可以采用不同的语言,不同的框架来开发,只要约定好接口定义信息。但是对于一个团队来说,最好还是使用同一套框架,这样的好处有很多,比如出现问题时各个成员间也可以快速的替换,不会出现某一个服务出现问题,只能服务的负责人员等少数人才能解决。服务框架其实就是服务定义和调用方式的确认,比如Dubbo,还是gRPC,还是RESTful,数据传输采用同步还是异步,一个线程一个连接,还是可以多路复用,数据是否需要压缩,压缩算法等。

服务端处理请求方式

  1. BIO,同步阻塞式,每来一个请求,就生成一个线程处理,线程数可能会用尽。适合连接数量较小的场景,线程足够用,也比较好写。
  2. NIO,同步非阻塞式,通过IO多路复用的方式,让系统可以在单线程的情况下处理大量客户端请求,适合连接数较多并且请求消耗较轻(响应很快,没什么复杂的请求处理)的场景
  3. AIO,异步非阻塞式,立刻放回客户端,然后异步执行IO操作等业务,完成后客户端可以收到操作完成的通知,比较适合请求比较多并且请求消耗很重(执行耗时很长)的场景

服务监控

服务的监控主要分为以下三个方面

  1. 指标收集,就是把每一次的请求调用的耗时和成功信息都收集起来,并上传到几种的数据处理中心,时序数据库InfluxDB非常适合这种使用场景
  2. 数据处理,根据收集到的指标进行一些聚合运算,比如QPS,平均耗时,成功率,或CPU内存使用率等指标
  3. 数据展示,以上数据处理的结果最好通过前端面板展示出来,方便开发和运维人员观察,做监控和报警

服务的监控对象

  • 用户端监控

    指业务直接对用户提供服务的监控。

  • 接口监控

    指业务提供的功能锁依赖的具体RPC接口的监控。

  • 资源监控

    接口依赖的资源的监控,比如对数据库的监控。

  • 基础监控

    CPU利用率、内存占用率,IO读写量,网络带宽的监控。

监控指标

  • 请求量

    实时请求量,用QPS衡量,反映服务调用的实时变化情况。

    统计请求量,一般用PV(一段时间内用户的访问量)来衡量,通常用来统计报表。

  • 错误率

    通常用一段时间内调用失败的次数和总调用次数的比率来衡量,接口的错误率一般用返回码503来表示。

  • 响应时间

    大多数情况下可以用一段时间内的所有调用的平均耗时来反映请求的响应时间,但是他只代表了请求的平均快慢情况,如果关心慢请求的数量,可以把响应时间按区段划分:如0-10ms,10-50ms等,然后多少ms以上就是慢请求。或者还可以从P90、P99、P999等角度,比如P99=500ms,则表示99%的请求时间在500ms以内,它代表了请求的服务质量,即SLA。

监控维度

  1. 全局维度

    整体看一个对象的请求量、响应时间和错误率 。

  2. 分机房维度

    监控不同机房的服务指标。

  3. 单机维度

    监控不同机器上的服务的指标。

  4. 时间维度

    不同时间时的监控指标进行比较。

  5. 核心维度

    核心监控对象和非核心监控对象分开监控。

监控系统的实现原理

监控系统主要包含四个环节:

  • 数据采集

    两种方式可以采用

    1. 服务主动上报,比如服务每完成一次调用,上报调用信息给监控系统。
    2. 由一个代理收集,但服务仍需要用某种方式提供自己的调用信息,比如对外开发/metrics接口,然后由一个Prometheus的exportor进行采集后汇总到Prometheus。

    如果使用了服务网格Istio的话,那么Istio本身就可以通过sidecar中的envoy拿到数据。

  • 数据传输

    由于数据量较大,并不是十分重要的话可以用UDP协议传输,如果很在意数据的准确性可以使用Kafka等吞吐量大的消息引擎。

    数据格式可以采用文本协议,追求性能的话可以采用二进制协议,比如protobuf。

  • 数据处理

    可以根据时机业务方式对数据进行聚合,一般可以按接口维度聚合,或者机器维度聚合等。

    数据存储,可以采用时序数据库,比如InfluxDB,可以按照1min,5min等维度进行查询。或者也可以用索引数据库,如ElasticSearch,以倒排索引的数据结构组织数据,根据索引来查询。

  • 数据展示

    一般常用曲线图,监控一些指标的变化趋势;饼状图用来监控占比情况。

常见的监控系统

ELK

即ElasticSearch + Logstash + Kibana三个开源软件的组合

  • Logstash:负责数据收集和传输,支持动态的从各处收集数据源,并对数据进行过滤,分析,格式化后,然后存储到指定位置。
  • ElasticSearch:负责数据的处理和存储,它是个分布式搜索和分析引擎,可以对采集来的数据进行搜索和分析。
  • Kibana:负责数据展示,通常和ES搭配使用,对数据进行搜索,分析和图表展示beats:由于Logstash较消耗cpu和内存,可能会对服务器上的其他服务造成影响,所以增加了一个beats工具作为数据收集。

ELK的技术栈比较成熟,除了可以监控,还可用作日志查询和分析。

Prometheus + Grafana + AlertManager

prometheus:用于接收metrics数据,向alertmanager推送数据,根据事先定义好的规则报警,或者推送到grafana用作数据展示。

服务追踪

服务追踪需要把每一次请求时各个微服务调用的每一层链路都记录起来,比如A服务调用了B服务,而B服务还需要调用C服务才能给A服务响应吗,这时如果A服务出现错误,通过观察调用链就可以快速得到是B服务还是C服务的错误。

具体的实现原理一般是服务最初的消费者在发起调用时,生成一个全局的requestId,然后在请求时将requestId发给服务提供者,服务提供者收到requestId后,如果还要调用其他服务,就也通过发送requestId作为参数的形式发起调用。最终我们就可以通过requetId串联起一整个调用路径。目前常用的trace工具往往都是通过traceId + spanId的方式进行链路追踪的。

traceId、spanId和annotations:

  • traceId

    用于标识一次具体的请求的ID,当用户请求进入系统后,会在RPC调用的第一层生成一个全局唯一的traceId,该值会随着每一次的RPC调用,不断地向后传递,通过这个值就可以找出一次调用时所有关联的RPC调用。

  • spanId

    用于标识一个RPC调用在整个调用链条中的位置,第一次的RPC调用是0,下一层就是0.1、0.2等,再下一层就是0.1.1、0.2.1等,根据spanId就可以画出一颗请求树,如上图。

  • annotations 用于业务自定义的埋点数据,可能是业务感兴趣的也想追踪的数据,比如一次请求的用户UID。

服务追踪系统的实现和监控系统类似,也是分成数据采集、数据处理和数据展示几层

  • 数据采集层

    负责数据埋点并上报。就是在系统的各个模块中埋点,采集数据并上报给数据处理层,一套微服务框架往往会提供一套用于追踪的SDK供使用。

  • 数据处理层

    负责数据的处理和计算,把采集到的数据按需计算然后存储供查询。一般可分为两种处理方式

    1. 实时数据处理,一般采用Storm、Spark Streaming或Flink对链路数据进行实时的聚合加工,存储一般使用OLTP数据库,比如HBase,以traceId作为rowKey。
    2. 离线数据处理,一般通过运行Map-Reduce或者Spark批处理程序来对链路数据进行离线计算,存储一般使用Hive。
  • 数据展示层

    展示层一般会使用到两种图形:调用链路图,和调用拓扑图。

服务治理

服务监控可以发现问题,服务追踪可以定位问题根因,那么错误的处理,就需要服务治理。服务治理就是通过一系列手段保证在各种意外情况下,调用依然可以正常进行。

常用的治理手段:

  • 单个服务的某个节点故障,就自动从注册中心摘除这个节点
  • 单个机房故障,就将流量切换到另一个机房
  • 依赖服务完全不可用,可以通过熔断(在一定时间内停止调用直接返回)的方式,一方面可以让消费者不至于被拖垮,另一方面也让提供者有时间恢复
  • 当服务的流量增大或减小时,可以自动扩缩容,提高资源利用率

节点故障管理

  • 注册中心主动摘除机制

    这种机制要求服务提供者定时想注册中心汇报,注册中心根据心跳判断节点是否异常,如果有异常就把节点从服务列表中摘除。

  • 服务消费者摘除机制

    这种机制是为了防止注册中心因为网络问题和所有服务节点断连,导致注册中心把所有服务节点都摘除了,但是此时服务提供者是正常的,所以将探活机制放在消费者端或许更合适,当服务消费者调用某个节点失败,就把这个节点从内存中摘除。

负载均衡

常用的的负载均衡算法

  • 随机算法

    就是从服务列表里随机选择一个访问

  • 轮询算法

    普通的轮询,或者加权轮询,比如配置高硬件好的节点可以分配有一个较大的权重

  • 最少活跃调用算法

    在服务消费者内存中维护每个服务节点的连接数,当调用某个服务节点时,就给这个服务节点的数字加1,返回后就减1,当有新的调用时,就取连接数最少的节点访问。因为配置越高的节点,往往性能越好,自然就会分配更多的请求过去,这种比加权轮询要好,因为不需要我们分配权重了。

  • 一致性哈希

    顾名思义,对请求ID进行哈希后发送到不同节点上,比如对节点数量取模后发给对应节点,这样相同的请求都可以发送到同一个节点上,对于哪些没有幂等性的请求,往往必须用这种方式。另外该机制还会有一个虚拟节点的机制,即当某个节点故障时,会把它的请求都平摊给其他节点,不会引起剧烈的变动,当然这种方式也有可能带来问题,比如Kafka最臭名昭著的就是这个问题。

  • 自适应最优选择算法

    客户端维护一个每个服务节点性能统计快照,该快照定时更新,每次发起请求时,可以根据二八法则,把20%那部分最慢的节点降低权重,这样保证总是能优先访问比较快的节点。这种方法是对加权轮询的改良,可以认为是种动态加权轮询。统计平均性能快照的时间间隔可以设为1分钟。

当发起一个RPC请求时,不仅要根据负载均衡算法,还要根据路由规则进行判断,使用路由规则的原因主要有两个:

  • 业务有灰度发布的需求,只需要部分用户使用某些新功能,就可以添加路由规则,让某些特定人群可以访问带有新功能的一些节点。
  • 多机房就近访问的需求,很多公司有多个IDC,尽量同一个IDC的服务应该相互调用,而不是跨IDC调用,可能会有更高的耗时。

容错

服务调用失败的情况,需要手段恢复,来尽量保证调用成功。

  • FailOver

    失败自动切换,消费者发现调用失败后,自动从可用服务列表中选下一个节点进行调用,也可以设置重试的次数,这种策略要求这个服务调用的操作是幂等的。

  • FailBack

    失败通知,失败后不再重试,而是根据失败的详细信息来决定后续的策略,对于非幂等的调用,不能简单的重试,而是应该查询服务端的状态,看上一次调用是否生效。如已经生效就不能再重试了。

  • FailCache

    失败缓存,失败后不立刻重试,而是过一段时间再发起调用,给时间让服务恢复。

  • FailFast

    快速失败,失败后不再重试,一般非核心的业务,会采用快速失败,记录一下日志即可。

常见的服务调用失败的处理方法

  1. 超时

    每次服务调用都要设置超时时间,以避免依赖的服务迟迟不响应,把整个链路堵死。超时时间的取值不能太长,也不能太短,可以根据服务提供者的服务水平来定,可以根据服务提供者的压测结果来定,取P999或者P9999的值,作为超时时间比较合适。

  2. 重试

    超时时间可以快速失败,但是这些失败原因大多数都是网络抖动问题,换个节点再访问往往可以解决。重试次数的取值,一次失败的概率如果是1%,那两次都失败的概率就是1% x 1%,所以重试次数设为1比较合适,在过了超时时间后再发起一次就可以了。

  3. 双发

    既然两次调用可以大大减少失败率,那么每次请求时,直接发两个请求过去,就可以更加提高成功率了,哪个先返回就可以采用哪个的结果。缺点是每次请求对服务端都是两倍的压力,消耗资源也是两倍的。优化以上问题,可以采用延迟双发的方式,在一定时间内没有响应的话,就再发一次就可以了,注意不要和重试时间重复了,重试时间设为P999,那么双发时间可以设为P99或P90。

  4. 熔断

    如果是服务提供者出现问题,这时,重试还是双发都会反而增大服务提供者的压力,甚至加剧故障。这时就需要调用方能及时探测到这种故障,并短时间不要访问这个服务,给服务提供者恢复的时间。

    熔断的原理:

    客户端的每次请求都用熔断器封装起来,熔断器会监控每一次调用,如果一段时间内,服务调用的失败次数达到一定阈值,后续的调用就直接返回,不真的发起请求了。

    正常情况下,熔断器是关闭状态。当调用失败次数达到一定阈值,熔断器就处于开启状态,后续的调用都会直接返回。当熔断器开启后,每隔一段时间,会进入半打开状态,这时会向服务提供者进行探测,如果调用成功了,熔断器就会到关闭状态,否则就保持开启。

以上这些治理手段往往在一些微服务框架中已经集成了,比如go-zerokratos等。

管理服务配置

一般有两种服务配置的管理方式

  • 本地配置

    把配置当做代码看待,随着代码一起发布

  • 配置中心统一管理

    把所有服务的配置,都放在一个地方统一管理,服务启动时自动从配置中心拉取所需的配置

多机房部署微服务实践

多机房的数据同步

主从机房架构

一个主机房,所有的写请求都交给主机房,同时主机房还要负责其他机房缓存的写入,数据库层则通过MySQL的binlog同步。

独立机房架构

每个机房都有写请求,通过中间的一个WMB的消息同步组件把各自机房的写请求同步一份给对方,这样就相当于每个机房都有一个全量的写入请求写入缓存,底层的数据库层则还是只有一个写入,其他通过binglog同步。优点就是任意机房出问题,不会影响其他机房的数据更新。可以类比于mysql的双主复制。

WMB消息同步组件的实现原理:

功能是把一个机房的写入请求转发给其他机房。reship:把本机房的写请求发给别的机房,collector:从别的机房读取写请求,然后发给本机房的处理机。具体reship和collector的实现方式也有很多中,可以采用添加MCQ消息队列的方式来削峰,防止就将写请求在发给别的机房时的压力过大,也可以不使用MCQ,主要还是看写请求多不多,在这里不过多说明。

多机房的数据一致性

多机房同步过程中可能会造成数据的不一致,可以通过对账机制来保证消息的最终一致性。

每个请求都会生成一个全局唯一的requestId,在写入的整个环节,requestId都会向下传递,每一次requestId的操作,都会记录一条日志,记录操作的成功或失败,日志就可以通过elk写入到ElasticSearch,然后写一个定时任务,定时检查ElasticSearch,找出所有requestId的日志,查看是否成功,如果某个阶段失败了,则可以根据日志信息,重新写入直到成功。