☰
拒绝服务压测实战:从连接队列、并发容量到限流防护
2026/11/2 20:45:21 网站建设 项目流程

简介:围绕DOS拒绝服务攻击原理整理的小型源代码包,面向网络安全初学者与攻防技术研究者,用于在受控实验环境中观察拒绝服务攻击的实现思路与代码结构,进而理解SYN Flood、Ping Flood、UDP Flood等常见手法的触发机制与资源消耗路径。压缩包共6个文件,约11KB,以Visual C++工程文件为主体,含1个cpp源码、dsp与dsw工程配置、opt与plg编译选项以及ncb浏览数据库,可直接在旧版VC环境中打开并阅读主逻辑,工程结构完整而体量精简,适合逐行对照分析。目前已有517人学习下载,在同类教学素材中具有一定参考价值。对于希望从代码层面认识攻击流程,并据此设计防火墙过滤、限速策略与流量清洗等防护方案的读者,这份材料可作为理解攻击面与防御切入点的辅助样本,阅读时需结合协议细节与工程配置把握其运行条件,并仅在合法合规的实验场景中使用。

1. 从「DOS拒绝服务攻击源代码」说起:多数人真正缺的是可复现的压测代码

搜「DOS拒绝服务攻击源代码」的人,最后基本会分流到两个方向:一个想验证自己的服务到底扛不扛得住,一个想给别人的服务添堵。前者是容量评估,后者是违法。我在实际工作里碰到的需求几乎全落在前者——某个接口上线前要摸清并发上限,某次线上抖动要复现是不是连接队列被打满了。所以这篇不聊怎么打人,聊的是拒绝服务在协议层和应用层靠什么成立,怎么在自己有授权的环境里用最小代码把它复现出来、量出来,再把防护参数一条条对上。读完之后你手上应该有一份能反复跑的压测脚本,而不是一份到处流传、跑起来先把自己机器拖垮的来路不明的代码。

2. 拒绝服务的四条资源路径:带宽、连接表、工作线程、上游依赖

很多人对拒绝服务的想象还停留在「流量特别大」这一层,真上手才发现,把一台机器打趴下根本不需要多大流量。一个每秒两千请求、每个请求睡两秒的接口,就能让默认配置的服务彻底失去响应能力,出口带宽连百分之一都没用到。所以第一步不是找代码,是把「什么资源会被耗尽」这件事拆清楚,拆清楚了才知道该测什么、该看哪些指标。

2.1 四条路径各自的表征完全不同

第一条是链路带宽和包速率饱和。特征是出入口带宽曲线顶到上限,而服务进程 CPU 不高,请求根本进不来,丢包发生在网卡或上游设备。这条路的成本最高,因为要堆量,防御手段也最成熟,通常是清洗和扩容。

第二条是内核连接表耗尽。这条最隐蔽也最常见。每个连接在内核里都要占一个槽位,还有 conntrack 表、监听队列 backlog、accept queue。特征很明确:服务 CPU 平稳,但ss -s里timewait数量暴涨,或者ss -lnt的 Recv-Q 一直贴着 Send-Q 的上限不动——这就是 accept queue 满了,新连接被内核直接丢掉,客户端看到的是握手超时而不是 502。

第三条是应用工作线程或进程池被占满。每个请求进来占一个 worker,worker 在等数据库、等下游、或者干脆在 sleep,新请求就在队列里排队。特征是 P99 延迟持续爬升但 CPU 利用率上不去,因为线程都在阻塞等待,不是在算。这条路是应用层拒绝服务的核心,也是最容易被忽略的:代码里一个没有超时的下游调用,等于把 worker 的生死交给了别人。

第四条是上游依赖被打穿。你的服务本身很健康,但数据库连接池只有 20 个,压测流量一进来连接池瞬间耗尽,后续请求全部卡在获取连接这一步。表现出来是你的服务慢,实际瓶颈在下一跳。

把这四条放在一起看就明白了:所谓「拒绝服务」不是一种攻击手法,是四类资源瓶颈被同时或分别触碰的结果。你做压测的目的,就是找出自己的系统最先倒在哪一条上。

2.2 所谓「源代码」在不同层次上根本不是一回事

网上流传的这类代码,多数是按层次分的,而每一层的门槛和后果差得很远。

最底层是原始套接字,自己构造 IP 头和 TCP 头往外发。这类代码要 root 权限,要处理校验和、分片、网卡卸载,现代内核和中间设备对畸形包的处理早就不是十几年前的样子,很多代码跑起来第一件事是被本机防火墙拦掉,第二件事是因为源地址不可达而收不到回包。写这类代码的实际收益,远低于它带来的麻烦。

