
本文详解如何在 spring webflux + spring data r2dbc 中安全、高效地实现带搜索条件的跨表分页查询,重点解决原生 sql 编写、手动映射、分页一致性及参数绑定等核心问题。
本文详解如何在 spring webflux + spring data r2dbc 中安全、高效地实现带搜索条件的跨表分页查询,重点解决原生 sql 编写、手动映射、分页一致性及参数绑定等核心问题。
在基于响应式栈(Reactive Stack)构建的微服务中,使用 Spring Data R2DBC 进行数据库操作时,开发者常误将 JPA 的 JPQL 习惯迁移到 R2DBC 上——这是导致查询失败、返回空对象或分页异常的最常见原因。R2DBC 是纯异步、无 ORM 映射层的轻量级驱动,@Query 注解仅支持原生 SQL,不支持 select f from file f ... 这类 JPQL 语法;同时,涉及 JOIN 的复杂查询必须显式声明字段并手动完成结果映射。
✅ 正确做法:使用原生 SQL + 手动 RowMapper
首先,修正 Repository 层的查询语句为标准 PostgreSQL 原生 SQL,并明确列出所有需返回的字段(避免歧义和 NULL 问题):
@Repository
public interface FileRepository extends ReactiveCrudRepository<file long> {
@Query("SELECT " +
"f.id AS file_id, f.file_name, f.uploaded_date_time, f.system_id, " +
"s.id AS system_id_pk, s.system_title " +
"FROM file f " +
"INNER JOIN system s ON f.system_id = s.id " +
"WHERE f.is_deleted = false " +
" AND (LOWER(f.file_name) LIKE LOWER(CONCAT('%', :searchParam, '%')) " +
" OR LOWER(s.system_title) LIKE LOWER(CONCAT('%', :searchParam, '%'))) " +
"ORDER BY f.uploaded_date_time DESC " +
"LIMIT :limit OFFSET :offset")
Flux<filewithsystem> findFilteredFiles(@Param("searchParam") String searchParam,
@Param("limit") int limit,
@Param("offset") int offset);
// 单独提供 COUNT 查询(用于计算总记录数)
@Query("SELECT COUNT(*) FROM file f " +
"INNER JOIN system s ON f.system_id = s.id " +
"WHERE f.is_deleted = false " +
" AND (LOWER(f.file_name) LIKE LOWER(CONCAT('%', :searchParam, '%')) " +
" OR LOWER(s.system_title) LIKE LOWER(CONCAT('%', :searchParam, '%')))")
Mono<long> countFilteredFiles(@Param("searchParam") String searchParam);
}</long></filewithsystem></file>
⚠️ 注意:
- 字段别名(如
f.id AS file_id)是必须的,尤其当两张表存在同名字段(如id)时,否则RowMapper无法准确绑定;- 使用
LOWER(...)统一大小写,提升搜索友好性;- 不依赖
Pageable自动注入(R2DBC 不支持Pageable在@Query中自动解析LIMIT/OFFSET),需手动传入limit和offset。
? 定义组合 DTO 并实现 RowMapper
由于查询结果跨 file 和 system 表,需创建专用 DTO 并注册 RowMapper:
@Data
@NoArgsConstructor
public class FileWithSystem {
private Long fileId;
private String fileName;
private LocalDateTime uploadedDateTime;
private Long systemId;
private String systemTitle;
// 构造函数便于 RowMapper 使用
public FileWithSystem(Long fileId, String fileName, LocalDateTime uploadedDateTime,
Long systemId, String systemTitle) {
this.fileId = fileId;
this.fileName = fileName;
this.uploadedDateTime = uploadedDateTime;
this.systemId = systemId;
this.systemTitle = systemTitle;
}
}
@Component
public class FileWithSystemRowMapper implements RowMapper<filewithsystem> {
@Override
public FileWithSystem apply(Row row, RowMetadata metadata) {
return new FileWithSystem(
row.get("file_id", Long.class),
row.get("file_name", String.class),
row.get("uploaded_date_time", LocalDateTime.class),
row.get("system_id", Long.class),
row.get("system_title", String.class)
);
}
}</filewithsystem>
并在配置类中注册该 RowMapper(或通过 DatabaseClient 手动指定):
@Configuration
public class R2dbcConfig {
@Bean
public DatabaseClient databaseClient(ConnectionFactory connectionFactory) {
return DatabaseClient.create(connectionFactory);
}
}
? Service 层:手动组装 PageImpl(关键!)
R2DBC 不支持 Pageable 自动分页,因此需在 Service 中手动计算 offset,并聚合数据与总数:
@Service
public class FileService {
private final FileRepository fileRepository;
private final DatabaseClient databaseClient;
public FileService(FileRepository fileRepository, DatabaseClient databaseClient) {
this.fileRepository = fileRepository;
this.databaseClient = databaseClient;
}
public Mono<page>> filterFileList(String searchParam, Pageable pageable) {
int page = pageable.getPageNumber();
int size = pageable.getPageSize();
long offset = (long) page * size;
return Mono.zip(
fileRepository.findFilteredFiles(searchParam, size, (int) offset)
.collectList(),
fileRepository.countFilteredFiles(searchParam)
)
.map(tuple -> {
List<filewithsystem> content = tuple.getT1();
long total = tuple.getT2();
return new PageImpl(content, pageable, total);
});
}
}</filewithsystem></page>
? Handler 层:健壮参数校验与错误处理
优化 ServerRequest 处理逻辑,避免 get() 引发 NoSuchElementException:
public Mono<serverresponse> handleFilteredFileList(ServerRequest request) {
return Mono.zip(
request.queryParam("searchParam").defaultIfEmpty("").next(),
request.queryParam("page").map(Integer::parseInt).defaultIfEmpty(0).next(),
request.queryParam("size").map(Integer::parseInt).defaultIfEmpty(10).next()
)
.flatMap(tuple -> service.filterFileList(tuple.getT1(), PageRequest.of(tuple.getT2(), tuple.getT3(), Sort.by("uploadedDateTime").descending())))
.map(page -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(page))
.onErrorResume(e -> ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage())));
}</serverresponse>
✅ 总结与最佳实践
- ❌ 禁用 JPQL:R2DBC
@Query只接受原生 SQL,勿写select f from ...; - ✅ 显式字段 + 别名:JOIN 查询必须为每个字段指定唯一别名,防止映射冲突;
- ✅ 手动分页:
LIMIT/OFFSET需由业务代码计算传入,不可依赖Pageable自动解析; - ✅ 分离 COUNT 查询:为保证分页准确性,
count必须与主查询 WHERE 条件完全一致; - ✅ 使用
LOWER()提升搜索鲁棒性,避免大小写敏感问题; - ✅ 始终校验请求参数,避免
null或非法数值引发运行时异常。
通过以上结构化改造,即可在响应式环境中稳定支撑高并发、低延迟的跨表搜索分页需求。










