CC攻击的可怕之处不在于带宽耗尽,而在于它用极低的成本模拟正常用户请求,穿透硬件防火墙,直接把后端服务器的连接池、数据库连接、PHP-FPM进程打满。传统的基于IP的频率限制在攻击者拥有庞大代理池的情况下几乎失效——每个IP只发几个请求,阈值根本触发不了。要解决这个问题,必须把限流的颗粒度从“IP维度”升级到“资源维度”,并且让限流状态在网关层共享。Nginx作为最前置的流量入口,配合Redis做分布式令牌桶,是目前性价比最高、落地最平滑的方案。

为什么令牌桶比漏桶更适合CC防护

漏桶算法强制固定速率处理请求,虽然能削峰填谷,但在遭遇突发CC攻击时,正常用户的请求也会被大量排队或丢弃,用户体验极差。令牌桶则允许一定程度的突发——桶里有足够的令牌时,请求可以立即被处理。在Nginx层面实现令牌桶,核心思路是:以请求的URI+某种标识作为key,每个key对应一个令牌桶,桶的容量和发放速率可以根据该资源的实际处理能力动态调整。比如登录接口,正常用户一分钟最多请求5次,那就设置容量为5、速率为每秒1/12个令牌;而静态资源可以设置更高的突发容忍度。

Nginx+Lua+Redis的架构选型

OpenResty是落地这个方案的最佳载体。它把LuaJIT嵌入Nginx,让Nginx在access阶段就能直接调用Redis,无需把请求转发到后端应用层再做判断。架构上,多台Nginx共享同一个Redis集群,所有网关节点看到的令牌桶状态是一致的。这样无论攻击流量从哪个节点进来,消耗的都是同一个桶里的令牌。Redis的原子性操作保证了在高并发下令牌扣减不会超发。如果公司没有OpenResty的技术栈,也可以用Nginx的HTTP模块配合外部脚本,但延迟和复杂度都会增加,不推荐在生产环境这么干。

令牌桶的数据结构设计

在Redis里,每个令牌桶用两个key来存储:一个存储当前令牌数量,一个存储最后一次补充令牌的时间戳。具体来说,key的命名规则可以设计为“ratelimit:资源标识:用户标识”,比如“ratelimit:/api/login:192.168.1.1”。令牌数量用Redis的String类型存储,时间戳也存为String。为什么不直接用Hash?因为我们需要对令牌数量执行DECR操作,而Redis对Hash的field操作虽然也支持原子增减,但在集群模式下,如果key的hash slot分散在不同节点,就无法用Lua脚本保证多个key操作的原子性。所以拆成两个独立的String key,用同一个slot的hash tag来确保它们落在同一个节点上,比如把key设计成“{ratelimit:/api/login:192.168.1.1}:tokens”和“{ratelimit:/api/login:192.168.1.1}:timestamp”。

核心Lua脚本实现

把令牌桶的逻辑写成Redis的Lua脚本,在服务端一次性执行,避免多次网络往返。脚本的逻辑是:先读取上次补充时间,计算从那时到现在应该新产生多少令牌,把令牌数加到桶里但不能超过容量上限,然后判断当前令牌数是否足够扣除一个,如果够就扣减并返回允许通过,不够就返回限流。以下是核心脚本:

-- KEYS[1]: 令牌数量key
-- KEYS[2]: 时间戳key
-- ARGV[1]: 令牌桶容量
-- ARGV[2]: 令牌生成速率(个/秒)
-- ARGV[3]: 当前时间戳(秒)
-- ARGV[4]: 请求消耗令牌数(通常为1)

local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
    last_tokens = capacity
end

local last_refreshed = tonumber(redis.call("get", timestamp_key))
if last_refreshed == nil then
    last_refreshed = now
end

local delta = math.max(0, now - last_refreshed)
local filled_tokens = math.min(capacity, last_tokens + (delta * rate))
local allowed = filled_tokens >= requested

if allowed then
    local new_tokens = filled_tokens - requested
    redis.call("set", tokens_key, new_tokens)
    redis.call("set", timestamp_key, now)
    -- 设置过期时间,防止冷数据常驻内存
    local ttl = math.ceil(capacity / rate) + 60
    redis.call("expire", tokens_key, ttl)
    redis.call("expire", timestamp_key, ttl)
end

return allowed and 1 or 0

这个脚本把计算、判断、更新三步合并在一次Redis调用里完成,避免了并发下的竞态条件。TTL的设置逻辑是:桶满所需时间加上一个缓冲值,这样即使这个key后续不再被访问,也不会永久占用内存。

Nginx侧的集成配置

在OpenResty的nginx.conf里,在access_by_lua_block阶段调用Redis执行这个脚本。先要在http块里初始化Redis连接池:

