通用的服务可用性治理手段
请使用电脑访问
https://myblog-ad4.pages.dev系统的高可用性不仅体现在系统架构设计上,还体现在系统日常对外提供服务的稳定性上。
本章讨论的是微服务架构下的服务可用性治理:
- 3.1节介绍微服务架构与网络调用的问题。
- 3.2节介绍请求重试及需要考虑的技术问题。
- 3.3节介绍熔断与隔离,也就是上游服务如何更好地保护下游服务、如何防止上游服务被下游服务拖垮,以及如何防止一个服务内各个接口之间因质量问题而相互影响。
- 3.4节与3.5节介绍限流,也就是下游服务如何更好地保护自己。
- 3.6节介绍服务降级策略及相关示例。
3.1 微服务架构与网络调用
单体服务拆成微服务后,原来进程内的方法调用会变成多个服务之间的网络调用。服务越多,依赖链路越复杂,一个服务正常工作往往依赖多个下游服务共同完成。
以个人页服务为例:为了展示完整的用户主页,个人页服务需要对多个其他微服务发起 RPC 请求。

微服务架构和单体架构最大的区别之一就是:
单体架构:进程内调用为主 |
网络是不稳定的,RPC 请求可能遇到:超时、抖动、连接断开等问题,这些问题都会直接影响微服务的可用性。
上游服务和下游服务
如果服务 A 调用服务 B,那么:
A 是 B 的上游服务 |
一个微服务的服务质量会同时受上游和下游影响:
上游请求太多:可能把本服务打垮 |
服务可用性治理的两个方向
书中把服务可用性治理归纳为两个方向。
1. 容错性设计
容错性设计的前提是接受两个事实:
网络本身脆弱 |
所以在服务设计时,要提前考虑请求失败、超时、下游不可用时怎么处理。
本章中对应的方案主要是:重试、降级
2. 流量控制
流量控制既包括保护自己,也包括适当保护下游:
面对上游:防止上游请求量过大,把自己打垮 |
本章中对应的方案主要是:熔断、隔离、限流
本节总结
微服务架构下,一个业务请求往往会经过多级 RPC 调用。网络异常、上游流量暴增、下游服务质量下降,都会影响服务可用性。
服务可用性治理的核心思路: |
本章后续主要围绕 5 种通用方案展开:重试、熔断、隔离、限流、降级
一句话总结:微服务的可用性问题本质上来自复杂网络调用,所以要同时做好容错和流量控制。
3.2 重试
RPC 请求遇到网络抖动时,最直接的处理方式就是重试。重试可以提高单次 RPC 请求的最终成功率,从而增强服务面对网络抖动时的可用性。
但重试不是“失败了就再来一次”这么简单,它至少要考虑三个问题:
1. 这个接口能不能被安全重试? |
3.2.1 幂等接口
当 RPC 请求超时时,调用方并不知道请求到底执行到了哪个阶段。有三种可能的情况:
1. 请求发送超时:下游服务可能根本没收到请求 |
调用方无法准确判断请求是否已经被下游成功处理,所以只能按最坏情况理解:
下游已经成功处理了请求 |
这时如果调用方要重试,下游接口就必须保证重复处理同一个请求仍然符合用户预期。
例如扣款接口:用户购买 100 元商品,请求第一次实际已经扣款成功,只是响应超时。如果重试后再次扣 100 元,用户最终被扣 200 元,这就不符合预期。
所以,能被安全重试的接口必须满足幂等性。
幂等接口:同一个请求执行一次,和重复执行多次,最终结果应该相同。
读接口和写接口
读接口天然是幂等的,因为读操作不会改变数据。
写接口需要具体分析。以 SQL 为例:
覆盖写:UPDATE table1 SET col1 = X WHERE col2 = Y |
幂等接口:非通用方案
基于数据库唯一约束实现幂等
(适用于上面提到的插入写)
核心思想:通过数据库唯一键约束保证同一条业务数据只能被插入一次。
适用场景: 如果接口涉及数据库插入操作,可以提前给相关业务数据表设置唯一键。重复调用此接口时,数据库会报 “键重复错误”,这说明该数据已经被插入过。
例如:订单号唯一、业务流水号唯一、或者 unique(user_id, product_id) 联合唯一索引也非常常见
注意:其与下面后面那种数据库防重表看上去相似(都基于唯一约束),但是这里是把唯一索引加在业务表本身上。而数据库防重表是额外建一张表,把唯一索引加在请求记录表上。
基于 CAS 思想实现更新操作幂等
(适用于上面提到的累加写)
适用场景:如果接口涉及数据库更新操作,可以借鉴 CAS 思想。
核心思想是:更新前先判断数据状态是否符合预期,只有符合预期才执行更新。
具体做法:
可以为数据库行数据引入一个版本号字段,例如:
UPDATE table1 SET col1 = col1 + 1 WHERE version = x AND col2 = y; |
其中:
version = x:表示只有当前版本号符合预期时才更新col2 = y:表示业务条件也必须满足col1 = col1 + 1:表示真正要执行的更新操作
重复调用时的效果:
第一次调用接口时,version 还是原来的值,SQL 可以正常命中数据并执行更新。
重复调用接口时,version 字段已经发生变化,因此 SQL 无法命中原来的数据行。
结果是:重复调用不会真正执行更新操作,从而满足幂等性。
以上保证幕等性的方案要求写操作必须是数据库操作,通用性较差。更直接的做法是采用判断请求是否已处理的思路:对于每个请求,使用分布式唯一 ID 作为 UUID,同时在接口侧保存已处理的请求记录;如果找到相应的记录,则说明它处理过此请求,直接返回成功即可,具体内容如下展开。
幂等接口设计方案一:Redis 分布式锁
核心思路是:为每个请求生成一个全局唯一 ID,用 Redis 记录这个请求是否处理过。
SET UUID EX 3600 NX |
其中:
NX:只有 key 不存在时才写入成功 |
流程可以理解为:
请求进入接口 |
设置过期时间是为了控制 Redis 存储空间。但过期时间也不能太短,因为它决定了接口保证幂等性的有效期。
幂等接口设计方案二:数据库防重表
数据库防重表利用的是唯一索引。
做法是专门建一张表保存已处理请求记录,并把请求 UUID 设为唯一索引。接口处理请求前,先把请求记录插入防重表:
插入成功:请求第一次到达,可以继续处理 |
例如:
request_idempotent_table |
请求一来,先插入:
insert into request_idempotent_table(request_id) |
插入成功,说明第一次处理,然后继续执行业务逻辑。
唯一索引冲突,说明这个请求之前来过,可以不再重复处理。
它的通用性在于:不管后面的业务逻辑是什么,都可以先用这张防重表挡住重复请求。
幂等接口设计方案三:token
Token 方案的核心是:先向目标服务领一次 token,然后调用接口时带上这个 token,目标服务通过“删除 token 是否成功”判断请求是否第一次执行。
流程:
上游服务先向目标服务申请一个唯一 token,目标服务把 token 存到 Redis。
上游服务调用真正接口时携带这个 token。
目标服务收到请求后,尝试从 Redis 删除该 token:
删除成功:说明 token 还没被用过,这是第一次请求,继续执行业务逻辑。
删除失败:说明 token 不存在,可能已经被前一次请求用掉了,直接返回即可。
注意:获取 token 只执行一次,真正调用接口可以重试。
3.2.2 重试时机
重试时机包含两个维度:
- 遇到什么错误时重试?
- 失败后什么时候重试?
RPC 调用错误大致可以分为三类。
| 错误类型 | 含义 | 是否适合重试 |
|---|---|---|
| 业务逻辑错误 | 参数非法、余额不足等业务自身不成立 | 不适合 |
| 服务质量异常错误 | 下游限流、拒绝服务、熔断等 | 可谨慎重试,但要退避 |
| 网络错误 | 超时、丢包、网络抖动、连接断开等 | 可以在未熔断前提下重试 |
常见退避策略:
无退避:失败后立即重试 |
重试判断的核心原则:
业务逻辑错误:重试没有意义 |
3.2.3 重试风险与重试风暴
重试可以提高请求成功率,但也会放大下游服务的请求量。
如果下游服务本来就因为容量不足而负载升高,上游继续重试会进一步增大下游压力,最终可能拖垮整个服务。
因此,每个请求都应该设置最大重试次数。生产环境里一般请求失败后最多重试 3 次,也就是不要无限重试。
即使对请求设置了最大重试次数,也可能会产生重试风暴现象。
假设服务链路是:
Server-1 → Server-2 → Server-3 → 数据库 |
如果每一层都最多请求 3 次,那么来自 Server-1 的 1 次业务请求,可能被层层放大成:
3 × 3 × 3 = 27 次数据库请求 |
数据库原本已经高负载,级联重试会让压力更大,这就是重试风暴。
3.2.4 重试控制:不重试的请求
重试的目标是提高服务质量。如果重试不能提高服务质量,就不应该重试。
1. 非关键下游服务
如果某个下游服务不是关键依赖,就应该接受请求失败,而不是执意重试。
例如个人页服务中,用户昵称、头像、已发布内容更关键;IP 地址归属地只是锦上添花。如果地理位置服务调用失败,可以不重试,把容量留给更需要它的业务。
2. 上游服务的重试请求不再重试
为了防止级联重试,可以让重试请求在请求头中携带标记。
如果当前请求已经是上游服务的重试请求,那么本服务调用下游失败时,不再继续重试。
这样可以避免请求量在服务链路上被多层放大。
3. 服务质量异常错误
如果下游已经返回限流错误,或者本服务内部熔断器已经熔断下游服务,说明下游负载或质量存在明显问题。
这时继续重试可能会进一步压垮下游,所以不应该重试。
3.2.5 重试控制:重试请求比
最大重试次数只能限制单个请求的放大倍数,不能控制整体重试量。
如果大量请求同时失败,即使每个请求只重试少数几次,也会形成很大的重试流量。因此还需要控制整体重试请求比。
每个上游服务都应该控制正常请求总数与重试请求总数的比例。
重试请求比 = 重试请求总数 / 正常请求总数 |
滑动窗口统计方式如图所示:

