设计一个限流系统
问题
令牌桶、漏桶、滑动窗口怎么选型?分布式限流如何保证精度?Sentinel 的滑动窗口源码是怎么实现的?
限流算法三兄弟
限流的核心是"控制流量速率",让后端系统不被突发流量打穿。三种经典算法,各有各的适用场景。
令牌桶:允许突发,适合网关层
令牌桶的思路很直观:一个桶容量固定,以固定速率往里放令牌(比如每秒 100 个)。请求来了,从桶里拿一个令牌,拿到了就通过,拿不到就拒绝或排队。桶里最多攒满容量,多余的令牌不累积。
什么时候用令牌桶:秒杀场景、API 网关层。秒杀时流量瞬间爆发,令牌桶允许消耗积攒的令牌来应对突发,不会把合法请求挡在门外。Guava 的 RateLimiter 就是令牌桶的经典实现。
// Guava RateLimiter 使用示例
RateLimiter limiter = RateLimiter.create(100.0); // 每秒 100 个令牌
while (true) {
// 获取令牌,会阻塞等待直到拿到
limiter.acquire();
// 执行业务逻辑
doBusiness();
}
// 实测:如果 5 秒没人请求,突然来 500 个请求,
// acquire() 会立刻放行前 500 个(因为桶里攒了 500 个令牌),
// 后续请求才会被限速漏桶:强制平滑,适合写密集型场景
漏桶是一个固定容量的桶,水(请求)以恒定速率从底部漏出。进水速度可以快,但出水速度固定。如果桶满了,多余的水溢出(被拒绝)。
什么时候用漏桶:数据库写入、MQ 消费端。这些场景需要严格的恒定速率,不允许突发——数据库的写入连接数有限,突然涌入大量写入请求会导致连接池爆满、死锁或者磁盘 IO 打满。
滑动窗口:精确控制,适合资源敏感场景
滑动窗口把时间窗口(比如 1 秒)切成若干小格子(比如 2 个 500ms 的格子),每个格子独立计数。窗口随着时间推移不断"滑动",只统计窗口内的格子。
什么时候用滑动窗口:对限流精度要求高的场景。令牌桶和漏桶都有"突刺"问题——令牌桶在长时间空闲后允许突发大量请求,漏桶可能有短时间窗口内请求堆积。滑动窗口通过更细的时间粒度,避免了单时间点上的流量尖刺。
算法选型实战:怎么选
| 场景 | 推荐算法 | 原因 |
|---|---|---|
| API 网关、秒杀入口 | 令牌桶 | 允许突发流量通过,不误杀正常请求 |
| 数据库写入、MQ 消费 | 漏桶 | 要求恒定速率,不能有突发 |
| 资源精确控制(如连接数) | 滑动窗口 | 精度最高,能精确到毫秒级 |
| 分布式限流 | 令牌桶 + Redis | 中心化计数,全局精确 |
实际项目中,往往不是只用一种:网关层用令牌桶(允许突发),业务逻辑限流用滑动窗口(更精确),MQ 消费端用漏桶(平滑消费)。
分布式限流:单机好做,跨机器麻烦
单机限流很简单,在内存里维护一个计数器就行。但多实例部署时,每个实例只做本地限流,三个实例合起来就可能超过全局阈值。
分布式限流的方案很直接:Redis 做全局计数器。
滑动窗口 + Redis Lua 脚本
-- 滑动窗口限流 Lua 脚本
-- KEYS[1] = 限流 key(如 "rate_limit:api:/order")
-- ARGV[1] = 窗口大小(毫秒)
-- ARGV[2] = 窗口内最大请求数
-- ARGV[3] = 当前时间戳(毫秒)
local key = KEYS[1]
local window = tonumber(ARGV[1])
local maxRequests = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 窗口起始时间
local windowStart = now - window
-- 移除窗口外的旧记录
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
-- 获取当前窗口内请求数
local current = redis.call('ZCARD', key)
if current >= maxRequests then
-- 限流,返回剩余时间(毫秒)
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retryAfter = window - (now - tonumber(oldest[2]))
return {0, math.ceil(retryAfter)}
end
-- 放行,记录当前请求
redis.call('ZADD', key, now, now .. ':' .. math.random())
redis.call('EXPIRE', key, math.ceil(window / 1000) + 1)
return {1, 0}这个脚本用 Redis ZSet 存储窗口内每个请求的时间戳,通过 ZREMRANGEBYSCORE 清理过期记录,ZCARD 统计窗口内请求数。每次请求都在 Redis 端完成,原子性有保证。
分布式限流的精度损失
Redis 做全局计数器有个问题:网络延迟。每个请求都去 Redis 查一次,在高并发下(单机 1 万 QPS,10 台机器就是 10 万 QPS),Redis 本身会变成瓶颈,而且网络往返的 1-2ms 延迟会导致限流判断滞后。
实际的精度损失大概在 5-10% 之间,大多数场景可以接受。如果要求更高精度,可以用本地限流 + 定期同步的方案:每个节点先从 Redis 拿到一批配额(比如 1000 个),在本地用完后再去申请下一批。类似 Leaf-segment 的分段思想,减少 Redis 的访问次数。
Sentinel 的滑动窗口怎么实现的
Sentinel 是阿里开源的流量治理组件,它的滑动窗口实现值得一看。
Sentinel 把 1 秒切成 2 个 500ms 的格子(WindowWrap),每个格子独立统计 pass(通过)和 block(拒绝)的数量。请求到来时,根据当前时间戳模运算定位到对应的格子,格子过期则重置计数。
关键的设计思路:时间片不重叠,同一时刻只看到一个格子。滑动窗口的"滑动",本质上是时间片在时间轴上平移,每次只移动一个格子的宽度。
// Sentinel 滑动窗口核心逻辑(简化版)
public class LeapArray<T> {
private int windowLengthMs; // 每个格子的时长(500ms)
private int sampleCount; // 格子数(2)
private int intervalInMs; // 总窗口时长(1000ms)
private AtomicReferenceArray<WindowWrap<T>> array;
public WindowWrap<T> currentWindow(long timeMillis) {
// 计算当前时间落在哪个格子
int idx = (int)((timeMillis / windowLengthMs) % array.length());
WindowWrap<T> wrap = array.get(idx);
if (wrap == null) {
// 新格子,初始化
wrap = new WindowWrap<>(windowLengthMs, timeMillis, newEmptyBucket());
array.compareAndSet(idx, null, wrap);
} else if (timeMillis - wrap.windowStart() > windowLengthMs) {
// 格子过期了,重置
wrap.resetTo(timeMillis);
}
return wrap;
}
}自适应限流:从固定阈值到动态调整
固定阈值有一个问题:阈值设高了保护不了系统,设低了浪费资源。而且系统在不同时间段的承载能力不同——凌晨和高峰期,CPU 使用率和内存压力完全不一样。
自适应限流的核心思路:不依赖固定 QPS 阈值,而是根据后端响应时间(RT)动态调整通过率。当后端 RT 变高时,说明系统压力大,自动降低限流阈值;当 RT 恢复正常时,再逐步放开。
类似 TCP 的 AIMD(加法增、乘法减)算法:RT 正常时,限流阈值缓慢增加(试探上限);RT 升高时,阈值快速降低(保护系统)。
// 自适应限流伪代码
public class AdaptiveRateLimiter {
private double maxQps = 100; // 初始阈值
private long lastRt = 50; // 上次 RT(毫秒)
private long rtThreshold = 200; // RT 告警阈值
public boolean tryAcquire() {
// 如果 RT 超过阈值,快速降低 QPS 限制
if (lastRt > rtThreshold) {
maxQps = maxQps * 0.5; // 乘法减,快速降
} else {
maxQps = maxQps + 5; // 加法增,缓慢升
}
// 用当前 maxQps 做限流判断
return doLimit(maxQps);
}
}总结
限流系统设计,面试官真正想听的不是"我选了令牌桶",而是你知不知道为什么选它、不选它的代价是什么。
- 选型看场景:网关层用令牌桶(允许突发),DB 写入用漏桶(强制平滑),精确控制用滑动窗口
- 分布式限流的核心是 Redis:但精度有损失,本地限流 + 定期同步可以优化
- Sentinel 的滑动窗口按时间片切分:不重叠、过期重置,逻辑简单但效率高
- 自适应限流是 P8 级别的方向:不依赖固定阈值,根据 RT 动态调整
最后提一句:限流不是万能的。限流挡不住的流量,需要配合降级(返回默认值、走缓存兜底)和熔断(彻底切断对下游的调用)一起使用,才能构成完整的流量治理体系。