限流、降级与容量保护:过载时怎样保住有效工作
当工作进入速度超过完成速度,队列会增长,等待变长,更多调用超时。若客户端立即重试,新增尝试又会争抢同一批资源。容量保护要在这条反馈链失控之前减少进入的工作,让已经接受的关键任务仍有机会完成。
拒绝、延迟执行、返回降级结果和转移到异步队列,会带来不同的用户结果。选择保护方式时,应先确定哪些工作必须完成、哪些可以稍后完成、哪些可以省略,然后再选择计数和调度机制。
速率、并发与隔离分别限制什么
每秒进入数与同时在途数
速率限制约束一个时间范围内接纳多少工作;并发限制约束某个范围内同时有多少工作。请求原来平均20ms,后来变成500ms,即使每秒进入数不变,同时占用资源的请求也会大幅增加。
入口RPS保持稳定时,也需要观察请求的持有时间。外部接口配额可以使用速率限制,数据库或昂贵计算适合再设置并发准入;队列还要限制长度与等待年龄。平均关系可用 Little定律 核对,上限仍需要根据分布和期限选择。
一个限制器必须说明计数单元。例如每个租户每秒100次请求、每个实例最多20个导出任务、每个数据库分片最多30个活动写事务,是三种不同约束。单次请求的成本差异很大时,还可以按操作类型赋权或分别分配额度。
常用速率算法
| 算法 | 保存的主要状态 | 对突发的影响 |
|---|---|---|
| 固定窗口 | 当前窗口计数 | 窗口交界处可能连续接纳两批额度 |
| 滑动日志 | 窗口内每次请求时刻 | 计数较精确,但存储与清理成本随请求增加 |
| 滑动窗口计数 | 多个子窗口或加权计数 | 用近似换取较小状态量 |
| 令牌桶 | 当前令牌、上次补充时间 | 桶容量允许有限突发,补充速率控制长期进入速度 |
| 漏桶式整形 | 等待队列与发送节奏 | 平滑输出,同时引入排队与等待上限问题 |
令牌桶每秒补充10个令牌、最多存20个时,空闲后可以短时接纳20次,然后靠补充继续接纳。桶容量决定允许积累的突发量,补充速率决定长期平均供给。一次操作消耗多少令牌要与业务计数一致;网络领域的桶通常按字节计量,概念见 RFC3290令牌桶与漏桶说明。
实现时,补充与扣除需要原子地更新共享状态,并使用适合计算间隔的时钟。并发线程分别“先看够不够、再扣减”会超发;分布式机器时钟不同,也会影响依赖绝对窗口边界的实现。
本地额度与全局额度
每个实例限100次/秒,十个实例合起来可能接纳约1,000次/秒。新增实例会改变总额度;流量分配不均时,又可能出现一台拒绝、其他实例闲置。因此,全局用户配额与本机资源保护通常需要分别设计。
集中计数可以维持共享配额,但引入远程调用、热点与自身故障;分配本地额度或租约可以减少调用成本,却需要明确额度误差、过期和实例失联时的行为。限制器不可用时放行还是拒绝,取决于资源损害与业务中断的代价,不能为所有接口使用同一个默认答案。
按租户、用户或优先级分类时,标识应来自验证后的身份。不能让客户端通过任意请求头自行宣称“关键请求”,否则低优先级流量可以绕过分配规则。连接、请求头与请求体的基础限制则可放在身份解析之前,保护完成认证所需的资源。
资源隔离与熔断的分工
并发隔离(bulkhead)为不同工作分配各自的许可、队列或连接。可选推荐任务用尽自己的许可后,订单核心流程仍可以获得预留许可。进程内Semaphore只隔离指定代码段的并发,不隔离CPU、堆、HTTP接入线程和共享数据库。
熔断器通常根据失败或慢调用观察,在一段时间内减少对故障依赖的调用;限流器主要依据配额或容量准入。两者可以配合,但触发原因和恢复条件应分别记录。资源池的范围与等待位置见线程池、连接池、IO与序列化。
拒绝、降级与恢复怎样影响用户结果
尽早拒绝昂贵工作
在请求体已解析、连接已借出、SQL已提交之后才返回“系统繁忙”,没有减少前面的成本,还可能让用户不知道操作是否发生。准入应尽量放在受保护的昂贵阶段之前,同时保留必要的身份和参数检查。
拒绝本身也消耗连接、CPU和网络。极端连接洪峰需要在更靠前的代理或网络入口处理,不能指望应用逐个生成JSON响应承受无限流量。过载信号、优先级与接纳控制的设计可查 Google SRE过载处理。
HTTP响应应说明原因与重试条件。配额超限通常用429;服务暂时无能力处理可以用503。429的语义见 RFC6585,503与Retry-After规则见 RFC9110。
Retry-After只传达建议等待时间,客户端仍需要限制重试次数、总期限和并发。大量客户端在同一秒重试,会再次形成突发;随机化退避和重试预算用于分散并限制这些额外尝试。写操作还要确认可重试性与幂等规则。
降级返回的是另一种明确结果
降级可以省略非必要字段、使用允许范围内的旧缓存、关闭可选推荐、降低批处理频率或转为异步接纳。每种方式都应在接口契约中可识别,并保留使用条件。例如推荐列表暂时为空可以接受,支付成功却返回未经确认的金额则不能用“降级”解释。
旧值降级需要最大陈旧时间与来源说明;静态兜底需要避免让用户误认为实时计算已经完成;异步接纳需要提供状态查询与最终失败处理。把异常吞掉后始终返回200,会让有效吞吐与业务错误难以统计。
高优先级也不应无限使用全部资源。可按关键工作预留份额,再让低优先级借用空闲部分;借用回收、饥饿和公平性需要明确处理。后台修复任务虽然不直接服务当前用户,也可能对最终状态恢复很重要。
降载以后还要让系统排空
进入率下降后,旧任务、重试和消息积压可能仍在消耗资源。恢复条件应包含队列年龄、在途数、资源可用量和依赖健康,而不只看瞬时CPU。
使用不同的收紧与恢复阈值,可以减少状态来回切换;恢复时逐步增加接纳比例并观察一段时间,通常比瞬间放开全部流量更容易发现余量不足。依赖刚恢复时,还要考虑缓存、连接与JIT预热,以及积压任务与新请求竞争。
用真实HTTP请求检查业务准入
启动两个独立许可范围
下载容量保护实验包,解压进入 protection-lab。环境为Linux、Docker Engine/Compose和Temurin25.0.4+7,源码按Java17编译,也可在17.0.20+8运行。宿主是有Docker权限的普通用户,编译身份使用宿主UID/GID,两个运行容器使用10001。
mkdir -p classes
docker run --rm --user "$(id -u):$(id -g)" --network none \
-v "$PWD:/lab" -w /lab eclipse-temurin:25.0.4_7-jdk \
javac --release 17 -Xlint:all -Werror -d classes GuardServer.java GuardProbe.java
docker compose up -d guard
curl -q --noproxy '*' --fail-with-body \
http://127.0.0.1:18019/health健康响应应为 {"status":"UP"}。未就绪时检查 docker compose logs guard并重试;日志应显示 optionalSlots=2 criticalSlots=1。不要在服务未启动时运行后面的反例。
服务只向宿主回环地址发布18019端口。/optional有两个许可,/critical有一个许可;两者共享HTTP执行器,但受保护业务段分别计数。核心准入操作为:
if (!slots.tryAcquire()) {
// 返回503与具体容量原因,此前没有执行受保护业务
return;
}
try {
// 执行已获准的业务工作
} finally {
slots.release();
}无参 tryAcquire()立即尝试获取许可,不在这里建立等待队列;规则见 Java Semaphore。完整实现还负责响应、异常和计数。异步处理时,许可应在实际异步工作结束后归还,不能只包住“提交任务”这一步。
实验的工作结果是实际受保护分支的执行计数,用来检查拒绝是否在执行前发生;它没有模拟数据库提交或生产计算成本。HTTP执行器配置12个线程,仅用于这轮少量请求实验,不能据此推导无限连接情况下关键请求仍可用。
占满可选许可,核对拒绝与关键请求
运行包内HTTP探针:
docker compose run --rm probe探针首先执行一次正常可选请求,确认200和正确JSON。随后通过两个真实异步HTTP请求占住两个可选许可;hold=1在服务端等待实验闩锁,最多等待5秒。探针轮询实际active计数,观察到2以后才发送下一次可选请求。
下一次请求应收到完整503、{"reason":"OPTIONAL_CAPACITY"}与 Retry-After: 1,同时服务端可选执行次数仍为1。两个保持请求尚未执行,拒绝请求也没有执行。紧接着发送关键请求,应收到200与关键结果:
overload status=503 reason=OPTIONAL_CAPACITY rejected_work_executed=false critical=200探针使用JDK HttpClient,显式禁用代理、限制目标为实验地址,并设连接与请求超时。任何传输异常、其他HTTP状态或错误载荷都会让探针失败。
释放资源并观察恢复
探针在finally中调用仅供实验使用的 POST /release,让两个保持请求完成。等待许可全部归还后,再发一次普通可选请求,检查它恢复200。最终状态应为:
{"optionalActive":0,"optionalExecutions":4,"criticalExecutions":1,"rejected":1,"writeFailures":0}四次可选执行分别来自最初正常请求、两个释放后的保持请求和最后的恢复请求。这个计数把拒绝与业务执行联系起来,避免只检查HTTP状态却漏掉“已经执行才拒绝”的错误。
可以在探针结束后查看服务端状态:
curl -q --noproxy '*' --fail-with-body \
http://127.0.0.1:18019/stats
docker compose down --remove-orphans只有健康和stats接口适合在这套固定探针前后手工调用。先手工请求业务接口会改变计数;需要重跑时,先down再up获得全新的guard,不把旧计数混入本轮。
Java17复验时替换编译镜像,并对 docker compose up 和 run 都设置 JDK_IMAGE=eclipse-temurin:17.0.20_8-jdk。这套实验验证应用内并发准入、明确拒绝和恢复;它没有实现分布式配额、生产鉴权或独立CPU隔离。/release这样的控制接口只应保留在回环隔离的实验中。
保护开启后仍然异常时怎样处理
拒绝很多,CPU仍然很高
检查拒绝发生的位置。若昂贵解析、认证远程调用或日志写入已经发生,先把可提前完成的限制移到对应资源之前;日志也要避免在每次拒绝时写入大载荷。
再检查客户端是否立即重试。入口尝试数增加,而唯一业务工作数没有增加,说明额外流量可能来自重试。按客户端、调用层级和原因统计重试放大,并在合适的一层集中重试,避免每层各重试三次形成乘法放大。
有独立许可,关键请求仍被拖慢
观察共享HTTP接入线程、CPU、堆、连接池和数据库。两个Semaphore仅限制各自业务段,不能让它们绕过共同耗尽的资源。关键路径需要哪些真实资源,就在那些资源上检查是否有预留、上限或更强的部署隔离。
如果低优先级任务占用连接很久,即使关键路径有独立业务许可,也可能拿不到共享连接。可以缩短低优先级持有时间、使用有限的独立连接配额,或把批处理移到隔离执行环境,再测总数据库负载。
流量下降后一直没有恢复
先找未结束的工作:等待锁、无法取消的远程调用、消费者积压或未归还的许可。检查正常返回、异常、取消和超时是否都经过资源释放路径;捕获异常后漏掉release会让容量永久减少。
再检查恢复信号是否被旧窗口支配、探测流量是否足够、恢复依赖是否已预热。手工提高额度前保留状态与原因,避免把泄漏掩盖为“容量不够”。
保护参数准备调整
先在固定业务数据和负载下观察正常、饱和、明确拒绝、依赖变慢和恢复,再逐步提高接纳量。记录有效吞吐、状态分类、队列年龄与资源占用;参数变化后的结果按基线、压测与性能回归进行对照。
处理节点故障、数据恢复和事故协作时,还需要结合可靠性设计;对应的学习入口见后端能力地图。
权威资料与规范地址
- RFC3290令牌桶与漏桶:https://www.rfc-editor.org/rfc/rfc3290.html#appendix-A
- Google SRE过载处理:https://sre.google/sre-book/handling-overload/
- RFC6585,429 Too Many Requests:https://www.rfc-editor.org/rfc/rfc6585.html#section-4
- RFC9110,503与Retry-After:https://www.rfc-editor.org/rfc/rfc9110.html#name-503-service-unavailable
- Java Semaphore:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/Semaphore.html
