从原理到实战:基于游标分页解决对账场景的深分页问题
一、背景:对账场景下的深分页痛点
在财务对账系统的开发中,拉取全量数据是高频操作------比如每日交易流水对账、跨平台账单核对、月度财务结算等场景,往往需要从数据库中查询数万甚至数十万条数据。此时传统的"页码+页大小"分页(Offset-Limit 分页)会暴露严重的性能问题:
- 深分页性能暴跌 :当分页页码较大时(如查询第1000页,每页20条),数据库执行
SELECT * FROM table LIMIT 20 OFFSET 20000时,需要先扫描前20000条数据并舍弃,只返回最后20条,数据量越大,扫描耗时越长; - 数据一致性风险:分页过程中如果数据发生新增/删除,会导致后续页码的数据重复或遗漏(比如第1页的最后一条数据被删除,第2页的第一条数据会变成第1页本该展示的最后一条);
- 对账业务稳定性差:大量深分页查询会占用数据库连接池、消耗IO资源,甚至引发慢查询,影响对账任务的时效性。
而游标分页(Cursor Pagination) 正是解决这些问题的最优方案,尤其适配对账场景"全量拉取、数据量大、要求精准"的核心诉求。
二、游标分页 vs 普通分页:核心原理对比
1. 普通分页(Offset-Limit)
- 核心逻辑 :基于"偏移量"定位,通过
OFFSET跳过指定数量的行,LIMIT限制返回行数; - 适用场景:数据量小、允许页码跳转(如用户翻页查看商品列表);
- 核心问题 :
- 性能:
OFFSET是"逻辑偏移",数据库需扫描并丢弃前N行,深分页时性能线性下降; - 一致性:依赖数据的物理位置,数据变动会导致分页结果错乱;
- 效率:全量拉取时需循环递增
OFFSET,多次查询的耗时随页码指数级增加。
- 性能:
2. 游标分页
- 核心逻辑 :基于"唯一有序标识"(游标)定位,查询条件为"大于/小于上一页最后一条数据的游标值",搭配
LIMIT限制页大小; - 核心前提 :必须有唯一且有序的字段(如主键ID、创建时间+ID)作为游标,且查询需按该字段排序;
- 优势 :
- 性能:直接通过游标字段的索引定位,无需扫描前N行,查询效率稳定(O(1) 级别);
- 一致性:依赖游标值而非物理位置,数据变动仅影响当前游标后的结果,无重复/遗漏;
- 效率:全量拉取时仅需记录上一页最后一条的游标值,循环查询的耗时稳定。
原理对比表
| 维度 | 普通分页(Offset-Limit) | 游标分页 |
|---|---|---|
| 定位方式 | 偏移量(跳过N行) | 游标值(大于/小于某值) |
| 性能 | 深分页时暴跌 | 全场景稳定(依赖索引) |
| 数据一致性 | 数据变动易错乱 | 数据变动不影响已查结果 |
| 适用场景 | 小数据量、支持页码跳转 | 大数据量、全量拉取 |
| 核心依赖 | 无特殊依赖 | 唯一有序的游标字段+索引 |
三、实战落地:通用游标分页工具类解析
以下是适配对账场景的通用游标分页工具类实现,解决了"强制游标字段校验、自动适配排序字段、兼容多返回类型"等核心问题,先贴完整代码,再逐模块解析:
1. 游标分页核心工具类(PaginationUtil)
java
package com.guazi.scmp.reconciliation.utils;
import com.guazi.scmp.common.sdk.api.response.PagedListResponse;
import org.springframework.beans.BeanUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Function;
/**
* 通用分页工具类
* 核心特性:
* 1. 强制要求传递游标参数字段名(cursorParamName),无默认值,避免歧义
* 2. 游标取值字段自动等于排序字段(sidx),避免数据遗漏
* 3. 增强字段校验,提前抛出明确异常,便于问题定位
* 4. 完善条件复制逻辑,保留原始业务参数
* 5. 适配页码分页+游标分页混合场景
*
* @author xieyuhang
*/
public class PaginationUtil {
// ==================== 常量配置 ====================
/** 默认每页查询条数(避免一次性查大量数据) */
public static final int DEFAULT_PAGE_SIZE = 200;
/** 默认游标取值字段(表主键ID,仅当sidx为空时兜底) */
public static final String DEFAULT_CURSOR_FIELD = "id";
/** 默认排序方向(降序) */
public static final String DEFAULT_ORDER = "DESC";
/** 排序字段名(业务查询类的sidx) */
private static final String SORT_FIELD_NAME = "sidx";
// ==================== 核心方法:循环游标分页查询全量数据(适配普通List) ====================
/**
* 循环游标分页查询全量数据(适配普通List返回,强制传游标参数字段名)
* @param initCondition 初始查询条件
* @param pageSize 每页条数
* @param cursorParamName 【必填】查询类中用于传递游标值的字段名(如cursorId/lastId,需和SQL中的参数名一致)
* @param queryFunction 查询函数
* @return 全量数据
*/
public static <T, C> List<T> queryAllByCursorPaginationForList(
C initCondition,
int pageSize,
String cursorParamName,
Function<C, List<T>> queryFunction) {
// 1. 强制校验游标参数字段名(不能为空)
if (!StringUtils.hasText(cursorParamName)) {
throw new IllegalArgumentException("游标参数字段名(cursorParamName)不能为空,请传递查询类中用于接收游标值的字段名(如cursorId/lastId)");
}
cursorParamName = cursorParamName.trim();
// 2. 分页大小容错兜底
pageSize = pageSize <= 0 ? DEFAULT_PAGE_SIZE : pageSize;
// 3. 从查询条件中提取排序字段作为游标取值字段(保证和排序字段一致)
String cursorField = getSortFieldFromCondition(initCondition);
List<T> allData = new ArrayList<>();
Object lastCursor = null;
boolean hasMore = true;
while (hasMore) {
try {
// 4. 复制原始条件(保留业务参数,避免修改原对象)
C currentCondition = copyQueryCondition(initCondition);
// 5. 提前校验游标参数字段是否存在于查询类中
checkCursorParamFieldExist(currentCondition, cursorParamName);
// 6. 设置游标参数(根据传入的字段名动态设置)
setCursorParam(currentCondition, lastCursor, cursorParamName);
// 7. 设置分页参数(容错处理,只设置存在的字段)
setPageParam(currentCondition, 1, pageSize, 0);
// 8. 执行查询
List<T> currentPageData = queryFunction.apply(currentCondition);
allData.addAll(currentPageData);
// 9. 判断是否还有下一页,更新游标值
hasMore = currentPageData.size() == pageSize;
if (hasMore && !CollectionUtils.isEmpty(currentPageData)) {
lastCursor = getLastCursor(currentPageData, cursorField);
}
} catch (IllegalArgumentException e) {
// 校验异常直接抛出,便于定位问题
throw e;
} catch (Exception e) {
throw new RuntimeException("循环分页查询全量数据失败,游标参数字段名:" + cursorParamName, e);
}
}
return allData;
}
// ==================== 核心方法:适配PagedListResponse返回(强制传游标参数字段名) ====================
/**
* 循环游标分页查询全量数据(适配PagedListResponse返回,强制传游标参数字段名)
* @param initCondition 初始查询条件
* @param pageSize 每页条数
* @param cursorParamName 【必填】查询类中用于传递游标值的字段名(如cursorId/lastId,需和SQL中的参数名一致)
* @param queryFunction 查询函数
* @return 全量数据
*/
public static <T, C> List<T> queryAllByCursorPaginationForPagedResponse(
C initCondition,
int pageSize,
String cursorParamName,
Function<C, PagedListResponse<T>> queryFunction) {
return queryAllByCursorPaginationForList(
initCondition,
pageSize,
cursorParamName,
condition -> {
PagedListResponse<T> response = queryFunction.apply(condition);
return response == null ? Collections.emptyList() : response.getList();
}
);
}
// ==================== 私有工具方法 ====================
/**
* 从查询条件中提取排序字段(sidx),作为游标取值字段
* 优先级:查询条件中的sidx > 默认游标取值字段(id)
*/
private static <C> String getSortFieldFromCondition(C condition) {
if (condition == null) {
return DEFAULT_CURSOR_FIELD;
}
try {
Field sidxField = ReflectionUtils.findField(condition.getClass(), SORT_FIELD_NAME);
if (sidxField == null) {
return DEFAULT_CURSOR_FIELD;
}
ReflectionUtils.makeAccessible(sidxField);
Object sidxValue = ReflectionUtils.getField(sidxField, condition);
return StringUtils.hasText(sidxValue != null ? sidxValue.toString() : null)
? sidxValue.toString().trim()
: DEFAULT_CURSOR_FIELD;
} catch (Exception e) {
// 反射异常时兜底使用默认值
return DEFAULT_CURSOR_FIELD;
}
}
/**
* 获取列表最后一条数据的游标取值字段值
*/
@SuppressWarnings("unchecked")
private static <T> Object getLastCursor(List<T> dataList, String cursorField) {
if (CollectionUtils.isEmpty(dataList)) {
return null;
}
T lastData = dataList.get(dataList.size() - 1);
try {
Field field = ReflectionUtils.findField(lastData.getClass(), cursorField);
if (field == null) {
throw new IllegalArgumentException("数据实体类中不存在游标取值字段:" + cursorField + ",请检查排序字段(sidx)是否正确");
}
ReflectionUtils.makeAccessible(field);
return field.get(lastData);
} catch (IllegalAccessException e) {
throw new RuntimeException("无法访问数据实体类的游标取值字段:" + cursorField, e);
}
}
/**
* 复制查询条件对象(避免修改原始条件)
*/
private static <C> C copyQueryCondition(C condition) {
if (condition == null) {
return null;
}
try {
C newCondition = (C) condition.getClass().getDeclaredConstructor().newInstance();
BeanUtils.copyProperties(condition, newCondition);
return newCondition;
} catch (Exception e) {
throw new RuntimeException("复制查询条件失败,请确保查询类有无参构造函数", e);
}
}
/**
* 校验查询类中是否存在指定的游标参数字段
*/
private static <C> void checkCursorParamFieldExist(C condition, String cursorParamName) {
if (condition == null) {
return;
}
Field cursorField = ReflectionUtils.findField(condition.getClass(), cursorParamName);
if (cursorField == null) {
throw new IllegalArgumentException("查询类[" + condition.getClass().getName() + "]中不存在游标参数字段:" + cursorParamName + ",请检查字段名是否和SQL参数一致");
}
}
/**
* 动态设置游标参数(根据传入的字段名)
*/
private static <C> void setCursorParam(C condition, Object cursor, String cursorParamName) {
if (condition == null || cursor == null) {
return;
}
try {
Field cursorField = ReflectionUtils.findField(condition.getClass(), cursorParamName);
ReflectionUtils.makeAccessible(cursorField);
// 兼容数值类型转换(Long/Integer)
if (cursor instanceof Number) {
if (cursorField.getType() == Integer.class) {
cursorField.set(condition, ((Number) cursor).intValue());
} else if (cursorField.getType() == Long.class) {
cursorField.set(condition, ((Number) cursor).longValue());
} else {
cursorField.set(condition, cursor);
}
} else {
cursorField.set(condition, cursor);
}
} catch (IllegalAccessException e) {
throw new RuntimeException("设置游标参数[" + cursorParamName + "]失败,请检查字段访问权限", e);
}
}
/**
* 设置分页参数(容错处理,只设置存在的字段)
*/
private static <C> void setPageParam(C condition, int currentPage, int pageSize, int startIndex) {
if (condition == null) {
return;
}
// 1. 设置当前页(仅当字段存在时)
Field currentPageField = ReflectionUtils.findField(condition.getClass(), "currentPage");
if (currentPageField != null) {
ReflectionUtils.makeAccessible(currentPageField);
ReflectionUtils.setField(currentPageField, condition, currentPage);
}
// 2. 设置页大小(仅当字段存在时)
Field pageSizeField = ReflectionUtils.findField(condition.getClass(), "pageSize");
if (pageSizeField != null) {
ReflectionUtils.makeAccessible(pageSizeField);
ReflectionUtils.setField(pageSizeField, condition, pageSize);
}
// 3. 设置起始索引(仅当字段存在时)
Field startIndexField = ReflectionUtils.findField(condition.getClass(), "startIndex");
if (startIndexField != null) {
ReflectionUtils.makeAccessible(startIndexField);
ReflectionUtils.setField(startIndexField, condition, startIndex);
}
// 4. 排序字段:仅当原始值为空时才设置默认值,避免覆盖业务配置
Field sidxField = ReflectionUtils.findField(condition.getClass(), SORT_FIELD_NAME);
if (sidxField != null) {
ReflectionUtils.makeAccessible(sidxField);
Object sidxValue = ReflectionUtils.getField(sidxField, condition);
if (!StringUtils.hasText(sidxValue != null ? sidxValue.toString() : null)) {
ReflectionUtils.setField(sidxField, condition, DEFAULT_CURSOR_FIELD);
}
}
// 5. 排序方向:仅当原始值为空时才设置默认值
Field sordField = ReflectionUtils.findField(condition.getClass(), "sord");
if (sordField != null) {
ReflectionUtils.makeAccessible(sordField);
Object sordValue = ReflectionUtils.getField(sordField, condition);
if (!StringUtils.hasText(sordValue != null ? sordValue.toString() : null)) {
ReflectionUtils.setField(sordField, condition, DEFAULT_ORDER);
}
}
}
}
2. 列表分批工具类(BatchSplitUtil)
java
package com.guazi.scmp.reconciliation.utils;
import java.util.ArrayList;
import java.util.List;
/**
* 列表分批工具类(处理超长ID列表拆分)
* @author: xieyuhang
*/
public class BatchSplitUtil {
/**
* 拆分列表为多个小批次
* @param list 原列表
* @param batchSize 每批大小
* @param <T> 列表元素类型
* @return 分批后的列表
*/
public static <T> List<List<T>> splitList(List<T> list, int batchSize) {
if (list == null || list.isEmpty()) {
return new ArrayList<>();
}
batchSize = Math.max(1, batchSize);
List<List<T>> result = new ArrayList<>();
int totalSize = list.size();
int batchCount = (totalSize + batchSize - 1) / batchSize;
for (int i = 0; i < batchCount; i++) {
int start = i * batchSize;
int end = Math.min((i + 1) * batchSize, totalSize);
result.add(list.subList(start, end));
}
return result;
}
/**
* 拆分列表为默认批次(每批500个)
*/
public static <T> List<List<T>> splitList(List<T> list) {
return splitList(list, 500);
}
}
3. 工具类核心设计解析
(1)核心流程(queryAllByCursorPaginationForList)
是 否 输入初始条件、页大小、游标参数字段名、查询函数 校验游标参数字段名非空 容错处理页大小(默认200) 提取排序字段作为游标取值字段(sidx→id) 初始化全量数据列表、游标值、是否有下一页标识 是否有下一页? 复制查询条件(避免修改原对象) 校验游标参数字段存在性 设置游标参数到查询条件 设置分页参数(currentPage/pageSize等) 执行查询获取当前页数据 将当前页数据加入全量列表 判断是否有下一页(当前页数据量=页大小) 更新游标值(取当前页最后一条的游标字段值) 返回全量数据
(2)核心特性解析
-
强制校验+容错兜底:
- 强制要求传入
cursorParamName(游标参数字段名),避免因字段名歧义导致查询失败; - 页大小为空/负数时兜底为200,避免一次性查询大量数据引发OOM;
- 排序字段
sidx为空时,自动兜底使用id作为游标字段,保证游标唯一性。
- 强制要求传入
-
反射适配多场景:
- 通过反射自动提取查询条件中的
sidx字段作为游标取值字段,保证"排序字段=游标字段",避免数据遗漏; - 动态设置游标参数,兼容
Integer/Long等数值类型转换,适配不同查询类的字段类型; - 仅当查询类中存在
currentPage/pageSize等字段时才设置,兼容不同分页参数规范的查询类。
- 通过反射自动提取查询条件中的
-
数据安全与一致性:
- 每次循环都复制原始查询条件(
copyQueryCondition),避免修改原对象导致的业务参数污染; - 提前校验游标参数字段存在性,抛出明确异常,便于快速定位问题(如字段名写错、SQL参数不匹配)。
- 每次循环都复制原始查询条件(
-
多返回类型适配:
- 提供
queryAllByCursorPaginationForList(适配普通List返回)和queryAllByCursorPaginationForPagedResponse(适配封装后的PagedListResponse)两个方法,覆盖大部分业务查询场景。
- 提供
(3)BatchSplitUtil 辅助作用
对账场景中常需批量处理超长ID列表(如批量查询账单),该工具类将超长列表拆分为每批500条(默认),避免因IN语句过长导致SQL执行失败,与游标分页工具类配合,形成"全量拉取→分批处理"的完整对账数据处理链路。
四、使用示例(对账场景实战)
1. 场景:拉取某商户全量交易流水(深分页风险高)
java
// 1. 定义查询条件
public class TradeBillQuery {
private String merchantId; // 商户ID(业务条件)
private Long cursorId; // 游标参数(对应SQL的cursor_id)
private String sidx; // 排序字段
private String sord; // 排序方向
private Integer currentPage;
private Integer pageSize;
// 无参构造、getter/setter 省略
}
// 2. 业务层使用游标分页工具类
@Service
public class TradeBillService {
@Autowired
private TradeBillMapper tradeBillMapper;
/**
* 拉取某商户全量交易流水
*/
public List<TradeBillDTO> queryAllTradeBill(String merchantId) {
// 初始化查询条件
TradeBillQuery query = new TradeBillQuery();
query.setMerchantId(merchantId);
query.setSidx("id"); // 排序字段=游标字段
query.setSord("DESC");
// 调用游标分页工具类,每页200条,游标参数字段名为cursorId
return PaginationUtil.queryAllByCursorPaginationForList(
query,
200,
"cursorId",
// 查询函数:执行Mapper层的游标分页查询
condition -> tradeBillMapper.queryTradeBillByCursor(condition)
);
}
}
// 3. Mapper层SQL(核心:基于游标字段查询)
<select id="queryTradeBillByCursor" resultType="TradeBillDTO">
SELECT id, merchant_id, amount, trade_time
FROM trade_bill
WHERE merchant_id = #{merchantId}
<if test="cursorId != null">
AND id < #{cursorId} <!-- 降序查询,游标值为上一页最后一条的id -->
</if>
ORDER BY id DESC
LIMIT #{pageSize}
</select>
2. 关键说明
- SQL中必须基于
cursorId(游标参数)做范围查询(id < #{cursorId}),而非OFFSET; - 排序字段必须和游标字段一致(示例中均为
id),保证游标定位的准确性; - 无需关心页码,工具类自动循环查询直到无下一页,最终返回全量数据。
五、总结
1. 核心原理回顾
- 普通分页基于"偏移量",深分页性能差、一致性低;
- 游标分页基于"唯一有序游标字段",通过范围查询定位,性能稳定、数据一致,适配对账等大数据量全量拉取场景。
2. 工具类核心价值
- 封装游标分页的通用逻辑,避免重复开发,降低业务接入成本;
- 增强校验和容错,提前暴露问题,提升对账系统的稳定性;
- 适配多场景(不同返回类型、不同分页参数),覆盖对账业务的核心需求。
3. 落地注意事项
- 游标字段必须加索引(如主键索引、联合索引),否则游标分页的性能优势会丧失;
- 排序方向需和SQL中的范围查询匹配(降序用
<,升序用>); - 全量拉取时页大小建议设置为200-500,平衡查询次数和单次查询数据量。