来自于视频点此的学习笔记
单体架构与微服务架构
单体
单体架构将所有功能模块集中在一个应用框架中,共享代码库、进程和数据库,各个模块紧密耦合
通常由传统三层架构构成:数据库、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需要解决两个问题:函数怎么映射到调用远程服务的,网络传输是怎么实现的
函数映射:
- 首先定义一个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序列化的例子:

协议层
最终要基于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框架

- 调用层:封装服务,提供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事件
- 通过一个后台进程异步读取这张表中的事件,找到待发送的事件发送消息

工作原理
表结构:
1
2
3
4
5
6
7
8
9
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
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
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工具会伪装为数据库的从库,持续监听日志,并解析出变更事件,推送到消息队列