# 秒杀场景应用层 CC 攻击的完整复盘:19.7 亿条日志、7 Gbps 与一份账单

背景

2025 年 8 月,一个数字藏品抢购 App 的秒杀场景。一年多以后归档时翻出了当时的监控截图和日志导出,把数据重新算了一遍,记录下来备查。

业务形态是典型的秒杀:每天固定时点开抢,流量在 1~2 分钟内拉满,抢完即结束。

这个秒杀为什么不好做

业务方给了五条约束,每一条都直接影响技术选型:

  1. IP 只能“堵”不能“封”:私域运营,封 IP 会带来大量用户投诉,只能限速、尽量不拉黑;
  2. 不限制多开:后端不限制同一用户多点登录,一个人多设备并发是允许的;
  3. 客户端自带“自动抢购”:用户点一次,App 会以每秒 10 次的频率轮询多个抢购接口;
  4. 分配要相对平均:不能出现多开或脚本用户抢走大头、正常用户抢不到的情况;
  5. 抢购时长要控在 2~5 分钟:太快没人气,太慢影响体验。

这五条合起来,等于把常规手段全堵死了:不能用 IP 封禁(第 1 条),不能用设备指纹严格限制(第 2、3 条),不能只按总量限流(第 4 条),还得主动控制放行节奏(第 5 条)。攻击流量和真实抢购流量在 IP、UA 上完全无法区分——这篇文章要解决的问题,源头就在这里。

架构的三段演进

阶段 链路 换掉的原因 代价
一 酷盾云防火墙 → ingress-nginx → ACK → Redis / RocketMQ / RDS 起始方案 后台管理不便、限流能力少、链路延迟高;RDS 主从延迟在洪峰下压不住
二 阿里云 ESA + DDoS 高防 → 微服务网关 → ACK → Redis / RocketMQ / PolarDB RDS → PolarDB 压主从延迟;ingress-nginx → 微服务网关,要开箱即用的限流、降级、熔断和秒级生效的配置 防护、流量、请求数三块分开计费,攻击越强账单越高(见后文)
三 快快云游戏盾(WAF + DDoS + CDN 三合一)→ ACK → Redis / RocketMQ / PolarDB 封禁功能更丰富,防护带宽更高,价格适中,支持自定义请求头拦截 ——

两个替换的技术原因单独说:

  • RDS → PolarDB:秒杀场景下写放大集中在库存扣减,RDS 主从延迟在洪峰期会明显放大,换 PolarDB 主要是压这段延迟;
  • ingress-nginx → 微服务网关:要的是开箱即用的限流、降级、熔断,以及秒级生效的实例级配置。网关直接接业务 Pod,就能按 Pod 做限流,这是 Nginx Ingress 做不到的粒度。

为什么最后还是换掉了阿里云

不是能力问题——阿里云这套的日志大盘是后面所有分析的基础,至今还在用当时的截图。劝退的是套餐的计费结构。

把当时的套餐拆开,实际是三块互不相干的东西捆在一起卖(目录价 37,792 元/月):

套餐项 套餐内 超出后
静态流量加速 6,000 GB / 月(折算 2.832 元/GB) 按量
DDoS 防护 保底 30 Gbps,最大 300 Gbps(20,800 元/月) 100 元/Gbps/天
WAF + HTTPS 请求数 各赠送 3 亿次 / 月 0.5 元/万次、0.1 元/万次

看起来最贵的是 DDoS 那两万,但它恰恰不是账单问题的来源:攻击峰值 7 Gbps,离 30 Gbps 的保底还差得远,这一项几乎不会超。

真正大头的是另外两行:

  • 套餐给的请求数额度是每月 3 亿次,而 ESA 记录的那 6 小时就有 9.2 亿次——还没到全天就把整月额度打穿三倍,超出的部分按 0.5 + 0.1 元/万次计;
  • 响应流量:按 19.7 亿条日志 × 约 1.4 KB/条 粗估,单日响应流量约 2.7 TB,而流量月包只有 6,000 GB(约 6 TB)——一个攻击日就吃掉近一半。

也就是说:防护带宽买多大都“扛得住”,但钱是按“被打进来的流量和请求数”收的——攻击越大,账单越高。这跟买防护时的直觉正好相反:以为买的是上限,实际买的是额度。

