PAYATHON 2026

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。

先核对版本和文档

  1. 确认运行时实际使用的 spring-data-jdbc、spring-data-relational 和 spring-data-commons 版本,检查是否手动覆盖过依赖版本。
  2. 查阅对应版本的 JDBC 文档,确认是否支持派生查询返回 Window<T>,以及是否支持 keyset scrolling。
  3. 如果项目由 Spring Boot 管理依赖,优先使用其依赖管理提供的兼容组合,避免只升级 spring-data-commons。
  4. 如果对应版本没有实现这种查询,改用分页,或自行实现按键续查。

同时继承 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 的含义,现有堆栈不能证明这些记录违反了业务唯一性约束。

备注:内容仅供参考。