CC攻击的本质不是拼带宽,而是拼资源。攻击者通过构造大量看似合法的请求,消耗服务器CPU、内存和数据库连接,最终导致正常用户无法访问。防御CC攻击的核心手段之一就是请求频率控制,而滑动窗口算法是实现精准限流的关键技术。很多人一上来就想着用漏桶或令牌桶,但在实际Web防护场景中,滑动窗口的灵活性和精确度往往更胜一筹。

为什么固定窗口计数器会失效

先看一个典型场景:你设置了一分钟内最多允许100次请求。攻击者可以在第59秒到第60秒之间瞬间发出100个请求,然后在下一分钟的第1秒再发出100个请求。这样在两秒内就发出了200个请求,完全绕过了你的限制。这就是固定窗口的边界问题,攻击者专门挑窗口切换的临界点发起脉冲式攻击,让限流形同虚设。固定窗口算法实现简单,用一个Redis的INCR命令配合过期时间就能搞定,但它的缺陷在真实攻防场景中会被无限放大。

滑动窗口的核心思想

滑动窗口算法不再按照固定的时间刻度来划分窗口,而是让窗口随着时间推移连续滑动。你可以把它想象成一个不断向前滚动的区间,只统计当前时间点往前推一个窗口周期内的请求数量。比如你设定的是60秒内最多100次请求,那么任意一个请求进来时,系统都会检查过去60秒内这个用户已经发起了多少次请求。如果超过100次就拒绝,没超过就放行。这样一来,攻击者无论选择哪个时间点发起脉冲攻击,都无法突破限制,因为窗口是连续的,不存在可以钻空子的临界点。

滑动窗口的两种实现模式

第一种是滑动日志模式。系统记录下用户每一次请求的时间戳,当新请求到来时,删除所有超出窗口范围的旧记录,然后统计剩余记录的数量。这种实现最精确,但存储成本高,尤其是高并发场景下,每个用户维护一个时间戳列表会消耗大量内存。第二种是分段滑动窗口,也叫滑动窗口计数器。把整个窗口切分成若干个更小的子窗口,每个子窗口独立计数,窗口滑动时只丢弃最旧的子窗口数据,加上最新的子窗口数据。这种方式在精度和性能之间取得了很好的平衡,是生产环境中最常用的方案。

Redis实现滑动窗口计数器的代码解析

下面这段代码展示了如何用Redis的有序集合实现一个精确的滑动窗口限流器。有序集合的score字段存储请求的时间戳,member可以用请求ID或者直接用一个随机字符串。每次请求进来,先把当前时间戳作为score插入有序集合,然后删除窗口范围之外的旧记录,最后统计集合中的元素数量即可判断是否超限。

import time
import redis

class SlidingWindowRateLimiter:
    def __init__(self, redis_client, window_size, max_requests):
        """
        window_size: 窗口大小,单位秒
        max_requests: 窗口内最大请求数
        """
        self.redis = redis_client
        self.window_size = window_size
        self.max_requests = max_requests

    def is_allowed(self, user_id):
        current_time = time.time()
        key = f"rate_limit:{user_id}"
        
        # 使用管道保证原子性
        pipe = self.redis.pipeline()
        # 移除窗口外的旧记录
        pipe.zremrangebyscore(key, 0, current_time - self.window_size)
        # 统计当前窗口内的请求数
        pipe.zcard(key)
        # 添加当前请求记录
        pipe.zadd(key, {str(current_time): current_time})
        # 设置key的过期时间,避免内存泄漏
        pipe.expire(key, self.window_size)
        
        _, current_count, _, _ = pipe.execute()
        
        return current_count < self.max_requests

这段代码的精妙之处在于使用了Redis的管道机制,把移除旧记录、统计数量、添加新记录三个操作打包成一个原子事务。如果不使用管道,在高并发下会出现竞态条件,导致计数不准确。另外,zremrangebyscore用score范围删除数据,天然契合滑动窗口的时间范围查询需求。过期时间设置为窗口大小即可,因为超过窗口的记录会被自动清理,key本身也会在窗口结束后自动销毁。

分段滑动窗口的工程实践

