业务系统性能改造手册 · 稳定性治理

用限流与背压守住容量边界

2026-08-053 min read稳定性治理
摘要

从吞吐拐点和延迟预算确定安全运行区间,再让入口准入、业务键保护、有界排队、明确拒绝和有限降级共用这份容量约束。

用限流与背压守住容量边界

系统在过载时最危险的表现,往往不是立即报错,而是继续接收请求。

吞吐量已经不再增长,队列却越来越长;调用方暂时只看到响应变慢,随后线程、连接和内存一起被占满。等到错误率明显上升,系统早已离开可恢复的运行区间。05-01 用超时和隔离封住了单个慢下游,本篇继续处理入口流量超过整条业务链可持续容量的问题。

小编现阶段的理解是:容量治理要尽早拒绝确定做不完的工作,并把有限资源留给仍能兑现承诺的请求。 这需要限流、有界短队列、调用方退让和业务降级共享同一份容量模型。四套各自独立的参数,只会把拥塞从一个位置搬到另一个位置。

一、先找到安全运行区间,再讨论限流算法

限流参数要从系统安全运行区间和下游容量证据推导而不是先选算法再猜数字 限流值不能直接照搬机器核数、线程池大小或历史峰值。我们需要回到第 1 章的阶梯升压结果,寻找吞吐、延迟和资源曲线开始分叉的位置。

假设有这样一个简化场景:订单服务的输入速率逐级增加。前几档中,吞吐量随输入近似增长,P95/P99 和队列等待保持稳定;到某一档后,吞吐增幅明显收窄,连接等待、线程池队列或 CPU 中的一项持续上升,尾延迟开始越过预算。这个位置是饱和拐点的候选,不是应该长期运行的目标值。

安全区要落在拐点之前,并保留处理正常波动、实例故障和下游抖动的余量。具体余量只能由重复实验和业务风险决定,本文不编造一个通用百分比。

1.1 容量边界至少记录四组证据

证据需要回答的问题不能单独得出的结论
输入与完成吞吐从哪一档开始,新增请求不再带来等量完成量峰值吞吐就是安全放行量
P95/P99 与队列等待尾延迟何时越过响应预算,时间花在执行还是等待平均延迟正常就没有过载
饱和资源线程、连接、CPU、下游并发中谁先到上限某项利用率高就一定要扩容
错误与恢复降压后积压多久消退,指标是否回到原稳态错误率下降就已经完全恢复

把每一档持续到稳态,记录多次实验的波动范围。若不同轮次的拐点差异很大,先检查数据状态、缓存命中、下游行为和压测机是否漂移,不要急着取一个平均值写进配置。

1.2 用同一模型约束速率、并发和排队

容量模型可以先保留变量:

text
1safe_rate = 实验确认的安全完成速率 2max_in_flight = 安全区内可持续的在途请求上限 3queue_wait_budget = 端到端预算扣除执行与返回余量后的最大排队时间 4queue_capacity = 在 queue_wait_budget 内能够消化的短暂积压

其中 max_in_flight 需要结合安全速率和服务时间观测;队列容量则要用实际出队速率验证。队列里一个请求前面还有多少工作,决定了它是否可能在截止时间前开始执行。扩大队列不会增加完成能力,只会推迟拒绝。

容量模型还要写明作用域:单实例、整个服务、单租户还是单业务键。配置单实例每秒放行量时,扩容实例会放大全局放行量;如果网关、应用和下游分别限流,它们的统计口径也可能不同。没有作用域的“每秒 N 次”不能用于验收。

二、把保护动作放在它能保护对象的位置

入口、业务键和下游并发限制看起来都在“挡请求”,保护对象并不相同。

2.1 入口准入保护整台服务

入口限流应在昂贵工作之前完成,保护应用线程、数据库连接和整机资源。它适合处理总流量突发,但无法识别某个租户或热点订单占用了大部分额度。

令牌桶适合允许受控突发:令牌按稳定速率补充,桶容量决定最多容纳多少瞬时流量。固定窗口实现更简单,却可能在窗口边界连续放行两批请求。算法不是重点,放行速率和突发容量都必须来自上一节的实验。

下面是一个不依赖框架的最小令牌桶。生产接入时还需补充指标、配置热更新和多实例作用域,本例只验证单进程准入语义:

