限流:从单机QPS计数器到分布式三层防御体系
大家好,我是程序员小策。
先说一个反直觉的事实:加了限流之后,你系统的成功请求数量反而可能变多。
听起来很荒诞对吧?限流的字面意思就是"拦住一部分请求",拦住了怎么可能变多?
但数据不会骗人:
| 场景 | 总请求数 | 成功数 | 成功率 |
|---|---|---|---|
| 不限流 | 50000 | 0 | 0% |
| 加了限流 | 50000 | 5000 | 10% |
不限流的时候,50000 个请求全部涌入数据库,连接池打满,超时重试又制造了一倍流量,雪崩导致所有接口全部失败——包括那些只想来浏览商品页的正常用户。
加了限流之后,50000 个请求里被拦掉了 45000 个,但这 45000 个被拦掉的人里,有 40000 个是恶意刷量的机器人。剩下的 5000 个真实用户的请求全部成功处理了。
限流不是在减少你的业务量——它是在帮你把有限的资源分配给真正有价值的那部分请求。
这就引出了今天要聊的问题:限流到底该怎么做?在什么位置做?用什么算法?限不住了怎么办?
问题定义:限流不是"不让用",是"让大家都能用一点"
很多人对限流有一个误解:限流 = 拒绝用户请求。
那是错的。限流的本质是用拒绝一小部分请求的方式,保证大部分请求能正常服务。
想象两个场景:
场景 A(没限流):秒杀开始了,QPS 从 1000 飙到 50000。数据库连接池打满,服务开始超时。调用方的超时重试又制造了一倍流量。最后所有请求全部失败——包括只想来浏览商品页的用户。
场景 B(有限流):秒杀开始了,网关层限流 5000 QPS。前 5000 个请求被秒杀服务正常处理,第 5001 个收到"活动太火爆,请稍后再试"。浏览商品页的用户完全无感——因为秒杀接口被隔离限流了,不影响其他接口。
场景 A 的结局:50000 个请求,0 个成功。场景 B 的结局:50000 个请求,5000 个成功。
限流的"残忍"是为了让系统"仁慈"——对一部分人残忍,才能对大部分人仁慈。
限流(Rate Limiting):通过控制单位时间内进入系统的请求数量,防止系统被突发流量冲垮。它不是拒绝服务,而是用有限的拒绝换取整体的可用性。
核心概念:用"药店限购"理解限流
疫情期间你去药店买退烧药,店员告诉你:每人每天最多买 2 盒。
这就是限流。
拆开看,这里面包含了限流的所有核心要素:
- 限流对象:每个买药的人(对应系统中的"每个用户 IP / 每个接口")
- 限流阈值:2 盒(对应 QPS 上限,比如每秒 1000 个请求)
- 时间窗口:1 天(对应 1 秒 / 1 分钟 / 滑动窗口)
- 限流算法:药店的实现很粗糙——人工记账,你今天来过了就看本子拒绝你。这对应"固定窗口"算法(凌晨 0 点重置计数)。
如果把药店的规则升级一下会怎样?
令牌桶算法:药店每天早上进货 100 盒,每卖一盒就从库存里减一。卖完了?只能等明天进货。但如果上午只卖了 20 盒,下午还剩下 80 盒可卖。没有固定窗口那种"最后一秒突然清零"的问题。
滑动窗口算法:不是"从凌晨 0 点开始算今天",而是"从现在往前推 24 小时,你买了没有超过 2 盒"。不会出现 23:59 买了 2 盒、00:01 又买 2 盒的漏洞。
漏桶算法:药店里只有一条取药通道,每次只能服务一个人,每个人最少间隔 5 分钟。不管来多少人,药剂师永远匀速工作。
翻译回技术语言:
- 固定窗口:简单但边界有毛刺——窗口切换瞬间可能被翻倍流量打穿。
- 令牌桶:允许一定程度的突发——桶里有令牌就能拿,但拿完了就得等。
- 滑动窗口:平滑精确——但分布式环境下的实现复杂度高。
- 漏桶:绝对平滑——强制匀速,完全消除了突发,但不适合需要快速响应的场景。
实现:企业级限流的三层防御体系
一次线上事故教会我:限流不能只在一个位置做。
第一层:网关层。在最外层就拦住明显异常的流量——比如同一个 IP 一秒发来 500 个请求,直接封。不让他进到业务层。
第二层:应用层(单机限流)。到了业务代码里,对每个核心接口做精细化的 QPS / 并发数控制。Sentinel 是这层的标杆方案。
第三层:分布式层。当你有 10 台机器,每台机器限 100 QPS,总限 1000 QPS——但如果流量倾斜到某一台机器上,单机 100 QPS 不够用,另外 9 台闲着。你需要一个跨机器的总闸门。
三层从外到内逐级收敛,每一层都有自己不可替代的职责。
4.1 单机限流:以 Sentinel FlowRule 为例
以下代码来自阿里巴巴开源的 alibaba/Sentinel(30k+ stars),这是国内使用最广泛的限流框架。
importcom.alibaba.csp.sentinel.Entry;importcom.alibaba.csp.sentinel.SphU;importcom.alibaba.csp.sentinel.slots.block.BlockException;importcom.alibaba.csp.sentinel.slots.block.flow.FlowRule;importcom.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;importjava.util.ArrayList;importjava.util.List;publicclassSeckillRateLimitExample{publicstaticvoidmain(String[]args){// 1. 定义限流规则List<FlowRule>rules=newArrayList<>();FlowRuleseckillRule=newFlowRule("seckill_submit")// grade: 1=QPS限流, 0=线程数限流.setGrade(RuleConstant.FLOW_GRADE_QPS)// count: QPS 阈值 — 这个接口最多每秒处理 1000 个请求.setCount(1000)// controlBehavior: 0=直接拒绝, 1=Warm Up, 2=匀速排队(漏桶), 3=Warm Up + 匀速排队.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT);rules.add(seckillRule);// 2. 加载规则 — 规则可以动态加载,不需要重启FlowRuleManager.loadRules(rules);// 3. 业务代码:用 SphU.entry() 包裹需要限流的代码for(inti=0;i<5000;i++){try(Entryentry=SphU.entry("seckill_submit")){// 限流通过了,正常执行秒杀逻辑doSeckill();}catch(BlockExceptione){// 被限流了 — 返回"活动太火爆"提示System.out.println("请求被限流,请稍后重试");}}}privatestaticvoiddoSeckill(){// 秒杀核心逻辑:扣库存、生成订单}}为什么这样设计?
SphU.entry("seckill_submit")是 Sentinel 的入口方法。它内部做的事远比"计数+判断"复杂:
- 调用
FlowSlot.checkFlow()——检查 QPS 是否超阈值 - 调用
DegradeSlot——检查下游服务是否熔断 - 调用
AuthoritySlot——校验调用方是否有权限
整个调用链是责任链模式,每个 Slot 独立可插拔。限流只是其中一个 Slot。
再看 FlowRule 的核心字段(直接来自 Sentinel 源码,FlowRule.java):
// grade: 0=线程数, 1=QPS — 两种粒度privateintgrade=RuleConstant.FLOW_GRADE_QPS;// count: 阈值,类型是 double — 可以设小数,比如 0.5 QPS = 2秒放一个privatedoublecount;// strategy: 0=直接, 1=关联, 2=链路// 关联限流:秒杀接口 QPS 超了,连带把商品详情页也限流(保核心链路)privateintstrategy=RuleConstant.STRATEGY_DIRECT;// controlBehavior: 0=直接拒绝, 1=Warm Up, 2=漏桶匀速排队// Warm Up:刚启动时阈值从 count/3 逐步上升到 count,给系统预热时间privateintcontrolBehavior=RuleConstant.CONTROL_BEHAVIOR_DEFAULT;privateintwarmUpPeriodSec=10;// Warm Up 的预热时长privateintmaxQueueingTimeMs=500;// 漏桶模式的最大排队时间privatebooleanclusterMode;// 是否启用集群限流这六个字段组合起来可以覆盖绝大多数限流场景。为什么 count 用 double 而不是 int?因为"每 2 秒放行 1 个"这种低频限流,count=0.5 比 int 更灵活。
4.2 分布式限流:Redis + Lua 原子令牌桶
单机限流的最大问题是:你有 10 台机器,每台限 QPS=100,总量 1000。但如果某一台因为哈希不均匀承受了 400 QPS,它自己被限了,其他 9 台加起来才 600——总的 QPS 远没到 1000,用户却被拒绝了。
分布式限流需要 Redis 做全局计数器,但要解决的核心问题是竞态条件:先读后写不是原子的。
下面是来自 lokeshpusarla100/java-redis-lua-distributed-rate-limiter 项目中的 Lua 脚本,实现了原子化的多计划令牌桶:
-- acquire_token.lua — 原子化多计划令牌桶实现-- KEYS: 交替排列 [bucket_key_1, config_key_1, bucket_key_2, config_key_2, ...]-- ARGV[1]: 请求的令牌数localrequested=tonumber(ARGV[1])if#KEYS==0or#KEYS%2~=0thenreturnredis.error_reply("KEYS must be alternating pairs of bucket_key and config_key")end-- 1. 调用一次 TIME,所有计划共用同一个时间戳-- 这是关键:保证 Refill 阶段对所有桶"看的是同一个时刻"localtime_res=redis.call('TIME')localnow_ms=(tonumber(time_res[1])*1000)+math.floor(tonumber(time_res[2])/1000)localallow_all=truelocalmin_remaining=math.hugelocalmax_wait_ms=0localtemp_updates={}-- 2. 读取与计算阶段:逐个计划检查fori=1,#KEYS,2dolocalbucket_key=KEYS[i]localconfig_key=KEYS[i+1]localconfig=redis.call('HMGET',config_key,'capacity','refillRate')localcapacity=tonumber(config[1])localrefill_rate=tonumber(config[2])-- 当前桶里的令牌数和上次补充时间localstate=redis.call('HMGET',bucket_key,'t','ts')localcurrent_tokens=tonumber(state[1])orcapacitylocallast_refill=tonumber(state[2])or0-- 计算这段时间应该补充多少令牌localdelta_ms=math.max(0,now_ms-last_refill)localrefill=delta_ms*(refill_rate/1000.0)localupdated_tokens=math.min(capacity,current_tokens+refill)ifupdated_tokens>=requestedthen-- 这个计划允许 — 记录扣除后的余额min_remaining=math.min(min_remaining,updated_tokens-requested)temp_updates[bucket_key]=updated_tokens-requestedelse-- 这个计划拒绝 — 计算还要等多少毫秒allow_all=falsemax_wait_ms=math.max(max_wait_ms,math.ceil((requested-updated_tokens)*(1000.0/refill_rate)))endend-- 3. 提交阶段(全或无 — All-or-Nothing)-- 只有所有计划都通过才真正写入,保证多维度限流的原子性ifnotallow_allthenreturn{0,min_remaining,max_wait_ms}endforbucket_key,remaininginpairs(temp_updates)doredis.call('HSET',bucket_key,'t',remaining,'ts',now_ms)endreturn{1,min_remaining,0}为什么这样写?
- 一次 TIME 调用:所有计划共享同一个时间戳。如果每个计划单独调 TIME,两个计划拿到的毫秒数不同,会出现"A 计划认为补充了 3 个令牌,B 计划认为补充了 5 个"的不一致。
- 全或无提交:先计算后写入。如果某个计划不通过,整个请求失败,已扣令牌的假象不会发生。这是 Redis Lua 脚本的关键优势——整个脚本在 Redis 内原子执行。
- 返回剩余令牌数:
return {1, min_remaining, 0}不仅仅返回通过/不通过,还返回了最小剩余令牌数。上层可以根据这个值做动态调整(比如当剩余接近 0 时提前告警)。
4.3 三层防御体系的完整架构
把单机限流和分布式限流拼起来:
请求流入 │ ┌────────────┼────────────┐ │ 网关层限流 │ │ (IP 黑名单 / 全局 QPS) │ │ 挡掉最明显的异常流量 │ └────────────┬────────────┘ │ 通过 ┌────────────┼────────────┐ │ 分布式限流 │ │ (Redis Lua 令牌桶) │ │ 跨机器的总闸门 │ └────────────┬────────────┘ │ 通过 ┌────────────┼────────────┐ │ 单机限流 │ │ (Sentinel FlowRule) │ │ 最后一道防线 │ └────────────┬────────────┘ │ 通过 业务处理三层各司其职:
- 网关层:粗放——按 IP / AppKey / 全局 QPS 拦截,不需要知道业务细节
- 分布式层:精准——跨机器总计,保证整体不超,代价是每次请求都要调 Redis
- 单机层:快速——纯内存操作,性能损耗极小,但粒度是单机的
边界与陷阱:限流实现中五个容易踩的坑
陷阱一:固定窗口的"毛刺效应"。限流规则设QPS=100,你在 00:00:00.900 到 00:00:01.100 这 200 毫秒内可以发进来 200 个请求——前 100 个属于第 0 秒,后 100 个属于第 1 秒。两个窗口切换瞬间,没有哪个窗口单独被限流,但你的服务在 200ms 内承受了 2 倍的压力。解法:用滑动窗口或令牌桶替代固定窗口。
陷阱二:Sentinel 的规则不持久化。Sentinel 的默认规则存储在内存中,服务重启后规则消失。如果你在 Dashboard 里配置了限流规则但没做持久化,重启后所有接口裸奔。解法:把规则推送到配置中心(Nacos / Apollo / ZooKeeper),启动时从配置中心拉取。
陷阱三:Redis Lua 脚本的 KEYS 哈希槽问题。分布式限流的 Lua 脚本用多个 KEY,在 Redis Cluster 模式下,这些 KEY 必须属于同一个哈希槽,否则脚本执行失败。解法:用{}包裹 hashtag——比如{rate_limit}:bucket:api1和{rate_limit}:config:api1,保证相同业务限制在同一个 slot。
陷阱四:分布式限流的 Redis 穿透。每次请求都调 Redis,当限流本身成为瓶颈就荒唐了。10000 QPS 的限流自身吃掉 10000 次 Redis 调用。解法:先过单机限流(纯内存),单机过了才去分布式层校验。加上本地预取(一次从 Redis 拿 50 个令牌存本地,用完再拿)。
陷阱五:兜底策略缺失。限流后返回什么?直接返回 429 Too Many Requests 是最偷懒的做法。更好的做法是:提示用户大概要等多久(Retry-After 头)、对高价值用户放行一部分、返回降级数据(缓存中的商品信息而不是实时查询)。限流不是终点——被限的请求怎么处理,比怎么限更重要。
高级考量:从单机到全链路
当你的系统大到一定程度时,限流需要扩展到三个新维度:
一是自适应限流。Sentinel 支持基于系统负载的自适应限流(SystemRule)——不再设固定 QPS,而是设定"系统 Load 不超过 4""CPU 使用率不超过 80%"等规则。系统自己根据当前负载动态调整放行量。比固定 QPS 更智能——夜里没人用可以全速跑,白天高峰期自动收紧。
二是集群限流。Sentinel 提供了 token server(令牌服务器)模式。所有机器去一台中心化的 token server 上申请令牌,token server 自己维护全局 QPS。缺点是 token server 本身成了单点。Sentinel 的解决方式是集群限流——流量均匀分摊到多个 token server,一台挂了自动 failover 到备用。
三是热点参数限流。限流不只是针对接口,还可以针对接口的某个参数。比如"同一个商品 ID 的秒杀请求每秒不超过 100 个"——如果有 1000 个不同商品在秒杀,总量远超过 100,但每个商品的限流各自独立。Sentinel 的ParamFlowSlot专门处理这个场景。
项目实战:在秒杀系统中落地三层限流
去年双十一之后,我们给秒杀系统重构了限流方案。场景是这样的:
项目背景:电商平台,日均 20 万单。大促秒杀时,3 款爆品同时上架,瞬时 QPS 达到平时的 50-80 倍。
场景拆解:三条链路需要分别管控——商品详情页(读)、库存查询(读)、下单(写)。写入链路最脆弱,必须单独限流。
方案落地:
- 网关层:基于 Nginx + OpenResty,IP 级别 100 QPS,全局 10000 QPS
- 分布式层:Redis Cluster 分片 + Lua 令牌桶,下单接口全局上限 500 QPS
- 应用层:Sentinel FlowRule,下单接口单机 100 QPS
核心代码——在 Spring Boot 中集成 Sentinel + 分布式限流:
importcom.alibaba.csp.sentinel.Entry;importcom.alibaba.csp.sentinel.SphU;importcom.alibaba.csp.sentinel.slots.block.flow.FlowRule;importcom.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.data.redis.core.RedisTemplate;importorg.springframework.data.redis.core.script.DefaultRedisScript;importorg.springframework.stereotype.Service;importjava.util.Arrays;importjava.util.Collections;@ServicepublicclassRateLimitedSeckillService{@AutowiredprivateRedisTemplate<String,String>redisTemplate;// 分布式限流 Lua 脚本(简化版滑动窗口)privatestaticfinalStringLUA_SLIDING_WINDOW="local key = KEYS[1]\n"+"local window_ms = tonumber(ARGV[1])\n"+"local max_count = tonumber(ARGV[2])\n"+"local now = redis.call('TIME')\n"+"local now_ms = now[1] * 1000 + math.floor(now[2] / 1000)\n"+"local window_start = now_ms - window_ms\n"+"redis.call('ZREMRANGEBYSCORE', key, 0, window_start)\n"+"local current = redis.call('ZCARD', key)\n"+"if current < max_count then\n"+" redis.call('ZADD', key, now_ms, now_ms .. '-' .. math.random())\n"+" redis.call('PEXPIRE', key, window_ms)\n"+" return 1\n"+"end\n"+"return 0";publicStringsubmitOrder(StringuserId,StringproductId){// 第一层:单机限流(Sentinel,纯内存,极快)try(Entryentry=SphU.entry("seckill_submit")){// 第二层:分布式限流(Redis 滑动窗口,全局精准)DefaultRedisScript<Long>script=newDefaultRedisScript<>();script.setScriptText(LUA_SLIDING_WINDOW);script.setResultType(Long.class);Longallowed=redisTemplate.execute(script,Collections.singletonList("seckill:global:window"),"1000",// 窗口 1000ms = 1秒"500"// 全局上限 500 QPS);if(allowed==null||allowed==0){return"活动太火爆,请稍后重试(全局限流)";}// 两层都通过了 — 执行秒杀returndoSeckill(userId,productId);}catch(BlockExceptione){return"活动太火爆,请稍后重试(单机限流)";}}privateStringdoSeckill(StringuserId,StringproductId){// 扣库存、生成订单...return"秒杀成功";}}踩坑记录:
- 坑一:分布式限流使用
ZSET滑动窗口时,忘了设PEXPIRE。结果 ZSET key 没有过期时间,内存持续增长直到 OOM。Redis 的ZREMRANGEBYSCORE只清理了超出窗口的成员,但如果一个 key 已经很久没有请求进来了,ZSET 本身不会被删除。 - 坑二:Sentinel 的
blockHandler和fallback是两回事。OpenFeign 整合 Sentinel 时,blockHandler处理限流/熔断,fallback处理业务异常。很多人写反了——在blockHandler里捕获了业务异常,限流反而没有生效。 - 坑三:Redis Cluster 分片模式下,Lua 脚本的 KEYS 如果跨 slot,Redis 直接报
CROSSSLOT错误。我们用{seckill}hashtag 把所有相关 key 框在同一个 slot 里,保证 Lua 脚本能正常执行。
兜底策略——限不住了怎么办?
限流不是万能的。如果流量大到连限流层本身都撑不住了(Redis CPU 100%、Sentinel 内存打满),需要有三层兜底:
- 接口降级:秒杀服务挂了?自动切到"预约模式"——用户点"抢购"变成"您已预约,开抢后通知"。比直接报 500 好得多。
- 静态化:商品详情页完全静态化推 CDN,不经过应用服务器。即使后端全挂,用户至少能看到商品信息。
- 手动熔断:运维 Dashboard 上有一个红色按钮,按下去直接把秒杀入口重定向到"活动已结束"页面。这个按钮那天救了我们的命。
对比表格
表格一:四种限流算法对比
| 算法 | 核心思路 | 突发处理 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|
| 固定窗口 | 每个时间窗口独立计数 | 差:窗口切换瞬间可能翻倍 | 低 | 对精度要求不高的低频接口 |
| 滑动窗口 | 窗口随时间滑动,边界平滑 | 好:窗口内精确计数 | 中:单机简单,分布式需 ZSET | API 限流、用户维度的精细化控制 |
| 令牌桶 | 固定速率补充令牌,桶满则停 | 好:允许短期突发(桶里有就能拿) | 中:需维护桶状态和补充逻辑 | 秒杀、抢购,允许集中爆发 |
| 漏桶 | 固定速率漏水,超出则溢出 | 差:完全消除突发,强制匀速 | 低:只需队列 | 消息队列消费端、第三方 API 调用 |
表格二:三层限流位置对比
| 限流层 | 部署位置 | 粒度 | 性能损耗 | 能挡住什么 |
|---|---|---|---|---|
| 网关层 | Nginx/网关 | IP / AppKey / 全局 QPS | 极低(C 语言级) | 恶意刷量、单 IP 高频请求 |
| 分布式层 | Redis Cluster | 接口 / 用户 / 商品维度 | 中(一次网络 IO) | 跨机器的超额流量,保证总 QPS 不超 |
| 应用层 | JVM 进程内 | 接口 / 方法级别的精细化控制 | 低(纯内存) | 单机资源保护、控制并发线程数 |
面试追问
追问 1:令牌桶和漏桶到底有什么区别?
回答方向:令牌桶允许突发(桶里存了多少就能瞬间发多少),漏桶强制匀速。选令牌桶意味着"我相信突发是正常的,系统扛得住",选漏桶意味着"我不相信任何突发,请匀速来"。秒杀用令牌桶,第三方 API 调用用漏桶——你不想因为发太快被对方封 IP。
追问 2:分布式限流中,Redis 挂了怎么办?
回答方向:必须做降级。Redis 不可用时,切换到纯单机 Sentinel 限流——精度下降但服务可用。更完善的方案是本地预取令牌 + 定期同步——先把 Redis 的 50 个令牌拿到本地用,用完了再去申请。Redis 短暂不可用期间,本地令牌还能撑一会。Sentinel 的DegradeSlot可以自动检测 Redis 是否可用并切换策略。
追问 3:为什么限流要分三层,一层不行吗?
回答方向:单一层的限流有各自的死穴。只用网关层——无法区分业务接口,商品浏览和秒杀下单被同等对待。只用分布式层——每次请求都要调 Redis,限流本身成为瓶颈。只用单机层——10 台机器,总 QPS 无法精确控制。三层互补:网关层挡掉最猛的、分布式层管住整体的、单机层保护自己的。哪一层挂了,另外两层还能撑住。
限流不是在拒绝用户——是在保护那些正常的用户。
读完这篇你应该能:理解四种限流算法的区别和适用场景、用自己的语言解释为什么限流要分三层而不是一层、用 Sentinel + Redis Lua 搭建一套生产可用的三层限流体系、在面试时说清楚"令牌桶和漏桶你怎么选"而不仅仅是"都用令牌桶"。
