大家好,我是程序员小策。
先说一个反直觉的事实:加了限流之后,你系统的成功请求数量反而可能变多。
听起来很荒诞对吧?限流的字面意思就是"拦住一部分请求",拦住了怎么可能变多?
但数据不会骗人:
| 场景 | 总请求数 | 成功数 | 成功率 |
|---|---|---|---|
| 不限流 | 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),这是国内使用最广泛的限流框架。
java
import com.alibaba.csp.sentinel.Entry;
import com.alibaba.csp.sentinel.SphU;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;
import java.util.ArrayList;
import java.util.List;
public class SeckillRateLimitExample {
public static void main(String[] args) {
// 1. 定义限流规则
List<FlowRule> rules = new ArrayList<>();
FlowRule seckillRule = new FlowRule("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 (int i = 0; i < 5000; i++) {
try (Entry entry = SphU.entry("seckill_submit")) {
// 限流通过了,正常执行秒杀逻辑
doSeckill();
} catch (BlockException e) {
// 被限流了 --- 返回"活动太火爆"提示
System.out.println("请求被限流,请稍后重试");
}
}
}
private static void doSeckill() {
// 秒杀核心逻辑:扣库存、生成订单
}
}
为什么这样设计?
SphU.entry("seckill_submit") 是 Sentinel 的入口方法。它内部做的事远比"计数+判断"复杂:
- 调用
FlowSlot.checkFlow()------检查 QPS 是否超阈值 - 调用
DegradeSlot------检查下游服务是否熔断 - 调用
AuthoritySlot------校验调用方是否有权限
整个调用链是责任链模式,每个 Slot 独立可插拔。限流只是其中一个 Slot。
再看 FlowRule 的核心字段(直接来自 Sentinel 源码,FlowRule.java):
java
// grade: 0=线程数, 1=QPS --- 两种粒度
private int grade = RuleConstant.FLOW_GRADE_QPS;
// count: 阈值,类型是 double --- 可以设小数,比如 0.5 QPS = 2秒放一个
private double count;
// strategy: 0=直接, 1=关联, 2=链路
// 关联限流:秒杀接口 QPS 超了,连带把商品详情页也限流(保核心链路)
private int strategy = RuleConstant.STRATEGY_DIRECT;
// controlBehavior: 0=直接拒绝, 1=Warm Up, 2=漏桶匀速排队
// Warm Up:刚启动时阈值从 count/3 逐步上升到 count,给系统预热时间
private int controlBehavior = RuleConstant.CONTROL_BEHAVIOR_DEFAULT;
private int warmUpPeriodSec = 10; // Warm Up 的预热时长
private int maxQueueingTimeMs = 500; // 漏桶模式的最大排队时间
private boolean clusterMode; // 是否启用集群限流
这六个字段组合起来可以覆盖绝大多数限流场景。为什么 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 脚本,实现了原子化的多计划令牌桶:
lua
-- acquire_token.lua --- 原子化多计划令牌桶实现
-- KEYS: 交替排列 [bucket_key_1, config_key_1, bucket_key_2, config_key_2, ...]
-- ARGV[1]: 请求的令牌数
local requested = tonumber(ARGV[1])
if #KEYS == 0 or #KEYS % 2 ~= 0 then
return redis.error_reply("KEYS must be alternating pairs of bucket_key and config_key")
end
-- 1. 调用一次 TIME,所有计划共用同一个时间戳
-- 这是关键:保证 Refill 阶段对所有桶"看的是同一个时刻"
local time_res = redis.call('TIME')
local now_ms = (tonumber(time_res[1]) * 1000) + math.floor(tonumber(time_res[2]) / 1000)
local allow_all = true
local min_remaining = math.huge
local max_wait_ms = 0
local temp_updates = {}
-- 2. 读取与计算阶段:逐个计划检查
for i = 1, #KEYS, 2 do
local bucket_key = KEYS[i]
local config_key = KEYS[i+1]
local config = redis.call('HMGET', config_key, 'capacity', 'refillRate')
local capacity = tonumber(config[1])
local refill_rate = tonumber(config[2])
-- 当前桶里的令牌数和上次补充时间
local state = redis.call('HMGET', bucket_key, 't', 'ts')
local current_tokens = tonumber(state[1]) or capacity
local last_refill = tonumber(state[2]) or 0
-- 计算这段时间应该补充多少令牌
local delta_ms = math.max(0, now_ms - last_refill)
local refill = delta_ms * (refill_rate / 1000.0)
local updated_tokens = math.min(capacity, current_tokens + refill)
if updated_tokens >= requested then
-- 这个计划允许 --- 记录扣除后的余额
min_remaining = math.min(min_remaining, updated_tokens - requested)
temp_updates[bucket_key] = updated_tokens - requested
else
-- 这个计划拒绝 --- 计算还要等多少毫秒
allow_all = false
max_wait_ms = math.max(max_wait_ms,
math.ceil((requested - updated_tokens) * (1000.0 / refill_rate)))
end
end
-- 3. 提交阶段(全或无 --- All-or-Nothing)
-- 只有所有计划都通过才真正写入,保证多维度限流的原子性
if not allow_all then
return {0, min_remaining, max_wait_ms}
end
for bucket_key, remaining in pairs(temp_updates) do
redis.call('HSET', bucket_key, 't', remaining, 'ts', now_ms)
end
return {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 + 分布式限流:
java
import com.alibaba.csp.sentinel.Entry;
import com.alibaba.csp.sentinel.SphU;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.Collections;
@Service
public class RateLimitedSeckillService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 分布式限流 Lua 脚本(简化版滑动窗口)
private static final String LUA_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";
public String submitOrder(String userId, String productId) {
// 第一层:单机限流(Sentinel,纯内存,极快)
try (Entry entry = SphU.entry("seckill_submit")) {
// 第二层:分布式限流(Redis 滑动窗口,全局精准)
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(LUA_SLIDING_WINDOW);
script.setResultType(Long.class);
Long allowed = redisTemplate.execute(
script,
Collections.singletonList("seckill:global:window"),
"1000", // 窗口 1000ms = 1秒
"500" // 全局上限 500 QPS
);
if (allowed == null || allowed == 0) {
return "活动太火爆,请稍后重试(全局限流)";
}
// 两层都通过了 --- 执行秒杀
return doSeckill(userId, productId);
} catch (BlockException e) {
return "活动太火爆,请稍后重试(单机限流)";
}
}
private String doSeckill(String userId, String productId) {
// 扣库存、生成订单...
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 搭建一套生产可用的三层限流体系、在面试时说清楚"令牌桶和漏桶你怎么选"而不仅仅是"都用令牌桶"。