Kyle's Notebook

微服务可用性设计

Word count: 6.6kReading time: 23 min
2021/08/11

微服务可用性设计

从单体架构到微服务架构,系统的复杂度变高。因此要接受系统出错的情况变得更频繁的现实,而由局部故障引起连锁反应的情况尤其需要重视。总的来说是要做到:

  • 变更管理:大多数问题是由变更引起的,出错首先考虑回滚。

  • 避免过载:过载保护(卸载部分流量)、流量调度(多集群)等。

  • 依赖管理:任何依赖都可能故障,需要做注入故障测试(chaos monkey testing)。

  • 优雅降级:提供有损服务,避免核心链路依赖故障。

  • 重试退避:结合退避算法和冻结时间,API retry detail 控制策略。

  • 超时控制:结合进程内 + 服务间实现超时控制。

  • 极限压测 + 故障演练:持续加压,观察服务在高负载时的表现(而不仅是能否支撑)。

  • 扩容 + 重启 + 消除有害流量。

隔离设计

本质上是对系统或资源进行分割,从而实现当系统发生故障时能限定传播范围和影响范围,即发生故障后只有出问题的服务不可用,保证其他服务仍然可用。

没有考虑隔离可能出现的问题:

  • 转码集群被超大视频资源攻击,导致转码大量延迟。

  • 缩略图服务,被大图实时缩略耗尽所有 CPU,导致正常缩略小图被丢弃。

  • 数据库实例 cgroup 未隔离,导致大 SQL 引起集体故障。

  • INFO 日志量过大,导致异常 ERROR 日志采集延迟。

服务隔离

动静隔离:小到 CPU 的 cacheline false sharing、数据库表设计中为避免 buffer pool 频繁过期隔离动静表,大到架构设计中的图片、静态资源等缓存加速,本质上都体现动静隔离的思路,即加速/缓存访问变换频次小的资源。比如 CDN 场景中,将静态资源和动态 API 分离。

  • 通过 CDN 访问静态文件,降低应用服务器负载。

  • 对象存储存储费用最低。

  • 海量存储空间,无需考虑存储架构升级。

  • 静态 CDN 带宽加速,延迟低。

读写分离:MySQL BufferPool 用于缓存 DataPage(缓存表的行),如果频繁更新 DataPage 会不断置换,导致命中率下降。所以在表设计时可沿用类似的思路,分离主表和统计表,减少主表更新而更多地更新统计表。即便在上游 Cache 未命中而透穿到 MySQL,仍然能利用上 BufferPool 的缓存。

主从、Replicaset、CQRS,都是读写分离的思路。

轻重隔离

核心隔离:业务根据不同等级进行资源池划分(L0/L1/L2),根据核心/非核心的故障域的差异隔离(机器资源、依赖资源);部署多集群(使用子集算法选取部分连接进行 RPC 建联),通过冗余资源来提升吞吐和容灾能力。

快慢隔离:服务吞吐类似蓄水池,当出现洪流时需要一定时间才能排放完,此时其他支流在池中停留的时间取决于前面的排放能力,耗时就会增加,从而对小请求产生延迟上的影响。比如在日志传输体系架构设计中,如果整个流都投放到单个 Kafka topic 中(实现更好的顺序 IO),流内区分不同的 logid、发送到指定的 sink 端(ES、HDFS deng1),则会出现差速(即消费能力的差异,比如 HDFS 抖动吞吐下降,ES 正常水位,全局数据就会整体反压),最终整体吞吐能力就被最慢的一端拉低。因此可以按照各种纬度隔离:sink、部门、业务、logid、重要性(S/A/B/C)等。而且业务日志也属于某个 logid,日志等级可以作为隔离通道。

image-20210808160550587

热点隔离:热点即经常访问的数据,比如统计某个热点数据中访问频次最高的 Top K 数据,并对其访问进行缓存。

  • 小表广播:从 remote cache 提升为 local cache,app 定时更新;甚至可让运营平台支持广播刷新 local cache。

  • 主动预热:比如某些页面高在线率情况下通过 bypass 监控主动防御。

物理隔离