http {
    lua_shared_dict ratelimit_lock 10m;
    
    # 初始化Redis连接池
    init_by_lua_block {
        local redis = require "resty.redis"
        -- 此处可以预加载脚本的SHA值,用EVALSHA执行
    }
    
    server {
        listen 80;
        
        location /api/ {
            access_by_lua_block {
                local redis = require "resty.redis"
                local red = redis:new()
                red:set_timeouts(1000, 1000, 1000)
                
                local ok, err = red:connect("127.0.0.1", 6379)
                if not ok then
                    -- Redis不可用时降级放行,避免误杀
                    ngx.log(ngx.ERR, "Redis连接失败: ", err)
                    return
                end
                
                -- 构造key,使用hash tag确保同一slot
                local base_key = "{ratelimit:" .. ngx.var.uri .. ":" .. ngx.var.remote_addr .. "}"
                local tokens_key = base_key .. ":tokens"
                local timestamp_key = base_key .. ":timestamp"
                
                -- 不同接口设置不同的容量和速率
                local capacity = 10
                local rate = 1  -- 每秒1个令牌
                
                -- 对敏感接口做更严格限制
                if ngx.var.uri == "/api/login" then
                    capacity = 5
                    rate = 0.2  -- 每5秒产生1个令牌
                end
                
                local script = [[
                    -- 上面的Lua脚本内容,或者用EVALSHA引用预加载的脚本
                ]]
                
                local result, err = red:eval(script, 2, tokens_key, timestamp_key, 
                    capacity, rate, ngx.time(), 1)
                
                if result == 0 then
                    ngx.status = 429
                    ngx.header["Retry-After"] = "5"
                    ngx.say("请求过于频繁,请稍后再试")
                    ngx.exit(429)
                end
                
                -- 连接放回池子
                local ok, err = red:set_keepalive(10000, 100)
            }
            
            proxy_pass http://backend;
        }
    }
}

这里有几个关键细节:Redis连接失败时选择放行而不是拒绝,这是基于可用性优先的原则——限流模块不应该成为单点故障。生产环境一定要用set_keepalive把连接放回连接池,否则每次请求都新建连接,Redis很快会被连接数打满。另外,脚本内容应该用init_by_lua_block预加载并通过EVALSHA执行,避免每次请求都传输完整脚本内容,节省带宽。

多维度限流的扩展策略

单纯按IP限流在代理池攻击下确实不够。可以把用户标识升级为更稳定的维度:如果系统有登录态,优先用用户ID;对于未登录用户,可以用IP+User-Agent+设备指纹的组合哈希。具体做法是在Nginx的access阶段先解析Cookie或JWT Token,提取用户ID。如果没有登录,就取IP和UA拼接后的MD5值作为标识。这样攻击者即使换IP,只要UA和设备指纹不变,依然会被限流。更进一步,可以对全局的某个接口设置一个总令牌桶,比如“/api/search”接口整体QPS上限1000,单个用户上限10,两个桶同时检查,任一超限就拒绝。这样既能防止单用户滥用,也能保护后端服务的整体容量。

监控与动态调整机制

限流规则写死后,遇到攻击规模变化或者业务高峰期,容易误伤。需要把令牌桶的容量和速率做成可动态调整的。可以在Redis里维护一个配置Hash,Nginx在每次请求时用缓存的方式读取配置,比如每10秒重新加载一次。配置变更只需要修改Redis里的值,所有Nginx节点会在下一个加载周期生效,无需重启。同时,把限流拒绝的次数通过ngx.log记录下来,用日志采集系统聚合分析。当某个接口的拒绝率突然飙升,可以触发告警,运维人员根据实际情况调高阈值或者切换到更严格的黑名单模式。黑名单可以存储在Redis的Set里,Nginx在access阶段先检查请求标识是否在黑名单中,命中则直接返回403,连令牌桶都不走,节省计算资源。

落地过程中踩过的坑

时钟同步问题容易被忽视。令牌补充量依赖时间差计算,如果多台Nginx服务器时间不同步,计算出的delta会有偏差。虽然Redis服务器的时间是统一的,但脚本里传入的当前时间戳是Nginx服务器的时间。如果Nginx时间比Redis快,就会多算令牌;比Redis慢就会少算。解决方法是统一使用Redis的TIME命令获取时间,或者在脚本里用Redis服务器的系统时间。另一个坑是Redis集群模式下的脚本执行。如果key分布在不同的slot,Lua脚本会报错“CROSSSLOT Keys in request don't hash to the same slot”。这就是前面强调要用hash tag的原因,确保同一令牌桶的两个key落在同一个节点。还有一个性能坑:高并发下,Redis的EVAL操作会消耗不少CPU,如果QPS达到数万级别,建议用Redis Cluster分片,把令牌桶的key均匀分布到多个节点上,避免单节点CPU成为瓶颈。

这套方案已经在多个日均千万级请求的生产环境稳定运行超过一年。关键在于把复杂逻辑下沉到Redis的Lua脚本里,Nginx只负责传参和判断返回值,整个链路在1-2毫秒内完成,对用户体验几乎没有影响。相比购买昂贵的硬件WAF或者依赖第三方云防护,自建Nginx+Redis令牌桶方案成本可控、策略灵活,能根据业务特点做精细化的流量整形,是中小团队应对CC攻击性价比极高的选择。