对“每天固定时点开抢、且长期被盯着打”的业务来说,这不是偶发成本,而是可预期的固定支出,一次攻击下来后付费就到万元级。

换到快快云游戏盾,除了封禁功能更丰富、防护带宽更高,成本结构可预期也是一个重要原因。

常态化措施

下面是几轮对抗之后逐步固化下来的措施,不代表案例发生时都已经在位。

  1. 多段 IP 限流:低阈值只丢请求不拉黑,高阈值才封禁 1~2 分钟——对应“堵而不封”;
  2. 封禁境外 IP:业务用户全在国内,境外来源一律不服务;
  3. 按身份限流:token 放在请求头,入口按指定 header 维度限速,叠加 IP 限流兜底;
  4. 网关白名单只放安全产品回源 IP:防止用户自定义解析域名绕过防火墙直连源站;
  5. 抢购前全链路扩容:调云 OpenAPI 下发定时任务,按「数据层 → 算力层 → 业务层」顺序扩容副本,回落交给云侧到期自动完成;
  6. 开抢后逐步放开:网关按实时负载逐级放宽后端 Pod 配额;开抢瞬间先设 30 秒的低配额准入窗口,等 Pod 预热完成、连接池建立后再逐步放开;
  7. 把 429 包装成业务提示:限流与拒绝统一返回一个轻量静态的“当前抢购人数过多,请稍后再试”页面(在边缘直接返回,不回源、可缓存),App 端识别后展示排队动画而不是错误 Toast。——对外只改文案,不改状态码:网关侧仍保留 429 与自定义 header 做统计,否则监控、告警、排障会直接失明。

这条看似只是前端体验,实际服务的正是第 1 条约束“堵而不封”:用户感知是“太火爆了”,而不是“我被封了”,投诉量差别很大。

业务侧还有一项:人机校验(研发实现,不在运维职责内)——它挡住了纯脚本,但挡不住真人加脚本的混合模式与协议模拟,所以流量层的限流仍然必须有。

本文的案例来自第二阶段

下面这次攻击发生在「ESA + DDoS 高防」阶段。阿里云这套的日志大盘最详尽(请求数、带宽、流量、回源都能拆开看),因此后面的分析都以它为准。

那时候的攻击还带着试探性质,到了后面换成游戏盾的阶段,已经变成每天 14:00 开抢必到——从「偶尔被打」走到「每天被打」,防护的定位也跟着从「应急」变成「常态化」。

当天下午,边缘侧开始出现异常。四波攻击并不卡着 14:00 的开抢时刻,波次之间有明显间隔。事后从监控和访问日志里捞出来的几个数:

指标 数值
边缘全量访问日志 1,973,013,980 条 / 日
其中境外来源 1,448,797,030 条(73.5%)
单分钟客户端请求数峰值 38,147,000 次(折合约 63.6 万 QPS)
响应带宽峰值 约 7.0 Gbps
响应流量峰值 约 55.9 GB / 分钟
清洗后到达网关的境外请求 47,786,412 次 / 日
后端业务实际承载 约 2 万 QPS

单分钟客户端请求数峰值

这里不再复述“我们怎么扛住的”,只讲三个当时真正卡住判断的问题,以及最后怎么定的。数字都能用一条 SQL 复现,方法写在每节末尾。

先把指标说清楚

复盘里最容易误判的就是“QPS 到底多少”。

  • 63.6 万 QPS 不是秒级峰值,是某一分钟 3814 万次请求 ÷ 60 得到的分钟均值,秒级峰值只会更高;
  • 19.7 亿是写入边缘访问日志的条数(境内外合计,含被 WAF 拦截的那部分),既不是客户端发出的全部请求,也不是源站收到的请求:四层清洗掉的流量没到边缘节点、不在这份日志里,而源站侧我们只按境外维度做了聚合(4779 万),没有留全量总数。两者差 1~2 个量级很正常;
  • 4779 万是“到达源站网关、且来源在境外”的请求数,不是一个站点的全部请求。

日志条数统计:全天 19.7 亿条

日志条数统计:全天 19.7 亿条,四波攻击。

判定一:这到底是带宽型攻击,还是应用层 CC?

直觉判断是看带宽:7 Gbps 已经很大了。但 7 Gbps 放在 63.6 万 QPS 的背景下,反推出来的数据却不一样:

