【杂项】微服务

  • ~9.91K 字
  1. 1. 单体架构与微服务架构
    1. 1.1. 单体
    2. 1.2. 微服务
  2. 2. RPC
    1. 2.1. 常规HTTP调用
    2. 2.2. RPC调用
    3. 2.3. 一个完整的RPC请求
    4. 2.4. RPC和HTTP
      1. 2.4.1. 概念
      2. 2.4.2. 编/解码层
      3. 2.4.3. 协议层
      4. 2.4.4. 网络传输层
      5. 2.4.5. 优势和不足
      6. 2.4.6. 适用场景
    5. 2.5. RPC框架
      1. 2.5.1. 编解码层
      2. 2.5.2. 协议层
        1. 2.5.2.1. RPC通信协议的设计
      3. 2.5.3. 网络传输层
      4. 2.5.4. 服务治理型框架
        1. 2.5.4.1. 分层设计
        2. 2.5.4.2. 服务治理层
  3. 3. 服务注册与服务发现
    1. 3.1. 作用
    2. 3.2. Client-Registry-Server
      1. 3.2.1. 服务上线
      2. 3.2.2. 服务下线
    3. 3.3. 注册中心选型-CAP理论
    4. 3.4. 心跳机制
    5. 3.5. 框架中的服务注册
      1. 3.5.1. 注册etcd实例
      2. 3.5.2. 注册注册中心实例
      3. 3.5.3. 执行服务
  4. 4. 负载均衡
    1. 4.1. 作用
    2. 4.2. 算法
      1. 4.2.1. 随机/加权随机
      2. 4.2.2. 轮询/加权轮询
      3. 4.2.3. 哈希/一致性哈希
      4. 4.2.4. 指标类
    3. 4.3. 源码中的负载请求
  5. 5. 熔断、限流、降级
    1. 5.1. 熔断
      1. 5.1.1. 发生场景
      2. 5.1.2. 目的
      3. 5.1.3. 手段
      4. 5.1.4. 流程
    2. 5.2. 限流
      1. 5.2.1. 发生场景
      2. 5.2.2. 手段
      3. 5.2.3. 流程
    3. 5.3. 降级
      1. 5.3.1. 发生场景
      2. 5.3.2. 手段
  6. 6. 超时、重试和幂等性
    1. 6.1. 超时和重试
      1. 6.1.1. 应用场景
      2. 6.1.2. 超时机制
      3. 6.1.3. 设置超时时间
      4. 6.1.4. 重试机制
      5. 6.1.5. 设置重试次数
    2. 6.2. 幂等
      1. 6.2.1. 基本概念
      2. 6.2.2. 幂等去重
  7. 7. 微服务网关与API网关
    1. 7.1. 微服务网关
    2. 7.2. API网关
  8. 8. Outbox发件箱模式
    1. 8.1. 应用场景
    2. 8.2. 设计思想
    3. 8.3. 工作原理
    4. 8.4. 发送保证
    5. 8.5. 轮询方式

来自于视频点此的学习笔记

单体架构与微服务架构

单体

单体架构将所有功能模块集中在一个应用框架中,共享代码库、进程和数据库,各个模块紧密耦合

通常由传统三层架构构成:数据库、UI和服务器端应用程序

服务端应用程序处理所有HTTP请求并执行业务逻辑,单体应用中,服务器端逻辑、UI逻辑和批处理作业捆绑在一个EAR(企业存档)、WAR(Web存档)或者JAR(Java存档)中

单体并非落后,它有着自己的优点:开发快、部署简单、调试方便、高性能

它的缺点也很明显:紧耦合、构建周期长等

微服务

微服务表示完成业务逻辑的小服务的集合

旨在创建小型服务套件,组合成更大的应用程序,所有微服务都运行他们的进程来完成这项工作,且他们独立构建,具有完全独立的部署机制

微服务具有轻量级的通信机制,HTTP资源API

微服务有着更好的组织,且敏捷性更高

RPC

RPC即远程过程调用(Remote Procedure Call)