线程隔离:主要通过线程池隔离(也是实现服务隔离的基础)。把业务分类并交给不同的线程池处理,当某个线程池处理某种业务请求发生问题时,不会将故障扩散和影响到其他线程池,保证服务可用。

正常调用不受影响,一旦出现故障、线程池的使用达到 maxSize,再次请求接口会触发 fallback 处理并进行熔断(除此之外还可以基于信号量)。

对于 Go 而言所有 I/O 都是非阻塞且托管给 Runtime 的,因此只会阻塞 Goroutine、不阻塞底层线程(协程被阻塞时,线程与协程解绑)。因此只需要考虑对 Goroutine 总量的控制,不需要考虑语言层面的线程隔离。

限制 Goroutine 的总量,可参考 自适应限流

进程隔离:比如基于虚拟化和容器化的编排(KVM、Docker Swarm、Kubernetes)。

集群隔离:配置多套集群,在逻辑上是同一个应用,而在物理上部署多套、通过 cluster 区分(region.zone.cluster.appid)。

超时控制

超时控制本质上是快速失败(Failfast),以免等待断开的实例直到超时,引起连锁故障。

挂起的请求和无响应浪费资源、降低用户体验,比如:

  • SLB 入口 Nginx 没配置超时导致连锁故障。

  • 服务依赖数据库连接池漏配超时导致请求阻塞,最终服务集体 OOM。

  • 下游服务发版耗时增加,而上游服务配置超时过短,导致请求失败。

1
2
3
                             Reset ReadTimeout
[Start] —> [Connect] -> [Write] -> [Read] -> [End]
Reset WriteTimeout

良好的超时策略应尽可能避免服务堆积请求,尽快清空高延迟的请求、释放 Goroutine。服务互相调用,在延迟叠加前应注意防止超时操作。

  • 关注网路传递的不确定性。

  • 客户端和服务端要设置一致的超时策略。

  • 设置合适的默认值(比如内网 RPC 不能超过 100ms、公网 1s)。

  • 高延迟服务导致客户端浪费资源等待。

超时策略

超时设置不当会造成问题:不清楚依赖的微服务超时策略,或随着业务迭代耗时变化,意外导致依赖者出现超时。

服务提供者应定义好 latency SLO、更新到 gRPC Proto 定义中,在后续迭代中应保证该 SLO。

  • 在 kit 基础库配置默认超时兜底,配置防御保护,避免出现过大的超时策略。

  • 配置中心设置公共模版,对于未配置的服务统一使用公共配置。

1
2
3
4
5
6
7
8
package google.example.library.v1;

service LibraryService {
// Lagency SLO: 95th in 100ms, 99th in 150ms.
rpc CreateBook(CreateBookRequest) returns (Book);
rpc GetBook(GetBookRequest) returns Book);
rpc ListBooks(ListBooksRequest) returns (ListBooksResponse);
}

如何运用好超时监控?

  • 双峰分布:比如 95% 的请求耗时在 100ms 内,5% 的请求可能永远不会完成(长超时)。

  • 不能只看均值,应参考耗时分布统计,比如 95th,99th。

  • 设置合理的超时(通常还要加上 ping、2~3 个 TTL 的时间),拒绝超长请求,或当服务端不可用要主动失败。

超时传递

指把当前服务剩余 quota 传递到下游服务中,继承超时策略,实现请求级别的全局超时控制(当上游服务已经超时返回错误,下游服务仍在执行,则会浪费资源)。

要使策略生效,应该取当前剩下的 quota 与配置超时策略的最小值,才是允许执行的时间。

1
2
3
4
5
6
        1s   100ms
----> [SvcA] ----> [Redis] (left 900ms)
| 500ms
+-----> [SvcB] (left 400ms)
350ms
min(config: 500ms, left: 400ms)

进程内超时控制:请求在每个阶段开始前就要检查是否有足够的时间来处理,以及继承该超时策略(可使用 Go 标准库 context.WithTimeout)。

1
2
3
4
5
6
func (c *asiiConn) Get(ctx context.Context, key string) (result *Item, err error) {
c.conn.SetWriteDeadline(shrinkDeadline(ctx, c.writeTimeout))
if _, err = fmt.Fprintf(c.rw, "gets %s\r\n", key); err != nil {
// ...
}
}