往上一层是传输层握手。核心思路是只发握手的第一步不收尾,让服务端维持大量半开连接,把连接表占满。这条路对源地址有要求,实现上依赖伪造,而现在的入口设备基本都有针对性的队列保护。我不建议在这个方向上花时间,不是因为难,是因为它离「验证自己系统容量」这个目标太远。

再往上是应用层。这一层最讽刺:实现门槛低到几十行 HTTP 请求就够,一个没有限流、没有超时、没有并发上限的接口,用最普通的客户端代码就能把它压到不可用。这也正好说明了防护的必要性——门槛越低,越不能指望「没人会这么干」。

所以当你看到「源代码」三个字时,先问自己一句:我要的是能打人的东西,还是能度量自己系统的工具?如果是后者,那你要写的其实是压测客户端。

2.3 为什么我坚持用压测代码代替攻击代码

三个理由,都是踩过之后才明白的。

第一是授权。压测是在你自己有权限的地址上跑,跑完能光明正大地看日志、看监控、看内核计数。攻击代码跑完,你连结果都不敢留存。

第二是可度量。压测脚本必须输出速率、延迟分位、错误数,这些数字才能和基线对比,才能得出结论「这个配置能撑 3000 并发」。攻击代码通常只输出「成功了」,没有信息量。

第三是可回归。今天调了一个参数,明天要验证有没有效果,压测脚本可以原样再跑一遍。这才是工程上真正有价值的部分——把一次性的验证变成可以反复执行的动作。

后面的章节就按这个思路走:先搭一个能被观测的被测服务,再写两个版本的压测客户端,最后把观测到的现象翻译成具体的配置参数。

3. 搭一套最小可复现实验台:被测服务、代理层和三个观测开关

实验台不用复杂,一台机器、三个进程就够:被测服务、一层反向代理、压测客户端。分成两段是为了能分别观察「代理层的连接队列」和「应用层的工作线程」,这两个瓶颈的位置不一样,混在一起就分不清是谁先撑不住。

3.1 用三十行代码造一个「慢接口」

被测服务要故意留一个慢路径,否则你压不出线程占满的效果。下面这段用标准库起一个多线程 HTTP 服务,/fast立即返回,/slow固定睡 200 毫秒模拟一次下游查询。

# server.py —— 被测服务:/slow 固定耗时,用来观察工作线程被占满 import time, json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer DELAY = 0.2 # 单请求处理耗时(秒),模拟一次数据库查询 class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" # 开启长连接,才能观察到连接堆积 def do_GET(self): if self.path == "/fast": body = b'{"ok":true}' else: time.sleep(DELAY) # 慢路径:占住一个线程 body = json.dumps({"ok": True}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass # 压测时关访问日志,否则磁盘先被打满 if __name__ == "__main__": ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()

逻辑说明:ThreadingHTTPServer每个连接起一个线程,线程数没有上限,所以它不会因为线程池小而卡住,会一直吃到文件描述符或内存——这正好方便你观察「系统级」而不是「框架级」的极限。protocol_version设成 HTTP/1.1 是为了让连接可以复用,否则每个请求都新建连接,你测到的全是握手开销。

参数说明:DELAY是这个实验里最重要的旋钮。设成 0.2 秒,意味着单线程每秒只能处理 5 个请求,50 个并发就能把吞吐锁死在 250 req/s 左右;把它改成 0.01,同样的并发能跑到几千。改这个值,你能直观看到「瓶颈从线程挪到 CPU」的全过程。

3.2 代理层故意把队列设小

在被测服务前面挂一层反向代理,并且故意把backlog调小,是为了让连接队列溢出这件事早点发生、看得见。

# nginx.conf —— 代理层:故意缩小 accept queue,方便观察溢出 worker_processes 1; events { worker_connections 1024; } http { upstream backend { server 127.0.0.1:8080; keepalive 32; # 到后端的连接复用,减少 TIME_WAIT } server { listen 8000 backlog=128; # 关键:故意设小,压测时很快能看到 Recv-Q 顶满 location / { proxy_pass http://backend; proxy_read_timeout 5s; # 后端迟迟不回就断开,避免连接无限堆积 proxy_connect_timeout 1s; } } }

逻辑说明:backlog=128是这行配置的核心。它决定了内核为这个监听套接字维护的 accept queue 长度。当代理层来不及 accept 时,新完成的连接就堆在这个队列里;队列满了,内核按策略丢弃或不再响应,你在客户端看到的是连接超时或 RST,而不是 5xx。