本地调用:

运行在同一个进程内的调用,即A方法调用B方法,不涉及网络传输

常规HTTP调用

假设有两个单体服务order和payment,从order注册一个client,并向payment发送http请求

以go语言为例,这里比较麻烦的一点是我们都是通过域名去调用的,两个服务要注册两个域名,才能发送http请求

RPC调用

RPC让你调用远程服务时,看上去像在调用本地方法

假如payment中有对应的pay()方法,还是在order里注册一个payment的client,RPC可以直接用client.pay()建立一个请求RPC连接,实现远程调用,代码中完全屏蔽掉了网络传输方面的代码

RPC

一个完整的RPC请求

RPC需要解决两个问题:函数怎么映射到调用远程服务的,网络传输是怎么实现的

函数映射:

  • 首先定义一个IDL文件,告诉service侧哪些方法可以被调用,使用某些编译工具生成stub桩文件(如protobuf,对应的pb文件里定义好了每个接口、请求参数、返回值等)
  • 本地将桩文件返回,相当于生成静态库。桩文件中就定义了哪些方法该如何被调用

网络传输:

  • 将请求参数、返回结果等进行加密/解密序列化成二进制数据(如protobuf的序列化协议)
  • 通过gRPC Runtime层来使符合协议,类似于协议层
  • 最后根据现有网络库进行TCP/UDP的传输
  • 传到server侧后进行一个相反的操作

RPC和HTTP

以下基于HTTP 1/1.1比较

概念

HTTP是应用层协议

但RPC是一种调用方式,对应的是本地调用

RPC协议指的是基于TCP/UDP甚至HTTP2改造后的自定义协议

编/解码层

首先将结构体转为二进制数据,即序列化

  • HTTP1.1的序列化协议使用JSON来传输数据,JSON易读,但占用空间比较大且没有类型,需要通过反射等手段统一解决

  • RPC中,如gRPC,我们通常使用protobuf来序列化,它的体积比JSON小很多,传输效率高;序列化和反序列化的速度快,开发时不需要反射

    以下是protobuf序列化的例子:

protobuf

协议层

最终要基于TCP传输,都会有请求头和请求体,两者区别于消息头

  • HTTP中请求头有很多字段,也可以自定义很多字段,但这些字段很多是为了适应浏览器的冗余,内部服务用不到
  • RPC中可以自定义必要字段,从而把一些HTTP头上各种用不到的字段去掉,因为RPC是方法之间调用,不需要适应浏览器

网络传输层

底层本质都是基于Socket的通信

HTTP:

  • HTTP通过建立TCP长链接,设置keepalive来长时间复用这个连接
  • 框架中会引入成熟网络库,且需要给HTTP加连接池来保障不要只有一个TCP连接可用

RPC:

  • RPC建立TCP连接池,框架也会引入成熟的网络库
  • gRPC基于HTTP2,有多路复用,优先级控制等优势

优势和不足

优势:数据包更小,序列化更快,传输效率更高;自定义RPC协议网络传输性能更快;适用于微服务架构

不足:协议本身无法解决微服务集群的问题;调用方对服务端的RPC接口有强依赖关系,需要自动化工具和版本管理工具来保证代码级别的强依赖关系

适用场景

  • RPC:微服务架构下
  • HTTP:对外服务、单体服务、给前端提供的服务

RPC框架

编解码层

  • 目标:
    • 生成代码:代码生成工具将IDL文件转换成不同语言可以依赖的lib代码
    • 序列化/反序列化:对象和二进制之间转换
  • 选型:
    • 安全性
    • 通用性:跨语言和平台
    • 兼容性:序列化协议升级后,保证原服务的稳定
    • 性能:序列化反序列化的速度和数据体积大小

协议层

  • 目标:支持解析多种协议,包括HTTP 自定义RPC协议等

RPC通信协议的设计

作用:TCP通道中的二进制包会被拆分合并,需要应用层协议确定消息边界(即哪几个二进制包是一个请求或一个请求内有多少二进制包)

