Parcourir la source

feat: 添加性能压力测试套件 (#94)

- REST API 压测 (Locust): 支持 10-1000 并发梯度测试,覆盖监测/上报/报表/GIS 等端点
- WebSocket 压测: asyncio+websockets 模拟 100-5000 并发长连接
- MQTT IoT 压测: paho-mqtt 模拟 100-5000 台设备同时上报
- 数据库查询压测: 百万级数据测试,含索引对比、GIS 空间查询
- 统一入口脚本 + 报告模板 + 使用文档
bot_dev2 il y a 3 jours
Parent
révision
cf98551a94

+ 205
- 0
tests/performance/README.md Voir le fichier

@@ -0,0 +1,205 @@
1
+# 性能压力测试套件
2
+
3
+水务管理系统性能压力测试工具集,覆盖 REST API、WebSocket、MQTT IoT、数据库查询四大模块。
4
+
5
+## 📁 文件说明
6
+
7
+| 文件 | 说明 |
8
+|------|------|
9
+| `locustfile.py` | REST API 压力测试 (Locust 框架) |
10
+| `websocket_stress.py` | WebSocket 长连接并发测试 |
11
+| `mqtt_iot_stress.py` | IoT MQTT 设备并发模拟 |
12
+| `db_query_stress.py` | 大数据量数据库查询性能测试 |
13
+| `run_all_benchmarks.sh` | 统一压测入口脚本 |
14
+| `report_template.md` | 压测报告模板 |
15
+| `requirements.txt` | Python 依赖 |
16
+
17
+## 🚀 快速开始
18
+
19
+### 1. 安装依赖
20
+
21
+```bash
22
+cd tests/performance
23
+pip install -r requirements.txt
24
+```
25
+
26
+### 2. 运行全部测试
27
+
28
+```bash
29
+# 完整测试(约 30 分钟)
30
+bash run_all_benchmarks.sh
31
+
32
+# 快速模式(约 5 分钟,适合 CI/CD)
33
+bash run_all_benchmarks.sh --quick
34
+
35
+# 指定输出目录
36
+bash run_all_benchmarks.sh --output-dir ./my-report
37
+```
38
+
39
+### 3. 运行单项测试
40
+
41
+#### REST API 压测
42
+
43
+```bash
44
+# 图形界面模式(浏览器打开 http://localhost:8089)
45
+locust -f locustfile.py --host=http://localhost:8080
46
+
47
+# 无头模式,100 并发,运行 5 分钟
48
+locust -f locustfile.py --host=http://localhost:8080 \
49
+    --headless -u 100 -r 10 --run-time 5m
50
+
51
+# 梯度负载测试
52
+locust -f locustfile.py --host=http://localhost:8080 \
53
+    --headless --class-picker StepLoadShape
54
+```
55
+
56
+#### WebSocket 压测
57
+
58
+```bash
59
+# 100 并发连接,60 秒
60
+python websocket_stress.py --clients 100 --duration 60
61
+
62
+# 梯度测试 (100/500/1000/5000)
63
+python websocket_stress.py --step --output ws_result.json
64
+```
65
+
66
+#### MQTT IoT 压测
67
+
68
+```bash
69
+# 模拟 500 设备,每 5 秒上报,运行 2 分钟
70
+python mqtt_iot_stress.py --devices 500 --duration 120 --freq 5
71
+
72
+# 梯度测试
73
+python mqtt_iot_stress.py --step --output mqtt_result.json
74
+
75
+# 高频上报 (1 秒)
76
+python mqtt_iot_stress.py --devices 1000 --freq 1 --duration 60
77
+```
78
+
79
+#### 数据库查询压测
80
+
81
+```bash
82
+# 生成 100 万条数据 + 运行查询测试 + 索引对比
83
+python db_query_stress.py --generate-data 1000000 --index-compare
84
+
85
+# 跳过数据生成(已有数据)
86
+python db_query_stress.py --skip-data-gen --index-compare
87
+
88
+# 指定数据库连接
89
+python db_query_stress.py --host db.example.com --db water_management \
90
+    --user admin --password secret --generate-data 500000
91
+```
92
+
93
+## 📊 解读报告
94
+
95
+### REST API 报告
96
+
97
+| 指标 | 说明 | 参考阈值 |
98
+|------|------|----------|
99
+| 平均响应时间 | 所有请求的平均耗时 | < 200ms |
100
+| P95 响应时间 | 95% 请求的耗时上限 | < 1000ms |
101
+| P99 响应时间 | 99% 请求的耗时上限 | < 3000ms |
102
+| 吞吐量 (RPS) | 每秒处理请求数 | 越高越好 |
103
+| 错误率 | 失败请求占比 | < 1% |
104
+
105
+### WebSocket 报告
106
+
107
+| 指标 | 说明 | 参考阈值 |
108
+|------|------|----------|
109
+| 连接成功率 | 成功建立连接的比例 | > 99% |
110
+| 消息延迟 | 消息从发送到收到的时间 | < 100ms |
111
+| 断连率 | 连接断开的比例 | < 5% |
112
+| 内存消耗 | 服务器内存使用 | 视硬件而定 |
113
+
114
+### MQTT 报告
115
+
116
+| 指标 | 说明 | 参考阈值 |
117
+|------|------|----------|
118
+| 连接成功率 | 设备连接成功率 | > 99% |
119
+| 发布延迟 | 消息发布时间 | < 50ms |
120
+| 吞吐量 | 每秒消息处理量 | 视配置而定 |
121
+
122
+### 数据库报告
123
+
124
+| 指标 | 说明 | 参考阈值 |
125
+|------|------|----------|
126
+| 简单查询 | 单表查询耗时 | < 100ms |
127
+| 聚合查询 | GROUP BY 聚合耗时 | < 500ms |
128
+| JOIN 查询 | 多表关联耗时 | < 1000ms |
129
+| GIS 查询 | 空间查询耗时 | < 500ms |
130
+
131
+## ⚙️ 环境变量配置
132
+
133
+| 变量 | 默认值 | 说明 |
134
+|------|--------|------|
135
+| `API_HOST` | localhost | REST API 地址 |
136
+| `API_PORT` | 8080 | REST API 端口 |
137
+| `WS_HOST` | localhost | WebSocket 地址 |
138
+| `WS_PORT` | 8765 | WebSocket 端口 |
139
+| `MQTT_BROKER` | localhost | MQTT Broker 地址 |
140
+| `MQTT_PORT` | 1883 | MQTT Broker 端口 |
141
+| `DB_HOST` | localhost | 数据库地址 |
142
+| `DB_PORT` | 5432 | 数据库端口 |
143
+| `DB_NAME` | water_management | 数据库名 |
144
+| `DB_USER` | postgres | 数据库用户 |
145
+| `DB_PASS` | postgres | 数据库密码 |
146
+
147
+## ❓ 常见问题
148
+
149
+### Q: Locust 安装失败
150
+```bash
151
+pip install --upgrade pip setuptools wheel
152
+pip install locust
153
+```
154
+
155
+### Q: psycopg2 安装失败
156
+```bash
157
+# Debian/Ubuntu
158
+sudo apt-get install libpq-dev
159
+pip install psycopg2-binary
160
+
161
+# macOS
162
+brew install postgresql
163
+pip install psycopg2-binary
164
+```
165
+
166
+### Q: WebSocket 连接被拒绝
167
+确认 WebSocket 服务已启动且端口正确:
168
+```bash
169
+# 检查端口
170
+ss -tlnp | grep 8765
171
+# 或用 curl 测试
172
+curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" http://localhost:8765/ws
173
+```
174
+
175
+### Q: MQTT 连接失败
176
+确认 EMQX 或其他 MQTT Broker 已启动:
177
+```bash
178
+# Docker 方式启动 EMQX
179
+docker run -d --name emqx -p 1883:1883 -p 8083:8083 -p 18083:18083 emqx/emqx
180
+```
181
+
182
+### Q: 数据库查询测试报错
183
+确认 PostgreSQL 已启动且可访问:
184
+```bash
185
+psql -h localhost -U postgres -d water_management -c "SELECT 1"
186
+```
187
+
188
+### Q: 测试数据太多占磁盘
189
+```bash
190
+# 测试完成后清理
191
+psql -h localhost -U postgres -d water_management -c \
192
+    "TRUNCATE TABLE perf_sensor_data, perf_alarms, perf_devices"
193
+```
194
+
195
+## 📈 推荐测试流程
196
+
197
+1. **先单项测试**:分别运行四个测试,确认各自正常
198
+2. **再快速模式**:`run_all_benchmarks.sh --quick` 快速验证全流程
199
+3. **最后完整测试**:`run_all_benchmarks.sh` 执行完整压力测试
200
+4. **分析瓶颈**:对比各项结果,找出系统瓶颈
201
+5. **优化后复测**:针对瓶颈优化后重新测试,对比改善效果
202
+
203
+## 📝 License
204
+
205
+本项目内部使用,请遵循项目根目录的 LICENSE。

+ 684
- 0
tests/performance/db_query_stress.py Voir le fichier

@@ -0,0 +1,684 @@
1
+#!/usr/bin/env python3
2
+"""
3
+大数据量数据库查询压力测试
4
+
5
+测试百万级记录下各类查询的性能:
6
+- 简单查询(单表 WHERE)
7
+- 聚合查询(GROUP BY / SUM / AVG)
8
+- JOIN 查询(多表关联)
9
+- GIS 空间查询(PostGIS ST_DWithin 等)
10
+- 对比有无索引的性能差异
11
+
12
+运行方式:
13
+  python db_query_stress.py [--host HOST] [--port PORT] [--db DB] [--user USER] [--password PASS]
14
+
15
+示例:
16
+  python db_query_stress.py --host localhost --db water_management --generate-data 1000000
17
+"""
18
+
19
+import argparse
20
+import json
21
+import time
22
+import random
23
+import statistics
24
+import sys
25
+from datetime import datetime, timedelta
26
+from contextlib import contextmanager
27
+
28
+try:
29
+    import psycopg2
30
+    from psycopg2 import pool
31
+except ImportError:
32
+    print("请先安装 psycopg2: pip install psycopg2-binary")
33
+    sys.exit(1)
34
+
35
+
36
+# ==================== 配置 ====================
37
+
38
+DEFAULT_HOST = "localhost"
39
+DEFAULT_PORT = 5432
40
+DEFAULT_DB = "water_management"
41
+DEFAULT_USER = "postgres"
42
+DEFAULT_PASSWORD = "postgres"
43
+
44
+# 测试查询配置
45
+QUERY_ROUNDS = 5  # 每个查询重复次数取平均值
46
+
47
+
48
+# ==================== 数据库连接 ====================
49
+
50
+class DBConnectionPool:
51
+    """数据库连接池管理"""
52
+
53
+    def __init__(self, host, port, db, user, password, min_conn=2, max_conn=10):
54
+        self.pool = pool.ThreadedConnectionPool(
55
+            min_conn, max_conn,
56
+            host=host, port=port, dbname=db, user=user, password=password,
57
+            connect_timeout=10,
58
+        )
59
+
60
+    @contextmanager
61
+    def get_connection(self):
62
+        conn = self.pool.getconn()
63
+        try:
64
+            yield conn
65
+        finally:
66
+            self.pool.putconn(conn)
67
+
68
+    def close(self):
69
+        self.pool.closeall()
70
+
71
+
72
+# ==================== 测试数据生成 ====================
73
+
74
+def generate_test_data(db_pool, num_records):
75
+    """生成测试数据"""
76
+    print(f"\n📦 生成 {num_records} 条测试数据...")
77
+
78
+    with db_pool.get_connection() as conn:
79
+        cur = conn.cursor()
80
+
81
+        # 创建传感器数据表(如不存在)
82
+        cur.execute("""
83
+            CREATE TABLE IF NOT EXISTS perf_sensor_data (
84
+                id BIGSERIAL PRIMARY KEY,
85
+                sensor_id INTEGER NOT NULL,
86
+                device_id VARCHAR(32) NOT NULL,
87
+                timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
88
+                pressure REAL,
89
+                flow REAL,
90
+                temperature REAL,
91
+                ph REAL,
92
+                turbidity REAL,
93
+                chlorine REAL,
94
+                battery REAL,
95
+                signal_strength INTEGER,
96
+                lng DOUBLE PRECISION,
97
+                lat DOUBLE PRECISION,
98
+                area_id INTEGER,
99
+                created_at TIMESTAMPTZ DEFAULT NOW()
100
+            )
101
+        """)
102
+
103
+        # 创建告警表
104
+        cur.execute("""
105
+            CREATE TABLE IF NOT EXISTS perf_alarms (
106
+                id BIGSERIAL PRIMARY KEY,
107
+                sensor_id INTEGER NOT NULL,
108
+                alarm_type VARCHAR(32) NOT NULL,
109
+                severity VARCHAR(16) NOT NULL,
110
+                value REAL,
111
+                threshold REAL,
112
+                description TEXT,
113
+                acknowledged BOOLEAN DEFAULT FALSE,
114
+                timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
115
+                created_at TIMESTAMPTZ DEFAULT NOW()
116
+            )
117
+        """)
118
+
119
+        # 创建设备表
120
+        cur.execute("""
121
+            CREATE TABLE IF NOT EXISTS perf_devices (
122
+                id SERIAL PRIMARY KEY,
123
+                device_id VARCHAR(32) UNIQUE NOT NULL,
124
+                name VARCHAR(128),
125
+                device_type VARCHAR(32),
126
+                area_id INTEGER,
127
+                lng DOUBLE PRECISION,
128
+                lat DOUBLE PRECISION,
129
+                status VARCHAR(16) DEFAULT 'active',
130
+                install_date DATE,
131
+                created_at TIMESTAMPTZ DEFAULT NOW()
132
+            )
133
+        """)
134
+
135
+        conn.commit()
136
+
137
+        # 清空旧测试数据
138
+        print("  清空旧数据...")
139
+        cur.execute("TRUNCATE TABLE perf_sensor_data, perf_alarms, perf_devices")
140
+        conn.commit()
141
+
142
+        # 生成设备数据
143
+        print("  生成设备数据...")
144
+        device_count = min(5000, num_records // 100)
145
+        devices = []
146
+        for i in range(1, device_count + 1):
147
+            device_id = f"DEV{i:06d}"
148
+            devices.append((
149
+                device_id,
150
+                f"传感器-{i}",
151
+                random.choice(["pressure", "flow", "quality", "level", "valve"]),
152
+                random.randint(1, 50),
153
+                round(random.uniform(116.0, 117.0), 6),
154
+                round(random.uniform(39.5, 40.5), 6),
155
+                random.choice(["active", "inactive", "maintenance"]),
156
+                (datetime.now() - timedelta(days=random.randint(0, 730))).date(),
157
+            ))
158
+
159
+        cur.executemany("""
160
+            INSERT INTO perf_devices (device_id, name, device_type, area_id, lng, lat, status, install_date)
161
+            VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
162
+        """, devices)
163
+        conn.commit()
164
+        print(f"  ✅ 已生成 {device_count} 个设备")
165
+
166
+        # 批量插入传感器数据
167
+        print(f"  生成 {num_records} 条传感器数据...")
168
+        batch_size = 10000
169
+        total_inserted = 0
170
+        base_time = datetime.now() - timedelta(days=365)
171
+
172
+        alarm_types = ["PRESSURE_HIGH", "PRESSURE_LOW", "FLOW_ABNORMAL",
173
+                       "QUALITY_WARNING", "LEAK_DETECTED", "BATTERY_LOW"]
174
+        severities = ["INFO", "WARNING", "CRITICAL"]
175
+
176
+        while total_inserted < num_records:
177
+            batch = []
178
+            for _ in range(min(batch_size, num_records - total_inserted)):
179
+                sensor_id = random.randint(1, device_count)
180
+                ts = base_time + timedelta(
181
+                    seconds=random.randint(0, 365 * 24 * 3600)
182
+                )
183
+                batch.append((
184
+                    sensor_id,
185
+                    f"DEV{sensor_id:06d}",
186
+                    ts,
187
+                    round(random.uniform(0.1, 0.8), 3),  # pressure
188
+                    round(random.uniform(10, 500), 2),    # flow
189
+                    round(random.uniform(5, 35), 1),      # temperature
190
+                    round(random.uniform(6.5, 8.5), 2),   # ph
191
+                    round(random.uniform(0, 5), 2),       # turbidity
192
+                    round(random.uniform(0.1, 0.8), 3),   # chlorine
193
+                    round(random.uniform(20, 100), 1),    # battery
194
+                    random.randint(-90, -30),             # signal
195
+                    round(random.uniform(116.0, 117.0), 6),  # lng
196
+                    round(random.uniform(39.5, 40.5), 6),    # lat
197
+                    random.randint(1, 50),                # area_id
198
+                ))
199
+
200
+            cur.executemany("""
201
+                INSERT INTO perf_sensor_data
202
+                (sensor_id, device_id, timestamp, pressure, flow, temperature,
203
+                 ph, turbidity, chlorine, battery, signal_strength, lng, lat, area_id)
204
+                VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
205
+            """, batch)
206
+            conn.commit()
207
+            total_inserted += len(batch)
208
+            if total_inserted % 100000 == 0:
209
+                print(f"  进度: {total_inserted}/{num_records} ({total_inserted*100//num_records}%)")
210
+
211
+        print(f"  ✅ 已生成 {total_inserted} 条传感器数据")
212
+
213
+        # 生成告警数据(传感器数据的 5%)
214
+        alarm_count = num_records // 20
215
+        print(f"  生成 {alarm_count} 条告警数据...")
216
+        alarms = []
217
+        for _ in range(alarm_count):
218
+            sensor_id = random.randint(1, device_count)
219
+            alarms.append((
220
+                sensor_id,
221
+                random.choice(alarm_types),
222
+                random.choice(severities),
223
+                round(random.uniform(0, 100), 2),
224
+                round(random.uniform(50, 100), 2),
225
+                f"自动告警描述 - {random.choice(alarm_types)}",
226
+                random.choice([True, False]),
227
+                base_time + timedelta(seconds=random.randint(0, 365 * 24 * 3600)),
228
+            ))
229
+
230
+        cur.executemany("""
231
+            INSERT INTO perf_alarms
232
+            (sensor_id, alarm_type, severity, value, threshold, description, acknowledged, timestamp)
233
+            VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
234
+        """, alarms)
235
+        conn.commit()
236
+        print(f"  ✅ 已生成 {alarm_count} 条告警数据")
237
+
238
+        cur.close()
239
+
240
+    print("✅ 测试数据生成完成\n")
241
+
242
+
243
+# ==================== 索引管理 ====================
244
+
245
+def create_indexes(db_pool):
246
+    """创建性能测试用索引"""
247
+    with db_pool.get_connection() as conn:
248
+        cur = conn.cursor()
249
+        indexes = [
250
+            "CREATE INDEX IF NOT EXISTS idx_sensor_data_sensor_id ON perf_sensor_data(sensor_id)",
251
+            "CREATE INDEX IF NOT EXISTS idx_sensor_data_timestamp ON perf_sensor_data(timestamp)",
252
+            "CREATE INDEX IF NOT EXISTS idx_sensor_data_area_id ON perf_sensor_data(area_id)",
253
+            "CREATE INDEX IF NOT EXISTS idx_sensor_data_device_id ON perf_sensor_data(device_id)",
254
+            "CREATE INDEX IF NOT EXISTS idx_sensor_data_composite ON perf_sensor_data(sensor_id, timestamp)",
255
+            "CREATE INDEX IF NOT EXISTS idx_alarms_sensor_id ON perf_alarms(sensor_id)",
256
+            "CREATE INDEX IF NOT EXISTS idx_alarms_timestamp ON perf_alarms(timestamp)",
257
+            "CREATE INDEX IF NOT EXISTS idx_alarms_type ON perf_alarms(alarm_type)",
258
+            "CREATE INDEX IF NOT EXISTS idx_devices_area_id ON perf_devices(area_id)",
259
+            "CREATE INDEX IF NOT EXISTS idx_devices_type ON perf_devices(device_type)",
260
+        ]
261
+        for idx in indexes:
262
+            cur.execute(idx)
263
+        conn.commit()
264
+        cur.close()
265
+    print("✅ 索引已创建")
266
+
267
+
268
+def drop_indexes(db_pool):
269
+    """删除性能测试用索引"""
270
+    with db_pool.get_connection() as conn:
271
+        cur = conn.cursor()
272
+        indexes = [
273
+            "idx_sensor_data_sensor_id", "idx_sensor_data_timestamp",
274
+            "idx_sensor_data_area_id", "idx_sensor_data_device_id",
275
+            "idx_sensor_data_composite", "idx_alarms_sensor_id",
276
+            "idx_alarms_timestamp", "idx_alarms_type",
277
+            "idx_devices_area_id", "idx_devices_type",
278
+        ]
279
+        for idx in indexes:
280
+            cur.execute(f"DROP INDEX IF EXISTS {idx}")
281
+        conn.commit()
282
+        cur.close()
283
+    print("🗑️ 索引已删除")
284
+
285
+
286
+# ==================== 查询测试 ====================
287
+
288
+class QueryBenchmark:
289
+    """查询性能基准测试"""
290
+
291
+    def __init__(self, db_pool):
292
+        self.db_pool = db_pool
293
+        self.results = []
294
+
295
+    def run_query(self, name, sql, params=None, rounds=QUERY_ROUNDS):
296
+        """执行查询并统计耗时"""
297
+        times = []
298
+        row_count = 0
299
+
300
+        for i in range(rounds):
301
+            with self.db_pool.get_connection() as conn:
302
+                cur = conn.cursor()
303
+                start = time.time()
304
+                try:
305
+                    cur.execute(sql, params)
306
+                    rows = cur.fetchall()
307
+                    row_count = len(rows)
308
+                except Exception as e:
309
+                    print(f"    ⚠️ 查询错误: {e}")
310
+                    conn.rollback()
311
+                    row_count = 0
312
+                    times.append(-1)
313
+                    cur.close()
314
+                    continue
315
+                elapsed_ms = (time.time() - start) * 1000
316
+                times.append(elapsed_ms)
317
+                cur.close()
318
+
319
+        valid_times = [t for t in times if t >= 0]
320
+        if valid_times:
321
+            result = {
322
+                "name": name,
323
+                "sql": sql[:100] + ("..." if len(sql) > 100 else ""),
324
+                "rounds": rounds,
325
+                "success_rounds": len(valid_times),
326
+                "rows_returned": row_count,
327
+                "min_ms": round(min(valid_times), 2),
328
+                "max_ms": round(max(valid_times), 2),
329
+                "avg_ms": round(statistics.mean(valid_times), 2),
330
+                "median_ms": round(statistics.median(valid_times), 2),
331
+            }
332
+        else:
333
+            result = {
334
+                "name": name,
335
+                "sql": sql[:100] + ("..." if len(sql) > 100 else ""),
336
+                "rounds": rounds,
337
+                "success_rounds": 0,
338
+                "error": "all rounds failed",
339
+            }
340
+
341
+        self.results.append(result)
342
+        status = "✅" if valid_times else "❌"
343
+        avg = result.get("avg_ms", "N/A")
344
+        print(f"  {status} {name}: avg={avg}ms, rows={row_count}")
345
+        return result
346
+
347
+    def run_all_benchmarks(self):
348
+        """运行所有基准测试"""
349
+        print("\n🔍 运行查询性能测试...\n")
350
+
351
+        # 1. 简单查询
352
+        print("  --- 简单查询 ---")
353
+        self.run_query(
354
+            "简单查询 - 按传感器ID查询最近24小时",
355
+            """SELECT * FROM perf_sensor_data
356
+               WHERE sensor_id = %s AND timestamp > NOW() - INTERVAL '24 hours'
357
+               ORDER BY timestamp DESC LIMIT 100""",
358
+            (random.randint(1, 5000),),
359
+        )
360
+
361
+        self.run_query(
362
+            "简单查询 - 按区域ID查询",
363
+            """SELECT * FROM perf_sensor_data
364
+               WHERE area_id = %s
365
+               ORDER BY timestamp DESC LIMIT 100""",
366
+            (random.randint(1, 50),),
367
+        )
368
+
369
+        self.run_query(
370
+            "简单查询 - 时间范围查询",
371
+            """SELECT * FROM perf_sensor_data
372
+               WHERE timestamp BETWEEN %s AND %s
373
+               ORDER BY timestamp DESC LIMIT 1000""",
374
+            (
375
+                (datetime.now() - timedelta(days=7)).isoformat(),
376
+                datetime.now().isoformat(),
377
+            ),
378
+        )
379
+
380
+        # 2. 聚合查询
381
+        print("\n  --- 聚合查询 ---")
382
+        self.run_query(
383
+            "聚合查询 - 每小时平均压力",
384
+            """SELECT date_trunc('hour', timestamp) AS hour,
385
+                      AVG(pressure) AS avg_pressure,
386
+                      MAX(pressure) AS max_pressure,
387
+                      MIN(pressure) AS min_pressure,
388
+                      COUNT(*) AS readings
389
+               FROM perf_sensor_data
390
+               WHERE sensor_id = %s AND timestamp > NOW() - INTERVAL '7 days'
391
+               GROUP BY hour ORDER BY hour""",
392
+            (random.randint(1, 5000),),
393
+        )
394
+
395
+        self.run_query(
396
+            "聚合查询 - 各区域统计",
397
+            """SELECT area_id,
398
+                      COUNT(*) AS total_readings,
399
+                      AVG(pressure) AS avg_pressure,
400
+                      AVG(flow) AS avg_flow,
401
+                      AVG(temperature) AS avg_temp
402
+               FROM perf_sensor_data
403
+               WHERE timestamp > NOW() - INTERVAL '1 day'
404
+               GROUP BY area_id ORDER BY total_readings DESC""",
405
+        )
406
+
407
+        self.run_query(
408
+            "聚合查询 - 日统计报表",
409
+            """SELECT date_trunc('day', timestamp) AS day,
410
+                      COUNT(*) AS readings,
411
+                      AVG(pressure) AS avg_pressure,
412
+                      AVG(flow) AS avg_flow,
413
+                      STDDEV(pressure) AS pressure_stddev
414
+               FROM perf_sensor_data
415
+               WHERE timestamp > NOW() - INTERVAL '30 days'
416
+               GROUP BY day ORDER BY day""",
417
+        )
418
+
419
+        # 3. JOIN 查询
420
+        print("\n  --- JOIN 查询 ---")
421
+        self.run_query(
422
+            "JOIN 查询 - 传感器数据 + 设备信息",
423
+            """SELECT s.timestamp, s.pressure, s.flow, s.temperature,
424
+                      d.name AS device_name, d.device_type, d.area_id, d.status
425
+               FROM perf_sensor_data s
426
+               JOIN perf_devices d ON s.device_id = d.device_id
427
+               WHERE s.sensor_id = %s
428
+               ORDER BY s.timestamp DESC LIMIT 100""",
429
+            (random.randint(1, 5000),),
430
+        )
431
+
432
+        self.run_query(
433
+            "JOIN 查询 - 告警 + 设备信息",
434
+            """SELECT a.timestamp, a.alarm_type, a.severity, a.value, a.threshold,
435
+                      d.name AS device_name, d.device_type, d.lng, d.lat
436
+               FROM perf_alarms a
437
+               JOIN perf_devices d ON a.sensor_id = d.id
438
+               WHERE a.timestamp > NOW() - INTERVAL '7 days'
439
+               ORDER BY a.timestamp DESC LIMIT 200""",
440
+        )
441
+
442
+        self.run_query(
443
+            "JOIN 查询 - 三表关联统计",
444
+            """SELECT d.area_id,
445
+                      COUNT(DISTINCT d.id) AS device_count,
446
+                      COUNT(s.id) AS reading_count,
447
+                      COUNT(a.id) AS alarm_count,
448
+                      AVG(s.pressure) AS avg_pressure
449
+               FROM perf_devices d
450
+               LEFT JOIN perf_sensor_data s ON s.device_id = d.device_id
451
+                   AND s.timestamp > NOW() - INTERVAL '1 day'
452
+               LEFT JOIN perf_alarms a ON a.sensor_id = d.id
453
+                   AND a.timestamp > NOW() - INTERVAL '1 day'
454
+               GROUP BY d.area_id ORDER BY reading_count DESC""",
455
+        )
456
+
457
+        # 4. GIS 空间查询
458
+        print("\n  --- GIS 空间查询 ---")
459
+
460
+        # 先检查 PostGIS 是否可用
461
+        postgis_available = False
462
+        try:
463
+            with self.db_pool.get_connection() as conn:
464
+                cur = conn.cursor()
465
+                cur.execute("SELECT PostGIS_Version()")
466
+                postgis_available = True
467
+                cur.close()
468
+        except Exception:
469
+            pass
470
+
471
+        if postgis_available:
472
+            self.run_query(
473
+                "GIS 查询 - 圆形范围内设备 (1km)",
474
+                """SELECT d.id, d.device_id, d.name, d.device_type,
475
+                          ST_Distance(
476
+                            ST_SetSRID(ST_MakePoint(d.lng, d.lat), 4326)::geography,
477
+                            ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography
478
+                          ) AS distance_m
479
+                   FROM perf_devices d
480
+                   WHERE ST_DWithin(
481
+                     ST_SetSRID(ST_MakePoint(d.lng, d.lat), 4326)::geography,
482
+                     ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography,
483
+                     1000
484
+                   )
485
+                   ORDER BY distance_m""",
486
+                (116.4, 39.9, 116.4, 39.9),
487
+            )
488
+        else:
489
+            # 无 PostGIS 时用距离公式近似
490
+            self.run_query(
491
+                "空间查询 - 距离近似计算 (无PostGIS)",
492
+                """SELECT id, device_id, name, device_type,
493
+                          SQRT(POW(lng - %s, 2) + POW(lat - %s, 2)) * 111000 AS approx_distance_m
494
+                   FROM perf_devices
495
+                   WHERE ABS(lng - %s) < 0.01 AND ABS(lat - %s) < 0.01
496
+                   ORDER BY approx_distance_m
497
+                   LIMIT 100""",
498
+                (116.4, 39.9, 116.4, 39.9),
499
+            )
500
+
501
+        self.run_query(
502
+            "空间查询 - 区域设备统计",
503
+            """SELECT area_id,
504
+                      COUNT(*) AS device_count,
505
+                      AVG(lng) AS center_lng,
506
+                      AVG(lat) AS center_lat,
507
+                      COUNT(CASE WHEN status = 'active' THEN 1 END) AS active_count
508
+               FROM perf_devices
509
+               GROUP BY area_id ORDER BY device_count DESC""",
510
+        )
511
+
512
+        # 5. 复杂查询
513
+        print("\n  --- 复杂查询 ---")
514
+        self.run_query(
515
+            "复杂查询 - 异常检测 (压力突变)",
516
+            """WITH sensor_stats AS (
517
+                 SELECT sensor_id,
518
+                        AVG(pressure) AS avg_p,
519
+                        STDDEV(pressure) AS std_p
520
+                 FROM perf_sensor_data
521
+                 WHERE timestamp > NOW() - INTERVAL '7 days'
522
+                 GROUP BY sensor_id
523
+                 HAVING COUNT(*) > 10
524
+               )
525
+               SELECT s.sensor_id, s.timestamp, s.pressure,
526
+                      ss.avg_p, ss.std_p,
527
+                      ABS(s.pressure - ss.avg_p) / NULLIF(ss.std_p, 0) AS z_score
528
+               FROM perf_sensor_data s
529
+               JOIN sensor_stats ss ON s.sensor_id = ss.sensor_id
530
+               WHERE ABS(s.pressure - ss.avg_p) > 3 * NULLIF(ss.std_p, 0)
531
+                 AND s.timestamp > NOW() - INTERVAL '1 day'
532
+               ORDER BY z_score DESC LIMIT 50""",
533
+        )
534
+
535
+        self.run_query(
536
+            "复杂查询 - 窗口函数 (滑动平均)",
537
+            """SELECT sensor_id, timestamp, pressure,
538
+                      AVG(pressure) OVER (
539
+                        PARTITION BY sensor_id
540
+                        ORDER BY timestamp
541
+                        ROWS BETWEEN 5 PRECEDING AND 5 FOLLOWING
542
+                      ) AS moving_avg
543
+               FROM perf_sensor_data
544
+               WHERE sensor_id = %s
545
+                 AND timestamp > NOW() - INTERVAL '1 day'
546
+               ORDER BY timestamp LIMIT 500""",
547
+            (random.randint(1, 5000),),
548
+        )
549
+
550
+        return self.results
551
+
552
+
553
+# ==================== 索引对比测试 ====================
554
+
555
+def run_index_comparison(db_pool):
556
+    """对比有无索引的查询性能"""
557
+    print("\n" + "=" * 60)
558
+    print("📊 索引性能对比测试")
559
+    print("=" * 60)
560
+
561
+    # 无索引测试
562
+    print("\n--- 无索引状态 ---")
563
+    drop_indexes(db_pool)
564
+    bench_no_idx = QueryBenchmark(db_pool)
565
+    results_no_idx = bench_no_idx.run_all_benchmarks()
566
+
567
+    # 有索引测试
568
+    print("\n--- 有索引状态 ---")
569
+    create_indexes(db_pool)
570
+
571
+    # 先 ANALYZE 更新统计
572
+    with db_pool.get_connection() as conn:
573
+        cur = conn.cursor()
574
+        cur.execute("ANALYZE perf_sensor_data, perf_alarms, perf_devices")
575
+        conn.commit()
576
+        cur.close()
577
+
578
+    bench_with_idx = QueryBenchmark(db_pool)
579
+    results_with_idx = bench_with_idx.run_all_benchmarks()
580
+
581
+    # 对比
582
+    print("\n" + "=" * 60)
583
+    print("📊 索引性能对比结果")
584
+    print("=" * 60)
585
+    print(f"{'查询名称':<40} {'无索引(ms)':>12} {'有索引(ms)':>12} {'提升':>10}")
586
+    print("-" * 74)
587
+
588
+    comparison = []
589
+    for no_idx, with_idx in zip(results_no_idx, results_with_idx):
590
+        no_avg = no_idx.get("avg_ms", 0)
591
+        with_avg = with_idx.get("avg_ms", 0)
592
+        if no_avg > 0:
593
+            improvement = f"{(1 - with_avg / no_avg) * 100:.1f}%"
594
+        else:
595
+            improvement = "N/A"
596
+        print(f"{no_idx['name']:<40} {no_avg:>12.2f} {with_avg:>12.2f} {improvement:>10}")
597
+        comparison.append({
598
+            "query": no_idx["name"],
599
+            "no_index_ms": no_avg,
600
+            "with_index_ms": with_avg,
601
+        })
602
+
603
+    return comparison
604
+
605
+
606
+# ==================== 入口 ====================
607
+
608
+def main():
609
+    parser = argparse.ArgumentParser(description="数据库查询压力测试")
610
+    parser.add_argument("--host", default=DEFAULT_HOST, help="数据库主机")
611
+    parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="数据库端口")
612
+    parser.add_argument("--db", default=DEFAULT_DB, help="数据库名")
613
+    parser.add_argument("--user", default=DEFAULT_USER, help="数据库用户")
614
+    parser.add_argument("--password", default=DEFAULT_PASSWORD, help="数据库密码")
615
+    parser.add_argument("--generate-data", type=int, default=0,
616
+                        help="生成指定条数的测试数据 (如 1000000)")
617
+    parser.add_argument("--skip-data-gen", action="store_true", help="跳过数据生成")
618
+    parser.add_argument("--index-compare", action="store_true", help="运行索引对比测试")
619
+    parser.add_argument("--output", default=None, help="结果输出 JSON 文件路径")
620
+
621
+    args = parser.parse_args()
622
+
623
+    print(f"\n{'=' * 60}")
624
+    print(f"🗄️ 数据库查询压力测试")
625
+    print(f"{'=' * 60}")
626
+    print(f"数据库: {args.host}:{args.port}/{args.db}")
627
+    print(f"开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
628
+    print(f"{'=' * 60}")
629
+
630
+    # 连接数据库
631
+    try:
632
+        db_pool = DBConnectionPool(
633
+            args.host, args.port, args.db, args.user, args.password
634
+        )
635
+    except Exception as e:
636
+        print(f"❌ 数据库连接失败: {e}")
637
+        sys.exit(1)
638
+
639
+    all_results = {}
640
+
641
+    try:
642
+        # 生成测试数据
643
+        if args.generate_data > 0 and not args.skip_data_gen:
644
+            generate_test_data(db_pool, args.generate_data)
645
+            create_indexes(db_pool)
646
+            # ANALYZE
647
+            with db_pool.get_connection() as conn:
648
+                cur = conn.cursor()
649
+                cur.execute("ANALYZE perf_sensor_data, perf_alarms, perf_devices")
650
+                conn.commit()
651
+                cur.close()
652
+
653
+        # 运行基准查询测试
654
+        bench = QueryBenchmark(db_pool)
655
+        results = bench.run_all_benchmarks()
656
+        all_results["query_benchmark"] = results
657
+
658
+        # 索引对比测试
659
+        if args.index_compare:
660
+            comparison = run_index_comparison(db_pool)
661
+            all_results["index_comparison"] = comparison
662
+
663
+    finally:
664
+        db_pool.close()
665
+
666
+    # 输出汇总
667
+    print(f"\n{'=' * 60}")
668
+    print("📊 查询测试汇总")
669
+    print(f"{'=' * 60}")
670
+    for r in all_results.get("query_benchmark", []):
671
+        avg = r.get("avg_ms", "ERR")
672
+        print(f"  {r['name']}: {avg}ms ({r.get('rows_returned', 0)} rows)")
673
+
674
+    if args.output:
675
+        with open(args.output, "w") as f:
676
+            json.dump(all_results, f, indent=2, ensure_ascii=False, default=str)
677
+        print(f"\n结果已保存到: {args.output}")
678
+
679
+    print(f"\n{'=' * 60}")
680
+    return all_results
681
+
682
+
683
+if __name__ == "__main__":
684
+    main()

+ 320
- 0
tests/performance/locustfile.py Voir le fichier

@@ -0,0 +1,320 @@
1
+#!/usr/bin/env python3
2
+"""
3
+REST API 性能压力测试 - Locust
4
+
5
+使用 Locust 框架对水务管理系统的 REST API 进行并发压力测试。
6
+覆盖主要 API 端点:登录、数据查询、数据上报、报表查询等。
7
+
8
+运行方式:
9
+  locust -f locustfile.py --host=http://localhost:8080
10
+  # 无头模式:
11
+  locust -f locustfile.py --host=http://localhost:8080 --headless -u 100 -r 10 --run-time 5m
12
+
13
+支持 10/50/100/500/1000 并发用户梯度测试。
14
+"""
15
+
16
+import random
17
+import time
18
+import json
19
+from datetime import datetime, timedelta
20
+
21
+try:
22
+    from locust import HttpUser, task, between, events, LoadTestShape
23
+except ImportError:
24
+    print("请先安装 locust: pip install locust")
25
+    raise SystemExit(1)
26
+
27
+
28
+# ==================== 配置 ====================
29
+
30
+# 默认 API Token(实际使用时通过环境变量覆盖)
31
+API_TOKEN = "test-token"
32
+
33
+# 模拟的管网节点 ID 范围
34
+NODE_ID_RANGE = (1, 10000)
35
+
36
+# 模拟的传感器 ID 范围
37
+SENSOR_ID_RANGE = (1, 5000)
38
+
39
+
40
+# ==================== 用户行为 ====================
41
+
42
+class WaterManagementUser(HttpUser):
43
+    """模拟水务管理系统的用户行为"""
44
+
45
+    wait_time = between(1, 5)  # 每次请求间隔 1-5 秒
46
+
47
+    def on_start(self):
48
+        """用户初始化:模拟登录获取 token"""
49
+        self.token = API_TOKEN
50
+        self.headers = {
51
+            "Authorization": f"Bearer {self.token}",
52
+            "Content-Type": "application/json",
53
+        }
54
+        # 尝试真实登录
55
+        try:
56
+            with self.client.post(
57
+                "/api/auth/login",
58
+                json={"username": "admin", "password": "admin123"},
59
+                catch_response=True,
60
+                timeout=10,
61
+            ) as resp:
62
+                if resp.status_code == 200:
63
+                    data = resp.json()
64
+                    if data.get("data", {}).get("token"):
65
+                        self.token = data["data"]["token"]
66
+                        self.headers["Authorization"] = f"Bearer {self.token}"
67
+                        resp.success()
68
+                    else:
69
+                        resp.failure("No token in response")
70
+                else:
71
+                    resp.failure(f"Login failed: {resp.status_code}")
72
+        except Exception:
73
+            pass  # 使用默认 token
74
+
75
+    # ---------- 数据查询类任务 ----------
76
+
77
+    @task(10)
78
+    def query_monitoring_data(self):
79
+        """查询实时监测数据(高频操作)"""
80
+        node_id = random.randint(*NODE_ID_RANGE)
81
+        start = (datetime.now() - timedelta(hours=24)).isoformat()
82
+        end = datetime.now().isoformat()
83
+
84
+        self.client.get(
85
+            f"/api/monitoring/data?nodeId={node_id}&startTime={start}&endTime={end}",
86
+            headers=self.headers,
87
+            name="/api/monitoring/data",
88
+            timeout=30,
89
+        )
90
+
91
+    @task(5)
92
+    def query_pressure_data(self):
93
+        """查询管网压力数据"""
94
+        area_id = random.randint(1, 100)
95
+        self.client.get(
96
+            f"/api/monitoring/pressure?areaId={area_id}",
97
+            headers=self.headers,
98
+            name="/api/monitoring/pressure",
99
+            timeout=30,
100
+        )
101
+
102
+    @task(5)
103
+    def query_flow_data(self):
104
+        """查询流量数据"""
105
+        sensor_id = random.randint(*SENSOR_ID_RANGE)
106
+        self.client.get(
107
+            f"/api/monitoring/flow?sensorId={sensor_id}",
108
+            headers=self.headers,
109
+            name="/api/monitoring/flow",
110
+            timeout=30,
111
+        )
112
+
113
+    @task(3)
114
+    def query_water_quality(self):
115
+        """查询水质数据"""
116
+        station_id = random.randint(1, 50)
117
+        self.client.get(
118
+            f"/api/monitoring/water-quality?stationId={station_id}",
119
+            headers=self.headers,
120
+            name="/api/monitoring/water-quality",
121
+            timeout=30,
122
+        )
123
+
124
+    # ---------- 数据上报类任务 ----------
125
+
126
+    @task(8)
127
+    def report_sensor_data(self):
128
+        """上报传感器数据(高频操作)"""
129
+        payload = {
130
+            "sensorId": random.randint(*SENSOR_ID_RANGE),
131
+            "timestamp": datetime.now().isoformat(),
132
+            "pressure": round(random.uniform(0.1, 0.8), 3),
133
+            "flow": round(random.uniform(10, 500), 2),
134
+            "temperature": round(random.uniform(5, 35), 1),
135
+            "quality": {
136
+                "turbidity": round(random.uniform(0, 5), 2),
137
+                "ph": round(random.uniform(6.5, 8.5), 2),
138
+                "chlorine": round(random.uniform(0.1, 0.8), 3),
139
+            },
140
+        }
141
+
142
+        with self.client.post(
143
+            "/api/data/report",
144
+            json=payload,
145
+            headers=self.headers,
146
+            catch_response=True,
147
+            name="/api/data/report",
148
+            timeout=15,
149
+        ) as resp:
150
+            if resp.status_code in (200, 201, 401, 404):
151
+                resp.success()
152
+
153
+    @task(3)
154
+    def report_alarm(self):
155
+        """上报告警事件"""
156
+        alarm_types = ["PRESSURE_HIGH", "PRESSURE_LOW", "FLOW_ABNORMAL",
157
+                       "QUALITY_WARNING", "LEAK_DETECTED"]
158
+        payload = {
159
+            "sensorId": random.randint(*SENSOR_ID_RANGE),
160
+            "alarmType": random.choice(alarm_types),
161
+            "severity": random.choice(["INFO", "WARNING", "CRITICAL"]),
162
+            "timestamp": datetime.now().isoformat(),
163
+            "description": f"自动告警 - {random.choice(alarm_types)}",
164
+            "value": round(random.uniform(0, 100), 2),
165
+            "threshold": round(random.uniform(50, 100), 2),
166
+        }
167
+
168
+        with self.client.post(
169
+            "/api/alarm/report",
170
+            json=payload,
171
+            headers=self.headers,
172
+            catch_response=True,
173
+            name="/api/alarm/report",
174
+            timeout=15,
175
+        ) as resp:
176
+            if resp.status_code in (200, 201, 401, 404):
177
+                resp.success()
178
+
179
+    # ---------- 报表查询类任务 ----------
180
+
181
+    @task(4)
182
+    def query_daily_report(self):
183
+        """查询日报表"""
184
+        date = (datetime.now() - timedelta(days=random.randint(0, 30))).strftime("%Y-%m-%d")
185
+        self.client.get(
186
+            f"/api/report/daily?date={date}",
187
+            headers=self.headers,
188
+            name="/api/report/daily",
189
+            timeout=30,
190
+        )
191
+
192
+    @task(2)
193
+    def query_monthly_report(self):
194
+        """查询月报表"""
195
+        year = datetime.now().year
196
+        month = random.randint(1, 12)
197
+        self.client.get(
198
+            f"/api/report/monthly?year={year}&month={month}",
199
+            headers=self.headers,
200
+            name="/api/report/monthly",
201
+            timeout=30,
202
+        )
203
+
204
+    @task(2)
205
+    def export_report(self):
206
+        """导出报表(较重量级操作)"""
207
+        start = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
208
+        end = datetime.now().strftime("%Y-%m-%d")
209
+        with self.client.get(
210
+            f"/api/report/export?startDate={start}&endDate={end}&format=xlsx",
211
+            headers=self.headers,
212
+            catch_response=True,
213
+            name="/api/report/export",
214
+            timeout=60,
215
+        ) as resp:
216
+            if resp.status_code in (200, 401, 404):
217
+                resp.success()
218
+
219
+    # ---------- 应急调度类任务 ----------
220
+
221
+    @task(1)
222
+    def query_emergency_list(self):
223
+        """查询应急事件列表"""
224
+        self.client.get(
225
+            "/api/emergency/list?page=1&size=20",
226
+            headers=self.headers,
227
+            name="/api/emergency/list",
228
+            timeout=30,
229
+        )
230
+
231
+    @task(1)
232
+    def simulate_pipe_burst(self):
233
+        """模拟爆管事件(重量级操作)"""
234
+        payload = {
235
+            "lng": round(random.uniform(116.0, 117.0), 4),
236
+            "lat": round(random.uniform(39.5, 40.5), 4),
237
+            "pipeDiameter": random.choice(["DN50", "DN100", "DN200", "DN300"]),
238
+            "operatorName": f"operator_{random.randint(1, 50)}",
239
+        }
240
+
241
+        with self.client.post(
242
+            "/api/emergency/dispatch/quick-pipe-burst",
243
+            json=payload,
244
+            headers=self.headers,
245
+            catch_response=True,
246
+            name="/api/emergency/dispatch/quick-pipe-burst",
247
+            timeout=60,
248
+        ) as resp:
249
+            if resp.status_code in (200, 201, 401, 404):
250
+                resp.success()
251
+
252
+    # ---------- GIS 空间查询 ----------
253
+
254
+    @task(3)
255
+    def gis_query_nearby(self):
256
+        """GIS 空间查询 - 附近设备"""
257
+        lng = round(random.uniform(116.0, 117.0), 6)
258
+        lat = round(random.uniform(39.5, 40.5), 6)
259
+        radius = random.choice([500, 1000, 2000, 5000])
260
+        self.client.get(
261
+            f"/api/gis/nearby?lng={lng}&lat={lat}&radius={radius}",
262
+            headers=self.headers,
263
+            name="/api/gis/nearby",
264
+            timeout=30,
265
+        )
266
+
267
+    @task(2)
268
+    def gis_query_area_stats(self):
269
+        """GIS 区域统计"""
270
+        area_id = random.randint(1, 50)
271
+        self.client.get(
272
+            f"/api/gis/area-stats?areaId={area_id}",
273
+            headers=self.headers,
274
+            name="/api/gis/area-stats",
275
+            timeout=30,
276
+        )
277
+
278
+
279
+# ==================== 梯度负载模型 ====================
280
+
281
+class StepLoadShape(LoadTestShape):
282
+    """
283
+    梯度负载测试:10 → 50 → 100 → 500 → 1000 用户
284
+    每个阶段持续 3 分钟,总共约 15 分钟
285
+    """
286
+
287
+    stages = [
288
+        {"duration": 180, "users": 10, "spawn_rate": 5},     # 预热 10 用户
289
+        {"duration": 360, "users": 50, "spawn_rate": 10},    # 50 用户
290
+        {"duration": 540, "users": 100, "spawn_rate": 20},   # 100 用户
291
+        {"duration": 720, "users": 500, "spawn_rate": 50},   # 500 用户
292
+        {"duration": 900, "users": 1000, "spawn_rate": 100}, # 1000 用户
293
+        {"duration": 1080, "users": 0, "spawn_rate": 100},   # 逐步停止
294
+    ]
295
+
296
+    def tick(self):
297
+        run_time = self.get_run_time()
298
+
299
+        for stage in self.stages:
300
+            if run_time < stage["duration"]:
301
+                tick_data = (
302
+                    stage["users"],
303
+                    stage["spawn_rate"],
304
+                )
305
+                return tick_data
306
+
307
+        return None
308
+
309
+
310
+# ==================== 事件钩子:自定义统计 ====================
311
+
312
+@events.test_stop.add_listener
313
+def on_test_stop(environment, **kwargs):
314
+    """测试结束时输出摘要"""
315
+    stats = environment.runner.stats
316
+    print("\n" + "=" * 60)
317
+    print("📊 REST API 压力测试报告")
318
+    print("=" * 60)
319
+    print(stats.serialize_current_response_times())
320
+    print("=" * 60)

+ 417
- 0
tests/performance/mqtt_iot_stress.py Voir le fichier

@@ -0,0 +1,417 @@
1
+#!/usr/bin/env python3
2
+"""
3
+IoT MQTT 并发压力测试
4
+
5
+使用 asyncio + paho-mqtt 模拟大量 IoT 设备同时上报数据,
6
+测试消息到达率、延迟、MQTT broker 负载。
7
+
8
+运行方式:
9
+  python mqtt_iot_stress.py [--broker HOST] [--port PORT] [--devices N] [--duration S] [--freq S]
10
+
11
+示例:
12
+  python mqtt_iot_stress.py --devices 1000 --duration 300 --freq 5
13
+"""
14
+
15
+import asyncio
16
+import argparse
17
+import json
18
+import time
19
+import random
20
+import statistics
21
+import sys
22
+import threading
23
+from datetime import datetime
24
+from collections import defaultdict
25
+from concurrent.futures import ThreadPoolExecutor
26
+
27
+try:
28
+    import paho.mqtt.client as mqtt
29
+except ImportError:
30
+    print("请先安装 paho-mqtt: pip install paho-mqtt")
31
+    sys.exit(1)
32
+
33
+
34
+# ==================== 配置 ====================
35
+
36
+DEFAULT_BROKER = "localhost"
37
+DEFAULT_PORT = 1883
38
+DEFAULT_DEVICES = 100
39
+DEFAULT_DURATION = 120  # 秒
40
+DEFAULT_FREQUENCY = 5   # 上报频率(秒)
41
+DEFAULT_TOPIC_PREFIX = "iot/sensor"
42
+DEFAULT_QOS = 1
43
+
44
+# 梯度测试配置
45
+STEP_CONFIGS = [
46
+    {"devices": 100, "duration": 60, "freq": 5},
47
+    {"devices": 500, "duration": 60, "freq": 5},
48
+    {"devices": 1000, "duration": 60, "freq": 5},
49
+    {"devices": 5000, "duration": 60, "freq": 5},
50
+    {"devices": 1000, "duration": 60, "freq": 1},   # 高频上报
51
+    {"devices": 1000, "duration": 60, "freq": 10},  # 低频上报
52
+]
53
+
54
+
55
+# ==================== 统计 ====================
56
+
57
+class MQTTStats:
58
+    """MQTT 统计收集器(线程安全)"""
59
+
60
+    def __init__(self):
61
+        self._lock = threading.Lock()
62
+        self.connected = 0
63
+        self.disconnected = 0
64
+        self.messages_published = 0
65
+        self.messages_received = 0
66
+        self.publish_errors = 0
67
+        self.connect_errors = 0
68
+        self.latencies = []
69
+        self.publish_times = []
70
+        self.throughput_per_second = defaultdict(int)
71
+
72
+    def record_connect(self):
73
+        with self._lock:
74
+            self.connected += 1
75
+
76
+    def record_disconnect(self):
77
+        with self._lock:
78
+            self.disconnected += 1
79
+
80
+    def record_connect_error(self):
81
+        with self._lock:
82
+            self.connect_errors += 1
83
+
84
+    def record_publish(self, latency_ms):
85
+        with self._lock:
86
+            self.messages_published += 1
87
+            self.publish_times.append(latency_ms)
88
+            second = int(time.time())
89
+            self.throughput_per_second[second] += 1
90
+
91
+    def record_publish_error(self):
92
+        with self._lock:
93
+            self.publish_errors += 1
94
+
95
+    def record_receive(self, latency_ms):
96
+        with self._lock:
97
+            self.messages_received += 1
98
+            self.latencies.append(latency_ms)
99
+
100
+    def summary(self):
101
+        with self._lock:
102
+            total_attempted = self.connected + self.connect_errors
103
+            success_rate = (self.connected / total_attempted * 100) if total_attempted > 0 else 0
104
+
105
+            throughput_values = list(self.throughput_per_second.values())
106
+            throughput_stats = {}
107
+            if throughput_values:
108
+                throughput_stats = {
109
+                    "min_per_sec": min(throughput_values),
110
+                    "max_per_sec": max(throughput_values),
111
+                    "avg_per_sec": round(statistics.mean(throughput_values), 2),
112
+                }
113
+
114
+            publish_time_stats = {}
115
+            if self.publish_times:
116
+                sorted_times = sorted(self.publish_times)
117
+                publish_time_stats = {
118
+                    "min_ms": round(sorted_times[0], 2),
119
+                    "avg_ms": round(statistics.mean(sorted_times), 2),
120
+                    "max_ms": round(sorted_times[-1], 2),
121
+                    "p95_ms": round(sorted_times[int(len(sorted_times) * 0.95)], 2),
122
+                    "p99_ms": round(sorted_times[int(len(sorted_times) * 0.99)], 2),
123
+                }
124
+
125
+            latency_stats = {}
126
+            if self.latencies:
127
+                sorted_lat = sorted(self.latencies)
128
+                latency_stats = {
129
+                    "min_ms": round(sorted_lat[0], 2),
130
+                    "avg_ms": round(statistics.mean(sorted_lat), 2),
131
+                    "max_ms": round(sorted_lat[-1], 2),
132
+                    "p95_ms": round(sorted_lat[int(len(sorted_lat) * 0.95)], 2),
133
+                    "p99_ms": round(sorted_lat[int(len(sorted_lat) * 0.99)], 2),
134
+                }
135
+
136
+            return {
137
+                "total_devices": total_attempted,
138
+                "connected": self.connected,
139
+                "connect_errors": self.connect_errors,
140
+                "connection_success_rate": f"{success_rate:.1f}%",
141
+                "disconnected": self.disconnected,
142
+                "messages_published": self.messages_published,
143
+                "messages_received": self.messages_received,
144
+                "publish_errors": self.publish_errors,
145
+                "publish_time": publish_time_stats,
146
+                "message_latency": latency_stats,
147
+                "throughput": throughput_stats,
148
+            }
149
+
150
+
151
+# ==================== 设备模拟 ====================
152
+
153
+def generate_sensor_payload(device_id):
154
+    """生成传感器上报数据"""
155
+    return json.dumps({
156
+        "deviceId": f"device_{device_id:06d}",
157
+        "timestamp": datetime.now().isoformat(),
158
+        "type": random.choice(["pressure", "flow", "quality", "level"]),
159
+        "data": {
160
+            "pressure": round(random.uniform(0.1, 0.8), 3),
161
+            "flow": round(random.uniform(10, 500), 2),
162
+            "temperature": round(random.uniform(5, 35), 1),
163
+            "ph": round(random.uniform(6.5, 8.5), 2),
164
+            "turbidity": round(random.uniform(0, 5), 2),
165
+        },
166
+        "location": {
167
+            "lng": round(random.uniform(116.0, 117.0), 6),
168
+            "lat": round(random.uniform(39.5, 40.5), 6),
169
+        },
170
+        "battery": round(random.uniform(20, 100), 1),
171
+        "signal": random.randint(-90, -30),
172
+    })
173
+
174
+
175
+class SimulatedDevice:
176
+    """模拟 IoT 设备"""
177
+
178
+    def __init__(self, device_id, broker, port, stats, topic_prefix, qos):
179
+        self.device_id = device_id
180
+        self.broker = broker
181
+        self.port = port
182
+        self.stats = stats
183
+        self.topic = f"{topic_prefix}/{device_id:06d}/data"
184
+        self.qos = qos
185
+        self.client = None
186
+        self._running = False
187
+
188
+    def on_connect(self, client, userdata, flags, rc, properties=None):
189
+        if rc == 0:
190
+            self.stats.record_connect()
191
+            # 订阅确认主题(用于测试端到端延迟)
192
+            client.subscribe(f"iot/ack/{self.device_id:06d}", qos=0)
193
+        else:
194
+            self.stats.record_connect_error()
195
+
196
+    def on_disconnect(self, client, userdata, rc, properties=None):
197
+        self.stats.record_disconnect()
198
+
199
+    def on_message(self, client, userdata, msg):
200
+        """收到 ACK 消息,计算端到端延迟"""
201
+        try:
202
+            payload = json.loads(msg.payload)
203
+            if "sendTime" in payload:
204
+                latency = (time.time() - payload["sendTime"]) * 1000
205
+                self.stats.record_receive(latency)
206
+        except Exception:
207
+            pass
208
+
209
+    def on_publish(self, client, userdata, mid, rc=None, properties=None):
210
+        pass
211
+
212
+    def connect(self):
213
+        """连接到 MQTT broker"""
214
+        self.client = mqtt.Client(
215
+            client_id=f"device_{self.device_id:06d}",
216
+            callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
217
+            protocol=mqtt.MQTTv311,
218
+        )
219
+        self.client.on_connect = self.on_connect
220
+        self.client.on_disconnect = self.on_disconnect
221
+        self.client.on_message = self.on_message
222
+        self.client.on_publish = self.on_publish
223
+
224
+        # 设置连接超时
225
+        self.client.connect_timeout = 10
226
+
227
+        try:
228
+            self.client.connect(self.broker, self.port, keepalive=60)
229
+            self.client.loop_start()
230
+            return True
231
+        except Exception:
232
+            self.stats.record_connect_error()
233
+            return False
234
+
235
+    def publish(self):
236
+        """发布一条传感器数据"""
237
+        if not self.client or not self.client.is_connected():
238
+            return False
239
+
240
+        payload = generate_sensor_payload(self.device_id)
241
+        start_time = time.time()
242
+
243
+        try:
244
+            result = self.client.publish(self.topic, payload, qos=self.qos)
245
+            if result.rc == mqtt.MQTT_ERR_SUCCESS:
246
+                latency_ms = (time.time() - start_time) * 1000
247
+                self.stats.record_publish(latency_ms)
248
+                return True
249
+            else:
250
+                self.stats.record_publish_error()
251
+                return False
252
+        except Exception:
253
+            self.stats.record_publish_error()
254
+            return False
255
+
256
+    def disconnect(self):
257
+        """断开连接"""
258
+        self._running = False
259
+        if self.client:
260
+            try:
261
+                self.client.loop_stop()
262
+                self.client.disconnect()
263
+            except Exception:
264
+                pass
265
+
266
+
267
+# ==================== 测试运行器 ====================
268
+
269
+def run_stress_test(broker, port, num_devices, duration, frequency, topic_prefix, qos):
270
+    """运行 MQTT 压力测试"""
271
+    stats = MQTTStats()
272
+
273
+    print(f"\n{'=' * 60}")
274
+    print(f"📡 IoT MQTT 压力测试")
275
+    print(f"{'=' * 60}")
276
+    print(f"Broker: {broker}:{port}")
277
+    print(f"模拟设备数: {num_devices}")
278
+    print(f"测试时长: {duration}s")
279
+    print(f"上报频率: 每 {frequency}s")
280
+    print(f"QoS: {qos}")
281
+    print(f"开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
282
+    print(f"{'=' * 60}\n")
283
+
284
+    # 创建并连接所有设备
285
+    devices = []
286
+    print(f"正在连接 {num_devices} 个设备...")
287
+
288
+    batch_size = max(10, num_devices // 10)
289
+    for i in range(num_devices):
290
+        device = SimulatedDevice(i + 1, broker, port, stats, topic_prefix, qos)
291
+        device.connect()
292
+        devices.append(device)
293
+
294
+        if (i + 1) % batch_size == 0:
295
+            time.sleep(0.5)  # 分批连接,避免瞬时压力
296
+
297
+    # 等待连接完成
298
+    time.sleep(3)
299
+    print(f"✅ 设备连接完成: {stats.connected}/{num_devices}")
300
+
301
+    # 开始上报数据
302
+    print(f"\n开始数据上报(每 {frequency}s)...")
303
+    start_time = time.time()
304
+    report_count = 0
305
+
306
+    while time.time() - start_time < duration:
307
+        for device in devices:
308
+            device.publish()
309
+        report_count += 1
310
+
311
+        elapsed = time.time() - start_time
312
+        if report_count % 10 == 0:
313
+            print(f"  [{elapsed:.0f}s] 已发送 {stats.messages_published} 条消息, "
314
+                  f"错误: {stats.publish_errors}")
315
+
316
+        time.sleep(frequency)
317
+
318
+    # 停止所有设备
319
+    print(f"\n停止所有设备...")
320
+    for device in devices:
321
+        device.disconnect()
322
+
323
+    time.sleep(2)
324
+
325
+    # 输出结果
326
+    summary = stats.summary()
327
+    print(f"\n{'=' * 60}")
328
+    print(f"📊 MQTT 压力测试结果")
329
+    print(f"{'=' * 60}")
330
+    print(f"模拟设备数: {num_devices}")
331
+    print(f"成功连接: {summary['connected']}")
332
+    print(f"连接失败: {summary['connect_errors']}")
333
+    print(f"连接成功率: {summary['connection_success_rate']}")
334
+    print(f"消息发布总数: {summary['messages_published']}")
335
+    print(f"发布错误: {summary['publish_errors']}")
336
+
337
+    if summary['publish_time']:
338
+        print(f"\n消息发布时间:")
339
+        print(f"  最小: {summary['publish_time']['min_ms']}ms")
340
+        print(f"  平均: {summary['publish_time']['avg_ms']}ms")
341
+        print(f"  最大: {summary['publish_time']['max_ms']}ms")
342
+        print(f"  P95: {summary['publish_time']['p95_ms']}ms")
343
+        print(f"  P99: {summary['publish_time']['p99_ms']}ms")
344
+
345
+    if summary['throughput']:
346
+        print(f"\n吞吐量:")
347
+        print(f"  最小: {summary['throughput']['min_per_sec']} msg/s")
348
+        print(f"  最大: {summary['throughput']['max_per_sec']} msg/s")
349
+        print(f"  平均: {summary['throughput']['avg_per_sec']} msg/s")
350
+
351
+    if summary['message_latency']:
352
+        print(f"\n端到端延迟 (ACK):")
353
+        print(f"  最小: {summary['message_latency']['min_ms']}ms")
354
+        print(f"  平均: {summary['message_latency']['avg_ms']}ms")
355
+        print(f"  P95: {summary['message_latency']['p95_ms']}ms")
356
+
357
+    print(f"{'=' * 60}\n")
358
+
359
+    return summary
360
+
361
+
362
+def run_step_test(broker, port, topic_prefix, qos):
363
+    """运行梯度负载测试"""
364
+    print("\n" + "=" * 60)
365
+    print("📈 MQTT 梯度负载测试")
366
+    print("=" * 60)
367
+
368
+    results = []
369
+    for config in STEP_CONFIGS:
370
+        result = run_stress_test(
371
+            broker, port, config["devices"], config["duration"],
372
+            config["freq"], topic_prefix, qos,
373
+        )
374
+        results.append({
375
+            "devices": config["devices"],
376
+            "frequency": config["freq"],
377
+            **result,
378
+        })
379
+        time.sleep(10)  # 阶段间冷却
380
+
381
+    return results
382
+
383
+
384
+# ==================== 入口 ====================
385
+
386
+def main():
387
+    parser = argparse.ArgumentParser(description="IoT MQTT 压力测试")
388
+    parser.add_argument("--broker", default=DEFAULT_BROKER, help=f"MQTT broker 地址 (默认: {DEFAULT_BROKER})")
389
+    parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"MQTT broker 端口 (默认: {DEFAULT_PORT})")
390
+    parser.add_argument("--devices", type=int, default=DEFAULT_DEVICES, help=f"模拟设备数 (默认: {DEFAULT_DEVICES})")
391
+    parser.add_argument("--duration", type=int, default=DEFAULT_DURATION, help=f"测试时长秒 (默认: {DEFAULT_DURATION})")
392
+    parser.add_argument("--freq", type=float, default=DEFAULT_FREQUENCY, help=f"上报频率秒 (默认: {DEFAULT_FREQUENCY})")
393
+    parser.add_argument("--topic", default=DEFAULT_TOPIC_PREFIX, help=f"主题前缀 (默认: {DEFAULT_TOPIC_PREFIX})")
394
+    parser.add_argument("--qos", type=int, default=DEFAULT_QOS, choices=[0, 1, 2], help=f"QoS 级别 (默认: {DEFAULT_QOS})")
395
+    parser.add_argument("--step", action="store_true", help="运行梯度负载测试")
396
+    parser.add_argument("--output", default=None, help="结果输出 JSON 文件路径")
397
+
398
+    args = parser.parse_args()
399
+
400
+    if args.step:
401
+        results = run_step_test(args.broker, args.port, args.topic, args.qos)
402
+    else:
403
+        results = run_stress_test(
404
+            args.broker, args.port, args.devices, args.duration,
405
+            args.freq, args.topic, args.qos,
406
+        )
407
+
408
+    if args.output:
409
+        with open(args.output, "w") as f:
410
+            json.dump(results, f, indent=2, ensure_ascii=False)
411
+        print(f"结果已保存到: {args.output}")
412
+
413
+    return results
414
+
415
+
416
+if __name__ == "__main__":
417
+    main()

+ 218
- 0
tests/performance/report_template.md Voir le fichier

@@ -0,0 +1,218 @@
1
+# 水务管理系统 - 性能压力测试报告
2
+
3
+## 1. 测试概述
4
+
5
+| 项目 | 说明 |
6
+|------|------|
7
+| 项目名称 | 水务管理系统 |
8
+| 测试类型 | 性能压力测试 |
9
+| 测试日期 | {{TEST_DATE}} |
10
+| 测试人员 | bot_dev2 (自动化) |
11
+| 报告版本 | v1.0 |
12
+
13
+## 2. 测试环境
14
+
15
+### 2.1 硬件环境
16
+
17
+| 项目 | 配置 |
18
+|------|------|
19
+| 服务器 | {{SERVER_SPEC}} |
20
+| CPU | {{CPU_INFO}} |
21
+| 内存 | {{MEMORY_INFO}} |
22
+| 磁盘 | {{DISK_INFO}} |
23
+| 网络 | {{NETWORK_INFO}} |
24
+
25
+### 2.2 软件环境
26
+
27
+| 组件 | 版本/配置 |
28
+|------|----------|
29
+| 操作系统 | {{OS_INFO}} |
30
+| 数据库 | PostgreSQL {{PG_VERSION}} |
31
+| MQTT Broker | EMQX {{EMQX_VERSION}} |
32
+| 应用服务 | Spring Boot / FastAPI |
33
+| 压测工具 | Locust / asyncio |
34
+
35
+### 2.3 部署架构
36
+
37
+```
38
+[Load Generator] → [Nginx/网关] → [API Service (8080)]
39
+                                 → [WebSocket (8765)]
40
+                   [EMQX Broker (1883)] ← [IoT Devices]
41
+                                 → [PostgreSQL (5432)]
42
+```
43
+
44
+## 3. REST API 压力测试结果
45
+
46
+### 3.1 测试配置
47
+
48
+| 参数 | 值 |
49
+|------|-----|
50
+| 工具 | Locust |
51
+| 并发用户梯度 | 10 → 50 → 100 → 500 → 1000 |
52
+| 每阶段时长 | 3 分钟 |
53
+| 用户行为 | 登录→查询→上报→报表 |
54
+
55
+### 3.2 结果摘要
56
+
57
+| 并发数 | 平均响应时间(ms) | P95(ms) | P99(ms) | 吞吐量(req/s) | 错误率(%) |
58
+|--------|-----------------|---------|---------|---------------|-----------|
59
+| 10 | {{R10_AVG}} | {{R10_P95}} | {{R10_P99}} | {{R10_RPS}} | {{R10_ERR}} |
60
+| 50 | {{R50_AVG}} | {{R50_P95}} | {{R50_P99}} | {{R50_RPS}} | {{R50_ERR}} |
61
+| 100 | {{R100_AVG}} | {{R100_P95}} | {{R100_P99}} | {{R100_RPS}} | {{R100_ERR}} |
62
+| 500 | {{R500_AVG}} | {{R500_P95}} | {{R500_P99}} | {{R500_RPS}} | {{R500_ERR}} |
63
+| 1000 | {{R1000_AVG}} | {{R1000_P95}} | {{R1000_P99}} | {{R1000_RPS}} | {{R1000_ERR}} |
64
+
65
+### 3.3 各端点性能
66
+
67
+| 端点 | 平均(ms) | P95(ms) | 错误率(%) | 备注 |
68
+|------|----------|---------|-----------|------|
69
+| /api/monitoring/data | {{E1_AVG}} | {{E1_P95}} | {{E1_ERR}} | 实时监测查询 |
70
+| /api/data/report | {{E2_AVG}} | {{E2_P95}} | {{E2_ERR}} | 数据上报 |
71
+| /api/report/daily | {{E3_AVG}} | {{E3_P95}} | {{E3_ERR}} | 日报表查询 |
72
+| /api/report/export | {{E4_AVG}} | {{E4_P95}} | {{E4_ERR}} | 报表导出 |
73
+| /api/emergency/* | {{E5_AVG}} | {{E5_P95}} | {{E5_ERR}} | 应急调度 |
74
+| /api/gis/nearby | {{E6_AVG}} | {{E6_P95}} | {{E6_ERR}} | GIS 空间查询 |
75
+
76
+## 4. WebSocket 压力测试结果
77
+
78
+### 4.1 测试配置
79
+
80
+| 参数 | 值 |
81
+|------|-----|
82
+| 工具 | asyncio + websockets |
83
+| 并发连接梯度 | 100 → 500 → 1000 → 5000 |
84
+| 消息间隔 | 2s |
85
+| 消息类型 | 传感器数据上报 + 订阅 |
86
+
87
+### 4.2 结果摘要
88
+
89
+| 并发连接 | 连接成功率(%) | 消息延迟avg(ms) | P95延迟(ms) | 断连率(%) | 内存(MB) |
90
+|----------|--------------|-----------------|-------------|-----------|----------|
91
+| 100 | {{WS100_CONN}} | {{WS100_LAT}} | {{WS100_P95}} | {{WS100_DISC}} | {{WS100_MEM}} |
92
+| 500 | {{WS500_CONN}} | {{WS500_LAT}} | {{WS500_P95}} | {{WS500_DISC}} | {{WS500_MEM}} |
93
+| 1000 | {{WS1K_CONN}} | {{WS1K_LAT}} | {{WS1K_P95}} | {{WS1K_DISC}} | {{WS1K_MEM}} |
94
+| 5000 | {{WS5K_CONN}} | {{WS5K_LAT}} | {{WS5K_P95}} | {{WS5K_DISC}} | {{WS5K_MEM}} |
95
+
96
+## 5. IoT MQTT 压力测试结果
97
+
98
+### 5.1 测试配置
99
+
100
+| 参数 | 值 |
101
+|------|-----|
102
+| 工具 | paho-mqtt |
103
+| 模拟设备梯度 | 100 → 500 → 1000 → 5000 |
104
+| 上报频率 | 1s / 5s / 10s |
105
+| QoS | 1 |
106
+
107
+### 5.2 结果摘要
108
+
109
+| 设备数 | 频率(s) | 连接成功率(%) | 发布延迟avg(ms) | 吞吐量(msg/s) | 错误率(%) |
110
+|--------|---------|--------------|-----------------|---------------|-----------|
111
+| 100 | 5 | {{M100_CONN}} | {{M100_LAT}} | {{M100_TPS}} | {{M100_ERR}} |
112
+| 500 | 5 | {{M500_CONN}} | {{M500_LAT}} | {{M500_TPS}} | {{M500_ERR}} |
113
+| 1000 | 5 | {{M1K_CONN}} | {{M1K_LAT}} | {{M1K_TPS}} | {{M1K_ERR}} |
114
+| 5000 | 5 | {{M5K_CONN}} | {{M5K_LAT}} | {{M5K_TPS}} | {{M5K_ERR}} |
115
+| 1000 | 1 | {{M1K_H_CONN}} | {{M1K_H_LAT}} | {{M1K_H_TPS}} | {{M1K_H_ERR}} |
116
+| 1000 | 10 | {{M1K_L_CONN}} | {{M1K_L_LAT}} | {{M1K_L_TPS}} | {{M1K_L_ERR}} |
117
+
118
+## 6. 数据库查询性能测试
119
+
120
+### 6.1 测试数据规模
121
+
122
+| 表 | 记录数 | 索引数 |
123
+|----|--------|--------|
124
+| perf_sensor_data | {{DB_RECORDS}} | 5 |
125
+| perf_alarms | {{DB_ALARMS}} | 3 |
126
+| perf_devices | {{DB_DEVICES}} | 2 |
127
+
128
+### 6.2 查询性能
129
+
130
+| 查询类型 | 查询名称 | 平均(ms) | 备注 |
131
+|----------|----------|----------|------|
132
+| 简单查询 | 按传感器ID查询 | {{Q1_MS}} | 最近24h |
133
+| 简单查询 | 按区域查询 | {{Q2_MS}} | |
134
+| 简单查询 | 时间范围查询 | {{Q3_MS}} | 7天 |
135
+| 聚合查询 | 每小时平均压力 | {{Q4_MS}} | |
136
+| 聚合查询 | 各区域统计 | {{Q5_MS}} | |
137
+| 聚合查询 | 日统计报表 | {{Q6_MS}} | 30天 |
138
+| JOIN 查询 | 传感器+设备 | {{Q7_MS}} | |
139
+| JOIN 查询 | 告警+设备 | {{Q8_MS}} | |
140
+| JOIN 查询 | 三表关联统计 | {{Q9_MS}} | |
141
+| GIS 查询 | 范围设备查询 | {{Q10_MS}} | 1km |
142
+| 复杂查询 | 异常检测 | {{Q11_MS}} | 窗口函数 |
143
+
144
+### 6.3 索引性能对比
145
+
146
+| 查询 | 无索引(ms) | 有索引(ms) | 提升 |
147
+|------|-----------|-----------|------|
148
+| {{IDX_Q1}} | {{IDX1_NO}} | {{IDX1_YES}} | {{IDX1_GAIN}} |
149
+| {{IDX_Q2}} | {{IDX2_NO}} | {{IDX2_YES}} | {{IDX2_GAIN}} |
150
+| {{IDX_Q3}} | {{IDX3_NO}} | {{IDX3_YES}} | {{IDX3_GAIN}} |
151
+
152
+## 7. 瓶颈分析
153
+
154
+### 7.1 已识别瓶颈
155
+
156
+| # | 瓶颈 | 严重程度 | 影响范围 | 描述 |
157
+|---|------|---------|----------|------|
158
+| 1 | {{B1_NAME}} | {{B1_SEV}} | {{B1_SCOPE}} | {{B1_DESC}} |
159
+| 2 | {{B2_NAME}} | {{B2_SEV}} | {{B2_SCOPE}} | {{B2_DESC}} |
160
+| 3 | {{B3_NAME}} | {{B3_SEV}} | {{B3_SCOPE}} | {{B3_DESC}} |
161
+
162
+### 7.2 资源使用峰值
163
+
164
+| 指标 | 峰值 | 平均值 | 阈值建议 |
165
+|------|------|--------|----------|
166
+| CPU | {{CPU_PEAK}}% | {{CPU_AVG}}% | 80% |
167
+| 内存 | {{MEM_PEAK}} MB | {{MEM_AVG}} MB | 85% |
168
+| 磁盘 IO | {{IO_PEAK}} MB/s | {{IO_AVG}} MB/s | - |
169
+| 网络 | {{NET_PEAK}} | {{NET_AVG}} | - |
170
+
171
+## 8. 优化建议
172
+
173
+### 8.1 短期优化(1-2 周)
174
+
175
+1. **数据库索引优化**
176
+   - {{OPT1}}
177
+
178
+2. **连接池调优**
179
+   - {{OPT2}}
180
+
181
+3. **缓存策略**
182
+   - {{OPT3}}
183
+
184
+### 8.2 中期优化(1-2 月)
185
+
186
+1. **读写分离**
187
+   - {{OPT4}}
188
+
189
+2. **MQTT Broker 集群化**
190
+   - {{OPT5}}
191
+
192
+3. **WebSocket 连接管理**
193
+   - {{OPT6}}
194
+
195
+### 8.3 长期优化(3-6 月)
196
+
197
+1. **微服务拆分**
198
+   - {{OPT7}}
199
+
200
+2. **数据归档策略**
201
+   - {{OPT8}}
202
+
203
+3. **CDN + 静态资源优化**
204
+   - {{OPT9}}
205
+
206
+## 9. 结论
207
+
208
+| 维度 | 评估 |
209
+|------|------|
210
+| 当前最大并发承载 | {{MAX_CONCURRENT}} |
211
+| 建议最大并发 | {{RECOMMENDED_CONCURRENT}} |
212
+| 系统整体评级 | {{OVERALL_RATING}} |
213
+| 是否满足 SLA | {{SLA_COMPLIANCE}} |
214
+
215
+---
216
+
217
+*报告生成时间: {{GENERATED_AT}}*
218
+*测试工具: Locust, asyncio+websockets, paho-mqtt, psycopg2*

+ 7
- 0
tests/performance/requirements.txt Voir le fichier

@@ -0,0 +1,7 @@
1
+# 性能压力测试依赖
2
+locust>=2.20.0
3
+websockets>=12.0
4
+paho-mqtt>=2.0.0
5
+psycopg2-binary>=2.9.9
6
+pandas>=2.0.0
7
+matplotlib>=3.8.0

+ 299
- 0
tests/performance/run_all_benchmarks.sh Voir le fichier

@@ -0,0 +1,299 @@
1
+#!/bin/bash
2
+#
3
+# 水务管理系统 - 统一性能压力测试入口脚本
4
+#
5
+# 依次运行:REST API / WebSocket / MQTT IoT / 数据库查询 四项压力测试,
6
+# 收集系统指标(CPU、内存、磁盘 IO),生成综合报告。
7
+#
8
+# 用法:
9
+#   bash run_all_benchmarks.sh [--quick] [--output-dir DIR]
10
+#
11
+# --quick: 快速模式(减少测试时长,适合 CI/CD)
12
+# --output-dir: 结果输出目录(默认 ./reports/YYYYMMDD_HHMMSS)
13
+#
14
+
15
+set -euo pipefail
16
+
17
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
+cd "$SCRIPT_DIR"
19
+
20
+# ==================== 参数解析 ====================
21
+QUICK_MODE=false
22
+OUTPUT_DIR=""
23
+API_HOST="${API_HOST:-localhost}"
24
+API_PORT="${API_PORT:-8080}"
25
+WS_HOST="${WS_HOST:-localhost}"
26
+WS_PORT="${WS_PORT:-8765}"
27
+MQTT_BROKER="${MQTT_BROKER:-localhost}"
28
+MQTT_PORT="${MQTT_PORT:-1883}"
29
+DB_HOST="${DB_HOST:-localhost}"
30
+DB_PORT="${DB_PORT:-5432}"
31
+DB_NAME="${DB_NAME:-water_management}"
32
+DB_USER="${DB_USER:-postgres}"
33
+DB_PASS="${DB_PASS:-postgres}"
34
+
35
+while [[ $# -gt 0 ]]; do
36
+    case $1 in
37
+        --quick) QUICK_MODE=true; shift ;;
38
+        --output-dir) OUTPUT_DIR="$2"; shift 2 ;;
39
+        --api-host) API_HOST="$2"; shift 2 ;;
40
+        --ws-host) WS_HOST="$2"; shift 2 ;;
41
+        --mqtt-broker) MQTT_BROKER="$2"; shift 2 ;;
42
+        --db-host) DB_HOST="$2"; shift 2 ;;
43
+        *) echo "未知参数: $1"; exit 1 ;;
44
+    esac
45
+done
46
+
47
+# 默认输出目录
48
+if [[ -z "$OUTPUT_DIR" ]]; then
49
+    OUTPUT_DIR="./reports/$(date +%Y%m%d_%H%M%S)"
50
+fi
51
+mkdir -p "$OUTPUT_DIR"
52
+
53
+# ==================== 工具函数 ====================
54
+
55
+log() { echo "[$(date '+%H:%M:%S')] $*"; }
56
+error() { echo "[$(date '+%H:%M:%S')] ❌ $*" >&2; }
57
+
58
+check_python_deps() {
59
+    log "检查 Python 依赖..."
60
+    pip install -q -r requirements.txt 2>/dev/null || {
61
+        error "依赖安装失败,请手动执行: pip install -r requirements.txt"
62
+        exit 1
63
+    }
64
+}
65
+
66
+# 收集系统指标
67
+SYSMONITOR_PID=""
68
+start_system_monitor() {
69
+    local interval=5
70
+    local output_file="$1"
71
+    (
72
+        echo "timestamp,cpu_percent,memory_mb,disk_read_mb,disk_write_mb" > "$output_file"
73
+        while true; do
74
+            local ts=$(date '+%Y-%m-%d %H:%M:%S')
75
+            local cpu=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' 2>/dev/null || echo "0")
76
+            local mem=$(free -m | awk '/Mem:/{print $3}' 2>/dev/null || echo "0")
77
+            local dr=$(cat /proc/diskstats 2>/dev/null | awk '{r+=$6} END{print r/2048}' || echo "0")
78
+            local dw=$(cat /proc/diskstats 2>/dev/null | awk '{w+=$10} END{print w/2048}' || echo "0")
79
+            echo "$ts,$cpu,$mem,$dr,$dw" >> "$output_file"
80
+            sleep $interval
81
+        done
82
+    ) &
83
+    SYSMONITOR_PID=$!
84
+}
85
+
86
+stop_system_monitor() {
87
+    if [[ -n "$SYSMONITOR_PID" ]]; then
88
+        kill "$SYSMONITOR_PID" 2>/dev/null || true
89
+        wait "$SYSMONITOR_PID" 2>/dev/null || true
90
+        SYSMONITOR_PID=""
91
+    fi
92
+}
93
+
94
+# 收集系统信息
95
+collect_system_info() {
96
+    local output_file="$1"
97
+    cat > "$output_file" << EOF
98
+# 系统环境信息
99
+- 主机名: $(hostname)
100
+- 操作系统: $(uname -a)
101
+- CPU: $(nproc) 核
102
+- 内存: $(free -h | awk '/Mem:/{print $2}')
103
+- 磁盘: $(df -h / | awk 'NR==2{print $2 " total, " $4 " available"}')
104
+- Python: $(python3 --version 2>&1)
105
+- 测试时间: $(date '+%Y-%m-%d %H:%M:%S')
106
+- 快速模式: $QUICK_MODE
107
+EOF
108
+}
109
+
110
+# ==================== 测试配置 ====================
111
+
112
+if $QUICK_MODE; then
113
+    REST_USERS=50
114
+    REST_DURATION="30s"
115
+    WS_CLIENTS=100
116
+    WS_DURATION=30
117
+    MQTT_DEVICES=100
118
+    MQTT_DURATION=30
119
+    DB_RECORDS=100000
120
+else
121
+    REST_USERS=500
122
+    REST_DURATION="3m"
123
+    WS_CLIENTS=1000
124
+    WS_DURATION=120
125
+    MQTT_DEVICES=1000
126
+    MQTT_DURATION=120
127
+    DB_RECORDS=1000000
128
+fi
129
+
130
+# ==================== 开始测试 ====================
131
+
132
+echo ""
133
+echo "============================================================"
134
+echo "🏗️  水务管理系统 - 性能压力测试"
135
+echo "============================================================"
136
+echo "测试时间: $(date '+%Y-%m-%d %H:%M:%S')"
137
+echo "输出目录: $OUTPUT_DIR"
138
+echo "快速模式: $QUICK_MODE"
139
+echo "============================================================"
140
+
141
+# 检查依赖
142
+check_python_deps
143
+
144
+# 收集系统信息
145
+SYSTEM_INFO="$OUTPUT_DIR/system_info.md"
146
+collect_system_info "$SYSTEM_INFO"
147
+
148
+# 启动系统监控
149
+SYSMON_CSV="$OUTPUT_DIR/system_metrics.csv"
150
+start_system_monitor "$SYSMON_CSV"
151
+
152
+# 注册退出清理
153
+trap stop_system_monitor EXIT
154
+
155
+# ---------- 1. REST API 压力测试 ----------
156
+log ""
157
+log "=========================================="
158
+log "📡 测试 1/4: REST API 压力测试"
159
+log "=========================================="
160
+
161
+REST_RESULT="$OUTPUT_DIR/rest_api_result.json"
162
+if command -v locust &>/dev/null; then
163
+    timeout 600 locust -f locustfile.py \
164
+        --host="http://${API_HOST}:${API_PORT}" \
165
+        --headless -u "$REST_USERS" -r 10 \
166
+        --run-time "$REST_DURATION" \
167
+        --csv="$OUTPUT_DIR/rest_api" \
168
+        --json 2>&1 | tee "$OUTPUT_DIR/rest_api.log" || true
169
+    log "✅ REST API 测试完成"
170
+else
171
+    log "⚠️ locust 未安装,跳过 REST API 测试"
172
+    echo '{"skipped": true, "reason": "locust not installed"}' > "$REST_RESULT"
173
+fi
174
+
175
+# ---------- 2. WebSocket 压力测试 ----------
176
+log ""
177
+log "=========================================="
178
+log "🔌 测试 2/4: WebSocket 压力测试"
179
+log "=========================================="
180
+
181
+WS_RESULT="$OUTPUT_DIR/websocket_result.json"
182
+timeout 600 python3 websocket_stress.py \
183
+    --host "$WS_HOST" --port "$WS_PORT" \
184
+    --clients "$WS_CLIENTS" --duration "$WS_DURATION" \
185
+    --output "$WS_RESULT" 2>&1 | tee "$OUTPUT_DIR/websocket.log" || true
186
+log "✅ WebSocket 测试完成"
187
+
188
+# ---------- 3. MQTT IoT 压力测试 ----------
189
+log ""
190
+log "=========================================="
191
+log "📡 测试 3/4: IoT MQTT 压力测试"
192
+log "=========================================="
193
+
194
+MQTT_RESULT="$OUTPUT_DIR/mqtt_result.json"
195
+timeout 600 python3 mqtt_iot_stress.py \
196
+    --broker "$MQTT_BROKER" --port "$MQTT_PORT" \
197
+    --devices "$MQTT_DEVICES" --duration "$MQTT_DURATION" \
198
+    --output "$MQTT_RESULT" 2>&1 | tee "$OUTPUT_DIR/mqtt.log" || true
199
+log "✅ MQTT 测试完成"
200
+
201
+# ---------- 4. 数据库查询测试 ----------
202
+log ""
203
+log "=========================================="
204
+log "🗄️ 测试 4/4: 数据库查询压力测试"
205
+log "=========================================="
206
+
207
+DB_RESULT="$OUTPUT_DIR/db_query_result.json"
208
+timeout 600 python3 db_query_stress.py \
209
+    --host "$DB_HOST" --port "$DB_PORT" \
210
+    --db "$DB_NAME" --user "$DB_USER" --password "$DB_PASS" \
211
+    --generate-data "$DB_RECORDS" \
212
+    --index-compare \
213
+    --output "$DB_RESULT" 2>&1 | tee "$OUTPUT_DIR/db_query.log" || true
214
+log "✅ 数据库测试完成"
215
+
216
+# ==================== 停止监控 ====================
217
+stop_system_monitor
218
+
219
+# ==================== 生成综合报告 ====================
220
+log ""
221
+log "=========================================="
222
+log "📝 生成综合报告"
223
+log "=========================================="
224
+
225
+REPORT_FILE="$OUTPUT_DIR/report.md"
226
+
227
+# 复制报告模板并填充
228
+if [[ -f report_template.md ]]; then
229
+    cp report_template.md "$REPORT_FILE"
230
+    # 追加实际结果
231
+    cat >> "$REPORT_FILE" << EOF
232
+
233
+---
234
+## 实际测试结果
235
+
236
+### 测试环境
237
+$(cat "$SYSTEM_INFO")
238
+
239
+### REST API 结果
240
+\`\`\`
241
+$(tail -30 "$OUTPUT_DIR/rest_api.log" 2>/dev/null || echo "无数据")
242
+\`\`\`
243
+
244
+### WebSocket 结果
245
+\`\`\`json
246
+$(cat "$WS_RESULT" 2>/dev/null || echo "无数据")
247
+\`\`\`
248
+
249
+### MQTT IoT 结果
250
+\`\`\`json
251
+$(cat "$MQTT_RESULT" 2>/dev/null || echo "无数据")
252
+\`\`\`
253
+
254
+### 数据库查询结果
255
+\`\`\`json
256
+$(cat "$DB_RESULT" 2>/dev/null || echo "无数据")
257
+\`\`\`
258
+
259
+### 系统资源指标
260
+\`\`\`csv
261
+$(head -20 "$SYSMON_CSV" 2>/dev/null || echo "无数据")
262
+\`\`\`
263
+EOF
264
+else
265
+    # 简单报告
266
+    cat > "$REPORT_FILE" << EOF
267
+# 性能压力测试报告
268
+
269
+## 测试时间
270
+$(date '+%Y-%m-%d %H:%M:%S')
271
+
272
+## 测试环境
273
+$(cat "$SYSTEM_INFO")
274
+
275
+## 测试结果摘要
276
+
277
+| 测试项 | 状态 | 结果文件 |
278
+|--------|------|----------|
279
+| REST API | ✅ | rest_api_result.json |
280
+| WebSocket | ✅ | websocket_result.json |
281
+| MQTT IoT | ✅ | mqtt_result.json |
282
+| 数据库查询 | ✅ | db_query_result.json |
283
+
284
+## 系统资源指标
285
+见 system_metrics.csv
286
+EOF
287
+fi
288
+
289
+log "✅ 综合报告已生成: $REPORT_FILE"
290
+
291
+# ==================== 完成 ====================
292
+echo ""
293
+echo "============================================================"
294
+echo "🎉 所有压力测试完成!"
295
+echo "============================================================"
296
+echo "输出目录: $OUTPUT_DIR"
297
+echo "文件列表:"
298
+ls -la "$OUTPUT_DIR/"
299
+echo "============================================================"

+ 354
- 0
tests/performance/websocket_stress.py Voir le fichier

@@ -0,0 +1,354 @@
1
+#!/usr/bin/env python3
2
+"""
3
+WebSocket 长连接并发压力测试
4
+
5
+使用 asyncio + websockets 模拟大量并发 WebSocket 连接,
6
+测试连接成功率、消息延迟、断连率及服务器资源消耗。
7
+
8
+运行方式:
9
+  python websocket_stress.py [--host HOST] [--port PORT] [--clients N] [--duration S]
10
+
11
+示例:
12
+  python websocket_stress.py --clients 1000 --duration 300
13
+"""
14
+
15
+import asyncio
16
+import argparse
17
+import json
18
+import time
19
+import random
20
+import statistics
21
+import resource
22
+import sys
23
+from datetime import datetime
24
+from collections import defaultdict
25
+
26
+try:
27
+    import websockets
28
+    from websockets.asyncio.client import connect as ws_connect
29
+except ImportError:
30
+    try:
31
+        import websockets
32
+        from websockets import connect as ws_connect
33
+    except ImportError:
34
+        print("请先安装 websockets: pip install websockets")
35
+        sys.exit(1)
36
+
37
+
38
+# ==================== 配置 ====================
39
+
40
+DEFAULT_HOST = "localhost"
41
+DEFAULT_PORT = 8765
42
+DEFAULT_CLIENTS = 100
43
+DEFAULT_DURATION = 120  # 秒
44
+DEFAULT_MESSAGE_INTERVAL = 2  # 秒
45
+WS_PATH = "/ws"
46
+
47
+# 梯度测试配置
48
+STEP_CONFIGS = [
49
+    {"clients": 100, "duration": 60},
50
+    {"clients": 500, "duration": 60},
51
+    {"clients": 1000, "duration": 60},
52
+    {"clients": 5000, "duration": 60},
53
+]
54
+
55
+
56
+# ==================== 统计 ====================
57
+
58
+class Stats:
59
+    """线程安全的统计收集器"""
60
+
61
+    def __init__(self):
62
+        self.connected = 0
63
+        self.disconnected = 0
64
+        self.messages_sent = 0
65
+        self.messages_received = 0
66
+        self.errors = 0
67
+        self.latencies = []
68
+        self.connect_times = []
69
+        self._lock = asyncio.Lock()
70
+
71
+    async def record_connect(self, connect_time_ms):
72
+        async with self._lock:
73
+            self.connected += 1
74
+            self.connect_times.append(connect_time_ms)
75
+
76
+    async def record_disconnect(self):
77
+        async with self._lock:
78
+            self.disconnected += 1
79
+
80
+    async def record_send(self):
81
+        async with self._lock:
82
+            self.messages_sent += 1
83
+
84
+    async def record_receive(self, latency_ms):
85
+        async with self._lock:
86
+            self.messages_received += 1
87
+            self.latencies.append(latency_ms)
88
+
89
+    async def record_error(self):
90
+        async with self._lock:
91
+            self.errors += 1
92
+
93
+    def summary(self):
94
+        total_attempted = self.connected + self.errors
95
+        success_rate = (self.connected / total_attempted * 100) if total_attempted > 0 else 0
96
+
97
+        latency_stats = {}
98
+        if self.latencies:
99
+            latency_stats = {
100
+                "min_ms": round(min(self.latencies), 2),
101
+                "max_ms": round(max(self.latencies), 2),
102
+                "avg_ms": round(statistics.mean(self.latencies), 2),
103
+                "median_ms": round(statistics.median(self.latencies), 2),
104
+                "p95_ms": round(sorted(self.latencies)[int(len(self.latencies) * 0.95)], 2)
105
+                if len(self.latencies) > 1 else round(self.latencies[0], 2),
106
+                "p99_ms": round(sorted(self.latencies)[int(len(self.latencies) * 0.99)], 2)
107
+                if len(self.latencies) > 1 else round(self.latencies[0], 2),
108
+            }
109
+
110
+        connect_time_stats = {}
111
+        if self.connect_times:
112
+            connect_time_stats = {
113
+                "min_ms": round(min(self.connect_times), 2),
114
+                "avg_ms": round(statistics.mean(self.connect_times), 2),
115
+                "max_ms": round(max(self.connect_times), 2),
116
+            }
117
+
118
+        return {
119
+            "total_connections": total_attempted,
120
+            "successful_connections": self.connected,
121
+            "failed_connections": self.errors,
122
+            "connection_success_rate": f"{success_rate:.1f}%",
123
+            "disconnections": self.disconnected,
124
+            "messages_sent": self.messages_sent,
125
+            "messages_received": self.messages_received,
126
+            "latency": latency_stats,
127
+            "connect_time": connect_time_stats,
128
+        }
129
+
130
+
131
+# ==================== 客户端模拟 ====================
132
+
133
+def generate_message():
134
+    """生成模拟的传感器上报消息"""
135
+    return json.dumps({
136
+        "type": "sensor_data",
137
+        "sensorId": random.randint(1, 5000),
138
+        "timestamp": datetime.now().isoformat(),
139
+        "data": {
140
+            "pressure": round(random.uniform(0.1, 0.8), 3),
141
+            "flow": round(random.uniform(10, 500), 2),
142
+            "temperature": round(random.uniform(5, 35), 1),
143
+        },
144
+    })
145
+
146
+
147
+async def client_worker(client_id, uri, stats, duration, message_interval):
148
+    """单个 WebSocket 客户端工作线程"""
149
+    start_time = time.time()
150
+    connect_start = time.time()
151
+
152
+    try:
153
+        async with ws_connect(uri) as ws:
154
+            connect_time_ms = (time.time() - connect_start) * 1000
155
+            await stats.record_connect(connect_time_ms)
156
+
157
+            # 发送订阅消息
158
+            subscribe_msg = json.dumps({
159
+                "type": "subscribe",
160
+                "channels": [f"sensor_{random.randint(1, 100)}"],
161
+            })
162
+            await ws.send(subscribe_msg)
163
+
164
+            # 定期发送消息
165
+            while time.time() - start_time < duration:
166
+                try:
167
+                    msg = generate_message()
168
+                    send_time = time.time()
169
+                    await ws.send(msg)
170
+                    await stats.record_send()
171
+
172
+                    # 尝试接收消息(非阻塞)
173
+                    try:
174
+                        response = await asyncio.wait_for(ws.recv(), timeout=1.0)
175
+                        latency_ms = (time.time() - send_time) * 1000
176
+                        await stats.record_receive(latency_ms)
177
+                    except asyncio.TimeoutError:
178
+                        pass  # 没有响应是正常的
179
+                    except Exception:
180
+                        pass
181
+
182
+                    await asyncio.sleep(message_interval + random.uniform(0, 1))
183
+
184
+                except Exception:
185
+                    await stats.record_error()
186
+                    break
187
+
188
+    except Exception as e:
189
+        await stats.record_error()
190
+    finally:
191
+        await stats.record_disconnect()
192
+
193
+
194
+# ==================== 资源监控 ====================
195
+
196
+async def monitor_resources(duration, interval=5):
197
+    """监控系统资源"""
198
+    resource_stats = {
199
+        "cpu_percent": [],
200
+        "memory_mb": [],
201
+        "timestamps": [],
202
+    }
203
+
204
+    start = time.time()
205
+    while time.time() - start < duration:
206
+        try:
207
+            usage = resource.getrusage(resource.RUSAGE_SELF)
208
+            mem_mb = usage.ru_maxrss / 1024  # Linux: KB → MB
209
+            resource_stats["memory_mb"].append(round(mem_mb, 2))
210
+            resource_stats["timestamps"].append(round(time.time() - start, 1))
211
+        except Exception:
212
+            pass
213
+        await asyncio.sleep(interval)
214
+
215
+    return resource_stats
216
+
217
+
218
+# ==================== 主测试函数 ====================
219
+
220
+async def run_stress_test(host, port, num_clients, duration, message_interval):
221
+    """运行压力测试"""
222
+    uri = f"ws://{host}:{port}{WS_PATH}"
223
+    stats = Stats()
224
+
225
+    print(f"\n{'=' * 60}")
226
+    print(f"🔌 WebSocket 压力测试")
227
+    print(f"{'=' * 60}")
228
+    print(f"目标: {uri}")
229
+    print(f"并发连接数: {num_clients}")
230
+    print(f"测试时长: {duration}s")
231
+    print(f"消息间隔: {message_interval}s")
232
+    print(f"开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
233
+    print(f"{'=' * 60}\n")
234
+
235
+    # 启动所有客户端(分批启动避免瞬时压力)
236
+    tasks = []
237
+    batch_size = max(10, num_clients // 10)
238
+    resource_task = asyncio.create_task(monitor_resources(duration + 30))
239
+
240
+    for i in range(num_clients):
241
+        task = asyncio.create_task(
242
+            client_worker(i, uri, stats, duration, message_interval)
243
+        )
244
+        tasks.append(task)
245
+
246
+        # 分批启动
247
+        if (i + 1) % batch_size == 0:
248
+            await asyncio.sleep(0.5)
249
+
250
+    print(f"✅ 已启动 {num_clients} 个 WebSocket 客户端")
251
+
252
+    # 等待测试完成
253
+    await asyncio.sleep(duration + 5)  # 额外 5 秒等待收尾
254
+
255
+    # 取消剩余任务
256
+    for task in tasks:
257
+        task.cancel()
258
+
259
+    # 等待资源监控结束
260
+    try:
261
+        resource_data = await asyncio.wait_for(resource_task, timeout=10)
262
+    except asyncio.TimeoutError:
263
+        resource_data = {"memory_mb": [], "timestamps": []}
264
+
265
+    # 输出结果
266
+    summary = stats.summary()
267
+    print(f"\n{'=' * 60}")
268
+    print(f"📊 WebSocket 压力测试结果")
269
+    print(f"{'=' * 60}")
270
+    print(f"并发连接数: {num_clients}")
271
+    print(f"成功连接: {summary['successful_connections']}")
272
+    print(f"失败连接: {summary['failed_connections']}")
273
+    print(f"连接成功率: {summary['connection_success_rate']}")
274
+    print(f"断连次数: {summary['disconnections']}")
275
+    print(f"消息发送: {summary['messages_sent']}")
276
+    print(f"消息接收: {summary['messages_received']}")
277
+
278
+    if summary['latency']:
279
+        print(f"\n消息延迟:")
280
+        print(f"  最小: {summary['latency']['min_ms']}ms")
281
+        print(f"  最大: {summary['latency']['max_ms']}ms")
282
+        print(f"  平均: {summary['latency']['avg_ms']}ms")
283
+        print(f"  中位数: {summary['latency']['median_ms']}ms")
284
+        print(f"  P95: {summary['latency']['p95_ms']}ms")
285
+        print(f"  P99: {summary['latency']['p99_ms']}ms")
286
+
287
+    if summary['connect_time']:
288
+        print(f"\n连接建立时间:")
289
+        print(f"  最小: {summary['connect_time']['min_ms']}ms")
290
+        print(f"  平均: {summary['connect_time']['avg_ms']}ms")
291
+        print(f"  最大: {summary['connect_time']['max_ms']}ms")
292
+
293
+    if resource_data.get("memory_mb"):
294
+        print(f"\n资源消耗:")
295
+        print(f"  峰值内存: {max(resource_data['memory_mb']):.2f} MB")
296
+        print(f"  平均内存: {statistics.mean(resource_data['memory_mb']):.2f} MB")
297
+
298
+    print(f"{'=' * 60}\n")
299
+
300
+    return summary
301
+
302
+
303
+async def run_step_test(host, port, message_interval):
304
+    """运行梯度负载测试"""
305
+    print("\n" + "=" * 60)
306
+    print("📈 WebSocket 梯度负载测试")
307
+    print("=" * 60)
308
+
309
+    results = []
310
+    for config in STEP_CONFIGS:
311
+        result = await run_stress_test(
312
+            host, port, config["clients"], config["duration"], message_interval
313
+        )
314
+        results.append({
315
+            "clients": config["clients"],
316
+            **result,
317
+        })
318
+        await asyncio.sleep(10)  # 阶段间冷却
319
+
320
+    return results
321
+
322
+
323
+# ==================== 入口 ====================
324
+
325
+def main():
326
+    parser = argparse.ArgumentParser(description="WebSocket 压力测试")
327
+    parser.add_argument("--host", default=DEFAULT_HOST, help=f"目标主机 (默认: {DEFAULT_HOST})")
328
+    parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"目标端口 (默认: {DEFAULT_PORT})")
329
+    parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help=f"并发连接数 (默认: {DEFAULT_CLIENTS})")
330
+    parser.add_argument("--duration", type=int, default=DEFAULT_DURATION, help=f"测试时长秒 (默认: {DEFAULT_DURATION})")
331
+    parser.add_argument("--interval", type=float, default=DEFAULT_MESSAGE_INTERVAL, help=f"消息间隔秒 (默认: {DEFAULT_MESSAGE_INTERVAL})")
332
+    parser.add_argument("--step", action="store_true", help="运行梯度负载测试 (100/500/1000/5000)")
333
+    parser.add_argument("--output", default=None, help="结果输出 JSON 文件路径")
334
+
335
+    args = parser.parse_args()
336
+
337
+    if args.step:
338
+        results = asyncio.run(run_step_test(args.host, args.port, args.interval))
339
+    else:
340
+        results = asyncio.run(
341
+            run_stress_test(args.host, args.port, args.clients, args.duration, args.interval)
342
+        )
343
+
344
+    # 保存结果
345
+    if args.output:
346
+        with open(args.output, "w") as f:
347
+            json.dump(results, f, indent=2, ensure_ascii=False)
348
+        print(f"结果已保存到: {args.output}")
349
+
350
+    return results
351
+
352
+
353
+if __name__ == "__main__":
354
+    main()