限流算法与高并发防护
校招考察频度:★ 高频(重点常考·大厂分水岭) 一句话速记:限流是系统自我保命的闸门;固定窗口简单但有交界 2 倍流量突刺,滑动窗口切分子窗口平滑去突刺;漏桶恒定流速保下游,令牌桶匀速放行且支持突发流量;分布式环境下借助 Redis + Lua 脚本实现原子限流。
在任何高并发架构(如电商秒杀、春运抢票、开放 API 网关)中,服务器算力和数据库吞吐都是有物理上限的。当瞬时流量超过系统容量时,“优雅地拒绝一部分多余流量,保护系统不崩溃”,远比“全部接下导致全盘瘫痪”要明智得多。
一、0 基础生动类比:景区游园检票模型
如果把高并发 Web 服务比作**“高峰期的故宫博物院”**:
| 限流算法 | 生活化比喻 | 优缺点与适用场景 |
|---|---|---|
| 固定窗口(计数器) | 景区分时段入园:每整点放行 100 人,时间一到计数清零。 | 最简单但有致命缺陷:59 分进 100 人,下个 01 分又进 100 人,交界瞬间涌入 200 人冲垮景区! |
| 滑动窗口(Sliding Window) | 检票口安装连续时间滚动雷达:随时向前看过去 60 秒内的总人数不能超 100。 | 精确平滑无突刺,但需要记录更细的时间片或请求时间戳。 |
| 漏桶算法(Leaky Bucket) | 景区大门装了一个单人旋转闸门:不管外面排了几万人,闸门每秒只能转动过去 5 个人。 | 绝对平滑恒定流速,适合保护脆弱的第三方下游,但无法应对正常的短时间合法突发流量。 |
| 令牌桶算法(Token Bucket) | 售票处以恒定速度印发门票,存入票箱(满则不印);游客凭票入园,票箱有存票时允许游客成批进入。 | 生产环境工业首选:既保证了长期平均速率,又包容短时间的瞬时并发脉冲! |
二、四大限流算法深度剖析
令牌桶 vs 漏桶算法原理对比
1. 固定窗口(Fixed Window)的“临界突刺”致命伤
- 设定规则:1 秒钟最多允许 100 个请求。
- 灾难场景:
- 恶意攻击者在
0.99s瞬间发送 100 个请求(通过); - 进入下一秒窗口,计数器在
1.00s自动清零; - 攻击者在
1.01s又瞬间发送 100 个请求(通过)。 - 结果:在短短 20 毫秒的跨窗口区间内,系统实质上承受了 200 个请求(2 倍峰值阈值),直接击穿数据库连接池!
- 恶意攻击者在
2. 滑动窗口(Sliding Window)如何解决突刺?
- 将 1 个大周期(如 1 秒)细分为多个更小的格子(如 5 个 200ms 的小格子)。
- 当时间向前推进时,窗口不断向右滑动,最左侧过期的小格子被移出,当前窗口的总计数只计算覆盖在当前滑动窗口内的格子之和。
- 无论流量在哪个毫秒爆发,滑动窗口总能敏锐地框住该突发区间并触发拦截。
3. 令牌桶算法为什么不需要起后台定时器?(惰性计算黑科技)
初学者常以为令牌桶需要一个单独的后台线程每隔几毫秒往桶里扔令牌。在实际工业实现(如 Google Guava RateLimiter)中,根本不需要后台线程!
- 内部只记录两个变量:
lastTime(上次取令牌的时间戳)和currentTokens(当前桶内令牌数)。 - 当新请求在
now到达时,惰性计算补充的令牌:newTokens = (now - lastTime) * tokenRate currentTokens = min(capacity, currentTokens + newTokens) - 只要判断
currentTokens >= 1,则原子扣减 1 并放行,更新lastTime = now。用极简的时间数学运算完全消除了定时线程的 CPU 消耗!
三、分布式限流实战:Redis + Lua 保证原子性
单机内存限流(如 Guava)在多台容器集群部署下各自为政,无法限制全系统的全局总流量。生产中采用 Redis 集中式存储计数,结合 Lua 脚本保障原子执行。
-- Redis 固定窗口/滑动计数 Lua 脚本
local key = KEYS[1] -- 限流的 Redis 键 (如 "rate_limit:order_api:user_123")
local limit = tonumber(ARGV[1]) -- 限流阈值 (如 100)
local expire_time = ARGV[2] -- 窗口过期时间 (如 1 秒)
-- 原子自增
local current = redis.call('INCR', key)
-- 第一次访问时设置过期时间
if current == 1 then
redis.call('EXPIRE', key, expire_time)
end
-- 超出限制返回 0 (拒绝),否则返回 1 (放行)
if current > limit then
return 0
else
return 1
end为什么必须用 Lua 脚本?
因为INCR和EXPIRE是两条独立的 Redis 命令。如果执行完INCR之后 Redis 突然发生网络抖动或主从切换,导致EXPIRE没能执行成功,这个 Key 将永远不会过期,造成该用户被永久封禁!Lua 脚本在 Redis 内部以单线程原子包裹执行,保证要么全执行、要么都不执行。
四、知识脉络关联网(Knowledge Highway)
- ⬅️ 底层原子保障:Redis 为什么能用 Lua 保证事务原子性?参见 Redis 事务与 Lua 脚本机制。
- ➡️ 高可用配套三剑客:限流之后的第二道与第三道防线是什么?参见 熔断与降级策略。
- 🔄 重试的连带陷阱:限流拒绝后客户端如果疯狂重试怎么办?参见 系统设计:重试与幂等性设计。
五、校招高频面试追问
- 限流应该在架构的哪一层做?
- 分层多级防御:
① 接入层 / API 网关(Nginx / Spring Cloud Gateway):按 IP、客户端指纹做全局粗粒度限流,防御恶意爬虫与 DDoS 流量;
② 微服务应用层(Sentinel / Guava):按具体业务接口(如秒杀下单 URL)、用户 ID 做细粒度限流,保护核心下游数据库;
③ 中间件层(如 Kafka 消费端限流):防止消费速度过快冲垮下游 DB。
- 分层多级防御:
- 被限流拒绝的请求,怎么给前端或调用方友好交代?
- HTTP 状态码规范返回
429 Too Many Requests; - 响应头中携带
Retry-After: 3(提示客户端在 3 秒后重试); - 在业务降级页面给用户返回兜底提示:“当前排队人数较多,请稍后再试”,坚决避免直接抛出 500 报错页面。
- HTTP 状态码规范返回