| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197 |
- """压力指标采集与计算(任务书 §17)。
- 只采集第三方侧可观察指标:请求量、批次、行数、成功率、QPS、行吞吐、
- 延迟分布(P50/P95/P99)、HTTP 错误分类、超时数、限流数、首次失败时间、起止时间。
- Ai-DOP CPU/内存/队列/数据库资源由测试人员在现有监控工具观察(§17 末句)。
- """
- from __future__ import annotations
- import threading
- import time
- # §17 指标快照字段(导出报告按此列全量输出)
- METRIC_FIELDS = (
- ("totalRequests", "总请求数"),
- ("totalBatches", "总批次数"),
- ("totalRows", "总数据行数"),
- ("success", "成功数"),
- ("failed", "失败数"),
- ("successRate", "成功率"),
- ("actualQps", "实际 QPS"),
- ("rowThroughput", "行吞吐(行/秒)"),
- ("avgMs", "平均耗时(ms)"),
- ("p50Ms", "P50(ms)"),
- ("p95Ms", "P95(ms)"),
- ("p99Ms", "P99(ms)"),
- ("maxMs", "最大耗时(ms)"),
- ("httpErrors", "HTTP 错误分类"),
- ("timeouts", "超时数"),
- ("rateLimited", "限流数"),
- ("firstFailureAt", "首次失败时间"),
- ("startedAt", "开始时间"),
- ("finishedAt", "结束时间"),
- )
- def percentile(sorted_values: list[float], p: float) -> float:
- """线性插值百分位(P50/P95/P99)。空列表返回 0.0。"""
- if not sorted_values:
- return 0.0
- if p <= 0:
- return sorted_values[0]
- if p >= 100:
- return sorted_values[-1]
- k = (len(sorted_values) - 1) * p / 100.0
- lo = int(k)
- hi = min(lo + 1, len(sorted_values) - 1)
- return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * (k - lo)
- class LoadMetrics:
- """线程安全的压力指标累计器。
- QPS 口径:最近 window_seconds 内完成的请求数 / 窗口秒数(滚动窗口,
- 长任务结束后与实际总吞吐可解释比对)。
- """
- def __init__(self, window_seconds: float = 60.0):
- self.window_seconds = max(1.0, window_seconds)
- self.started_at: str | None = None
- self.finished_at: str | None = None
- self.total_requests = 0
- self.total_batches = 0
- self.total_rows = 0
- self.success = 0
- self.failed = 0
- self.timeouts = 0
- self.rate_limited = 0
- self.http_errors: dict[int, int] = {}
- self.latencies: list[float] = []
- self.first_failure_at: str | None = None
- self._completion_times: list[float] = []
- self._final_qps: float | None = None
- self._final_throughput: float | None = None
- self._lock = threading.Lock()
- def reset(self) -> None:
- """清空全部累计(新一轮压测开始前由 mark_start 调用)。"""
- self.started_at = None
- self.finished_at = None
- self.total_requests = 0
- self.total_batches = 0
- self.total_rows = 0
- self.success = 0
- self.failed = 0
- self.timeouts = 0
- self.rate_limited = 0
- self.http_errors = {}
- self.latencies = []
- self.first_failure_at = None
- self._completion_times = []
- self._final_qps = None
- self._final_throughput = None
- def mark_start(self) -> None:
- with self._lock:
- # 单例跨轮复用:新一轮压测开始即重置上一轮累计
- self.reset()
- self.started_at = time.strftime("%Y-%m-%dT%H:%M:%S")
- def mark_finish(self) -> None:
- with self._lock:
- if self.finished_at is None:
- self.finished_at = time.strftime("%Y-%m-%dT%H:%M:%S")
- # 结束后冻结滚动指标:QPS/行吞吐定格在结束时刻,不再随时间衰减
- now = time.monotonic()
- self._final_qps = self._actual_qps_locked(now)
- self._final_throughput = self._row_throughput_locked()
- def record(self, status: int, latency_s: float, rows: int = 0, batches: int = 1) -> None:
- """记录一次请求/批次结果。status:0=超时/不可达,429=限流,其余 HTTP。"""
- now = time.monotonic()
- with self._lock:
- self.total_requests += 1
- self.total_batches += batches
- self.total_rows += max(0, rows)
- self.latencies.append(max(0.0, latency_s))
- self._completion_times.append(now)
- if status == 0:
- self.timeouts += 1
- self.failed += 1
- elif status == 429:
- self.rate_limited += 1
- self.failed += 1
- elif 200 <= status < 300 or status == 202:
- self.success += 1
- else:
- self.failed += 1
- self.http_errors[status] = self.http_errors.get(status, 0) + 1
- if (status == 0 or status >= 400) and self.first_failure_at is None:
- self.first_failure_at = time.strftime("%Y-%m-%dT%H:%M:%S")
- # -- 计算 ----------------------------------------------------------------
- def _pruned_completion_times(self, now: float) -> list[float]:
- cutoff = now - self.window_seconds
- return [t for t in self._completion_times if t >= cutoff]
- def _actual_qps_locked(self, now: float) -> float:
- recent = self._pruned_completion_times(now)
- if not recent:
- return 0.0
- span = now - recent[0]
- if span <= 0:
- return 0.0
- return round(len(recent) / span, 2)
- def actual_qps(self) -> float:
- now = time.monotonic()
- with self._lock:
- return self._actual_qps_locked(now)
- def _row_throughput_locked(self) -> float:
- if not self.started_at or self.total_rows == 0:
- return 0.0
- end = time.strptime(self.finished_at, "%Y-%m-%dT%H:%M:%S") if self.finished_at else time.localtime()
- start = time.strptime(self.started_at, "%Y-%m-%dT%H:%M:%S")
- elapsed = max(1e-9, time.mktime(end) - time.mktime(start))
- return round(self.total_rows / elapsed, 2)
- def row_throughput(self) -> float:
- with self._lock:
- return self._row_throughput_locked()
- # -- 快照 ----------------------------------------------------------------
- def snapshot(self) -> dict:
- with self._lock:
- latencies = sorted(self.latencies)
- total = self.total_requests
- success_rate = round(self.success / total, 4) if total else 0.0
- # 持锁内用 *_locked 计算,避免重入锁死(Lock 非重入)
- now = time.monotonic()
- frozen = self.finished_at is not None
- return {
- "totalRequests": total,
- "totalBatches": self.total_batches,
- "totalRows": self.total_rows,
- "success": self.success,
- "failed": self.failed,
- "successRate": success_rate,
- "actualQps": (self._final_qps if frozen and self._final_qps is not None
- else self._actual_qps_locked(now)),
- "rowThroughput": (self._final_throughput
- if frozen and self._final_throughput is not None
- else self._row_throughput_locked()),
- "avgMs": round(sum(latencies) / len(latencies) * 1000, 1) if latencies else 0.0,
- "p50Ms": round(percentile(latencies, 50) * 1000, 1),
- "p95Ms": round(percentile(latencies, 95) * 1000, 1),
- "p99Ms": round(percentile(latencies, 99) * 1000, 1),
- "maxMs": round(max(latencies) * 1000, 1) if latencies else 0.0,
- "httpErrors": dict(sorted(self.http_errors.items())),
- "timeouts": self.timeouts,
- "rateLimited": self.rate_limited,
- "firstFailureAt": self.first_failure_at,
- "startedAt": self.started_at,
- "finishedAt": self.finished_at,
- }
|