1. 动态SQL引发的血泪史:两小时debug实录
那天下午三点四十二分,我盯着屏幕上诡异的查询结果,后颈的汗毛一根根竖了起来。明明测试环境跑得好好的分页查询,在生产环境却返回了完全乱序的数据。更可怕的是,这个bug只在特定页码才会出现——当用户翻到第7页时,系统突然开始返回第3页的内容,就像有个调皮的精灵在数据库里偷偷改写了我的SQL。
作为经历过三次618大促的老兵,我本能地嗅到了动态SQL特有的"酸臭味"。这种味道往往出现在拼接字符串的WHERE条件里,潜伏在<if test>标签的夹缝中,或是藏在foreach循环的变量替换处。接下来的120分钟,我像法医解剖尸体般逐行拆解那段看似无害的XML配置,最终在某个被五层嵌套的<choose>标签里,揪出了那个缺失的#符号——它本应该标记参数绑定,却被写成了字符串拼接的$符号。
2. 动态SQL的暗礁分布图
2.1 参数绑定的生死抉择
MyBatis提供了#{}和${}两种参数注入方式,就像手术刀和砍刀的区别。那天坑害我的正是这段代码:
xml复制<select id="findUsers" resultType="User">
SELECT * FROM users
WHERE 1=1
<if test="department != null">
AND dept_id = ${department} <!-- 灾难的源头 -->
</if>
ORDER BY create_time DESC
LIMIT #{offset}, #{pageSize}
</select>
当传入department=3时,实际执行的SQL是:
sql复制SELECT * FROM users WHERE 1=1 AND dept_id = 3 ORDER BY create_time DESC LIMIT 60, 10
看起来没问题?但当攻击者传入department=3;DROP TABLE users--时,拼接后的SQL就会变成致命注射器。更隐蔽的问题是,这种写法会导致每次请求生成新的执行计划,当分页参数变化时,数据库可能选择不同的索引路径,这就是我遇到的分页乱序问题的根源。
2.2 条件分支的迷宫陷阱
动态SQL最迷人的<choose>/<when>组合,也是最容易制造逻辑漏洞的地方。我曾见过这样的时间查询:
xml复制<select id="findLogs">
SELECT * FROM operation_log
<where>
<choose>
<when test="startTime != null and endTime != null">
AND operate_time BETWEEN #{startTime} AND #{endTime}
</when>
<when test="startTime != null"> <!-- 缺少endTime判空 -->
AND operate_time >= #{startTime}
</when>
<when test="endTime != null"> <!-- 缺少startTime判空 -->
AND operate_time <= #{endTime}
</when>
</choose>
</where>
</select>
当同时传入startTime=null和endTime='2023-01-01'时,由于第一个when条件判断的是startTime != null and endTime != null,第二个when只判断了startTime != null,最终会落入第三个when条件,产生与预期完全相反的查询逻辑——返回所有早于2023年的记录,而不是预期的"无时间条件时返回空"。
3. 动态SQL的防御工事
3.1 参数绑定的军规
-
永远优先使用
#{}:就像不用root账户日常办公一样,把${}的使用场景缩减到极致。仅在以下情况考虑它:- 动态表名(但考虑用分表中间件更好)
- 动态列名(建议用枚举白名单校验)
- ORDER BY子句(需要参数过滤)
-
给
${}套上紧箍咒:必须使用时,添加正则校验:java复制@Pattern(regexp="^[a-zA-Z_][a-zA-Z0-9_]*$") private String sortColumn;
3.2 条件逻辑的单元测试矩阵
为每个动态SQL编写测试用例时,要像测试密码强度一样覆盖所有分支组合。以搜索接口为例:
| 测试用例 | name参数 | status参数 | department参数 | 预期结果 |
|---|---|---|---|---|
| TC001 | 非空 | 非空 | 非空 | 三个条件AND |
| TC002 | 空 | 非空 | 非空 | 后两个条件AND |
| TC003 | 非空 | 空 | 空 | 只有name条件 |
| TC004 | 空 | 空 | 空 | 无WHERE条件 |
3.3 SQL注射的熔断机制
在DAO层添加SQL拦截器,就像给代码装上烟雾报警器:
java复制public class SqlInjectionInterceptor implements Interceptor {
private static final Pattern SQL_PATTERN = Pattern.compile("([';]+|(--)+)", Pattern.CASE_INSENSITIVE);
@Override
public Object intercept(Invocation invocation) {
BoundSql boundSql = ...;
if (SQL_PATTERN.matcher(boundSql.getSql()).find()) {
throw new SecurityException("检测到可疑SQL语句");
}
return invocation.proceed();
}
}
4. 调试动态SQL的刑侦工具包
4.1 让SQL现出原形
在mybatis-config.xml中开启日志:
xml复制<configuration>
<settings>
<setting name="logImpl" value="STDOUT_LOGGING"/>
</settings>
</configuration>
或者更精细地控制:
java复制@Configuration
public class MyBatisConfig {
@Bean
public ConfigurationCustomizer configurationCustomizer() {
return configuration -> {
configuration.setLogPrefix("MYBATIS_DEBUG:");
configuration.setLogImpl(StdOutImpl.class);
};
}
}
4.2 执行计划显微镜
对于分页乱序问题,用EXPLAIN查看索引使用情况:
sql复制EXPLAIN SELECT * FROM users WHERE dept_id = 3 ORDER BY create_time DESC LIMIT 60, 10;
重点关注type列:
ALL:全表扫描(红色警报)index:全索引扫描(黄色预警)range:索引范围扫描(绿色)
4.3 参数快照技术
在Interceptor中打印完整参数:
java复制@Override
public Object intercept(Invocation invocation) {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
System.out.println("=== SQL参数快照 ===");
System.out.println("ID: " + ms.getId());
System.out.println("Params: " + JSON.toJSONString(parameter));
return invocation.proceed();
}
5. 那些年我们填过的坑
5.1 分页插件的幽灵数据
使用PageHelper时,这个陷阱坑过我们团队三次:
java复制// 错误示范
PageHelper.startPage(1, 10);
List<User> users = userMapper.selectByExample(example);
PageInfo<User> pageInfo = new PageInfo<>(users);
// 正确姿势
PageHelper.startPage(1, 10);
List<User> users = userMapper.selectByExample(example);
PageInfo<User> pageInfo = new PageInfo<>(users);
PageHelper.clearPage(); // 必须清理线程变量!
忘记调用clearPage()会导致后续非分页查询意外被改写,就像厨房里没关的煤气阀门。
5.2 枚举值的类型陷阱
当参数是枚举类型时:
java复制public enum UserStatus {
ACTIVE(1), INACTIVE(0);
private final int code;
// getter...
}
动态SQL中要这样判断:
xml复制<if test="status != null and status.code == 1"> <!-- 不能直接写status == 1 -->
5.3 日期比较的时区刺客
比较日期时永远显式指定格式:
xml复制<if test="startTime != null">
AND create_time >= DATE_FORMAT(#{startTime}, '%Y-%m-%d %H:%i:%s')
</if>
否则当应用服务器与数据库时区不一致时,你会收获一堆"薛定谔的查询结果"。
6. 动态SQL的生存法则
-
XML里不写业务逻辑:把复杂的条件判断移到Java代码中,XML只做简单组装。就像不在SQL里写业务逻辑一样。
-
每个
<if>标签都是雷区:为每个条件添加注释说明业务含义,就像给地雷阵画地图:xml复制<!-- 仅管理员可以查看已删除用户 --> <if test="showDeleted == true and currentUser.admin"> OR deleted = 1 </if> -
定期SQL扫描:使用Alibaba Druid的WallFilter或P3C插件进行静态检查,把动态SQL的安全检查做成CI/CD流水线的必过关卡。
那天下午五点四十三分,当我给那个${department}加上#号时,夕阳正好透过窗户照在屏幕上。两小时的debug之旅教会我的不仅是技术细节,更是一种对动态SQL的敬畏——它就像核能,用好了能提升十倍的开发效率,用不好就是一场灾难。现在每次写<if>标签时,我的手指都会不自觉地停顿0.3秒,这大概就是成长的代价吧。
