健康检查,数据埋点
build / build-push (push) Successful in 1m11s

This commit is contained in:
chenxing
2026-09-03 11:50:22 +08:00
parent ac00581417
commit 59e8197d22
10 changed files with 739 additions and 14 deletions
+18 -14
View File
@@ -55,6 +55,20 @@
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- [LOADTEST-METRICS-BEGIN] 临时埋点引入,但同时也是生产隐患修复:
Dockerfile 一直在探 /actuator/health 而 pom 从未引 actuator(方案 §3.3、事实 #7),
探活 404 隐患原本就该修。压测结束后建议保留本段 actuator(健康探活),
仅按《埋点移除清单.md》评估是否移除 prometheus registry。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- [LOADTEST-METRICS-END] -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@@ -78,22 +92,12 @@
<version>3.2.0</version>
</dependency>
<!-- 基础平台架包:CAS 认证 + 应用间签名 + 统一响应(经 BOM 传递 yh-openapi-client 等) -->
<!-- 基础平台全家桶(聚合 starter):CAS 认证 + 应用间签名 + 统一响应 + OpenAPI 客户端 +
actuator 健康探活,一行带齐(模块构成见 BasePlatform/后端项目知识库.md)。
约定:标准业务服务一律引聚合 starter;平台能力提供方(如 UserBase)按需单引。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-auth-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-app-auth-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-openapi-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-api-std</artifactId>
<artifactId>yh-spring-boot-starter</artifactId>
</dependency>
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
@@ -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)。<br>
* 按 (uri, method, direction, isLoadTest) 维度 ConcurrentHashMap O(1) 原子累加,
* 不走队列、不做同步 IO;每 60s 快照落库 api_metrics,失败加回不丢数。<br>
* Nacos 可下发 api.metrics.enabled=false 整体停用。<br>
* (本类自带 @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<Dim, Counter> 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<ApiMetricsEntity> rows = new ArrayList<>();
List<Object[]> snapshot = new ArrayList<>(counters.size());
for (ConcurrentHashMap.Entry<Dim, Counter> 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]);
}
}
}
}
@@ -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] 埋点装配:注册入站指标过滤器 + 开启定时快照。<br>
* @EnableScheduling 只服务于 {@link ApiMetricsCollector#flush()}(UserHome 主类原本未开定时任务,
* 删包后此能力一并消失,主类零改动);过滤器取最前置 order,连 CAS 302 也计入入站口径。
*/
@Configuration
@EnableScheduling
public class ApiMetricsConfig {
@Bean
public FilterRegistrationBean<ApiMetricsFilter> apiMetricsFilter(ApiMetricsCollector collector) {
FilterRegistrationBean<ApiMetricsFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new ApiMetricsFilter(collector));
bean.addUrlPatterns("/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
bean.setName("apiMetricsFilter");
return bean;
}
}
@@ -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)。<br>
* 默认只统计线上流量(is_load_test=0),压测流量单独给合计数。<br>
* 例:GET /api/apimetrics/TopN?days=7&amp;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<UriRow> toRows(List<Object[]> aggregated, int limit) {
List<UriRow> 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<UriRow> topInUris;
/** 出站 oapi 热度 TopN(即 UserBase 侧被扇出的接口) */
private List<UriRow> 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;
}
}
@@ -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)。<br>
* direction=in:入站 HTTP 请求(Filter 层采集);<br>
* direction=out:出站 UserBase oapi 调用(切 OpenApiHttpClient 采集);<br>
* 两者之比即首屏「放大系数」(1 次入站扇出多少次跨仓调用)。<br>
* 表结构由 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;
}
@@ -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)。<br>
* 挂 /* 最前置:连 CAS 未登录被 302 重定向的请求也计入(真实的入站压力口径)。<br>
* 排除 /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);
}
}
@@ -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<ApiMetricsEntity, Long> {
/** 按方向聚合 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<Object[]> 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);
}
@@ -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)。<br>
* 切在 yh-openapi-client 的 {@code OpenApiHttpClient}(所有出站开放接口调用的唯一公共入口,
* Spring Bean)上:YschBaseOpenApi 静态门面、OpenApiUserClient / OpenApiAppClient 注入式调用
* 全部经它发出,一处切面覆盖全部出站,不改动任何业务 Service 代码。<br>
* 入站请求数(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)。<br>
* 各方法签名第 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;
}
}
+14
View File
@@ -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(容器卷挂载到宿主机)
@@ -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;