构成:

  • 协议头(固定部分):整体长度、协议头长度、消息类型、序列化方式、消息ID等
  • 协议头(扩展部分):不固定的扩展字段和各种协议DIY的字段
  • 协议体:业务数据

网络传输层

  • 目标:IO多路复用高并发、可靠传输(快)
  • 选型指标:
    • 易用:封装原生socket API
    • 性能:零拷贝、建立连接池、减少GC等

服务治理型框架

分层设计

以kitex为例,一个服务治理型的RPC框架

kitex
  • 调用层:封装服务,提供RPC调用接口
  • 服务治理层:服务发现、负载均衡、熔断限流等
  • 通信层:多网络通信协议、多消息传输协议(编解码、序列化、压缩)

服务治理层

  • 服务端:
    • 服务注册:上报服务名和服务的IP,端口到注册中心
    • 健康检测:第一时间让调用方知道服务出现问题,也是送到注册中心
    • 限流:当请求量过大时,启动过载保护,抛出限流异常
  • 客户端:
    • 服务发现:根据服务名找到服务的IP和端口
    • 路由策略:实现了流量隔离,用于灰度发布、隔离联调环境;即同一个服务名称下,根据不同参数能到不同ip上面
    • 负载均衡:把请求分发到服务集群的每个服务节点
    • 重试机制:捕获异常,根据负载均衡策略再次选择节点重发请求
    • 故障熔断:确定下游异常,直接截断请求,快速执行失败

服务注册与服务发现

作用

解决的问题:服务名称→服务地址(节点)

在RPC框架下发起调用时,和调用本地方法类似,意味着client代码中写的是server的服务名和方法名,所以我们需要一个机制,让client根据服务名查询到server的ip和端口

我们知道,DNS实现了一个域名→IP的映射,但这里是不能用DNS来代替的

  • DNS有多级缓存机制,client无法及时感知server节点变化
  • DNS不能注册端口,只能用来注册HTTP服务

Client-Registry-Server

三角模型

服务上线

  • server启动后,向registry注册自身信息
    • registry保存着所有服务的节点信息
    • server和registry保持心跳,即registry需要感知server是否可用
  • client第一次发起RPC调用前,向registry请求服务节点列表,并把这个列表缓存在本地
    • client和registry保持数据同步,当服务节点有变化时,registry通知client,client更新本地缓存
  • client发起RPC请求,server返回响应

服务下线

  • server通知registry当前节点即将下线
  • registry通知client,server的某个节点下线
  • client收到通知后,更新本地缓存的节点列表,选择server的其他节点发请求
  • server等待一段时间后,暂停服务并下线,防止网络延迟

注册中心选型-CAP理论

  • C:一致性,所有节点同时看到的数据相同

  • A:可用性,任何时候都能被读写,至少有一个服务节点可以用

  • P:分区容错性,部分节点出现网络故障时,整个系统对外还能正常提供服务

  • 其中P是必须项,CA难以同时满足,需要在CP下尽量保证A,在AP下尽量保证C

  • 选型:

    • 体量小:集群规模不大时,CP可以满足需求
    • 体量大:有大批量服务节点同时上下限,需要选择AP,此时注册中心负载可能过高,有大量节点更改请求,且若要满足强一致性,则需要同步大量节点之间的数据,服务可能长时间不可用

心跳机制

为了避免给不可用节点发送请求,client需要实时感知server节点变化

正常流程:

server每隔几秒向registry发送心跳包,收到响应则表示服务节点正常,在指定时间内没收到响应,则判定失败

注册中心发现某个节点不可用时,会通知client,client更新本地缓存的服务节点列表

特殊情况:

  • 若发现心跳断了,registry立即通知client某节点不可用,避免真正宕机时还有请求
  • registry继续向server连续发送心跳,若心跳恢复,则再告知client可用,过一定时间间隔后再次连续发几次心跳,若都失败,才认为服务节点不可用

框架中的服务注册

以kitex框架中的源码为例

注册etcd实例

这里使用的是etcd中间件,首先注册一个etcd实例:

