☰
高并发流量治理实战(1):限流的三个问题:在哪一层限、限谁、被限用户看到什么
2026/9/27 11:06:08 网站建设 项目流程

问题背景

这是《高并发流量治理实战》系列的第一篇,先把整个系列要回答的问题摆出来:当流量远远超过系统能扛的量,谁先死、谁后死、死多难看,其实全部设计在"治理"这两个字里。系列后面十篇——限流算法、熔断、降级、热点 Key、分布式 ID、缓存一致性、读写分离、全链路压测、大促复盘——都会挂在这篇建立的骨架上。我们用贯穿全系列的例子开场:成都马拉松开放报名,名额三万人,零点一过五十万人同时点提交。这一晚暴露的不是"算法不够精妙",而是三个更基本的问题:限流规则配在系统的哪一层?限的是谁——IP、用户、接口还是整个集群?被限住的那四十九万七千人,屏幕上看到的是什么?这三个问题答错了任何一个,哪怕用最正确的令牌桶,系统照样雪崩、用户照样投诉。本篇把三问逐一拆开,并用两个可复现的模拟实验量化"分层拦截"与"单层拦截"的代价差。

第一问:在哪一层限——纵深防线的经济学

请求从浏览器到数据库,大致经过边缘静态缓存、API 网关、应用接口、数据库连接池四层。每一层都会"消化"到达它的请求:边缘层最便宜,只查一次路由表;网关层要建连、解析 HTTP;应用层要跑业务代码、取连接;数据库层最贵,一个请求可能占着一个连接位和几十毫秒的磁盘 IO。于是得到限流的第一定律:同一个请求,拦得越早,代价越小。限流不是"挑一层配规则",而是每一层都配、且容量逐层收紧——上层放行量必须大于等于下层容量,否则下层形同虚设;下层容量则是整条链路的"总闸",反推上层应该配多少。

只在数据库层限流是最常见的错误配置:五万个请求全都要穿过网关、跑完应用逻辑、抢到一个连接,最后三万个发现排队超时。系统没被打挂,是被自己的"陪跑开销"拖死的。还有一个容易忽略的推论:如果某层(比如会话服务、风控服务)本身是共享的,那么哪怕请求最终被网关限掉,它也已经消耗了共享层的资源——所以静态资源和无状态校验要尽可能往边缘堆。

# 模拟:成都马拉松报名开放瞬间,50 万请求涌入的四层防线# 每层有自己的"窗口容量",请求逐层上递,超了当场拦下# cost = 在这一层拦下/处理一个请求的资源代价(带宽、CPU、连接位,量纲自定)layers=[{"name":"边缘静态缓存","capacity":500000,"cost":0.001},{"name":"API 网关","capacity":60000,"cost":0.01},{"name":"应用接口","capacity":20000,"cost":0.05},{"name":"数据库连接池","capacity":3000,"cost":0.30},]defsimulate(defense_layers,arrived):total_cost=0.0forlayindefense_layers:passed=min(arrived,lay["capacity"])blocked=arrived-passed total_cost+=arrived*lay["cost"]# 到达本层的请求都消耗本层资源print("%-14s 窗口容量 %7d | 到达 %6d | 本层拦截 %6d | 放行 %6d"%(lay["name"],lay["capacity"],arrived,blocked,passed))arrived=passedreturntotal_costprint("== 方案 A:四层纵深,各自限量 ==")cost_a=simulate(layers,500000)print("总代价 %.0f"%cost_a)print()print("== 方案 B:前两层不设限,只在数据库连接池拦 ==")naive=[dict(l,capacity=500000)forlinlayers[:3]]+[layers[3]]cost_b=simulate(naive,500000)print("总代价 %.0f"%cost_b)print("方案 B 是方案 A 的 %.1f 倍:拦截要趁早,越靠近源头的防线越便宜"%(cost_b/cost_a))

运行输出:

== 方案 A:四层纵深,各自限量 == 边缘静态缓存 窗口容量 500000 | 到达 500000 | 本层拦截 0 | 放行 500000 API 网关 窗口容量 60000 | 到达 500000 | 本层拦截 440000 | 放行 60000 应用接口 窗口容量 20000 | 到达 60000 | 本层拦截 40000 | 放行 20000 数据库连接池 窗口容量 3000 | 到达 20000 | 本层拦截 17000 | 放行 3000 总代价 14500 == 方案 B:前两层不设限,只在数据库连接池拦 == 边缘静态缓存 窗口容量 500000 | 到达 500000 | 本层拦截 0 | 放行 500000 API 网关 窗口容量 500000 | 到达 500000 | 本层拦截 0 | 放行 500000 应用接口 窗口容量 500000 | 到达 500000 | 本层拦截 0 | 放行 500000 数据库连接池 窗口容量 3000 | 到达 500000 | 本层拦截 497000 | 放行 3000 总代价 180500 方案 B 是方案 A 的 12.4 倍:拦截要趁早,越靠近源头的防线越便宜

同一批流量、同一个总闸(数据库 3000),仅因为拦截位置不同,整体代价差了十二倍多:方案 B 里网关和应用层白陪了五十万请求各跑一程,成本从 14500 涨到 180500。这不是模拟器的巧合,是真实的钱:网关多扛几倍的连接数、应用多烧几倍的 CPU,全都是为"注定被拒绝的请求"服务。

第二问:限谁——维度与账本

确定了"层层设闸",接下来是给每个闸选计数维度。常用的有四种,经常组合使用:全局维度保护总容量(集群 QPS 上限);接口维度保护热点路径(报名提交接口单独配额,查询接口另算);调用方维度分责任(每个 App 密钥、每个商户一个桶,防止一个大客户挤死别人);用户维度最贴近公平(每个注册用户、每个 IP 一个窗口)。选择的原则是"按伤害面计费":哪个维度上的过量请求会造成不可接受的伤害,就在哪个维度设闸。马拉松报名的伤害来自机器人批量刷提交,所以用户维度和接口维度是主力;开放 API 平台则是调用方维度优先。

限流器都是有状态的,记账方式决定行为:固定窗口简单但有两个窗口的边界叠加问题;滑动窗口日志精确但内存随请求数增长;滑动计数折中。这些算法的实现细节与各自的坑,正是下一篇的主题,这里只需记住工程结论——每个维度都要有账本,账本要有 TTL,否则"runner_A"们留下的键会把限流器的内存吃穿。

第三问:被限用户看到什么

限流器拦下请求之后做什么,是产品问题而不是算法问题。糟糕的做法:连接直接 reset、返回 500、或者白屏。正确的做法至少满足三条:状态码语义正确(HTTP 用 429 Too Many Requests,并在Retry-After头里给出建议重试秒数);文案诚实(“前方拥挤"而不是"系统错误”,别让用户去重启手机);给客户端可编程的区分(开放 API 必须返回机器可读的错误体,让调用方能自动退避)。下面模拟一个按用户记账的固定窗口限流器,同一个 429 对三类客户端分别落到排队页、置灰倒计时和结构化 JSON:

# 限谁:单用户固定窗口限流器(10 秒窗口内最多 3 次报名提交)# 被限看到什么:同一个 429,面向浏览器 / App / 开放 API 三类客户端给不同响应WINDOW,LIMIT=10,3classUserLimiter:def__init__(self):self.hits={}# user -> [窗口起点, 已用次数]deftry_acquire(self,user,now):win=now//WINDOW*WINDOW rec=self.hits.setdefault(user,[-1,0])ifrec[0]!=win:rec[0],rec[1]=win,0ifrec[1]>=LIMIT:returnFalse,(win+WINDOW)-now# 本窗口结束还要等的秒数rec[1]+=1returnTrue,0defrender(client,ok,wait):ifok:return"200 报名提交成功"ifclient=="浏览器":return"429 排队页:前方拥挤,%d 秒后自动刷新重试"%waitifclient=="App":return"429 按钮置灰+倒计时 %ds,本地静默重试"%waitreturn'429 {"code":"rate_limited","retry_after":%d}'%wait limiter=UserLimiter()events=[(1,"runner_A","浏览器"),(2,"runner_A","浏览器"),(3,"runner_A","浏览器"),(4,"runner_A","浏览器"),(5,"runner_B","App"),(6,"runner_B","开放API"),(12,"runner_A","浏览器"),(13,"runner_B","开放API"),]print("t(s) 用户 客户端 响应")fornow,user,clientinevents:ok,wait=limiter.try_acquire(user,now)print("%3d %-10s %-8s -> %s"%(now,user,client,render(client,ok,wait)))

