业务系统性能改造手册 · 稳定性治理
用限流与背压守住容量边界
从吞吐拐点和延迟预算确定安全运行区间,再让入口准入、业务键保护、有界排队、明确拒绝和有限降级共用这份容量约束。
用限流与背压守住容量边界
系统在过载时最危险的表现,往往不是立即报错,而是继续接收请求。
吞吐量已经不再增长,队列却越来越长;调用方暂时只看到响应变慢,随后线程、连接和内存一起被占满。等到错误率明显上升,系统早已离开可恢复的运行区间。05-01 用超时和隔离封住了单个慢下游,本篇继续处理入口流量超过整条业务链可持续容量的问题。
小编现阶段的理解是:容量治理要尽早拒绝确定做不完的工作,并把有限资源留给仍能兑现承诺的请求。 这需要限流、有界短队列、调用方退让和业务降级共享同一份容量模型。四套各自独立的参数,只会把拥塞从一个位置搬到另一个位置。
一、先找到安全运行区间,再讨论限流算法
限流值不能直接照搬机器核数、线程池大小或历史峰值。我们需要回到第 1 章的阶梯升压结果,寻找吞吐、延迟和资源曲线开始分叉的位置。
假设有这样一个简化场景:订单服务的输入速率逐级增加。前几档中,吞吐量随输入近似增长,P95/P99 和队列等待保持稳定;到某一档后,吞吐增幅明显收窄,连接等待、线程池队列或 CPU 中的一项持续上升,尾延迟开始越过预算。这个位置是饱和拐点的候选,不是应该长期运行的目标值。
安全区要落在拐点之前,并保留处理正常波动、实例故障和下游抖动的余量。具体余量只能由重复实验和业务风险决定,本文不编造一个通用百分比。
1.1 容量边界至少记录四组证据
| 证据 | 需要回答的问题 | 不能单独得出的结论 |
|---|---|---|
| 输入与完成吞吐 | 从哪一档开始,新增请求不再带来等量完成量 | 峰值吞吐就是安全放行量 |
| P95/P99 与队列等待 | 尾延迟何时越过响应预算,时间花在执行还是等待 | 平均延迟正常就没有过载 |
| 饱和资源 | 线程、连接、CPU、下游并发中谁先到上限 | 某项利用率高就一定要扩容 |
| 错误与恢复 | 降压后积压多久消退,指标是否回到原稳态 | 错误率下降就已经完全恢复 |
把每一档持续到稳态,记录多次实验的波动范围。若不同轮次的拐点差异很大,先检查数据状态、缓存命中、下游行为和压测机是否漂移,不要急着取一个平均值写进配置。
1.2 用同一模型约束速率、并发和排队
容量模型可以先保留变量:
1safe_rate = 实验确认的安全完成速率
2max_in_flight = 安全区内可持续的在途请求上限
3queue_wait_budget = 端到端预算扣除执行与返回余量后的最大排队时间
4queue_capacity = 在 queue_wait_budget 内能够消化的短暂积压其中 max_in_flight 需要结合安全速率和服务时间观测;队列容量则要用实际出队速率验证。队列里一个请求前面还有多少工作,决定了它是否可能在截止时间前开始执行。扩大队列不会增加完成能力,只会推迟拒绝。
容量模型还要写明作用域:单实例、整个服务、单租户还是单业务键。配置单实例每秒放行量时,扩容实例会放大全局放行量;如果网关、应用和下游分别限流,它们的统计口径也可能不同。没有作用域的“每秒 N 次”不能用于验收。
二、把保护动作放在它能保护对象的位置
入口、业务键和下游并发限制看起来都在“挡请求”,保护对象并不相同。
2.1 入口准入保护整台服务
入口限流应在昂贵工作之前完成,保护应用线程、数据库连接和整机资源。它适合处理总流量突发,但无法识别某个租户或热点订单占用了大部分额度。
令牌桶适合允许受控突发:令牌按稳定速率补充,桶容量决定最多容纳多少瞬时流量。固定窗口实现更简单,却可能在窗口边界连续放行两批请求。算法不是重点,放行速率和突发容量都必须来自上一节的实验。
下面是一个不依赖框架的最小令牌桶。生产接入时还需补充指标、配置热更新和多实例作用域,本例只验证单进程准入语义:
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 日期或非负秒数。随手写一个固定值会让大量客户端在同一时刻重试,形成新的尖峰。
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 是否留在响应预算内,排队等待是否受到硬上限约束。
429、503、超时和业务拒绝分别占多少,是否被客户端正确识别。- 队列峰值、积压消退时间和隔离舱占用是否符合预算。
- 调用方重试是否遵守退避与总预算,有没有产生同步重试尖峰。
- 降压后吞吐、延迟和资源指标何时回到基线波动范围。
恢复阶段尤其容易被忽略。故障刚解除时,积压、重试和新流量会同时争抢容量;若控制器立刻放开全部额度,延迟可能再次越界,然后又触发收紧,形成反复振荡。比较稳妥的做法是逐步恢复放行量,并要求延迟、队列和饱和资源连续多个采样窗口处于安全区后再扩大。窗口数量和步长同样要靠实验确定,不写死成通用参数。
可以用下面的配置清单保存本轮决策,所有占位值都需要压测结果或业务契约支撑:
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 数字回答不了这些问题。边界说清楚了,过载才会从随机崩溃变成可以解释和演练的受控退化。