java
1import java.util.Objects; 2import java.util.function.LongSupplier; 3 4public final class TokenBucket { 5 private final double capacity; 6 private final double nanosPerToken; 7 private final LongSupplier ticker; 8 private double tokens; 9 private long lastRefillNanos; 10 11 public TokenBucket(double capacity, double tokensPerSecond) { 12 this(capacity, tokensPerSecond, System::nanoTime); 13 } 14 15 public TokenBucket( 16 double capacity, 17 double tokensPerSecond, 18 LongSupplier ticker) { 19 if (!Double.isFinite(capacity) || capacity < 1d) { 20 throw new IllegalArgumentException("capacity must be finite and at least one token"); 21 } 22 if (!Double.isFinite(tokensPerSecond) || tokensPerSecond <= 0d) { 23 throw new IllegalArgumentException("rate must be finite and positive"); 24 } 25 double calculatedNanosPerToken = 1_000_000_000d / tokensPerSecond; 26 if (!Double.isFinite(calculatedNanosPerToken) 27 || calculatedNanosPerToken <= 0d) { 28 throw new IllegalArgumentException("rate cannot be represented in nanoseconds per token"); 29 } 30 this.capacity = capacity; 31 this.nanosPerToken = calculatedNanosPerToken; 32 this.ticker = Objects.requireNonNull(ticker); 33 this.tokens = capacity; 34 this.lastRefillNanos = ticker.getAsLong(); 35 } 36 37 public synchronized boolean tryAcquire() { 38 refill(); 39 if (tokens < 1d) return false; 40 tokens -= 1d; 41 return true; 42 } 43 44 private void refill() { 45 long now = ticker.getAsLong(); 46 long elapsedNanos = now - lastRefillNanos; 47 if (elapsedNanos <= 0L) return; 48 double added = elapsedNanos / nanosPerToken; 49 tokens = Math.min(capacity, tokens + added); 50 lastRefillNanos = now; 51 } 52}

生产构造器使用 System.nanoTime() 计算经过时间,它不表达日期,也不受墙上时钟校准影响;需要生成 Retry-After 的 HTTP 日期时再使用墙上时间,两种用途不要共用一个时钟。第二个构造器注入 LongSupplier,方便测试用例控制单调时间。

nanoTime 只能比较同一 JVM 内两次读取的差值。代码使用 now - lastRefillNanos,即使底层计数器回绕,只要两次读取间隔不超过 long 可表达的半个周期,差值语义仍然成立;测试替身也必须遵守单调递增契约。一次请求固定消耗一个令牌,所以容量至少为 1。容量和速率拒绝 NaN、无穷值及非正值,时间换算结果也要保持有限正数,避免错误配置静默变成全放行或全拒绝。

2.2 业务键限流处理不公平占用

总量仍在安全区内,单个租户、用户或热点订单也可能制造局部拥塞。业务键限流用于限制这种不公平占用,不能替代总入口上限。

按键维护桶会带来状态成本。键数量无界时,限流器本身可能成为内存问题,因此要定义键来源、空闲淘汰、最大条目数和高基数指标策略。多实例部署还要决定接受“每实例近似限制”,还是使用网关或共享状态实现全局口径;两种选择的延迟、可用性和精度代价不同。

2.3 下游并发限制保护依赖容量

入口安全不代表每个依赖都安全。一次订单请求可能并行访问库存、营销和数据库,而它们的容量不同。05-01 的独立资源舱继续承担这里的下游并发上限:舱满时快速失败,不能再把任务转入另一个无界队列。

三层限制要避免重复放大等待。请求已经在入口短队列等待过,就不能进入下游后重新领取完整的等待预算;它携带的仍是同一个截止时间。

三、有界短队列和明确拒绝,才会形成反馈

有界短队列与明确拒绝会形成背压反馈静默堆积只会放大延迟和重试风暴 在同步 HTTP 接口中,“背压”容易被说得过于抽象。HTTP 请求不会因为服务端写了一个开关,就自动降低上游发送速度。这里能做的是:限制本地排队,超过容量时返回明确的过载响应;调用方识别响应后减少并发、延迟重试或停止重试。调用方不配合时,服务端只能持续拒绝,无法凭空形成端到端背压。

队列只吸收服务时间范围内的短暂抖动。入队前应检查截止时间,队首等待也要计入请求总预算;队列满或预计等待已经越界时立即拒绝。不要用 CallerRunsPolicy 把工作悄悄推回 Web 请求线程,它会改变线程职责,还可能让入口线程被慢任务占住。

3.1 HTTP 拒绝不能包装成业务成功

当某个调用方在一段时间内发送过多请求时,HTTP 429 Too Many Requests 能明确表达限流。RFC 6585 允许响应携带 Retry-After,但没有规定服务端如何识别调用方或统计请求。若服务整体临时无法承载,而不是某个调用方触发配额,也可以按既有 API 契约选择 503 Service Unavailable

无论选择哪一个,都应返回稳定的机器可识别错误码、限流作用域和请求追踪标识。只有系统确实知道建议等待多久时才发送 Retry-After;它的值可以是 HTTP 日期或非负秒数。随手写一个固定值会让大量客户端在同一时刻重试,形成新的尖峰。

