Spring Data JDBC Scrolling 为何报结果数量不匹配异常?
从堆栈和调试结果看,当前运行的 Spring Data JDBC 实现进入了单对象查询分支,没有按滚动查询执行这个方法,因此报出 expected 1, actual 10。
findFirst10By... 允许查询返回最多 10 条记录,而 queryForObject(...) 要求结果至多一条,两者的要求冲突。将 First10 改成 First1 只能掩盖异常,无法补上滚动查询能力。
Spring Data JDBC 是否支持这种 Scrolling 用法,要看具体版本。问题中没有提供依赖版本,因此还不能判断支持范围,也无法确认升级到哪个版本可以解决。
为什么会被识别为单对象查询?
堆栈中有以下调用:
AbstractJdbcQuery.singleObjectQuery
NamedParameterJdbcTemplate.queryForObject
DataAccessUtils.nullableSingleResult
异常发生在结果数量校验阶段。这里的 expected 1 来自单对象查询执行路径,与每批希望读取多少条记录无关。
你观察到的 AbstractJdbcQuery 分支也能解释这一现象:如果该版本没有识别 Window 的专用分支,也没有在其他位置接管滚动查询,Window<Customer> 就可能落入单对象处理路径。
Window、ScrollPosition 和 WindowIterator 能正常导入,只能说明这些公共 API 可用,不能说明 JDBC 模块已经实现了相应查询。WindowIterator 负责消费窗口、传递后续位置,SQL 中的续查条件和窗口结果仍需存储模块实现。
官方 Spring Data JPA 文档中有与问题类似的 Scrolling 示例。使用时要核对文档所属模块,JPA 的支持情况不能直接套用到 JDBC。
先核对版本和文档
- 确认运行时实际使用的
spring-data-jdbc、spring-data-relational和spring-data-commons版本,检查是否手动覆盖过依赖版本。 - 查阅对应版本的 JDBC 文档,确认是否支持派生查询返回
Window<T>,以及是否支持 keyset scrolling。 - 如果项目由 Spring Boot 管理依赖,优先使用其依赖管理提供的兼容组合,避免只升级
spring-data-commons。 - 如果对应版本没有实现这种查询,改用分页,或自行实现按键续查。
同时继承 ListPagingAndSortingRepository 和 CrudRepository 本身不能解释这次异常。增加或更换基础接口,也不会自动获得缺失的滚动查询实现。
只需分批读取时,改用 Slice
如果每次需要处理 10 条记录,可以使用 Slice<Customer> 和 Pageable。JDBC 官方文档列出了这种派生查询方式,具体支持情况仍应以项目所用版本的文档为准。参考:JDBC Query Methods
Repository 可以改为:
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.ListPagingAndSortingRepository;
public interface CustomerRepository
extends ListPagingAndSortingRepository<Customer, Long>,
CrudRepository<Customer, Long> {
Slice<Customer> findByFirstNameOrderByIdAsc(
String firstName, Pageable pageable);
}
调用代码:
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
Pageable pageable = PageRequest.of(0, 10);
while (true) {
Slice<Customer> slice =
customerRepository.findByFirstNameOrderByIdAsc(
"FIRST_NAME", pageable);
for (Customer customer : slice.getContent()) {
System.out.println(customer);
}
if (!slice.hasNext()) {
break;
}
pageable = slice.nextPageable();
}
方法名中去掉 First10,每批大小由 PageRequest.of(0, 10) 指定。Slice 提供是否还有下一批的信息;如果需要总记录数,可以考虑返回 Page<Customer>。
仅将原方法的返回类型改成 List<Customer>,同时保留 ScrollPosition,不能保证位置参数会生效。普通列表查询仍需要明确的分页或续查条件。
需要按 id 续查时,可以显式表达条件
如果需要按 keyset 思路续查,但当前 JDBC 版本不支持原生 Scrolling,可以用普通派生查询表达从上次最后一个 id 之后继续读取的条件:
List<Customer> findFirst10ByFirstNameOrderByIdAsc(
String firstName);
List<Customer> findFirst10ByFirstNameAndIdGreaterThanOrderByIdAsc(
String firstName, Long lastId);
首次调用第一个方法,之后取当前批次最后一条记录的 id,传给第二个方法,遇到空列表时结束。这样无需假设初始 id 为 0,也不依赖 Window 的查询适配。JDBC 文档列出了 GreaterThan、OrderBy 以及限制结果数量的派生查询用法。参考:JDBC Query Methods
这一方案要求 id 唯一、非空,并在遍历过程中保持稳定。如果改为按多个字段排序,续查条件必须覆盖完整的排序顺序,不能只比较 id。
使用时还要注意什么?
- 将
ScrollPosition.keyset()改成 offset 类型,无法补上缺失的Window查询处理逻辑。 Slice配合Pageable属于 offset 分页。数据量较大时,需要评估较大偏移量的查询成本。- 按唯一
id排序可以确定顺序,但分页和按键续查都不会自动提供整个遍历过程的数据快照。并发插入、删除或修改筛选字段时,实际读取到的记录集合仍可能变化。
这次异常应重点检查当前 JDBC 模块是否支持识别和执行 Window 查询。返回 10 条匹配记录符合 First10 的含义,现有堆栈不能证明这些记录违反了业务唯一性约束。
备注:内容仅供参考。
