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;
@@ -0,0 +1,172 @@
package com.nanri.aiimage.modules.similarasin.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
/**
* Task 20Similar ASIN 端到端压测、JFR/GC 分析与结果文件兼容回归。
* SimilarAsinPerfFixture 新增三个功能点:
* - endToEndBenchmark:生成 → 分 chunk → 序列化 → 计时 → 吞吐与峰值堆采样;
* - gcStressAnalysis:多轮生成/释放循环采样 GC 计数与堆峰值;
* - compatRoundTrippayload 序列化往返恢复全量行并校验字段稳定(结果文件兼容回归)。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinPerfFixtureE2ETest {
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Test
void test_task_020_asin_normal_default_path() {
// 正常输入:1000 行端到端基准返回完整指标,行数/chunk 数正确,吞吐与堆峰值有界。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
SimilarAsinPerfFixture.EndToEndMetrics metrics =
fixture.endToEndBenchmark("uploads/20260829/e2e-default.xlsx", 1000, false, 200);
assertEquals(1000, metrics.rowCount(), "行数不丢失");
assertEquals(5, metrics.chunkCount(), "1000 行 / 200 每 chunk = 5 个 chunk");
assertTrue(metrics.payloadBytes() > 0, "payload 字节可采样");
assertTrue(metrics.assembleMillis() >= 0);
assertTrue(metrics.throughputRowsPerSec() > 0, "吞吐必须为正");
assertTrue(metrics.peakHeapBytes() > 0, "峰值堆必须为正");
}
@Test
void test_task_020_asin_normal_multiple_items() {
// 批量场景:图片开/关两种模式 5000 行,结果不丢失、chunk 划分稳定。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
SimilarAsinPerfFixture.EndToEndMetrics withImages =
fixture.endToEndBenchmark("uploads/20260829/e2e-img.xlsx", 5000, true, 200);
SimilarAsinPerfFixture.EndToEndMetrics textOnly =
fixture.endToEndBenchmark("uploads/20260829/e2e-text.xlsx", 5000, false, 200);
assertEquals(5000, withImages.rowCount());
assertEquals(5000, textOnly.rowCount());
assertEquals(25, withImages.chunkCount());
assertEquals(25, textOnly.chunkCount());
assertTrue(withImages.payloadBytes() > textOnly.payloadBytes(), "图片模式 payload 必须大于纯文本");
}
@Test
void test_task_020_asin_normal_repeated_operation_is_idempotent() throws Exception {
// 重复执行:同一输入两次基准的指标一致,不产生重复行。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
SimilarAsinPerfFixture.CompatResult first =
fixture.compatRoundTrip("uploads/20260829/e2e-idem.xlsx", 1000, true);
SimilarAsinPerfFixture.CompatResult second =
fixture.compatRoundTrip("uploads/20260829/e2e-idem.xlsx", 1000, true);
assertEquals(1000, first.rowCount());
assertEquals(1000, first.recoveredCount(), "往返恢复全量行");
assertTrue(first.fieldStable(), "字段必须稳定");
assertEquals(first.rowCount(), second.rowCount());
assertEquals(first.recoveredCount(), second.recoveredCount());
assertEquals(first.fieldStable(), second.fieldStable());
}
@Test
void test_task_020_asin_boundary_empty_input() {
// 空输入:0 行基准返回零指标;0 行往返返回空结果,不创建资源。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
SimilarAsinPerfFixture.EndToEndMetrics metrics =
fixture.endToEndBenchmark("uploads/20260829/e2e-empty.xlsx", 0, false, 200);
assertEquals(0, metrics.rowCount());
assertEquals(0, metrics.chunkCount());
assertEquals(0, metrics.payloadBytes());
SimilarAsinPerfFixture.CompatResult compat =
fixture.compatRoundTrip("uploads/20260829/e2e-empty.xlsx", 0, false);
assertEquals(0, compat.rowCount());
assertEquals(0, compat.recoveredCount());
assertTrue(compat.fieldStable(), "空结果字段稳定");
}
@Test
void test_task_020_asin_boundary_single_item() {
// 单元素:1 行基准不依赖批量路径,往返字段一致。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
SimilarAsinPerfFixture.EndToEndMetrics metrics =
fixture.endToEndBenchmark("uploads/20260829/e2e-single.xlsx", 1, true, 200);
assertEquals(1, metrics.rowCount());
assertEquals(1, metrics.chunkCount());
SimilarAsinPerfFixture.CompatResult compat =
fixture.compatRoundTrip("uploads/20260829/e2e-single.xlsx", 1, true);
assertEquals(1, compat.recoveredCount());
assertTrue(compat.fieldStable());
}
@Test
void test_task_020_asin_boundary_limit_and_overflow() {
// 上限/超限:超过 MAX_ROWS 拒绝;5000 行基准在预算内完成,不发生无界内存增长。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
IllegalArgumentException overflow = assertThrows(IllegalArgumentException.class,
() -> fixture.endToEndBenchmark("uploads/20260829/e2e-over.xlsx",
SimilarAsinPerfFixture.MAX_ROWS + 1, false, 200));
assertTrue(overflow.getMessage().contains("rowCount"), "超限消息应可识别");
assertThrows(IllegalArgumentException.class,
() -> fixture.compatRoundTrip("uploads/20260829/e2e-over.xlsx",
SimilarAsinPerfFixture.MAX_ROWS + 1, false));
SimilarAsinPerfFixture.EndToEndMetrics metrics =
fixture.endToEndBenchmark("uploads/20260829/e2e-max.xlsx", 5000, true, 200);
assertTrue(metrics.assembleMillis() < 15000,
"5000 行端到端基准须在预算内完成,实际=" + metrics.assembleMillis() + "ms");
assertTrue(metrics.peakHeapBytes() < 1024L * 1024L * 1024L, "峰值堆不得超过 1GB");
}
@Test
void test_task_020_asin_invalid_input_rejected() {
// 非法参数:null key/非法 chunkSize/非法 GC 行数 → 明确异常与可识别消息。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
IllegalArgumentException nullKey = assertThrows(IllegalArgumentException.class,
() -> fixture.endToEndBenchmark(null, 100, false, 200));
assertTrue(nullKey.getMessage().contains("sourceFileKey"));
assertThrows(IllegalArgumentException.class,
() -> fixture.endToEndBenchmark("uploads/20260829/x.xlsx", 100, false, 0));
assertThrows(IllegalArgumentException.class,
() -> fixture.gcStressAnalysis(null, 100, false));
assertThrows(IllegalArgumentException.class,
() -> fixture.gcStressAnalysis("uploads/20260829/x.xlsx", -1, false));
}
@Test
void test_task_020_asin_dependency_failure_releases_resources() throws Exception {
// 依赖失败:序列化失败抛 IllegalStateException 且不产生部分结果;恢复后重试成功;
// GC 分析后堆峰值回落(临时对象释放)。
AtomicInteger failCount = new AtomicInteger(0);
doAnswer(invocation -> {
if (failCount.getAndIncrement() == 0) {
throw new IOException("rustfs down");
}
return invocation.callRealMethod();
}).when(objectMapper).writeValueAsBytes(any());
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
assertThrows(IllegalStateException.class,
() -> fixture.endToEndBenchmark("uploads/20260829/e2e-fail.xlsx", 100, false, 200));
SimilarAsinPerfFixture.EndToEndMetrics recovered =
fixture.endToEndBenchmark("uploads/20260829/e2e-fail.xlsx", 100, false, 200);
assertEquals(100, recovered.rowCount(), "依赖恢复后重试成功");
SimilarAsinPerfFixture.GcStressSample gc =
fixture.gcStressAnalysis("uploads/20260829/e2e-gc.xlsx", 1000, true);
assertNotNull(gc);
assertTrue(gc.rounds() >= 1, "GC 分析至少执行一轮");
assertTrue(gc.gcCount() >= 0);
assertTrue(gc.peakHeapBytes() > 0, "堆峰值必须可采样");
assertTrue(gc.peakHeapBytes() < 1024L * 1024L * 1024L, "GC 分析峰值堆不得超过 1GB");
}
}