http
1HTTP/1.1 429 Too Many Requests 2Content-Type: application/problem+json 3Retry-After: ${MEASURED_RETRY_DELAY_SECONDS} 4 5{ 6 "code": "ORDER_ADMISSION_LIMITED", 7 "scope": "tenant", 8 "traceId": "${TRACE_ID}" 9}

订单提交被拒绝时不能返回 HTTP 200 和“已受理”。客户端若可能重试写请求,仍要沿用第 4 章的幂等键;限流拒绝不代表服务端可以忽略重复提交风险。

3.2 调用方退让要有上限和抖动

调用方收到明确过载响应后,应降低并发或按服务端建议等待。自动重试仍受 04-03 的次数、总时间和退避预算约束,并加入抖动,避免一批请求同时醒来。

浏览器、第三方客户端和旧版本 SDK 未必遵守这些约定,所以服务端的安全性不能依赖“大家都会正确退让”。把实际遵守 Retry-After 的客户端比例纳入观测,未配合的调用方继续在入口被拒绝。

四、容量紧张时,业务选择比算法更重要

所有请求共享一个令牌桶,看起来公平,结果可能是订单列表刷新挤掉订单提交。保护策略应由业务承诺决定,不要让 URL 顺序或线程竞争替我们做决定。

对这个订单示例,可以把动作分成两类,但具体归属仍需业务确认:

  • 订单提交、状态推进等核心写入保留独立容量。无法受理时明确拒绝或返回未受理,不能伪造成功。
  • 非核心查询可以降低刷新频率、关闭附加信息,或在契约允许时返回带时间戳和版本的旧数据。

独立容量不是永久闲置一块资源。可以设计受控借用,但借用量、收回条件和监控必须明确;核心流量回升时,应先收回借出的容量。这里不建议做复杂的动态评分系统,先用少量稳定业务类别把拒绝行为讲清楚。

降级响应也要进入正确性验收。旧数据必须暴露新鲜度,省略字段要符合 API 契约,写操作的未知状态要沿用 05-01 的查询与对账方式。限流器只负责准入,不负责替业务决定“失败能否算成功”。

五、用突发与恢复实验检查系统会不会振荡

稳定性实验要覆盖升压、持续过载和降压恢复,不能只截取过载期间的一张监控图。

先按 01-02 的负载比例运行安全区基线,再注入短突发和持续超载。分别启用入口总量限制、业务键热点限制和下游并发舱,每轮只改变一个主要变量。容量值仍从同一环境的拐点实验取得,报告中记录所有配置作用域。

验收报告至少回答这些问题:

  • 实际放行量是否稳定在安全区,核心写入获得了多少独立容量。
  • P95/P99 是否留在响应预算内,排队等待是否受到硬上限约束。
  • 429503、超时和业务拒绝分别占多少,是否被客户端正确识别。
  • 队列峰值、积压消退时间和隔离舱占用是否符合预算。
  • 调用方重试是否遵守退避与总预算,有没有产生同步重试尖峰。
  • 降压后吞吐、延迟和资源指标何时回到基线波动范围。

恢复阶段尤其容易被忽略。故障刚解除时,积压、重试和新流量会同时争抢容量;若控制器立刻放开全部额度,延迟可能再次越界,然后又触发收紧,形成反复振荡。比较稳妥的做法是逐步恢复放行量,并要求延迟、队列和饱和资源连续多个采样窗口处于安全区后再扩大。窗口数量和步长同样要靠实验确定,不写死成通用参数。

可以用下面的配置清单保存本轮决策,所有占位值都需要压测结果或业务契约支撑:

yaml
1admission: 2 scope: ${INSTANCE_OR_GLOBAL} 3 safe-rate: ${MEASURED_SAFE_RATE} 4 burst-capacity: ${MEASURED_BURST_CAPACITY} 5 queue-capacity: ${QUEUE_WITHIN_WAIT_BUDGET} 6 queue-wait-budget: ${QUEUE_WAIT_BUDGET} 7tenant-limit: 8 enabled: ${TENANT_LIMIT_ENABLED} 9 idle-entry-ttl: ${KEY_STATE_TTL} 10 max-entries: ${MAX_LIMITER_KEYS} 11recovery: 12 initial-rate: ${RECOVERY_INITIAL_RATE} 13 step: ${RECOVERY_RATE_STEP} 14 stable-windows-before-step: ${MEASURED_STABLE_WINDOWS}

回退时先恢复上一组已验证参数或关闭新准入层,不要同时扩大队列和下游并发。指标和拒绝错误码要保留,否则下一轮无法判断是限流过严,还是原容量模型已经变化。

这篇文章要交付一条可核验的边界:什么负载下系统仍能按预算完成工作,超过边界时谁被拒绝、如何退让,流量恢复后多久回到稳态。单个漂亮的 QPS 数字回答不了这些问题。边界说清楚了,过载才会从随机崩溃变成可以解释和演练的受控退化。

参考资料