diff --git a/USAGE.md b/USAGE.md new file mode 100755 index 0000000..4196d15 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,535 @@ +# Website Stress Testing Tool - 使用说明 + +基于 Python 异步框架的网站压力测试工具,用于评估网站在高并发下的承载能力。 + +--- + +## 目录 + +1. [环境要求](#1-环境要求) +2. [安装](#2-安装) +3. [快速开始](#3-快速开始) +4. [参数详解](#4-参数详解) +5. [使用场景](#5-使用场景) +6. [输出说明](#6-输出说明) +7. [最佳实践](#7-最佳实践) +8. [常见问题](#8-常见问题) + +--- + +## 1. 环境要求 + +- Python >= 3.8 +- 依赖:aiohttp >= 3.9.0 + +--- + +## 2. 安装 + +```bash +# 进入工具目录 +cd d:\work\work-KD\tools + +# 安装依赖 +pip install -r requirements.txt + +# 或直接安装 +pip install aiohttp +``` + +验证安装: + +```bash +python stress_test.py --help +``` + +--- + +## 3. 快速开始 + +### 最简单的用法 + +```bash +# 对 example.com 发送 100 个请求,并发数 10 +python stress_test.py -u https://example.com -n 100 + +# 持续压测 30 秒 +python stress_test.py -u https://example.com -d 30 +``` + +### 提高并发 + +```bash +# 100 并发,1000 请求 +python stress_test.py -u https://example.com -c 100 -n 1000 +``` + +### 查看帮助 + +```bash +python stress_test.py --help +``` + +--- + +## 4. 参数详解 + +### 4.1 目标参数(必选,二选一) + +| 参数 | 说明 | 示例 | +|------|------|------| +| `-u, --url` | 目标 URL,可多次指定 | `-u https://a.com -u https://b.com` | +| `-f, --file` | URL 列表文件路径 | `-f urls.txt` | + +**URL 文件格式:** + +``` +# 这是注释行 +https://example.com +https://example.com/api +https://example.com/about +``` + +- 每行一个 URL +- `#` 开头为注释 +- 空行自动忽略 +- 可省略 `https://` 前缀(自动补全) + +### 4.2 测试模式(必选,二选一) + +| 参数 | 说明 | 示例 | +|------|------|------| +| `-n, --num-requests` | 总请求数,达到后停止 | `-n 1000` | +| `-d, --duration` | 持续时间(秒),到期后停止 | `-d 60` | + +### 4.3 并发控制 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `-c, --concurrency` | 10 | 并发 worker 数量 | +| `-r, --ramp-up` | 0 | 渐进加压时间(秒),从 1 并发逐步增加到目标并发 | + +**渐进加压示例:** + +```bash +# 100 并发,但用 30 秒逐步加压(避免瞬间打垮服务器) +python stress_test.py -u https://example.com -c 100 -d 120 -r 30 +``` + +### 4.4 HTTP 请求配置 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `-m, --method` | GET | HTTP 方法:GET / POST / PUT / DELETE / PATCH / HEAD | +| `-H, --header` | 无 | 自定义请求头,格式:`-H "Key: Value"`,可多次使用 | +| `--cookie` | 无 | Cookie,格式:`k1=v1;k2=v2` | +| `--data` | 无 | 请求体(字符串) | +| `--json` | 无 | 请求体(JSON 格式) | + +### 4.5 网络参数 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `-t, --timeout` | 30 | 单次请求超时时间(秒) | +| `--no-verify-ssl` | false | 跳过 SSL 证书验证 | +| `--no-keep-alive` | false | 禁用 HTTP Keep-Alive | + +### 4.6 输出控制 + +| 参数 | 说明 | +|------|------| +| `--report FILE` | 将 JSON 格式报告保存到指定文件 | +| `--no-display` | 禁用实时统计面板(适合日志采集或 CI 环境) | + +--- + +## 5. 使用场景 + +### 场景一:基础网站可用性测试 + +```bash +python stress_test.py -u https://your-site.com -c 50 -n 500 +``` + +评估网站在 50 并发下的基本响应能力。 + +### 场景二:API 接口压力测试 + +```bash +python stress_test.py \ + -u https://api.example.com/v1/users \ + -m GET \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Accept: application/json" \ + -c 100 \ + -n 2000 +``` + +测试需要认证的 API 接口。 + +### 场景三:POST 提交测试 + +```bash +python stress_test.py \ + -u https://api.example.com/login \ + -m POST \ + -H "Content-Type: application/json" \ + --data '{"username":"testuser","password":"testpass"}' \ + -c 50 \ + -n 500 +``` + +### 场景四:表单提交测试 + +```bash +python stress_test.py \ + -u https://example.com/contact \ + -m POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data "name=test&email=test@example.com&message=hello" \ + -c 30 \ + -n 300 +``` + +### 场景五:多页面轮询测试 + +```bash +# 从文件加载多个 URL,模拟真实用户访问不同页面 +python stress_test.py -f urls.txt -c 50 -d 120 +``` + +`urls.txt` 内容: + +``` +https://example.com/ +https://example.com/products +https://example.com/about +https://example.com/contact +https://example.com/api/search?q=test +``` + +### 场景六:渐进加压测试 + +```bash +# 用 60 秒从 0 加压到 200 并发,持续测试 5 分钟 +python stress_test.py -u https://example.com -c 200 -d 300 -r 60 +``` + +逐步增加负载,观察系统在哪一级开始出现问题。 + +### 场景七:带 Cookie 的登录态测试 + +```bash +python stress_test.py \ + -u https://example.com/dashboard \ + --cookie "session_id=abc123;user_id=42" \ + -c 50 \ + -d 60 +``` + +### 场景八:跳过 SSL 验证(自签名证书) + +```bash +python stress_test.py -u https://internal.dev.local -c 20 -n 100 --no-verify-ssl +``` + +### 场景九:CI/CD 自动化测试 + +```bash +# 静默模式 + JSON 报告,适合集成到自动化流程 +python stress_test.py \ + -u https://staging.example.com \ + -c 50 \ + -n 1000 \ + --no-display \ + --report stress_result.json +``` + +### 场景十:多站点对比测试 + +```bash +# 同时测试多个站点(轮询模式) +python stress_test.py \ + -u https://server-a.example.com \ + -u https://server-b.example.com \ + -c 50 \ + -n 500 +``` + +--- + +## 6. 输出说明 + +### 6.1 实时统计面板 + +测试运行时,终端每 0.5 秒刷新一次: + +``` +============================================================ + | Stress Test Running +------------------------------------------------------------ + 进度: [###############...................] 50.0% (500/1000) + + QPS: 152.3 req/s + 并发数: 100 + 总请求: 500 + 耗时: 0:00:03 + + 平均延迟: 45.2 ms + P50: 38.1 ms + P90: 72.5 ms + P99: 156.3 ms + + 成功率: 99.80% (成功: 499 失败: 1) + 吞吐量: 1.2 MB/s +============================================================ +``` + +| 指标 | 说明 | +|------|------| +| QPS | 每秒完成的请求数 | +| P50 | 50% 的请求在此延迟内完成(中位数) | +| P90 | 90% 的请求在此延迟内完成 | +| P99 | 99% 的请求在此延迟内完成 | +| 成功率 | HTTP 2xx/3xx 响应占比 | + +### 6.2 最终报告 + +测试完成后自动输出完整报告: + +``` +================================================================ + [*] 压力测试报告 + 2026-05-28 15:40:13 +================================================================ + + > 测试配置 + 目标 URL: https://example.com + HTTP 方法: GET + 并发数: 100 + 总耗时: 6.52s + + > 请求统计 + 总请求数: 1000 + 成功: 998 + 失败: 2 + 成功率: 99.80% + 平均 QPS: 153.4 req/s + + > 延迟分布 (ms) + P50 38.1 ms ##...................... + P75 55.2 ms ####.................. + P90 72.5 ms #####................. + P95 102.8 ms ########.............. + P99 156.3 ms ############.......... + P99.9 312.1 ms ####################.. + + 最小: 12.3 ms + 最大: 312.1 ms + 平均: 45.2 ms + 标准差: 28.7 ms + + > 吞吐量 + 总传输: 2.4 MB + 吞吐量: 378.5 KB/s + + > 状态码分布 + HTTP 200 998 #################### + HTTP 502 2 #................... + + > 综合评价 + [Good] 良好 - 网站表现正常 + +================================================================ +``` + +### 6.3 JSON 报告(--report) + +```json +{ + "timestamp": "2026-05-28T15:40:13.123456", + "config": { + "urls": ["https://example.com"], + "method": "GET", + "concurrency": 100, + "total_requests": 1000, + "duration": null + }, + "results": { + "total_requests": 1000, + "success": 998, + "errors": 2, + "success_rate": 99.8, + "elapsed_seconds": 6.52, + "avg_qps": 153.4, + "avg_latency_ms": 45.23, + "p50_ms": 38.1, + "p90_ms": 72.5, + "p95_ms": 102.8, + "p99_ms": 156.3, + "total_bytes": 2516480, + "throughput_bytes_sec": 386729.1, + "status_codes": { + "200": 998, + "502": 2 + }, + "errors_detail": { + "Timeout": 1, + "ClientConnectorError": 1 + } + } +} +``` + +可用于: +- 存档记录 +- 自动化流水线中的质量门禁 +- 与 Grafana/Prometheus 等监控系统集成 +- Python/JS 脚本二次分析 + +### 6.4 综合评价标准 + +| 等级 | 条件 | 含义 | +|------|------|------| +| Excellent | 成功率 >= 99.9% 且 P99 < 500ms | 优秀,可放心上线 | +| Good | 成功率 >= 99% 且 P99 < 1000ms | 良好,表现正常 | +| Fair | 成功率 >= 95% | 一般,建议优化 | +| Poor | 成功率 >= 80% | 较差,需要关注 | +| Bad | 成功率 < 80% | 很差,无法承受负载 | + +--- + +## 7. 最终实践 + +### 7.1 测试前准备 + +1. **确认权限** - 只对你拥有或获得授权的网站进行压测 +2. **选择低峰期** - 避免影响正常用户 +3. **从小到大** - 先用小并发验证脚本正确,再逐步加压 +4. **通知相关方** - 提前告知运维/开发团队 + +### 7.2 推荐测试流程 + +``` +第 1 轮:验证脚本 + python stress_test.py -u http://118.89.55.254 -c 5 -n 20 + +第 2 轮:基准测试 + python stress_test.py -u http://118.89.55.254 -c 10 -n 100 + +第 3 轮:负载测试 + python stress_test.py -u http://118.89.55.254 -c 50 -n 1000 + +第 4 轮:压力测试 + python stress_test.py -u http://118.89.55.254 -c 100 -d 300 -r 30 + +第 5 轮:极限测试 + python stress_test.py -u http://118.89.55.254 -c 500 -d 300 -r 60 +``` + +### 7.3 并发数建议 + +| 场景 | 建议并发数 | 说明 | +|------|-----------|------| +| 个人博客/小型站点 | 10 - 50 | 通常单机部署 | +| 企业官网 | 50 - 200 | 可能有 CDN | +| 中型 API 服务 | 100 - 500 | 取决于后端架构 | +| 大型平台 | 500 - 2000 | 建议使用分布式工具 | + +> 注意:单机压测受限于本机 CPU/内存/带宽,并发数过高时瓶颈可能在测试端而非目标服务器。 + +### 7.4 如何解读结果 + +- **QPS 随并发增加而趋于平稳** -> 系统已达到瓶颈 +- **P99 延迟突增** -> 存在长尾请求,可能有慢查询或资源竞争 +- **成功率下降** -> 服务器开始拒绝请求或超时 +- **HTTP 502/503 增多** -> 后端服务过载或网关超时 +- **Timeout 增多** -> 服务器处理不过来,请求排队 + +--- + +## 8. 常见问题 + +### Q: 提示 "需要安装 aiohttp" + +```bash +pip install aiohttp +``` + +### Q: 中文显示乱码 + +Windows 默认终端使用 GBK 编码。解决方法: + +```bash +# 方法 1:使用 Windows Terminal(推荐) +# 方法 2:切换终端编码 +chcp 65001 +# 方法 3:使用 --no-display 导出 JSON 报告 +python stress_test.py -u https://site.com -n 100 --no-display --report result.json +``` + +### Q: 出现 "Event loop is closed" 警告 + +这是 Python 3.8 在 Windows 上的已知无害警告,不影响测试结果。升级到 Python 3.9+ 可消除。 + +### Q: 测试端成为瓶颈怎么办 + +- 减少并发数 +- 在多台机器上分别运行(各自测试不同 URL 子集) +- 使用更专业的分布式工具(如 Locust、k6) + +### Q: 如何测试 WebSocket + +当前版本仅支持 HTTP/HTTPS。如需 WebSocket 测试,建议使用专业工具。 + +### Q: 如何测试需要登录的页面 + +```bash +# 方法 1:通过 Cookie +python stress_test.py -u https://site.com/dashboard \ + --cookie "session=abc123" -c 20 -n 100 + +# 方法 2:通过 Header(Token 认证) +python stress_test.py -u https://api.site.com/data \ + -H "Authorization: Bearer eyJhbG..." -c 20 -n 100 +``` + +### Q: 支持 HTTP/2 吗 + +当前使用 aiohttp,默认 HTTP/1.1。如需 HTTP/2 支持,可考虑替换为 httpx。 + +--- + +## 完整参数速查表 + +``` +用法: python stress_test.py [目标] [模式] [选项] + +目标(必选,二选一): + -u URL 目标 URL(可多次指定) + -f FILE URL 列表文件 + +模式(必选,二选一): + -n NUM 总请求数 + -d SECONDS 持续时间(秒) + +并发: + -c NUM 并发数(默认: 10) + -r SECONDS 渐进加压时间(默认: 0) + +HTTP: + -m METHOD GET/POST/PUT/DELETE/PATCH/HEAD(默认: GET) + -H "K: V" 自定义请求头(可多次使用) + --cookie "K=V" Cookie + --data STRING 请求体(字符串) + --json '{}' 请求体(JSON) + +网络: + -t SECONDS 超时时间(默认: 30) + --no-verify-ssl 跳过 SSL 验证 + --no-keep-alive 禁用 Keep-Alive + +输出: + --report FILE 保存 JSON 报告 + --no-display 禁用实时显示 +``` diff --git a/r100.json b/r100.json new file mode 100755 index 0000000..af0904f --- /dev/null +++ b/r100.json @@ -0,0 +1,34 @@ +{ + "timestamp": "2026-05-29T08:52:03.256740", + "config": { + "urls": [ + "http://118.89.55.254" + ], + "method": "GET", + "concurrency": 100, + "total_requests": null, + "duration": 30 + }, + "results": { + "total_requests": 2732, + "success": 2721, + "errors": 11, + "success_rate": 99.6, + "elapsed_seconds": 38.23, + "avg_qps": 71.5, + "avg_latency_ms": 1132.38, + "p50_ms": 433.26, + "p90_ms": 2242.57, + "p95_ms": 4382.01, + "p99_ms": 13778.92, + "total_bytes": 38417799, + "throughput_bytes_sec": 1004842.8, + "status_codes": { + "200": 2721, + "0": 11 + }, + "errors_detail": { + "Timeout": 11 + } + } +} \ No newline at end of file diff --git a/r200.json b/r200.json new file mode 100755 index 0000000..81a4189 --- /dev/null +++ b/r200.json @@ -0,0 +1,34 @@ +{ + "timestamp": "2026-05-29T08:53:19.265742", + "config": { + "urls": [ + "http://118.89.55.254" + ], + "method": "GET", + "concurrency": 200, + "total_requests": null, + "duration": 30 + }, + "results": { + "total_requests": 2998, + "success": 2902, + "errors": 96, + "success_rate": 96.8, + "elapsed_seconds": 45.14, + "avg_qps": 66.4, + "avg_latency_ms": 2058.16, + "p50_ms": 562.24, + "p90_ms": 2626.2, + "p95_ms": 8781.4, + "p99_ms": 30109.4, + "total_bytes": 40973338, + "throughput_bytes_sec": 907561.0, + "status_codes": { + "200": 2902, + "0": 96 + }, + "errors_detail": { + "Timeout": 96 + } + } +} \ No newline at end of file diff --git a/r500.json b/r500.json new file mode 100755 index 0000000..b0e8872 --- /dev/null +++ b/r500.json @@ -0,0 +1,34 @@ +{ + "timestamp": "2026-05-29T08:55:03.464684", + "config": { + "urls": [ + "http://118.89.55.254" + ], + "method": "GET", + "concurrency": 500, + "total_requests": null, + "duration": 30 + }, + "results": { + "total_requests": 3127, + "success": 2833, + "errors": 294, + "success_rate": 90.6, + "elapsed_seconds": 50.54, + "avg_qps": 61.9, + "avg_latency_ms": 5131.72, + "p50_ms": 1099.29, + "p90_ms": 27384.13, + "p95_ms": 30285.08, + "p99_ms": 30483.01, + "total_bytes": 39999127, + "throughput_bytes_sec": 791378.9, + "status_codes": { + "200": 2833, + "0": 294 + }, + "errors_detail": { + "Timeout": 294 + } + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..3beb7cb --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +aiohttp>=3.9.0 diff --git a/stress_test.py b/stress_test.py new file mode 100755 index 0000000..6ab255c --- /dev/null +++ b/stress_test.py @@ -0,0 +1,1040 @@ +""" +Website Stress Testing Tool +=========================== +纯 Python 异步压力测试工具,基于 aiohttp 实现高并发请求。 + +功能: + - 并发用户模拟(控制并发数/总请求数/持续时间) + - 多 HTTP 方法支持(GET/POST/PUT/DELETE/PATCH/HEAD) + - 自定义请求头、请求体、Cookie + - 实时终端统计面板(QPS、延迟、成功率) + - 详细最终报告(百分位延迟、错误分布、吞吐量图表) + - 自动探测临界并发点并持续维持压测 + - 极限测试模式:探测临界点、警告点、崩溃点 + +使用示例: + # 基础 GET 测试:100 并发,共 1000 请求 + python stress_test.py -u https://example.com -c 100 -n 1000 + + # 持续 30 秒测试,50 并发 + python stress_test.py -u https://example.com -c 50 -d 30 + + # POST 请求带 JSON body + python stress_test.py -u https://api.example.com/login -m POST \ + --data '{"user":"test","pass":"123"}' \ + -H "Content-Type: application/json" -c 20 -n 200 + + # 从文件读取 URL 列表轮询测试 + python stress_test.py -f urls.txt -c 50 -d 60 + + # 自动探测临界点,然后持续 2 小时 + python stress_test.py -u https://example.com --find-limit --hours 2 + + # 极限测试:探测临界点、警告点、崩溃点 + python stress_test.py -u https://example.com --crash-test +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +import time +import statistics +import warnings +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Optional, List, Dict + +try: + import aiohttp +except ImportError: + print("需要安装 aiohttp: pip install aiohttp") + sys.exit(1) + + +# --------------------------- 数据结构 --------------------------- + +@dataclass +class RequestResult: + """单次请求结果""" + status: int # HTTP 状态码,0 表示连接失败 + elapsed: float # 耗时(秒) + size: int # 响应体大小(字节) + error: str = "" # 错误信息 + timestamp: float = 0 # 请求发起时间戳 + + +@dataclass +class TestConfig: + """测试配置""" + urls: list[str] + method: str = "GET" + headers: dict = field(default_factory=dict) + cookies: dict = field(default_factory=dict) + data: Optional[str] = None + json_data: Optional[dict] = None + concurrency: int = 10 + total_requests: Optional[int] = None + duration: Optional[int] = None # 秒 + timeout: int = 30 + ramp_up: int = 0 # 渐进加压时间(秒) + verify_ssl: bool = True + keep_alive: bool = True + no_display: bool = False + # find-limit 模式 + find_limit: bool = False + hours: float = 0.0 # 临界点后持续小时数 + step: int = 10 # 并发递增步长 + probe_time: int = 10 # 每级探测持续秒数 + threshold: float = 95.0 # 成功率临界阈值百分比 + # crash-test 模式 + crash_test: bool = False + crash_threshold: float = 10.0 # 崩溃阈值:成功率低于此值视为崩溃 + + +# --------------------------- 统计收集器 --------------------------- + +class StatsCollector: + """线程安全的统计数据收集器""" + + def __init__(self): + self.results: list[RequestResult] = [] + self.start_time: float = 0 + self._lock = asyncio.Lock() + self._status_counts: dict[int, int] = defaultdict(int) + self._error_counts: dict[str, int] = defaultdict(int) + self._total_bytes: int = 0 + self._latencies: list[float] = [] + + async def add(self, result: RequestResult): + async with self._lock: + self.results.append(result) + self._status_counts[result.status] += 1 + self._total_bytes += result.size + self._latencies.append(result.elapsed) + if result.error: + self._error_counts[result.error] += 1 + + def total_requests(self) -> int: + return len(self.results) + + def success_count(self) -> int: + return sum(1 for r in self.results if 200 <= r.status < 400) + + def error_count(self) -> int: + return sum(1 for r in self.results if r.status >= 400 or r.error) + + def current_qps(self, window: float = 1.0) -> float: + """计算最近 window 秒的 QPS""" + if not self.results: + return 0.0 + now = time.time() + recent = [r for r in self.results if now - r.timestamp < window] + return len(recent) / window + + def avg_latency(self) -> float: + return statistics.mean(self._latencies) if self._latencies else 0.0 + + def percentile(self, p: float) -> float: + """计算百分位延迟""" + if not self._latencies: + return 0.0 + sorted_lats = sorted(self._latencies) + idx = int(len(sorted_lats) * p / 100) + idx = min(idx, len(sorted_lats) - 1) + return sorted_lats[idx] + + def elapsed_time(self) -> float: + return time.time() - self.start_time if self.start_time else 0.0 + + def throughput(self) -> float: + """吞吐量 (bytes/sec)""" + elapsed = self.elapsed_time() + return self._total_bytes / elapsed if elapsed > 0 else 0.0 + + +# --------------------------- 实时显示 --------------------------- + +class RealtimeDisplay: + """终端实时统计面板""" + + SPINNER = ["|", "/", "-", "\\"] + + def __init__(self, stats: StatsCollector, config: TestConfig): + self.stats = stats + self.config = config + self._frame = 0 + self._running = False + self._last_qps_samples: list[float] = [] + self.critical_concurrency: int = 0 # find-limit 模式下探测到的临界并发数 + + def _format_bytes(self, n: float) -> str: + for unit in ["B", "KB", "MB", "GB"]: + if abs(n) < 1024: + return f"{n:.1f} {unit}" + n /= 1024 + return f"{n:.1f} TB" + + def _format_duration(self, seconds: float) -> str: + return str(timedelta(seconds=int(seconds))) + + def _progress_bar(self, current: int, total: int, width: int = 30) -> str: + if total <= 0: + return "∞" + filled = int(width * current / total) + bar = "#" * filled + "." * (width - filled) + pct = current / total * 100 + return f"[{bar}] {pct:.1f}%" + + def render(self) -> str: + s = self.stats + total = self.config.total_requests or 0 + duration = self.config.duration or 0 + + spinner = self.SPINNER[self._frame % len(self.SPINNER)] + self._frame += 1 + + elapsed = s.elapsed_time() + done = s.total_requests() + qps = done / elapsed if elapsed > 0 else 0 + + lines = [] + lines.append(f"\033[1;36m{'=' * 60}\033[0m") + lines.append(f" {spinner} \033[1mStress Test Running\033[0m") + lines.append(f"\033[1;36m{'-' * 60}\033[0m") + + # 进度 + if total > 0: + lines.append(f" 进度: {self._progress_bar(done, total)} ({done}/{total})") + elif duration > 0: + lines.append(f" 进度: {self._progress_bar(int(elapsed), duration)} " + f"({self._format_duration(elapsed)}/{self._format_duration(duration)})") + + lines.append("") + + # 核心指标 + lines.append(f" \033[1;33mQPS:\033[0m {qps:>10.1f} req/s") + lines.append(f" \033[1;33m并发数:\033[0m {self.config.concurrency:>10}") + if self.config.find_limit and self.critical_concurrency > 0: + remaining = max(0, self.config.duration - elapsed) + lines.append(f" \033[1;35m临界并发:\033[0m {self.critical_concurrency:>10}") + lines.append(f" \033[1;35m剩余时间:\033[0m {self._format_duration(remaining):>10}") + lines.append(f" \033[1;33m总请求:\033[0m {done:>10}") + lines.append(f" \033[1;33m耗时:\033[0m {self._format_duration(elapsed):>10}") + lines.append("") + + # 延迟 + lines.append(f" \033[1;32m平均延迟:\033[0m {s.avg_latency() * 1000:>10.1f} ms") + lines.append(f" \033[1;32mP50:\033[0m {s.percentile(50) * 1000:>10.1f} ms") + lines.append(f" \033[1;32mP90:\033[0m {s.percentile(90) * 1000:>10.1f} ms") + lines.append(f" \033[1;32mP99:\033[0m {s.percentile(99) * 1000:>10.1f} ms") + lines.append("") + + # 成功率 + success = s.success_count() + errors = s.error_count() + rate = (success / done * 100) if done > 0 else 0 + color = "\033[1;32m" if rate >= 99 else "\033[1;33m" if rate >= 95 else "\033[1;31m" + lines.append(f" 成功率: {color}{rate:.2f}%\033[0m " + f"(成功: {success} 失败: {errors})") + lines.append(f" 吞吐量: {self._format_bytes(s.throughput())}/s") + + lines.append(f"\033[1;36m{'=' * 60}\033[0m") + + return "\n".join(lines) + + async def run(self, stop_event: asyncio.Event): + """每 0.5 秒刷新一次显示""" + self._running = True + while self._running and not stop_event.is_set(): + output = self.render() + # 移动光标到顶部并覆盖输出 + sys.stdout.write(f"\033[{output.count(chr(10)) + 1}A\033[0J") + sys.stdout.write(output + "\n") + sys.stdout.flush() + await asyncio.sleep(0.5) + + def stop(self): + self._running = False + + +# --------------------------- 压力测试引擎 --------------------------- + +class StressTester: + """核心异步压力测试引擎""" + + def __init__(self, config: TestConfig): + self.config = config + self.stats = StatsCollector() + self._stop_event = asyncio.Event() + self._active_tasks: set[asyncio.Task] = set() + + async def _make_request(self, session: aiohttp.ClientSession, url: str) -> RequestResult: + """执行单次 HTTP 请求""" + start = time.time() + try: + kwargs = { + "headers": self.config.headers or None, + "timeout": aiohttp.ClientTimeout(total=self.config.timeout), + "ssl": self.config.verify_ssl, + } + if self.config.data: + kwargs["data"] = self.config.data + elif self.config.json_data: + kwargs["json"] = self.config.json_data + + method = self.config.method.upper() + async with session.request(method, url, **kwargs) as resp: + body = await resp.read() + elapsed = time.time() - start + return RequestResult( + status=resp.status, + elapsed=elapsed, + size=len(body), + timestamp=start, + ) + except asyncio.TimeoutError: + return RequestResult( + status=0, elapsed=time.time() - start, + size=0, error="Timeout", timestamp=start, + ) + except (ConnectionResetError, ConnectionAbortedError): + return RequestResult( + status=0, elapsed=time.time() - start, + size=0, error="ConnectionReset", timestamp=start, + ) + except aiohttp.ClientError as e: + return RequestResult( + status=0, elapsed=time.time() - start, + size=0, error=type(e).__name__, timestamp=start, + ) + except Exception as e: + return RequestResult( + status=0, elapsed=time.time() - start, + size=0, error=str(e)[:80], timestamp=start, + ) + + async def _worker(self, session: aiohttp.ClientSession, url_iter): + """单个并发 worker:不断从 url_iter 取 URL 发请求""" + while not self._stop_event.is_set(): + try: + url = next(url_iter) + except StopIteration: + break + result = await self._make_request(session, url) + await self.stats.add(result) + + def _url_generator(self): + """无限循环生成 URL""" + urls = self.config.urls + idx = 0 + while True: + yield urls[idx % len(urls)] + idx += 1 + + async def _ramp_up_scheduler(self) -> int: + """渐进加压:逐步返回当前应使用的并发数""" + if self.config.ramp_up <= 0: + return self.config.concurrency + + elapsed = self.stats.elapsed_time() + if elapsed >= self.config.ramp_up: + return self.config.concurrency + + ratio = elapsed / self.config.ramp_up + target = max(1, int(self.config.concurrency * ratio)) + return target + + async def _control_loop(self, display: RealtimeDisplay, stop_display: asyncio.Event): + """控制循环:管理任务生命周期和停止条件""" + url_iter = iter(self._url_generator()) + connector = aiohttp.TCPConnector( + limit=self.config.concurrency + 10, + keepalive_timeout=30 if self.config.keep_alive else 0, + enable_cleanup_closed=True, + ) + jar = aiohttp.CookieJar() + if self.config.cookies: + for k, v in self.config.cookies.items(): + jar.update_cookies({k: v}) + + async with aiohttp.ClientSession(connector=connector, cookie_jar=jar) as session: + # 主循环 + while not self._stop_event.is_set(): + # 检查停止条件 + if self.config.total_requests and self.stats.total_requests() >= self.config.total_requests: + break + if self.config.duration and self.stats.elapsed_time() >= self.config.duration: + break + + # 渐进加压 + target = await self._ramp_up_scheduler() + + # 启动新 worker + while len(self._active_tasks) < target: + task = asyncio.create_task(self._worker(session, url_iter)) + self._active_tasks.add(task) + task.add_done_callback(self._active_tasks.discard) + + await asyncio.sleep(0.05) + + # 停止所有 worker + self._stop_event.set() + if self._active_tasks: + await asyncio.gather(*self._active_tasks, return_exceptions=True) + + stop_display.set() + + async def _find_critical_point(self) -> int: + """探测临界并发点:逐步加压,找到性能拐点""" + step = self.config.step + probe_time = self.config.probe_time + threshold = self.config.threshold + + print(f"\n\033[1;36m[>>] 阶段一:探测临界并发点\033[0m") + print(f" 步长: {step}") + print(f" 探测时间: {probe_time}s/级") + print(f" 成功率阈值: {threshold}%") + print() + + baseline_p99 = None + current_concurrency = step + last_good_concurrency = 0 + + while True: + # 为本级探测创建独立的 stats + probe_stats = StatsCollector() + probe_stats.start_time = time.time() + + url_iter = iter(self._url_generator()) + connector = aiohttp.TCPConnector( + limit=current_concurrency + 10, + keepalive_timeout=30 if self.config.keep_alive else 0, + enable_cleanup_closed=True, + ) + jar = aiohttp.CookieJar() + if self.config.cookies: + for k, v in self.config.cookies.items(): + jar.update_cookies({k: v}) + + stop_event = asyncio.Event() + active_tasks: set[asyncio.Task] = set() + + async with aiohttp.ClientSession(connector=connector, cookie_jar=jar) as session: + # 启动 worker + async def probe_worker(): + while not stop_event.is_set(): + try: + url = next(url_iter) + except StopIteration: + break + result = await self._make_request(session, url) + await probe_stats.add(result) + + for _ in range(current_concurrency): + task = asyncio.create_task(probe_worker()) + active_tasks.add(task) + task.add_done_callback(active_tasks.discard) + + # 等待探测时间 + await asyncio.sleep(probe_time) + + # 停止 worker + stop_event.set() + if active_tasks: + await asyncio.gather(*active_tasks, return_exceptions=True) + + # 分析结果 + total = probe_stats.total_requests() + if total == 0: + print(f" \033[33m并发 {current_concurrency}: 无请求完成,跳过\033[0m") + current_concurrency += step + continue + + success = probe_stats.success_count() + rate = (success / total * 100) if total > 0 else 0 + p99 = probe_stats.percentile(99) * 1000 # 转为 ms + + if baseline_p99 is None: + baseline_p99 = p99 if p99 > 0 else 1.0 + + # 打印本级结果 + rate_color = "\033[32m" if rate >= threshold else "\033[31m" + print(f" 并发 {current_concurrency:>5}: " + f"成功率 {rate_color}{rate:.1f}%\033[0m " + f"P99 {p99:.1f}ms " + f"({total} 请求)") + + # 判断是否到达临界点 + reached_limit = False + if rate < threshold: + print(f" \033[1;31m[!] 成功率 {rate:.1f}% < {threshold}%,到达临界点\033[0m") + reached_limit = True + elif baseline_p99 > 0 and p99 > baseline_p99 * 5: + print(f" \033[1;31m[!] P99 {p99:.1f}ms > 基线 {baseline_p99:.1f}ms × 5,到达临界点\033[0m") + reached_limit = True + + if reached_limit: + critical = last_good_concurrency + if critical == 0: + critical = max(1, current_concurrency - step) + print(f"\n \033[1;32m[*] 临界并发数: {critical}\033[0m") + return critical + + # 本级通过,记录为"好的"并发数 + last_good_concurrency = current_concurrency + current_concurrency += step + + async def _find_crash_point(self) -> dict: + """极限测试:探测临界点、警告点、崩溃点""" + step = self.config.step + probe_time = self.config.probe_time + threshold = self.config.threshold + crash_threshold = self.config.crash_threshold + + print(f"\n\033[1;36m[>>] 极限测试模式\033[0m") + print(f" 步长: {step}") + print(f" 探测时间: {probe_time}s/级") + print(f" 临界阈值: {threshold}%") + print(f" 崩溃阈值: {crash_threshold}%") + print() + + baseline_p99 = None + current_concurrency = step + last_good_concurrency = 0 + critical_concurrency = 0 + warning_concurrency = 0 + crash_concurrency = 0 + + # Phase 1 & 2: 逐步加压直到崩溃 + while True: + probe_stats = StatsCollector() + probe_stats.start_time = time.time() + + url_iter = iter(self._url_generator()) + connector = aiohttp.TCPConnector( + limit=current_concurrency + 10, + keepalive_timeout=30 if self.config.keep_alive else 0, + enable_cleanup_closed=True, + ) + jar = aiohttp.CookieJar() + if self.config.cookies: + for k, v in self.config.cookies.items(): + jar.update_cookies({k, v}) + + stop_event = asyncio.Event() + active_tasks: set[asyncio.Task] = set() + + async with aiohttp.ClientSession(connector=connector, cookie_jar=jar) as session: + async def probe_worker(): + while not stop_event.is_set(): + try: + url = next(url_iter) + except StopIteration: + break + result = await self._make_request(session, url) + await probe_stats.add(result) + + for _ in range(current_concurrency): + task = asyncio.create_task(probe_worker()) + active_tasks.add(task) + task.add_done_callback(active_tasks.discard) + + await asyncio.sleep(probe_time) + + stop_event.set() + if active_tasks: + await asyncio.gather(*active_tasks, return_exceptions=True) + + # 分析结果 + total = probe_stats.total_requests() + if total == 0: + print(f" \033[33m并发 {current_concurrency}: 无请求完成,跳过\033[0m") + current_concurrency += step + continue + + success = probe_stats.success_count() + rate = (success / total * 100) if total > 0 else 0 + p99 = probe_stats.percentile(99) * 1000 + + if baseline_p99 is None: + baseline_p99 = p99 if p99 > 0 else 1.0 + + # 打印本级结果 + if rate >= threshold: + rate_color = "\033[32m" + elif rate >= crash_threshold: + rate_color = "\033[33m" + else: + rate_color = "\033[31m" + print(f" 并发 {current_concurrency:>5}: " + f"成功率 {rate_color}{rate:.1f}%\033[0m " + f"P99 {p99:.1f}ms " + f"({total} 请求)") + + # 判断临界点(首次低于 threshold) + if critical_concurrency == 0 and rate < threshold: + critical_concurrency = last_good_concurrency if last_good_concurrency > 0 else max(1, current_concurrency - step) + print(f" \033[1;33m[!] 临界并发点: {critical_concurrency}\033[0m") + + # 判断警告点(首次低于 50%) + if warning_concurrency == 0 and rate < 50.0: + warning_concurrency = current_concurrency + print(f" \033[1;33m[!] 警告并发点: {warning_concurrency}\033[0m") + + # 判断崩溃点(首次低于 crash_threshold) + if rate < crash_threshold: + crash_concurrency = current_concurrency + print(f" \033[1;31m[!] 崩溃并发点: {crash_concurrency}\033[0m") + break + + last_good_concurrency = current_concurrency + current_concurrency += step + + # 如果从未触发临界点或警告点(不太可能,但处理边界情况) + if critical_concurrency == 0: + critical_concurrency = last_good_concurrency if last_good_concurrency > 0 else 1 + if warning_concurrency == 0: + warning_concurrency = crash_concurrency + + print(f"\n \033[1;36m[*] 极限测试结果:\033[0m") + print(f" 临界并发点: {critical_concurrency}") + print(f" 警告并发点: {warning_concurrency}") + print(f" 崩溃并发点: {crash_concurrency}") + + return { + "critical": critical_concurrency, + "warning": warning_concurrency, + "crash": crash_concurrency, + } + + async def run(self) -> StatsCollector: + """执行压力测试""" + # find-limit 模式:先探测临界点 + critical_concurrency = 0 + crash_results = None + if self.config.crash_test: + crash_results = await self._find_crash_point() + self.stats.start_time = time.time() + return self.stats + elif self.config.find_limit: + critical_concurrency = await self._find_critical_point() + self.config.concurrency = critical_concurrency + self.config.duration = int(self.config.hours * 3600) + # 重置 stats 用于正式测试 + self.stats = StatsCollector() + + self.stats.start_time = time.time() + + print(f"\n\033[1;36m[>>] 压力测试开始\033[0m") + print(f" 目标: {', '.join(self.config.urls)}") + print(f" 方法: {self.config.method}") + if self.config.find_limit: + print(f" 并发: {self.config.concurrency} (临界点)") + print(f" 持续: {self.config.duration}s ({self.config.hours} 小时)") + else: + print(f" 并发: {self.config.concurrency}") + if self.config.total_requests: + print(f" 请求数: {self.config.total_requests}") + if self.config.duration: + print(f" 持续: {self.config.duration}s") + print() + + stop_display = asyncio.Event() + + if self.config.no_display: + # 无显示模式:只运行控制循环 + control_task = asyncio.create_task(self._control_loop(None, stop_display)) + await control_task + else: + # 实时显示模式 + display = RealtimeDisplay(self.stats, self.config) + display.critical_concurrency = critical_concurrency + for _ in range(20): + print() + + display_task = asyncio.create_task(display.run(stop_display)) + control_task = asyncio.create_task(self._control_loop(display, stop_display)) + + await control_task + display.stop() + await display_task + + return self.stats + + +# --------------------------- 报告生成 --------------------------- + +class ReportGenerator: + """生成详细的最终测试报告""" + + def __init__(self, stats: StatsCollector, config: TestConfig): + self.stats = stats + self.config = config + + def _bar(self, value: float, max_value: float, width: int = 30) -> str: + if max_value <= 0: + return "" + filled = int(width * min(value / max_value, 1.0)) + return "#" * filled + "." * (width - filled) + + def generate(self) -> str: + s = self.stats + elapsed = s.elapsed_time() + total = s.total_requests() + success = s.success_count() + errors = s.error_count() + + lines = [] + lines.append("") + lines.append("\033[1;36m" + "=" * 64 + "\033[0m") + lines.append(" \033[1;33m[*] 压力测试报告\033[0m") + lines.append(" " + datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + lines.append("\033[1;36m" + "=" * 64 + "\033[0m") + + # 基本信息 + lines.append("") + lines.append(" \033[1m> 测试配置\033[0m") + lines.append(f" 目标 URL: {', '.join(self.config.urls)}") + lines.append(f" HTTP 方法: {self.config.method}") + lines.append(f" 并发数: {self.config.concurrency}") + lines.append(f" 总耗时: {elapsed:.2f}s") + if self.config.find_limit: + lines.append("") + lines.append(" \033[1;35m> 临界点探测结果\033[0m") + lines.append(f" 临界并发数: {self.config.concurrency}") + lines.append(f" 持续时间: {self.config.hours} 小时") + lines.append(f" 探测步长: {self.config.step}") + lines.append(f" 成功率阈值: {self.config.threshold}%") + if self.config.crash_test: + lines.append("") + lines.append(" \033[1;35m> 极限测试结果\033[0m") + lines.append(f" 探测步长: {self.config.step}") + lines.append(f" 探测时间: {self.config.probe_time}s/级") + lines.append(f" 临界阈值: {self.config.threshold}%") + lines.append(f" 崩溃阈值: {self.config.crash_threshold}%") + lines.append("") + + # 请求统计 + lines.append(" \033[1m> 请求统计\033[0m") + lines.append(f" 总请求数: {total}") + rate = (success / total * 100) if total > 0 else 0 + color = "\033[32m" if rate >= 99 else "\033[33m" if rate >= 95 else "\033[31m" + lines.append(f" 成功: \033[32m{success}\033[0m") + lines.append(f" 失败: \033[31m{errors}\033[0m") + lines.append(f" 成功率: {color}{rate:.2f}%\033[0m") + lines.append(f" 平均 QPS: {total / elapsed:.1f} req/s" if elapsed > 0 else "") + lines.append("") + + # 延迟分布 + if s._latencies: + lines.append(" \033[1m> 延迟分布 (ms)\033[0m") + percentiles = [50, 75, 90, 95, 99, 99.9] + for p in percentiles: + val = s.percentile(p) * 1000 + bar = self._bar(val, s.percentile(99.9) * 1000, 20) + lines.append(f" P{p:<5} {val:>8.1f} ms {bar}") + + lines.append("") + lines.append(f" 最小: {min(s._latencies) * 1000:.1f} ms") + lines.append(f" 最大: {max(s._latencies) * 1000:.1f} ms") + lines.append(f" 平均: {s.avg_latency() * 1000:.1f} ms") + if len(s._latencies) > 1: + std = statistics.stdev(s._latencies) * 1000 + lines.append(f" 标准差: {std:.1f} ms") + lines.append("") + + # 吞吐量 + lines.append(" \033[1m> 吞吐量\033[0m") + total_bytes = s._total_bytes + for unit in ["B", "KB", "MB", "GB"]: + if abs(total_bytes) < 1024: + lines.append(f" 总传输: {total_bytes:.1f} {unit}") + break + total_bytes /= 1024 + + tp = s.throughput() + for unit in ["B/s", "KB/s", "MB/s", "GB/s"]: + if abs(tp) < 1024: + lines.append(f" 吞吐量: {tp:.1f} {unit}") + break + tp /= 1024 + lines.append("") + + # 状态码分布 + if s._status_counts: + lines.append(" \033[1m> 状态码分布\033[0m") + max_count = max(s._status_counts.values()) + for code, count in sorted(s._status_counts.items()): + bar = self._bar(count, max_count, 20) + color = "\033[32m" if 200 <= code < 300 else "\033[33m" if 300 <= code < 400 else "\033[31m" + label = f"HTTP {code}" if code > 0 else "连接失败" + lines.append(f" {color}{label:<12}\033[0m {count:>6} {bar}") + lines.append("") + + # 错误详情 + if s._error_counts: + lines.append(" \033[1;31m> 错误详情\033[0m") + for err, count in sorted(s._error_counts.items(), key=lambda x: -x[1]): + lines.append(f" \033[31m{err:<30}\033[0m {count:>6} 次") + lines.append("") + + # 评价 + lines.append(" \033[1m> 综合评价\033[0m") + if rate >= 99.9 and s.percentile(99) < 0.5: + grade = "\033[1;32m[Excellent] 优秀\033[0m - 网站表现稳定,响应迅速" + elif rate >= 99 and s.percentile(99) < 1.0: + grade = "\033[1;32m[Good] 良好\033[0m - 网站表现正常" + elif rate >= 95: + grade = "\033[1;33m[Fair] 一般\033[0m - 存在部分超时或错误,建议优化" + elif rate >= 80: + grade = "\033[1;33m[Poor] 较差\033[0m - 高负载下错误率偏高" + else: + grade = "\033[1;31m[Bad] 很差\033[0m - 网站无法承受当前负载" + + lines.append(f" {grade}") + lines.append("") + lines.append("\033[1;36m" + "=" * 64 + "\033[0m") + + return "\n".join(lines) + + def save_json(self, filepath: str): + """保存 JSON 格式报告""" + s = self.stats + elapsed = s.elapsed_time() + total = s.total_requests() + + report = { + "timestamp": datetime.now().isoformat(), + "config": { + "urls": self.config.urls, + "method": self.config.method, + "concurrency": self.config.concurrency, + "total_requests": self.config.total_requests, + "duration": self.config.duration, + }, + "find_limit": { + "enabled": self.config.find_limit, + "critical_concurrency": self.config.concurrency if self.config.find_limit else None, + "hours": self.config.hours if self.config.find_limit else None, + "step": self.config.step if self.config.find_limit else None, + "threshold": self.config.threshold if self.config.find_limit else None, + } if self.config.find_limit else None, + "crash_test": { + "enabled": self.config.crash_test, + "step": self.config.step, + "probe_time": self.config.probe_time, + "threshold": self.config.threshold, + "crash_threshold": self.config.crash_threshold, + } if self.config.crash_test else None, + "results": { + "total_requests": total, + "success": s.success_count(), + "errors": s.error_count(), + "success_rate": round(s.success_count() / total * 100, 2) if total else 0, + "elapsed_seconds": round(elapsed, 2), + "avg_qps": round(total / elapsed, 1) if elapsed else 0, + "avg_latency_ms": round(s.avg_latency() * 1000, 2), + "p50_ms": round(s.percentile(50) * 1000, 2), + "p90_ms": round(s.percentile(90) * 1000, 2), + "p95_ms": round(s.percentile(95) * 1000, 2), + "p99_ms": round(s.percentile(99) * 1000, 2), + "total_bytes": s._total_bytes, + "throughput_bytes_sec": round(s.throughput(), 1), + "status_codes": dict(s._status_counts), + "errors_detail": dict(s._error_counts), + }, + } + + with open(filepath, "w", encoding="utf-8") as f: + json.dump(report, f, indent=2, ensure_ascii=False) + print(f"\n JSON 报告已保存: {filepath}") + + +# --------------------------- CLI 入口 --------------------------- + +def parse_headers(raw: list[str]) -> dict: + """解析 -H "Key: Value" 格式的请求头""" + headers = {} + for h in raw: + if ":" in h: + k, v = h.split(":", 1) + headers[k.strip()] = v.strip() + return headers + + +def parse_cookies(raw: str) -> dict: + """解析 k1=v1;k2=v2 格式的 Cookie""" + cookies = {} + for pair in raw.split(";"): + if "=" in pair: + k, v = pair.split("=", 1) + cookies[k.strip()] = v.strip() + return cookies + + +def main(): + # Windows 终端 UTF-8 支持 + if sys.platform == "win32": + try: + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + except Exception: + pass + + # 抑制 event loop 关闭时的无害警告 + warnings.filterwarnings("ignore", message=".*Event loop is closed.*") + + # Windows: 抑制 asyncio 底层连接重置的噪音回调 + if sys.platform == "win32": + def _silent_handler(loop, context): + exc = context.get("exception") + if isinstance(exc, (ConnectionResetError, ConnectionAbortedError, OSError)): + return # 静默忽略远端强制断开 + loop.default_exception_handler(context) + + try: + asyncio.get_event_loop().set_exception_handler(_silent_handler) + except RuntimeError: + pass + + parser = argparse.ArgumentParser( + description="Website Stress Testing Tool - 异步高并发压力测试工具", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + %(prog)s -u https://example.com -c 100 -n 1000 + %(prog)s -u https://example.com -c 50 -d 30 + %(prog)s -u https://api.example.com/login -m POST --data '{"user":"test"}' + %(prog)s -f urls.txt -c 50 -d 60 --report report.json + %(prog)s -u https://example.com --find-limit --hours 2 + %(prog)s -u https://example.com --find-limit --hours 1 --step 20 --probe-time 15 + %(prog)s -u https://example.com --crash-test + %(prog)s -u https://example.com --crash-test --step 20 --probe-time 15 + """, + ) + + # 目标 + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument("-u", "--url", action="append", help="目标 URL(可多次指定)") + target.add_argument("-f", "--file", help="URL 列表文件(每行一个)") + + # 测试模式(-n/-d 与 --find-limit 互斥) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("-n", "--num-requests", type=int, help="总请求数") + mode.add_argument("-d", "--duration", type=int, help="持续时间(秒)") + mode.add_argument("--find-limit", action="store_true", + help="自动探测临界并发点(需配合 --hours)") + mode.add_argument("--crash-test", action="store_true", + help="极限测试:探测临界点、警告点、崩溃点") + + # 并发 + parser.add_argument("-c", "--concurrency", type=int, default=10, help="并发数 (默认: 10)") + parser.add_argument("-r", "--ramp-up", type=int, default=0, help="渐进加压时间(秒)(默认: 0)") + + # find-limit 模式参数 + parser.add_argument("--hours", type=float, default=0, + help="临界点后持续压测小时数(配合 --find-limit)") + parser.add_argument("--step", type=int, default=10, + help="并发递增步长 (默认: 10)") + parser.add_argument("--probe-time", type=int, default=10, + help="每级探测持续秒数 (默认: 10)") + parser.add_argument("--threshold", type=float, default=95, + help="成功率临界阈值百分比 (默认: 95)") + parser.add_argument("--crash-threshold", type=float, default=10, + help="崩溃阈值:成功率低于此值视为崩溃 (默认: 10)") + + # HTTP 配置 + parser.add_argument("-m", "--method", default="GET", + choices=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"], + help="HTTP 方法 (默认: GET)") + parser.add_argument("-H", "--header", action="append", default=[], + help="自定义请求头 (-H 'Key: Value')") + parser.add_argument("--cookie", default="", help="Cookie (k1=v1;k2=v2)") + parser.add_argument("--data", help="请求体(字符串)") + parser.add_argument("--json", dest="json_data", help="JSON 请求体") + + # 网络 + parser.add_argument("-t", "--timeout", type=int, default=30, help="请求超时(秒)(默认: 30)") + parser.add_argument("--no-verify-ssl", action="store_true", help="跳过 SSL 验证") + parser.add_argument("--no-keep-alive", action="store_true", help="禁用 Keep-Alive") + + # 输出 + parser.add_argument("--report", help="保存 JSON 报告到文件") + parser.add_argument("--no-display", action="store_true", help="禁用实时显示(适合日志采集)") + + args = parser.parse_args() + + # find-limit 模式验证 + if args.find_limit and args.hours <= 0: + parser.error("--find-limit 需要配合 --hours 指定持续小时数(必须 > 0)") + + # 解析 URL + urls = [] + if args.url: + urls = args.url + elif args.file: + with open(args.file, "r", encoding="utf-8") as f: + urls = [line.strip() for line in f if line.strip() and not line.startswith("#")] + if not urls: + print(f"错误: 文件 {args.file} 中没有有效 URL") + sys.exit(1) + + # 确保 URL 有协议前缀 + urls = [u if u.startswith(("http://", "https://")) else "https://" + u for u in urls] + + # 解析 JSON body + json_data = None + if args.json_data: + try: + json_data = json.loads(args.json_data) + except json.JSONDecodeError: + print("错误: --json 参数不是有效的 JSON") + sys.exit(1) + + config = TestConfig( + urls=urls, + method=args.method, + headers=parse_headers(args.header), + cookies=parse_cookies(args.cookie) if args.cookie else {}, + data=args.data, + json_data=json_data, + concurrency=args.concurrency, + total_requests=args.num_requests, + duration=args.duration, + timeout=args.timeout, + ramp_up=args.ramp_up, + verify_ssl=not args.no_verify_ssl, + keep_alive=not args.no_keep_alive, + no_display=args.no_display, + find_limit=args.find_limit, + hours=args.hours, + step=args.step, + probe_time=args.probe_time, + threshold=args.threshold, + crash_test=args.crash_test, + crash_threshold=args.crash_threshold, + ) + + # 运行测试 + try: + tester = StressTester(config) + stats = asyncio.run(tester.run()) + except KeyboardInterrupt: + print("\n\n\033[33m[!] 测试被用户中断\033[0m") + stats = tester.stats + + # 生成报告 + report = ReportGenerator(stats, config) + print(report.generate()) + + if args.report: + report.save_json(args.report) + + +if __name__ == "__main__": + main() diff --git a/test_result.json b/test_result.json new file mode 100755 index 0000000..d5d9db2 --- /dev/null +++ b/test_result.json @@ -0,0 +1,31 @@ +{ + "timestamp": "2026-05-29T08:21:25.325564", + "config": { + "urls": [ + "http://118.89.55.254" + ], + "method": "GET", + "concurrency": 20, + "total_requests": 100, + "duration": null + }, + "results": { + "total_requests": 124, + "success": 124, + "errors": 0, + "success_rate": 100.0, + "elapsed_seconds": 1.39, + "avg_qps": 88.9, + "avg_latency_ms": 167.43, + "p50_ms": 75.7, + "p90_ms": 375.06, + "p95_ms": 1055.75, + "p99_ms": 1100.21, + "total_bytes": 1750756, + "throughput_bytes_sec": 1255476.5, + "status_codes": { + "200": 124 + }, + "errors_detail": {} + } +} \ No newline at end of file diff --git a/urls_example.txt b/urls_example.txt new file mode 100755 index 0000000..805f010 --- /dev/null +++ b/urls_example.txt @@ -0,0 +1,6 @@ +# 示例 URL 列表文件 +# 每行一个 URL,# 开头的行为注释 + +https://example.com +https://example.com/api +https://example.com/about