task-20: Similar ASIN 端到端压测、GC 分析与结果文件兼容回归

SimilarAsinPerfFixture 新增三个功能点:
- endToEndBenchmark:生成→分 chunk→序列化,记录行数/chunk 数/payload 字节/耗时/吞吐/峰值堆;
- gcStressAnalysis:多轮生成/序列化/释放循环,采样 GC 计数差与堆峰值;
- compatRoundTrip:payload 序列化往返恢复全量行并校验字段稳定(结果文件兼容回归)。
全量测试 508 通过。
This commit is contained in:
2026-08-29 18:18:24 +08:00
parent 9b5873bb84
commit 29b6353d7e
2 changed files with 307 additions and 0 deletions
@@ -1,14 +1,17 @@
package com.nanri.aiimage.modules.similarasin.util;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
import lombok.extern.slf4j.Slf4j;
import java.lang.management.ManagementFactory;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Similar ASIN 性能基线夹具:生成 1000/5000 行解析数据、图片开关两种模式、
@@ -20,6 +23,7 @@ public class SimilarAsinPerfFixture {
public static final int MAX_ROWS = 5000;
public static final int DEFAULT_CHUNK_SIZE = 200;
private static final int GC_ROUNDS = 10;
private static final String[] COUNTRIES = {"英国", "德国", "法国", "意大利", "西班牙"};
private static final String[] TITLES = {
@@ -126,6 +130,137 @@ public class SimilarAsinPerfFixture {
public record Metrics(int rowCount, int chunkCount, long payloadBytes) {
}
/**
* 端到端基准:生成 → 分 chunk → 序列化全量 payload,记录行数、chunk 数、
* payload 字节、组装耗时(毫秒)、吞吐(行/秒)与峰值堆(字节)。
* 依赖失败(序列化抛异常)时向上抛出 IllegalStateException,不产生部分结果。
*/
public EndToEndMetrics endToEndBenchmark(String sourceFileKey, int rowCount, boolean withImages, int chunkSize) {
List<SimilarAsinParsedRowVo> rows = generateRows(sourceFileKey, rowCount, withImages);
List<List<SimilarAsinParsedRowVo>> chunks = splitChunks(rows, chunkSize);
if (rows.isEmpty()) {
return new EndToEndMetrics(0, 0, 0, 0, 0, 0);
}
long startMillis = System.currentTimeMillis();
long peakHeapBefore = sampledPeakHeapBytes();
long payloadBytes;
try {
byte[] bytes = objectMapper.writeValueAsBytes(rows);
payloadBytes = bytes.length;
} catch (Exception ex) {
throw new IllegalStateException("端到端基准序列化失败", ex);
}
long elapsedMillis = Math.max(1L, System.currentTimeMillis() - startMillis);
long peakHeap = Math.max(peakHeapBefore, sampledPeakHeapBytes());
double throughput = rows.size() * 1000.0 / elapsedMillis;
return new EndToEndMetrics(rows.size(), chunks.size(), payloadBytes, elapsedMillis, throughput, peakHeap);
}
/**
* GC 压力分析:多轮生成/序列化/释放循环,每轮记录 GC 计数差与堆峰值。
* 返回最后采样样本;rounds 表示实际执行轮数。临时对象随轮释放,堆峰值有界。
*/
public GcStressSample gcStressAnalysis(String sourceFileKey, int rowCount, boolean withImages) {
if (sourceFileKey == null || sourceFileKey.isBlank()) {
throw new IllegalArgumentException("sourceFileKey 不能为空");
}
if (rowCount < 0 || rowCount > MAX_ROWS) {
throw new IllegalArgumentException("rowCount 必须在 [0, " + MAX_ROWS + "] 范围内,实际 " + rowCount);
}
long before = totalGcCount();
long peak = sampledPeakHeapBytes();
int rounds = 0;
for (int round = 1; round <= GC_ROUNDS; round++) {
List<SimilarAsinParsedRowVo> rows = generateRows(sourceFileKey, rowCount, withImages);
if (!rows.isEmpty()) {
try {
objectMapper.writeValueAsBytes(rows);
} catch (Exception ex) {
throw new IllegalStateException("GC 压力分析序列化失败", ex);
}
}
rounds++;
peak = Math.max(peak, sampledPeakHeapBytes());
}
return new GcStressSample(rounds, totalGcCount() - before, peak);
}
/** payload 序列化往返兼容回归:序列化全量行 → 反序列化恢复 → 校验行数一致与字段稳定。 */
public CompatResult compatRoundTrip(String sourceFileKey, int rowCount, boolean withImages) {
List<SimilarAsinParsedRowVo> rows = generateRows(sourceFileKey, rowCount, withImages);
if (rows.isEmpty()) {
return new CompatResult(0, 0, true);
}
byte[] bytes;
try {
bytes = objectMapper.writeValueAsBytes(rows);
} catch (Exception ex) {
throw new IllegalStateException("兼容回归序列化失败", ex);
}
List<SimilarAsinParsedRowVo> recovered;
try {
recovered = objectMapper.readValue(bytes, new TypeReference<List<SimilarAsinParsedRowVo>>() {
});
} catch (Exception ex) {
throw new IllegalStateException("兼容回归反序列化失败", ex);
}
return new CompatResult(rows.size(), recovered.size(), fieldsStable(rows, recovered));
}
public record EndToEndMetrics(int rowCount, int chunkCount, long payloadBytes, long assembleMillis,
double throughputRowsPerSec, long peakHeapBytes) {
}
public record GcStressSample(int rounds, long gcCount, long peakHeapBytes) {
}
public record CompatResult(int rowCount, int recoveredCount, boolean fieldStable) {
}
private static boolean fieldsStable(List<SimilarAsinParsedRowVo> original, List<SimilarAsinParsedRowVo> recovered) {
if (original.size() != recovered.size()) {
return false;
}
for (int i = 0; i < original.size(); i++) {
SimilarAsinParsedRowVo a = original.get(i);
SimilarAsinParsedRowVo b = recovered.get(i);
if (!Objects.equals(a.getAsin(), b.getAsin())
|| !Objects.equals(a.getCountry(), b.getCountry())
|| !Objects.equals(a.getSku(), b.getSku())
|| !Objects.equals(a.getTitle(), b.getTitle())
|| !Objects.equals(a.getUrl(), b.getUrl())
|| !Objects.equals(a.getRowToken(), b.getRowToken())
|| !Objects.equals(a.getSourceFileKey(), b.getSourceFileKey())) {
return false;
}
}
return true;
}
/** 采样当前堆已用字节峰值;MXBean 不可用时返回 -1,与 "> 0" 类断言不冲突(GC 场景始终可用)。 */
private static long sampledPeakHeapBytes() {
try {
return Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
} catch (Exception ex) {
return -1;
}
}
private static long totalGcCount() {
long total = 0;
try {
for (java.lang.management.GarbageCollectorMXBean bean : ManagementFactory.getGarbageCollectorMXBeans()) {
long count = bean.getCollectionCount();
if (count >= 0) {
total += count;
}
}
} catch (Exception ex) {
return 0;
}
return total;
}
private static String deterministicAsin(long seed) {
StringBuilder sb = new StringBuilder("B0");
long state = seed;