注册实例方法中主要new了一个etcd的client,这里的client是server和registry交互时充当server中etcd的client,对应registry中就有etcd的server

注册注册中心实例

在newServer方法中传入上文注册的注册中心实例,将registry赋值为传入的实例,相当于把外面的中间件保存下来,保存在了当前server的option里面

执行服务

run函数内从svr.start()开始,首先执行了buildRegistryInfo()函数,即上文中服务器启动后向registry注册自身信息,包括名字、地址、权重等

在s.stop()上面有waitExit()方法,用于etcdClient发送请求

首先定义了一个channel,让服务启动一秒钟,防止刚启动时服务可能不可用,后面调用了Register()方法,并传入了之前注册的信息,用于发起注册

这个方法中将用到的信息打包JSON,通过上下文传给etcdClient,通过put方法传给注册中心

负载均衡

作用

为了保证服务的可用性,一个应用会部署到多个节点,他们构成了服务集群,这也是分布式/微服务架构的显著特点置一

如何把请求分发给集群下的每个节点,是负载均衡要解决的问题

  • 请求尽量均匀打在各个节点上,每个节点都能接受请求
  • 提高请求性能,哪个节点响应最快,就优先调用哪个节点

算法

随机/加权随机

通过随机算法生成随机数,当节点足够多、访问量足够大时,每个节点被访问的概率基本相同

适用于请求量大,各个节点的性能差异不大的场景

轮询/加权轮询

按照固定顺序,挨个访问可用的服务节点

可以给节点赋权重,权重越大,被访问的概率越高(成功加权,失败减权)

适用于存在新老机器,节点性能不同的情况下,优先发挥新节点的优势

哈希/一致性哈希

通过哈希函数算出哈希值,将服务节点放到一个哈希环上

当一个请求过来的时候,计算他的哈希值,并放到哈希环上,将这个请求分配到顺时针离它最近的机器上

和本地缓存相结合,因为同一来源的请求出的哈希值相同,同一来源的请求都映射到同一节点,提高了缓存的命中率

指标类

  • 最少连接法:用C/S间的连接数代表节点负载,用于场景性能差异大,不好提前做好权重定义
  • 最少活跃数:用活跃请求数(已经接收但没有返回的请求)代表负载,但每个请求耗时不同,请求数不能代表实际负载
  • 最快响应时间:指标包括平均耗时,TP99,TP999,选择响应时间最短的

源码中的负载请求

以kitex框架为例

首先new了一个一致性哈希算法和balancer实例

同时new了一个ectdClient,把若干端口和balancer实例传入,因为是客户端通过某些机制挑选节点

newClient()方法中,调用了kitex的newClient方法,一路追踪到中间件中的源码,找到BalancerFactory相关

对应函数中的是通过函数GetPicker()和picker.Next()实现选择节点的

熔断、限流、降级

熔断

发生场景

服务端出现了问题

  • 服务指标:响应时间、错误率、连续错误数等,设置一个阈值,若持续超过阈值则触发熔断
  • 硬件指标:CPU,内存,网络IO

目的

  • 服务端需要时间恢复
  • 避免全调用链路崩溃,不能再把请求发给server

手段

熔断

通过熔断器判断,它放置在client和server中间

熔断器有三个状态,互相切换来决定是否处于熔断状态

流程

  • server监控到异常,触发熔断,熔断器抛出熔断的异常响应
  • client收到异常,利用负载均衡重新选择节点,后续请求不再打到被熔断的节点
  • 一段时间后,client再对这个节点重新请求,若正常响应,则缓慢对这个节点放开流量,如果还是熔断状态,则继续循环上述流程

限流

发生场景

突发的流量增大,使系统崩溃

判断指标:节点当前连接数,QPS(每秒请求数)等

手段

  • 令牌桶算法:系统以恒定速率产生令牌,并将令牌放到桶里,每个请求从桶里拿到令牌才能被执行,反之被限流
