4. 削峰填谷原理:流量削峰概念、令牌桶与漏桶算法、Redis实现限流的基本思路
在热点事件、秒杀活动或突发流量场景下,系统面临的核心矛盾是:瞬时请求速率远高于后端服务的处理能力。削峰填谷正是解决这一矛盾的核心思想——通过缓冲和整形,将突发的流量高峰削平,转化为相对平稳的请求流,从而保护后端系统不被击垮。
4.1 流量削峰概念
流量削峰并非简单地拒绝请求,而是通过请求排队、速率限制和异步处理等手段,让系统在单位时间内处理的请求量不超过其最大承载能力。削峰填谷的核心价值体现在:
- 保护后端资源:避免数据库、缓存、微服务等组件因瞬时高并发而崩溃。
- 提升系统稳定性:平滑流量波动,减少因过载导致的连锁故障。
- 优化用户体验:通过排队和降级策略,让大部分请求得到合理响应,而非直接返回错误。
4.2 令牌桶与漏桶算法
限流算法的核心是控制请求的速率和突发性。业界最经典的两种算法是漏桶算法和令牌桶算法,它们在处理突发流量时的行为截然不同。
4.2.1 漏桶算法(Leaky Bucket)
漏桶算法将请求比作水滴,系统是一个底部有漏洞的桶。无论进水速度多快,桶内的水(请求)都会以固定的速率从漏洞流出。如果桶满了,后续的水(请求)就会溢出(被拒绝)。
- 核心特性:强制输出速率恒定,不允许突发。
- 适用场景:对流量整形要求严格的场景,如网络流量整形、数据库写入限流。
- 缺点:无法应对突发流量,即使系统有空闲资源,也无法加速处理。
4.2.2 令牌桶算法(Token Bucket)
令牌桶算法以固定速率向桶中添加令牌,每个请求需要消耗一个令牌才能被处理。桶有最大容量,允许积累一定数量的令牌。当突发流量到来时,只要桶中有足够的令牌,就可以一次性处理多个请求。
- 核心特性:允许一定程度的突发,平均速率受限于令牌生成速率。
- 适用场景:大多数互联网业务场景,如API限流、秒杀入口保护。
- 优点:既能限制平均速率,又能应对突发流量,灵活性更高。
| 对比维度 | 漏桶算法 | 令牌桶算法 |
|---|---|---|
| 输出速率 | 严格恒定 | 平均恒定,允许突发 |
| 突发处理 | 不支持 | 支持(桶容量决定突发量) |
| 资源利用 | 可能浪费空闲资源 | 更高效,能利用空闲资源 |
| 实现复杂度 | 较低 | 中等 |
| 典型应用 | 网络QoS、流量整形 | API限流、秒杀系统 |
4.2.3 算法对比流程图
下图直观展示了两种算法在处理突发流量时的行为差异:
4.3 Redis实现限流的基本思路
Redis凭借其单线程模型、原子操作和丰富的数据结构,成为实现分布式限流的首选组件。以下是几种基于Redis的经典限流实现思路:
4.3.1 基于计数器的固定窗口限流
使用INCR和EXPIRE命令,在固定时间窗口内统计请求次数。这是最简单的实现方式,但存在窗口边界突刺问题。
-- 固定窗口限流伪代码
-- 假设限制:每分钟最多100次请求
local key = "rate_limit:" .. KEYS[1] .. ":" .. math.floor(redis.call("TIME")[1] / 60)
local count = redis.call("INCR", key)
if count == 1 then
redis.call("EXPIRE", key, 60)
end
if count > 100 then
return 0 -- 拒绝请求
end
return 1 -- 允许请求
4.3.2 基于有序集合的滑动窗口限流
使用ZADD和ZREMRANGEBYSCORE,维护一个滑动时间窗口内的请求时间戳集合。精度更高,但内存消耗较大。
-- 滑动窗口限流伪代码
-- 假设限制:最近1秒内最多10次请求
local key = "sliding_window:" .. KEYS[1]
local now = redis.call("TIME")[1]
local window_start = now - 1
-- 移除窗口外的旧记录
redis.call("ZREMRANGEBYSCORE", key, 0, window_start)
-- 统计当前窗口内的请求数
local current_count = redis.call("ZCARD", key)
if current_count >= 10 then
return 0 -- 拒绝请求
end
-- 添加当前请求
redis.call("ZADD", key, now, now)
redis.call("EXPIRE", key, 2) -- 设置过期时间,避免内存泄漏
return 1 -- 允许请求
4.3.3 基于令牌桶的限流(推荐方案)
使用Redis的HASH结构存储令牌桶状态(上次更新时间、当前令牌数),通过Lua脚本实现原子化的令牌获取逻辑。这是企业级应用中最常用的方案。
-- 令牌桶限流 Lua 脚本
-- KEYS[1]: 令牌桶key
-- ARGV[1]: 令牌生成速率(每秒)
-- ARGV[2]: 桶最大容量
-- ARGV[3]: 当前时间戳(秒)
-- ARGV[4]: 本次请求消耗的令牌数(通常为1)
local bucket = redis.call("HMGET", KEYS[1], "tokens", "last_time")
local tokens = tonumber(bucket[1]) or tonumber(ARGV[2])
local last_time = tonumber(bucket[2]) or tonumber(ARGV[3])
-- 计算时间差,补充令牌
local elapsed = tonumber(ARGV[3]) - last_time
local new_tokens = math.min(tonumber(ARGV[2]), tokens + elapsed * tonumber(ARGV[1]))
-- 判断是否允许请求
if new_tokens >= tonumber(ARGV[4]) then
new_tokens = new_tokens - tonumber(ARGV[4])
redis.call("HMSET", KEYS[1], "tokens", new_tokens, "last_time", ARGV[3])
redis.call("EXPIRE", KEYS[1], 10) -- 防止key长期不更新
return 1 -- 允许
else
return 0 -- 拒绝
end
- 原子性:务必使用Lua脚本或Redis事务,保证令牌获取和状态更新的原子性。
- 时钟同步:令牌桶依赖时间计算,确保Redis服务器和业务服务器的时间同步(NTP)。
- 内存管理:为每个限流key设置合理的过期时间,避免僵尸key占用内存。
- 降级策略:当Redis不可用时,应启用本地限流(如Guava RateLimiter)作为降级方案。
4.4 三种实现方案对比
| 方案 | 精度 | 内存消耗 | 突发支持 | 实现复杂度 | 推荐场景 |
|---|---|---|---|---|---|
| 固定窗口 | 低(窗口边界突刺) | 低 | 不支持 | 低 | 非关键路径的粗略限流 |
| 滑动窗口 | 高 | 高(存储时间戳集合) | 不支持 | 中 | 对精度要求极高的场景 |
| 令牌桶 | 中(依赖时间精度) | 低(仅存储两个字段) | 支持 | 中 | 企业级热点削峰首选 |
4.5 削峰填谷的完整流程
下图展示了在Redis热点削峰场景中,令牌桶限流如何与业务系统协同工作,实现完整的削峰填谷流程:
通过上述流程可以看出,Redis令牌桶限流在整个削峰填谷架构中扮演着流量阀门的角色。它不直接处理业务逻辑,而是通过精确的速率控制,确保进入后端服务的请求量始终在安全阈值内,从而保障整个系统的稳定性。