用户登录

技术文章

外部链接将在新标签页打开。

Cloudflare Q3 DDoS 报告:蜂群攻击防护窗口怎么设

一句话回答

把防护窗口拆成 10 秒、60 秒、300 秒三层滑动窗口,短层吸收蜂群脉冲、长层限制累计配额,并用 Redis 异常降级到 Nginx 限流兜底,短高频攻击才不会穿透。

核心要点 TL;DR
  • 单层窗口挡不住蜂群攻击,边界还会双倍放行
  • 短窗按合法峰值×时长×2~3 倍突发系数定容
  • Redis 窗口脚本 P99 超过 5ms 就该分片或换本地令牌桶

防护窗口设三层:10 秒短窗吸收蜂群脉冲、60 秒中窗限制累计速率、300 秒长窗做配额兜底,三层都用滑动窗口(Sliding Window)而不是固定桶。只设一层,短高频攻击必然穿透。

先讲人话。传统 DDoS 像一群人堵在门口,来得猛、走得也快,门口的保安看一眼流量图就能反应过来。蜂群攻击(Swarm Attack)是一群麻雀,每秒来几只、啄一口就走,散在几十个 IP 上,单看任何一个 IP 都不算异常,但一天下来接口被啃掉几十万次。Cloudflare 在 Cloudflare DDoS Threat Report 2026 Q3 里给出的口径是 Q3 平均每小时缓解 8200 多次攻击,攻击形态从"巨浪"转向"更短、更高频的打击",同时 CLDAP 反射向量崩塌、几起僵尸网络(Botnet)被端掉之后,攻击者把资源摊薄到更多小批次上。8200 次/小时折合约 2.28 次/秒,一天接近 19.7 万次事件——这个量级意味着防护决策窗口被压缩到了秒级,靠人盯告警已经来不及。

蜂群攻击和传统流量洪水差在哪?#

差别不在峰值,在时间分布和身份分布。我把这两年处理过的两类流量做了个对照:

对比表:蜂群攻击与传统流量洪水的形态差异

特征传统流量洪水蜂群攻击
单次持续几分钟到几小时几秒到一两分钟
峰值带宽数十 Gbps 起步常常低于清洗阈值
源 IP 分布集中,易聚类分散,混在代理池与住宅 IP 里
攻击向量单一放大反射为主多向量轮换,CLDAP 退潮后转向其他 UDP 与 HTTP 混合
对防护的要求带宽清洗 + 黑洞细粒度计数 + 窗口判定速度
误杀风险低高,容易把正常突发打掉

2026 Q3 的另一面是可用性事件本身也在变密,Cloudflare Radar 的季度中断复盘里,电力故障、自然灾害与政府侧断网仍然是主要成因,What took the Internet offline in Q3 2026 做了完整梳理。这两件事叠在一起,意味着你的限流阈值不能假设上游一直健康。

关键结论:蜂群攻击考验的不是清洗带宽,是计数窗口的精度和判定延迟。

我把应对思路收敛成三条:

  1. 计数维度不能只有 IP。至少叠加 API Key、User-Agent 指纹、路径三段,否则住宅代理池直接绕开。
  2. 窗口必须滑。固定桶在时间边界会漏,这一点后面用代码验证。
  3. 判定延迟要压在 2ms 以内。超过这个数,网关 QPS 一上来就是排队雪崩。

防护窗口为什么不能只设一个固定值?#

因为短窗和长窗要解决的是两个相反的问题。短窗要"容忍突发但快速止损",长窗要"限制总量但不误伤"。塞进一个参数里,必然二选一。

先看固定桶的坑。设一个 1 秒固定窗口、上限 40 次。攻击者在第 0.99 秒打 40 次,第 1.01 秒再打 40 次,两个桶各自计数都不超限,实际 0.02 秒内放过了 80 次——这就是边界双倍放行(Boundary Burst)。滑动窗口(Sliding Window)用有序集合按时间戳裁剪,没有这个缝隙。

