metrics.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. """压力指标采集与计算(任务书 §17)。
  2. 只采集第三方侧可观察指标:请求量、批次、行数、成功率、QPS、行吞吐、
  3. 延迟分布(P50/P95/P99)、HTTP 错误分类、超时数、限流数、首次失败时间、起止时间。
  4. Ai-DOP CPU/内存/队列/数据库资源由测试人员在现有监控工具观察(§17 末句)。
  5. """
  6. from __future__ import annotations
  7. import threading
  8. import time
  9. # §17 指标快照字段(导出报告按此列全量输出)
  10. METRIC_FIELDS = (
  11. ("totalRequests", "总请求数"),
  12. ("totalBatches", "总批次数"),
  13. ("totalRows", "总数据行数"),
  14. ("success", "成功数"),
  15. ("failed", "失败数"),
  16. ("successRate", "成功率"),
  17. ("actualQps", "实际 QPS"),
  18. ("rowThroughput", "行吞吐(行/秒)"),
  19. ("avgMs", "平均耗时(ms)"),
  20. ("p50Ms", "P50(ms)"),
  21. ("p95Ms", "P95(ms)"),
  22. ("p99Ms", "P99(ms)"),
  23. ("maxMs", "最大耗时(ms)"),
  24. ("httpErrors", "HTTP 错误分类"),
  25. ("timeouts", "超时数"),
  26. ("rateLimited", "限流数"),
  27. ("firstFailureAt", "首次失败时间"),
  28. ("startedAt", "开始时间"),
  29. ("finishedAt", "结束时间"),
  30. )
  31. def percentile(sorted_values: list[float], p: float) -> float:
  32. """线性插值百分位(P50/P95/P99)。空列表返回 0.0。"""
  33. if not sorted_values:
  34. return 0.0
  35. if p <= 0:
  36. return sorted_values[0]
  37. if p >= 100:
  38. return sorted_values[-1]
  39. k = (len(sorted_values) - 1) * p / 100.0
  40. lo = int(k)
  41. hi = min(lo + 1, len(sorted_values) - 1)
  42. return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * (k - lo)
  43. class LoadMetrics:
  44. """线程安全的压力指标累计器。
  45. QPS 口径:最近 window_seconds 内完成的请求数 / 窗口秒数(滚动窗口,
  46. 长任务结束后与实际总吞吐可解释比对)。
  47. """
  48. def __init__(self, window_seconds: float = 60.0):
  49. self.window_seconds = max(1.0, window_seconds)
  50. self.started_at: str | None = None
  51. self.finished_at: str | None = None
  52. self.total_requests = 0
  53. self.total_batches = 0
  54. self.total_rows = 0
  55. self.success = 0
  56. self.failed = 0
  57. self.timeouts = 0
  58. self.rate_limited = 0
  59. self.http_errors: dict[int, int] = {}
  60. self.latencies: list[float] = []
  61. self.first_failure_at: str | None = None
  62. self._completion_times: list[float] = []
  63. self._final_qps: float | None = None
  64. self._final_throughput: float | None = None
  65. self._lock = threading.Lock()
  66. def reset(self) -> None:
  67. """清空全部累计(新一轮压测开始前由 mark_start 调用)。"""
  68. self.started_at = None
  69. self.finished_at = None
  70. self.total_requests = 0
  71. self.total_batches = 0
  72. self.total_rows = 0
  73. self.success = 0
  74. self.failed = 0
  75. self.timeouts = 0
  76. self.rate_limited = 0
  77. self.http_errors = {}
  78. self.latencies = []
  79. self.first_failure_at = None
  80. self._completion_times = []
  81. self._final_qps = None
  82. self._final_throughput = None
  83. def mark_start(self) -> None:
  84. with self._lock:
  85. # 单例跨轮复用:新一轮压测开始即重置上一轮累计
  86. self.reset()
  87. self.started_at = time.strftime("%Y-%m-%dT%H:%M:%S")
  88. def mark_finish(self) -> None:
  89. with self._lock:
  90. if self.finished_at is None:
  91. self.finished_at = time.strftime("%Y-%m-%dT%H:%M:%S")
  92. # 结束后冻结滚动指标:QPS/行吞吐定格在结束时刻,不再随时间衰减
  93. now = time.monotonic()
  94. self._final_qps = self._actual_qps_locked(now)
  95. self._final_throughput = self._row_throughput_locked()
  96. def record(self, status: int, latency_s: float, rows: int = 0, batches: int = 1) -> None:
  97. """记录一次请求/批次结果。status:0=超时/不可达,429=限流,其余 HTTP。"""
  98. now = time.monotonic()
  99. with self._lock:
  100. self.total_requests += 1
  101. self.total_batches += batches
  102. self.total_rows += max(0, rows)
  103. self.latencies.append(max(0.0, latency_s))
  104. self._completion_times.append(now)
  105. if status == 0:
  106. self.timeouts += 1
  107. self.failed += 1
  108. elif status == 429:
  109. self.rate_limited += 1
  110. self.failed += 1
  111. elif 200 <= status < 300 or status == 202:
  112. self.success += 1
  113. else:
  114. self.failed += 1
  115. self.http_errors[status] = self.http_errors.get(status, 0) + 1
  116. if (status == 0 or status >= 400) and self.first_failure_at is None:
  117. self.first_failure_at = time.strftime("%Y-%m-%dT%H:%M:%S")
  118. # -- 计算 ----------------------------------------------------------------
  119. def _pruned_completion_times(self, now: float) -> list[float]:
  120. cutoff = now - self.window_seconds
  121. return [t for t in self._completion_times if t >= cutoff]
  122. def _actual_qps_locked(self, now: float) -> float:
  123. recent = self._pruned_completion_times(now)
  124. if not recent:
  125. return 0.0
  126. span = now - recent[0]
  127. if span <= 0:
  128. return 0.0
  129. return round(len(recent) / span, 2)
  130. def actual_qps(self) -> float:
  131. now = time.monotonic()
  132. with self._lock:
  133. return self._actual_qps_locked(now)
  134. def _row_throughput_locked(self) -> float:
  135. if not self.started_at or self.total_rows == 0:
  136. return 0.0
  137. end = time.strptime(self.finished_at, "%Y-%m-%dT%H:%M:%S") if self.finished_at else time.localtime()
  138. start = time.strptime(self.started_at, "%Y-%m-%dT%H:%M:%S")
  139. elapsed = max(1e-9, time.mktime(end) - time.mktime(start))
  140. return round(self.total_rows / elapsed, 2)
  141. def row_throughput(self) -> float:
  142. with self._lock:
  143. return self._row_throughput_locked()
  144. # -- 快照 ----------------------------------------------------------------
  145. def snapshot(self) -> dict:
  146. with self._lock:
  147. latencies = sorted(self.latencies)
  148. total = self.total_requests
  149. success_rate = round(self.success / total, 4) if total else 0.0
  150. # 持锁内用 *_locked 计算,避免重入锁死(Lock 非重入)
  151. now = time.monotonic()
  152. frozen = self.finished_at is not None
  153. return {
  154. "totalRequests": total,
  155. "totalBatches": self.total_batches,
  156. "totalRows": self.total_rows,
  157. "success": self.success,
  158. "failed": self.failed,
  159. "successRate": success_rate,
  160. "actualQps": (self._final_qps if frozen and self._final_qps is not None
  161. else self._actual_qps_locked(now)),
  162. "rowThroughput": (self._final_throughput
  163. if frozen and self._final_throughput is not None
  164. else self._row_throughput_locked()),
  165. "avgMs": round(sum(latencies) / len(latencies) * 1000, 1) if latencies else 0.0,
  166. "p50Ms": round(percentile(latencies, 50) * 1000, 1),
  167. "p95Ms": round(percentile(latencies, 95) * 1000, 1),
  168. "p99Ms": round(percentile(latencies, 99) * 1000, 1),
  169. "maxMs": round(max(latencies) * 1000, 1) if latencies else 0.0,
  170. "httpErrors": dict(sorted(self.http_errors.items())),
  171. "timeouts": self.timeouts,
  172. "rateLimited": self.rate_limited,
  173. "firstFailureAt": self.first_failure_at,
  174. "startedAt": self.started_at,
  175. "finishedAt": self.finished_at,
  176. }