From 9166656673e19c5967b126826fcb585b8a471630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Mon, 14 Sep 2026 11:21:40 +0800 Subject: [PATCH] =?UTF-8?q?perf(C6):=20=E5=8E=BB=E9=87=8D=E6=80=BB?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=88=97=E8=A1=A8=E9=A1=BA=E5=BA=8F=E7=BF=BB?= =?UTF-8?q?=E9=A1=B5=E6=94=B9=20keyset=EF=BC=88=E5=89=8D=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E4=B8=80=E8=B5=B7=E6=94=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:列表页 `ORDER BY id DESC LIMIT offset,size`,带筛选且选择性低时每页都要 对命中集做一次 filesort;深翻页 offset 也白扫索引。 改法(保持跳页/回退/改每页的原有行为): - 后端:page 接口新增可选 `last_id` 游标 —— 传了就 `id < lastId` + `LIMIT size`(无 offset), 响应新增 `nextLastId`(本页最后一行 id);不传仍是原 OFFSET 分页 - 管理前端:只有"下一页"用游标(上一页响应带回),跳页/改每页/筛选清空游标走 OFFSET - 测试:新增 2 个后端契约测试(keyset 无 offset + nextLastId;无游标保持 LIMIT 30,15) 验证:mvn test 2897 全绿;admin-frontend-vue vue-tsc 通过 + 1619 测试全绿; 已部署 JAR 2b9ab774c60c3f5d802c9a2cee549008(双节点 health=200)与 admin-vue-20260914-112055。 --- .../src/pages/asin/DedupeRegistryPage.vue | 17 +++++- .../src/pages/asin/asin-filter.ts | 5 ++ .../src/pages/asin/dedupe-total-filter.ts | 2 + .../src/pages/asin/dedupe-total-model.ts | 4 ++ .../controller/DedupeTotalDataController.java | 4 +- .../model/vo/DedupeTotalDataPageVo.java | 5 ++ .../service/DedupeTotalDataService.java | 20 ++++++- .../service/DedupeTotalDataServiceTest.java | 52 ++++++++++++++++++- 8 files changed, 105 insertions(+), 4 deletions(-) diff --git a/admin-frontend-vue/src/pages/asin/DedupeRegistryPage.vue b/admin-frontend-vue/src/pages/asin/DedupeRegistryPage.vue index c1757a4c..abc397c5 100644 --- a/admin-frontend-vue/src/pages/asin/DedupeRegistryPage.vue +++ b/admin-frontend-vue/src/pages/asin/DedupeRegistryPage.vue @@ -43,6 +43,10 @@ const lockedGroupId = computed(() => { return groups.value.length === 1 ? groups.value[0].id : null }) const jumpPage = ref('') +/** 顺序翻页游标:上一页响应给的 nextLastId;仅在"下一页"时使用,跳页/筛选时清空走 OFFSET。 */ +const pageCursor = ref(null) +/** 本次请求实际使用的游标(load 时决定) */ +let pendingCursor: number | null = null const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) @@ -85,10 +89,15 @@ function stopExportWait(): void { async function load(): Promise { loading.value = true try { - const result = await fetchDedupeTotalList(toDedupeListParams(filter, page.value, pageSize.value)) + const result = await fetchDedupeTotalList( + toDedupeListParams(filter, page.value, pageSize.value, pendingCursor), + ) rows.value = result.items total.value = result.total if (result.page >= 1) page.value = result.page + // 本页响应回传的游标留作"下一页"用;空页则清空(没有更多) + pageCursor.value = result.nextLastId ?? null + pendingCursor = null } catch (error) { ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败') } finally { @@ -98,11 +107,15 @@ async function load(): Promise { function apply(): void { page.value = 1 + pageCursor.value = null + pendingCursor = null void load() } function changePage(next: number): void { if (next < 1 || next > totalPages.value) return + // 只有"顺序下一页"用 keyset 游标(避免深分页 offset);跳页/回退走 OFFSET,行为不变 + pendingCursor = next === page.value + 1 ? pageCursor.value : null page.value = next void load() } @@ -119,6 +132,8 @@ function goJump(): void { function changeSize(size: number) { pageSize.value = size page.value = 1 + pageCursor.value = null + pendingCursor = null void load() } diff --git a/admin-frontend-vue/src/pages/asin/asin-filter.ts b/admin-frontend-vue/src/pages/asin/asin-filter.ts index f4bfb41c..d96dcd93 100644 --- a/admin-frontend-vue/src/pages/asin/asin-filter.ts +++ b/admin-frontend-vue/src/pages/asin/asin-filter.ts @@ -24,6 +24,8 @@ export interface AsinListParams { groupId?: number | null /** 国家代码(如 DE、UK)。 */ country?: string + /** 顺序翻页游标(上一页返回的 nextLastId):传了就忽略 page 偏移,走 keyset。 */ + lastId?: number } /** 序列化到 Java 分页接口的查询参数(snake_case)。 */ @@ -36,6 +38,8 @@ export interface AsinPageQuery { end_date?: string group_id?: number country?: string + /** 顺序翻页游标(keyset):传了就忽略 page 偏移。 */ + last_id?: number } function finiteInt(value: unknown): number | null { @@ -77,5 +81,6 @@ export function toAsinPageQuery(params: AsinListParams): AsinPageQuery { if (params.endDate) query.end_date = params.endDate if (params.groupId != null) query.group_id = params.groupId if (params.country) query.country = params.country + if (typeof params.lastId === 'number' && params.lastId > 0) query.last_id = params.lastId return query } diff --git a/admin-frontend-vue/src/pages/asin/dedupe-total-filter.ts b/admin-frontend-vue/src/pages/asin/dedupe-total-filter.ts index 2e5583fe..d6756d84 100644 --- a/admin-frontend-vue/src/pages/asin/dedupe-total-filter.ts +++ b/admin-frontend-vue/src/pages/asin/dedupe-total-filter.ts @@ -30,8 +30,10 @@ export function toDedupeListParams( state: DedupeTotalFilterState, page: number, pageSize: number, + lastId?: number | null, ): AsinListParams { const params: AsinListParams = { page, pageSize } + if (typeof lastId === 'number' && lastId > 0) params.lastId = lastId const keyword = (state.keyword || '').trim() const username = (state.username || '').trim() const country = (state.country || '').trim() diff --git a/admin-frontend-vue/src/pages/asin/dedupe-total-model.ts b/admin-frontend-vue/src/pages/asin/dedupe-total-model.ts index 8d3f5cf6..ba9354d9 100644 --- a/admin-frontend-vue/src/pages/asin/dedupe-total-model.ts +++ b/admin-frontend-vue/src/pages/asin/dedupe-total-model.ts @@ -18,6 +18,8 @@ export interface DedupeTotalPageResult { total: number page: number pageSize: number + /** 顺序翻页游标:本页最后一行 id;下一页回传它即可走 keyset。 */ + nextLastId?: number } export function emptyDedupeTotalPage(): DedupeTotalPageResult { @@ -65,6 +67,8 @@ export function parseDedupeTotalPage(payload: unknown): DedupeTotalPageResult { } if (typeof record.total === 'number') out.total = Math.floor(record.total) if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page) + const rawNextLastId = record.nextLastId ?? record.next_last_id + if (typeof rawNextLastId === 'number' && rawNextLastId > 0) out.nextLastId = Math.floor(rawNextLastId) const rawSize = record.pageSize ?? record.page_size if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize) return out diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataController.java index cfc01900..c30a177e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataController.java @@ -88,10 +88,12 @@ public class DedupeTotalDataController { @RequestParam(name = "end_date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, @Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId, @Parameter(description = "国家代码(如 DE、UK)") @RequestParam(name = "country", required = false) String country, + @Parameter(description = "顺序翻页游标(上一页返回的 nextLastId;传了就忽略 page 偏移)") + @RequestParam(name = "last_id", required = false) Long lastId, HttpServletRequest request) { RequestOperator operator = requireDedupeTotalDataAccess(request); return ApiResponse.success(dedupeTotalDataService.page( - page, pageSize, keyword, username, startDate, endDate, groupId, country, operator.id())); + page, pageSize, keyword, username, startDate, endDate, groupId, country, lastId, operator.id())); } @GetMapping("/export") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataPageVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataPageVo.java index 6bc2ca78..2ef17b3e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataPageVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataPageVo.java @@ -20,4 +20,9 @@ public class DedupeTotalDataPageVo { @Schema(description = "每页数量") private Long pageSize; + /** + * 顺序翻页游标(2026-09 审查 C6):本页最后一行的 id;下一页把它回传为 last_id + * 即可走 keyset(索引顺序扫描 + LIMIT),避免深分页的 offset 代价。跳页仍用 page。 + */ + private Long nextLastId; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java index 34410309..06d0248b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java @@ -156,6 +156,16 @@ public class DedupeTotalDataService { public DedupeTotalDataPageVo page(long page, long pageSize, String keyword, String username, LocalDate startDate, LocalDate endDate, Long groupId, String country, Long operatorId) { + return page(page, pageSize, keyword, username, startDate, endDate, groupId, country, null, operatorId); + } + + /** + * @param lastId keyset 游标(上一页返回的 nextLastId);非空时忽略 page 偏移, + * 直接按 id 倒序取"比它小"的一页,避免深分页 offset 扫索引的代价 + */ + public DedupeTotalDataPageVo page(long page, long pageSize, String keyword, String username, + LocalDate startDate, LocalDate endDate, Long groupId, String country, + Long lastId, Long operatorId) { if (startDate != null && endDate != null && startDate.isAfter(endDate)) { throw new BusinessException("开始日期不能晚于结束日期"); } @@ -181,8 +191,14 @@ public class DedupeTotalDataService { .orderByDesc(DedupeTotalDataEntity::getId); applyGroupScope(query, scope, groupId); Long total = dedupeTotalDataMapper.selectCount(query); + boolean keyset = lastId != null && lastId > 0; + if (keyset) { + query.lt(DedupeTotalDataEntity::getId, lastId); + } List rows = dedupeTotalDataMapper.selectList( - query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize)); + keyset + ? query.last("LIMIT " + safePageSize) + : query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize)); Map groupNames = loadGroupNames(rows); List items = rows.stream() .map(row -> toItemVo(row, row.getGroupId() == null @@ -194,6 +210,8 @@ public class DedupeTotalDataService { vo.setTotal(total); vo.setPage(safePage); vo.setPageSize(safePageSize); + // 顺序翻页游标:本页最后一行的 id(空页给 null,前端据此停止换页) + vo.setNextLastId(items.isEmpty() ? null : items.get(items.size() - 1).getId()); return vo; } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java index e48549d8..59991094 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java @@ -43,6 +43,7 @@ import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -632,4 +633,53 @@ class DedupeTotalDataServiceTest { output.toByteArray()); } } -} + @Test + void pageWithLastIdUsesKeysetAndReturnsNextCursor() { + // 2026-09 审查 C6:顺序翻页走 keyset(id < lastId + LIMIT n,无 offset) + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + DedupeTotalDataEntity.class); + when(adminUserMapper.selectById(1L)).thenReturn(user(1L, "super_admin", "root")); + when(dedupeTotalDataMapper.selectCount(any())).thenReturn(0L); + List rows = new ArrayList<>(); + DedupeTotalDataEntity first = new DedupeTotalDataEntity(); + first.setId(900L); + first.setDataValue("B0ROW900"); + DedupeTotalDataEntity second = new DedupeTotalDataEntity(); + second.setId(880L); + second.setDataValue("B0ROW880"); + rows.add(first); + rows.add(second); + when(dedupeTotalDataMapper.selectList(any())).thenReturn(rows); + + DedupeTotalDataPageVo vo = service.page(3, 15, "", "", null, null, null, null, 1000L, 1L); + + ArgumentCaptor> queryCaptor = + ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class); + verify(dedupeTotalDataMapper).selectList(queryCaptor.capture()); + LambdaQueryWrapper query = queryCaptor.getValue(); + assertTrue(query.getSqlSegment().contains("id <"), "keyset 条件应为主键范围: " + query.getSqlSegment()); + assertTrue(query.getCustomSqlSegment().contains("LIMIT 15"), "keyset 分页不带 offset: " + query.getCustomSqlSegment()); + assertFalse(query.getCustomSqlSegment().contains(","), "keyset 不应出现 offset 逗号: " + query.getCustomSqlSegment()); + assertEquals(880L, vo.getNextLastId(), "nextLastId 取本页最后一行 id"); + } + + @Test + void pageWithoutLastIdKeepsOffsetPaging() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + DedupeTotalDataEntity.class); + when(adminUserMapper.selectById(1L)).thenReturn(user(1L, "super_admin", "root")); + when(dedupeTotalDataMapper.selectCount(any())).thenReturn(0L); + when(dedupeTotalDataMapper.selectList(any())).thenReturn(new ArrayList<>()); + + DedupeTotalDataPageVo vo = service.page(3, 15, "", "", null, null, null, null, 1L); + + ArgumentCaptor> queryCaptor = + ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class); + verify(dedupeTotalDataMapper).selectList(queryCaptor.capture()); + assertTrue(queryCaptor.getValue().getCustomSqlSegment().contains("LIMIT 30, 15"), + "无游标时保持原有 offset 分页: " + queryCaptor.getValue().getCustomSqlSegment()); + assertNull(vo.getNextLastId(), "空页无游标"); + } +} \ No newline at end of file