:限流的三个问题:在哪一层限、限谁、被限用户看到什么)
问题背景这是《高并发流量治理实战》系列的第一篇先把整个系列要回答的问题摆出来当流量远远超过系统能扛的量谁先死、谁后死、死多难看其实全部设计在治理这两个字里。系列后面十篇——限流算法、熔断、降级、热点 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_cost0.0forlayindefense_layers:passedmin(arrived,lay[capacity])blockedarrived-passed total_costarrived*lay[cost]# 到达本层的请求都消耗本层资源print(%-14s 窗口容量 %7d | 到达 %6d | 本层拦截 %6d | 放行 %6d%(lay[name],lay[capacity],arrived,blocked,passed))arrivedpassedreturntotal_costprint( 方案 A四层纵深各自限量 )cost_asimulate(layers,500000)print(总代价 %.0f%cost_a)print()print( 方案 B前两层不设限只在数据库连接池拦 )naive[dict(l,capacity500000)forlinlayers[:3]][layers[3]]cost_bsimulate(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,LIMIT10,3classUserLimiter:def__init__(self):self.hits{}# user - [窗口起点, 已用次数]deftry_acquire(self,user,now):winnow//WINDOW*WINDOW recself.hits.setdefault(user,[-1,0])ifrec[0]!win:rec[0],rec[1]win,0ifrec[1]LIMIT:returnFalse,(winWINDOW)-now# 本窗口结束还要等的秒数rec[1]1returnTrue,0defrender(client,ok,wait):ifok:return200 报名提交成功ifclient浏览器:return429 排队页:前方拥挤,%d 秒后自动刷新重试%waitifclientApp:return429 按钮置灰倒计时 %ds,本地静默重试%waitreturn429 {code:rate_limited,retry_after:%d}%wait limiterUserLimiter()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,waitlimiter.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 秒它进入新窗口立刻恢复放行。其二t13 时 runner_B 的请求被记进窗口 10 的账本与它 t5 那次窗口 0无关——固定窗口的跨窗口不结转既是特性也是下一篇要拆的坑。常见陷阱只在最内层设闸数据库或下游 RPC 独自扛限前面的所有层都在为被拒请求白烧资源本篇实验量化过代价差一个数量级。维度错配只限 IP 会误伤整个写字楼的 NAT 出口只限全局会被单个脚本用户吃光所有人的配额至少做到全局 用户双维度。429 被客户端 SDK 当 5xx 重试没有退避的自动重试会让被限流量指数放大所以响应里必须带 Retry-After且开放 API 文档要写清楚 429 不在重试白名单。限流账本无限增长每个用户一个键却不清理限流器先于业务 OOM键必须随窗口过期。落地清单画出请求路径上的所有层边缘/网关/应用/存储每层给出窗口容量且逐层收紧、与最内层总闸对齐至少配两个计数维度全局保护总量 用户或调用方保护公平所有被限响应统一正确状态码、Retry-After、诚实文案、机器可读错误体压测时验证被限用户的体验而不只是验证拦截率限流的位置、对象、体验三问有了答案但每一层的窗口容量具体用什么算法记账——固定窗口的边界突发、令牌桶的容量语义、漏桶的恒定速率——下一篇《高并发流量治理实战2令牌桶、漏桶、滑动窗口四种限流算法的实现与坑》逐一实现并踩坑。参考来源Wikipedia: Rate limiting: https://en.wikipedia.org/wiki/Rate_limitingRFC 6585: Additional HTTP Status Codes (429 Too Many Requests): https://datatracker.ietf.org/doc/html/rfc6585RFC 9110: HTTP Semantics (Retry-After 字段定义): https://www.rfc-editor.org/rfc/rfc9110nginx 官方文档: ngx_http_limit_req_module: https://nginx.org/en/docs/http/ngx_http_limit_req_module.htmlEnvoy 文档: Rate limit HTTP filter: https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/rate_limit_filter本系列已结集为免费专栏高并发流量治理实战从限流到全链路压测进阶推荐付费专栏提示词工程实战从入门到生产级 Prompt 设计限时 ¥19.9首篇免费试读