CC攻击的防护难点,在于攻击流量和正常流量在单次请求的“长相”上几乎一模一样。一个简单的HTTP GET请求,不带任何攻击特征,却能通过海量的频次将服务器资源耗尽。要解决这个问题,单纯依靠传统的防火墙规则已经失效,必须深入到访问频率的行为分析维度。基于IP的滑动窗口计数与全局计数器协同工作,是目前在应用层防御中性价比极高、实现逻辑非常清晰的一种防刷策略。它的核心思路是:不把IP一棍子打死,也不放过瞬时洪峰,通过局部和全局两个维度的数据交叉验证,精准识别出那些“装成正常用户的机器人”。

滑动窗口计数的本质:解决“临界点”误杀

在频率控制中,最原始的方法是固定窗口计数。比如设定一个规则:单个IP在1分钟内访问/api接口不得超过60次。这种固定窗口(例如从00:00:00到00:00:59)有一个致命缺陷,叫做“临界突发”。假设用户在00:00:50到00:00:59这10秒内发送了60个请求,然后在00:01:00到00:01:09又发送了60个请求。从两个独立的固定窗口看,每个窗口都刚好是60次,没有超限。但从时间轴上看,用户在20秒内连续发出了120次请求,这显然是一次典型的CC攻击脉冲。攻击者只要摸清了你的窗口重置时间点,就能轻松绕过限制。

滑动窗口计数器正是为了修补这个漏洞而生。它不再以物理时间点划分区间,而是维护一个随时间滚动的数据链表或时间轮。我们需要将时间轴切分为更细粒度的格子,例如每1秒或每100毫秒一个格子。当设定“1分钟内不超过60次”的规则时,系统实际上会动态计算当前时间点往前推60秒这个区间内的计数器总和。每当一个新请求进来,系统会将当前时间所在的格子计数加1,同时丢弃掉60秒前的过期格子。这种数据结构通常使用Redis的ZSET(有序集合)来实现,以时间戳为score,以请求的唯一标识为member,或者更轻量级地使用List结合时间戳标记。这样一来,无论攻击者如何选取攻击起始点,系统监控的都是一个平滑滚动的60秒窗口,临界突发攻击将直接导致窗口内的计数瞬间冲破阈值,从而被立刻拦截。

滑动窗口的数据结构与实现细节

在实际工程落地中,滑动窗口的性能消耗是必须考虑的因素。如果直接使用Redis的ZSET,每个IP的每次请求都要执行一次ZADD和ZREMRANGEBYSCORE操作,当并发IP量达到数十万级别时,Redis的CPU负载会急剧上升。更优的方案是采用“时间轮算法”结合内存哈希表。我们可以预先在内存中分配一个长度为60的数组(假设窗口为60秒,粒度为1秒),数组的每个元素存储该秒内的请求计数和该秒对应的时间戳。每次请求到来时,根据当前秒数对60取模定位到数组索引。如果该索引存储的时间戳与当前秒一致,则计数累加;如果不一致,说明该格子已过期,清零后重新计数。在判断是否超限时,只需要遍历这个长度为60的数组,将时间戳在有效范围内的格子计数累加即可。这种操作的时间复杂度极低,且无需频繁申请内存,非常适合高并发网关层使用。

以下是一个基于内存时间轮的简易实现逻辑,用于演示滑动窗口的本地计数:

// 简易滑动窗口计数器(基于时间轮)
class SlidingWindowCounter {
    constructor(windowSize, granularity) {
        this.windowSize = windowSize; // 窗口大小,如60秒
        this.granularity = granularity; // 粒度,如1秒
        this.slots = new Array(windowSize).fill(0);
        this.timestamps = new Array(windowSize).fill(0);
    }

    // 处理新请求
    addRequest() {
        const now = Math.floor(Date.now() / 1000);
        const index = now % this.windowSize;
        
        // 如果当前时间戳与槽位记录的时间戳不同,说明该槽位已过期
        if (this.timestamps[index] !== now) {
            this.slots[index] = 0;
            this.timestamps[index] = now;
        }
        
        this.slots[index]++;
    }

    // 获取当前窗口内的总计数
    getCount() {
        const now = Math.floor(Date.now() / 1000);
        let total = 0;
        for (let i = 0; i < this.windowSize; i++) {
            // 只累加未过期的槽位
            if (now - this.timestamps[i] < this.windowSize) {
                total += this.slots[i];
            }
        }
        return total;
    }
}
局部视角的局限:为何需要全局计数器

滑动窗口解决了单个IP的瞬时突发问题,但它本质上是“局部视角”。它假设每个IP都是独立的攻击源。但在现代CC攻击中,攻击者早已升级为分布式僵尸网络,也就是DDoS与CC的结合体。攻击者可能控制着成千上万个代理IP或肉鸡,每个IP的请求频率都极低,甚至每分钟只发送1到2次请求。从单个IP的滑动窗口来看,这些IP完全符合速率限制,属于“正常用户”。然而,当这些海量的低速IP同时涌向同一个URL时,服务器依然会不堪重负。这就是典型的“慢速CC攻击”或“分布式低频攻击”。

