这次我们接着SpringJDBC系列往下讲“条件进阶”。前面几篇已经覆盖了JdbcTemplate的增删改查、RowMapper、事务管理;这一篇解决的是实际工程里最常遇到的问题:查询条件一多,SQL 怎么拼才安全、好维护、不注入。条件进阶不是引入新框架,而是把动态条件拼接、参数绑定、排序分页、IN查询、批量更新这些细节统一处理。
如果你正在用Spring JDBC做中小型项目,或者准备自研一套轻量持久层,下面这几种条件是必写的:动态AND拼接、命名参数、IN列表、排序字段白名单、分页偏移量、批量更新。文章会从简单写法逐步过渡到可维护的封装,最后给出常见报错排查表。示例以Spring Boot 2.7+ / Spring Framework 5.3+为主,JDK 8以上就能跑;如果你用的是Spring Boot 3.x,则对应JDK 17+,代码差异不大。
这篇文章适合三类读者:第一类是项目里还在手写各种String sql = ...拼接条件的同学;第二类是准备把查询条件抽成公共对象、减少 DAO 重复代码的同学;第三类是想要对比JdbcTemplate和NamedParameterJdbcTemplate在实际业务中怎么选型的同学。读完以后,你至少能把一个带筛选、排序、分页、批量操作的用户查询完整跑通。
1. 核心能力速览
先看这次教程覆盖的能力范围,方便你判断需要重点看哪一部分。
| 能力项 | 说明 |
|---|---|
| 主题定位 | Spring JDBC 动态条件查询的编写与封装 |
| 基础依赖 | spring-jdbc / spring-boot-starter-jdbc |
| 运行环境 | JDK 8+,Spring Framework 5.3+,Spring Boot 2.x / 3.x |
| 核心 API | JdbcTemplate、NamedParameterJdbcTemplate、RowMapper、ParameterSource |
| 动态条件 | AND拼接、空值跳过、IN列表、排序、分页 |
| 安全重点 | 参数绑定、排序字段白名单、服务端权限过滤 |
| 批量任务 | 批量更新状态、事务回滚、批大小控制 |
| 适合场景 | 后台管理列表、报表筛选、中小项目持久层 |
| 不适合场景 | 超高复杂度动态 SQL、海量数据分库分表 |
这里要提前说明:Spring JDBC本身不提供类似 MyBatis 的<if>动态 SQL 语法。所谓“条件进阶”,本质上是你在 Java 层把条件和参数组织好,再传给JdbcTemplate或NamedParameterJdbcTemplate。方案没有唯一答案,但安全底线是一致的:任何外部输入都不能直接拼进 SQL 字符串。
2. 适用场景与使用边界
Spring JDBC的条件进阶适合业务规则相对稳定、条件数量有限的项目。比如后台用户管理列表,常见筛选条件就是用户名、状态、创建时间、部门 ID,这类场景完全用不着引入 MyBatis,用JdbcTemplate加上一个查询条件对象就够了。
它也适合作为自研轻量持久层的基础。很多团队不想把整个 ORM 框架铺进来,只想要一个“能安全处理动态条件”的薄封装,那么这篇文章里的NamedParameterJdbcTemplate和条件构造器思路可以直接拿来改。
但也有不适合的边界。如果你的系统里出现几十个可选条件、动态JOIN、动态SELECT字段、不同数据库方言混用,那么再继续堆ConditionBuilder会导致 SQL 逻辑分散在 Java 层,维护成本会快速上升。这时候更合适的方案是 MyBatis 的 XML 动态 SQL,或者成熟的查询库。
还有一个必须强调的边界:条件查询经常涉及手机号、邮箱、证件号等个人信息。测试环境一定要用脱敏数据,生产环境要根据登录用户身份做数据权限过滤。后端不能只依赖前端传来的 userId 作为过滤条件,而是应该从SecurityContext、Session或网关透传的登录态里取当前用户。这不是额外的“加分项”,是合规底线。
3. 环境准备与前置条件
我们先准备一个最小可运行工程。使用 Maven 管理依赖时,加入spring-boot-starter-jdbc,再选择一个数据库驱动。演示阶段建议使用 H2 内存库,避免每个人数据库方言不同导致 SQL 执行结果不一致;确认逻辑没问题后,再把连接串切回 MySQL 或 PostgreSQL。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <!-- 演示用 H2 --> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <!-- 实际项目如果使用 MySQL,去掉 H2 后加下面依赖 --> <!-- <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> -->H2 内存库的连接配置如下。使用 MySQL 时,把url、driver-class-name替换成你本机的配置。
spring: datasource: url: jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1 driver-class-name: org.h2.Driver username: sa password: # 打开 JDBC SQL 日志,方便观察条件拼接结果 logging: level: org.springframework.jdbc.core: DEBUG初始化脚本可以放在src/main/resources/schema.sql,Spring Boot 默认会启动时执行。
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 1, email VARCHAR(128), update_time TIMESTAMP ); INSERT INTO sys_user (username, status, email, update_time) VALUES ('alice', 1, 'alice@example.com', CURRENT_TIMESTAMP), ('bob', 1, 'bob@example.com', CURRENT_TIMESTAMP), ('carol', 0, 'carol@example.com', CURRENT_TIMESTAMP), ('dave', 1, 'dave@example.com', CURRENT_TIMESTAMP);这张sys_user表足够覆盖文章里的全部示例。注意不要使用user作为表名,因为它在部分数据库里是保留字,容易踩坑。
4. 基础条件查询:JdbcTemplate 的两种取数方式
在讲动态条件之前,先确认两种最常用取数方式。第一种是queryForList,适合字段固定、直接映射为Map的场景;第二种是query配合RowMapper,适合映射为业务对象。
4.1 queryForList 快速取值
@Repository public class UserDao { private final JdbcTemplate jdbcTemplate; public UserDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public List<Map<String, Object>> findSimpleList() { String sql = "SELECT id, username, status FROM sys_user WHERE status = ?"; return jdbcTemplate.queryForList(sql, 1); } }queryForList(String sql, Object... args)返回的是List<Map<String, Object>>,每一行是一个列名到值的 Map。优点是写起来快,适合页面掉数据、临时接口;缺点是列名和值都以 Map 方式流动,重构时容易写错列名,类型也要手动转换。建议只用于简单场景。
4.2 query + RowMapper 映射对象
更推荐的是RowMapper。把结果集每一行映射成User对象,列名转换成属性名的逻辑集中在同一个地方。
public class User { private Long id; private String username; private Integer status; private String email; private LocalDateTime updateTime; // getter / setter / toString }@Repository public class UserDao { private final JdbcTemplate jdbcTemplate; public UserDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } private final RowMapper<User> userRowMapper = (rs, rowNum) -> { User user = new User(); user.setId(rs.getLong("id")); user.setUsername(rs.getString("username")); user.setStatus(rs.getInt("status")); user.setEmail(rs.getString("email")); user.setUpdateTime(rs.getTimestamp("update_time") == null ? null : rs.getTimestamp("update_time").toLocalDateTime()); return user; }; public List<User> findByStatus(int status) { String sql = "SELECT id, username, status, email, update_time FROM sys_user WHERE status = ?"; return jdbcTemplate.query(sql, userRowMapper, status); } }从这开始,后面所有动态条件都复用userRowMapper,所以业务代码只关注 SQL 条件和参数。
5. 动态条件拼接的几种方案
动态条件的难点不是“能拼出来”,而是“拼得对不对、安不安全、可不可维护”。下面从错误写法开始,逐步给出三种可落地的方案。
5.1 字符串拼接:能跑但别这么写
很多老代码会写成这样:
String sql = "SELECT id, username, status, email FROM sys_user WHERE username = '" + username + "'";这个写法一旦username来自用户输入,就存在 SQL 注入风险。更隐蔽的问题是:如果username为空,你还要判断要不要带这个WHERE条件,代码会越写越长,最后变成一串if-else拼AND。
所以第一条原则很简单:不要直接拼接用户传入的值,哪怕只是临时调试也必须改成参数绑定。
5.2 NamedParameterJdbcTemplate + Map:推荐方案
NamedParameterJdbcTemplate是条件进阶的基础,它把参数从?换成:name,和Map配合时,顺序问题基本消失。
@Repository public class UserDao { private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; private final RowMapper<User> userRowMapper = (rs, rowNum) -> { /* 同上 */ }; public UserDao(NamedParameterJdbcTemplate namedParameterJdbcTemplate) { this.namedParameterJdbcTemplate = namedParameterJdbcTemplate; } public List<User> searchUsers(UserQuery query) { StringBuilder sql = new StringBuilder( "SELECT id, username, status, email, update_time FROM sys_user WHERE 1 = 1"); Map<String, Object> params = new HashMap<>(); if (StringUtils.hasText(query.getUsername())) { sql.append(" AND username = :username"); params.put("username", query.getUsername()); } if (query.getStatus() != null) { sql.append(" AND status = :status"); params.put("status", query.getStatus()); } sql.append(" ORDER BY id DESC"); return namedParameterJdbcTemplate.query(sql.toString(), params, userRowMapper); } }这里用WHERE 1 = 1简化代码,后续每个条件都以AND开头,条件不存在时就跳过。这个写法能解决 80% 的动态条件问题。它的好处是:参数名和值放在同一个Map里,代码阅读顺序和 SQL 顺序一致;再也不用关心?的先后位置。
如果你不喜欢WHERE 1 = 1,可以把拼接逻辑抽成专用构造器,最后只插入一次WHERE。
5.3 JdbcTemplate + 占位符:参数顺序稳定时也可以用
如果项目里没有引入NamedParameterJdbcTemplate,只用原生JdbcTemplate也能动态拼接,但参数必须按顺序放进Object[]。
public List<User> searchUsersWithPlaceholder(UserQuery query) { StringBuilder sql = new StringBuilder( "SELECT id, username, status, email, update_time FROM sys_user WHERE 1 = 1"); List<Object> args = new ArrayList<>(); if (StringUtils.hasText(query.getUsername())) { sql.append(" AND username = ?"); args.add(query.getUsername()); } if (query.getStatus() != null) { sql.append(" AND status = ?"); args.add(query.getStatus()); } sql.append(" ORDER BY id DESC"); return jdbcTemplate.query(sql.toString(), args.toArray(), userRowMapper); }这个方案适合条件数量少、参数顺序不容易搞错的场景。一旦条件超过五六个,或者后续有人调整条件顺序,维护难度会变大。所以只要条件比较灵活,优先用命名参数。
5.4 IN 条件与列表参数
列表筛选是常见需求,比如批量查询 ID 为 1、3、5 的用户。如果手写占位符,需要动态生成IN (?, ?, ?),还要把列表元素逐个放进参数数组,非常容易错。命名参数可以直接传一个Collection。
public List<User> findByIds(List<Long> ids) { String sql = "SELECT id, username, status, email FROM sys_user WHERE id IN (:ids)"; Map<String, Object> params = new HashMap<>(); params.put("ids", ids); return namedParameterJdbcTemplate.query(sql, params, userRowMapper); }NamedParameterJdbcTemplate遇到:ids对应一个Collection时,会自动展开成多个占位符,底层实现就是先扩展 SQL,再绑定参数。这个特性非常适合批量列表页和批量操作。需要注意:如果ids为空,不要执行这条 SQL,否则数据库端会收到一个空的IN,导致语法错误或查询全部数据。
6. 排序、分页与统计
动态查询常常伴随排序和分页,这两块容易踩坑:排序字段如果直接拼接,会有 SQL 注入风险;分页偏移量计算错了,结果会漏数据或重复。
6.1 排序字段白名单
排序字段不能直接接受前端传的字符串再拼进 SQL,正确做法是做一个白名单映射。前端只能传“排序代码”,后端把它翻译成安全的列名。
private String resolveOrderBy(String orderBy) { Set<String> allowed = new HashSet<>(Arrays.asList("id", "username", "status")); if (orderBy != null && allowed.contains(orderBy)) { return orderBy; } return "id"; } private String resolveOrderDirection(String direction) { return "asc".equalsIgnoreCase(direction) ? "ASC" : "DESC"; }使用的时候,把解析后的列名和方向拼进 SQL。因为列名只能从白名单里产生,所以不会被注入。
String safeOrderBy = resolveOrderBy(query.getOrderBy()); String safeDirection = resolveOrderDirection(query.getOrderDirection()); sql.append(" ORDER BY ").append(safeOrderBy).append(" ").append(safeDirection);6.2 分页与 OFFSET
分页参数通常由前端传入pageNo和pageSize,后端必须做默认值和上限控制。以 MySQL 的LIMIT ? OFFSET ?为例:
int pageNo = query.getPageNo() == null ? 1 : query.getPageNo(); int pageSize = query.getPageSize() == null ? 20 : query.getPageSize(); // 防止一次拿太多数据拖垮数据库 pageSize = Math.min(pageSize, 200); int offset = (pageNo - 1) * pageSize; sql.append(" LIMIT ? OFFSET ?"); args.add(pageSize); args.add(offset);如果是NamedParameterJdbcTemplate,就改成给params放limit、offset两个值,SQL 里写LIMIT :limit OFFSET :offset。要注意不同的数据库分页语法不同,例如 PostgreSQL 也是LIMIT/OFFSET,SQL Server 则是OFFSET ... ROWS FETCH NEXT ... ROWS ONLY。封装时把分页方言抽出来,尽量不散落到各个查询里。
6.3 动态统计 count
分页通常需要先查总数。统计条件要和查询条件保持完全一致,否则会出现“列表显示第一页,但总页数错误”的问题。最简单的方式是把同一个条件构造器复用两次。
public long countUsers(UserQuery query) { StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM sys_user WHERE 1 = 1"); Map<String, Object> params = new HashMap<>(); if (StringUtils.hasText(query.getUsername())) { sql.append(" AND username = :username"); params.put("username", query.getUsername()); } if (query.getStatus() != null) { sql.append(" AND status = :status"); params.put("status", query.getStatus()); } Long count = namedParameterJdbcTemplate.queryForObject(sql.toString(), params, Long.class); return count == null ? 0L : count; }这里的关键是:不能让查询列表和统计的筛选条件各写一遍,否则后续新增一个email条件时,漏改 count 就会出现隐藏 bug。更好的做法是把公共条件构建放在一个方法里。
7. 查询条件对象与 DAO 接口设计
条件多了以后,方法参数会越来越难看。比如searchUsers(String username, Integer status, String email, Integer pageNo, Integer pageSize, String orderBy, String orderDirection)这样的方法签名,谁看到都头疼。解决方法是定义一个查询条件对象。
public class UserQuery { private String username; private Integer status; private List<Long> ids; private Integer pageNo = 1; private Integer pageSize = 20; private String orderBy = "id"; private String orderDirection = "DESC"; // getter / setter }DAO 方法签名就变成一个参数:
public interface UserDao { List<User> searchUsers(UserQuery query); long countUsers(UserQuery query); int[] batchUpdateStatus(List<Long> ids, int status); }Service 层调用时,前端传的参数先转换成UserQuery,再交给 DAO。UserQuery也可以直接在 Spring MVC 里作为查询参数对象使用,因为 Spring 支持按照对象属性名绑定请求参数。
@RestController @RequestMapping("/users") public class UserController { private final UserDao userDao; public UserController(UserDao userDao) { this.userDao = userDao; } @GetMapping public List<User> list(UserQuery query) { return userDao.searchUsers(query); } }这样 Controller 很薄,DAO 也不臃肿,条件对象还可以在多个查询之间复用。如果你还需要分页结果对象,可以用一个PageResult<T>包装total和list,在 Service 层完成组装。
8. 批量更新与事务边界
条件进阶不只是查询,也包括批量任务。比如后台勾选一批用户后批量禁用,前端传入ids和status,后端一次性更新。用NamedParameterJdbcTemplate.batchUpdate可以避免循环单条更新,减少数据库往返。
@Transactional(rollbackFor = Exception.class) public int[] batchUpdateStatus(List<Long> ids, int status) { String sql = "UPDATE sys_user SET status = :status, update_time = NOW() WHERE id = :id"; SqlParameterSource[] batch = new SqlParameterSource[ids.size()]; for (int i = 0; i < ids.size(); i++) { batch[i] = new MapSqlParameterSource() .addValue("status", status) .addValue("id", ids.get(i)); } return namedParameterJdbcTemplate.batchUpdate(sql, batch); }batchUpdate返回一个int[],每个元素表示对应批次影响的行数。如果你希望“要么全部成功,要么全部回滚”,必须在方法上加上@Transactional。注意,事务注解默认只对RuntimeException回滚,如果方法可能抛出受检异常,要使用rollbackFor = Exception.class或明确指定异常类型。
批量任务还要控制批大小。一次更新 10 万条数据,平均每批次 500 条左右比较常见,具体取决于数据库性能和网络延迟。一次性把 10 万条记录全部放进SqlParameterSource[],会占用大量内存,也容易造成数据库锁竞争。分批处理时,每批执行完建议记录成功条数和失败原因,方便后续补偿或重试。
9. DAO 方法 API 化:Service 层调用和批量任务
这一节把上面的 DAO 方法当作“内部 API”来使用,演示 Service 层的标准调用方式。这里的“接口 API”不是 HTTP 接口,而是指 DAO 层方法对外提供的稳定契约。先把条件构建和分页结果封装好,Service 调用时才不会越写越乱。
@Service public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao = userDao; } public List<User> searchUsers(UserQuery query) { return userDao.searchUsers(query); } @Transactional(rollbackFor = Exception.class) public void disableUsers(List<Long> ids) { if (ids == null || ids.isEmpty()) { return; } userDao.batchUpdateStatus(ids, 0); } }如果你确实需要暴露一个 HTTP 接口给前端,也可以在上面的UserController基础上补充批量操作接口。
@PostMapping("/batch/disable") public void disableUsers(@RequestBody List<Long> ids) { userService.disableUsers(ids); }接口层不要直接接收String拼出来的 SQL,也不要接收任意排序字段。对外参数进入 Service 后,一律转换成内部可信的查询对象或合法字段,这是条件进阶接口设计的基本要求。
10. 资源占用与性能观察
Spring JDBC没有独立的推理引擎,所以资源占用主要看连接池、SQL 执行计划和查询结果集大小。观察性能时可以从几个维度入手:SQL 日志、连接池监控、慢查询日志。
先打开 SQL 日志,确认实际执行的 SQL 和参数是否符合预期。
logging: level: org.springframework.jdbc.core: DEBUG日志里会出现类似:
Executing prepared SQL statement Executing prepared statement [SELECT id, username, status, email FROM sys_user WHERE status = ?]看到真实 SQL 后,再到数据库执行EXPLAIN,观察是否走了索引。动态条件越多,索引选择越复杂,尤其是多条件组合查询很容易变成全表扫描。常用做法是:在username、status、update_time上按实际筛选频率建联合索引;字段区分度太低时,索引不一定有效。
WHERE 1 = 1在生产上并不是严重的性能问题,绝大多数数据库优化器会忽略这个恒真条件。但从代码整洁度来说,可以用条件构造器生成不带1 = 1的 SQL。分页时不要用OFFSET翻到很深的页,比如第 100000 条,数据库还是要扫描前 10 万行,性能会明显下降。对于后台管理列表,限制最多翻 500 页或限制最大pageSize是常见的保护手段。
如果是批量任务,还要关注内存占用。batchUpdate的批次数组、超大IN列表、一次性载入大量查询结果,都会推高 JVM 内存。建议批量处理时每批List<Long> ids控制在几百到几千,处理完一批立即释放引用。
11. 常见问题与排查方法
条件进阶的报错,很多都出在参数、列名、SQL 语法三方面。下面表格整理了高频问题,基本覆盖日常开发。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 查询结果一直为空 | 条件拼接错误,参数没传进去 | 打开 SQL 日志,拼接的 SQL 和参数逐个核对 | 检查StringUtils.hasText和 null 判断 |
| SQL 注入风险 | 用户输入直接拼进 SQL | 检查是否用到?或:name | 全部改为参数绑定 |
?数量不匹配 | 条件多了一个或少了一个参数 | 查看Incorrect result set或SQLException | 用NamedParameterJdbcTemplate降低参数维护成本 |
| 明明有数据但查不到 | 列名和属性名映射不上 | 检查表列名与RowMapper取值名 | 统一使用username, status等明确列名 |
IN (:ids)执行失败 | ids为空或传了 null | 打印params查看集合内容 | 空集合不执行查询 |
| 排序字段注入 | 前端直接传orderBy=username;drop table | 日志观察最终 SQL | 使用排序白名单 |
| 分页重复或漏数据 | offset计算错误 | 检查pageNo / pageSize和OFFSET值 | 统一使用(pageNo - 1) * pageSize |
| 事务批量更新只成功一部分 | 没有加@Transactional或异常被吞掉 | 查看日志是否有异常被 catch | 加@Transactional(rollbackFor = Exception.class) |
| count 和 list 结果不一致 | count 条件比 list 少或多了 | 对比两个方法拼接条件是否一致 | 抽取公共条件构造方法 |
特殊字符%_导致模糊查询不准确 | 直接使用LIKE :keyword | 确认业务是否需要通配符 | 使用ESCAPE或手动转义通配符 |
排查时最忌讳直接改代码反复尝试。正确流程是:先打开 SQL 日志,确认真实执行的 SQL;再拿到数据库客户端手动执行这条 SQL,判断是 SQL 问题还是参数问题;最后检查代码里条件是否按预期拼接。
12. 最佳实践与使用建议
把这套条件进阶方案放进真实项目时,建议遵守下面几条工程规范,能省掉很多后续维护成本。
第一,所有外部输入都走参数绑定。这是安全底线,没有例外。包括搜索关键词、筛选状态、排序方向,排序列名必须走白名单。任何情况都不允许把用户输入直接拼进sql.append。
第二,查询条件对象统一入参。不要写参数超过三四个的 DAO 方法。定义UserQuery这样的条件对象,后续加筛选字段不会改方法签名,也不会影响已经写好的调用方。
第三,只查需要的字段。条件查询最忌讳SELECT *。列表页按需选择字段,既能减少网络传输,也能让RowMapper保持稳定。需要大字段时单独定义详情查询。
第四,分页和批量操作都要有上限。pageSize设置最大值,批量更新的ids分批执行。宁愿多写一个循环分页方法,也不要让数据库一次扛下全量更新。
第五,日志里不要打印敏感参数。SQL 日志一般会打印参数值,手机号、邮箱、密码这类字段不要出现在查询条件里。测试环境可以打详细日志,生产环境要收紧到WARN以上。
第六,为条件查询写测试用例。至少覆盖:无任何条件的查询、只传一个条件、多个条件组合、传入null、传入空字符串、传入id列表、排序方向和分页偏移。条件进阶的 bug 通常藏在这些边界组合里,不测试很难发现。
13. 总结与下一步
这篇文章的核心是把Spring JDBC的条件查询做成“可控状态”。从最基础的queryForList和RowMapper开始,到NamedParameterJdbcTemplate的动态条件,再到IN、排序白名单、分页、批量更新,最后落到 DAO 接口设计和常见问题排查。
最容易踩的坑有两个:一个是参数和占位符错位,一个是排序字段被注入。建议你回到自己项目里,先挑一个最常变的列表查询,改造成UserQuery + NamedParameterJdbcTemplate,再把排序字段改成白名单,然后跑一遍原有接口的功能测试。
下一步可以继续做三件事:把公共条件拼接抽成一个ConditionBuilder,减少 DAO 里的重复if;把分页结果封装成PageResult<T>,统一所有列表接口返回结构;如果条件复杂度继续上升,再评估引入 MyBatis 或 Spring Data JDBC。条件进阶的本质不是背 API,而是把“安全、可读、可维护”这三个要求落实在每一行 SQL 拼接代码上。