参数说明:keepalive 32是到上游的连接池大小,它决定了代理层同时能向后端发起多少条长连接;这个值设得太小,代理层自己就成了瓶颈。proxy_read_timeout必须设,不设的话后端卡住时代理层的连接会一直挂着,这是很多线上故障的直接原因。

3.3 三个必须提前打开的观测开关

没有观测的压测等于白跑。我一般固定看三处:

第一处是内核连接状态。ss -s看总览,重点盯timewait和closed的数量级;ss -lnt看监听队列,Recv-Q 是当前排队的连接数,Send-Q 是上限,Recv-Q 长期贴着 Send-Q 就是溢出前兆。

第二处是文件描述符。ulimit -n在多数发行版默认是 1024,这个值会在你还没打到服务瓶颈之前就把客户端自己限制住。跑之前先ulimit -n 65535,跑的时候用ls /proc/$(pgrep -f server.py)/fd | wc -l看服务端实际开了多少。

第三处是延迟分位而不是平均值。平均值会把长尾吃掉,一个平均 30 毫秒的接口可能 P99 是 3 秒。压测脚本必须自己算 P50/P95/P99,或者至少把每次请求的耗时都记录下来。

3.4 基线:先知道正常长什么样

正式压之前,先用 1 个并发跑 60 秒,把这条曲线记下来。单并发下/slow的延迟应该稳定在 200 毫秒出头,吞吐 5 req/s 左右,错误 0。这个数字没有任何意义,但它是标尺——后面并发升到 50、200、1000 时,你才能判断延迟的上升是线性的(正常排队)还是突然拐弯(撞到瓶颈)。

基线还有一个作用:验证你的客户端脚本本身没问题。如果单并发就跑出 30% 的错误率,那问题在脚本,不在被测服务。

4. 压测客户端怎么写:线程版摸上限,异步版推千级并发

客户端脚本要满足三个条件:能控制并发数、能控制持续时间、能输出分位延迟。线程版写起来最简单,适合把并发从 1 推到几百;异步版在单机上能推到几千连接,适合测连接队列。两个都给你。

4.1 线程版:五十行拿到第一组数字

# load_sync.py —— 线程版:N 个线程各自循环发请求,适合几百并发 import threading, time, http.client TARGET = ("127.0.0.1", 8000) PATH = "/slow" CONCURRENCY = 50 # 并发线程数 DURATION = 30 # 持续秒数 TIMEOUT = 3 # 单请求超时,必须设,否则卡死的连接会拖垮整个脚本 lat, err = [], [0] lock = threading.Lock() def worker(stop_at): # 每线程一条长连接,避免把 TCP 握手算进延迟 conn = http.client.HTTPConnection(*TARGET, timeout=TIMEOUT) while time.time() < stop_at: t0 = time.perf_counter() try: conn.request("GET", PATH) resp = conn.getresponse() resp.read() with lock: lat.append(time.perf_counter() - t0) except Exception: with lock: err[0] += 1 conn.close() # 出错的连接不能复用 conn = http.client.HTTPConnection(*TARGET, timeout=TIMEOUT) conn.close() stop_at = time.time() + DURATION threads = [threading.Thread(target=worker, args=(stop_at,)) for _ in range(CONCURRENCY)] for t in threads: t.start() for t in threads: t.join() lat.sort() n = len(lat) print(f"样本 {n} 错误 {err[0]}") if n: p = lambda q: lat[min(int(n * q), n - 1)] * 1000 print(f"P50 {p(0.50):.1f}ms P95 {p(0.95):.1f}ms P99 {p(0.99):.1f}ms") print(f"实际吞吐 {n / DURATION:.0f} req/s")

逻辑说明:每个线程维护一条长连接循环发请求,测的是「服务端同时处理 N 个请求」的能力,而不是「每秒能建立多少连接」。所有线程共享同一个stop_at,保证同时开始同时结束,否则样本会被早退出的线程污染。延迟统计用perf_counter而不是time.time,后者在有些平台上精度只有毫秒级。

参数说明:CONCURRENCY是主旋钮。从 10 开始,每次翻倍,观察吞吐什么时候不再上涨、P99 什么时候开始拐弯——那个点就是当前配置的容量上限。TIMEOUT一定要小于服务端的proxy_read_timeout,否则你会看到大量「客户端还在等、服务端已经断开」的假错误。DURATION不少于 30 秒,太短的话 JIT 预热、连接池建立这些一次性开销会污染结果。