例如策略是最近 10 秒内重试请求比低于 10% 才允许重试:
SC:正常请求数 |
这样可以把整体请求放大控制在约 1.1 倍。
本节总结
重试可以提高 RPC 请求的最终成功率,但前提是接口可以安全重试。
能重试的接口:必须满足幂等性 |
幂等接口常见实现:
Redis 分布式锁 |
重试控制重点:
设置最大重试次数 |
一句话总结:重试能提升成功率,但必须以幂等、退避和重试量控制为前提。
3.3 熔断与隔离
熔断和隔离都是上游服务可以采取的流量控制策略。
熔断:发现下游质量差时,停止或减少调用,防止被下游拖垮 |
关于隔离的补充:
比如一个服务里有两个接口:
接口 A:下单接口
接口 B:查询接口如果它们共用同一个线程池、连接池、队列:
接口 A 出现问题,请求大量阻塞
→ 占满线程池
→ 接口 B 明明没问题,也拿不到线程处理
→ 接口 B 也被拖慢甚至不可用
3.3.1 服务雪崩
每个微服务都难以保证 100% 可用。某个服务出现质量问题后,它的上游服务调用线程可能会被大量阻塞。
如果阻塞线程越来越多,上游服务自身负载升高,最终也可能宕机。故障沿着依赖链路向上传播,最后导致整个服务链路不可用,这就是服务雪崩。
示例:
Server-1 → Server-2 → Server-3 |
故障传播过程:
Server-3 宕机或响应极慢 |
熔断的目的就是在风险扩大前,主动断开对故障下游的调用。
3.3.2 Hystrix 熔断器
Hystrix 熔断器把下游服务分为 3 种熔断状态。
| 状态 | 含义 |
|---|---|
| Closed,熔断关闭 | 默认状态,下游正常,可以正常调用 |
| Open,熔断开启 | 下游失败率达到阈值,不再调用下游 |
| Half-Open,熔断半开 | 经过一段时间后,允许一个请求试探下游是否恢复 |
状态转换关系:
平时正常调(Closed)→ 故障多了就暂停调(Open)→ 过一会儿少量试探(Half-Open)→ 恢复了就继续正常调(Closed),不行就继续熔断(Open)。 |
关键变量
| 变量名 | 含义 |
|---|---|
| timeInMilliseconds | 统计时间窗口,Hystrix 默认 10s |
| requestVolumeThreshold | 请求总数阈值,默认 20,防止样本太小导致误判 |
| sleepWindowInMilliseconds | 休眠时间窗口,Open 后多久进入 Half-Open 试探 |
| errorThresholdPercentage | 请求失败率阈值,默认 50,即失败率达到 50% 开启熔断 |
工作流程
调用下游服务前,熔断器先判断请求是否允许执行。
Closed:允许请求执行 |
试探请求执行后:
调用成功:Half-Open → Closed,认为下游恢复 |
同时,Hystrix Metrics 会统计时间窗口内的请求成功数和失败数。如果满足下面条件,就开启熔断:
熔断器处于 Closed |
Hystrix 已停止维护,Resilience4j 、Sentinel 才是现在的常用选择。下面简单对比 Resilience4j 和 Sentinel 相比 Hystrix 的改进。
3.3.3 Resilience4j 和 Sentinel 熔断器
不同熔断器的策略对比:
| 开源项目 | 熔断器策略 |
|---|---|
| Hystrix | 时间窗口请求失败率 |
| Resilience4j | 时间窗口请求失败率、慢调用比例 |
| Sentinel | 时间窗口请求失败率、慢调用比例、错误计数 |
慢调用比例
Resilience4j 除了关注失败率,还关注慢调用比例。
| 变量名 | 含义 |
|---|---|
| slowCallDurationThreshold | 调用耗时阈值,高于该阈值视为慢调用 |
| slowCallRateThreshold | 慢调用比例阈值,慢调用比例超过该阈值时开启熔断 |
慢调用比例关注的是下游性能劣化,而不仅仅是请求失败。
错误计数
Sentinel 还支持错误计数策略:
最近 1 分钟请求失败数超过阈值 |
这种方式不依赖失败率,可以避免请求样本很大时,需要大量失败请求才能达到失败率阈值的问题。
3.3.4 共享资源与舱壁隔离
业务服务通常会用固定大小的线程池处理下游调用。如果多个接口共享同一个线程池,那么一个下游服务响应变慢,可能会占住大量线程,进而影响其他接口。
举例:服务 A 有 150 个线程,并提供 3 个接口 I1、I2、I3,分别依赖服务 B、C、D。正常情况下,每个接口占 50 个线程。
如果服务 B 响应非常慢:
I1 调用 B 的线程长时间阻塞 |
这说明共享线程池会导致接口之间互相影响。
“舱壁隔离”的思想来自船舶:把船舱分成多个隔离空间,一个船舱漏水不会影响其他船舱。
在服务中,舱壁隔离就是:
把共享资源拆成多个独立资源 |
3.3.5 舱壁隔离的实现
Hystrix 提供两种舱壁隔离策略:线程池隔离和信号量隔离。
1. 线程池隔离
线程池隔离的做法是:为每个下游服务创建专用线程池。
例如服务 A 依赖 B、C、D,可以创建 3 个线程池:
调用 B:线程池 B,大小 50 |
如果服务 B 响应变慢,只会耗尽线程池 B,不会占用调用 C、D 的线程池。
缺点是:如果下游服务很多,就要创建很多线程池,会增加内存开销、线程调度开销和上下文切换开销。
2. 信号量隔离
信号量隔离通过信号量的 PV 操作限制并发调用量。
信号量可以理解为一个资源计数器:
S > 0:还有 S 个资源可用 |
PV 操作:
P 操作:获取资源,S = S - 1 |
Hystrix 会为每个下游服务创建一个信号量,并设置初始值。例如给 B、C、D 分别设置值为 50 的信号量,就可以保证每个下游服务的并发调用量不超过 50。
调用下游前:先执行 P 操作 |
这样即使 B 响应很慢,也最多占用 50 个并发调用,不会挤压 C、D。
本节总结
熔断解决的是服务雪崩问题:当下游服务质量下降时,上游服务主动停止调用,避免被拖垮。
Hystrix 三种状态: |
隔离解决的是共享资源互相影响问题:
线程池隔离:每个下游服务独立线程池,隔离效果强,但资源开销大 |
一句话总结:熔断是及时断开故障链路,隔离是把共享资源切开,二者都是为了防止局部故障扩大成整体不可用。
3.4 限流
重试、熔断、隔离更多是上游服务为了提高自身质量、适当保护下游而采取的策略。限流则主要是下游服务保护自己,防止被上游的海量请求打垮。
限流的常见形式:
- 频控:限制单个用户在一段时间内的操作次数
- 单机限流 + 固定阈值:每台机器每秒最多处理固定请求数
- 全局限流 + 固定阈值:整个服务所有实例共享一个限流阈值
- 单机自适应限流:每台机器根据自身状态动态决定是否限流
3.4.1 频控
频控是一种特殊的限流,通常面向单个用户的操作频率。
例如:
30 秒内只能下载 1 次文件 |
可以借助Redis实现频控。
1. N 秒内只能执行 1 次操作
可以使用 Redis SET 命令结合 EX 和 NX 参数。
SET download_{userID} 0 EX 30 NX |
含义是:
NX:只有 key 不存在时才写入成功 |
判断逻辑:
写入成功:最近 30 秒没有下载过,允许下载 |
2. N 秒内最多执行 M 次操作
如果要限制一段时间内最多执行 M 次,就需要维护计数。
核心命令:
INCR publish_{userID} # 如果这个 key 不存在,Redis 会先把它当成 0,然后再加 1 |
关键点是:只有首次计数时才设置过期时间。为了保证 INCR 和 EXPIRE 的并发安全,使用 Lua 脚本实现。
流程可以简化为:
count = INCR key |
3.4.2 单机限流 1:时间窗口
时间窗口限流是最容易理解的限流算法。
例如限制 1 秒内最多 100 个请求,可以把时间线切成 1 秒一个窗口,每个窗口有 100 个计数。
请求到来 |
固定时间窗口的问题是:窗口边界附近可能出现 2 倍流量。
例如:
11:30:06 后 500ms 通过 100 个请求 |
这样在中间连续 1 秒内,实际上通过了 200 个请求,不符合“1 秒最多 100 个”的预期。因此限流效果比较差。
滑动时间窗口
滑动时间窗口把时间切成更小的槽,例如 50ms 一个槽。
1 秒窗口 = 最近 20 个 50ms 槽的总和 |
请求到来时,在最近的多个槽中查找是否还有可用计数:
找到可用槽:扣减计数,请求通过 |
滑动窗口能提高限流准确性,槽越小越准确。但它需要维护更多槽信息,并在窗口内查找可用槽,内存和 CPU 开销更大。
3.4.3 单机限流 2:漏桶算法
漏桶算法可以类比一个漏水的桶:
请求以任意速率进入漏桶 |
漏桶通常可以用队列实现,体现的是先进先出。
漏桶算法的优点是请求处理速率稳定,可以保护服务。
缺点也明显:
请求需要排队,响应时间会增加 |
3.4.4 单机限流 3:令牌桶算法
令牌桶算法是更常用的限流算法。它的核心思路是:系统按固定速率往桶里放令牌,请求处理前必须先拿到令牌。
基本流程:
1. 每秒向令牌桶放入 R 个令牌 |
令牌桶比漏桶更灵活,因为它允许一定程度的突发流量。
例如限流阈值是每秒 100 个请求:
第 1 秒只有 80 个请求 |
从长期看,令牌桶仍然把平均请求并发数限制在固定速率附近;但短期内可以利用桶中积累的令牌,容忍流量波动。
3.4.5 全局限流
单机限流只限制单个服务实例。全局限流要对一个服务的所有实例统一限流,所有实例共享同一个限流配额。
方案一:请求实时访问限流服务器
当任意服务实例收到请求时,先访问限流服务器,查询请求是否允许通过。限流服务器可以用 Redis 实现,细节类似 3.4.1 中的 “在N秒内最多执行M次操作的频控场景” 的频控方案一样,即使用Redis INCR命令,这里不再赘述。
优点:限流精准、实时性强、所有实例共享同一个配额
缺点:每个请求多一次网络调用、限流服务器容易成为单点和瓶颈、不适合请求量巨大的服务
方案二:时间片统计
时间片统计是一种“近似全局限流”方案。
它不是每来一个请求都去中心化服务判断能不能放行,而是:
先让各个服务实例在本地统计一段时间内的流量,再把统计结果汇总到限流服务器,由限流服务器计算一个新的丢弃比例,最后下发给所有服务实例,让它们在本地按概率丢弃请求。
所以它的特点是:不追求每个请求都精确控制,而是周期性根据最近一段时间的整体流量,动态调整各服务实例的限流比例。
为什么需要这种方案?
假设一个服务部署了多个实例:
服务实例 1 |
如果每个实例都自己限流,那么只能做到单机限流,无法准确知道整个集群的总 QPS。
如果每个请求都去 Redis 或限流服务器扣减计数,虽然全局精确,但会有两个问题:
1. Redis / 限流服务器会成为额外依赖 |
所以时间片统计的思路是:不用每个请求都访问中心化组件,而是让各实例自己先统计,定期上报,再统一计算下一阶段的限流比例。
流程:
服务实例本地统计请求量 |
限流比例公式:
dropRate = (实际 QPS - 限流阈值) / 实际 QPS |
例如:
实际 QPS = 1200 |
也就是每个请求有 16.7% 的概率被丢弃。
时间片不宜太长,否则调整不及时;也不宜太短,否则影响性能,10s、30s 是比较合适的选择。
Redis 频控方案和时间片统计方案的区别
可以这样对比:
| 方案 | 特点 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| Redis 频控 | 每个请求都访问 Redis 进行判断 | 精确、实时 | Redis 成为额外依赖,可能影响稳定性 | 请求量不太大,要求限流精确 |
| 时间片统计 | 周期性统计,再下发限流比例 | 性能好,适合高并发 | 不够精确,有延迟 | 请求量大,允许近似限流 |
一句话总结:Redis 方案更精确,但中心化依赖更重;时间片统计方案更高性能,但限流结果是近似的。
什么场景适合全局限流
并不是所有业务都需要全局限流。
如果只是为了防止某台机器被打垮,通常使用单机限流就够了。
例如:每个服务实例最多处理 100 QPS,超过就本地拒绝。
这种情况下,不一定需要全局限流。
但如果要控制的是某个共享资源的总访问量,就适合全局限流。
例如秒杀场景:
商品总库存 = 1000 |
这时候限制的不是某台机器的流量,而是整个系统对这个商品的总请求量。
所以可以把 “商品总库存” 看作全局限流阈值。
对于抢购请求,就可以做全局限流,避免放进来太多无意义的请求。
本节总结
限流的目标是控制请求进入速度,防止服务被上游流量打垮。
频控:控制单个用户操作频率,常用 Redis 实现 |
一句话总结:限流不是单纯拒绝请求,而是在服务能力边界内控制流量进入,令牌桶通常是兼顾稳定性和突发流量的常用方案。
3.5 自适应限流
前面介绍的时间窗口、漏桶、令牌桶、全局限流,都依赖人工设置限流阈值,这就意味着这些限流策略的实际效果很被动,依赖限流阈值的设置是否足够合理。
人工阈值的问题是:服务能力会不断变化。
服务重构后性能提升:原阈值过低,会浪费资源 |
理论上,每次服务迭代后都应该重新压测并调整限流阈值,但这需要大量准备工作和人力成本,难以持续执行。
自适应限流的思路是:
让服务根据自身当前负载情况,自动判断是否继续处理新请求。 |
3.5.1 服务与等待队列
每个服务都有一定并发度。并发请求在服务能力范围内时,可以被立即处理;当请求量超过服务并发度时,请求就会排队等待。
Netflix 技术团队用等待队列描述服务处理请求的过程,。
请求到达服务 |
如果不限制等待队列,请求会越积越多:
排队请求增加 |
这就是服务过载。
自适应限流要自动找到一个合适的限流阈值:
在请求延迟开始升高前,服务能允许通过的最大请求并发数。 |
它应该具备几个特点:
不需要工程师手动设置阈值 |
3.5.2 基于请求排队时间
Dagor 是微信团队研发的微服务过载控制系统,提供服务过载检测和准入控制能力。
这里重点关注 Dagor 怎样检测服务过载。
Dagor 使用等待队列中请求的平均等待时间判断服务是否过载:
排队时间 = 请求开始被处理的时间 - 请求到达服务的时间 |
微信团队设置的平均等待时间阈值是 20ms。如果请求被处理前的平均等待时间超过 20ms,就认为服务已经过载。
Dagor 没有直接使用 CPU 使用率作为过载标准,因为:
CPU 使用率高只是服务高负载的必要不充分条件 |
也没有直接使用请求响应时间作为过载标准,因为:
响应时间会受到下游服务处理能力影响 |
因此,请求排队时间更直接反映当前服务自己的处理压力。
3.5.3 基于延迟比率
Netflix 的 concurrency-limits 组件借鉴 TCP 拥塞控制思想,通过请求最小延迟和采样延迟之间的比值动态调整限流窗口。
第一个核心公式用于计算延迟变化梯度:
gradient = RTT_noload / RTT_actual |
变量含义:
RTT_noload:服务无负载时的最佳请求延迟,取最近一段时间内最小请求延迟 |
梯度的含义:
gradient = 1:没有请求在等待队列中排队,可以适当放宽限流窗口 |
第二个核心公式用于调整限流窗口:
new_limit = current_limit × gradient + queue_size |
其中:
current_limit:当前限流窗口大小 |
这个公式能产生类似 TCP 拥塞控制的效果:
窗口小:增长更快 |
Netflix 在组件中实现了 gradient、gradient2、vegas 等算法。
它们的共同点是:根据等待队列长度或请求延迟变化,持续上下调整限流窗口,最终收敛到合理范围。
3.5.4 其他方案
Kratos 是 bilibili 开源的 Go 微服务框架,其中提供了一种自适应限流器 BBR limiter,用于在服务压力过高时自动丢弃部分新请求,从而保护系统可用性。
核心思想:
BBR limiter 不是简单设置一个固定 QPS 阈值,而是根据系统当前状态动态判断是否限流。
它主要关注两个指标:
- CPU 使用率
- CPU 低于阈值时,认为系统压力不大,直接放行请求。
- CPU 超过阈值时,才进入进一步判断。
- 默认阈值通常是 80%。
- 当前正在处理的请求数 inFlight
- 表示系统中还没有处理完成的请求数量。
- 如果 inFlight 超过系统最近估算出的最大承载能力,就丢弃新请求。
BBR limiter 的本质是:
在 CPU 高负载时,根据服务最近的最大吞吐量和最小响应时间,估算服务当前能承载的最大并发请求数。如果当前正在处理的请求数超过这个估算值,就主动丢弃新请求。 |
它的优点是:
- 不需要人为写死固定 QPS。
- 能根据服务实际处理能力动态调整。
- 可以在系统过载前主动保护服务。
- 通过“上次限流时间”避免限流频繁抖动。
可以把它理解为一种 基于 CPU + inFlight + 动态吞吐估算的自适应限流方案。
本节总结
自适应限流解决的是固定阈值难以长期适配服务能力变化的问题。
固定阈值:需要人工压测和配置,服务变更后容易失效 |
典型思路:
Dagor:看请求平均排队时间,超过 20ms 认为过载 |
一句话总结:自适应限流的核心是让服务自己感知过载迹象,在延迟明显恶化前主动收紧请求入口。
3.6 降级策略
限流像景区控制游客数量,降级则像景区在高峰期暂时关闭不重要、风险较高或难管理的项目。
服务降级的目的:
保障用户核心体验 |
降级方案通常和具体业务强相关,有几种常见思路。
3.6.1 服务依赖度降级
一个服务通常依赖多个下游服务,但每个下游服务的重要程度不一样。
如果 B 是 A 的下游服务,那么 B 对 A 的重要程度叫作依赖度。依赖度越高,说明 B 对 A 越重要。
常见依赖度划分方式:
二元划分: |
当请求量暴增时,就可以优先切断低依赖度的下游调用,把资源留给更核心的下游服务。
盛典活动示例
背景:A 服务在活动期间预计 QPS 高达 100 万,研发团队需要提前做容量评估、扩容、全链路压测,并梳理所有下游依赖。
对各个服务依赖度分级为:
一级依赖:B、D、H、J |
活动开始前,可以先关闭三级依赖调用。活动过程中如果 A 服务性能继续劣化,再关闭二级依赖调用。这样 A 服务最终只保留一级依赖调用,把资源留给核心功能。
依赖度也能用于其他治理策略
服务依赖度不只用于降级,也可以增强其他策略:
重试:只对强依赖下游进行重试 |
3.6.2 读请求降级
读请求降级主要依赖两类方案:缓存、兜底数据
一个请求从客户端到最终数据响应,通常会依次经过:
客户端 |
可以在客户端、接入层网关、HTTP/RPC 服务中设置本地缓存,并通过动态配置中心控制各层是否使用缓存,以及缓存过期时间。
兜底数据
兜底数据的思路是:如果访问目标数据失败,就降级为访问另一份基本可用的数据
兜底数据可以是静态数据,也可以来自另一个数据源。
示例一:推荐服务兜底
信息流推荐服务依赖两个核心下游:
推荐算法服务:计算用户可能感兴趣的内容 ID |
推荐算法服务是计算密集型场景,可用性和性能可能不稳定。可以每天零点离线计算一批用户可能感兴趣的内容或热门内容 ID,由兜底推荐服务维护。
推荐算法服务正常:使用推荐算法结果 |
示例二:礼物列表静态兜底
直播平台的礼物列表服务不可用时,用户可能无法打赏,影响收入。
可以在接入层网关预先配置几个常见礼物数据:
礼物列表服务正常:返回完整礼物列表 |
这样用户至少能看到部分礼物,保留打赏可能性。
3.6.3 写请求降级
写请求降级可以使用第 2 章讲过的异步写和写聚合,这里不再展开。
除此之外,某些业务场景也可以直接丢弃写请求。
例如:直播间弹幕自见。
正常弹幕流程:
用户发送弹幕 |
如果直播间有 100 万在线用户,每秒 100 个用户发送弹幕,那么弹幕服务每秒可能需要下发 1 亿条消息,网络带宽压力巨大。
弹幕自见的降级方案:
1. 用户 A 发送弹幕后,客户端直接在本地展示这条弹幕 |
这个方案可行,是因为热门直播间弹幕量本来就很大,单个用户的弹幕很容易被快速刷过去;用户通常没有强诉求要求所有人都看到自己的弹幕。
本节总结
降级的目标是保障核心体验和核心功能可用。
服务依赖度降级:
按强依赖/弱依赖或一二三级依赖,优先切断低依赖度调用读请求降级:
使用多级缓存和兜底数据写请求降级:
使用异步写、写聚合,必要时按业务特性丢弃部分写请求
降级不是固定技术方案,而是根据业务重要性做取舍。
一句话总结:降级就是在压力或故障下主动牺牲非核心能力,把有限资源留给最核心的用户体验。
3.7 本章小结
微服务架构通过复杂的网络调用把多个服务串联起来。网络健壮性、下游服务质量和请求流量变化都会影响每个服务的可用性。