要防御这种攻击,就必须引入“全局计数器”。全局计数器不再关心是哪个IP发起的请求,它只关心“某个资源”在“某个时间段”内被访问的总次数。例如,一个秒杀活动的商品详情页,正常业务预估的QPS(每秒查询率)是5000,如果全局计数器检测到该页面的实际QPS突然飙升到了5万,且持续超过10秒,那么无论这些请求来自多少个不同的IP,系统都应该判定该资源正在遭受攻击。全局计数器监控的是业务层面的宏观流量模型,它弥补了IP滑动窗口在分布式低频攻击面前的盲区。

协同机制:漏斗式过滤与联动处置

IP滑动窗口和全局计数器并不是二选一的关系,而是要通过“漏斗式过滤”逻辑进行串联。当一个请求到达防护网关时,数据包需要经过两层过滤:第一层是IP滑动窗口,第二层是资源全局计数器。但这里的顺序和逻辑设计非常讲究。如果先过全局计数器,一旦全局触发阈值,可能会误伤在攻击期间访问的正常用户。因此,更精细的做法是:IP滑动窗口负责“精准狙击”高频单IP,全局计数器负责触发“熔断预警”。

具体协同流程如下:首先,请求进入IP滑动窗口模块,如果该IP在窗口内计数超标,直接丢弃或弹出验证码,这部分处理的是“暴力型”攻击者。如果IP滑动窗口放行了该请求,请求会进入全局计数器模块。全局计数器维护着每个资源的实时访问速率。当全局计数器发现某个资源的访问速率超过了预设的“安全阈值”但未达到“极限阈值”时,系统进入“观察模式”。在观察模式下,系统可以动态调低所有访问该资源的IP滑动窗口阈值。比如,原本单IP限制是60次/分钟,现在动态调整为10次/分钟。这种动态缩容策略能够有效压制分布式低频攻击的总体流量,因为攻击者虽然IP多,但每个IP能分到的“合法配额”被大幅压缩了。

当全局计数器突破“极限阈值”,意味着服务器即将过载,此时系统进入“熔断模式”。在熔断模式下,不能简单粗暴地拦截所有请求,否则会误杀所有正常用户。此时,协同机制会引入“信任IP优先”策略。系统会结合历史行为数据,将IP分为白名单、灰名单和黑名单。对于在全局滑动窗口内表现良好、有正常业务交互(如携带有效Token、有浏览深度)的IP,标记为灰名单或白名单,允许其以较低速率继续访问。而对于无历史记录、仅发起单次请求的陌生IP,直接拦截或强跳验证码。这种协同机制,实际上是将“基于IP的微观行为”与“基于资源的宏观负载”进行了实时关联计算。

工程落地中的关键优化:避免状态爆炸

在大型分布式系统中落地这套协同方案,最大的挑战是状态同步与内存开销。如果一个网站有数百万个独立IP活跃,为每个IP维护一个滑动窗口,Redis或本地内存的存储压力会非常大。对于IP滑动窗口,必须引入“惰性清理”和“稀疏存储”策略。只有当IP发起请求时,才在内存中创建该IP的窗口对象;如果某个IP超过一个窗口周期没有新请求,其窗口数据应当被被动过期或主动回收。对于全局计数器,通常不需要存储每个IP的明细,只需要存储资源的聚合计数,这部分数据量极小,但并发极高。为了应对高并发下的全局计数原子性问题,可以使用Redis的INCRBY原子操作,或者采用本地累加加定时异步上报的方式,牺牲极小的实时性换取吞吐量。

此外,在多层代理(如CDN、反向代理)架构下,获取真实源IP是防刷的基础。务必确保通过X-Forwarded-For或自定义头部透传真实IP,并在网关层进行IP白名单校验,防止攻击者伪造头部绕过限制。对于全局计数器的阈值设定,不建议使用固定值,最好接入机器学习或自适应算法,根据过去7天同一时刻的历史流量进行动态基线计算,这样能有效识别节假日或促销活动带来的正常流量峰值,避免误触发熔断。

从防刷到业务风控的演进

IP滑动窗口与全局计数器的协同,本质上解决的是网络层和应用层交界处的问题。但真正的业务防刷还需要更深的维度。比如,攻击者可能会利用大量真实代理IP,每个IP都维持着极低的请求速率,且总请求量并未触发全局熔断,但每次请求都在恶意查询数据库或消耗大量CPU。这时候,仅仅依靠频率计数已经不够,还需要引入“请求指纹”和“业务成本控制”。可以将滑动窗口的概念扩展到“用户ID”或“设备指纹”上,对未登录用户的设备指纹进行滑动窗口限制,对已登录用户进行ID限制。同时,在全局计数器层面,除了统计请求次数,还可以统计“慢查询次数”或“总响应时长”,一旦发现某个接口的全局平均响应时间异常升高,立即联动IP窗口进行限流。这套协同机制不是死板的规则,而是一套可以不断叠加维度的防御矩阵,其核心思想始终是:用局部的精准限制保护单点,用全局的宏观感知保护系统,两者联动,缺一不可。