这个版本的天花板在 GIL 和线程切换上。实测单机推到 500 线程左右,客户端自己的 CPU 就吃满了,此时你测到的是客户端的极限,不是服务端的。要突破这个限制,得换异步。

4.2 异步版:把并发从百级推到千级

# load_async.py —— 异步版:单进程推几千连接,每次新建连接以压 accept queue import asyncio, time HOST, PORT = "127.0.0.1", 8000 CONCURRENCY = 2000 # 同时保持的在途请求数 DURATION = 30 CONN_TIMEOUT = 2 # 建连超时,压队列时必须设小 READ_TIMEOUT = 5 async def one(sem, lat, err, stop_at): while time.time() < stop_at: async with sem: t0 = time.perf_counter() try: # 每个请求新建连接:这条路径才会打到 listen backlog r, w = await asyncio.wait_for( asyncio.open_connection(HOST, PORT), CONN_TIMEOUT) w.write(b"GET /slow HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n") await w.drain() await asyncio.wait_for(r.read(), READ_TIMEOUT) w.close() lat.append(time.perf_counter() - t0) except Exception: err[0] += 1 async def main(): lat, err = [], [0] sem = asyncio.Semaphore(CONCURRENCY) # 用信号量精确控制在途数量 stop_at = time.time() + DURATION await asyncio.gather(*[one(sem, lat, err, stop_at) for _ in range(CONCURRENCY)]) lat.sort() n = len(lat) print(f"样本 {n} 错误 {err[0]}") if n: p = lambda q: lat[min(int(n * q), n - 1)] * 1000 print(f"P50 {p(0.50):.1f}ms P95 {p(0.95):.1f}ms P99 {p(0.99):.1f}ms") print(f"实际吞吐 {n / DURATION:.0f} req/s") asyncio.run(main())

逻辑说明:每个协程在拿到信号量之后才建连接、发请求、读完、关闭,形成一个完整的短连接生命周期。这里刻意不复用连接,因为你要测的如果是代理层的 accept queue,就必须持续制造新连接;复用连接测的是并发处理能力,两者不是一回事。

参数说明:CONCURRENCY在这里的含义变了——线程版是「同时有 N 个线程在跑」,异步版是「同时在途的请求数是 N」。CONN_TIMEOUT必须设小,压队列时大量连接会卡在握手上,超时设成 10 秒的话脚本会一直挂着不返回。Connection: close是关键,去掉它连接会被复用,队列就压不出来了。

跑之前记得ulimit -n 65535,2000 并发意味着客户端至少需要 2000 个文件描述符,加上内核的 TIME_WAIT 占用,1024 的默认值会在第 1000 个连接时直接报Too many open files。

4.3 四个参数怎么定:并发、时长、超时、是否复用

参数线程版含义异步版含义调整策略
并发数线程数在途请求数从 10 开始每次翻倍,直到吞吐不再上涨
持续时间总时长总时长不少于 30 秒,稳态至少占 80%
单请求超时连接+读取建连/读取分开建连超时必须小于代理层 connect_timeout
连接复用默认复用默认不复用测处理能力复用,测队列不复用

这张表的用法是:先固定「复用 + 线程版」测出服务端的处理容量,再切到「不复用 + 异步版」测出连接队列的承受能力。两组数字往往差一个数量级,两个都要有,因为它们对应完全不同的防护手段。

5. 避坑与排查:五个把结论带偏的典型现象

5.1 客户端 CPU 先满,被测服务毫无压力

现象:并发加到 200,客户端机器 CPU 飙到 100%,服务端监控曲线纹丝不动,吞吐停在几百。

原因:线程版的 GIL 加上每次请求的 Python 层解析,客户端自身的开销已经超过服务端。你测的是自己的脚本。

解决:把压测脚本和被测服务放到两台机器上,中间走真实网络;或者直接切到异步版,单进程的协程开销远低于线程。判断方法很简单:压测时看客户端top,如果它比服务端还忙,结论就不可信。

5.2 并发设了 2000,实际吞吐只有 300,但 P99 很低

现象:脚本报的 P99 只有几十毫秒,看起来很健康,但吞吐上不去,错误数却是 0。

原因:文件描述符被限制了。异步版在open_connection时如果 fd 不够,会抛异常被except吞掉,错误计数没加,样本也没进延迟数组,于是看起来「又快又没错误」,实际有大量请求根本没发出去。

解决:先把err[0]和样本数打印出来做交叉验证——正常情况样本数应该约等于「吞吐 × 时长」。开跑前ulimit -n 65535,跑的过程中用ls /proc/<pid>/fd | wc -l确认实际用量。

