1. 先理解“条件进阶”到底在解决什么问题
SpringJDBC 是 Spring 框架里处理数据库访问的基础方案,很多项目在没有引入 MyBatis、JPA 这类重量级 ORM 框架时,都会直接用它来操作数据库。JDBC 本身写起来啰嗦,SpringJDBC 通过JdbcTemplate把连接管理、异常转换、结果集映射这些重复工作封装掉了,让开发者只需要关注 SQL 本身。
但实际的业务查询从来不是“查全表”那么简单。用户搜索列表要按关键字过滤,后台管理页面要根据时间段、状态、分类组合筛选,统计报表可能要动态拼接查询条件。这些场景落到 SpringJDBC 上,核心问题就变成一个:查询条件不是固定的,怎么在代码里安全、灵活、可维护地组装 SQL?
条件进阶这个主题,重点就是解决这类问题。它不是讲某个新 API,而是讲一套组合条件查询的写法、策略和防坑思路。
适合看这篇文章的人也比较明确:正在用JdbcTemplate,还没引入 MyBatis,但项目里的列表查询开始出现“一个方法写了好几段 if、拼接 SQL 越拼越长”这种情况。也可以说,你已经在写动态条件查询,但不确定有没有更稳的写法。
最值得先关注的能力有三个:
- 条件参数怎么拼才不容易出错
- 怎么避免 SQL 注入和参数占位符错位
- 怎么让条件代码在项目变大之后还能维护
如果能把这三点想清楚,SpringJDBC 的动态查询就不会变成后期维护的负担。
2. 先搭一个可以反复试验的基础环境
条件进阶不是背 API,而是要在代码里反复验证。所以我建议先把一个最小可运行的项目搭起来,后面的示例都往这个环境里放。
2.1 环境准备和依赖引入
我用的环境是普通的 Java 工程,Spring Boot 版本是 2.x 或 3.x 都可以,核心依赖只需要两个:spring-boot-starter-jdbc和对应数据库的驱动。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>这里不需要引入 MyBatis、JPA 或任何 ORM 框架,因为我们要看的正是JdbcTemplate在纯 JDBC 层面的条件处理能力。
如果你的项目不是 Spring Boot,而是传统 Spring 工程,那就手动配置一个DataSource和一个JdbcTemplateBean,效果一样。
2.2 准备一张测试表和测试数据
条件查询要验证,必须有一张带多种字段类型的表。我一般会建一张简单的商品表或者用户表,字段要覆盖:
- 字符串精确匹配,比如状态字段
- 字符串模糊匹配,比如名称搜索
- 数值范围匹配,比如价格或年龄区间
- 时间范围匹配,比如创建时间区间
- 多个条件组合的情况
我这里用一张用户表来做示例:
CREATE TABLE user_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, status TINYINT NOT NULL DEFAULT 1, age INT, created_at DATETIME ); INSERT INTO user_account (username, status, age, created_at) VALUES ('zhangsan', 1, 25, '2024-01-01 10:00:00'), ('lisi', 1, 30, '2024-01-02 11:00:00'), ('wangwu', 0, 22, '2024-01-03 09:30:00'), ('zhaoliu', 1, 35, '2024-01-04 15:00:00');这张表覆盖了最常用的条件类型。后面所有示例都基于它来写,你拿到项目里换成自己的业务表即可。
2.3 理解 JdbcTemplate 查询方法的基本套路
SpringJDBC 的查询方法整体思路是:SQL 里用?占位符,参数按顺序放到可变参数或参数数组里,查询结果通过RowMapper或BeanPropertyRowMapper转成对象。
单条件查询很简单:
@Repository public class UserDao { @Autowired private JdbcTemplate jdbcTemplate; public List<UserAccount> findByStatus(int status) { String sql = "SELECT id, username, status, age, created_at FROM user_account WHERE status = ?"; return jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(UserAccount.class), status); } }注意:BeanPropertyRowMapper要求数据库列名和实体类属性名能匹配。如果列名是created_at,属性名是createdAt,默认情况下可能会映射不上。解决方式有两个:SQL 里给列起别名,或者把BeanPropertyRowMapper配置成下划线转驼峰。Spring Boot 2.x 之后,BeanPropertyRowMapper默认是支持下划线转驼峰的,但为了保险起见,我会在 SQL 里显式写别名,避免不同版本行为不一致。
单条件没有难度,真正的难点在“多个条件同时出现,并且每个条件都可能为空”。
3. 多条件动态查询的常见实现对比
动态条件查询最简单的想法是:传入一个实体对象,里面有值就拼条件,没值就跳过。这个思路没问题,问题是怎么拼。
3.1 方案一:字符串直接拼接(最不推荐)
很多项目刚开始会写出这种代码:
public List<UserAccount> searchUsers(String username, Integer status, Integer minAge, Integer maxAge) { StringBuilder sql = new StringBuilder("SELECT id, username, status, age, created_at FROM user_account WHERE 1=1"); if (StringUtils.hasText(username)) { sql.append(" AND username LIKE '%").append(username).append("%'"); } if (status != null) { sql.append(" AND status = ").append(status); } // ... 其他条件 return jdbcTemplate.query(sql.toString(), new BeanPropertyRowMapper<>(UserAccount.class)); }这段代码能跑,但有两个硬伤。
第一,SQL 注入风险。username如果是用户输入,直接拼进 SQL,恶意输入可能改变语句语义。有人说“内部系统不用管”,但数据安全不该赌。
第二,可维护性差。条件一多,SQL 字符串交错在 if 里面,后期想找一个字段对应的查询逻辑,眼睛要看很久。
这个方案只适合一次性临时脚本,不适合正式项目的数据访问层。
3.2 方案二:参数占位符配合条件判断(推荐)
正确做法是:SQL 的WHERE部分动态拼接,但参数值永远通过占位符传递,并且传入的参数列表和 SQL 里的?必须一一对应。
public List<UserAccount> searchUsers(String username, Integer status, Integer minAge, Integer maxAge) { StringBuilder sql = new StringBuilder("SELECT id, username, status, age, created_at FROM user_account WHERE 1=1"); List<Object> params = new ArrayList<>(); if (StringUtils.hasText(username)) { sql.append(" AND username LIKE ?"); params.add("%" + username + "%"); } if (status != null) { sql.append(" AND status = ?"); params.add(status); } if (minAge != null) { sql.append(" AND age >= ?"); params.add(minAge); } if (maxAge != null) { sql.append(" AND age <= ?"); params.add(maxAge); } sql.append(" ORDER BY id DESC"); return jdbcTemplate.query(sql.toString(), new BeanPropertyRowMapper<>(UserAccount.class), params.toArray()); }这个写法有四个关键点:
- SQL 里的
?只负责占位,不写具体值 - 参数全部放进
params列表 params.toArray()作为查询参数传入- 每个条件追加 SQL 的同时,必须同步添加参数,顺序不能乱
WHERE 1=1这种写法看着有点怪,但它提供了很实际的便利:后续每个条件都可以直接用AND开头,不用判断当前是不是第一个条件。虽然在极大数据量的极端场景下,数据库优化器可能对1=1有一定处理成本,但现代数据库基本都能识别并优化掉。如果实在不喜欢,也可以用List<String>收集条件片段,最后用String.join(" AND ", conditions)组装。
3.3 方案三:条件对象封装
当查询条件太多,比如超过五个字段,方法参数会越来越长。这时候我习惯单独定义一个查询条件类。
public class UserQuery { private String username; private Integer status; private Integer minAge; private Integer maxAge; private String startDate; private String endDate; // getter/setter 省略 }然后 DAO 方法只接收这个查询对象:
public List<UserAccount> searchUsers(UserQuery query) { StringBuilder sql = new StringBuilder("SELECT id, username, status, age, created_at FROM user_account WHERE 1=1"); List<Object> params = new ArrayList<>(); if (StringUtils.hasText(query.getUsername())) { sql.append(" AND username LIKE ?"); params.add("%" + query.getUsername() + "%"); } if (query.getStatus() != null) { sql.append(" AND status = ?"); params.add(query.getStatus()); } // ... 其他 return jdbcTemplate.query(sql.toString(), new BeanPropertyRowMapper<>(UserAccount.class), params.toArray()); }这样做的最大好处是,以后新增查询条件时,只需要往条件类里加字段,再在 DAO 方法里加一段 if,不用改方法签名,调用方也更清晰。
3.4 三种方案对比
| 方案 | 安全性 | 可维护性 | 适用场景 |
|---|---|---|---|
| 字符串直接拼接 | 低,存在注入风险 | 差,条件杂乱 | 临时脚本,不推荐用于正式项目 |
| 参数占位符 + if | 高,参数化查询 | 中等 | 小型项目、条件数量少、逻辑清晰 |
| 条件对象 + if | 高,参数化查询 | 较好 | 条件多、查询复杂、后续要扩展 |
比较下来,我个人的建议是:正式项目默认选择方案三。哪怕当前只有两三个条件,也值得用条件对象。因为条件查询几乎是必然扩展的,今天两个条件,下个月可能就是五个。
4. 条件进阶的实用模式
基础的 if 追加条件很容易理解,但这个思路在真实项目里会碰到一些更具体的问题。下面几个模式是我在项目里反复用到的。
4.1 模糊查询的写法差异
模糊查询最常见的写法是LIKE '%keyword%'。在 SpringJDBC 里,不能把%直接写进 SQL 的参数位置,而是要拼到参数值里。
有人会写:
sql.append(" AND username LIKE ?"); params.add("%" + username + "%");这是正确的。但有个细节值得注意:如果用户搜索的关键字本身包含%或_,这两个字符在 LIKE 里是通配符,可能导致查询结果超出预期。如果需要严格按字面量匹配,就要做转义处理,用ESCAPE关键字指定转义字符。
String keyword = username.replace("!", "!!") .replace("%", "!%") .replace("_", "!_"); sql.append(" AND username LIKE ? ESCAPE '!' "); params.add("%" + keyword + "%");这个需求不是每个项目都有,但如果你的业务允许用户输入特殊字符做搜索,这块就要注意。普通的关键字搜索不处理也不会有大问题,最多是搜索结果多几条。
4.2 in 条件怎么动态处理
动态查询经常遇到IN条件,比如按多个 ID 查询,或者按多个状态查询。
如果传入的是一个集合:
public List<UserAccount> findByStatuses(List<Integer> statuses) { if (statuses == null || statuses.isEmpty()) { return Collections.emptyList(); } StringBuilder sql = new StringBuilder( "SELECT id, username, status, age, created_at FROM user_account WHERE status IN (" ); // 生成占位符 for (int i = 0; i < statuses.size(); i++) { if (i > 0) { sql.append(", "); } sql.append("?"); } sql.append(")"); return jdbcTemplate.query(sql.toString(), new BeanPropertyRowMapper<>(UserAccount.class), statuses.toArray()); }这里需要注意两点:
IN列表为空时不能直接执行,否则 SQL 会变成WHERE status IN (),语法错误。可以先做空集合判断,返回空结果。IN列表元素数量特别多时,数据库可能性能下降。一般超过 1000 个 ID,我会考虑分批查询,或者改用临时表关联。
4.3 日期范围查询
时间范围查询是列表页里最常见的条件之一。传入的开始时间和结束时间,在 SQL 里用>=和<而不是>和<=,这样更精确。
通常开始时间取当天零点,结束时间取下一天的零点。
if (query.getStartDate() != null) { sql.append(" AND created_at >= ?"); params.add(query.getStartDate()); } if (query.getEndDate() != null) { sql.append(" AND created_at < ?"); params.add(query.getEndDate()); }这里要注意一个常见错误:如果结束时间直接用2024-01-04,可能丢失当天的数据,因为2024-01-04 15:00:00大于2024-01-04 00:00:00。所以在传参时,要么把结束日期转换成次日零点,要么用户在页面上选择日期时后端自动拼接时间。
4.4 排序字段动态化
查询条件里经常要支持排序,但排序字段不能直接用占位符,因为 SQL 里ORDER BY ?会被当成字符串常量处理,不会解析成列名。
正确的做法是:排序字段用白名单校验,排序方式单独判断。
String orderBy = "id"; // 默认排序 if ("username".equals(query.getOrderBy())) { orderBy = "username"; } else if ("age".equals(query.getOrderBy())) { orderBy = "age"; } // 其他字段同理 String direction = "DESC"; if ("asc".equalsIgnoreCase(query.getOrderDirection())) { direction = "ASC"; } sql.append(" ORDER BY ").append(orderBy).append(" ").append(direction);这里绝对不能直接把用户传的排序字段拼进 SQL,否则等于给拼接注入留了后门。白名单是简单可靠的方案。
4.5 分页查询
SpringJDBC 并没有像 MyBatis PageHelper 那样自带分页插件,分页逻辑需要自己写。
MySQL 的分页比较简单,使用LIMIT ? OFFSET ?:
int pageNum = 1; int pageSize = 10; int offset = (pageNum - 1) * pageSize; sql.append(" LIMIT ? OFFSET ?"); params.add(pageSize); params.add(offset); List<UserAccount> list = jdbcTemplate.query(sql.toString(), new BeanPropertyRowMapper<>(UserAccount.class), params.toArray());同时还需要一条 COUNT 查询来拿总条数:
String countSql = "SELECT COUNT(*) FROM user_account WHERE 1=1"; // 同样追加条件部分,但不需要排序和分页 Integer total = jdbcTemplate.queryForObject(countSql, Integer.class, params.toArray());这里有一个实践细节:COUNT 查询和列表查询的条件部分一样,但排序、分页部分不能加。如果你用方案三的条件对象,最好把“构建条件 SQL 和参数”抽成一个公共方法,列表查询和 COUNT 查询都调用它。这样可以保证两条 SQL 条件完全一致。
private SqlAndParams buildWhereSql(UserQuery query) { StringBuilder sql = new StringBuilder(" WHERE 1=1"); List<Object> params = new ArrayList<>(); // 各种条件判断 return new SqlAndParams(sql.toString(), params.toArray()); }4.6 条件过多时的可读性优化
如果一张表有十几个字段可以作为查询条件,一个方法里堆十段 if 会显得很笨重。这时候可以引入策略模式,或者更简单的方式是提取私有方法。
比如把每个条件判断独立成方法,或者在条件对象里增加一个“转成 SQL 片段”的处理逻辑。不过在小项目里,我认为把条件判断控制在同一个方法里反而更直观,只要 SQL 和参数都在同一个地方维护,就不容易错位。拆得太散反而增加理解成本。
5. 参数管理与 SQL 组装顺序的坑
动态条件查询里,最容易出的问题不是 SQL 语法错误,而是参数顺序错位。
5.1 为什么参数顺序这么容易乱
因为 SQL 是字符串拼接出来的,参数是不断 add 到 List 里的。两者分开写,一旦中间加了一个条件,或者调整了某个条件的顺序,SQL 里的?和数组里的参数就可能对不上。
举个真实例子。一开始代码是这样:
sql.append(" AND status = ?"); params.add(status); sql.append(" AND username LIKE ?"); params.add("%" + username + "%");后来有人加了年龄条件,但加在了中间:
sql.append(" AND status = ?"); params.add(status); sql.append(" AND age >= ?"); params.add(minAge); // 位置没问题 sql.append(" AND username LIKE ?"); params.add("%" + username + "%");如果追加条件和添加参数的代码不是紧紧挨着的,中间隔了其他逻辑,就容易出现参数添加顺序和?出现顺序不一致的情况。运行时会报Parameter index out of range或者查出来的数据莫名其妙。
5.2 怎么避免参数错位
我的习惯是:每次追加 SQL 片段,紧接着就添加参数,中间不要插入其他无关代码。这样每组 SQL 片段和参数形成一个小的“原子操作”,可读性最高。
有条件的项目,可以考虑写一个简单的工具类或内部类,把 SQL 片段和参数绑定在一起:
public class SqlParams { private final StringBuilder sql = new StringBuilder(); private final List<Object> params = new ArrayList<>(); public void append(String condition, Object... values) { sql.append(" ").append(condition); if (values != null) { Collections.addAll(params, values); } } public String getSql() { return sql.toString(); } public Object[] getParams() { return params.toArray(); } }使用起来更方便:
SqlParams sp = new SqlParams(); sp.append("SELECT * FROM user_account WHERE 1=1"); if (StringUtils.hasText(username)) { sp.append("AND username LIKE ?", "%" + username + "%"); } if (status != null) { sp.append("AND status = ?", status); } jdbcTemplate.query(sp.getSql(), new BeanPropertyRowMapper<>(UserAccount.class), sp.getParams());这种小工具也不复杂,写一次可以复用到很多 DAO 里。
5.3 每批条件的先后顺序会影响性能
在少数情况下,条件的先后顺序会影响 SQL 执行效率。
假设业务上大部分查询都带有status = 1,少部分查询按用户名模糊搜索。对数据库优化器来说,如果status字段有索引,把它写在前面更利于快速过滤。如果username没有索引,LIKE '%xxx%'会全表扫描,把它写在后面,可以让前面的条件先缩小结果集,再减少 LIKE 扫描的行数。
这是个经验判断,不是绝对原则。你可以在测试环境观察执行计划,再决定条件顺序。但在绝大多数中小规模数据上,条件顺序对性能的影响不明显,可以先把代码可读性放在第一位。
6. 查询性能与安全边界
动态条件查询写到一定程度,性能和安全性就成了绕不开的话题。
6.1 索引与统计
动态查询最大的问题是:条件组合多了,索引不一定能覆盖所有查询路径。
比如用户表上建了(status, age)的联合索引,那么WHERE status = ? AND age >= ?可以走索引。但如果用户只按username查询,这个索引就帮不上忙。
建议做法很简单:
- 统计业务中高频出现的条件组合
- 针对高频组合建联合索引
- 低频组合不建索引,避免索引过多拖慢写入
这个环节不建议一开始就做优化,而是等项目跑起来、慢查询出现后再调整。SpringJDBC 的动态查询不会改变数据库的索引选择逻辑,索引该怎么建还是怎么建。
6.2 数据量大时的 limit 还是必须加
如果一个动态查询没有带分页,又不限制最大返回条数,数据库可能一次返回几十万行,JVM 内存占用飙升。
我一般会在查询方法层面限制一个最大返回行数。比如列表查询必须分页,或者后台数据导出类任务也要分批处理,不能一条 SQL 把全表拉出来。
if (pageSize <= 0 || pageSize > 100) { pageSize = 20; }这个限制写在 DAO 层或 Service 层都可以,目的就是防止调用方误传了过大的分页参数。
6.3 防止 SQL 注入的完整思路
前面提到了参数占位符,这里再补几个容易忽略的点:
- 不要用拼接方式处理
ORDER BY、GROUP BY、LIMIT里的字段名,要白名单校验 - 不要用参数占位符处理表名、列名,因为占位符只能替换值,不能替换结构
- 如果必须动态传表名或列名,一定要硬编码一个白名单,不能直接用用户输入
假设你的系统有按不同业务类型查不同表的需求,正确做法是:
Map<String, String> tableMap = new HashMap<>(); tableMap.put("user", "user_account"); tableMap.put("order", "order_info"); String tableName = tableMap.get(bizType); if (tableName == null) { throw new IllegalArgumentException("不支持的业务类型"); }这样即使bizType是用户传的,也只能映射到预定义的几张表,不会注入其他地方。
7. 常见错误与排查方法
动态条件查询写多了,会遇到几个典型的报错和异常现象。我按排查顺序整理一遍。
7.1 报错“Column 'xxx' not found”或“BadSqlGrammarException”
现象:查询执行时报 SQL 语法错误或者列名不存在。
排查顺序:
- 先看 SQL 字符串最终是什么。最简单的方式是在 DAO 方法里临时打印
sql.toString()。 - 确认列名是否写错,数据库里到底是
created_at还是create_time。 - 确认表名是否写错,有没有加上库名前缀导致误匹配。
- 确认条件片段拼接的位置对不对,尤其是
AND或OR前后有没有缺空格,拼接出来的 SQL 可能变成usernamezhangsan。
这类问题 90% 以上是拼写或空格导致,不是框架问题。
7.2 报错“Parameter index out of range (2 > number of parameters, which is 1)”
现象:SQL 里有两个?,但只传了一个参数。
排查顺序:
- 数一下最终 SQL 里到底有几个
?。 - 再看
params列表长度是多少。 - 重点检查是不是某个条件进去了,但参数没有 add;或者某个参数 add 了,但 SQL 片段没拼上去。
- 检查有没有条件判断和参数添加逻辑之间插入了
return或异常抛出。
这类错误几乎都是条件判断和参数列表不同步导致,用前面提到的SqlParams工具类能有效避免。
7.3 查询结果为空,但数据明明存在
现象:条件查询返回空列表,但直接用 SQL 查数据库有数据。
排查顺序:
- 先检查参数值传进来到底是什么。比如
status传入的是null,那if (status != null)就不会拼条件,查出来可能是全表,不是空。如果查出来是空,看是不是status被传了数字 0,而业务上 0 是启用还是停用,搞反了。 - 检查日期范围是否包含边界值。比如条件用了
> startDate,而数据里的时间正好等于startDate,就会被排除。 - 检查模糊查询的
%位置。%拼在左边只能匹配前匹配,拼在右边是后匹配,两边都有才是包含匹配。 - 确认使用的列名和值的数据类型是否匹配,比如数据库字段是
VARCHAR,但前端传入整数,可能会有隐式转换问题。
7.4 查询执行很慢
现象:数据量不大,但查询耗时明显偏高。
排查顺序:
- 查看执行计划,确认是否走索引。
EXPLAIN SELECT ...是最直接的方式。 - 确认条件里有没有对索引列做函数运算。比如
WHERE DATE(created_at) = ?,会导致索引失效,应该改成created_at >= ? AND created_at < ?。 - 检查是不是
LIKE '%keyword%'导致的全文扫描。这种写法索引通常用不上,数据量大时只能接受全表扫描,或者引入专门的搜索方案。 - 确认是否查询返回了大量不需要的列。有时候
SELECT *会把超大字段也拉出来,影响性能,改成只查需要的列。
7.5 并发环境下查询数据不一致
动态条件查询本身不存在线程安全问题,因为每次查询都会创建新的StringBuilder和List,没有共享的可变状态。但如果有人在 DAO 里用了类级别的SimpleDateFormat,并发下可能出现时间格式错乱,这个坑更隐蔽。
我的建议是:
- 如果 JDK 8 及以上,用
LocalDateTime系列配合DateTimeFormatter,线程安全 - 不要在 DAO 或 Service 内部定义可变的 SimpleDateFormat 静态字段
8. 一个完整示例:多条件用户列表查询
把前面的思路综合起来,写一个完整的 DAO 示例,包含条件对象、动态 SQL、分页、排序、COUNT 查询。这可以当作你自己项目的代码模板。
8.1 实体类和条件对象
public class UserAccount { private Long id; private String username; private Integer status; private Integer age; private LocalDateTime createdAt; // getter/setter 省略 }public class UserQuery { private String username; private Integer status; private Integer minAge; private Integer maxAge; private LocalDateTime startDate; private LocalDateTime endDate; private String orderBy = "id"; private String orderDirection = "DESC"; private Integer pageNum = 1; private Integer pageSize = 20; // getter/setter 省略 }8.2 构建条件 SQL
private String buildWhere(UserQuery query, List<Object> params) { StringBuilder sql = new StringBuilder(" WHERE 1=1"); if (StringUtils.hasText(query.getUsername())) { sql.append(" AND username LIKE ?"); params.add("%" + query.getUsername() + "%"); } if (query.getStatus() != null) { sql.append(" AND status = ?"); params.add(query.getStatus()); } if (query.getMinAge() != null) { sql.append(" AND age >= ?"); params.add(query.getMinAge()); } if (query.getMaxAge() != null) { sql.append(" AND age <= ?"); params.add(query.getMaxAge()); } if (query.getStartDate() != null) { sql.append(" AND created_at >= ?"); params.add(query.getStartDate()); } if (query.getEndDate() != null) { sql.append(" AND created_at < ?"); params.add(query.getEndDate()); } return sql.toString(); }注意:这个方法和列表查询、COUNT 查询共用,所以不能包含排序和分页。
8.3 列表查询 + 分页 + 排序
public List<UserAccount> searchUsers(UserQuery query) { List<Object> params = new ArrayList<>(); StringBuilder sql = new StringBuilder( "SELECT id, username, status, age, created_at FROM user_account" ); sql.append(buildWhere(query, params)); String orderBy = "id"; if ("username".equals(query.getOrderBy())) { orderBy = "username"; } else if ("age".equals(query.getOrderBy())) { orderBy = "age"; } String direction = "DESC"; if ("asc".equalsIgnoreCase(query.getOrderDirection())) { direction = "ASC"; } sql.append(" ORDER BY ").append(orderBy).append(" ").append(direction); int pageNum = (query.getPageNum() == null || query.getPageNum() < 1) ? 1 : query.getPageNum(); int pageSize = (query.getPageSize() == null || query.getPageSize() < 1) ? 20 : query.getPageSize(); sql.append(" LIMIT ? OFFSET ?"); params.add(pageSize); params.add((pageNum - 1) * pageSize); return jdbcTemplate.query(sql.toString(), new BeanPropertyRowMapper<>(UserAccount.class), params.toArray()); }8.4 COUNT 查询
public int countUsers(UserQuery query) { List<Object> params = new ArrayList<>(); String sql = "SELECT COUNT(*) FROM user_account" + buildWhere(query, params); Integer count = jdbcTemplate.queryForObject(sql, Integer.class, params.toArray()); return count == null ? 0 : count; }8.5 为什么 COUNT 和列表查询要分开
有人会问:能不能一次查询就把总数和列表都拿到?在 SpringJDBC 里没有这种内置能力,需要分别执行。但这里有一个实践技巧:
如果你不需要在页面上显示“总共多少条”,只是想“取前 20 条”,那可以直接执行列表查询,不需要 COUNT。如果业务必须显示总数,才需要两条 SQL。
这个设计不是框架限制,而是职责分离的原则。COUNT 查询只关心总数,列表查询关心数据和排序,两个方法共享同一个buildWhere,保证条件一致,但各自负责各自的返回结果。
8.6 这个示例在真实项目里怎么用
Service 层调用示例:
@Service public class UserService { @Autowired private UserDao userDao; public PageResult<UserAccount> pageQuery(UserQuery query) { List<UserAccount> list = userDao.searchUsers(query); int total = userDao.countUsers(query); return new PageResult<>(total, list); } }PageResult就是一个简单的分页结果封装,里面放 total 和 list。如果后续要接入更多筛选条件,比如按角色、按用户来源、按最近登录时间,只需要改UserQuery和buildWhere。
9. 进阶:条件组合复杂了怎么办
前面的方案适合条件数量在 10 个以内。如果条件更多,或者条件之间存在复杂的业务关系,比如 A 或 B 同时满足、按分组动态拼接括号,就需要更进一步的处理。
9.1 用 Specification 思想封装条件
很多框架都有类似 JPA Criteria API 那样的条件抽象。SpringJDBC 没有内置这套东西,但可以自己做一个轻量的条件构建器。
核心思路是定义一个接口:
public interface Condition { String toSql(List<Object> params); }然后每个条件实现这个接口:
public class SimpleCondition implements Condition { private final String sql; private final Object[] values; public SimpleCondition(String sql, Object... values) { this.sql = sql; this.values = values; } @Override public String toSql(List<Object> params) { if (values != null) { Collections.addAll(params, values); } return sql; } }再提供一个组合类,支持and和or:
public class CompositeCondition implements Condition { private final List<Condition> conditions = new ArrayList<>(); private final String operator; // "AND" 或 "OR" public CompositeCondition(String operator) { this.operator = operator; } public CompositeCondition add(Condition condition) { conditions.add(condition); return this; } @Override public String toSql(List<Object> params) { if (conditions.isEmpty()) { return " 1=1 "; } StringBuilder sb = new StringBuilder("("); for (int i = 0; i < conditions.size(); i++) { if (i > 0) { sb.append(" ").append(operator).append(" "); } sb.append(conditions.get(i).toSql(params)); } sb.append(")"); return sb.toString(); } }使用时:
CompositeCondition condition = new CompositeCondition("AND") .add(new SimpleCondition("status = ?", 1)) .add(new CompositeCondition("OR") .add(new SimpleCondition("age >= ?", 30)) .add(new SimpleCondition("username LIKE ?", "%zhang%"))); String sql = "SELECT * FROM user_account WHERE " + condition.toSql(params);这种设计适合条件组合特别灵活的场景,但代码复杂度会提升。如果当前项目条件没有复杂到需要组合嵌套,不建议一上来就用这种模式,前面的SqlParams已经够用。
9.2 逻辑分组:括号怎么拼
有些业务查询可能是:
WHERE (status = 1 OR status = 2) AND age >= 20这种就需要在 SQL 片段里手动加括号。
用简单的 if 拼接也能实现,但要注意括号的位置。我更推荐把这种“固定业务含义”的查询条件抽取成独立方法,不要让buildWhere里的逻辑越来越长。
private void applyComplexCondition(StringBuilder sql, List<Object> params, UserQuery query) { if (query.getType() != null) { sql.append(" AND (status = ? OR status = ?)"); params.add(query.getType()); params.add(query.getType() + 1); // 具体业务规则按实际场景调整 } }把每个复杂的业务条件分离开,主方法保持简单,可读性会好很多。
9.3 多表关联条件下的动态查询
如果动态条件涉及JOIN查询,核心思路不变,只是 SQL 前缀部分要写好JOIN关系,后面的条件拼装和单表完全一致。
public List<OrderInfo> searchOrders(OrderQuery query) { StringBuilder sql = new StringBuilder( "SELECT o.id, o.order_no, u.username FROM order_info o " + "INNER JOIN user_account u ON o.user_id = u.id " ); List<Object> params = new ArrayList<>(); if (StringUtils.hasText(query.getOrderNo())) { sql.append("AND o.order_no = ?"); params.add(query.getOrderNo()); } if (StringUtils.hasText(query.getUsername())) { sql.append("AND u.username LIKE ?"); params.add("%" + query.getUsername() + "%"); } return jdbcTemplate.query(sql.toString(), new BeanPropertyRowMapper<>(OrderInfo.class), params.toArray()); }这里有个经验:多表查询的结果映射如果用BeanPropertyRowMapper,要注意同名列冲突。比如订单表和用户表都有status字段,SQL 查出来的是两张表的全字段,映射到实体类时可能互相干扰。解决办法是只查需要的列,并且给重复列起别名。
SELECT o.id, o.order_no, o.status AS order_status, u.username, u.status AS user_status FROM ...这样映射时就能区分。
10. 生产环境落地时的一些建议
条件进阶的写法在本地跑通只是第一步,真正放到生产环境,还应该提前想好下面几件事。
10.1 不要过度设计条件构建器
有些开发者会把条件构建器做得很重,支持各种嵌套、各种函数式写法。但 SpringJDBC 的项目本身通常不复杂,过重的抽象会让其他同事看不懂。
我的建议是:
- 条件少于 10 个:直接用
SqlParams工具类或 if 拼接 - 条件复杂但稳定:独立方法拆分,按业务块管理
- 条件灵活多变且需要 API 化:再考虑条件抽象模式
优先保证代码可读性,不要为了“优雅”牺牲直观性。
10.2 日志与监控
动态 SQL 很难像 MyBatis 那样直接看到完整 SQL,因为 SQL 和参数是分离的。排错时没有日志会非常痛苦。
建议在开发环境开启 SQL 日志。Spring Boot 配置:
logging.level.org.springframework.jdbc.core.JdbcTemplate=DEBUG logging.level.org.springframework.jdbc.core.StatementCreatorUtils=TRACE这样能打印 SQL 模板和执行参数。生产环境如果怕刷日志,可以只在出问题时临时开启,或在网关层统一记录慢查询日志。
long start = System.currentTimeMillis(); List<UserAccount> result = jdbcTemplate.query(sql.toString(), mapper, params.toArray()); long cost = System.currentTimeMillis() - start; if (cost > 500) { log.warn("Slow query, cost {} ms, sql: {}, params: {}", cost, sql, params); }慢查询日志一定要带params,不然查问题时不知道参数是什么。
10.3 单元测试怎么覆盖动态条件
动态条件查询最好写单元测试,不然每次改动都可能引入回归问题。重点覆盖这些场景:
- 所有条件都为空:应该返回全表,SQL 合法
- 单个条件生效:比如只传 status,其他不拼
- 组合条件:多个条件同时生效,参数顺序无误
- 分页参数:pageNum 为 1 时 offset 为 0
- 排序白名单:传入非法排序字段时使用默认排序
@Test void testSearchUsers_WithStatusOnly() { UserQuery query = new UserQuery(); query.setStatus(1); List<UserAccount> result = userDao.searchUsers(query); assertNotNull(result); assertTrue(result.stream().allMatch(u -> u.getStatus() == 1)); }如果项目里还没有引入测试框架,建议用spring-boot-starter-test,它能覆盖大部分测试需求。
10.4 数据权限怎么融合到条件里
很多后台系统有数据权限,比如“只能看本部门的数据”。这类权限过滤条件最好是固定拼在 DAO 层或 Service 层,不能在页面传入,否则会被绕过。
常见做法:
if (currentUser.isDataScoped()) { sql.append(" AND dept_id IN (?, ?, ?)"); params.addAll(currentUser.getDeptIds()); }这个条件应该由后端基于当前登录用户动态生成,而不是从请求参数里获取。放在条件构建的最后一段,保证任何列表查询都带上数据权限,避免越权访问。
11. 写到最后的一些实操体会
SpringJDBC 的条件查询,说到底就是两件事:SQL 要动态、参数要安全。做得好的项目,代码看起来清爽,加条件时不容易出错;做得不好的项目,一个大方法里堆满字符串连接,看着都头痛。
我个人最推荐的方式,是建立一个轻量的SqlParams工具类,或者直接采用条件对象加公共构建方法。它不用引入任何新框架,只是在 SpringJDBC 之上做了一层很薄的组织。项目规模不大时,用不上Specification、条件构建器等复杂抽象,反而更容易让没见过这段代码的同事快速理解。
如果你正处在“JdbcTemplate 能用,但列表查询越来越乱”的阶段,不要急着换 MyBatis,先把条件构建这一层整理好。很多时候,问题不是框架能力不够,而是条件组织方式还停留在粗暴拼接层面。把WHERE构建、参数列表、排序白名单、分页逻辑拆开,代码会立刻清晰很多。
真的要换 MyBatis 时,动态 SQL 的 XML 或注解写法也更容易迁移,因为你的业务语义已经拆得很清楚了。条件对象可以直接复用,DAO 方法签名也可以保留,只是实现方式换掉而已。
如果只记住几个关键点,我希望是这三个:参数永远通过占位符传递,排序字段必须白名单,SQL 片段和参数添加必须紧挨着写。做到这三条,SpringJDBC 的动态条件查询基本不会出大问题。