本章的核心治理手段可以整理为:
重试:提高单次请求的最终成功率 |
重点复习
1. 重试
只有幂等接口可以安全重试。读接口天然幂等,写接口需要做幂等设计。
幂等设计包括:Redis 分布式锁、数据库防重表、token 方案
对于一些特殊业务,如
一些插入写情况,可以基于数据库业务表唯一约束
一些累加写情况,可以基于乐观锁
需要根据调用失败的原因决定是否需要重试。如果重试、则需要使用合适的退避策略决定请求重试的时机
为了防止重试风暴,需要控制 最大重试次数和重试请求比
2. 熔断
- 熔断器可以自动感知下游服务故障,开启熔断后停止调用下游。
- Hystrix 熔断器基于时间窗口失败率的策略开启熔断,是最基本的熔断器。
- 在开启熔断一段时间后,Hystrix 熔断器通过半开状态(Half-Open)探测下游服务是否已经恢复。
- Resilience4j 熔断器还采用了慢调用比例策略开启熔断(慢速请求在总请求量中的比例是否达到阈值)
- Sentinel 熔断器还可以基于错误计数的策略开启熔断(最近 1 分钟的请求失败数超过阈值)。避免请求样本很大时,需要大量失败请求才能达到失败率阈值的问题。
3. 隔离
为不同接口(不同下游调用)划分独立资源,例如线程池、连接池、信号量等,防止接口互相拖累。某个下游服务异常、超时或阻塞时耗尽共享资源,从而影响其他调用。
线程池隔离:每个下游独立线程池 |
4. 限流
限流是下游服务保护自己的手段。
- 频控是一种特殊的限流,对单个用户限制操作频率,基于 Redis 实现。
- 单机限流有滑动时间窗口算法、漏桶算法、令牌桶算法,限流阈值依赖人工设置,令牌桶算法表现最优。
- 全局限流通过引入限流服务,业务服务的各个实例共享限流配额,只有涉及共享资源并发控制时才建议使用此策略。
- 服务处理请求的流程可以被抽象为请求在等待队列中排队等待执行。
- 自适应限流不需要人工设置限流阈值,而是根据服务实例的当前服务能力动态限流。业界采用的方案有基于请求排队时间和基于延迟比率的自适应限流,以及 BBR limiter 算法等。
5. 降级
降级用于保障核心功能可用,具体的实施方案比较灵活。
- 服务依赖度降级:切断低依赖度下游调用,将服务资源倾斜给重要的下游服务
- 读请求降级:可以使用多级缓存数据或兜底数据做降级策略,兜底数据可以是静态数据,也可以是来自另一个数据源的数据
- 写请求降级:异步写和写聚合是较为通用的降级策略,在某些业务场景中也可以丢弃写请求,减小写请求量级。