负载削减是在全部接收工作会威胁服务时,主动拒绝其中一部分。目标是保留容量,让仍有机会完成的工作继续进行,而不是保证每个获准请求都足够快、都足够重要。拒绝本身也会消耗资源,因此在哪个阶段做准入判断,以及拒绝路径有多贵,与阈值大小同样重要。
保护还能产生价值的容量
过载队列既占内存,也增加等待时间。客户端可能在任务被工作线程取走之前就已经放弃,服务器却继续为无人等待的答案花费资源。更长的队列可以吸收某些突发,但在需求长期超过处理能力时,它不会创造额外容量,只会推迟暴露问题。
Google SRE 讨论了队列限制、资源压力信号、选择性拒绝和降低响应质量。削减与降级相关,但并不相同:拒绝一个请求,不等于完成一个较便宜的版本。后者必须有清楚的产品契约,说明剩下的结果仍有什么价值,不能只是悄悄少做一部分工作。
不同保护机制也可能针对不同组件。Envoy 过载管理器保护代理自身资源,而它的熔断机制主要保护上游服务。依赖熔断器不会自动解决本地内存、连接或文件描述符耗尽,所以不能只看到请求被快速拒绝,就认定整个入口已经免于过载。
选择有依据的政策
按优先级拒绝,可以保护关键业务路径,但 HTTP 方法或是否容易重试,本身并不能决定重要性。一次读取可能非常关键,一次写入也可能只是允许丢弃的遥测。应使用可信的应用分类,而不是任由调用方把自己的所有请求都声明成最高优先级。
统一准入上限或按比例拒绝,也可以是合理政策,不能因为没有业务优先级就一概认为它们错误。需要明确公平性与容量之间的取舍,包括低优先级工作是否可能长期得不到执行,以及不同租户是否共用同一个限制。拒绝顺序应该服务于真实契约,而不是凭方法名称猜测。
请求数量、字节、执行时间和 CPU 成本是不同预算。只限制三个任务,如果每个任务都可以保留任意大的正文,仍然不能约束内存。应选择与受保护资源对应的信号,并通过真实请求组合验证行为,不能把单一计数上限解释成对所有资源的统一保证。
限制准入,而不只是线程数量
线程池的工作线程上限约束同时执行的任务数,不一定限制已经接受、正在排队的工作。只在工作线程内部获取信号量,会让它前面的队列继续增长。应在昂贵工作提交之前做有界准入,并在任务生命周期真正结束时归还容量,而不是等到执行阶段才开始计数。
名称类似的代理限制,也需要精确理解。NGINX limit_conn 按文档规定的连接规则计数;在 HTTP/2、HTTP/3 中,每个并发请求都被单独计入。不能默认某个“连接数上限”始终只是在统计 TCP socket,否则复用连接下的真实请求并发可能与预想完全不同。
下面的实验同时约束排队和运行中的任务。被拒提交不会创建 future;完成回调会在成功、失败或执行前取消后归还名额,而向已经关闭的执行器提交失败,也会归还尚未使用的名额。这样准入预算对应完整生命周期,而不是只覆盖正常结束的路径。
坏掉的样子
如果已经解析巨大正文、打开数据库连接或执行了业务写入,才开始拒绝,可能已经来不及保护目标资源。昂贵的日志和错误响应生成,也可能让拒绝路径本身过载。应一起观察拒绝成本、获准吞吐量、排队时长、活动工作量与资源压力,而不是仅统计拒绝了多少次。
HTTP 503 可以表达服务暂时不可用,429 则表示调用方在某种政策下请求过多。Retry-After 只是等待信号,不证明重放写操作安全,也不保证之后一定存在容量。立即重试可能把刚削减的负载原样加回来,因此拒绝与客户端行为必须放在同一条链路中理解。
不要仅因为调用方停止等待,就提前回收仍在运行任务的名额。等待 future 结果超时,不会终止工作线程。提前归还会让新任务与实际上尚未结束的旧任务重叠,破坏原先承诺的容量上限;资源记账应以工作真实结束为依据。
快速验证
保存为 shedding_check.py,用 Python 3.10 以上版本执行 python3 shedding_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。真实线程等待测试协调事件,不访问外部服务,也不制造生产负载。前后两种配置接收的是同样十二个任务的突发,用来比较获准与等待数量。
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier, BoundedSemaphore, Event, Lock
def exercise(gated):
slots = BoundedSemaphore(3) # Bounds queued plus running work, not just threads.
started, release, lock = Barrier(3), Event(), Lock()
running, began = set(), []
def held(job):
with lock:
running.add(job)
began.append(job)
try:
if job < 2:
started.wait(timeout=5)
assert release.wait(timeout=5), "test coordinator did not release workers"
return job
finally:
with lock:
running.remove(job)
with ThreadPoolExecutor(max_workers=2) as pool:
def submit(function, *args):
if not gated:
return pool.submit(function, *args)
if not slots.acquire(blocking=False):
return None # Explicit local admission refusal; no future was enqueued.
try:
future = pool.submit(function, *args)
except RuntimeError:
slots.release()
raise
# Completion, failure, and cancellation before execution all return the slot.
future.add_done_callback(lambda _: slots.release())
return future
futures = [submit(held, job) for job in range(12)]
accepted = [future for future in futures if future is not None]
replacement = None
try:
started.wait(timeout=5)
with lock:
assert running == {0, 1} and set(began) == {0, 1}
assert all(not future.done() for future in accepted)
if gated:
assert len(accepted) == 3 and futures[3:] == [None] * 9
assert futures[2].cancel() # Both workers are held, so job 2 has not started.
replacement = submit(held, 12)
assert replacement is not None
assert submit(held, 13) is None
print("gated burst: 2 running + 1 queued, 9 initial refusals; cancellation returns a slot")
else:
assert len(accepted) == 12
print("ungated burst: 2 workers accept 12 unfinished tasks; the other 10 still wait")
finally:
release.set()
completed = [future.result(timeout=5) for future in accepted if not future.cancelled()]
if replacement is not None:
completed.append(replacement.result(timeout=5))
assert sorted(completed) == ([0, 1, 12] if gated else list(range(12)))
if gated:
def fail():
raise ValueError("injected task failure")
failed = submit(fail)
assert failed is not None
try:
failed.result(timeout=5)
except ValueError as error:
assert str(error) == "injected task failure"
else:
raise AssertionError("the failed task must preserve its failure")
if gated:
# The executor is now closed; a failed submission must not consume a slot.
try:
submit(lambda: None)
except RuntimeError:
pass
else:
raise AssertionError("submission to a closed executor must fail")
acquired = [slots.acquire(blocking=False) for _ in range(3)]
try:
assert acquired == [True, True, True]
assert not slots.acquire(blocking=False)
finally:
for success in acquired:
if success:
slots.release()
print("completion, task failure, cancellation, and closed-executor submission leave all 3 slots reusable")
exercise(False)
exercise(True)
只有两个工作线程、没有准入门槛时,十二个任务都仍未完成:两个正在运行,十个在等待。增加三个名额后,只接收三个任务,其他九个在提交之前被拒绝。这里获准的是两个运行任务加一个排队任务,不是同时运行三个线程,断言明确区分了两种数量。
取消还在排队的任务,会归还一个名额供替代任务使用,紧接着再提交一个则仍会被拒。协调事件释放线程后,预期任务完成。注入的任务异常和执行器关闭后的提交失败,都保留各自错误;最后通过公开信号量操作确认,三个名额全部可以再次使用。
实验验证的是准入记账与任务生命周期,不是 HTTP 响应、字节限制、优先级调度、生产延迟或自动资源压力控制器。NGINX、Envoy 只作为语义来源被引用,没有在本例中部署或执行。把该机制用于真实服务之前,仍需针对自己的保护对象验证容量与失败路径。
延伸阅读
来源
- Google SRE:处理过载,关键性、资源信号、拒绝成本与客户端行为,已核验 2026-09-08。
- Google SRE:处理级联故障,队列上限、负载削减、截止期限与降级,已核验 2026-09-08。
- Envoy 过载管理器,本地资源保护与上游熔断的区别,已核验 2026-09-08。
- NGINX 连接限制,请求计数、HTTP/2–HTTP/3 语义与拒绝状态,已核验 2026-09-08。
- Python futures,任务提交、结果等待超时、取消与完成回调,已核验 2026-09-08。
- Python 线程工具,非阻塞有界信号量准入与协调原语,已核验 2026-09-08。
- RFC 9110,服务不可用、Retry-After 与重试安全语义,已核验 2026-09-08。
- RFC 6585,第四节单独定义 429 与服务器决定的计数政策,已核验 2026-09-08。