参数表:三层窗口的推荐配置,以单 IP 合法峰值 2 rps 为例

层窗口长度上限(cap)折算速率作用
短窗10 s404 rps吸收正常突发,压制秒级脉冲
中窗60 s3005 rps约束持续速率
长窗300 s9003 rps累计配额兜底,防慢速爬取

定容公式我给两个,实测够用:

  • cap_short = max_legit_rps × T_short × 2,突发系数取 2,前端有并发预加载的取 3。
  • cap_long = max_legit_rps × T_long × 1.5,长窗系数必须比短窗小,否则长窗等于没有。

失败案例说一个。去年我把短窗设成 1 秒、上限 20,结果一个做批量比对的客户在页面初始化时并发 24 个请求,正常业务被 429 打回,工单量当天涨了三倍。窗口拉到 10 秒、上限 40 之后,同一批请求全部放行,攻击流量照样在第二层被卡住。窗口太短的问题不在漏,在误杀。

怎么用 Redis 滑动窗口把三层逻辑写成可运行代码?#

Redis 的有序集合(Sorted Set)做滑动窗口是最省事的方案:成员用"时间戳-进程号"保证唯一,分数用毫秒时间戳,每次先用 ZREMRANGEBYSCORE 裁掉窗口外的成员再计数。裁剪、计数、写入必须在 Lua 里一次完成,否则并发下会重复放行。

可执行脚本:三层滑动窗口限流(Python 3.10+ / redis-py 5.x)

# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
import os
import time
import logging
import redis

log = logging.getLogger("swarm")
r = redis.Redis(
    host="127.0.0.1", port=6379, db=3,
    socket_timeout=0.05,          # 50ms 必须硬超时,否则拖垮网关
    socket_connect_timeout=0.05,
    retry_on_timeout=True,
    max_connections=64,
)

_LUA = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local win = tonumber(ARGV[2])
local cap = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - win)
local n = redis.call('ZCARD', key)
if n >= cap then
  local first = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
  local wait = tonumber(first[2]) + win - now
  return {0, n, wait}