在传递到某个环节发现剩余时间小于超时时间,应马上取消传递。在需要强制执行时,下游服务可覆盖上游超时传递和配额。

gRPC 框架依赖 gRPC Metadata Exchange,基于 HTTP2 的 Headers 传递 grpc-timeout 字段,自动传递到下游,构建带 timeout 的 context。

只有确保服务能运行,后续的限流、熔断、降级才有机会生效。

过载保护

静态限流

令牌桶算法:设置固定容量存放令牌的桶,按照固定速率往桶里添加令牌(/x/time/rate)。

  • 比如限制 2req/s,则以 500ms 的速率往桶中添加令牌。

  • 桶中最多存放 b 个令牌,在桶满时新添加的令牌被丢弃。

  • 当某 n 字节大的数据包到达,从桶中删除 n 个令牌,并允许发送数据包。

  • 如果桶中令牌不足 n 个,则不会删除令牌,该数据包被限流处理(丢弃或进入缓冲区等待)。

漏桶算法:固定容量的漏桶作为计量工具,可用于流量整形或可以用于流量整形(/go.uber.org/ratelimit)。

  • 按固定速率流出水滴(为空则没有流出)。

  • 允许以任意速率把水滴加入漏桶,超过桶容量时则丢弃。

思路都是设定指标,在超过该指标后则阻止或减少流量进入,当系统负载降低到某一水平后再恢复。这些策略相对被动、难以适应流量变化,实际效果取决于阈值设置的合理性:

  • 当集群增减机器要重新设置限流阈值。

  • 考虑设置限流阈值的依据。

  • 考虑如何降低人力运维成本。

  • 在调用方反馈故障后设置限流,但错过流量高峰再评估则没有意义。

自适应限流

过载保护:计算系统临近过载时的峰值吞吐量,作为限流阈值来进行流量控制,实现系统保护。在系统稳定时可保持吞吐量,当临近过载则主动抛弃一定量的负载以自保。常见做法:

  • 利特尔法则:在服务高峰时测量 QPS * latency,表示系统最大吞吐量。

  • CPU、内存作为信号量进行节流。

  • 队列管理:队列长度、LIFO。

  • 可控延迟算法:CoDel。

考虑接近峰值时的系统吞吐:

  • CPU:使用独立的线程采样,每隔 250ms 触发一次测量,计算均值时使用滑动平均去除峰值的影响。

  • Inflight:当前线上服务中进行请求的计量:比如设置一个共享变量,每接入一个请求数值 +1,处理完成 -1。

  • Pass&RT: 比如最近 5s,pass 为每 100ms 采样窗口内成功请求的数量,rt 为单个采样窗口中平均响应时间。

具体做法:

  • 使用 CPU 滑动均值(如 CPU > 800)作为启发阈值,一旦触发则进入过载保护:pass * rt < inflight
  • 限流生效后 CPU 会在临界值(800)附近抖动。如果不使用冷却时间,短时间的 CPU 下降就可能导致大量请求被放行,严重时迅速占满 CPU。
  • 设置冷却时间,在冷却后重新判断阈值(CPU > 800 ),再决定是否持续进入过载保护。
  • 合理的过载保护应该是无论接入多大压力,放行的请求数量都是恒定、延迟都是稳定的。

限流设计

指在一段时间内,定义某个客户端或应用可接收或处理多少个请求。通过限流可以过滤产生流量峰值的客户端或微服务,或确保应用程序在自动扩展失效前不会出现过载的情况。由于拒绝请求也有成本,不能无限制地接入、然后拒绝(过载保护),因此必须引入限流。

缺少限流措施:

  • 二层缓存穿透、大量回源导致的核心服务故障。

  • 异常客户端引起的服务故障:请求放大或资源数放大。

  • 用户重试导致的大面积故障。

