4. 削峰填谷原理:流量削峰概念、令牌桶与漏桶算法、Redis实现限流的基本思路

在热点事件、秒杀活动或突发流量场景下,系统面临的核心矛盾是:瞬时请求速率远高于后端服务的处理能力。削峰填谷正是解决这一矛盾的核心思想——通过缓冲和整形,将突发的流量高峰削平,转化为相对平稳的请求流,从而保护后端系统不被击垮。

4.1 流量削峰概念

流量削峰并非简单地拒绝请求,而是通过请求排队、速率限制和异步处理等手段,让系统在单位时间内处理的请求量不超过其最大承载能力。削峰填谷的核心价值体现在:

  • 保护后端资源:避免数据库、缓存、微服务等组件因瞬时高并发而崩溃。
  • 提升系统稳定性:平滑流量波动,减少因过载导致的连锁故障。
  • 优化用户体验:通过排队和降级策略,让大部分请求得到合理响应,而非直接返回错误。
关键指标:削峰填谷的效果通常用“峰值削减率”和“请求积压深度”来衡量。理想状态下,后端处理的请求速率应接近一个恒定值,而前端瞬时峰值被缓冲层吸收。

4.2 令牌桶与漏桶算法

限流算法的核心是控制请求的速率和突发性。业界最经典的两种算法是漏桶算法和令牌桶算法,它们在处理突发流量时的行为截然不同。

4.2.1 漏桶算法(Leaky Bucket)

漏桶算法将请求比作水滴,系统是一个底部有漏洞的桶。无论进水速度多快,桶内的水(请求)都会以固定的速率从漏洞流出。如果桶满了,后续的水(请求)就会溢出(被拒绝)。

  • 核心特性:强制输出速率恒定,不允许突发。
  • 适用场景:对流量整形要求严格的场景,如网络流量整形、数据库写入限流。
  • 缺点:无法应对突发流量,即使系统有空闲资源,也无法加速处理。

4.2.2 令牌桶算法(Token Bucket)

令牌桶算法以固定速率向桶中添加令牌,每个请求需要消耗一个令牌才能被处理。桶有最大容量,允许积累一定数量的令牌。当突发流量到来时,只要桶中有足够的令牌,就可以一次性处理多个请求。

  • 核心特性:允许一定程度的突发,平均速率受限于令牌生成速率。
  • 适用场景:大多数互联网业务场景,如API限流、秒杀入口保护。
  • 优点:既能限制平均速率,又能应对突发流量,灵活性更高。
对比维度 漏桶算法 令牌桶算法
输出速率 严格恒定 平均恒定,允许突发
突发处理 不支持 支持(桶容量决定突发量)
资源利用 可能浪费空闲资源 更高效,能利用空闲资源
实现复杂度 较低 中等
典型应用 网络QoS、流量整形 API限流、秒杀系统
选型建议:在Redis热点削峰场景中,令牌桶算法是更优选择。因为它允许在系统空闲时积累令牌,在流量高峰时释放,更符合“削峰填谷”中“填谷”的思想——利用低谷期的资源处理高峰期的积压请求。

4.2.3 算法对比流程图

下图直观展示了两种算法在处理突发流量时的行为差异:

漏桶算法与令牌桶算法对比 漏桶算法 vs 令牌桶算法 行为对比 漏桶算法 请求积压 固定速率流出 突发输入 溢出丢弃 令牌桶算法 令牌池(最大容量) 固定速率生成令牌 消耗令牌流出 突发请求 消耗多个令牌 关键区别: 漏桶强制恒定输出,无法利用空闲资源;令牌桶允许积累令牌,在突发时释放,更符合“削峰填谷”的工程实践。

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令牌桶削峰填谷流程 Redis令牌桶削峰填谷完整流程 用户请求 Redis令牌桶 限流判断 请求排队/降级 业务处理 (削峰后) 后端服务 (稳定处理) 异步反馈:处理完成通知排队请求 核心思想:令牌桶将瞬时高峰流量整形为平稳流量,被拒绝的请求进入排队或降级通道,待后端处理完成后异步通知,实现真正的“削峰填谷”。

通过上述流程可以看出,Redis令牌桶限流在整个削峰填谷架构中扮演着流量阀门的角色。它不直接处理业务逻辑,而是通过精确的速率控制,确保进入后端服务的请求量始终在安全阈值内,从而保障整个系统的稳定性。