在CC防护(Challenge Collapsar,即HTTP请求型DDoS攻击防护)场景下,滑动窗口计数器和令牌桶算法是两种最常用的限流手段,但它们的适用场景、精度表现和实现复杂度差异很大。简单说:滑动窗口计数器适合对短时间突发流量做精确计数,防止瞬间打满服务器连接;令牌桶算法适合对长期流量做平滑限速,允许一定程度的突发但整体速率可控。实际生产环境中,很多高防系统是把两者结合使用的,比如用滑动窗口做前端粗筛,再用令牌桶做后端精控。下面我会从原理、实现、对比、选型四个维度,把这两个算法彻底讲透。
一、滑动窗口计数器:原理与实战实现
滑动窗口计数器的核心思想是把时间切成固定大小的窗口,统计每个窗口内的请求数量。当窗口滑动时,旧数据被丢弃,新数据进来,这样就能实时反映"最近N秒内"的请求量。它最大的优势是对突发流量的感知非常直接——比如你设了1秒窗口、阈值100次,那么只要1秒内超过100次请求,直接触发拦截,逻辑简单粗暴但有效。
不过滑动窗口有个经典问题叫"边界突变"。举个例子:假设窗口大小是1秒,阈值100。在第0.9秒到第1.1秒这200毫秒内,如果前后各来了100次请求,那么从0.9到1.0秒这个窗口有100次,从1.0到1.1秒这个窗口也有100次,单独看都没超限,但实际上这200毫秒内总共有200次请求,远远超过了预期。这就是为什么很多系统会采用"滑动窗口日志"或者"滑动窗口计数器+细粒度时间片"的方案来弥补。
下面是一个基于Redis实现滑动窗口限流的核心代码示例:
import time
import redis
class SlidingWindowLimiter:
def __init__(self, redis_client, window_size=1, max_requests=100):
self.redis = redis_client
self.window_size = window_size # 窗口大小(秒)
self.max_requests = max_requests # 窗口内最大请求数
def is_allowed(self, key):
now = time.time()
window_start = now - self.window_size
# 移除窗口外的旧数据
self.redis.zremrangebyscore(key, 0, window_start)
# 统计当前窗口内的请求数
current_count = self.redis.zcard(key)
if current_count >= self.max_requests:
return False
# 记录本次请求
self.redis.zadd(key, {str(now): now})
# 设置key过期时间,防止内存泄漏
self.redis.expire(key, self.window_size * 2)
return True
这段代码的逻辑很清晰:用Redis的Sorted Set存储每个请求的时间戳,每次判断时先清理过期数据,再统计当前窗口内的数量。生产环境中通常还会加上分布式锁、多维度key(比如按IP+URL维度)等增强逻辑。
二、令牌桶算法:原理与实战实现
令牌桶算法的模型更形象:想象有一个固定容量的桶,系统以恒定速率往桶里放令牌,每个请求来了必须先拿到一个令牌才能被处理。如果桶满了,新令牌就丢弃;如果桶空了,请求就得等或者被拒绝。这种机制天然允许"突发"——只要桶里有存货,短时间内可以处理大量请求,但长期来看速率被令牌生成速率严格限制住了。
令牌桶有两个关键参数:桶容量(burst size)和令牌生成速率(rate)。比如桶容量500、速率每秒100个令牌,意味着正常情况下每秒处理100个请求,但如果之前空闲了一段时间,桶里攒了500个令牌,那么瞬间可以处理500个请求而不被限流。这对CC防护特别有价值,因为正常用户偶尔的快速连续点击不应该被误判为攻击。
下面是一个基于内存实现的令牌桶限流器:
import time
import threading
class TokenBucketLimiter:
def __init__(self, capacity, rate):
self.capacity = capacity # 桶容量
self.rate = rate # 每秒生成令牌数
self.tokens = capacity # 当前令牌数
self.last_time = time.time()
self.lock = threading.Lock()
def is_allowed(self):
with self.lock:
now = time.time()
elapsed = now - self.last_time
# 补充令牌
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
self.last_time = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
这是单机版的实现,多机分布式场景下通常会用Redis的Lua脚本来保证原子性,避免多节点各自维护令牌桶导致限流失效。实际高防产品中,令牌桶往往作为后端精细限流的核心组件,配合前端的IP信誉、行为分析等多层策略一起工作。
三、两种算法的核心对比维度
从精度角度看,滑动窗口计数器对"固定时间窗口内的请求总量"控制非常精确,适合需要严格限制某个时间段内请求次数的场景。令牌桶则对"长期平均速率"控制精确,但对瞬时峰值的控制相对宽松,因为它允许利用桶内积累的令牌。
从突发处理能力看,滑动窗口几乎不允许任何突发——窗口一到就重置计数,超了就拦。令牌桶天然支持合理突发,这对用户体验更友好。但如果攻击者利用令牌桶的突发容量反复打满再等恢复,也可能造成周期性冲击,这时候需要配合滑动窗口做二次限制。
从实现复杂度看,滑动窗口在分布式环境下需要维护时间序列数据(比如Redis Sorted Set),存储和清理开销较大,尤其是高QPS场景下每个请求都要写一条时间戳记录。令牌桶的分布式实现相对轻量,只需要维护一个令牌计数和上次更新时间,用Lua脚本就能保证原子性,性能更好。
从内存消耗看,滑动窗口如果要精确到毫秒级,需要存储大量时间戳数据,内存占用随窗口大小和QPS线性增长。令牌桶只需要存几个数字(令牌数、上次时间),内存占用几乎可以忽略。
四、CC防护中的实战选型建议
在真实的CC防护体系中,我的建议是不要二选一,而是分层组合。第一层用滑动窗口做粗粒度防护:比如按IP维度设1秒窗口、200次阈值,快速拦截明显的洪峰攻击。这一层的目的是"挡住大浪",不追求精细,追求速度和覆盖面。
第二层用令牌桶做精细限流:对通过第一层的流量,按IP+URL甚至IP+UserAgent维度做令牌桶控制,比如每秒50个令牌、桶容量200。这一层的目的是"平滑流量",防止单个IP对特定接口做持续性慢速攻击,同时给正常用户的合理突发留出空间。
第三层叠加行为分析和人机验证:对于触发限流阈值的请求,不是直接丢弃,而是返回验证码页面或者JavaScript挑战,让真实用户完成验证后放行。这一层才是CC防护的核心壁垒,因为纯算法限流总有被绕过的可能,但人机验证能有效区分机器和真人。
还有一个容易被忽略的点:滑动窗口的窗口大小选择非常关键。窗口太小(比如100毫秒),正常用户的一次页面加载可能就触发限流;窗口太大(比如10秒),攻击流量已经把连接池打满了才被发现。一般建议根据业务接口的正常响应时间分布来设定,Web接口通常1-3秒比较合理,API接口可以更短。
五、性能优化与常见坑
滑动窗口在高并发下的性能瓶颈主要在Redis的ZREM和ZCARD操作上。优化方案包括:用时间片预分桶(比如把1秒分成10个100毫秒的子桶),减少每次操作的数据量;或者用HyperLogLog近似计数换精确计数,牺牲一点精度换性能。但CC防护场景下精度很重要,通常不建议用近似方案。
令牌桶的常见坑是时钟漂移问题。多机部署时如果各节点时钟不一致,令牌补充的计算就会出错,导致有的节点限流过严、有的过松。解决方案是统一用Redis服务器时间或者NTP同步,在分布式Lua脚本中用Redis的TIME命令获取当前时间。
另外一个实战经验是:不要只看单一维度。纯按IP限流在共享IP(比如公司出口、移动基站NAT)场景下会误伤大量正常用户。建议加上URL维度、Cookie维度、甚至设备指纹维度做多维联合限流,才能在防护效果和用户体验之间找到平衡。
总结一下,滑动窗口计数器是"硬拦截"利器,适合对抗短时洪峰;令牌桶是"软调节"工具,适合长期流量治理。两者不是竞争关系而是互补关系,在成熟的CC防护架构中几乎都是同时存在的。选型时根据业务特点、攻击类型和系统架构综合判断,没有万能方案,只有最适合的组合。
