这是我参与「第五届青训营 」伴学笔记创作活动的第10天。
微服务架构介绍
微服务架构概览
- 网关
- 服务配置和治理
- 链路追踪和监控
是彻底的服务化,服务至上。
微服务构架三大要素
服务治理
- 服务注册
- 服务发现
- 负载均衡
- 扩缩容
- 流量治理
- 稳定性治理
可观测性
- 日志采集
- 日志分析
- 监控打点
- 监控大盘
- 异常报警
- 链路追踪
安全性
- 身份验证
- 认证授权
- 访问令牌
- 审计
- 传输加密
- 黑产攻击
微服务架构原理及特征
基本概念
服务(Service)
一组具有相同逻辑(一个服务的代码是一样的)的运行实体(实例)。
实例(instance)
一个服务中,每个运行实体即为一个实例。
实例与进程的关系
实例与进程之间没有必然对应关系,可以一个实例对应一个或多个进程(反之不常见)。
集群(cluster)
通常指服务内部的逻辑划分,包含多个实例。
常见的实例承载形式
进程、VM、k8s pod。
有状态/无状态服务
服务的实例是否存储了可持久化的数据(例如磁盘文件),存储的服务时有状态,代理形式的服务大概率是无状态。
服务间的通信
- 对于单体服务,不同模块通信只是简单的函数调用
- 对于微服务,服务间通信意味着网络传输,常见的通信协议包括 HTTP、RPC
服务注册及服务发现
这些概念我们由一个问题引出:
在代码层面,如何指定调用一个目标服务的地址(ip:port)?
或许可以直接指定端口,像这样:
client := grpc.NewClient("10.23.45.67:8080")
但方式指定的问题在于:不太可能指定一个固定的IP地址,在正式微服务环境中IP会动态变化,要借用某种中间件。
如上图所示,如果直接指定端口,服务B中只有一个实例能被调用到。
那么使用DNS呢?
即指定域名与端口,让接受方注册一个域名,这样IP地址变化,改DNS的记录就好了。
这样好像就能实现了,但还是有一些问题:
- 本地DNS存在缓存,导致延时,需要实时刷一下
- 负载均衡问题,DNS会偏向于选择其中第一个IP
- 不支持服务实例的探火检查
- 域名无法配置端口,但实际上可以运行上万个服务
基于这些问题,提出了服务注册及发现的概念。
新增一个统一的服务注册中心,用于存储服务名到服务实例的映射,用一个map去存储。且可以加上个服务的权重。
服务实例上线及下线过程
在实现这个需求时,需要考虑一个核心问题:
如果直接把一个实例拿掉,那么流量还是会流向已经下线的实例,由此导致线上问题。
解决方法
- 在注册中心把下线的实例的记录删掉,由此调用的服务就不会再调用要下线的实例
- 删除要拿掉的实例,因为此时已经没有流量
- 要添加实例来缓解其他实例的压力,首先先加入该实例,一开始加记录的话同样会导致流量的失败
- 进行health check,会发生一个请求试一下,通过后再注册到服务注册中心,流量恢复
流量特征
- 统一网关入口,处理了负载均衡等
- 内网通信多数采用RPC(二进制协议),比如Thrift, gRPC,而HTTP是文本协议,运行性能比较差
- 网状调用链路
基于流量的维度的架构图
核心服务治理功能
服务发布
服务发布(deployment),即让一个服务升级运行新代码的过程。
服务发布的难点
- 服务不可用
- 整个服务都要升级
- 服务抖动
- 服务中的某个实例要升级,流向该实例的流量就会消失,造成服务功能的缺失
- 服务回滚
- 是否回归到之前没问题的代码中
解决方案
蓝绿部署
方案是让发布分别进行,当一个集群要升级时,将其的流量切给其他集群,此时该集群开始升级。
特点: 简单、稳定,但需要两倍资源,由于要让一半的资源承受所有的流量,所以更适合在流量低时(比如凌晨)进行。
灰度发布(金丝雀发布)
背景
由于金丝雀对瓦斯极其敏感,在17世纪时,英国矿工会在下矿前放一只金丝雀来探测是否有瓦斯。
具体方法
慢慢加入新代码的实例,如果没问题就继续加,直到替代全部的旧代码。解决了需要额外资源的问题。
- 先发布少部分实例,接着逐步增加发布比例
- 不需要增加资源
- 回滚难度大,基础设施要求高
问题:这需要一直更新注册表,而且回滚比较困难,因此对基础设施要求高,比如k8s就可以实现精细化回滚的操作。
流量治理
在微服务架构中,我们可以基于地区、集群、实例、请求等维度,对端到端流量的路由路径进行精准控制。
- 基于地区的控制:基于机器数量的流量控制
- 基于集群的控制:少部分流量流向测试集群
- 基于实例的控制:新机器承担高一些的流量
- 基于请求的控制:正常请求发向正常稳定的集群,内部用户的流量发向测试集群
负载均衡
负责分配组件在上游分配请求在每个下游实例上的分布,希望每个实例的负载即所处理的请求数是均衡的。
常见的LB策略
- Round Robin
- 绝对公平的策略
- Random
- Ring Hash(一致性哈希)
- 跟请求有特点绑定关系,如某一个用户绑定到某一个实例上
- Least Request
稳定性治理
在工程上,线上服务总是会出问题的,而这与程序的正确性无关。
代码控制不了的问题
- 网络攻击
- 流量突增
- 机房断电
- 光纤被挖
- 机器故障
- 网络故障
- 机房空调故障
- ...
微服务架构中的稳定性治理功能
- 限流
- 拒绝一部分的qps
- 熔断
- 类似跳闸,不会再去调用需要的服务,而是拒绝发来的请求,在此期间会不间断的尝试能否连上服务
- 过载保护
- 服务的CPU已经过大(>80%),则直接拒绝掉流量或拒绝一部分
- 降级
- 保证重要服务能正常工作,拒绝优先级不太高的服务,会识别服务的等级,比如一些内部的用户的服务就可以拒绝掉
关于重试的实践探索
注:以下内容的成果是由字节跳动产出研发的
请求重试的意义
本地函数调用
可能的异常:
- 参数非法
- OOM(Out of memory)
- NPE(Null Pointer Exception)
- 边界case
- 系统奔溃
- 死循环
- 程序异常退出
除特殊场景外,没有重试的必要,因为函数都是本地调用,如果错了大概率是因为代码的逻辑错误或其他错误。
远程函数调用
可能的异常:
- 网络抖动
- 下游负载高导致超时
- 下游机器宕机
- 本地机器负载高,调度超时
- 下游熔断、限流
是具有重试的意义的。
重试的意义
重试可以避免掉偶发的错误,提高SLA(Service-Level Agreement)(可用性)
- 降低错误率
- 假设单次请求的错误概率为0.01,那么连续两次错误概率则为0.0001
- 降低长尾延时
- 对于偶尔耗时较长的请求,重试请求有机会提前返回
- 容忍暂时性错误
- 某些时候系统会有暂时性异常(例如网络抖动),重试可以进来规避
- 避开下游故障实例
- 一个服务中可能会有少量实例故障(例如机器故障),重试其他实例可以成功
请求重试的难点
虽然重试的工程意义是重要的,但在实际中,重试是默认不用的哦,原因是:
幂等性
链路中的每个服务在调用下游的服务时都会重试相同的次数,故上游的重试次数在下游是幂指数增加的。
重试风暴
重试多次,微服务中调用该服务的链路如果很长,那么下游的重试次数会很多,就可能会把下游的服务打垮,发生雪崩。
超时设置
假设调用时间一共1s,经过多少时间开始重试?
重试策略
限制重试比例
设定一个重试比例阈值(例如1%),重试次数占所有请求比例不超过该阈值,比如一个服务重试99次都是成功,只有一次失败,则有重试的意义,若大部分都是失败的,则无意义。
重试比例是,假如成功了1000次,就限制重试的次数不能超过其百分之一。
防止链路重试
链路层面的防重试风暴的核心是限制每层都发生重试,理想情况下只有最下一层发生重试。
实现的方法是:可以返回特殊的status表明"请求失败,但别重试"。
Hedged requests(对冲请求)
该策略是针对延迟很高的场景的。
对于可能超时(或延时高)的请求,重新向另一个下游实例发生一个相同的请求,并等待先到达的响应,达到缩短服务延迟的效果。
以上内容若有不正之处,恳请您不吝指正!