DDoS 这个词在运维和安全圈里被说得太笼统了,笼统到很多人一听告警就条件反射地去加带宽、换高防,结果该稳的还是不稳。我印象很深的一次值班,凌晨一点半业务侧报"页面加载不出来",我第一反应是入口流量被打满了,登录监控一看,出口带宽才用了不到三成,但服务器的连接数曲线已经顶到天花板,应用进程的 CPU 反而是空闲的。那次之后我才真正把"流量型、连接型、应用层"这三类消耗拆开来看——它们表面上都表现为服务不可用,实际上是三场完全不同性质的资源争夺战,抢的东西分别是网络管道、内核的会话状态表、以及应用进程的处理时间。你只有判断出对手在抢哪一样,处置动作才有意义。这篇内容我按自己的理解顺序梳理一遍:三类攻击各自消耗什么、从哪些指标能看出来、防御端有哪些真能落地的手段,以及我踩过的几个典型误判。适合已经上手在管服务、被这类问题折磨过的同行看,也适合刚接触这块、还在背概念的朋友对照着建立整体框架。
1. 三种"消耗"其实在抢三种不同的资源
1.1 带宽、连接表、CPU时间——三条互不相干的瓶颈
先把这三件事在脑子里摆正位置。网络数据进入你的服务器,要经过几道关口:链路上的物理带宽、网卡与内核协议栈的包处理能力、内核为每个连接维护的状态记录、最后才是应用进程读走数据并执行业务逻辑。这三类打法分别卡在不同的关口上,所以表现出来的"症状"完全不一样。
流量型消耗的目标是最靠外的一层:把链路带宽灌满,或者把网络设备的包处理能力打满。这时候服务器本身可能一点事没有,CPU 空着,内存空着,但数据包在路上就进不来了,外部请求超时,健康检查失败。它比拼的是谁的带宽和清洗能力更厚,跟你的代码质量、数据库优化毫无关系。
连接型消耗往内走了一层,目标是内核的连接表和端口资源。它不追求把管道灌满,而是用极其廉价的方式让你维持海量的"半成品连接",把内核的会话记录槽位耗光。槽位满了之后,正常用户的三次握手都排不进来,表现为连接超时或者握手失败,但带宽曲线可能只涨了一点点,CPU 也很淡定。
应用层消耗最靠内,目标是应用进程和工作线程。请求完全合规,三次握手正常完成,HTTP 报文格式挑不出毛病,但它专挑那些"每处理一次都很贵"的接口反复打——耗 CPU 的计算、耗数据库的复杂查询、耗第三方调用的慢接口。带宽没满、连接数不高、内核一切正常,就是业务的响应时间一路飙,线程池被占死。
我在实际排查里最常用的一个判断动作,就是同时看三条曲线:入口带宽利用率、并发连接数(尤其是处于非 ESTABLISHED 状态的数量)、应用的平均响应时间与线程池排队数。哪条先异常、哪条最先顶到上限,基本就锁定了攻击类型。这个动作花不了三十秒,但能省掉后面半小时的瞎折腾。
1.2 为什么同样"打不开",处置动作却完全相反
这一小节是我最想强调的,因为我见过太多因为搞错类型而"越救越死"的案例。
如果你把应用层攻击误判成流量型,最可能做的动作是加大带宽、上高防清洗。结果是流量确实被放行了,因为人家流量本来就不大,请求长驱直入打到应用层,你那点线程池照样被占满,钱花了问题没解决。更糟的是部分清洗设备会做会话保持和延迟放行,反而让本就宝贵的线程挂得更久。
反过来,把流量型误判成应用层,去调线程池大小、加机器,也是白费。包在链路上就被丢了,你后端加十台机器也收不到请求,扩容的钱等于打水漂。
还有一种更隐蔽的误判:把连接型攻击当成流量型。有些人看到告警第一反应是"洪水来了",其实对方发的是大量小包,带宽占用并不高,但每秒包数(pps)极高。很多设备标称的带宽单位是 bit/s,标称能力很漂亮,实际包处理能力(pps)才是短板。小包打过来,带宽看着还有余量,设备先被打挂了。
我总结下来一句话:流量型靠"管道和清洗",连接型靠"内核和限速",应用层靠"识别和限频"。三条路的技术栈几乎没有重叠,方向错了就是在错误的战场上堆积资源。所以下面三章我按"原理—指标—手段"这个顺序分别拆开讲,中间穿插一些我实际踩过的具体细节。
2. 流量型消耗:把出口管道灌满的最粗暴打法
2.1 反射放大:借别人的服务器当放大器
流量型里最经典也最难缠的一类是反射放大。它的思路非常"省力":攻击方并不需要自己有巨大的带宽,只需要伪造源地址,把请求发给互联网上那些"有问必答"的服务,让这些服务把体积大得多的应答全部回给被攻击的目标。这就好比有人用你的名字在网上订了一万份外卖,全部送到别人家门口,送餐员的数量和你的下单量完全不成比例。
它成立需要三个条件同时具备,理解这三条比记住任何工具都有用:
- 协议不需要先建立会话。基于 UDP 的查询类服务天然满足,因为没有握手,服务端收到包就直接回,无法验证源地址真实性。
- 应答体积显著大于请求体积。这个比值就是放大倍数,公开的分析报告里,一些查询类协议在特定应答下能到几十倍甚至数百倍量级,这也是为什么这类打法在历史上造成过单次数百 Gbps 的流量事件。
- 互联网上存在大量配置不当、对任意来源都提供应答的服务实例。这一条是根本原因,也是防御侧真正能改善的地方。
防御上,作为普通服务方你其实做不了太多,因为流量不是从你的网络产生的。你能做的三件事:一是接入上游清洗,把流量在骨干侧牵引掉;二是用 Anycast 一类的地址分散方案,把同一地址的服务点铺到多个位置,让流量被就近吸收;三是对被攻击的域名做灰度降级,先把非核心业务的解析摘掉,保核心。这里有个我踩过的坑:很多人只做第一步,以为买了清洗就万事大吉,实际上清洗服务往往有"回源带宽"的限额,一旦攻击流量超过限额,回源链路直接被压死,业务照样断。所以签这类服务时一定要盯清楚回源带宽和清洗阈值这两个数字,而不是只看标称的防护峰值。
至于根治,属于整个生态层面的问题:网络侧对源地址做严格校验、查询类服务默认不对外网开放递归,这两件事做了,放大的土壤就没了。作为使用方,我建议至少把自己名下的这类对外服务全部自查一遍,关闭不必要的外部查询能力——这既是保护别人,也是防止自家的服务器被当成"帮凶"。
2.2 从三个指标里认出"小包洪水"还是"大包洪峰"
流量型攻击的识别比想象中简单,关键看你有没有埋对指标。我一般看这三个:
| 指标 | 含义 | 异常时说明什么 |
|---|---|---|
| 入口 bps(比特/秒) | 链路带宽占用 | 冲高到接近链路容量,典型的大包洪峰或正常大流量 |
| 入口 pps(包/秒) | 设备包处理压力 | 冲高但 bps 不高,典型的小包洪水 |
| 平均包长(bps ÷ pps ÷ 8) | 单包平均字节数 | 突然掉到几十字节,说明对方在用极小的包拼 pps |
平均包长这个指标特别管用,但很多监控面板默认不给,需要自己算或者让采集侧加上。我遇到过一种情况:带宽监控显示只涨了 15%,看着风平浪静,结果防火墙先扛不住了,因为对方是拿几十字节的小包刷 pps,设备的会话建立速率被瞬间打爆。如果当时我先看了 pps 和平均包长,就不会浪费二十分钟去查后端。
还有一个细节:协议分布。流量型攻击在协议分布上通常极度偏斜,某一类协议突然占到 90% 以上是很强的信号。正常业务的流量分布一般是相对平滑的,HTTP、HTTPS、DNS、NTP 各有各的比例。看到某一类异常膨胀,基本就能确定对方的打法,也就能针对性地在边界设备上做过滤。不过过滤规则要慎重,我曾经过于激进地整段封禁某类协议,结果把自己内部的时间同步和解析都影响了,属于典型的误伤,后来改成"只丢弃来自外网、目的为内部该服务端口、且源地址不在白名单"这种更精细的规则才稳妥。
2.3 源站硬扛这件事,越早放弃越好
有种想法我听过很多次:"我们机房带宽很大,硬扛一下问题不大。" 这个思路在一种场景下勉强成立:攻击量远低于你的链路容量,且你边缘设备的 pps 能力足够。但现实是这个前提几乎不成立,因为攻击方调整成本极低,而你的链路容量是固定的。
更重要的是,即使你真的"扛住了",被占满的链路会连带影响同机房的其他业务,你的上行设备会持续高负载,故障风险成倍上升。真正的解法是把流量在够靠前的位置就处理掉,源站始终只承接"已经过筛的干净流量"。这也是为什么我一直建议把源站的地址做得尽量隐蔽:不做直接的公网解析、回源走专线或固定通道、对外只暴露中间层地址。源站地址一旦泄露,前面做的所有清洗工作都可能被绕过。
3. 连接型消耗:不打满带宽,专打状态表
3.1 半开连接是怎么把队列占住的
要讲清楚这类攻击,得先回顾一下三次握手。客户端发 SYN,服务端回 SYN-ACK 并把这条连接放进半开队列等待最终确认;收到 ACK 后,连接从半开队列移到已完成队列,等待应用进程调用 accept 取走。
这里有个天然的脆弱点:半开队列的长度是有上限的,内核不会无限接客。经典的 SYN 洪水就是狂发伪造源地址的 SYN 包,服务端老老实实为每一个都分配一个半开槽位并回 SYN-ACK,但那个伪造的地址根本不会回 ACK。槽位被占满、超时后才释放,而新的 SYN 进来没地方放,正常的用户握手就失败了。整个过程,攻击方几乎不消耗什么资源,防守方却被拖进了状态维护的泥潭。
内核早就给了对策,也就是SYN Cookie:不再为半开连接预分配状态,而是把连接信息编码进 SYN-ACK 的序列号里,等对方回 ACK 时再还原校验。开启之后,半开队列被撑爆的风险大幅下降。
在 Linux 上是这个开关:
# 1 = 启用,2 = 仅在队列满时启用,0 = 关闭 sysctl -w net.ipv4.tcp_syncookies=1我个人的建议是设成 1 而不是 2。设 2 看起来更"聪明",只在压力大时才启用,但代价是流量陡增的瞬间你可能正在切换,而那个瞬间恰恰是最危险的。常开的问题是会丢失一部分 TCP 选项信息(比如窗口缩放),对长肥管道(高延迟大带宽)的吞吐有一定影响,但对绝大多数面向公网的业务,这点影响远小于被打穿的风险。
与它配套的还有两个参数:net.ipv4.tcp_max_syn_backlog管半开队列,net.core.somaxconn和 listen 时传的 backlog 共同决定已完成队列。这里有个非常常见的坑——很多框架的 listen backlog 默认值偏小,你内核参数调得再大,应用只申请了很小的 backlog,队列照样早早溢出。我在一次压测里就遇到过这个:内核看着挺宽裕,实际吞吐上不去,最后发现是应用侧配置没改。
3.2 端口、会话记录、文件句柄的连锁枯竭
连接型攻击的第二层杀伤在于它会顺着资源链条往下传染。几个常见的连锁反应:
端口耗尽。如果你的服务作为客户端去连后端(比如反向代理连上游),每条连接会占用一个本地临时端口,端口范围是有限的(默认大约两三万个)。连接不释放,端口就被占住,新的上游连接建立不了,前端表现就是 502 或超时。这种故障特别迷惑人,因为前端看着完全正常,问题全在后端无法建连。
会话跟踪表打满。如果边界上开了连接跟踪,每条流都会占一个条目。条目满了之后,内核会直接丢弃新流,日志里会看到大量 "table full, dropping packet"。这个报错很有辨识度,看到它基本可以确定是连接数层面的问题。
文件句柄耗尽。每条连接在进程里对应一个文件描述符,进程的 fd 上限(ulimit -n)打满后,日志里会刷 "Too many open files",甚至导致配置文件都读不进来,故障表现离原始原因非常远。
我把这几条整理成一张对照表,排查时按顺序看:
| 观察点 | 命令或指标 | 打满后的典型现象 |
|---|---|---|
| 会话跟踪条目 | cat /proc/sys/net/netfilter/nf_conntrack_count对比nf_conntrack_max | 内核日志刷 table full,新连接直接被丢 |
| 本地临时端口 | ss -s看 TIME-WAIT 与已用端口 | 连上游失败,报无法分配地址 |
| 进程文件句柄 | ls /proc/<pid>/fd | wc -l对比ulimit -n | 日志刷 Too many open files |
| 半开与完成队列 | ss -lnt看 Send-Q / Recv-Q | 队列溢出计数上涨,握手失败率升高 |
提示:连接数告警一定要区分状态。ESTABLISHED 多和 SYN-RECV 多是两个完全不同的问题,前者可能是业务增长或者慢请求堆积,后者才更像攻击。只看总数不看状态分布,是我早期最常犯的错误。
3.3 真正值得动的几个内核参数,以及它们的取舍
参数调优这块有很多流传甚广但需要谨慎对待的说法。我按自己的实践经验说几个真正有用的,以及为什么。
net.ipv4.tcp_syncookies=1,前面讲过,常开。net.ipv4.tcp_max_syn_backlog适当调大,比如 8192 到 16384 区间,具体看并发量级,不是越大越好——它只是缓冲,配合 Cookie 之后意义有限,调太大只是浪费一点内存。net.core.somaxconn调到 4096 或更高,同时务必去改应用侧的 listen backlog,两头对齐才有意义。
关于 TIME-WAIT,我一直反对盲目开tcp_tw_recycle(新内核已经移除),tcp_tw_reuse在客户端场景可以开,服务端要谨慎,因为它会让一些依赖时间戳的安全校验变松。TIME-WAIT 本身是保护机制,不是敌人,靠缩短tcp_fin_timeout硬压时间在真正的连接攻击面前杯水车薪。
net.ipv4.tcp_abort_on_overflow这个参数要特别小心。设成 1 时,完成队列溢出会直接回 RST,好处是客户端快速失败,坏处是在瞬时突发下会误杀正常用户。我一般保持默认 0,宁可让客户端重试,也不要主动 RST。
还有一个被低估的手段是限速,比如按源地址限制新建连接速率。这在连接型攻击下非常有效,因为对方的连接几乎全部来自有限的源地址池(或者可识别的异常地址分布)。配合边界设备做每秒新建连接数(CPS)的限制,比在内核里死磕参数要直接得多。
4. 应用层消耗:请求看起来很"合法",但每一条都很贵
4.1 先算清楚一次请求在后端到底花了多少资源
应用层攻击最阴的地方在于它不发畸形包,握手正常、报文格式规范、字段齐全,边界设备看不出任何异常。它的逻辑是:找到你最贵的那个接口,然后反复调用。所以防御的第一步不是买设备,而是先摸清自己的接口成本分布。
举个具体的例子。一个登录接口,如果密码哈希用的是成本参数较高的算法,单次校验可能要占用几十毫秒的 CPU 时间。这几十毫秒在正常流量下完全无感,但换算一下:单核一秒理论上最多处理二三十次,一台四核机器大概也就百次出头。也就是说,每秒一百多个并发请求就能把这台机器的 CPU 吃干净,而这个 QPS 对一个有正常缓存配置的网站来说根本不算什么流量,带宽、连接数全都在合理范围内。这就是所谓的"低成本攻击、高成本防守"。
再比如搜索接口。如果查询条件没走索引,或者带了模糊匹配,单次查询可能扫几百万行,耗时数百毫秒,还会把数据库的连接池占满。攻击方只需要构造那些"最不走索引"的参数组合,几百个并发就能把整个库拖垮。
所以我一直坚持一个做法:给核心接口做单位成本画像。方法很朴素,在压测环境里对每个接口做单并发基准测试,记录单次请求的 CPU 时间、数据库耗时、下游调用次数,然后算出一个"单次成本"。有了这张表,你就知道哪些接口是"便宜的大路货",哪些是"贵重的稀缺资源"。贵重接口必须单独隔离——独立的线程池、独立的限流阈值、独立的降级开关。这一条如果早做,能挡掉一大半应用层攻击造成的伤害。
4.2 慢速请求与长连接:把线程"挂"住而不是"打"满
应用层消耗还有一类手法,它不追求单位时间内的请求数量,而是追求单条请求占用资源的时间。逻辑很简单:如果你的工作线程是"一请求一线程"模型,那么把一个请求拖到 30 秒才结束,这一个线程就白白被占 30 秒。几百条这样的请求,线程池就空了,而这段时间内它只发了几百个请求,每秒个位数,任何基于 QPS 的限流阈值都不会触发。
具体怎么拖?用极慢的速度发请求头、发请求体,或者发一个声明了很大长度但就是不传完的报文。服务端为了等这个报文,会保持连接并占用工作线程。这类打法的特点是:并发连接数不高、QPS 极低、CPU 极低、但活跃线程数和平均响应时间飙升。
对策的核心是超时和隔离,而不是加大线程池:
- 给请求头和请求体的读取设置明确的、较短的时间上限,不要让默认配置替你决定。
- 限制单个连接同时能处理的请求数,启用请求排队上限,超过就快速失败。
- 尽量使用异步非阻塞模型,或者在阻塞模型前端加一层事件驱动的接入层,把"等待慢客户端"的成本从工作线程身上剥离。
- 对超长的请求体做硬性大小限制,超过直接拒绝,不要先收完再判断。
我在这块吃过的教训是:超时时间不能只设一层。曾经有个服务,网关设了 60 秒超时,应用设了 30 秒,但连接本身的读取超时没设,结果慢速请求在连接层可以无限期挂着,前两层超时根本没机会触发。后来统一梳理了一遍,从最外层到最内层按递减顺序设置:连接读超时 10 秒、应用处理超时 20 秒、网关总超时 30 秒,任何一层先到就断开,问题才算彻底解决。
还有一类和业务强相关的消耗:依赖放大。一个页面请求如果串行调用五个下游服务,每个下游平均 100 毫秒,这个请求就占 500 毫秒。攻击方只要盯着这个页面打,就能用极低的 QPS 把你的线程和下游连接池同时占满,并且压力会沿着调用链传导到那些本来毫无防备的内部服务。我见过的内部服务被拖垮的案例,绝大多数都是这么来的——外网入口只被打了几百个请求。
4.3 从耗时分布和日志里把"贵请求"揪出来
应用层攻击的排查,靠的是分布而不是均值。均值会被正常的慢请求稀释,也可能被攻击请求拉高但看不出结构。我一般看三个东西:
第一是响应时间的分位数。p50、p95、p99 一起看。如果 p50 基本没变,p99 涨到离谱,说明大部分请求是好的,少数请求极慢——这要么是慢请求堆积,要么是资源竞争。如果 p50 也在涨,说明整体都在变慢,可能是 CPU 或数据库层面的整体压力。
第二是接口维度的请求量分布。正常情况下流量在各接口上应该大致符合业务逻辑,形成相对稳定的比例。攻击时这个比例会突然失衡,某个原本占比 2% 的接口忽然占到 40%。这个信号极其明显,几乎可以直接定位。所以我一直建议在网关或应用层做按路径的请求量统计,粒度到具体的接口甚至具体的参数组合。
第三是按源地址、来源特征的聚类。应用层攻击往往来自相对集中的地址池,或者有明显的特征共性,比如 UA 完全一致、请求头缺失、参数构造规律、访问顺序固定。把这些维度做上采样,能比较快地圈出攻击流量。
定位之后的手段,按代价从低到高排列:缓存(把贵接口的结果缓存住,让成本从"每次算"变成"算一次")、限频(按地址、按会话、按接口多维度限)、排队与降级(贵接口独立小线程池,满了就返回稍后重试)、人机校验(对可疑会话加验证,代价是体验)、流量清洗(把已知特征在边界丢弃)。顺序不要搞反,缓存和限频的性价比永远最高,清洗应该是最后手段。
提示:限频的阈值不要凭感觉定。我建议的做法是统计线上真实用户请求量的 p99.9,把阈值设在它的两到三倍。低于这个值会误伤正常用户,高于这个值则起不到保护作用。这个数字必须靠数据得出,我见过太多凭直觉拍脑袋设阈值,结果要么形同虚设、要么把正常用户挡在门外的案例。
5. 三类攻击的识别对照与处置优先级
5.1 一张能贴在工位上的对照表
排查的时候最怕的就是慌乱中忘了看哪个指标。我把三类攻击最容易区分的特征整理成下表,实际值班时基本能在一两分钟内完成初判:
| 观察维度 | 流量型消耗 | 连接型消耗 | 应用层消耗 |
|---|---|---|---|
| 入口带宽 | 明显冲高,接近或顶到上限 | 涨幅有限,通常不显眼 | 几乎正常 |
| 每秒包数 pps | 可能极高(小包洪水时) | 偏高但不极端 | 正常 |
| 平均包长 | 可能骤降到几十字节 | 正常偏小 | 正常 |
| 并发连接数 | 不明显 | 急剧上升,尤其是 SYN-RECV 等中间态 | 上升,但以 ESTABLISHED 和排队为主 |
| CPU 使用率 | 网络软中断高,应用本身不高 | 内核态偏高,应用不高 | 应用进程用户态高 |
| 数据库与下游 | 无异常 | 通常无异常 | 连接池占满、慢查询暴涨 |
| 故障表现 | 请求超时、健康检查失败 | 握手失败、连不上、上游连不通 | 响应极慢、线程池排满、部分接口 5xx |
| 第一处置动作 | 上游牵引清洗、就近吸收 | 开启 Cookie、限制新建速率、检查队列 | 缓存、限频、接口隔离降级 |
这张表里我最看重的是"CPU 使用率的类型"和"数据库与下游"这两行。因为带宽和连接数有时候会因为监控采集间隔而看不准,但 CPU 用户态和内核态的分离,加上下游是否被拖累,能非常干净地区分"内核在忙"和"应用在忙"。
5.2 处置顺序的经验:先保命,再定位,最后才追源
很多人在攻击发生时第一反应是"我要找出是谁打的"。这个顺序在实战里是错的。正确的优先级是:先让业务活下来,再收集证据,最后才是追溯。因为攻击持续期间,你丢失的每一分钟都是真实损失,而追溯的收益远没有那么即时。
具体我一般这么做:第一步做容量腾挪,把非核心功能摘掉、把静态资源切到独立的缓存层、把贵接口的入口关掉或者加验证,用最快的速度把核心链路腾出来。这一步不看原因,只看效果。第二步做限流兜底,在入口按地址和接口做粗粒度限流,宁可误伤一点也要止住血。第三步才开始分层定位,用前面那张对照表判断类型,然后做针对性处置。第四步是留证据,把攻击期间的抓包、日志、地址分布、请求特征保存下来,这部分对后续优化规则非常关键。
有个细节值得说:抓包一定要提前做,不要等打起来才抓。攻击期间在高负载机器上抓包本身就会加剧问题,而且很容易漏掉关键时段。我的做法是在边界设备上常开"抽样留存",只保留报文头部和少量载荷,占用很小,出事时直接回捞,比临时上阵靠谱得多。
5.3 把自己的容量基线量出来,别等被打时才知道上限
这条是我最想强调的一条经验:所有防御动作的前提,是你知道自己能扛多少。没有基线,限流阈值定不了、扩容决策做不了、清洗服务的规格也选不准。
基线的测量方法不复杂,但需要认真做:在和生产环境尽量一致的配置下,对核心链路做阶梯加压,找到三个拐点——响应时间开始明显上升的点、错误率开始上升的点、系统彻底失去响应的点。第一个点就是你的安全工作区间上限,日常告警应该设在它的 70% 到 80%。
测的时候有几个容易忽略的点。一是要测混合场景,单一接口压测的结论往往过于乐观,真实流量是混合的,资源争抢更严重。二是要测依赖降级场景,把缓存关掉、把某个下游模拟成慢响应,看看系统还能撑多久——应用层攻击恰恰就是制造这种场景。三是要测连接层面的上限,包括新建连接速率和并发连接数,这两项和 QPS 是独立的,很多系统 QPS 能扛很高但并发连接数一上来就不行了。
我们之前做过一次完整的基线测量,结论挺反直觉:系统能扛住的 QPS 比预估低很多,但能扛住的并发连接数比预估高很多。也就是说,如果对方用慢速请求的方式打,我们能撑比预期更久;但如果对方用高并发的快速请求打,我们的崩溃点会来得更早。有了这个结论之后,限流策略就明确了——重点限制新建连接速率和每秒请求数,而不是限制并发连接总数。
6. 一次混合型攻击的完整排查链路复盘
6.1 现象:服务没挂,但成功率掉到六成
真实世界里的攻击很少是纯粹的一种,混合打法才是常态,这也是为什么前面要分类讲清楚——分类不是目的,是为了在混合场景下能拆开看。我复盘一次印象比较深的经历。
那次的现象很怪:服务始终没有完全挂掉,一直有响应,但请求成功率从 99.9% 掉到了 60% 左右,持续了将近四十分钟。没有明显的错误峰值,没有进程崩溃,没有重启,监控大盘看上去"哪儿都不太对,但哪儿都没红"。值班同事第一反应是数据库抖动,查了一轮没发现异常。第二反应是某个下游不稳定,逐个排查也正常。这就属于最难搞的类型——症状模糊但影响巨大。
我现在回看,当时最大的问题是排查顺序没有章法,东看一块西看一块,浪费了二十多分钟。后来我固化成了一套固定顺序,从外到内一层层剥,效率高得多。
6.2 逐层剥离:从入口流量到应用耗时的固定顺序
那次最后定型的顺序是这样,我建议你直接拿去用:
第一层看入口流量和包特征。带宽涨幅不到 20%,pps 涨了将近三倍,平均包长从正常的几百字节掉到了八十字节左右。这就已经有信号了:pps 涨得远超带宽,说明对方在用大量小包。但仅仅这一层还不足以解释 40% 的失败率,因为带宽还有很大余量。
第二层看连接状态分布。并发连接总数涨了两倍多,其中 ESTABLISHED 占大头,SYN-RECV 只是小幅上升。这一条很关键——它排除了纯 SYN 洪水的可能。SYN-RECV 占比不高,说明对方不是靠伪造握手来打,而是真的把连接建起来了。
第三层看应用层的接口分布。这一步直接定位了问题。按路径统计的请求量里,一个原本只占 1.5% 的导出类接口,占比突然升到了 45%。这个接口会做复杂的数据聚合,单次耗时在秒级,而且会占用数据库的长连接。攻击方用大量真实建立的连接,反复调这个接口,同时每个连接还保持着不怎么发数据的状态。
第四层看线程池和下游。应用线程池排队数持续增长,数据库连接池占用率达到 100%,慢查询日志里全是那个导出接口相关的语句。到这儿,整条链路就闭合了。
所以这是一次典型的混合打法:用大量小包和高频连接维持压力、消耗网络层的处理能力,同时用真实连接精准打击最贵的接口,把应用层的线程和数据库连接吃干净。单看任何一层都"不足以致命",三层叠加起来就造成了 40% 的失败率。这也解释了为什么当时单看某个监控都觉得"还好"——每一层的压力都只有中等,但合起来就是致命的。
处置动作也很明确:先把那个导出接口整体下线,换成异步任务提交的方式;然后对新建连接速率做限制;最后在入口把 pps 层面的异常特征做过滤。三项做完,成功率在几分钟内回到 99% 以上。
6.3 复盘:当时缺的那几个监控项
事后复盘,我列了几条当时确实没有、后来补上的东西,价值比任何设备采购都高:
第一个是按接口维度的请求占比监控和异常告警。这个能力做起来极其便宜,就是在网关层按路径做计数统计,设一个"占比偏离基线超过阈值"的告警。按当时的情况,这个告警能在两分钟内把问题指出来,而不是花二十分钟逐个排查下游。
第二个是平均包长的实时曲线。这个指标不常被默认采集,但对判断流量型和小包混合型攻击极有价值,而且计算成本几乎为零。
第三个是线程池排队数与下游连接池占用率的联合告警。单独看线程池可能只是"有点忙",单独看连接池也可能只是"有点紧",但两者同时向上,几乎必然是有贵接口被打或者有慢依赖。
第四个是单接口成本画像表,也就是前面提到的那个"每次调用花多少资源"的表。有了它,在告警发生时你能立刻知道"这个接口被打意味着什么量级的压力",判断速度完全不一样。当时我们完全靠现场估算,慢了很多。
最后分享一个我自己养成的习惯:每次处理完这类问题,我都会在事后写一段"如果重来一次,前三分钟我会做什么"的复盘记录。写了七八次之后我发现,前三分钟的动作基本固定下来了——看三条曲线、看连接状态分布、看接口占比、然后按类型套处置模板。这些写下来的东西比任何手册都管用,因为它们是从真实的错误里长出来的,而不是从文档里抄来的。
另外还有一个容易被忽略的点:三类攻击的防御能力建设,本质上是在做成本对比。对方用越便宜的手段,你就越需要一个同样便宜的防线。带宽很贵,连接状态也很贵,但缓存、限频、超时这些手段几乎是免费的。我现在的思路很明确——先把所有"免费或便宜"的防线做扎实,让对方的攻击成本被迫抬高,再去考虑那些昂贵的清洗和扩容手段。这个顺序反了,就会变成一直在花钱补窟窿。