好 我们接下来讲降级
我们前面讲的超时过载保护 限流
那么这些其实都是提供一个手段
让我们的服务进程能够比较稳定的 持续的可以运行下去
降级其实本质上可以理解为它提供的
是一种降低质量的一些恢复
因为如果你的原本的计算逻辑可能比较重
它消耗的资源比较多
那么在一些意外的情况下
它可能没有更多资源去处理这些请求
你只能用一些降低质量的回复去响应用户的一些请求
当然降级它也可能是一种因为你可能
访问下游报错了或者超时之类的
你为了避免说用户看到的是一些白屏空窗之类的
你也可以回复一些提前准备好的一些数据
所以本质上还是来减少工作量
丢弃一些消耗资源的一些请求
那么你要了解哪一些流量可以被降级
你就要搞清楚你这个业务场景里面最核心的场景是什么
就像我们做电商交易里面
可能打折卡或者说一些小活动它未必是非常核心的
那么如果你的一个业务场景里面有些小活动
可能因为一些原因
它可能故障无法继续下去
那么你只能靠牺牲它
让你的主干逻辑交易可以进行下去
那就像播放视频一样
可能这个页面里面最核心的场景是播放
那么可能有一些功能不可用的时候
用户仍然可以看视频
所以我觉得这个是比较重要的
我们通常自动降级
我们一般会考虑几个点
因为你有时候你要手动去处理的话
手动去打开一些开关去做降级的话是一种方式
但有可能我们要考虑自动的一些降级的一些方式
第一就是说你要用哪个指标来作为流量评估和优雅降级的决定性指标
比方说CPU 线程数量 报错情况
让它自动去触发
把这个降级打开
那么当服务进入降级的时候
需要执行什么动作
就可能需要做一些操作
可能做一些配置开关之类的
另外就是说流量抛弃或者是优雅降级
应该在服务的哪一层实现
是需要整个服务的每一层实现
还是说可以选择某个高层面的关键节点来实现
所以这里面其实抛出一个观点
就是说你到底是每一层都要做降级
还是只在最上层就是某个高层面的一个关键点来做
我们容易想到就是在BFF这一层做
相对会比较合适
因为你底层的服务如果去提供降级的一些回复的话
万一上一层基于你的数据做了一些缓存
那么这些被降级的数据可能就会污染你的缓存
因为它毕竟是一些低回复质量的一些数据
在底层的服务我觉得应该是要报错
那么最终这种策略应该是用高层BFF这种层面来决定
这个模块如果调用它rpc失败的
那是不是要把它这个错误吞掉
然后返回一些空窗或者返回一些你提前准备好的数据
我认为是在BFF层做相对来说是合适一些的
但是我们还要考虑几个点
降级首先不应该被经常触发
通常来说经常被触发
说明我们容量预估是有问题的
就是你可能资源不太足
然后经常触发降级肯定是不合适的
它应该是因为意外的一些负载导致的降级
不应该是说你容量规划都没做好
就没有搞清楚你需要部署多少节点来提供这么多吞吐的这些请求
第二也是很关键的一点
就是演练
因为这些代码平时不太会被触发和使用
所以你最好是定期的去针对一些一小部分的流量来进行演练
保证模式正常
像我们的话一般会在公司内找一个人比较少的时候
有时候跟我们客服或者跟我们的一些运维人员打好招呼
我们可能会在某个时间段做一些演练
然后通过演练一是让我们的操作人员比较熟悉整个降级的流程
第二就是说来看一下这些代码不经常被触发
它能否在关键的时候能够起作用
其实非常关键
另外就是说降级本身应该实现得非常简单
不要搞一个很复杂的策略
这些都是很关键的一些点
那么降级的本质叫提供的是有损的服务
我们一般分为几大块
叫UI模块化
非核心模块降级什么意思
就像我们这个界面
我们可能是看小姐姐
这个场景里面核心的是播放功能
如果你的评论不可用
那么这个tab可以灰掉不让点
或者点完之后里面显示的是数据无法加载
或者说视频相关标签服务出现了一些数据故障
然后无法出来
这个地方可以留空
或者说我们AI的视频相关推荐
可能因为一些原因AI推荐的结果可能推荐不出来的
我们可能可以留空
或者说这个地方返回热门的一些相关数据
这都是可以的
都是常用的一些策略
避免说你接口整个报错
因为你知道BFF这一层它会call很多个rpc
总有一些rpc可能因为意外的一些原因失败
那么你这种手动降级的模式
可能call某一个微服务失败的时候
那么手动降级就是说会走到一个fallback的代码
那个代码里面会做一些填充
做一些内容的填充
把错误吞掉
就像我们这个视频相关
如果调a接口失败的话
我们这个时候降级做fallback的逻辑可能就是返回一些热门数据
那么这个标签可能也会改成就是热门视频
这样的一种策略的话也是非常常见的
我们也叫UI模块化
我们BFF层它一般是通过都是做聚合API的
所以说有一些模块不可使用的话
你只能配合移动端做一些UI模块的降级
第二就是说你像我们的评论页面
我们的评论的页面不是会显示很多
用户的头像和一些个人的信息
如果你这时候账号服务出现了故障
那么我们常见的做法是什么
把因为大家其实关注的重点可能是评论的内容
那么这个时候你只能说让你的账号的头像
和他的那些信息变成一个默认用户的默认头像
或者改成叫bilibili用户
然后显示默认头像
这也是一种降级的方式
对于我去阅读评论其实是不受影响的
非常常见这种做法
其实都需要你在BFF层你的业务聚合API
你的聚合代码里面要把这些逻辑给做掉
这种方式偏手动降级
当然也有一些情况可能是自动降级
比方说像我们搜索页面
我们搜索页面有一些场景
比方我们搜索服务整个可能压力非常大
那么这时候可能我会整个全局降级
全局降级才能打开一个开关
那么以前走到我们的basic search就是做基础检索的服务
可能流量就不会再发下去了
我可能会走命中一些cache
可能常用的几千个关键字
可以有一些数据的缓存
那么这些被检索结果可以被搜到
那么如果你搜其他的
不在我这些几千个关键字里面的这些搜索热词可能就不见了
这个是没办法
因为可能是一种自动
可能也是一种手动的
但是是全局打开的一种降级模型
像我们一些核心的导航页面
所谓的索引页
一般来说会去让我们的APP端会
缓存上一次能够成功打开的一个页面
就是说避免说我请求一个接口失败
然后让我的APP整个就变成白屏了
我们一般是让它上一次能够刷成功的页面的数据不丢
这样的话你至少能看到上一次成功的页面
不是说一个白屏
这也是很常见的一种策略
像我们首页这种导航业
因为一些原因
之前可能是返回AI千人千面的这种信息流推荐
我们有些情况下因为可能服务故障
我们这个时候可能会返回一些热门的推荐数据
这也是没办法
可能是在我请求它接口失败以后
我之前可能会起一个goroutine
goroutine定时会去别的服务托一些热门的数据
在我的in-process cache
就是在我的进程内去做一些缓存
然后会直接会返回
这也是其实很常见的一种策略
因为你看到白屏肯定是优先
我们说至少都得可以返回一些热门数据
也不应该直接返回白屏
当然了 我们很多框架类似像我们kratos
可能也会去做流量的拦截和定期数据的一些 cache
怎么做
比方有一个接口
有一些接口
它可能会填不同的一些关键字
填不同的一些参数
但是这些参数相对来说可枚举不是很多那种值
它是可枚举的
相对固定这么几个
那么我们可以说在框架层面说根据这几个关键词的组合
每多少时间每多少小时
我希望能够踩它几次
这样的一个策略
能够把这些数据放到内存里面或者放到redis里面
本质上它是一种过期副本的策略
有时候这种接口如果失败的话
它就会自动去触发fallback的代码
就说如果调下游接口失败
它在自动的从缓存里面搂出来
可以把它之前过期的一些副本数据可以展现出来
流量拦截和定期数据缓存其实也是非常常见
当然定期数据缓存这种模式
其实你去手动写这种逻辑也是可以的
还有一些你像如果我们用熔断
或者说我们想用history streak的这种代码
它去实现它的时候
其实是一个函数指针
你会填一个正常的逻辑和一个fallback的逻辑
那么这个都需要你去手填
像我们如果调一个gRPC失败的话
gRPC的client其实是一个interface
你可以把它完全整个client给mock掉
返回的是你自己mock的一些数据也是可以的
所以mock数据也好
fallback处理也好
返回一些约定协议的处理也好
我认为都是比较常见的一些策略
但是我们页面降级其实是指某一些模块不再展现
我们延迟服务
比方说像我们的评论系统
如果我们这时候存储产生一些压力的话
因为我们的消息其实是先给我们的消息队列的
那么这个时候它本质上是消息会delay
但是其实数据并不会丢
虽然也是提供有损服务
但是最终数据还是可以被写入到我们的数据库中
所以延迟服务也是一种思路
就是说可能爆炸性的一些事件
针对某一个主题的写特别密集的情况下
可能会采用这种模型
这些都是常见的一些模式
其实降级没有太多可说的
我重点要讲一下
降级的一些Case Study
第一块其实是非常常见的
就是当我们要返回降级数据的时候
字段是不存在
完全没有
还是说等于nil 还是等于一个空数组这种
一定要跟客户端要跟我们的APP的同事去约定好
避免说因为他解析失败
导致整个APP闪退
这种一定要对好
就是你要返回哪些降级数据
一定是客户端能够可解析的
并且你们之前商定约定好的
甚至是一些默认的零值也是可能需要提前沟通好的
这是第一块
第二块就是说当我们客户端去做协议解析的时候
如果发生了协议不兼容解析失败
先是局部失败
而不是整个页面打不开
第二就是说我们能够尽可能局部做失败
第三个就是说我们曾经也遇到过降级的一些数据
是存在local cache里面的
local cache就是在我们的进程里面有一些场景里面
我建议是说这种local cache可以多副本去放置
你进程里面放一份
远程缓存可以放一份
我们之前就出现过一个case
我们要做一个降级的数据
然后刚好热门的服务也挂掉了
推荐的服务也挂掉了
然后我们的服务刚好发完版之后
因为降级数据为空
所以说最终也是显示的白屏
所以我们后来做的这种策略就是说
我会把这种降级数据local cache开始会放一份
remote cache redis里面也会放一份
这样的话其实是一种双保险
因为有时候的确会遇到一些意外的情况
那么第四个其实是没有playbook导致MTTR上升
我们要做一个服务的可用性
其实核心是两个指标
一个是MTTR
MTTR就是说我出现一次故障平均要去修复故障花费的时间
还有一个指标是叫我多久出一次故障
我是一天出一次故障
还是一年出一次
那么这两个指标都抓起来
你的可能性才会提升
因为你每次故障的时间修复时间越长
你的可用性肯定变差
那么你如果频繁出故障肯定也不行
所以MTTR是修复时间
还有一个指标叫间隔时间
那么这两个指标都要去抓
那么MTTR这个指标平均修复时间最关键的几个点
第一是你的服务尽可能能够自愈
无论你是做限流做熔断对下游进行保护
它能够自愈是最重要的
这是第一
第二能够让你的oncall
oncall就是说我们值班的时候如果出故障
我们要人工登录进去进行一些操作
那么让时间缩短
如果你之前不经常演练
没有这种操作手册
那么你真正在遇到一些大case大故障的时候
你通常都会很慌
你很慌
你的操作有可能就会出现二次事故
或者说你因为很慌
导致这个操作可能只要5分钟变成了一个小时
你还没操作完
丢三落四
这里忘记改你得忘记改
所以说一定要把这种操作的出现故障
故障应该怎么应对
要做哪些事情
写成一个sop
我们sop是个标准的操作流程
这个其实是非常重要的
我们以前我印象中每次出事故的时候
就有个同学在那处理
然后后面站着一堆人的看着他
然后那个人手都是抖的
其实是不太好的
因为说明团队其实之前没有经常演练
像做的好的团队的话
其实因为经常演练
那么会在群里面有个协调者会通知先止损
然后做信息同步上来做一些标准的sop
其实这块还是非常重要的
那么我会找时间给大家讲一下sre的一些方法论
后面我们也会去重点去介绍一下
因为sop一定是非常重要的
很多同学都不太重视
服务故障以后是先回滚或者是先止损
其实几步大法就简单来说就是先止损
那么止损你看最近有没有发布
有发布赶紧回滚
如果没有发布先扩容
如果扩容发现不解决问题
赶紧先重启
如果重启不解决问题那么
尽快去提供备案去做降级
如果还不行的话
那就没办法了
你就出事故了
所以其实是有些标准的一些一些操作流程的
OK 降级就到这
我们接下来讲一下重试
因为在很多场景里面
当我们调别人接口返回错误
比方说配额不足
超时内部错误
我的后端其实没有全局过载
有可能只是部分节点过载
或者说因为一些意外的网络原因偶尔的抖动
我们倾向来说是立刻重试
因为这样的话你的整体的接口的请求成功率会变得更高一点
但是说如果你的做法是简单的
重试就是简单程序是什么意思呢
就是你调一次不成功再调一次
如果只是简单的重试
有可能会对我们下游本来已经雪上加霜的一些节点
导致二次事故
因为它的流量会被放大很多倍
简单重试
简单重试的核心一定要限制从事次数
一般来说我们rpc来说是不允许重试超过三次的
如果你在重试次数变动的话
对我们下游的放大倍数其实是很恐怖的
你可以算一下其实是非常流量放大是非常厉害的
所以简单重试是不可取的
有一个什么比较好的做法
第一个我们刚才说的是限制重试的次数
比方说我们约定内部最多只能从事三次对吧
第二就是说基于重试分布的策略
我能不能做成这样的
就是说我统计一下我最近正常请求和重试请求占比多少
我让它不能超过10%
我限定我的client最多发出1.1倍的请求
你不能超过正常流量的10%来做重试
那么如果超过就直接报错
相当于在client就拒绝掉
有点像熔断
但是它的做法会有一些不一样
我之前看头条他们也是有这样的思路
做一个重试分布的限制
我觉得还是不错的
那么对于重试的间隔
通常来说rpc的话倾向是立刻重试
所以不太会去做退避
但是对于一些移动端或者是像连接
连接因为连不上可能要重新重连
这种一般会做随机化和指数型的递增的一种重试周期
做重试的一个backoff
那么这个算法我们这刚刚前面也介绍过
这个是第二个点是非常重要的
那么第三个点我认为可能也是比较重要的
我们前面讲的是1.1倍的重试
虽然每个client是1.1倍的重试
那么最终达到我们server的流量
它可能流量也会不少
那么还有一种做法就是client侧
在每次从事的时候会有一个gRPC的metadata
会告诉它是第一次从事还是第二次从事
我既可以把这些第一次第二次重试调rpc的时候发到server
server可以把这些指标采集统计起来
交由我的重试的分布
相当于你的server端
那些节点都能知道
第一次重试 第二次重试 正常重试 零次重试
它的分布是个什么情况
那么有这些指标以后你就可以在server上来做判定拒绝
我认为这个可能比纯客户端来做重试
可能效果还要好一些
相当于你把这些重试的策略推到了server
由server它汇总看它被重试多少次来拒绝可能会更好
可能是第三个
那么最后一个其实也要重点提的就是
我们只应该在失败的这一层进行重试
举个例子
我们的流量是从边缘cdn到我们的核心机房到SLB
SLB到API Gateway
API Gateway到BFF请求为服务
然后再请求我们的数据库
假设是因为微服务请求数据库失败
那么它重试三次
那么因为BFF发现它没返回或者超时了
或者因为别的原因报错了
那么它也重试三次
那么你如果每一次人都进行重试的话
它最后会叠出一个级联的重试风暴
它3×3×3它会变得非常大
整个重试的次数会变得非常的多
所以说这句话叫什么意思
只应该在失败这一层进行重试指的是
如果我们微服调数据库失败重试三次
它应该主动的明确的反馈一个全局约定的错误
比方说叫503
那么503意味着过载失败
无需重试
人们上层发现如果是503他就直接放行
那么它如果是发现504是超时
那么它可能会重试
所以尽量要去约定一个全局错误
只在底层产生错误以后
它重试跑到上层
它如果发现底层已经有人重试过了
它就一路放行
那么最终抛给用户
你不能说它重试第一次
你不能说每一层都去做这个重试
而是说下面层的人重试过了
我上层就不再重试了
这个就会好得多
所以一定要是去注意的
因为很容易导致一个重试的一个级联风暴
我们之前case里面就出现过
nginx的upstream过大
它是重试三次
但是它有几个点做得不好
第一就是说它是无脑重试三次
它没有基于说只能放大我的全局的请求数量
只有10%是做重试的
第一没有限制
第二就是说我们当时Nginx和我们的下游的那些状态码没有做一个联动
导致Nginx upstream重试的时候
它也是会导致这个级联重试放大了
所以这个其实是要注意的
另外说写操作啊
我们通常不建议做重试
因为可能会导致数据不幂等
因为你要去做一个业务幂等其实非常复杂
因为涉及到类似像所谓的全局
唯一id去做一些数据去重
做一些去重表
或者是做一些多版本的并发控制
都会导致我们的业务非常复杂
所以我们一般读请求做重试相对多一点
写操作可能做重试会少一些
因为对于一个客户端来说
失败不代表数据没有写进去
因为有可能写进去
网络超时没返回
那么如果你做个重试
可能会写成两条数据
那么从业务逻辑上可能会产生一些不确定的一些情况
所以我们通常不建议写操作来做重试
第三一定要注意多层级的重试传递
放大流量可能会引起雪崩
这个也是我们经常遇到的
那么我们后来在我们框架里面做一些重试的策略的时候
比方数据库也好
或者是gRPC rpc框架也好
我们后来做重试都是内置到框架层面
让框架来底层去做重试
并且它做完重试以后
它会明确抛一个错误码出来
就是说这个错误产生了
然后大家看到这个错误就一路放行
不要再去做重试了
否则可能会出现一些意外的一些case出现
所以这个是重试