网关限流、配额与韧性策略:从一次拒绝到容量闭环
一次促销把网关扩到四个副本,单实例“每秒一百次”的限流规则仍然全部生效,后端却比扩容前更早过载。规则没有失效:每个副本都独立允许一百次,总入口额度随着副本数变成了近似四倍。团队看到的 429 只占少量请求,却没有记录请求落在哪个副本、计数键是什么、全局允许量是多少。
另一次故障来自“提高成功率”的重试。网关对超时请求自动再试两次,异常实例又没有及时摘除,一个客户端请求变成三个上游尝试;支付接口没有可靠幂等键,于是客户端收到 504,后台却落了两笔订单。限流、配额、并发、超时、重试、熔断与异常摘除都在保护容量,但它们控制的是不同状态,合成一个“韧性开关”只会隐藏放大路径。
先给每个保护器一个状态边界
本地限流在单个数据面进程或副本内保存令牌、漏桶或窗口计数,响应快、依赖少,适合保护该实例。它不天然提供集群总额度;副本、连接亲和、重启和配置传播都会改变观察结果。
全局限流把 descriptor 发送给共享计数服务,按消费者、route、租户、套餐或其他受控维度统一决定。以 Envoy 全局 rate limit filter 为例,匹配规则生成 descriptor,再由独立服务作出限流结论。它能表达集群总额度,却增加网络跳转、状态存储、一致性和热点键成本。限流服务不可达时是放行还是拒绝必须显式配置,并单独记录;不能从产品名推断默认值。
配额描述较长周期内可消费的总量,例如一个订阅周期的请求数、字节数或业务单位。它需要稳定消费者和周期结算,不能用一个短窗口令牌桶冒充。限流控制瞬时速率,配额控制累计权益;配额耗尽后的通知、续期和人工调整也属于消费者生命周期。
并发限制控制同时占用资源的请求数或连接数。即使 RPS 很低,慢请求、流式响应、WebSocket 和大请求体也可能耗尽并发、连接或内存。并发达到上限后应明确是立即拒绝还是排队,队列长度与等待年龄本身也要受界限保护。
总请求超时限制客户端请求在网关内可花费的全部时间,后端或单次尝试超时限制一次上游尝试。单次后端超时不得大于总预算;DNS、建连、TLS、排队、授权、限流、重试退避和响应传输都会消费总预算。Gateway API 的 HTTPRoute 参考 也把 request 与 backendRequest 作为不同超时表达;具体控制器是否支持对应字段仍需按目标版本验证。只增加 upstream timeout,可能让更多请求长期占住连接而降低整体吞吐。
重试是新的上游尝试,不是时间倒流。它必须同时满足可重试错误、剩余总预算、单次预算、重试次数/令牌预算和操作幂等性。熔断限制连接、待处理请求、活动请求或重试等资源,快速拒绝以阻止级联;异常实例摘除根据连接失败、超时、5xx 或成功率等被动信号暂时移出负载均衡池。二者都不是业务补偿,也不能证明写操作没有重复。
算法决定允许什么突发,也决定怎样误伤
固定窗口把时间切成离散区间并在每个区间累计。实现成本低,但边界两侧可以在很短时间内通过接近两倍额度;如果“一分钟 600 次”被用来保护只能承受每秒 10 次的后端,窗口平均值会掩盖瞬时过载。滑动日志保存窗口内每个事件,结果精确但状态与清理成本随流量增长;滑动窗口计数用相邻桶加权近似,降低成本,也引入边界误差。选型时要把允许误差、键基数、窗口长度、时钟来源和过期方式一起固定,而不是只写一个 requests_per_unit。
令牌桶以速率 r 补充令牌,桶容量 b 决定可吸收的突发;长期平均不超过 r,但空闲后最多可瞬时放行 b。它适合允许短时突发的交互流量。漏桶以稳定速率排出队列,更接近整形,但会引入等待;队列满时必须拒绝,且排队时间仍消耗总请求预算。只需要保护在途资源时,信号量式并发限制比 RPS 更直接:允许量应由后端并发容量、平均/尾部耗时和队列预算推导,不能把“每秒 100 次”等同于“最多 100 个在途请求”。
分布式实现还要回答一次消费何时算成功。强一致共享计数能减少超发,却把每次请求耦合到状态服务延迟和可用性;分片、本地预取令牌或最终一致复制降低延迟,却允许一定超发。若每个数据面预取 p 个令牌,最坏未回收额度至少要按活跃副本数乘以 p 预算;扩缩容、网络分区和进程崩溃都会改变这个窗口。时钟不能成为隐含仲裁者:依赖客户端墙钟的窗口容易受漂移和回拨影响,应由权威计数服务确定周期,或明确采用单调时钟和可接受误差。
配额不是更大的限流窗口。月度调用权益、字节额度或计费业务单位应落到带周期 ID、消费事件 ID、消费者 ID、单位和策略 revision 的账本;重复请求通过幂等消费键去重,套餐变更通过调整事件而不是覆盖历史计数。网关的在线计数可以快速拒绝,但财务、合同或硬权益的最终结算必须有可对账的权威记录。否则 fail-open、重试、延迟复制和人工补额之后,团队既无法证明用户消费了多少,也无法安全退款或补偿。
一个请求如何消耗容量
容量模型至少包含客户端请求率 R、平均上游尝试数 A、请求占用时间 L 和每次尝试资源成本 C。上游压力近似为 R × A,在途请求近似为 R × L。当重试让 A 从 1 变成 3,或超时让 L 拉长,入口 RPS 不变也会耗尽连接、线程、内存和后端容量。
本地限流总额度则受副本数和流量分布影响。四个各自允许 N 的副本,均匀分布时总允许量接近 4N;热点副本会先拒绝,冷副本额度却用不完。需要严格消费者总额度时,应使用共享计数或在入口固定分片,并接受对应状态服务成本。
descriptor 不能直接使用高基数、不可信原值。把完整 URL、随机 request ID 或任意 Header 放进计数键,会制造无界状态和绕过空间。常见键由受信消费者 ID、稳定 route ID、套餐和少量业务维度组成;IP 只适合特定滥用保护,不能替代消费者身份。
用隔离实验看见本地额度和重试放大
先用 Python 3 标准库在回环地址启动两个上游、两个网关入口和一个共享计数服务。同一脚本可以切换每副本本地桶与共享桶;上游的 /flaky 会让每个幂等键第一次返回 503,第二次成功。这样可以直接观察副本扩容怎样改变本地总额度、共享依赖故障怎样触发 fail-close/fail-open,以及一次客户端请求怎样放大为多次上游尝试。产品字段名、算法精度和默认策略并不通用,生产配置仍需重放同一组判据。
将代码保存到临时目录的 resilience_lab.py:
import json
import os
import threading
import time
import urllib.error
import urllib.request
from collections import defaultdict
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ATTEMPTS = defaultdict(int)
def reply(handler, status, body, extra=None):
data = json.dumps(body).encode()
handler.send_response(status)
handler.send_header("content-type", "application/json")
handler.send_header("content-length", str(len(data)))
for key, value in (extra or {}).items():
handler.send_header(key, value)
handler.end_headers()
try:
handler.wfile.write(data)
except (BrokenPipeError, ConnectionAbortedError):
# The gateway may have exhausted its per-try budget and closed first.
pass
class Upstream(BaseHTTPRequestHandler):
instance = "unset"
def do_GET(self):
key = self.headers.get("idempotency-key", "missing")
ATTEMPTS[(self.instance, key)] += 1
attempt = ATTEMPTS[(self.instance, key)]
if self.path == "/slow":
time.sleep(0.35)
if self.path == "/flaky" and attempt == 1:
return reply(self, 503, {"instance": self.instance, "attempt": attempt})
reply(self, 200, {"instance": self.instance, "attempt": attempt, "key": key})
def do_POST(self):
reply(self, 409, {"detail": "write_retry_requires_idempotency_contract"})
def log_message(self, *_):
pass
class Blue(Upstream):
instance = "blue"
class Green(Upstream):
instance = "green"
class Counter(BaseHTTPRequestHandler):
tokens = 2
reset_at = time.monotonic() + 60
lock = threading.Lock()
def do_POST(self):
with self.lock:
if time.monotonic() >= self.reset_at:
type(self).tokens = 2
type(self).reset_at = time.monotonic() + 60
if type(self).tokens <= 0:
return reply(self, 429, {"detail": "global_rate_limited", "remaining": 0})
type(self).tokens -= 1
remaining = type(self).tokens
reply(self, 200, {"allowed": True, "remaining": remaining, "counter_revision": "counter-r1"})
def log_message(self, *_):
pass
def gateway_handler(name, upstream_port):
class Gateway(BaseHTTPRequestHandler):
tokens = 2
reset_at = time.monotonic() + 60
lock = threading.Lock()
def do_GET(self):
limit_scope = "local"
bypassed = False
if os.environ.get("GLOBAL_LIMIT") == "1":
limit_scope = "global"
request = urllib.request.Request("http://127.0.0.1:18170/check", data=b"{}", method="POST")
try:
with urllib.request.urlopen(request, timeout=0.1) as response:
json.load(response)
except urllib.error.HTTPError as error:
if error.code == 429:
return reply(self, 429, {"gateway": name, "detail": "global_rate_limited"})
return reply(self, 503, {"gateway": name, "detail": "rate_limit_dependency_error"})
except Exception:
if os.environ.get("RATE_FAIL_OPEN") != "1":
return reply(self, 503, {"gateway": name, "detail": "rate_limit_dependency_unavailable"})
bypassed = True
else:
with self.lock:
if time.monotonic() >= self.reset_at:
type(self).tokens = 2
type(self).reset_at = time.monotonic() + 60
if type(self).tokens <= 0:
return reply(self, 429, {"gateway": name, "detail": "local_rate_limited"})
type(self).tokens -= 1
key = self.headers.get("idempotency-key")
deadline = time.monotonic() + 0.5
last_status = 503
for attempt in (1, 2):
remaining = deadline - time.monotonic()
if remaining <= 0:
break
request = urllib.request.Request(
f"http://127.0.0.1:{upstream_port}{self.path}",
headers={"idempotency-key": key or "missing"}
)
try:
with urllib.request.urlopen(request, timeout=min(0.2, remaining)) as response:
body = json.load(response)
body.update({
"gateway": name,
"gateway_attempts": attempt,
"limit_scope": limit_scope,
"rate_limit_bypassed": bypassed,
})
return reply(self, 200, body, {"x-gateway-attempts": str(attempt)})
except urllib.error.HTTPError as error:
last_status = error.code
if error.code not in (502, 503, 504) or not key:
break
except TimeoutError:
last_status = 504
if not key:
break
reply(self, last_status, {"gateway": name, "detail": "attempts_exhausted"})
def do_POST(self):
reply(self, 409, {"gateway": name, "detail": "automatic_write_retry_disabled"})
def log_message(self, *_):
pass
return Gateway
def serve(port, handler):
ThreadingHTTPServer(("127.0.0.1", port), handler).serve_forever()
if os.environ.get("COUNTER_DISABLED") != "1":
threading.Thread(target=serve, args=(18170, Counter), daemon=True).start()
for args in ((18181, Blue), (18182, Green),
(18180, gateway_handler("gw-a", 18181)),
(18190, gateway_handler("gw-b", 18182))):
threading.Thread(target=serve, args=args, daemon=True).start()
while True:
time.sleep(3600)启动实验并分别打满两个网关的本地桶:
python3 resilience_lab.py
for port in 18180 18180 18180 18190 18190 18190; do
curl -sS -w ' status=%{http_code}\n' "http://127.0.0.1:${port}/ok"
done预期 gw-a 与 gw-b 各自前两次为 200、第三次为 429。单副本额度是 2,两个副本合计却允许 4;如果生产要求消费者全局只能使用 2,就必须引入共享计数,不能靠复制相同本地配置实现。
停止脚本并以共享计数模式重启,再向两个入口交替发送四次请求:
GLOBAL_LIMIT=1 python3 resilience_lab.py
for port in 18180 18190 18180 18190; do
curl -sS -w ' status=%{http_code}\n' "http://127.0.0.1:${port}/ok"
doneWindows PowerShell 的等价操作是:
$env:GLOBAL_LIMIT='1'; Remove-Item Env:COUNTER_DISABLED,Env:RATE_FAIL_OPEN -ErrorAction Ignore
python resilience_lab.py
18180,18190,18180,18190 | ForEach-Object {
curl.exe -sS -w " status=%{http_code}`n" "http://127.0.0.1:$_/ok"
}预期只有前两次为 200,后两次无论落到哪个入口都返回 429 global_rate_limited。这一次额度状态由 18170 的共享 Counter 持有,网关副本只消费结论;证据应同时保留消费者与 route 组成的 descriptor、数据面 ID、counter revision、窗口边界和允许/拒绝结果。实验为简洁只用了一个固定键,生产绝不能把所有消费者压进同一个桶。
再停止脚本,关闭共享 Counter,先验证 fail-close,再验证 fail-open。每条启动命令都要在上一进程停止后执行:
GLOBAL_LIMIT=1 COUNTER_DISABLED=1 python3 resilience_lab.py
GLOBAL_LIMIT=1 COUNTER_DISABLED=1 RATE_FAIL_OPEN=1 python3 resilience_lab.py$env:GLOBAL_LIMIT='1'; $env:COUNTER_DISABLED='1'; Remove-Item Env:RATE_FAIL_OPEN -ErrorAction Ignore
python resilience_lab.py
$env:RATE_FAIL_OPEN='1'; python resilience_lab.py请求任一入口时,fail-close 应返回 503 rate_limit_dependency_unavailable,没有上游尝试;fail-open 应返回 200,同时出现 limit_scope=global 与 rate_limit_bypassed=true。旁路后的潜在超额量无法由失联计数器推导,必须由网关独立计数。写入、管理、资金和硬容量保护路径默认应 fail-close;只有明确评估过的低风险 route 才能限时 fail-open,并配置自动到期。
清除 GLOBAL_LIMIT、COUNTER_DISABLED 和 RATE_FAIL_OPEN 后重启脚本,再验证重试:
curl -sS -i http://127.0.0.1:18180/flaky \
-H 'Idempotency-Key: order-demo-7'
curl -sS -i http://127.0.0.1:18190/flaky
curl -sS -i -X POST http://127.0.0.1:18180/orders第一条应返回 200、gateway_attempts=2,上游 attempt=2,证明一个客户端请求产生两次上游尝试;第二条没有幂等键,不重试并返回首次 503;写请求返回 409 automatic_write_retry_disabled。模型只把幂等键作为允许重试的必要条件,真实项目还必须由上游在共享持久化状态中原子保存键、请求摘要、处理中状态和最终结果,才能让任一副本对重复请求返回同一业务结果。若每个上游副本只在内存里记键,换副本后仍会重复执行。
访问 /slow 时,单次后端超时是 0.2s,总预算是 0.5s。预期无幂等键的请求在第一次超时后返回 504,而不是把连接无限占住。真实网关还应输出总耗时、每次尝试耗时、剩余预算和 response-code detail。
结束时按 Ctrl+C,清除三个环境变量,确认 18170、18180、18190、18181、18182 均停止监听,再删除临时脚本。实验若连接了外部共享存储,还要按测试命名空间删除 descriptor、计数键和持久卷;若使用云网关,还要删除测试 stage、日志、告警和计费资源。清理后的证据是端口不再监听、测试键查询为空、临时身份已撤销且后续账单或用量中不再出现对应资源。
全局限流与配额要验证一致性窗口
目标网关的全局实验应让两个数据面同时使用同一个消费者和 route,交替发送超过额度的请求。证据至少包括 descriptor、允许/拒绝结果、计数服务 revision、数据面 ID 和窗口边界。任一匹配 descriptor 超限时,是拒绝整个请求还是只命中某级规则,要按实现验证。
随后断开一个数据面到限流服务的网络。fail-close 应产生明确依赖错误或受控拒绝;fail-open 会继续放行,并记录旁路次数、旁路持续时间、受影响消费者和潜在超额量。以 Envoy 的全局限流过滤器为例,failure_mode_deny=true 才会在限流服务通信失败时拒绝;通信错误与真正的 over-limit 必须使用不同响应细节和指标,不能把依赖故障伪装成 429。恢复连接后不要假设本地放行量自动补记,必须验证实现是否支持对账;不支持时,以独立旁路事件回补账本。严格财务权益通常不能只靠最终一致的在线计数器,需由业务账本承担权威结算。
配额实验跨越一个缩短的测试周期,验证消费、耗尽、周期重置、套餐变更和重复请求。计数单位必须稳定:一次客户端请求、一次上游尝试、一个成功响应还是业务单位,含义完全不同。网关配额通常按客户端请求记账;重试若也扣配额,必须向消费者明确,否则网关内部故障会消耗用户权益。
共享状态带来容量成本。计数服务要预算 QPS、热点消费者、键数量、窗口过期、复制延迟、网络超时和故障恢复;同步一致性越强,路径延迟和故障耦合通常越高。超高基数 descriptor、长周期精确计数和多区域强一致不能作为免费能力写进方案。
超时、重试、熔断和摘除要共同算账
为每条 route 画出总预算,而不是只填一个 timeout:
总请求预算
= 入口排队
+ 身份/限流策略
+ 第一次上游尝试
+ 退避
+ 后续尝试
+ 响应回传余量若单次后端超时为 T、最多尝试 A 次,最坏上游等待接近 A × T + 退避,必须小于总预算。客户端超时还应大于或等于网关总预算加网络余量,否则客户端先断开,网关仍可能继续尝试。多层代理都重试会乘法放大,通常只选最了解幂等性和剩余预算的一层负责。
重试条件只包含瞬态且可安全重放的失败。连接失败、reset、部分 5xx 或 per-try timeout 是否可重试,要结合请求体是否已发送、协议和业务合同。POST 并不天然非幂等,GET 也不保证无副作用;可靠判断来自幂等键、业务去重状态和结果重放。
熔断阈值应保护下游容量,而不是追求零拒绝。最大连接、pending、active request 和 retry 数达到边界时,网关要快速失败并给出本地拒绝证据。队列越长不代表越可靠,它会增加等待年龄并消耗总超时。判断效果要看拒绝数、队列年龄、在途请求和恢复时间,不只看平均延迟。
异常摘除用被动结果调整端点可用集合。Envoy outlier detection 将连续错误与成功率类检测分开,连接错误、本地超时和外部 5xx 也应分别取证;成功率检测在请求样本不足时可能根本不执行。摘除比例、最短保留端点和恢复探测要防止整个池被清空。摘除后剩余端点承受更高负载,可能继续失败,因此必须把端点健康与剩余容量一起观察。
项目接入从策略合同和预算开始
每条 API 在接入网关前提交稳定 route ID、消费者维度、瞬时速率、突发容量、长周期配额、最大并发、排队策略、总请求预算、单次后端预算、允许重试的错误、最大尝试数、幂等合同、熔断资源和异常摘除信号。演示阈值不能直接进入生产,生产值由流量基线、SLO、后端容量和成本预算共同决定。
配置发布先观察候选策略:记录“若启用会拒绝”的请求、候选 descriptor 与当前实际结果,不直接阻断。确认维度不会误伤共享 NAT、批处理或大客户后,再按消费者或 route 小流量启用。限流、超时、重试和熔断应分批改变,每次只改变一个变量,才能把失败证据归因到具体策略。
请求证据至少包含 request ID、消费者、route、数据面 ID、限流范围、descriptor 摘要、剩余/重置提示、配额周期、排队时间、总耗时、上游端点、尝试次数、每次结果、熔断/摘除细节和配置 revision。不要记录原始授权 Header、完整 query 或可被攻击者控制的高基数字段。
容量评审同时计算网关 CPU/内存/连接、授权与限流依赖、上游尝试量、日志和 Trace 存储、跨区流量以及托管调用费用。重试增加后端与出站成本,全局计数增加状态服务成本,细粒度 descriptor 增加存储基数,长超时增加在途资源;这些都是配置影响,不是部署后的意外。
按拒绝来源排查,而不是见错就放宽
遇到 429,先确定拒绝者是边缘、网关本地 filter、全局限流服务、云平台还是上游,再核对消费者、route、descriptor、窗口、数据面 ID 和配置 revision。本地限流若随副本变化,检查流量分布与每副本桶;全局限流若节点结果不一,检查计数服务连接、fail-open、复制延迟和时钟/窗口边界。
遇到 503,区分无健康端点、熔断拒绝、全局策略依赖故障和上游真实 503。网关本地拒绝通常没有 upstream status;上游返回则应有端点和尝试证据。异常摘除导致池缩小时,查看触发信号、已摘除端点、剩余容量和恢复时间。
遇到 504 或客户端超时,按总预算时间线还原排队、策略调用、每次后端尝试和退避。检查客户端是否先取消、网关是否在取消后仍继续请求上游、是否多层重试,以及慢请求是否占满并发。不要先统一扩大 timeout;这可能把可见超时换成更隐蔽的连接耗尽。
看到重复写入,立即关联幂等键、请求摘要、客户端 request ID、网关尝试次数和业务结果记录。网关只能限制是否重试,不能替应用实现事务去重。若没有可靠幂等合同,先关闭该 route 的自动重试,再处理重复副作用与补偿。
回滚、清理与团队治理
回滚按放大风险排序:先停止不安全重试或 fail-open,再恢复上一版总/后端超时、熔断与摘除参数,最后回退限流和配额。每一步都验证数据面 revision、客户端结果、上游尝试数和在途资源。直接删除全局规则可能瞬间放开全部流量,应先切回已知安全 revision 或放置临时保守上限。
隔离实验清理后,还要删除测试消费者、配额周期、共享计数键、告警静默、临时日志与 Trace。生产变更结束时撤销调试 Header 和旁路规则,确认无旧数据面继续使用旧策略;云环境通过资源清单和后续用量证明不再计费,不能只删除本地配置文件。
平台团队维护策略实现、共享计数服务和发布门禁,服务团队提供容量、幂等与错误语义,产品/商务团队拥有消费者套餐和配额变更,SRE 维护 SLO、告警与事故旁路。任何提高额度、扩大超时、开启重试或 fail-open 的紧急动作都要有 owner、自动到期和回滚证据。
上线门禁最终应能重放四类事实:单副本与多副本的允许量符合所选计数范围;全局依赖故障按 route 级策略处理;重试后的上游尝试数受总预算和幂等合同约束;熔断与摘除能保护容量且恢复可解释。只有这些状态能被逐层证明,429、503 和 504 才是可诊断的保护结果,而不是新的故障来源。
