perf(C6): 去重总数据列表顺序翻页改 keyset(前后端契约一起改)

背景:列表页 `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。
This commit is contained in:
2026-09-14 11:21:40 +08:00
parent 1360a44e01
commit 9166656673
8 changed files with 105 additions and 4 deletions
@@ -43,6 +43,10 @@ const lockedGroupId = computed<number | null>(() => {
return groups.value.length === 1 ? groups.value[0].id : null return groups.value.length === 1 ? groups.value[0].id : null
}) })
const jumpPage = ref('') const jumpPage = ref('')
/** 顺序翻页游标:上一页响应给的 nextLastId;仅在"下一页"时使用,跳页/筛选时清空走 OFFSET。 */
const pageCursor = ref<number | null>(null)
/** 本次请求实际使用的游标(load 时决定) */
let pendingCursor: number | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
@@ -85,10 +89,15 @@ function stopExportWait(): void {
async function load(): Promise<void> { async function load(): Promise<void> {
loading.value = true loading.value = true
try { 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 rows.value = result.items
total.value = result.total total.value = result.total
if (result.page >= 1) page.value = result.page if (result.page >= 1) page.value = result.page
// 本页响应回传的游标留作"下一页"用;空页则清空(没有更多)
pageCursor.value = result.nextLastId ?? null
pendingCursor = null
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败') ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败')
} finally { } finally {
@@ -98,11 +107,15 @@ async function load(): Promise<void> {
function apply(): void { function apply(): void {
page.value = 1 page.value = 1
pageCursor.value = null
pendingCursor = null
void load() void load()
} }
function changePage(next: number): void { function changePage(next: number): void {
if (next < 1 || next > totalPages.value) return if (next < 1 || next > totalPages.value) return
// 只有"顺序下一页"用 keyset 游标(避免深分页 offset);跳页/回退走 OFFSET,行为不变
pendingCursor = next === page.value + 1 ? pageCursor.value : null
page.value = next page.value = next
void load() void load()
} }
@@ -119,6 +132,8 @@ function goJump(): void {
function changeSize(size: number) { function changeSize(size: number) {
pageSize.value = size pageSize.value = size
page.value = 1 page.value = 1
pageCursor.value = null
pendingCursor = null
void load() void load()
} }
@@ -24,6 +24,8 @@ export interface AsinListParams {
groupId?: number | null groupId?: number | null
/** 国家代码(如 DE、UK)。 */ /** 国家代码(如 DE、UK)。 */
country?: string country?: string
/** 顺序翻页游标(上一页返回的 nextLastId):传了就忽略 page 偏移,走 keyset。 */
lastId?: number
} }
/** 序列化到 Java 分页接口的查询参数(snake_case)。 */ /** 序列化到 Java 分页接口的查询参数(snake_case)。 */
@@ -36,6 +38,8 @@ export interface AsinPageQuery {
end_date?: string end_date?: string
group_id?: number group_id?: number
country?: string country?: string
/** 顺序翻页游标(keyset):传了就忽略 page 偏移。 */
last_id?: number
} }
function finiteInt(value: unknown): number | null { 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.endDate) query.end_date = params.endDate
if (params.groupId != null) query.group_id = params.groupId if (params.groupId != null) query.group_id = params.groupId
if (params.country) query.country = params.country if (params.country) query.country = params.country
if (typeof params.lastId === 'number' && params.lastId > 0) query.last_id = params.lastId
return query return query
} }
@@ -30,8 +30,10 @@ export function toDedupeListParams(
state: DedupeTotalFilterState, state: DedupeTotalFilterState,
page: number, page: number,
pageSize: number, pageSize: number,
lastId?: number | null,
): AsinListParams { ): AsinListParams {
const params: AsinListParams = { page, pageSize } const params: AsinListParams = { page, pageSize }
if (typeof lastId === 'number' && lastId > 0) params.lastId = lastId
const keyword = (state.keyword || '').trim() const keyword = (state.keyword || '').trim()
const username = (state.username || '').trim() const username = (state.username || '').trim()
const country = (state.country || '').trim() const country = (state.country || '').trim()
@@ -18,6 +18,8 @@ export interface DedupeTotalPageResult {
total: number total: number
page: number page: number
pageSize: number pageSize: number
/** 顺序翻页游标:本页最后一行 id;下一页回传它即可走 keyset。 */
nextLastId?: number
} }
export function emptyDedupeTotalPage(): DedupeTotalPageResult { 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.total === 'number') out.total = Math.floor(record.total)
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page) 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 const rawSize = record.pageSize ?? record.page_size
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize) if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
return out return out
@@ -88,10 +88,12 @@ public class DedupeTotalDataController {
@RequestParam(name = "end_date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, @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 = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "国家代码(如 DE、UK") @RequestParam(name = "country", required = false) String country, @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) { HttpServletRequest request) {
RequestOperator operator = requireDedupeTotalDataAccess(request); RequestOperator operator = requireDedupeTotalDataAccess(request);
return ApiResponse.success(dedupeTotalDataService.page( 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") @GetMapping("/export")
@@ -20,4 +20,9 @@ public class DedupeTotalDataPageVo {
@Schema(description = "每页数量") @Schema(description = "每页数量")
private Long pageSize; private Long pageSize;
/**
* 顺序翻页游标(2026-09 审查 C6):本页最后一行的 id;下一页把它回传为 last_id
* 即可走 keyset(索引顺序扫描 + LIMIT),避免深分页的 offset 代价。跳页仍用 page。
*/
private Long nextLastId;
} }
@@ -156,6 +156,16 @@ public class DedupeTotalDataService {
public DedupeTotalDataPageVo page(long page, long pageSize, String keyword, String username, public DedupeTotalDataPageVo page(long page, long pageSize, String keyword, String username,
LocalDate startDate, LocalDate endDate, Long groupId, String country, Long operatorId) { 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)) { if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
throw new BusinessException("开始日期不能晚于结束日期"); throw new BusinessException("开始日期不能晚于结束日期");
} }
@@ -181,8 +191,14 @@ public class DedupeTotalDataService {
.orderByDesc(DedupeTotalDataEntity::getId); .orderByDesc(DedupeTotalDataEntity::getId);
applyGroupScope(query, scope, groupId); applyGroupScope(query, scope, groupId);
Long total = dedupeTotalDataMapper.selectCount(query); Long total = dedupeTotalDataMapper.selectCount(query);
boolean keyset = lastId != null && lastId > 0;
if (keyset) {
query.lt(DedupeTotalDataEntity::getId, lastId);
}
List<DedupeTotalDataEntity> rows = dedupeTotalDataMapper.selectList( List<DedupeTotalDataEntity> rows = dedupeTotalDataMapper.selectList(
query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize)); keyset
? query.last("LIMIT " + safePageSize)
: query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
Map<Long, String> groupNames = loadGroupNames(rows); Map<Long, String> groupNames = loadGroupNames(rows);
List<DedupeTotalDataItemVo> items = rows.stream() List<DedupeTotalDataItemVo> items = rows.stream()
.map(row -> toItemVo(row, row.getGroupId() == null .map(row -> toItemVo(row, row.getGroupId() == null
@@ -194,6 +210,8 @@ public class DedupeTotalDataService {
vo.setTotal(total); vo.setTotal(total);
vo.setPage(safePage); vo.setPage(safePage);
vo.setPageSize(safePageSize); vo.setPageSize(safePageSize);
// 顺序翻页游标:本页最后一行的 id(空页给 null,前端据此停止换页)
vo.setNextLastId(items.isEmpty() ? null : items.get(items.size() - 1).getId());
return vo; return vo;
} }
@@ -43,6 +43,7 @@ import java.util.Set;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -632,4 +633,53 @@ class DedupeTotalDataServiceTest {
output.toByteArray()); output.toByteArray());
} }
} }
@Test
void pageWithLastIdUsesKeysetAndReturnsNextCursor() {
// 2026-09 审查 C6:顺序翻页走 keysetid < 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<DedupeTotalDataEntity> 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<LambdaQueryWrapper<DedupeTotalDataEntity>> queryCaptor =
ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class);
verify(dedupeTotalDataMapper).selectList(queryCaptor.capture());
LambdaQueryWrapper<DedupeTotalDataEntity> 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<LambdaQueryWrapper<DedupeTotalDataEntity>> 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(), "空页无游标");
}
} }