设计难点:

  • 令牌桶、漏桶算法仅针对单个节点,无法实现分布式限流。

  • 根据 QPS 限流:不同的请求可能需要不同数量的资源处理;某种静态 QPS 限流不太准确。

  • 要为每个租户设置限制:全局过载发生时针对某些异常进行控制,且可能出现配额超售。

  • 可根据优先级丢弃请求(不容易实现)。

  • 要考虑拒绝请求的成本。

分布式限流

目的是控制某个应用全局的流量,而非只对单个节点处理。

最简单的思路是使用 Redis,但存在以下问题:

  • 单个大流量的接口,Redis 本身容易成为热点。

  • pre-request 模式产生高频的网络往返,对性能有一定影响。

1
2
3
4
5
6
7
                                 判断超限
+------------> [限制算法模块]
↓ 逐条上报汇总
[req1][req2]...[reqn] ----> [计数控制模块] <-----------> [Redis]

+------------- [配置模块]
规则匹配

可以从获取单个速率 quota 升级为批量 quota,在本地使用令牌桶算法限制。

  • 每次接收服务心跳后异步批量获取 quota,减少请求 Redis 的频次,获取后在本地消费,基于令牌桶实现拦截。

  • 如果每次申请的 quota 是静态值则不够灵活(数值过大容易产生饥饿,过小则不够用)。可在最初使用时指定默认值,保留历史窗口数据,再基于窗口数据发起 quota 请求。

1
2
3
4
5
6
7
8
                               ↑ 丢弃或等待
| 放行
[req1][req2]...[reqn] ----> [拦截器] ------->
↑ |
| +--------+ 反馈统计信息
| ↓
异步更新 +-----> [算法分配调度] <--- [配额管理]
(每秒增加 n 个令牌)

基于单个节点按需申请要避免不公平,可基于 最大最小公平分享(Max-Min Fairness)分配资源:分配给每个用户想要的、可以满足的最小需求,并将没有使用的资源均匀分配给需要大资源的用户。其定义如下:

  • 按照资源需求递增的顺序进行分配。

  • 不存在用户得到的资源超过自身需求。

  • 未被满足的用户等价的分享资源。

假设每次分配 10 个资源给四个节点,其各自需求如下:

A (2) B (2.6) C (4) D (5) 描述
第一次 2.5 2.5 2.5 2.5 初次平均分配:10/4
第二次 2 2.666 2.666 2.666 A 只需要 2,因此多出 0.5 分给其它:
(2.5-2)/3 + 2.5
第三次 2.6 2.7 2.7 2.7 为避免饥饿,之后按此方案都如此分配。

各种限流措施对比:

类型 优点 缺点 现有实现
单机限制 实现简单
稳定可靠
性能高
流量不均匀会引发错误
机器数量变化时,需要手动调整配额
多数语言都提供相应实现
动态限制 根据服务情况动态限流
无需调整额度
需要主动收集性能数据
要实现客户端主动善意限流
只限于接口调用,应用场景较少
BBR 限流(各种连接池)
全局限制 流量不均不会误触发限流
机器数量变动时无需调整
应用场景丰富,任何资源都能使用
实现较复杂
需要手动配置

重要性

如果每个接口配置阈值会导致运营工作繁重,按服务级别配置则粒度太粗。最简单做法是配置服务级别 quota,更细粒度则根据不同重要性设定 quota,因此引入重要性(criticality):

  • 最重要(CRITICAL_PLUS):最终的要求预留类型,拒绝这些请求会造成严重的用户可见性问题。

  • 重要(CRITICAL):生产任务的默认请求类型。拒绝这些请求也会造成用户可见性问题(次重要)。

  • 可丢弃(SHEDDABLE_PLUS):可容忍某种程度不可用性,是批量任务的默认请求类型,通常可以过几分钟、几小时后重试。

  • 可丢弃(SHEDDABLE):可能会经常遇到部分不可用、偶尔会完全不可用的情况。

gRPC 系统之间需要自动传递重要性信息:如果后端接收请求 A,在处理过程中发出请求 B 和 C 给其他后端,则这些请求会使用与 A 相同的重要性属性。

  • 全局配额不足时优先拒绝较低优先级的。

  • 全局配额可按重要性分别设置。

  • 过载保护时低优先级请求先被拒绝。

熔断

