
本文详解如何在 spring webflux + spring data r2dbc 环境下,安全、高效地实现带搜索条件(跨表模糊匹配)和分页的响应式数据库查询,重点解决原生 sql 编写、结果映射、分页一致性等核心问题。
本文详解如何在 spring webflux + spring data r2dbc 环境下,安全、高效地实现带搜索条件(跨表模糊匹配)和分页的响应式数据库查询,重点解决原生 sql 编写、结果映射、分页一致性等核心问题。
在 Spring Data R2DBC 中实现带过滤条件的分页查询时,必须明确一个关键前提:R2DBC 不支持 JPQL 或实体关系映射(ORM),它仅面向原生 SQL 和行级结果映射。因此,直接使用 select f from file f inner join system p ... 这类 JPQL 风格语句(源自 JPA 习惯)会导致查询失败或返回空/null 结果——这正是你遇到“列表全为 null”的根本原因。
✅ 正确做法:使用原生 SQL + 自定义 RowMapper
首先,修正 Repository 中的 @Query 方法,改用标准 PostgreSQL 原生语法,并显式指定字段(避免列名歧义):
@Repository
public interface FileRepository extends R2dbcRepository<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, '%')))")
Flux<FileWithSystem> findFilteredFiles(@Param("searchParam") String searchParam);
}⚠️ 注意:
- 使用
LOWER(...)统一大小写,提升搜索友好性; - 显式别名(如
f.id AS file_id,s.id AS system_id_pk)避免system_id字段冲突; - 返回类型不再是
File,而是自定义的扁平化 DTO(见下文)。
? 定义聚合 DTO 并实现 RowMapper
由于涉及 file 和 system 两表字段,需创建组合对象并手动映射:
@Data // Lombok
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;
}
}
// 自定义 RowMapper(推荐作为 Repository 内部静态类或独立 Bean)
public static final RowMapper<FileWithSystem> FILE_WITH_SYSTEM_MAPPER =
(row, rowMetadata) -> 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)
);然后在 Repository 中注入 DatabaseClient 实现带分页的完整逻辑(R2DBC 不支持 Pageable 直接用于 @Query 的 JOIN 查询):
@Component
public class FileRepositoryImpl implements FileRepositoryCustom {
private final DatabaseClient databaseClient;
public FileRepositoryImpl(DatabaseClient databaseClient) {
this.databaseClient = databaseClient;
}
public Mono<Page<FileWithSystem>> findFilteredFilesPaged(
String searchParam, PageRequest pageRequest) {
String baseSql = "SELECT f.id AS file_id, f.file_name, f.uploaded_date_time, " +
" f.system_id, 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, '%')))";
// 总数查询(无 LIMIT/OFFSET)
Mono<Long> totalMono = databaseClient.sql(
baseSql.replace("SELECT ...", "SELECT COUNT(*)"))
.bind("searchParam", searchParam)
.map(row -> row.get(0, Long.class))
.one();
// 分页数据查询(带 ORDER BY + LIMIT/OFFSET)
String paginatedSql = baseSql +
" ORDER BY f.uploaded_date_time DESC " +
"LIMIT :limit OFFSET :offset";
Flux<FileWithSystem> contentFlux = databaseClient.sql(paginatedSql)
.bind("searchParam", searchParam)
.bind("limit", (long) pageRequest.getPageSize())
.bind("offset", (long) pageRequest.getPageSize() * pageRequest.getPageNumber())
.map(FILE_WITH_SYSTEM_MAPPER)
.all();
return Mono.zip(contentFlux.collectList(), totalMono)
.map(tuple -> new PageImpl<>(tuple.getT1(), pageRequest, tuple.getT2()));
}
}? Service 层整合(响应式链式处理)
@Service
public class FileService {
private final FileRepositoryCustom fileRepositoryCustom;
public Mono<Page<FileWithSystem>> filterFileList(String searchParam, PageRequest pageRequest) {
return fileRepositoryCustom.findFilteredFilesPaged(
StringUtils.defaultString(searchParam), // 防空
pageRequest.withSort(Sort.by("uploadedDateTime").descending())
);
}
}? Handler 层健壮性增强
public Mono<ServerResponse> handleFilteredFileList(ServerRequest request) {
String searchParam = request.queryParam("searchParam").orElse("");
int page = Integer.parseInt(request.queryParam("page").orElse("0"));
int size = Integer.parseInt(request.queryParam("size").orElse("10"));
return service.filterFileList(searchParam, PageRequest.of(page, size))
.flatMap(pageResult ->
ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(pageResult)
)
.onErrorResume(e -> ServerResponse.badRequest()
.bodyValue(Map.of("error", "Invalid pagination params: " + e.getMessage())));
}⚠️ 关键注意事项总结
- ❌ 禁用 JPQL / HQL 语法:R2DBC 的
@Query只接受原生 SQL; - ✅ 必须手动映射:JOIN 查询无法自动转为实体,需
RowMapper或DatabaseClient显式解析; - ✅ 分页必须手写
LIMIT/OFFSET:Pageable不适用于@Query中的复杂 JOIN,应通过DatabaseClient拆分为总数 + 数据双查询; - ✅ 排序必须显式声明:
ORDER BY是LIMIT/OFFSET正确性的前提,否则分页结果不稳定; - ✅ SQL 注入防护:始终使用
:param绑定参数,切勿字符串拼接; - ✅ 空值防御:对
queryParam使用orElse(""),避免NullPointerException。
遵循以上模式,即可在响应式栈中稳定支撑高并发、低延迟的跨表搜索分页需求。

















