这是我参与「第五届青训营 」伴学笔记创作活动的第 9 天
一、本堂课重点内容
- 微服务架构介绍
- 微服务架构原理及特征
- 核心服务治理功能
- 字节跳动服务治理实践
二、详细知识点介绍
微服务架构介绍
系统架构演变历史
首先提出一个问题:为什么系统架构需要演进?
主要有以下几点原因:
- 互联网的爆炸性发展
- 硬件设施的快速发展
- CPU
- MEM
- 存储
- 网络
- 需求复杂性的多样化
- 文本
- 图片
- 音频
- 视频
- VR
- 开发人员的急剧增加
- 早期的精英程序员
- 如今易于上手的开发平台
- 计算机理论及技术的发展
- 算法
- Paxos
- Raft
- NoSQL大数据
- 算法
系统架构的演变历史有多个发展阶段。从单体架构到垂直应用架构再到分布式架构,之后出现SOA架构,由此衍生出去中心化的微服务架构。
由于之前学习过架构相关内容,下面分别对几种架构总结。
单体架构
特点:all in one process,所有的模块都集中在一个进程中。
优势
- 性能最高
- 冗余小
劣势
- debug困难
- 模块相互影响
- 模块分工、开发流程难
垂直应用架构
将单体架构中的进程按照业务进行垂直划分,得到垂直应用架构。
优势
- 业务独立开发维护
劣势
- 不同业务之间存在冗余,无法复用
- 每个业务还是单体
分布式架构
在之前的基础上抽离出与业务无关的公共模块,让其分布式独立运行,得到分布式架构。
优势
- 业务无关的独立服务
劣势
- 服务模块bug可导致全站瘫痪
- 调用关系复杂
- 不同服务冗余
SOA架构
引入服务以及服务注册的概念
优势
- 服务注册
劣势
- 整个系统设计是中心化的
- 需要从上至下设计
- 重构困难
微服务架构
去中心化的SOA,彻底的服务化。
优势
- 高效的开发迭代效率
- 业务独立设计
- 自下而上
- 故障可控,故障隔离
劣势
- 治理、运维难度急剧增加
- 观测挑战
- 安全等问题
- 分布式系统本身的复杂性
微服务架构概览
从组件的角度去看微服务架构的整体视角
微服务架构核心要素
服务治理
- 服务注册
- 服务发现
- 负载均衡
- 扩缩容
- 流量治理
- 稳定性治理
- ......
可观测性
- 日志采集
- 日志分析
- 监控打点
- 监控大盘
- 异常报警
- 链路追踪
- ......
安全
- 身份验证
- 认证授权
- 访问令牌
- 审计
- 传输加密
- 黑产攻击
- ......
主要挑战
- 日志采集
- 监控打点
- 链路追踪
- 认证授权
微服务架构原理及特征
基本概念
-
服务(Service)
一组具有相同逻辑的运行实体。
-
实例(Instance)
一个服务中,每个运行的实体即为一个实例。
-
实例与进程的关系
实例与进程之间没有必然的对应关系,一个实例可以对于一个或多个进程(反之不常见)。
-
集群(Cluster)
通常指服务内部的逻辑划分,包含多个实例。
-
常见的实例承载形式
- 进程
- VM
- k8s pod
- ......
-
有状态/无状态服务
根据服务的实例是否存储了可持久化的数据(例如磁盘文件)来划分。
服务间通信
- 对于单体,不同模块通信只是简单的函数调用。
- 对于微服务,服务间通信意味着网络传输。
服务注册及发现
首先提出一个问题:在代码层面,如何指定调用一个目标服务的地址(IP:Port)?
方法一
可以说硬编码(hardcode) 可以解决,但是直接指定地址有什么问题吗?
问题就在于一个服务可能不只有一个实例,因此服务的实例IP和Port是在动态变化的,也就是说如果硬编码的话,每次都得重新修改IP和Port之后重新编译一遍,可见问题很大。
方法二
此时我们又想到了一种方法,也就是利用DNS,让DNS服务器来帮我们处理域名和IP的映射问题,我们只要给定一个域名就行,这样就可以定位到多个服务实例的IP,想法虽然很美好,不过也是有些问题。
- 本地DNS存在缓存,可能会导致延时
- 负载均衡,DNS无法做到每个域名对应的IP负载均衡
- 不支持服务器实例的探活检查
- 域名无法配置端口
不过好在DNS为我们提供了一种解决的思路:添加一个中间层。
服务注册的提出
因此,与DNS类似,引入一个中间层,新增一个统一的服务注册中心,由于存储服务名到服务实例的映射。
服务实例上线及下线过程
由于引入了服务注册中心,因此,对于服务实例的上下线需要进行管理。
下线
以上图为例,此时需要下线service B的instance-3。此时并不能直接将其下线,因为还有其他的服务正在请求该实例。因此正确的做法是先将其在服务注册中心注册的实例给删除,然后其他服务就无法访问到该实例,因此就可以将instance-3给下线。
上线
以上图为例,此时服务B的压力很大,为了减轻其他实例的压力,我们考虑上线一个新的实例。上线的流程其实与下线的流程相反,下线是先删除注册中心的记录,再删除实例;而上线是先部署好实例(进行健康检查,其全程都在检查,确保工作正常),然后再将实例地址注册到注册中心,这样当从服务注册中心获取实例时就可以获取到新的实例,总而减轻其他实例压力。
流量特征
- 统一网关入口
- 内网通信多数采用RPC
- 网状调用链路
核心服务治理功能
服务发布
指的是一个服务升级运行新的代码的过程。
难点
服务不可用
服务抖动
服务回滚
解决方案
蓝绿部署
特点
- 简单
- 稳定
- 需要两倍资源
灰度发布(金丝雀发布)
源于17世纪时,英国矿工在下井前会先放入一只金丝雀(对瓦斯极其敏感),以确保矿井中没有瓦斯。
而金丝雀发布同理,先将一个升级的实例发布上去,看是否有问题,逐步升级,直到全部完成升级。
特点
- 回滚难度大
- 基础设施要求高
流量治理
在微服务架构下,我们可以基于地区、集群、实例、请求等维度,对端到端流量的路由路径进行精确控制。
可以看到下图中,路由可以精准控制。
负载均衡
负载均衡(Load Balance)负责分配请求在每个下游实例上的分布。
通常一个服务中,每个实例的负载应该是大体均衡一致的。
常见的LB策略
- Round Robin
- Random
- Ring Hash
- Least Request
- ......
稳定性治理
线上服务总是会出问题,其与程序的正确性无关。
可能导致故障的原因
- 网络攻击
- 流量激增
- 机房断电
- 光纤被挖
- 机器故障
- 网络故障
- 机房空调故障
- ......
而在微服务架构中有几大典型的稳定性治理功能
- 限流
字节跳动服务治理实践
重试的意义
对于本地函数的调用,可能出现如下几种异常
- 参数非法
- OOM (Out Of Memory)
- NPE (Null Pointer Exception)
- 边界case
- 系统崩溃
- 死循环
- 程序异常退出
那么本地函数调用有没有重试的必要呢?
func LocalFunc(x int) int {
res := calculate(x * 2)
return res
}
以上述代码为例,该函数调用的结果可以说只与参数有关,而参数传递一般是写死的,因此,一般情况下,本地函数的调用是没有重试的必要的。
现在我们来看看远程函数调用,以以下代码为例
func RemoteFunc(ctx context.Context, x int) (int, error) {
ctx2, defer_func := context.WithTimeout(ctx, tiem.Second)
defer defer_func()
res, err := grpc_client.Calculate(ctx2, x * 2)
return res, err
}
这一段函数执行了RPC调用,其中可能有如下异常
- 网络抖动
- 下游负载高导致超时
- 下游机器宕机
- 本地机器负载高,调度超时
- 下游熔断、限流
- ......
因此,RPC调用不仅仅与参数相关,还与当前状态的网络环境有关,因此重试是有必要的。
重试代码示例
// RPC
func RemoteFunc(ctx context.Context, x int) (int, error) {
ctx2, defer_func := context.WithTimeout(ctx, tiem.Second)
defer defer_func()
res, err := grpc_client.Calculate(ctx2, x * 2)
return res, err
}
// RPC Retry
func RemoteFuncRetry(ctx context.Context, x int) (res int, err error) {
for i := 0; i < 3; i++ {
if res, err = RemoteFunc(ctx, x); err == nil {
return
}
}
return
}
那么重试可以带来哪些好处呢?
首先,重试可以避免掉偶发的错误,提高SLA(Service-Level Agreement)
-
降低错误率
假设单次请求的错误概率为0.01,那么连续两次错误概率则为0.0001。
-
降低长尾延时
对于偶尔耗时较长的请求,重试请求有机会提前返回。
-
容忍暂时性错误
某些时候系统会有暂时性异常(如网络抖动),重试可以尽量避免。
-
避开下游故障实例
一个服务中可能会有少量实例故障(例如机器故障),重试其他实例可以成功。
重试的难点
既然重试有如此多的好处,那为什么默认不使用呢?
主要有三点
-
幂等性
多次请求可能会造成数据不一致
-
重试风暴
随着调用深度的增加,重试次数会指数级上涨
-
超时设置
假设一个调用正常是1s的超时时间,如果允许一次重试,那么第一次请求经过多少时间时,才开始重试呢?
重试的策略
限制重试比例
对于重试风暴,从第一个服务调用开始,经过服务调用链,每次重试都会引起下游服务进行三次重试(以之前代码为例),这样,重试次数将会呈现指数级递增。
从请求的特点来看,只有当大部分请求成功,少部分请求失败时,才有必要重试,如果大部分都失败,重试只会加剧问题的严重性。
因此解决方法就是限制重试比例,设定一个重试比例阈值(例如1%),重试次数占所有请求比例不超过该阈值。
防止链路重试
限制重试比例用于防止单个服务重试过多次数,而在调用链路上,仍无法进行防御。
不过观察重试的特点,其总是要到最后要调用的服务才知道调用失败,因此可以在最后一层调用失败的时候返回特殊的status来表明“请求失败,但别重试”。
不过缺点就是会对业务代码具有侵入性。
Hedged requests
对于可能超时(或延时高)的请求,重新像另外一个下游实例发送相同的请求,并等待先到达的响应,可以提高响应速度。
三、实践练习例子
课程并未给实际用来练手的实战例子,不过给出了字节跳动在重试方面所作的实践,其效果表明响应速度得到了很大的提高。
四、课后个人总结
本次课程对微服务架构进行了深入的学习,从微服务架构的发展历程到其基本设计原理该有字节在其相关方面所作的努力。
总的来说,课程对我帮助很大,受益匪浅。