令牌桶
  • 漏桶算法:令牌桶的特殊情况,当令牌桶的容量为0的时候就是漏桶,系统均匀产生令牌,没被取走也不会积攒

    令牌桶允许积攒令牌,可以解决偶发的流量突变,而且令牌桶的容量不能设置太大,否则达到不了限流效果

  • 固定窗口:固定时间段只执行固定数量的请求

固定窗口
  • 滑动窗口:会随着时间线挪动窗口
    • 注意:窗口时间以秒为单位更合适
滑动窗口
  • 动态算法BBR:根据一系列指标来判断是否需要触发限流

流程

  • 在中间件记录流量和阈值,在中间件中实现限流算法
  • 对于偶发性的触发限流,只要在超时范围内,可以同步阻塞等待请求被处理
  • server的某个节点触发了非偶发性限流,client利用负载均衡调低该节点的权重,尽量少往这个节点发请求

降级

发生场景

系统出现故障后的补救措施,或可预见故障前的应对措施,来保证整体的可用性

手段

  • 考虑停用部分监控埋点,日志上报等观测类中间件
  • 根据业务场景判断,停用边缘服务,返回服务繁忙之类的响应
  • 对于有缓存的接口,降级只查缓存,不查DB,没命中缓存则返回错误响应

超时、重试和幂等性

超时和重试

应用场景

只要涉及网络调用、服务器宕机等问题,就要设置超时和重试

超时机制

当请求超过设置时间还没被处理,则直接被取消,抛出超时异常

目的是尽量不在服务端堆积请求连接

设置超时时间

根据响应时间调整:TP99(或TP999)都在x时间范围内,则超时时间可以设置为这个时间,称为99线或39线

可以通过接入可观测工具,或做压力测试来确定TP99值

只要和第三方打交道,针对不同的调用下游来设置不同的超时时间

重试机制

调用第三方接口时搭配超时使用,多次发送相同请求,避免网络抖动和偶然故障,目的是为了尽可能让请求被成功处理

由于偶然故障发生频率小,重试对服务器资源消耗可以忽略不计

设置重试次数

重试次数不宜过多,否则给系统负载带来压力

大部分情况下不会设置重试,但一定要设置超时

幂等

基本概念

幂等为了保证同一个请求不被多次执行

发生场景:请求的响应结果是超时,并不能确定是服务端没处理(请求堆积)还是服务端处理了发送响应时,碰到网络抖动导致超时

这时执行重试,可能会出现请求多次执行的情况,不重试,可能会出现请求一次都没被执行的情况

幂等去重

为了解决上述情况,可以设置幂等接口

针对写请求的接口,对请求进行去重,确保同一个请求处理一次和多次的结果是相同的

逻辑:

  • 请求方每次请求生成唯一ID,在首次调用和重试时,唯一ID保持不变
  • 服务端收到请求时,查询ID是否被处理过,处理过则直接返回结果,不再重复执行业务逻辑

微服务网关与API网关

这部分是该视频的笔记

微服务之间的调用是去中心化调用的模式,通过服务注册和服务发现中心实现

微服务网关

多个微服务都有自己的接口,它们都需要注册到注册中心里。在去中心化模式下,某个微服务若想查询其他微服务系统中的某个接口,就需要通过注册中心查询,从注册中心拿到微服务接口地址,再发起具体调用

但当我们建立了前端app和应用的场景时,微服务接口需要暴露给前端使用,这时就引入了微服务网关

一般前端只能调用标准http的接口地址,我们不能用注册中心了,所以前端需要调用微服务网关中的接口,它间接起了一个内部微服务的作用

微服务网关中会对相应微服务进行注册接入,把地址朝上暴露,进一步结合Nginx反向代理

app送来具体的微服务地址连接请求,微服务网关收到后可以直接从注册中心中寻址,找到具体调用地址后,去调用对应微服务

有的情况下,若只需要地址的路由或流量转发,可以不用微服务网关,直接Nginx反向代理,但Nginx无法实现服务发现,底层微服务更新后需要手动配置和重启

API网关

若需要实现跨应用之间的协同,需要把不同应用的API接口注册到更上层的平台,称为API网关