运行输出:

t(s) 用户 客户端 响应 1 runner_A 浏览器 -> 200 报名提交成功 2 runner_A 浏览器 -> 200 报名提交成功 3 runner_A 浏览器 -> 200 报名提交成功 4 runner_A 浏览器 -> 429 排队页:前方拥挤,6 秒后自动刷新重试 5 runner_B App -> 200 报名提交成功 6 runner_B 开放API -> 200 报名提交成功 12 runner_A 浏览器 -> 200 报名提交成功 13 runner_B 开放API -> 200 报名提交成功

注意两处细节。其一,runner_A 第 4 秒被限,提示"6 秒后自动刷新"——这个数字来自窗口记账本(10 秒窗口起点 0,结束于 10),比"请稍后再试"诚实得多;第 12 秒它进入新窗口,立刻恢复放行。其二,t=13 时 runner_B 的请求被记进窗口 10 的账本,与它 t=5 那次(窗口 0)无关——固定窗口的"跨窗口不结转"既是特性也是下一篇要拆的坑。

常见陷阱

  • 只在最内层设闸:数据库或下游 RPC 独自扛限,前面的所有层都在为被拒请求白烧资源,本篇实验量化过,代价差一个数量级。
  • 维度错配:只限 IP 会误伤整个写字楼的 NAT 出口;只限全局会被单个脚本用户吃光所有人的配额;至少做到"全局 + 用户"双维度。
  • 429 被客户端 SDK 当 5xx 重试:没有退避的自动重试会让被限流量指数放大,所以响应里必须带 Retry-After,且开放 API 文档要写清楚 429 不在重试白名单。
  • 限流账本无限增长:每个用户一个键却不清理,限流器先于业务 OOM;键必须随窗口过期。

落地清单

  • 画出请求路径上的所有层(边缘/网关/应用/存储),每层给出窗口容量,且逐层收紧、与最内层总闸对齐
  • 至少配两个计数维度:全局保护总量 + 用户或调用方保护公平
  • 所有被限响应统一:正确状态码、Retry-After、诚实文案、机器可读错误体
  • 压测时验证"被限用户的体验"而不只是验证拦截率

限流的位置、对象、体验三问有了答案,但每一层的"窗口容量"具体用什么算法记账——固定窗口的边界突发、令牌桶的容量语义、漏桶的恒定速率——下一篇《高并发流量治理实战(2):令牌桶、漏桶、滑动窗口:四种限流算法的实现与坑》逐一实现并踩坑。

参考来源

  • Wikipedia: Rate limiting: https://en.wikipedia.org/wiki/Rate_limiting
  • RFC 6585: Additional HTTP Status Codes (429 Too Many Requests): https://datatracker.ietf.org/doc/html/rfc6585
  • RFC 9110: HTTP Semantics (Retry-After 字段定义): https://www.rfc-editor.org/rfc/rfc9110
  • nginx 官方文档: ngx_http_limit_req_module: https://nginx.org/en/docs/http/ngx_http_limit_req_module.html
  • Envoy 文档: Rate limit HTTP filter: https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/rate_limit_filter

本系列已结集为免费专栏:高并发流量治理实战:从限流到全链路压测;进阶推荐付费专栏:提示词工程实战:从入门到生产级 Prompt 设计(限时 ¥19.9,首篇免费试读)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询