From 59e8197d2247efbb28f6133b22d95e9bf22e0a25 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 3 Sep 2026 11:50:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=EF=BC=8C?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=9F=8B=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 32 ++-- .../loadtestmetrics/ApiMetricsCollector.java | 143 ++++++++++++++++++ .../loadtestmetrics/ApiMetricsConfig.java | 35 +++++ .../loadtestmetrics/ApiMetricsController.java | 141 +++++++++++++++++ .../loadtestmetrics/ApiMetricsEntity.java | 81 ++++++++++ .../loadtestmetrics/ApiMetricsFilter.java | 113 ++++++++++++++ .../loadtestmetrics/ApiMetricsRepository.java | 35 +++++ .../OutboundOapiMetricsAspect.java | 134 ++++++++++++++++ src/main/resources/application.yml | 14 ++ .../resources/sql/api_metrics_loadtest.sql | 25 +++ 10 files changed, 739 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsCollector.java create mode 100644 src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsConfig.java create mode 100644 src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsController.java create mode 100644 src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsEntity.java create mode 100644 src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsFilter.java create mode 100644 src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsRepository.java create mode 100644 src/main/java/com/yunhai/userhome/loadtestmetrics/OutboundOapiMetricsAspect.java create mode 100644 src/main/resources/sql/api_metrics_loadtest.sql diff --git a/pom.xml b/pom.xml index 69662a2..965c326 100644 --- a/pom.xml +++ b/pom.xml @@ -55,6 +55,20 @@ spring-boot-starter-aop + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + org.projectlombok lombok @@ -78,22 +92,12 @@ 3.2.0 - + com.yunhai.platform - yh-auth-spring-boot-starter - - - com.yunhai.platform - yh-app-auth-spring-boot-starter - - - com.yunhai.platform - yh-openapi-spring-boot-starter - - - com.yunhai.platform - yh-api-std + yh-spring-boot-starter diff --git a/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsCollector.java b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsCollector.java new file mode 100644 index 0000000..b7f0952 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsCollector.java @@ -0,0 +1,143 @@ +package com.yunhai.userhome.loadtestmetrics; + +/* ============================================================================= + * [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二 + * 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml + * 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。 + * 移除步骤详见仓库根《埋点移除清单.md》。 + * ============================================================================= */ + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * [LOADTEST-METRICS] UserHome 指标内存累加器(方案 §3)。
+ * 按 (uri, method, direction, isLoadTest) 维度 ConcurrentHashMap O(1) 原子累加, + * 不走队列、不做同步 IO;每 60s 快照落库 api_metrics,失败加回不丢数。
+ * Nacos 可下发 api.metrics.enabled=false 整体停用。
+ * (本类自带 @EnableScheduling 见 ApiMetricsConfig,UserHome 主类原本未开启定时任务, + * 删除本包后定时能力一并消失,不留痕迹。) + */ +@Slf4j +@Component +public class ApiMetricsCollector { + + /** 聚合维度 key(uri×method×direction×压测标记) */ + private static final class Dim { + final String uri; + final String method; + final String direction; + final boolean loadTest; + + Dim(String uri, String method, String direction, boolean loadTest) { + this.uri = uri; + this.method = method; + this.direction = direction; + this.loadTest = loadTest; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Dim)) { + return false; + } + Dim d = (Dim) o; + return loadTest == d.loadTest && uri.equals(d.uri) && method.equals(d.method) + && direction.equals(d.direction); + } + + @Override + public int hashCode() { + return Objects.hash(uri, method, direction, loadTest); + } + } + + /** 单维度三指标:次数 / 耗时累计 / 错误数 */ + private static final class Counter { + final AtomicLong cnt = new AtomicLong(); + final AtomicLong cost = new AtomicLong(); + final AtomicLong err = new AtomicLong(); + } + + private final ConcurrentHashMap counters = new ConcurrentHashMap<>(); + + @Autowired + private ApiMetricsRepository repository; + + /** 埋点总开关(Nacos 可下发紧急关停) */ + @Value("${api.metrics.enabled:true}") + private boolean enabled; + + /** 入站 Filter / 出站切面调用:一次请求(或一次出站调用)结束时记一笔 */ + public void record(String uri, String method, String direction, long costMs, boolean error, boolean loadTest) { + if (!enabled) { + return; + } + Counter c = counters.computeIfAbsent(new Dim(uri, method, direction, loadTest), k -> new Counter()); + c.cnt.incrementAndGet(); + c.cost.addAndGet(costMs); + if (error) { + c.err.incrementAndGet(); + } + } + + /** 快照窗口(ms),对齐方案 60s */ + @Scheduled(fixedDelayString = "${api.metrics.flush-interval-ms:60000}") + public void flush() { + if (!enabled || counters.isEmpty()) { + return; + } + LocalDateTime sliceTime = LocalDateTime.now(); + List rows = new ArrayList<>(); + List snapshot = new ArrayList<>(counters.size()); + for (ConcurrentHashMap.Entry e : counters.entrySet()) { + long cnt = e.getValue().cnt.getAndSet(0); + long cost = e.getValue().cost.getAndSet(0); + long err = e.getValue().err.getAndSet(0); + if (cnt <= 0) { + continue; + } + snapshot.add(new Object[]{e.getKey(), cnt, cost, err}); + ApiMetricsEntity row = new ApiMetricsEntity(); + row.setStatTime(sliceTime); + row.setUri(e.getKey().uri); + row.setMethod(e.getKey().method); + row.setDirection(e.getKey().direction); + row.setCnt(cnt); + row.setTotalCost(cost); + row.setErrCnt(err); + row.setIsLoadTest(e.getKey().loadTest ? 1 : 0); + rows.add(row); + } + if (rows.isEmpty()) { + return; + } + try { + repository.saveAll(rows); + } catch (Exception ex) { + // 落库失败:数值加回内存,并入下个窗口,不静默丢数 + log.warn("[LOADTEST-METRICS] api_metrics 落库失败,{} 行并入下个窗口: {}", rows.size(), ex.getMessage()); + for (Object[] s : snapshot) { + Dim dim = (Dim) s[0]; + Counter c = counters.computeIfAbsent(dim, k -> new Counter()); + c.cnt.addAndGet((Long) s[1]); + c.cost.addAndGet((Long) s[2]); + c.err.addAndGet((Long) s[3]); + } + } + } + +} diff --git a/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsConfig.java b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsConfig.java new file mode 100644 index 0000000..3878c17 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsConfig.java @@ -0,0 +1,35 @@ +package com.yunhai.userhome.loadtestmetrics; + +/* ============================================================================= + * [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二 + * 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml + * 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。 + * 移除步骤详见仓库根《埋点移除清单.md》。 + * ============================================================================= */ + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * [LOADTEST-METRICS] 埋点装配:注册入站指标过滤器 + 开启定时快照。
+ * @EnableScheduling 只服务于 {@link ApiMetricsCollector#flush()}(UserHome 主类原本未开定时任务, + * 删包后此能力一并消失,主类零改动);过滤器取最前置 order,连 CAS 302 也计入入站口径。 + */ +@Configuration +@EnableScheduling +public class ApiMetricsConfig { + + @Bean + public FilterRegistrationBean apiMetricsFilter(ApiMetricsCollector collector) { + FilterRegistrationBean bean = new FilterRegistrationBean<>(); + bean.setFilter(new ApiMetricsFilter(collector)); + bean.addUrlPatterns("/*"); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE); + bean.setName("apiMetricsFilter"); + return bean; + } + +} diff --git a/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsController.java b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsController.java new file mode 100644 index 0000000..c1671d9 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsController.java @@ -0,0 +1,141 @@ +package com.yunhai.userhome.loadtestmetrics; + +/* ============================================================================= + * [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二 + * 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml + * 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。 + * 移除步骤详见仓库根《埋点移除清单.md》。 + * ============================================================================= */ + +import com.yunhai.userhome.config.Config; +import lombok.Data; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * [LOADTEST-METRICS] UserHome 热度 + 放大系数报告查询(方案 §3)。
+ * 默认只统计线上流量(is_load_test=0),压测流量单独给合计数。
+ * 例:GET /api/apimetrics/TopN?days=7&top=10 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/apimetrics/") +public class ApiMetricsController { + + @Autowired + private ApiMetricsRepository repository; + + /** + * 报告:入站 TopN、出站 oapi TopN、入站/出站总量与放大系数。 + */ + @GetMapping("/TopN") + public ReportVo topN( + @RequestParam(value = "days", defaultValue = "1") Integer days, + @RequestParam(value = "top", defaultValue = "10") Integer top) { + + int d = clamp(days == null ? 1 : days, 1, 30); + int n = clamp(top == null ? 10 : top, 1, 100); + LocalDateTime since = LocalDateTime.now().minusDays(d); + ReportVo vo = new ReportVo(); + vo.setGeneratedAt(LocalDateTime.now().toString()); + vo.setDays(d); + vo.setTop(n); + + Object[] in = repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_IN, 0); + vo.setInCnt(asLong(in, 0)); + vo.setInAvgCostMs(avg(asLong(in, 0), asLong(in, 1))); + vo.setInErrCnt(asLong(in, 2)); + + Object[] out = repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_OUT, 0); + vo.setOutCnt(asLong(out, 0)); + vo.setOutAvgCostMs(avg(asLong(out, 0), asLong(out, 1))); + vo.setOutErrCnt(asLong(out, 2)); + + // 放大系数 = 出站 oapi 调用数 / 入站请求数(首屏放大问题的量化,方案 §5 缓存决策输入) + vo.setAmplification(vo.getInCnt() <= 0 ? 0.0 + : Math.round(vo.getOutCnt() * 10000.0 / vo.getInCnt()) / 100.0); + + vo.setTopInUris(toRows(repository.aggregateByUri(since, ApiMetricsEntity.DIRECTION_IN, 0), n)); + vo.setTopOutUris(toRows(repository.aggregateByUri(since, ApiMetricsEntity.DIRECTION_OUT, 0), n)); + + // 压测流量合计(单列口径,不与线上热度混算) + Object[] ltIn = repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_IN, 1); + Object[] ltOut = repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_OUT, 1); + vo.setLoadTestInCnt(asLong(ltIn, 0)); + vo.setLoadTestOutCnt(asLong(ltOut, 0)); + return vo; + } + + private List toRows(List aggregated, int limit) { + List rows = new ArrayList<>(); + for (Object[] r : aggregated) { + UriRow row = new UriRow(); + row.setUri((String) r[0]); + row.setMethod((String) r[1]); + row.setCnt(asLong(r, 2)); + row.setAvgCostMs(avg(asLong(r, 2), asLong(r, 3))); + row.setErrCnt(asLong(r, 4)); + rows.add(row); + if (rows.size() >= limit) { + break; + } + } + return rows; + } + + private long asLong(Object[] arr, int i) { + if (arr == null || i >= arr.length || arr[i] == null) { + return 0L; + } + return ((Number) arr[i]).longValue(); + } + + private double avg(long cnt, long total) { + return cnt <= 0 ? 0.0 : Math.round(total * 100.0 / cnt) / 100.0; + } + + private int clamp(int v, int min, int max) { + return v < min ? min : (v > max ? max : v); + } + + /** 报告 VO(嵌套声明,压测结束随包整体删除) */ + @Data + public static class ReportVo { + private String generatedAt; + private Integer days; + private Integer top; + /** 入站请求总数 */ + private Long inCnt; + private Double inAvgCostMs; + private Long inErrCnt; + /** 出站 UserBase oapi 调用总数 */ + private Long outCnt; + private Double outAvgCostMs; + private Long outErrCnt; + /** 放大系数 = outCnt / inCnt(1 次入站扇出多少次跨仓调用) */ + private Double amplification; + /** 入站热度 TopN */ + private List topInUris; + /** 出站 oapi 热度 TopN(即 UserBase 侧被扇出的接口) */ + private List topOutUris; + /** 压测流量合计(单列,不混入上面各项) */ + private Long loadTestInCnt; + private Long loadTestOutCnt; + } + + @Data + public static class UriRow { + private String uri; + private String method; + private Long cnt; + private Double avgCostMs; + private Long errCnt; + } + +} diff --git a/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsEntity.java b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsEntity.java new file mode 100644 index 0000000..b4f343c --- /dev/null +++ b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsEntity.java @@ -0,0 +1,81 @@ +package com.yunhai.userhome.loadtestmetrics; + +/* ============================================================================= + * [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二 + * 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml + * 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。 + * 移除步骤详见仓库根《埋点移除清单.md》。 + * ============================================================================= */ + +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.Table; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * [LOADTEST-METRICS] UserHome 访问频次指标(60s 切片聚合,方案 §3)。
+ * direction=in:入站 HTTP 请求(Filter 层采集);
+ * direction=out:出站 UserBase oapi 调用(切 OpenApiHttpClient 采集);
+ * 两者之比即首屏「放大系数」(1 次入站扇出多少次跨仓调用)。
+ * 表结构由 ddl-auto=update 自动创建;DBA 手工建表脚本见 + * resources/sql/api_metrics_loadtest.sql。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "api_metrics", indexes = { + @Index(name = "idx_time", columnList = "stat_time"), + @Index(name = "idx_direction_time", columnList = "direction, stat_time") +}) +public class ApiMetricsEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final String DIRECTION_IN = "in"; + public static final String DIRECTION_OUT = "out"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 统计切片时间(该 60s 窗口的结束时刻) */ + @Column(name = "stat_time", nullable = false, columnDefinition = "DATETIME") + private LocalDateTime statTime; + + /** 规范化路由:入站为本地路径(去 query、数字段归一);出站为 oapi path(如 /oapi/app/list) */ + @Column(name = "uri", nullable = false, length = 200) + private String uri; + + /** GET/POST/PUT/DELETE */ + @Column(name = "method", nullable = false, length = 10) + private String method; + + /** in=入站请求,out=出站 oapi 调用 */ + @Column(name = "direction", nullable = false, length = 8) + private String direction; + + /** 窗口内请求次数 */ + @Column(name = "cnt", nullable = false) + private Long cnt; + + /** 窗口内耗时累计 ms */ + @Column(name = "total_cost", nullable = false) + private Long totalCost; + + /** 窗口内错误次数:入站为 HTTP 非 200;出站为抛异常(HTTP 非 2xx / IO 错误) */ + @Column(name = "err_cnt", nullable = false) + private Long errCnt; + + /** 压测流量单列:入站看 X-Load-Test 头,出站继承触发它的入站请求标记 */ + @Column(name = "is_load_test", nullable = false, columnDefinition = "TINYINT(1)") + private Integer isLoadTest; + +} diff --git a/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsFilter.java b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsFilter.java new file mode 100644 index 0000000..9bd122f --- /dev/null +++ b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsFilter.java @@ -0,0 +1,113 @@ +package com.yunhai.userhome.loadtestmetrics; + +/* ============================================================================= + * [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二(方案 §3.1) + * 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml + * 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。 + * 移除步骤详见仓库根《埋点移除清单.md》。 + * ============================================================================= */ + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * [LOADTEST-METRICS] UserHome 入站热度采集过滤器(direction=in)。
+ * 挂 /* 最前置:连 CAS 未登录被 302 重定向的请求也计入(真实的入站压力口径)。
+ * 排除 /upload/**(静态文件,路径基数不可控)与 /actuator/**(prometheus 周期抓取, + * 会污染热度),只统计 API 热度。 + */ +public class ApiMetricsFilter implements Filter { + + /** 压测流量标记头(方案 §2.4,k6 全部请求携带;出站切面会继承此标记) */ + public static final String HEADER_LOAD_TEST = "X-Load-Test"; + + private static final int MAX_URI_LEN = 200; + + private final ApiMetricsCollector collector; + + public ApiMetricsFilter(ApiMetricsCollector collector) { + this.collector = collector; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + + String uri = normalizeUri(req); + if (uri.startsWith("/upload/") || uri.startsWith("/actuator/")) { + chain.doFilter(request, response); + return; + } + + String method = req.getMethod(); + boolean loadTest = isLoadTest(req); + + long start = System.currentTimeMillis(); + boolean error = false; + try { + chain.doFilter(request, response); + error = res.getStatus() != 200; + } catch (IOException | ServletException e) { + error = true; + throw e; + } finally { + collector.record(uri, method, ApiMetricsEntity.DIRECTION_IN, + System.currentTimeMillis() - start, error, loadTest); + } + } + + /** + * 规范化路由:去 context-path、去 query、纯数字路径段归一为 {id} + * (如 /mobiapi/app/detail/12 → /mobiapi/app/detail/{id}),防止路径参数撑爆维度基数。 + */ + private String normalizeUri(HttpServletRequest req) { + String uri = req.getRequestURI(); + String ctx = req.getContextPath(); + if (ctx != null && ctx.length() > 1 && uri.startsWith(ctx)) { + uri = uri.substring(ctx.length()); + } + if (uri.indexOf('/') >= 0) { + String[] segs = uri.split("/"); + StringBuilder sb = new StringBuilder(uri.length()); + for (String seg : segs) { + if (seg.length() > 0 && isDigits(seg)) { + seg = "{id}"; + } + sb.append('/').append(seg); + } + uri = sb.toString(); + } + if (uri.length() > MAX_URI_LEN) { + uri = uri.substring(0, MAX_URI_LEN); + } + return uri; + } + + private boolean isDigits(String s) { + for (int i = 0; i < s.length(); i++) { + if (!Character.isDigit(s.charAt(i))) { + return false; + } + } + return true; + } + + /** 压测标记:X-Load-Test 为 1/true/非 0 值即认为压测流量 */ + private boolean isLoadTest(HttpServletRequest req) { + String v = req.getHeader(HEADER_LOAD_TEST); + if (v == null || v.trim().isEmpty()) { + return false; + } + v = v.trim(); + return !"0".equals(v) && !"false".equalsIgnoreCase(v); + } + +} diff --git a/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsRepository.java b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsRepository.java new file mode 100644 index 0000000..e8086a9 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/loadtestmetrics/ApiMetricsRepository.java @@ -0,0 +1,35 @@ +package com.yunhai.userhome.loadtestmetrics; + +/* ============================================================================= + * [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二 + * 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml + * 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。 + * 移除步骤详见仓库根《埋点移除清单.md》。 + * ============================================================================= */ + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +/** [LOADTEST-METRICS] api_metrics 聚合查询(热度 + 放大系数报告用)。 */ +public interface ApiMetricsRepository extends JpaRepository { + + /** 按方向聚合 uri 热度,按总次数降序 */ + @Query("select e.uri, e.method, sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from ApiMetricsEntity e " + + "where e.statTime >= :since and e.direction = :direction and e.isLoadTest = :isLoadTest " + + "group by e.uri, e.method order by sum(e.cnt) desc") + List aggregateByUri(@Param("since") LocalDateTime since, + @Param("direction") String direction, + @Param("isLoadTest") Integer isLoadTest); + + /** 按方向全量汇总(总次数/总耗时/总错误),入站与出站合计相除即放大系数 */ + @Query("select sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from ApiMetricsEntity e " + + "where e.statTime >= :since and e.direction = :direction and e.isLoadTest = :isLoadTest") + Object[] aggregateTotal(@Param("since") LocalDateTime since, + @Param("direction") String direction, + @Param("isLoadTest") Integer isLoadTest); + +} diff --git a/src/main/java/com/yunhai/userhome/loadtestmetrics/OutboundOapiMetricsAspect.java b/src/main/java/com/yunhai/userhome/loadtestmetrics/OutboundOapiMetricsAspect.java new file mode 100644 index 0000000..d418fc5 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/loadtestmetrics/OutboundOapiMetricsAspect.java @@ -0,0 +1,134 @@ +package com.yunhai.userhome.loadtestmetrics; + +/* ============================================================================= + * [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二(方案 §3.2) + * 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml + * 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。 + * 移除步骤详见仓库根《埋点移除清单.md》。 + * ============================================================================= */ + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; + +/** + * [LOADTEST-METRICS] 出站 UserBase oapi 调用计数(direction=out,方案 §3.2)。
+ * 切在 yh-openapi-client 的 {@code OpenApiHttpClient}(所有出站开放接口调用的唯一公共入口, + * Spring Bean)上:YschBaseOpenApi 静态门面、OpenApiUserClient / OpenApiAppClient 注入式调用 + * 全部经它发出,一处切面覆盖全部出站,不改动任何业务 Service 代码。
+ * 入站请求数(direction=in)与出站调用数(direction=out)相除即「放大系数」, + * 是缓存层次决策的直接依据(方案 §5)。 + */ +@Slf4j +@Aspect +@Component +public class OutboundOapiMetricsAspect { + + private static final int MAX_URI_LEN = 200; + + private final ApiMetricsCollector collector; + + public OutboundOapiMetricsAspect(ApiMetricsCollector collector) { + this.collector = collector; + } + + /** + * 拦截 OpenApiHttpClient 全部公开执行方法(getJson/postJson/get/post/postMultipart)。
+ * 各方法签名第 2 参均为 path(如 /oapi/app/list),据此归一化记 uri。 + */ + @Around("execution(* com.yunhai.platform.openapi.OpenApiHttpClient.*(..))") + public Object around(ProceedingJoinPoint point) throws Throwable { + String uri = resolveUri(point); + String method = resolveHttpMethod(point.getSignature().getName()); + boolean loadTest = inheritLoadTestFlag(); + + long start = System.currentTimeMillis(); + boolean error = false; + try { + return point.proceed(); + } catch (Throwable e) { + error = true; + throw e; + } finally { + try { + collector.record(uri, method, ApiMetricsEntity.DIRECTION_OUT, + System.currentTimeMillis() - start, error, loadTest); + } catch (Exception recordEx) { + // 埋点自身绝不影响出站调用 + log.warn("[LOADTEST-METRICS] 出站计数失败: {}", recordEx.getMessage()); + } + } + } + + /** 方法名 → HTTP 方法(getJson/get→GET,postJson/post/postMultipart→POST) */ + private String resolveHttpMethod(String signatureName) { + return signatureName.startsWith("post") ? "POST" : "GET"; + } + + /** 取第 2 参 path 归一化;防御签名变化时记方法名兜底,不抛错 */ + private String resolveUri(ProceedingJoinPoint point) { + try { + Object[] args = point.getArgs(); + if (args.length >= 2 && args[1] instanceof String) { + return normalize((String) args[1]); + } + } catch (Exception ignore) { + // 兜底 + } + return "oapi:" + point.getSignature().getName(); + } + + /** 出站 path 归一化:保证前导 /、纯数字段归一为 {id}、截断 200 */ + private String normalize(String path) { + String p = path == null ? "" : path.trim(); + if (p.isEmpty()) { + return "/"; + } + if (!p.startsWith("/")) { + p = "/" + p; + } + int q = p.indexOf('?'); + if (q >= 0) { + p = p.substring(0, q); + } + String[] segs = p.split("/"); + StringBuilder sb = new StringBuilder(p.length()); + for (String seg : segs) { + if (seg.length() > 0 && isDigits(seg)) { + seg = "{id}"; + } + sb.append('/').append(seg); + } + p = sb.toString(); + return p.length() > MAX_URI_LEN ? p.substring(0, MAX_URI_LEN) : p; + } + + private boolean isDigits(String s) { + for (int i = 0; i < s.length(); i++) { + if (!Character.isDigit(s.charAt(i))) { + return false; + } + } + return true; + } + + /** 出站调用的压测标记继承自触发它的入站请求(X-Load-Test 头);定时任务等无请求上下文则为 false */ + private boolean inheritLoadTestFlag() { + if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes) { + HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); + String v = req.getHeader(ApiMetricsFilter.HEADER_LOAD_TEST); + if (v != null && !v.trim().isEmpty()) { + v = v.trim(); + return !"0".equals(v) && !"false".equalsIgnoreCase(v); + } + } + return false; + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1bbbe9f..30d33fb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -72,6 +72,20 @@ logging: level: com.yunhai.userhome: debug +# [LOADTEST-METRICS-BEGIN] 临时埋点:健康探活 + prometheus 指标端点(方案 §3.3)。 +# Dockerfile 一直在探 /actuator/health,本段同时修复了探活 404 隐患; +# show-details=never 不外泄 DB/Redis 细节,仅返回总体状态。 +# 压测结束后:保留 health(生产探活需要),按《埋点移除清单.md》评估是否去掉 prometheus。 +management: + endpoints: + web: + exposure: + include: health,prometheus + endpoint: + health: + show-details: never +# [LOADTEST-METRICS-END] + # ===== 文件上传与学校配置 ===== # 文件存储根目录(UserSettingsController 壁纸/图标上传使用) # 本地默认 ./upload/(相对工作目录);生产由 Nacos 覆盖为 /data/upload(容器卷挂载到宿主机) diff --git a/src/main/resources/sql/api_metrics_loadtest.sql b/src/main/resources/sql/api_metrics_loadtest.sql new file mode 100644 index 0000000..ef2a7f4 --- /dev/null +++ b/src/main/resources/sql/api_metrics_loadtest.sql @@ -0,0 +1,25 @@ +-- ============================================================================= +-- [LOADTEST-METRICS] 临时埋点表 —— 《压测与oapi埋点统计方案.md》阶段二 +-- 压测结束后移除:DROP TABLE(见文末);代码侧删除 loadtestmetrics 包。 +-- 清单见仓库根《埋点移除清单.md》。 +-- ============================================================================= + +-- 本地 dev 由 Hibernate(ddl-auto=update) 依据 ApiMetricsEntity 自动建表; +-- 生产 / 独立压测环境由 DBA 手工执行本脚本(与实体定义保持一致)。 + +CREATE TABLE IF NOT EXISTS api_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + stat_time DATETIME NOT NULL COMMENT '统计切片时间(60s 窗口结束时刻)', + uri VARCHAR(200) NOT NULL COMMENT '入站=本地路径,出站=oapi path(均去 query、数字段归一为 {id})', + method VARCHAR(10) NOT NULL COMMENT 'GET/POST/PUT/DELETE', + direction VARCHAR(8) NOT NULL COMMENT 'in=入站请求,out=出站 UserBase oapi 调用', + cnt BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内请求次数', + total_cost BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内耗时累计 ms', + err_cnt BIGINT NOT NULL DEFAULT 0 COMMENT '入站=HTTP 非 200;出站=调用抛异常', + is_load_test TINYINT(1) NOT NULL DEFAULT 0 COMMENT '压测流量单列(入站 X-Load-Test: 1,出站继承)', + KEY idx_time (stat_time), + KEY idx_direction_time (direction, stat_time) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '[LOADTEST-METRICS] UserHome 入站/出站频次统计(临时,压测结束后删除)'; + +-- 压测结束、热度报告导出后执行: +-- DROP TABLE IF EXISTS api_metrics;