精确的滑动日志虽然准确,但当用户量达到百万级别时,每个用户的有序集合可能存储成百上千条记录,Redis内存会迅速膨胀。分段滑动窗口把一个大窗口切成N个小段,比如60秒的窗口切成6个10秒的小段。每个小段用一个独立的计数器,窗口滑动时只需要把最旧的那个小段清零并复用。这样每个用户只需要维护固定数量的计数器,内存占用是常数级别的。缺点就是精度从毫秒级降到了小段粒度,但对于CC防护来说,10秒的精度完全够用,攻击者不可能在10秒内打出足以瘫痪服务器的流量而不被察觉。

结合IP画像的动态阈值调整

固定阈值有一个致命问题:阈值设高了挡不住攻击,设低了误伤正常用户。真正落地的CC防护系统不会对所有用户一视同仁。你需要构建一个IP画像系统,根据历史行为给每个IP打分。正常用户访问频率低、页面跳转有逻辑、停留时间合理;而攻击请求往往集中在单一URL、没有JS执行能力、请求间隔均匀得像机器人。把这些特征量化后,对高风险IP自动降低阈值,对正常用户适当放宽。滑动窗口算法提供了精确的计数基础,而动态阈值则让限流策略具备了自适应能力。

分布式环境下的滑动窗口一致性

单机限流好做,但生产环境通常是多台服务器同时提供服务。如果每台机器独立计数,攻击者只要把请求分散到不同服务器就能轻松绕过限制。解决方案是集中式计数,所有服务器共享同一个Redis集群。但这又引入了网络延迟和Redis性能瓶颈的问题。折中方案是本地预判加远程校验:先在本地用滑动窗口做初步限流,达到本地阈值的80%时再去Redis做精确校验。这样既减少了Redis的访问压力,又保证了分布式环境下的一致性。本地窗口可以设置得比远程窗口略小,留出缓冲余地。

Nginx层面的滑动窗口集成

在应用层做限流已经慢了一步,流量已经打到了后端服务。最佳实践是在Nginx或OpenResty层面就完成频率控制。OpenResty的lua-resty-limit-traffic模块提供了resty.limit.req接口,底层就是基于漏桶算法实现的。但如果你需要滑动窗口的精确性,可以自己用Lua脚本操作Redis有序集合,逻辑和前面Python版本完全一致。把这段Lua脚本嵌入到Nginx的access_by_lua阶段,在请求到达后端之前就完成判断和拦截。对于被拦截的请求,返回429状态码,同时在响应头中加入Retry-After和X-RateLimit-Reset字段,让客户端知道什么时候可以重试。

误杀处理与降级策略

任何限流系统都无法做到100%准确。搜索引擎爬虫、RSS阅读器、企业内部代理这些场景会产生大量合法但频率较高的请求。你需要维护一个白名单,对已知的善意爬虫和合作伙伴IP放行。同时要设计降级开关,当Redis出现故障时,限流模块应该自动切换到宽松模式或者直接放行,而不是把所有请求都拦截掉。这个降级逻辑必须在代码层面实现,不能依赖运维手动切换。一个实用的做法是设置Redis连接超时和重试次数,超时后走本地内存计数作为兜底方案。

滑动窗口算法的局限与补充手段

频率控制只是CC防护链条上的一环。攻击者可以控制请求频率在阈值以下,但每个请求都去查询一个复杂且耗时的数据库语句,同样能把服务器拖垮。所以还需要配合其他手段:验证码挑战用于区分人机、行为分析用于识别异常模式、静态资源缓存用于减少动态请求压力。滑动窗口解决的是请求数量的问题,但请求质量的问题需要更深层次的检测机制。把频率控制作为第一道防线,把深度行为分析作为第二道防线,两者结合才能构建完整的CC防护体系。

滑动窗口算法的真正价值在于它提供了一种精确且灵活的时间窗口控制能力。从固定窗口到滑动窗口的演进,本质上是从粗粒度限流向细粒度限流的转变。在实际部署中,选择Redis有序集合实现精确版还是分段计数器实现高性能版,取决于你的业务规模和精度要求。无论哪种实现,核心思路都是把时间窗口从固定的刻度线变成连续滑动的区间,彻底消除边界漏洞。掌握了这个技术,你的CC防护系统就具备了对抗脉冲式攻击的基础能力。