Cloudflare Q3 DDoS 报告:蜂群攻击防护窗口怎么设
把防护窗口拆成 10 秒、60 秒、300 秒三层滑动窗口,短层吸收蜂群脉冲、长层限制累计配额,并用 Redis 异常降级到 Nginx 限流兜底,短高频攻击才不会穿透。
- 单层窗口挡不住蜂群攻击,边界还会双倍放行
- 短窗按合法峰值×时长×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 做了完整梳理。这两件事叠在一起,意味着你的限流阈值不能假设上游一直健康。
关键结论:蜂群攻击考验的不是清洗带宽,是计数窗口的精度和判定延迟。
我把应对思路收敛成三条:
- 计数维度不能只有 IP。至少叠加 API Key、User-Agent 指纹、路径三段,否则住宅代理池直接绕开。
- 窗口必须滑。固定桶在时间边界会漏,这一点后面用代码验证。
- 判定延迟要压在 2ms 以内。超过这个数,网关 QPS 一上来就是排队雪崩。
防护窗口为什么不能只设一个固定值?#
因为短窗和长窗要解决的是两个相反的问题。短窗要"容忍突发但快速止损",长窗要"限制总量但不误伤"。塞进一个参数里,必然二选一。
先看固定桶的坑。设一个 1 秒固定窗口、上限 40 次。攻击者在第 0.99 秒打 40 次,第 1.01 秒再打 40 次,两个桶各自计数都不超限,实际 0.02 秒内放过了 80 次——这就是边界双倍放行(Boundary Burst)。滑动窗口(Sliding Window)用有序集合按时间戳裁剪,没有这个缝隙。
参数表:三层窗口的推荐配置,以单 IP 合法峰值 2 rps 为例
| 层 | 窗口长度 | 上限(cap) | 折算速率 | 作用 |
|---|---|---|---|---|
| 短窗 | 10 s | 40 | 4 rps | 吸收正常突发,压制秒级脉冲 |
| 中窗 | 60 s | 300 | 5 rps | 约束持续速率 |
| 长窗 | 300 s | 900 | 3 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 秒够不够?
Redis 挂掉之后限流还能用吗?
三层窗口之间是取最大值还是都要满足?
参考来源
- Cloudflare DDoS Threat Report 2026 Q3: More swarms, fewer giants
- What took the Internet offline in Q3 2026
以上为本文写作时的参考资料,引用观点以原文为准。