55.9 GB/分钟 ÷ 3814 万次/分钟 ≈ 1.4 KB / 次

平均一次响应 1.4KB —— 这个量级和正常抢购接口返回一个商品 JSON 没什么区别。

ESA 响应带宽峰值

第一个可复用的公式:用「带宽 ÷ 请求数」反推单请求字节数。

  • 单请求只有几十字节、带宽却很高 → 典型的小包大流量(UDP 反射、畸形包洪泛),这是 DDoS,打的是带宽和连接数,防护思路是清洗设备 + 带宽扩容;
  • 单请求接近正常业务响应体、但次数被打爆 → 应用层 CC,打的是 CPU、数据库连接和业务逻辑,防护思路必须回到请求层:限流、人机识别、频次控制。

我们属于后者。这也直接决定了后续的精力主要花在请求层的识别与限流上,而不是继续堆清洗带宽。

只看“带宽峰值”这一个指标做选型,几乎一定会买错。

判定二:攻击来自多少 IP?

第二个问题决定限流维度。如果攻击集中在少数几个 IP 上,直接封就行;如果是几十万个 IP 各打几次,封 IP 就是自残——还会误伤 NAT 后面的真实用户。

做法是按 IP 聚合看分布,而不是看总量。以清洗后到达网关的那 4779 万次境外请求为样本,取请求量最高的 1 万条 IP 记录:

指标 数值
记录的源 IP 数 9,643 个
单 IP 最高请求数 167,293 次
第 10 名 21,046 次
按 IP 聚合后的中位数 2 次
按 IP 聚合后的均值 207 次

均值 207、中位数 2、Top1 16.7 万——典型的长尾加头部结构:极少数 IP 承担了绝大部分请求,同时存在近万个只打了两三次的 IP。

再看国家维度(同一批数据):

国家 IP 数 请求数 每 IP 均值
澳大利亚 809 317,461 392
印度尼西亚 921 285,700 310
美国 934 188,758 202
德国 57 188,076 3,300
阿尔巴尼亚 8 176,996 22,124

阿尔巴尼亚 8 个 IP 打出 17.7 万次,德国 57 个 IP 打出 18.8 万次——这种“国家小、IP 少、请求极密”的组合,基本可以确定是代理池 / 云主机池在跑脚本,而不是真实用户分布。

而且不是一批:每 IP 的请求密度横跨三个数量级——美国 202 次、澳大利亚 392 次、德国 3,300 次、阿尔巴尼亚 22,124 次。大池子的住宅代理靠“IP 多、每个只摊几百次”躲单 IP 限速,数据中心 VPS 用中等密度铺量,少数固定出口做最高频的轮询。三档密度同时出现,说明至少是多个来源在同时压上来;至于是几个团伙,日志里看不出来,但对处置没区别——封掉其中一批,其他几批照样在打。

第二个公式:先看「每 IP 请求密度」,再看总量。

真实用户的长尾是“IP 多、每 IP 请求少,且集中在一个时间窗内的一次点击”;代理池是“单个或少量 IP 高频,且跨多个国家成簇出现”。

这个结论决定了后面限流不能以 IP 为主维度(会把 NAT 后面的真实用户一起限掉),只能把 IP 频率当兜底,主维度必须换成用户身份。

判定三:清洗到底洗干净了没有?

第三个问题是当时最容易被忽略的:安全产品显示“已拦截 7.7 亿次”,源站该不该继续信这个数?

ESA 请求分析:拦截、边缘响应与回源的拆分

ESA 请求分析,2025-08-12 08:55–14:50:总计 9.2 亿次请求,其中 7.74 亿被 ESA 防护拦截、1.25 亿由边缘直接响应、只有 2140.8 万落到源站。

这张图是全量口径(境内外合计、6 小时窗口),与下面按境外维度做的对账不是同一个分母,但结论一致:绝大多数被挡在了边缘,真正落到源站的是少数。问题在于穿透进来的那部分。

我们做了一次对账:边缘侧境外请求 14.5 亿,到达源站网关的境外请求 4779 万,约 3% 穿透。比例本身不算离谱,但结合业务表现——抢购开始后接口超时、用户“过了验证却下不了单”——就知道这 3% 不全是漏网的真实用户。

后来定位到两个具体问题:

  1. 大量请求直连源站 IP,完全绕过了边缘;
  2. 清洗后的回源流量,在网关侧又被自己的防护规则拦了一次。

