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
@@ -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")
@@ -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;
}
@@ -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<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);
List<DedupeTotalDataItemVo> 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;
}