接搜索列表需求的人,几乎都逃不过同一件事:今天加一个筛选框,明天加一个下拉条件,后天再加一个时间范围。刚开始你很爽,Spring Data JPA 的方法名派生查询太好写了,findByStatus、findByNameContaining一个个往仓库里堆,等堆到二三十个方法名比字段还长的时候,你就知道什么叫痛苦了。这篇博客就聊聊 Spring Boot JPA 里怎么用多条件动态查询,核心是基于 JPA 的 Specifidation 机制,把查询条件在运行时组装起来,而不是靠预定义方法名或手拼 SQL。这里要解决的痛点,就是条件组合不固定时“一个条件一个方法”变成“所有条件自由拼装”的问题。
我尽量用一段实际可跑通的代码、几个高频踩坑的场景,把这个思路讲透。适合两类人看:一是刚接触 Spring Data JPA、发现方法名查询开始失控的开发者;二是已经在用 JPA 但一直想找个更优雅的动态查询写法、又不想引入太重框架的人。看完你可以直接照着落地,也可以对着代码排查你现在项目里那几个隐蔽 Bug。
1. 为什么要做“多条件动态查询”
1.1 方法名派生查询先撑爆你的仓库
先复盘一下大多数人是怎么开始的。假设有个商品表,需求最初只有一个:按状态查商品。
List<Product> findByStatus(Integer status);挺好,简单粗暴。过两周需求来了:按状态和分类查。你又写了一个:
List<Product> findByStatusAndCategoryId(Integer status, Long categoryId);再过一阵要按价格区间查:
List<Product> findByStatusAndCategoryIdAndPriceBetween( Integer status, Long categoryId, BigDecimal min, BigDecimal max, Pageable pageable);你发现这个房间已经不对劲了。方法名越来越长,字段稍微多一点整个接口根本没法看。更致命的是,这些方法都是把条件“写死”在名字里的,前端传来的参数可能是空的,比如用户不想筛分类,只想筛价格,你总不能把所有组合都穷举一遍。
这不是编码能力问题,而是工具选型问题。你要的不是“针对每种组合写一个方法”,而是“让条件在运行时自由组合”。这个需求本质上是要动态生成 SQL 的 where 条件,而 Spring Data JPA 留给我们的正规入口就是 Specification。
什么是 Specification?简而言之,它是 Spring Data JPA 提供的一个组合式查询谓词协议,接受一个实体查询的根类型、查询上下文和条件构造器,返回一个谓词对象。它可以被 and、or 组合,也可以和分页排序无缝衔接。你写的每一步条件判断,都可以变成一个独立的谓词,最后合并到一起,交给 JPA 去执行。
1.2 动态查询的常见实现方案横评
动手之前,先泼一盆冷水:别一上来就上 QueryDSL。它不是不好,而是学习成本和构建成本都更高。下面是几个主流方案的对比:
| 实现方式 | 类型安全程度 | 上手成本 | 适合场景 |
|---|---|---|---|
| 派生方法查询 | 方法名固定,类型安全但不可扩展 | 低 | 条件少且固定不变 |
| 手写 EntityManager + Criteria API | 类型安全,但样板代码极多 | 中 | 不想带任何封装 |
| JPA Specification | 类型安全,可组合,原生支持 | 中 | 条件不固定,前端可选择性传参 |
| QueryDSL | 编译期校验字段,最严格 | 较高 | 团队经验丰富,查询极度复杂 |
| 拼接 SQL / XML SQL | 无类型安全,容易注入 | 低 | 追求快速但隐患多 |
我的看法是,大部分业务系统的查询复杂度,远没到必须上 QueryDSL 的程度。Specification 本身就够用,而且不需要额外引入代码生成插件,不会因为 Java 版本升级、Maven 插件不兼容而折腾半天。先用好它,等到你真正碰到那种复杂到 Specification 写起来像天书、可维护性崩掉的查询,再考虑升级也不迟。
提示:下文代码以 Spring Boot 3 和 Jakarta 名字空间为例。你如果还在维护 Spring Boot 2 项目,只需把
jakarta.persistence.*替换为javax.persistence.*,逻辑完全一致。
2. 开工前的基础准备
2.1 建实体、建 Repository
说再多也不如跑一个例子。这里模拟一个电商后台的商品列表筛选场景,实体是商品,关联一个分类,字段包含名称、编号、价格、库存、状态、创建时间。
@Entity @Table(name = "t_product") public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String sn; private String name; private BigDecimal price; private Integer stock; private Integer status; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "category_id") private Category category; @Column(name = "created_at") private LocalDateTime createdAt; // 省略 getter / setter }分类实体:
@Entity @Table(name = "t_category") public class Category { @Id private Long id; private String name; private Integer enabled; // 省略 getter / setter }注意商品里的分类关联是懒加载的。这一个小细节会在后面列表接口引发 N+1 问题,先留个印象。
基础依赖不需要额外引入什么,Spring Boot 的spring-boot-starter-data-jpa已经带了 JPA 标准 API。即便你后续要用 Specification,也不需要额外新加包。
接下来是 Repository。要让仓库支持 Specification,必须让它同时继承JpaRepository和JpaSpecificationExecutor:
public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> { }2.2 认识 JpaSpecificationExecutor 的三个关键对象
继承了JpaSpecificationExecutor,仓库就会自动多出一组以 Specification 为参数的方法:
findAll(Specification<T> spec)findAll(Specification<T> spec, Pageable pageable)findOne(Specification<T> spec)count(Specification<T> spec)
重点理解Specification这个接口本身。它是一个函数式接口,核心方法只有一个:
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);Root<T> root代表实体查询的根,你可以想象成画板,root.get("name")就是在画板上选中一个叫 name 的列。CriteriaBuilder cb是条件构造器,也就是画笔,cb.like(...)、cb.equal(...)、cb.greaterThan(...)都是从画笔里弹出的操作。最后你把构造好的 Predicate 返回给 Spring Data,它负责把这个谓词翻译成 SQL 的 where 条件。
一句话记住三者的关系:root 是画板,cb 是画笔,Predicate 是画出来的成品。
你要特别注意的是,root.get("name")里写的 name 是实体的属性名,不是数据库表里的列名。写错了一样会报错,而且这种错误是在真实查询时才会暴露,不是编译期能发现的。很多人第一次写 Specification 都栽在属性名拼写上。
3. 实战:一个查询服务从零到一
3.1 定义请求参数
前端筛选页的查询参数,不要用散落的多个 BigDecimal、Long 参数传进去,那只会让代码越来越乱。建议建一个查询对象,字段和前端表单一一对应。
public class ProductQuery { private String keyword; // 名称或编号模糊搜索 private Long categoryId; // 分类 private BigDecimal minPrice; // 最低价 private BigDecimal maxPrice; // 最高价 private Integer status; // 状态 private LocalDate startDate; // 创建开始日期 private LocalDate endDate; // 创建结束日期 private Integer page = 1; // 页码,从1开始 private Integer pageSize = 20;// 每页数量 private String sortField; // 排序字段 private String sortOrder; // asc / desc // 省略 getter / setter }这里价格区间用了两个字段,时间范围用了两个字段。前端传参的时候,哪个不传就让它 null,动态查询的价值就体现在这些 null 的处理上。
3.2 用 Specification 组装动态条件
核心代码来了。在商品服务里写一个搜索方法,方法里构建 Specification,一层层 if 判断把有值的条件放进去,没值的条件直接不管。
public Page<Product> search(ProductQuery query) { Specification<Product> spec = (root, queryCb, cb) -> { List<Predicate> predicates = new ArrayList<>(); if (StringUtils.hasText(query.getKeyword())) { String pattern = "%" + escapeLike(query.getKeyword()) + "%"; predicates.add(cb.or( cb.like(root.get("name"), pattern, '\\'), cb.like(root.get("sn"), pattern, '\\') )); } if (query.getCategoryId() != null) { predicates.add(cb.equal(root.get("category").get("id"), query.getCategoryId())); } if (query.getMinPrice() != null) { predicates.add(cb.greaterThanOrEqualTo(root.get("price"), query.getMinPrice())); } if (query.getMaxPrice() != null) { predicates.add(cb.lessThanOrEqualTo(root.get("price"), query.getMaxPrice())); } if (query.getStatus() != null) { predicates.add(cb.equal(root.get("status"), query.getStatus())); } if (query.getStartDate() != null) { predicates.add(cb.greaterThanOrEqualTo( root.get("createdAt"), query.getStartDate().atStartOfDay())); } if (query.getEndDate() != null) { predicates.add(cb.lessThan( root.get("createdAt"), query.getEndDate().plusDays(1).atStartOfDay())); } return cb.and(predicates.toArray(new Predicate[0])); }; String sortField = normalizeSortField(query.getSortField()); Sort sort = Sort.by( "asc".equalsIgnoreCase(query.getSortOrder()) ? Sort.Direction.ASC : Sort.Direction.DESC, sortField); Pageable pageable = PageRequest.of(query.getPage() - 1, query.getPageSize(), sort); return productRepository.findAll(spec, pageable); }这段代码有几个细节值得拆开讲。
第一,keyword 模糊搜索用了一个escapeLike方法。因为用户输入的搜索词里如果包含%或_,在 SQL 的 LIKE 语句里会被当作通配符。搜索一个普通的“100%”反而会把所有带百分比的商品都捞出来,甚至_会匹配任意单字符。这个隐患很多老手都踩过,建议把转义写成一个通用方法:
private String escapeLike(String value) { return value.replace("\\", "\\\\") .replace("%", "\\%") .replace("_", "\\_"); }第二,like 方法第三个参数'\\'是转义字符。它表示 SQL 里的like 'xxx' escape '\',意思是告诉数据库,百分号和下划线前面如果出现反斜杠,就按普通字符处理。
第三,时间范围的写法我用了endDate.plusDays(1).atStartOfDay()配合lessThan,也就是“小于次日零点”。这个比用atTime(LocalTime.MAX)安全得多,因为数据库时间列如果精度更高,23:59:59.999可能仍然漏掉一些数据。
第四,root.get("category").get("id")这种写法虽然能工作,其实会导致 SQL 隐式生成一条 inner join。如果商品关联的分类是可以为空的,这个查询会把分类为空的商品直接过滤掉,与你“按分类筛选”的本意不符。后面第 4 节会专门说这个问题,用显式的 left join 更稳。
3.3 用组合式工厂方法整理代码
上面的写法简单直接,但你把所有 if 都堆在一个方法里,时间长了这段代码会变得很肥。更好的做法是把每个条件拆成独立的 Specification 工厂方法,再通过 Spring Data 提供的Specification.where组合。
先写一个商品查询的规格工厂类:
public final class ProductSpecifications { private ProductSpecifications() {} public static Specification<Product> nameLike(String keyword) { return (root, query, cb) -> cb.like(root.get("name"), "%" + escapeLike(keyword) + "%", '\\'); } public static Specification<Product> snLike(String keyword) { return (root, query, cb) -> cb.like(root.get("sn"), "%" + escapeLike(keyword) + "%", '\\'); } public static Specification<Product> categoryIdEq(Long categoryId) { return (root, query, cb) -> cb.equal(root.get("category").get("id"), categoryId); } public static Specification<Product> statusEq(Integer status) { return (root, query, cb) -> cb.equal(root.get("status"), status); } public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) { return (root, query, cb) -> { if (min == null && max == null) { return cb.conjunction(); } if (min != null && max != null) { return cb.between(root.get("price"), min, max); } if (min != null) { return cb.greaterThanOrEqualTo(root.get("price"), min); } return cb.lessThanOrEqualTo(root.get("price"), max); }; } private static String escapeLike(String value) { ... } }然后在 Service 里用流式链来组合:
Specification<Product> spec = Specification.where(null); if (StringUtils.hasText(query.getKeyword())) { spec = spec.and(ProductSpecifications.nameLike(query.getKeyword())) .or(ProductSpecifications.snLike(query.getKeyword())); } if (query.getCategoryId() != null) { spec = spec.and(ProductSpecifications.categoryIdEq(query.getCategoryId())); } if (query.getStatus() != null) { spec = spec.and(ProductSpecifications.statusEq(query.getStatus())); }Specification.where(null)的好处是它本身对 null 安全,后续调用and、or时如果当前值为空,它会自动替换成新条件。这样代码可读性更好,也方便每个条件单独做单元测试。
提示:不要过度设计一个“万能动态条件构建器”,比如用 Map 加反射去自动拼接任意字段的条件。那种代码短期内写起来很爽,后期维护会非常痛苦,出问题也没法快速定位。Specification 工厂方法本身已经足够表达业务语义了。
3.4 分页和排序的正确打开方式
动态查询本质上是列表接口,不可能不分页。使用PageRequest.of时要先搞清楚一个最容易翻车的细节:Spring Data 的页码是从 0 开始的,而前端分页组件通常是从 1 开始。
所以你有两个选择:一是在 Controller 层把传入的页码直接减 1;二是在 Service 层转换,像我在示例代码里写的那样。我的习惯是在 Service 层转换,这样 Controller 保持干净。
排序字段必须做白名单校验。原因有两个:一是如果前端传一个不存在的实体属性名,JPA 执行到排序阶段会直接抛异常,整个接口 500;二是字段一旦开放成可配置入口,就等于把数据库结构暴露给了外部调用方。
private static final List<String> ALLOWED_SORT_FIELDS = List.of("createdAt", "price", "stock"); private String normalizeSortField(String field) { if (field == null || !ALLOWED_SORT_FIELDS.contains(field)) { return "createdAt"; } return field; }默认排序字段设为创建时间,对于一个有“新发布的排前面”诉求的列表页来说,是最自然不过的兜底方案。
findAll(spec, pageable)返回的Page对象里天然包含totalElements和totalPages,这两个值是 JPA 在分页查询时另外生成一条 count 查询算出来的,你不用自己写 count 逻辑,更不要手动组装PageImpl。手搓 Page 是制造隐患的高发区,后面第 5 节会把坑展开讲。
4. 动态查询里躲不开的进阶话题
4.1 多表关联的条件过滤
商品和分类是多对一关系。当查询条件里需要“按分类筛选”时,有两种写法。
第一种是前面出现过的,直接在路径上取:
cb.equal(root.get("category").get("id"), categoryId)这个写法简洁,但有个隐患:JPA 会把它翻译成一条implicit inner join。对于分类这个关联,如果商品分类必须存在,inner join 没问题;可如果分类允许为空,那么分类为空的商品就会被这条 join 自动过滤掉,即使前端只是想查某个分类,用户也会看到存在“数据莫名其妙少了”的现象。
更可控的做法是显式声明一个 left join:
Join<Product, Category> categoryJoin = null; if (query.getCategoryId() != null) { if (categoryJoin == null) { categoryJoin = root.join("category", JoinType.LEFT); } predicates.add(cb.equal(categoryJoin.get("id"), query.getCategoryId())); }JoinType.LEFT保证商品的左表记录不会因为关联分类不存在而被删除。一个查询里如果有多处用到同一张关联表,join 对象只用创建一次,后面全部复用同一个引用。
4.2 懒加载导致的 N+1 问题
现在回到 2.1 节埋下的伏笔。商品关联了分类,而且用的是FetchType.LAZY。在列表查询里,如果你只查出商品,不把分类信息一起查出来,前端展示每个商品都需要再发一次 SQL 去查分类,这就是 N+1 问题。
一种做法是在 Specification 里调用root.fetch("category", JoinType.LEFT)。这个办法可行,但一旦和分页组合,会产生两个非常恶心的副作用:一个是结果集出现重复行,因为 fetch join 问出的结果可能膨胀;另一个是 count 查询和普通查询逻辑不一致。你要再额外加distinct,然后 count 又要重新关照,一不留神总数就不对。
我更推荐在 Repository 接口上,用@EntityGraph对列表查询方法显式声明要提前加载的关联属性。注意是覆盖父类那个接收Specification和Pageable的findAll方法:
@EntityGraph(attributePaths = {"category"}) Page<Product> findAll(@Nullable Specification<Product> spec, Pageable pageable);@EntityGraph会生成一条 left join fetch,而且和分页配合良好。这样你的 Service 代码完全不用改,筛选条件和分页逻辑还是原来的,只是查询计划从一次查商品变成一次查商品加分类。这个方法在 Spring Data JPA 里是官方支持的,不少人没发现而已。
4.3 时间段查询的边界处理
时间条件是最容易出 Bug 的地方,因为用户的心智模型和数据库的存储精度往往不一致。前端给的是日期(年-月-日),后端存的是LocalDateTime,甚至数据库里是带纳秒的时间戳。
我推荐的写法是“日期转区间,用半开区间左闭右开”:
if (query.getStartDate() != null) { predicates.add(cb.greaterThanOrEqualTo( root.get("createdAt"), query.getStartDate().atStartOfDay())); } if (query.getEndDate() != null) { predicates.add(cb.lessThan( root.get("createdAt"), query.getEndDate().plusDays(1).atStartOfDay())); }假设 endDate 是 2025-03-10,那么查询条件是created_at >= 2025-03-10 00:00:00 AND created_at < 2025-03-11 00:00:00,正好覆盖一整天。这个方案不会因为数据库时间列有毫秒、微秒精度而漏数据,也比atTime(LocalTime.MAX)更干净。
有人会习惯在 SQL 里写DATE(created_at) = ?,然后用函数包住字段。千万别这么干,一旦对字段使用函数,数据库索引基本就废了,表数据量大之后查询会明显变慢。时间区间的条件放在字段本身上,才能让索引派上用场。
如果前端传的是字符串时间,建议在 DTO 层直接转成LocalDateTime或LocalDate再进 Service,不要在 Specification 里搞字符串解析。
5. 实战踩坑实录:这些问题都是真实翻车现场
5.1 空值条件放进 Predicate,结果查不到数据
新手最容易犯的错,是不加判空直接把 null 放进去:
predicates.add(cb.equal(root.get("status"), query.getStatus()));当前端没有传 status,query.getStatus()为 null 时,这条谓词会被 JPA 翻译成类似status = null的 SQL。在标准 SQL 里,status = null永远不为 true,因为 null 不参与等值比较。结果就是:你不传状态,数组返回为空,查不到任何商品。
这也是我为什么反复强调要做 if 判空。All 条件处理都必须基于“用户不传就当没有这个条件”的原则,否则动态查询就是在给你挖坑。
5.2 like 搜索里的 % 和 _ 让人血压上升
我遇到过一次真实案例,商品名称里有“100%”,用户按这个词搜索,结果列表把 100%、90% 的商品全筛出来了。本质就是%被 LIKE 语句当作匹配任意串的通配符。
我在 3.2 节已经给过escapeLike方法,这里再强调一次:String.replace("\\", "\\\\")必须放在第一个,防止你先把%转成\%,再被后续的反斜杠替换处理毁掉。顺序错了,转义就失效了。
5.3 自己组装 PageImpl,总数对不上
很多人在动态查询里发现自带 Page 太“黑盒”,干脆自己先List<Product>查出来,再手动new PageImpl<I>(list, pageable, total)返回。问题在于,一旦分页列表里还混了关联查询或其他复杂逻辑,手动计数很容易跟实际过滤不匹配,最后前端分页器和后端数量对不上。
不推荐手搓 Page 的根本原因,倒不是不能做,而是你需要自己维护“列表查询”和“count 查询”两套条件的一致性,改一处忘了另一处就是 Bug。直接用 JPA 的findAll(Specification, Pageable),它会用同一个 Specification 生成列表查询和 count 查询,天然保持一致,也省掉一条手工 count SQL 的出错环节。
5.4 count 查询与 distinct 的相爱相杀
如果你的 Specification 里显式使用query.distinct(true)去处理关联表记录重复问题,请先确认订单。因为 Spring Data 在生成 count 查询时,会对 Specification 做某种程度的重放,如果它被带上了distinct,生成的 count 可能是select count(distinct product),这样就没法正确统计总数;反过来说,如果你不加 distinct,普通查询可能产生重复行。
因此我的建议是:能用@EntityGraph解决的关联加载问题,就别在 Specification 里依赖root.fetch或query.distinct。这两个特性在 and 查询时单独用没问题,组合到分页里立刻变得棘手。
5.5 排序字段不安全,直接让接口报错
排序字段写错,JPA 启动时不会报错,但你一调用接口就抛类似Unable to locate Attribute的异常。用户看到的画面就是一个 500。
处理办法就是我在 3.4 里写的白名单方案。你需要明确告诉前端哪些字段可以参与排序,不合法就回退到默认值。同时记得后端要把排序字段和 SQL 列名隔离,前端传createTime,后端映射到实体的createdAt,不要图省事直接透传。
5.6 看不到动态生成的 SQL,排查全靠猜
有人说“Specification 像个黑盒,出问题都不知道最终 SQL 是什么样”。不需要猜,配置就能直接看到:
spring: jpa: show-sql: true properties: hibernate: format_sql: true打开show-sql,每个动态查询生成的最终 SQL 都会打印在控制台。排查 pin 字段名拼写错误、关联 join 类型不对时,这是最直接的手段。生产环境记得关掉,并去掉format_sql,它只适合本地调试。
以下是高频问题速查表:
| 现象 | 常见原因 | 建议 |
|---|---|---|
| 不传某个条件时,结果为空 | 条件未判空,null 进入 equal 谓词 | 所有不能为空的条件必须 if 判空 |
| 搜索“100%”把 90% 也查出来 | % 被当作通配符 | escapeLike转义,like 第三个参数传'\\' |
| 列表和总数对不上 | 手写 PageImpl 或规范与 count 不一致 | 直接用findAll(Spec, Pageable) |
| 商品有 100 条,查出来总是少一部分 | root.get("cat").get("id")触发了 inner join | 显式root.join("category", JoinType.LEFT) |
| 每个商品多出一次查询数据库 | LAZY 关联未被提前加载 | 用@EntityGraph(attributePaths = "category") |
| 输入无效排序字段直接 500 | 字段名不存在 | 白名单校验,非法字段回退默认 |
| 分页 count 值异常变大或不对 | Specification 里加了 distinct/fetch | 优先用 entityGraph 代替 |
6. 关于动态查询,我的一点实操建议
在这个项目之外,我自己的项目里也反复用过好几轮 Specification,最终沉淀下来的习惯是:条件不散落在 Service 里,而是集中到一个语义明确的规格工厂类里,每个条件一个静态方法,然后用Specification.where去组合。这样做的好处是每个条件都能单独测,将来新增筛选条件就像是新增一个工厂方法,而不是在 Service 方法里又加一层 if。
选型上,我的最终态度是:先熟练掌握 Specification,再考虑是否需要 QueryDSL。Specification 零额外依赖、Spring 原生支持、和分页排序集成自然,已经覆盖了绝大多数业务列表查询场景。如果你哪一天真的遇上几十个筛选条件、动态排序且排序字段复杂到令人抓狂的超级查询,再去调研 QueryDSL 也不迟。
最后再分享一个细节。动态查询不只是后端代码的事,接口设计上要逼着前端只传有意义的参数。很多前端会把空字符串、'undefined'、0 都一股脑传过来,你在后端判空如果只判断 null 不判断空字符串,照样会翻车。建议在进入 Service 之前做一次参数清洗:空字符串统一转 null,数值为 null 的不参与条件,这样 Specification 里的判空逻辑才能保持简单,也不会出现“明明是空条件却生成了 where name = ''”这种尴尬 SQL。
我自己在写过两年普通 CRUD 之后,最大的体会是:查询条件的动态化不是魔法,它只是把“预先写死 SQL”变成“运行时组装 Predicate”。组装的每一步都慢一点、稳一点,后面线上就会少很多让你半夜爬起来的 Bug。