Java案例:如何优雅实现多条件查询?从SQL拼接到MyBatis动态SQL的实战指南
📑 目录导读
- 多条件查询的常见痛点
- 纯JDBC实现(含防SQL注入)
- MyBatis动态SQL(企业级推荐)
- Spring Data JPA Specification
- 高频问答:面试与实战避坑
- 性能优化与最佳实践
多条件查询的常见痛点
在Java企业级开发中,多条件查询(又称“组合查询”、“动态查询”)是最常见的需求之一——用户可以在UI上勾选多个筛选条件,后台需要根据实际输入动态生成SQL,许多开发者初期会陷入这些误区:

- 暴力拼接字符串:直接
String sql = "SELECT * FROM user WHERE 1=1",然后不断append,极易引发SQL注入 - 代码膨胀:每个查询条件写一个if判断,一个10个条件的查询,代码量翻倍,维护成本极高
- 索引失效:条件顺序不当或使用了
LIKE '%keyword%'导致全表扫描
真实案例:某电商后台管理系统,商品列表需要按“类目”、“价格区间”、“上架时间”、“关键词”等8个条件组合查询,初期采用JDBC拼接,上线一周后因SQL注入被攻破,紧急重构为MyBatis动态SQL。
方案一:纯JDBC实现(含防SQL注入)
如果项目不依赖ORM框架,可以用PreparedStatement+条件容器模式实现安全的多条件查询。
核心代码示例:
public List<User> searchUsers(String name, Integer ageMin, Integer ageMax, String city) {
StringBuilder sql = new StringBuilder("SELECT * FROM user WHERE 1=1");
List<Object> params = new ArrayList<>();
if (name != null && !name.isEmpty()) {
sql.append(" AND name LIKE ?");
params.add("%" + name + "%");
}
if (ageMin != null) {
sql.append(" AND age >= ?");
params.add(ageMin);
}
if (ageMax != null) {
sql.append(" AND age <= ?");
params.add(ageMax);
}
if (city != null && !city.isEmpty()) {
sql.append(" AND city = ?");
params.add(city);
}
// 执行PreparedStatement
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql.toString())) {
for (int i = 0; i < params.size(); i++) {
ps.setObject(i + 1, params.get(i));
}
return executeQuery(ps);
}
}
✅ 优点:
- 完全避免SQL注入(参数化查询)
- 不依赖第三方库,适合遗留系统
❌ 缺点:
- 条件增多时代码冗长
- 动态排序、分页需额外处理
方案二:MyBatis动态SQL(企业级推荐)
MyBatis提供的<if>、<where>、<choose>标签是解决多条件查询的利器,结合<sql>片段可复用。
1 Mapper XML写法
<select id="searchUserByCondition" resultType="User">
SELECT * FROM user
<where>
<if test="name != null and name != ''">
AND name LIKE CONCAT('%',#{name},'%')
</if>
<if test="ageMin != null">
AND age >= #{ageMin}
</if>
<if test="ageMax != null">
AND age <= #{ageMax} <!-- 注意小于号转义 -->
</if>
<if test="city != null and city != ''">
AND city = #{city}
</if>
</where>
<if test="orderBy != null and orderBy != ''">
ORDER BY ${orderBy} ${direction} <!-- 仅对字段名排序 -->
</if>
</select>
2 进阶:使用<trim>自定义前缀后缀
<select id="searchWithTrim" resultType="User">
SELECT * FROM user
<trim prefix="WHERE" prefixOverrides="AND |OR ">
<if test="name != null">AND name LIKE #{name}</if>
<if test="code != null">AND code = #{code}</if>
</trim>
</select>
3 动态排序与分页结合
PageHelper.startPage(pageNum, pageSize); // 分页插件 List<User> list = userMapper.searchUserByCondition(userQuery); PageInfo<User> pageInfo = new PageInfo<>(list);
✅ 优点:
- 高度可读,与SQL语法接近
- 内置OGNL表达式,支持复杂逻辑
- 与Spring Boot无缝集成
❌ 缺点:
- XML文件可能膨胀(可通过注解@SelectProvider缓解)
方案三:Spring Data JPA Specification
如果使用JPA,可通过Specification接口实现类型安全的动态查询。
核心实现:
public class UserSpecification {
public static Specification<User> buildQuery(UserQuery query) {
return (root, criteriaQuery, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
if (query.getName() != null) {
predicates.add(criteriaBuilder.like(root.get("name"), "%" + query.getName() + "%"));
}
if (query.getAgeMin() != null) {
predicates.add(criteriaBuilder.greaterThanOrEqualTo(root.get("age"), query.getAgeMin()));
}
if (query.getAgeMax() != null) {
predicates.add(criteriaBuilder.lessThanOrEqualTo(root.get("age"), query.getAgeMax()));
}
return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
};
}
}
调用方式:
List<User> users = userRepository.findAll(UserSpecification.buildQuery(query));
✅ 优点:
- 纯Java API,无XML,编译期类型安全检查
- 适合喜欢面向对象风格的团队
❌ 缺点:
- 复杂关联查询(JOIN)时Predicate构造较繁琐
- 需熟悉JPA Criteria API
高频问答:面试与实战避坑
❓ Q1:WHERE 1=1 写法是否影响性能?
A:MySQL查询优化器会自动忽略WHERE 1=1(条件恒为true),因此对性能无影响,但代码可读性较差,推荐使用MyBatis的<where>标签或JPA的Specification自动处理。
❓ Q2:如何防止排序字段的SQL注入?
A:排序字段不能直接拼接字符串!推荐两种方案:
- 使用白名单校验:只允许预定义的字段名(如
"name","age","create_time") - MyBatis中用但不信任用户输入,结合枚举限制
❓ Q3:多表关联查询时,动态条件如何处理?
A:建议封装成VO(视图对象),在Mapper中通过<association>或<collection>映射。
// 商品+分类名查询
public class ProductQuery {
private String productName;
private String categoryName; // 关联分类表
private BigDecimal priceMin;
}
<select id="searchProduct" resultMap="productMap">
SELECT p.*, c.name as category_name
FROM product p
LEFT JOIN category c ON p.category_id = c.id
<where>
<if test="productName != null">AND p.name LIKE #{productName}</if>
<if test="categoryName != null">AND c.name = #{categoryName}</if>
</where>
</select>
❓ Q4:前端传空字符串与null如何处理?
A:必须区分!
- 空字符串:表示用户输入了内容但清空(应忽略该条件)
- null:表示用户未操作(同样忽略)
MyBatis的
test="param != null and param != ''"可以同时过滤。
性能优化与最佳实践
1 索引设计原则
-- 针对高频查询组合创建复合索引 CREATE INDEX idx_user_age_name ON user(age, name); -- 注意:LIKE '%keyword' 无法使用索引,改为全文检索或ES
2 分页查询的陷阱
- 不要使用
LIMIT offset,size(大偏移量性能差),改用WHERE id > ? LIMIT size的分页方式 - 结合子查询提前过滤主键
3 代码结构建议
com.example.query
├── UserQuery.java // 查询参数DTO
├── QueryBuilder.java // 查询构建器(可选)
├── UserMapper.java // MyBatis接口
└── UserRepository.java // JPA Repository
4 最终决策树
| 条件 | 推荐方案 |
|---|---|
| 已有MyBatis项目 | MyBatis Dynamic SQL |
| 纯JPA项目 | Specification + Criteria |
| 老系统JDBC | PreparedStatement+参数列表 |
| 超复杂查询(如报表) | 存储过程/Elasticsearch |
通过以上四种方案的详细对比,你会发现MyBatis动态SQL是Java多条件查询的最佳实践——它在SQL灵活性、可维护性、安全性之间取得了完美平衡,建议结合项目现有技术栈选择,但无论哪种方案,核心原则始终是:参数化查询避免注入,动态条件需精准控制,索引与分页不能忽视。
实践建议:从明天开始,把你的Controller中的查询参数都封装成
*Query的DTO类,并统一在Service层构建条件,一周后你会感激这个重构带来的代码清晰度提升。