虽然使用超时限制操作的持续时间,防止挂起操作并保证系统响应,但由于环境是高度动态变化的,几乎不可能确定准确的、每种情况下都能正常工作的时间限制。

当服务依赖的资源出现大量错误或某用户超过资源配额时,后端任务会快速拒绝请求、返回配额不足的错误。但由于拒绝回复仍然会消耗一定资源,有可能导致后端忙于发送拒绝请求导致过载。

断路器(Circuit Breakers):在客户端侧保护下游服务,阻止重复的故障,避免导致雪崩效应使整个系统崩溃。其一般有三个状态:

  • 关闭(CLOSED):默认状态,远程请求会真正发送给服务提供者。此后持续监视远程请求的数量和执行结果,决定是否要进入 OPEN 状态。

  • 开启(OPEN):此时不会进行远程请求,直接给服务调用者返回调用失败信息,以实现快速失败策略。转变为 OPEN 的条件:

    • 一段时间内请求数量达到一定阈值(比如 10s、20 个请求)。即如果请求本身很少就无需断路器介入。

    • 一段时间内(比如 10 秒)请求的故障率(发生失败、超时、拒绝的统计比例)到达一定阈值(比如 50%)。即如果请求本身能正确返回就不用断路器介入。

  • 半开启(HALF OPEN):断路器必须带有自动故障恢复能力,进入 OPEN 状态一段时间后,将自动(下次请求触发)切换到该状态。先放行一次远程调用,根据调用结果转换为 CLOSED 或 OPEN 状态,以实现断路器的弹性恢复。

Gutter

基于 Failover 思路解决可用性问题很普遍,但是完整的 Failover 需要翻倍的机器资源,在平时不接收流量时造成资源浪费;而且高负载情况下接管流量也不一定能完整接住。

基于熔断的 Gutter Kafka 用于接管自动修复系统运行过程中的负载,只需要付出 10% 的资源就能解决部分系统可用性问题。利用熔断的思路把抛弃的流量转移到 Gutter 集群,Gutter 也接不住的流量再重新回抛到主集群(最大力度接收)。

image-20210809150151913

客户端流控

一般客户端总是会积极重试,访问一个不可达的服务。要实现流控:

  • 客户端需要限制请求频次,做一定的请求退让(retry backoff)。

  • 可通过接口级别的 error_details,挂载到每个 API 的响应。

降级设计

通过降级回复可减少工作量,或丢弃不重要的请求,保障用户体验。通常需要有能力区分不同的请求、了解可以降级的流量(比如降低回复质量来减少回复所需的计算量或时间)。

缺少降级措施的例子:

  • 客户端解析协议失败,app 奔溃。

  • 客户端部分协议不兼容,导致页面失败。

  • local cache 数据源缓存,发版失效 + 依赖接口故障,引起白屏。

  • 没有 playbook,导致的 MTTR 上升。

需要考虑:

  • 流量评估和优雅降级的决定性指标(CPU、延迟、队列长度、线程数量、错误等)。

  • 进入降级模式时需要执行的动作。

  • 一般在 BFF 层处理,如果由底层服务提供降级、上层再基于低质量数据做缓存会造成污染。

  • 优雅降级只在容量规划失误或出现意外的负载时触发,不应频繁出现。

  • 充分的测试演练,平时不会触发和使用代码,需要定期针对一小部分流量进行演练,保证功能正常。

  • 足够简单。

降级的本质是提供有损的服务:

  • 模块化 UI ,对非核心模块降级。

  • 页面上次缓存副本。

  • 设置默认值、热门推荐等。

  • 流量拦截 + 定期数据缓存(过期副本策略)。

一般处理方法:

  • 页面降级、延迟服务、写/读降级、缓存降级。

  • 抛出异常、返回约定协议、Mock 数据、Fallback 处理。

重试设计

当请求返回错误(比如配额不足、超时、内部错误等),对于后端部分节点过载的情况,倾向于立刻重试(前提是该错误是否可以通过重试解决,比如鉴权失败则没有必要反复重试)。