第一个问题的修法是:网关侧只放行安全产品的回源节点并单独加白,其余直连流量在网关层直接丢弃;第二个问题是把网关上的拦截规则按“回源白名单”重新梳理,两层防护职责不重叠。

教训是:防护是两层的,但账必须对得上。 边缘说拦了多少、源站收到多少、业务成功了多少——这三个数放在一起看,才知道防护有没有真正生效。只看其中一层,很容易得出“已经防住了”的错误结论。

三层漏斗:每一层负责什么

换产品不是目的,先要说清三层漏斗各自兜住什么。选型时的三问:

  • 应用层高频请求 → 看请求层规则、人机识别、限流维度;
  • 带宽被打满 → 看清洗能力和线路质量;
  • 成本 → 按活动日峰值买还是按常态买,差一个数量级。

最终架构:

CDN + 高防 + WAF(清洗) → 网关(按用户配额限流) → 业务(库存兜底)
  • 安全层:以请求头中的用户凭证(token/JWT)为维度限单用户速率,IP 频率兜底;
  • 网关层:按用户配额限流,洪峰期根据实时负载逐步放宽单 Pod 配额;
  • 业务层:库存扣减的原子性由业务侧保证,网关不承担防超卖。

为什么敢在洪峰期逐步放宽网关配额? 因为先确认了每一层的失败边界:超卖的兜底在业务层的原子操作里,网关放宽只影响排队体验,不会造成超卖。先想清楚“哪一层兜不住会怎样”,再动阈值。

四条复盘

一年后从备份里翻出当时的报价单和更细的监控图,把数重新对了一遍。

  1. 口径先于结论。同一份日志,按“请求数”聚合和按“IP 数”聚合能得出相反的判断。分析之前先定义清楚:你说的 QPS 是哪一分钟的、是边缘的还是源站的。事后还发现更麻烦的一层——日志条数、请求数、计费请求数是三个不同的数(这次分别是 19.7 亿/日、9.2 亿/6 小时、以及账单上的那个数),混着用,对账必然对不上。
  2. 不要用单一指标选型。7 Gbps 看起来是带宽问题,除以请求数才发现是应用层问题。只看“带宽峰值”这一个指标,几乎一定买错防护产品。
  3. 防护要分层,账要对齐。边缘拦截数、源站到达数、业务成功数三个数必须能对上,否则“防住了”只是自我安慰。事后拿到的 ESA 请求分析把这件事量化了:6 小时内 9.2 亿次请求里,7.74 亿被拦在边缘、1.25 亿由边缘直接响应,只有 2140.8 万(2.3%)落到源站——比例很漂亮,但真正要回答的还是“那 2.3% 是什么”。
  4. 成本要和风险一起建模。选防护产品时,除了“能扛多大”,还要算“被打一次要花多少”。DDoS 高防卖的是防护带宽上限,但账单落在流量和请求数额度上——攻击越大、打得越久,账越高。对每天固定时点开抢的业务,这笔钱是可以提前算出来的固定成本,不是意外支出。

这都是当时踩出来的坑。


附:复现用的 SQL 思路(表名已脱敏)

-- 1. 全量日志量
SELECT count(*) FROM edge_access_log WHERE __date__ = '2025-08-12';

-- 2. 境外占比(按国家聚合)
SELECT ClientCountryCode, count(*) AS pv
FROM edge_access_log
WHERE __date__ = '2025-08-12' AND ClientCountryCode <> 'CN'
GROUP BY ClientCountryCode
ORDER BY pv DESC
LIMIT 100;

-- 3. 源站侧按 IP 聚合(看密度分布)
SELECT count(*) AS pv,
       downstream_remote_address,
       ip_to_country(split_part(downstream_remote_address, ':', 1)) AS country
FROM gateway_access_log
WHERE __date__ = '2025-08-12'
  AND ip_to_country(split_part(downstream_remote_address, ':', 1)) <> '中国'
GROUP BY downstream_remote_address, country
ORDER BY pv DESC
LIMIT 10000;

第 3 条把结果导出成 CSV,再算中位数和分位数,比在 SQL 里硬算方便。

posted @ 2026-10-29 11:48  再过半世纪  阅读(8)  评论(0)    收藏  举报