可以从微服务网关暴露地址,也可以直接从微服务模块暴露地址,但是一定要保证API网关要管理到每一个API接口,细粒度控制到每个API接口

又因为API网关是一个集中化管理的模式,我们可以在其中做各种拦截

Outbox发件箱模式

微服务架构中,服务之间通常需要通过异步消息传递来解耦,如一个订单服务创建订单后,需要通知库存服务减库存;我们需要保证不同服务间传递事件时保持数据的一致性(避免双写问题),outbox模式就是一种经典的一致性设计模式

双写问题指的是在分布式系统中,一个业务操作需要更新两个不同的数据源,但是无法保证原子性导致了数据的不一致

outbox可以将消息的实际发送被推迟到事务之外,因此只要事务内部保证业务+消息存储的原子性就可以了

应用场景

假设一个创建订单的业务:订单先写入数据库,再发送创建订单事件到消息队列

1
2
3
4
5
6
db.Begin()
// 1. 插入订单表
db.Insert(order)
// 2. 发送订单创建事件到 Kafka/RabbitMQ
mq.Publish("order.created", order)
db.Commit()

若publish成功,但commit失败,导致消费者处理了消息但数据库没有订单

若commit成功,但publish失败,导致数据库有数据但事件没发出去

这两种情况都会破坏原子性,我们可以使用outbox

设计思想

outbox的核心思想如下:

  • 在本地数据库中新增一个outbox表,用于保存待发送的事件
  • 在本地事务中,同时写入业务数据和outbox事件
  • 通过一个后台进程异步读取这张表中的事件,找到待发送的事件发送消息
outbox

工作原理

表结构:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
aggregate_type VARCHAR(100), -- 如 'Order'
aggregate_id VARCHAR(100), -- 订单ID
event_type VARCHAR(100), -- 'order.created'
payload TEXT, -- 事件内容,JSON
created_at DATETIME,
published TINYINT DEFAULT 0 -- 0=未发送,1=已发送
);

事务内插入outbox:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
db.Transaction(func(tx *gorm.DB) error {
// 业务操作:创建订单
tx.Create(&order)

// 在同一事务内插入 outbox 记录
tx.Create(&Outbox{
AggregateType: "Order",
AggregateID: order.ID,
EventType: "order.created",
Payload: toJSON(order),
})

return nil
})

注意:业务数据和outbox记录放在同一个数据库事务中,保证原子性

扫描outbox并发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
for {
var events []Outbox
db.Where("published = 0").Limit(100).Find(&events)

for _, e := range events {
err := mq.Publish(e.EventType, e.Payload)
if err == nil {
db.Model(&e).Update("published", true)
}
}
time.Sleep(1 * time.Second)
}

存在一个后台线程不断查询所有 published=0的记录,并逐条发送消息到消息队列,发送成功后在同一个事务中标记published=1

发送保证

outbox模式提供的是至少一次送达语义,若发生发送完消息但还没来得及标记时程序崩溃,服务重启后会再次拉取这条记录并重复发送

因此消费者需要实现幂等处理

如果不想重复发送,可以采取下面的方法:

  • 使用乐观锁更新状态,根据受影响行数决定是否真正发送
  • 使用事务性发件箱

轮询方式

可以使用纯轮询方式,但是延迟较高,数据库也会有轮询压力,推荐使用CDC(Change Data Capture)方式,利用 MySQL binlog / PostgreSQL WAL,用 Debezium 或 Maxwell 监听 outbox 表的数据变化,一旦有新记录立即推送至消息队列

这样可以保证零业务代码轮询,和极低的延迟

CDC(Change Data Capture,变更数据捕获)是一种监听数据库变更日志并实时同步出去的机制。

数据库在写数据的同时,会将每一次操作记录到一份顺序日志中(如MySQL的binlog和PostgreSQL的WAL)

CDC工具会伪装为数据库的从库,持续监听日志,并解析出变更事件,推送到消息队列

赞助喵
非常感谢您的喜欢!
赞助喵