end
redis.call('ZADD', key, now, ARGV[4])
redis.call('PEXPIRE', key, win)
return {1, n + 1, 0}
"""
_script = r.register_script(_LUA)

# (窗口毫秒, 上限),顺序即执行顺序
WINDOWS = ((10_000, 40), (60_000, 300), (300_000, 900))


def allow(ip: str, now_ms: int | None = None, retries: int = 1) -> tuple[bool, int]:
    """返回 (是否放行, 建议等待毫秒)。Redis 异常时降级放行。"""
    if not ip or len(ip) > 45:            # 边界保护:空值/超长直接拒
        return False, 60_000
    now_ms = now_ms or int(time.time() * 1000)
    for win_ms, cap in WINDOWS:
        member = f"{now_ms}-{os.getpid()}"
        for attempt in range(retries + 1):
            try:
                ok, _, wait = _script(
                    keys=[f"sw:{ip}:{win_ms}"],
                    args=[now_ms, win_ms, cap, member],
                    client=r,
                )
                if int(ok) == 0:
                    return False, min(int(wait), 60_000)   # 上限封顶防溢出
                break
            except redis.RedisError as exc:
                log.warning("window fail ip=%s win=%s err=%s", ip, win_ms, exc)
                if attempt == retries:
                    break        # 降级:本层放行,交给 Nginx 兜底
    return True, 0

参数逐条解释:socket_timeout=0.05 是硬性要求,Redis 一旦抖动超过 50ms,限流本身就成了新的故障点;WINDOWS 三层按短到长执行,命中短窗就直接返回,省掉两次往返;wait 是该层最早成员过期还差多少毫秒,直接塞进 Retry-After 响应头;min(..., 60_000) 防止 Redis 时钟异常时返回一个天文数字的等待时间。

预期输出:单 IP 在 10 秒内连打 45 次,前 40 次返回 (True, 0),第 41 次返回 (False, 约 9900),含义是"再等 9.9 秒"。同机房的 Redis,这个 Lua 脚本往返 P99 实测在 0.8~1.5ms,单分片跑到 3 万 QPS 时 CPU 到 60% 左右就该考虑分片了。

Nginx 层怎么配合 Redis 窗口,避免全站被拖死?#

Redis 窗口管的是业务维度,Nginx 管的是连接维度。两层不能互相替代:Redis 挂了靠 Nginx 兜底,Nginx 挡不住的慢速爬取靠 Redis 的长窗收口。

配置片段:Nginx 1.25.x 的两级 limit_req 加连接数限制

# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
limit_req_zone  $binary_remote_addr zone=swarm_10s:32m rate=4r/s;
limit_req_zone  $binary_remote_addr zone=swarm_60s:64m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:16m;

server {
    listen 443 ssl;
    http2 on;

    location /api/ {
        limit_conn conn_per_ip 24;
        limit_req  zone=swarm_10s burst=40 nodelay;
        limit_req  zone=swarm_60s burst=20;      # 不加 nodelay,超出的排队削峰
        limit_req_status 429;
        limit_conn_status 429;

        proxy_pass http://php_fpm_pool;
        proxy_connect_timeout 1s;
        proxy_read_timeout    5s;
        proxy_next_upstream error timeout http_502;
    }
}

几个取舍必须说清楚。swarm_10s 用了 nodelay,突发请求直接放行不排队,因为短窗本来就是用来吸收突发的;swarm_60s 故意不加 nodelay,超出 burst 的请求会被延迟处理,这个延迟正好把蜂群的第二轮脉冲摊平。代价是延迟会累积,所以 proxy_read_timeout 压到 5s,防止 PHP-FPM 工作进程被排队请求占满。

验证用的是本站的真实接口,注意只对自己的 apikey 做小规模探测,不要对公共接口压测:

# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
for i in $(seq 1 60); do
  curl -sS -o /dev/null \
       --connect-timeout 1 --max-time 3 \
       -H 'X-Trace-Id: swarm-probe-20261030-01' \
       -w '%{http_code} %{time_total}\n' \
       'https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8'
done | sort | uniq -c

--connect-timeout 1 卡住握手阶段的慢连接,--max-time 3 兜住整体耗时,-w 只输出状态码和总耗时便于聚合。预期输出大致是这样:

     40 200 0.187
     20 429 0.003

前 40 次正常返回,200 的耗时集中在 180~220ms,说明请求真的穿透到了源站;后 20 次被 429 拦在 Nginx,耗时掉到 3ms 以内,这才是限流生效的正确形态。如果 429 也耗时 180ms,说明限流规则挂在了错误的位置,请求已经打到后端才被拒。

单条请求的返回体长这样,字段随接口版本会变,字段定义以 GET 示例接口文档 为准:

{"code":200,"msg":"ok","data":{"ip":"8.8.8.8","country":"US"}}

监控与告警清单#

窗口参数定下来只是开始,没有监控就是在盲跑。下面这张表是我在生产里实际挂着的一套,API.PLUS 这类聚合网关场景可以直接抄。

监控与告警清单:短高频攻击下的关键指标与动作

指标建议阈值告警动作
429 占比5 分钟内 > 3%值班群通知,先看是否被扫
单 IP 10s 计数 P99> cap_short 的 80%提前预热扩容,检查是否存在共享出口 IP
窗口 Lua 脚本 P99> 5ms查连接池、慢查询,准备切本地令牌桶
Redis 命令失败率> 0.5%触发降级开关,人工确认上游
源站 TTFB P95> 800ms检查 PHP-FPM 队列与 MySQL 慢查询
黑名单命中率5 分钟内涨 3 倍更新 IP 段与 UA 指纹库
降级放行次数任意一次必须人工介入,说明限流层已经失守

风险清单#

把踩过的坑集中列一下,别散在正文里让人找。

风险清单:窗口策略的失效场景与规避方式

风险影响规避方式
用固定桶而非滑动窗口整点边界双倍放行,攻击可卡点ZSET 按时间戳裁剪,裁剪与计数放进同一段 Lua
只按 IP 限流住宅代理池几万 IP 轮换,形同虚设IP + API Key + UA 指纹三键并联,任一超限即拒
Redis 单点或主从延迟大限流整体失效或计数漂移本地令牌桶兜底,Redis 异常降级放行并立即告警
长窗系数比短窗宽松慢速爬取能持续数小时不被发现长窗系数严格小于短窗,按 1.5 倍以内定容
把 429 打在源站层后端资源已被消耗,等于没限限流下沉到 Nginx 或网关入口,源站只做兜底
共享 NAT 出口被误杀整栋写字楼或校园网用户全挂对已知共享出口 IP 单独提额,配合按 Key 限流

最后的取舍:参数先定死,再谈清洗#

我的建议是把三层窗口当成基线配置写进网关代码,而不是每次出事临时调。先用 2 rps 的合法峰值跑一周,观察 429 比例和单 IP 计数 P99,再决定突发系数是 2 还是 3。这套东西不需要多贵的设备,Redis 单分片加两台 Nginx 就够撑起中小规模的 API 网关,沐小悠API 的统计口径在 API 文章中心 里也有对应说明,统计与限流用同一套时间戳口径,对账时少很多扯皮。

需要提醒的是,窗口不是越严越好。参数收紧一半,误杀大概率翻倍,而攻击者换个出口就能绕开。

适用范围

  • HTTP/HTTPS 七层接口,单 IP 合法峰值在 10 rps 以内,Redis 与网关同可用区部署。
  • 已有 Nginx 或等价反向代理层,能在入口处返回 429 并携带 Retry-After。
  • 业务容忍秒级延迟抖动,例如查询类、聚合类 API。

不适用范围

  • 支付回调、Webhook 接收这类必须全量放行、事后对账的端点,限流会直接丢单。
  • 四层 UDP 反射型攻击,几 Gbps 的 CLDAP 或 NTP 放大不靠应用层窗口解决,要靠上游清洗。
  • 移动端首次启动的批量并发拉取,单 IP 峰值可能超过 20 rps,需要按 Key 维度单独定容。
  • Redis 与网关跨地域部署、RTT 超过 20ms 的场景,窗口脚本的往返成本会盖过收益。

延伸阅读#

想直接动手的话,可以先看这些接口文档和相关文章:

常见问题(FAQ)

防护窗口设成 1 秒够不够?
不够。1 秒固定桶在整秒边界会双倍放行,攻击者卡在边界前后各打一轮就能绕过。改用 10 秒滑动窗口吸收脉冲,再用 60 秒中窗约束累计请求量。
Redis 挂掉之后限流还能用吗?
能用,但要在代码里显式降级。限流函数捕获 RedisError 后放行,由 Nginx 的 limit_req 与 limit_conn 兜底,同时把降级次数打进监控,5 分钟内必须人工确认。
三层窗口之间是取最大值还是都要满足?
都要满足,逻辑上是与关系。任何一层判定超限就返回 429,并给出该层的建议重试等待时间,避免客户端立刻重试造成二次冲击。

参考来源

  1. Cloudflare DDoS Threat Report 2026 Q3: More swarms, fewer giants
  2. What took the Internet offline in Q3 2026

以上为本文写作时的参考资料,引用观点以原文为准。

作者:沐小悠

本文约 20 分钟读完 · 关键词:蜂群攻击、DDoS防护、滑动窗口限流、Cloudflare报告、Redis限流

更多文章 GET 示例接口

「」标签下的其他文章