首先应确保接口幂等性:

  • 遵循 RESTful 的通用原则,一般只有 POST 是非幂等。

  • 全局唯一 ID:根据业务生成全局唯一 ID,在调用接口时会传入。接口提供方从存储系统中去检索该 ID 是否存在,如果存在则说明操作已经执行过,将拒绝本次服务请求;否则接收服务请求并将 ID 存入存储系统中,之后包含相同业务 ID 参数的请求将被拒绝。

  • 去重表:适用于在业务中有唯一标识的插入场景。比如一个订单只能支付一次,可建立将订单 ID 作为唯一索引的去重表。在一个事务中完成支付和去重操作,当出现重复支付时抛出唯一约束异常,操作回滚。

  • 多版本并发控制:适合对更新请求作幂等性控制。比如在更新接口中增加版本号实现幂等性控制。

留意流量放大:

  • 限制重试次数和基于重试分布的策略(重试比率:10%)。

  • 随机化、指数型递增的重试周期:exponential ackoff + jitter。

  • 客户端记录重试次数直方图,传递到服务端进行分布判定,交由服务端判定拒绝。

  • 只在主路逻辑的关键服务上进行重试,非关键服务不建议首选重试,尤其不应同步重试。

  • 只应在失败层次进行重试,当重试仍然失败,返回全局约定错误码以避免级联重试。

重试要有明确的终止条件:

  • 超时终止:重试模式更应配合超时机制使用,否则可能对系统有害。

  • 次数终止:不能无限制,一般只重试 2 至 5 次(可结合退避策略),或重试请求只占所有请求较小的比例。

重试设计不当的例子:

  • Nginx upstream retry 过大,导致服务雪崩。

  • 业务不幂等,重试导致数据重复。

  • 多层级重试传递,放大流量引起雪崩。

负载均衡

特指数据中心内部的负载均衡。在理想情况下,某个服务的负载会完全均匀地分发给所有后端任务,在任何时刻节点消耗同样数量的 CPU。目标是实现:

  • 均衡的流量分发。

  • 可靠地识别异常节点。

  • scale-out,增加同质节点扩容。

  • 减少错误,提高可用性。

有时 backend 之间的负载差异较大,一般是使用 JSQ(最闲轮询)算法、缺乏服务端全局视图带来的问题:

  • 每个请求的处理成本不同。

  • 物理机环境的差异:服务器本身很难强同质性,以及存在共享资源争用的情况(内存缓存、带宽、I/O 等)。

  • 性能因素,比如 Java 的 FGC、JIT 等。

因此目标需要综合考虑负载和可用性。参考《The power of two choices in randomized load balancing》,可采用 p2c 算法随机选取两个节点打分,选择更优的节点:

  • 考虑服务端指标(CPU 等)和客户端指标(health、inflight、latency 等),使用简单的线性方程进行打分。

  • 对新启动的节点使用常量惩罚值(penalty),以及使用探针方式最小化放量,目的是预热。

  • 对于打分较低的节点,为避免进入黑名单而无法恢复,要使用统计衰减的方式,让节点指标逐渐恢复到初始状态(默认值)。

  • 当前发出的请求超过了 predict lagtency,就会增加惩罚。

指标计算结合 moving average,使用时间衰减,计算 vt = v(t-1) * β + at * (1-β) 。

其中 β 为若干次幂的倒数,即:Math.Exp((-span) / 600ms)。

参考

CATALOG
  1. 1. 微服务可用性设计
    1. 1.1. 隔离设计
      1. 1.1.1. 服务隔离
      2. 1.1.2. 轻重隔离
      3. 1.1.3. 物理隔离
    2. 1.2. 超时控制
      1. 1.2.1. 超时策略
      2. 1.2.2. 超时传递
    3. 1.3. 过载保护
      1. 1.3.1. 静态限流
      2. 1.3.2. 自适应限流
    4. 1.4. 限流设计
      1. 1.4.1. 分布式限流
      2. 1.4.2. 重要性
      3. 1.4.3. 熔断
        1. 1.4.3.1. Gutter
      4. 1.4.4. 客户端流控
    5. 1.5. 降级设计
    6. 1.6. 重试设计
    7. 1.7. 负载均衡
    8. 1.8. 参考