5.3 TIME_WAIT 堆到几万,端口不够用

现象:跑了几轮之后报Cannot assign requested address,ss -s里 timewait 数量几万。

原因:短连接由客户端主动关闭,每条连接都会在客户端侧进入 TIME_WAIT 并占一个本地端口,默认 60 秒才释放。两万个连接意味着两万个端口被占住,本地端口范围根本不够。

解决:测处理能力时改成连接复用;确实要测短连接,就把轮次之间留足间隔,或者把net.ipv4.ip_local_port_range调宽。不建议随手改 TIME_WAIT 相关的内核开关,它会掩盖真实问题,等上线后照样翻车。

5.4 压测一停,服务反而挂了

现象:脚本停止的瞬间,服务端的错误率飙升,几秒后才恢复。

原因:压测期间请求在队列里堆积,脚本一停,队列里的请求被集中处理,瞬时压力反而高于压测期间。这个现象在 accept queue 被打满时特别明显。

解决:不要瞬间停。把并发分成几档逐步降下来,每档停 10 秒。同时观察ss -lnt的 Recv-Q,降到个位数再完全停止。这个细节决定了你的压测会不会反过来把测试环境搞挂。

5.5 指标很好看,线上照样出事

现象:压测报告写着 P99 80 毫秒、零错误,上线后高峰期照样超时。

原因:压测的请求间隔太均匀。真实流量是有突发的——秒杀、定时任务、批量导入,瞬间能到平均值的十几倍。均匀压测只能测出平均容量,测不出突发承受力。

解决:在脚本里加一个突发模式,比如每 10 秒把所有并发在 200 毫秒内打出去一次,其余时间按正常速率跑。这个模式下 P99 会难看很多,但那个数字才接近真实。

6. 从压测数字到防护参数:限流、队列和观测的落地方式

压测跑完,你手上应该有两组数字:服务端能扛多少并发处理、连接队列能排多长。这两个数字直接对应两类配置,别混着调。

第一类是入口限流,对应「别让请求无限制进来」。在代理层加两层限制:按来源的请求速率、按来源的并发连接数。

# 在 http 块里定义限流区 http { limit_req_zone $binary_remote_addr zone=perip:10m rate=20r/s; limit_conn_zone $binary_remote_addr zone=conn:10m; server { listen 8000 backlog=1024; # 队列按压测结果给,别照抄默认值 location /slow { limit_req zone=perip burst=40 nodelay; # 允许 40 个突发,超出直接拒 limit_conn conn 50; # 单来源最多 50 条并发 proxy_pass http://backend; } } }

rate=20r/s是稳态速率,burst=40是允许的突发量,nodelay表示突发额度内的请求立即放行而不是排队——不加这三个参数,限流本身会变成一个延迟源。limit_conn 50要和压测出来的单来源并发上限对齐,设太大等于没设,设太小正常用户会被误伤。这两个值我一般按「压测容量的 70%」给,留出余量。

第二类是内核队列,对应「撑住瞬时冲击」。backlog要和net.core.somaxconn一起看,两个取小值生效,只改一个等于没改。

# 临时生效,写进 /etc/sysctl.conf 才是持久化 sysctl -w net.core.somaxconn=4096 # accept queue 上限 sysctl -w net.ipv4.tcp_max_syn_backlog=8192 # 半开连接队列上限 ulimit -n 65535 # 文件描述符,服务进程也要设

注意这几个数字是缓解不是根治。队列加长只是把「拒绝」推迟了几秒,真正决定系统能不能扛住的是处理速率——队列再长,只要消费速度低于到达速度,早晚还是满。我见过太多把somaxconn调到 65535 就以为解决了的配置,高峰期照样 502。

第三类是观测。压测时盯的三个指标,上线后要变成常态告警:监听队列的 Recv-Q 与 Send-Q 比值持续超过 0.8、服务的 P99 连续三分钟超过基线的两倍、工作线程(或协程)的在途数接近上限。这三个信号出现的顺序,通常就是故障发生的顺序,早一步发现就能早一步扩容或限流。

这套东西我做过几轮之后最大的教训是:别在压测报告里只写「支持多少并发」。那个数字脱离了延迟分位和错误率毫无意义,而且换个接口就失效。真正能沉淀下来的是脚本本身——同一份脚本,改个路径、改个并发,随时能对新接口跑一遍,十分钟出结论。我现在接手任何新服务,第一件事不是看代码,是把这块的实验台搭起来跑一遍基线。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询