task-20: Similar ASIN 端到端压测、GC 分析与结果文件兼容回归
SimilarAsinPerfFixture 新增三个功能点: - endToEndBenchmark:生成→分 chunk→序列化,记录行数/chunk 数/payload 字节/耗时/吞吐/峰值堆; - gcStressAnalysis:多轮生成/序列化/释放循环,采样 GC 计数差与堆峰值; - compatRoundTrip:payload 序列化往返恢复全量行并校验字段稳定(结果文件兼容回归)。 全量测试 508 通过。
This commit is contained in:
+135
@@ -1,14 +1,17 @@
|
|||||||
package com.nanri.aiimage.modules.similarasin.util;
|
package com.nanri.aiimage.modules.similarasin.util;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.lang.management.ManagementFactory;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Similar ASIN 性能基线夹具:生成 1000/5000 行解析数据、图片开关两种模式、
|
* Similar ASIN 性能基线夹具:生成 1000/5000 行解析数据、图片开关两种模式、
|
||||||
@@ -20,6 +23,7 @@ public class SimilarAsinPerfFixture {
|
|||||||
|
|
||||||
public static final int MAX_ROWS = 5000;
|
public static final int MAX_ROWS = 5000;
|
||||||
public static final int DEFAULT_CHUNK_SIZE = 200;
|
public static final int DEFAULT_CHUNK_SIZE = 200;
|
||||||
|
private static final int GC_ROUNDS = 10;
|
||||||
|
|
||||||
private static final String[] COUNTRIES = {"英国", "德国", "法国", "意大利", "西班牙"};
|
private static final String[] COUNTRIES = {"英国", "德国", "法国", "意大利", "西班牙"};
|
||||||
private static final String[] TITLES = {
|
private static final String[] TITLES = {
|
||||||
@@ -126,6 +130,137 @@ public class SimilarAsinPerfFixture {
|
|||||||
public record Metrics(int rowCount, int chunkCount, long payloadBytes) {
|
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) {
|
private static String deterministicAsin(long seed) {
|
||||||
StringBuilder sb = new StringBuilder("B0");
|
StringBuilder sb = new StringBuilder("B0");
|
||||||
long state = seed;
|
long state = seed;
|
||||||
|
|||||||
+172
@@ -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 20:Similar ASIN 端到端压测、JFR/GC 分析与结果文件兼容回归。
|
||||||
|
* SimilarAsinPerfFixture 新增三个功能点:
|
||||||
|
* - endToEndBenchmark:生成 → 分 chunk → 序列化 → 计时 → 吞吐与峰值堆采样;
|
||||||
|
* - gcStressAnalysis:多轮生成/释放循环采样 GC 计数与堆峰值;
|
||||||
|
* - compatRoundTrip:payload 序列化往返恢复全量行并校验字段稳定(结果文件兼容回归)。
|
||||||
|
*/
|
||||||
|
@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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user