一、为什么需要自定义SQL?

你可能已经习惯了MyBatis-Plus的QueryWrapper,用它写简单的查询确实爽到飞起,但遇到下面这些场景时,是不是感觉被捆住了手脚?

  • 多表联查:想查用户信息连带部门名称,结果发现Join操作根本没法用Wrapper搞定
  • 复杂统计:老板让你统计每个部门的平均工资+最高年龄,手写SQL又得掏出来
  • 特殊语法:比如Oracle的WITH语句、SQL Server的TOP,MP的Wrapper根本不支持
  • 性能优化:某些场景必须手写优化过的SQL,自动生成的SQL效率太低

这时候,自定义SQL就是你的救命稻草!


二、两种姿势写自定义SQL

姿势1:注解式(适合简单SQL)

直接在Mapper接口的方法上加@Select@Update等注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface UserMapper extends BaseMapper<User> {

// 案例:查询指定部门下薪资最高的用户(假设需要复杂子查询)
@Select("SELECT * FROM user WHERE dept_id = #{deptId} ORDER BY salary DESC LIMIT 1")
User findTopSalaryUser(@Param("deptId") Long deptId);

// 案例:批量重置用户状态(用foreach处理集合)
@Update("<script>" +
"UPDATE user SET status = 0 WHERE id IN " +
"<foreach item='id' collection='ids' open='(' separator=',' close=')'>" +
"#{id}" +
"</foreach>" +
"</script>")
int batchResetStatus(@Param("ids") List<Long> ids);
}

适用场景

  • SQL简短(不超过10行)
  • 不需要动态拼接条件
  • 快速写个简单查询

坑点提醒

  • 注解里写复杂SQL,代码会变得像乱炖菜
  • 动态SQL得用<script>标签包裹,手写容易漏符号

姿势2:XML式(推荐复杂SQL)

resources/mapper目录下建XML文件,比如UserMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- 1. 先配置XML头 -->
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<!-- 2. 绑定Mapper接口 -->
<mapper namespace="com.yourpackage.mapper.UserMapper">

<!-- 案例:带条件的分页联表查询 -->
<select id="selectUserWithDept" resultType="com.yourpackage.vo.UserDeptVO">
SELECT
u.id, u.name, u.age,
d.name AS deptName
FROM user u
LEFT JOIN dept d ON u.dept_id = d.id
<!-- 这里可以复用Wrapper的条件! -->
${ew.customSqlSegment}
</select>

<!-- 案例:调用存储过程 -->
<select id="callCalculateBonus" statementType="CALLABLE">
{call calculate_annual_bonus(#{userId, mode=IN})}
</select>
</mapper>

为什么推荐XML?

  • 复杂SQL排版清晰,自带高亮
  • 支持resultMap处理复杂结果映射
  • 可以复用MyBatis的动态SQL标签(ifforeach等)

三、实战技巧:把Wrapper和自定义SQL揉在一起

场景:动态条件联表查询

需求:根据前端传参,动态查询用户及其部门信息,支持姓名模糊搜索、部门ID过滤、年龄范围。

步骤1:定义VO接收结果

1
2
3
4
5
6
7
@Data
public class UserDeptVO {
private Long userId;
private String userName;
private Integer age;
private String deptName;
}

步骤2:Mapper接口加方法,注意:@Param(Constants.WRAPPER)还可以写成@Param(“ew”),这部分是不能遗漏的

1
2
3
public interface UserMapper extends BaseMapper<User> {
List<UserDeptVO> selectUserWithDept(@Param(Constants.WRAPPER) Wrapper<User> wrapper);
}

步骤3:XML中写SQL

1
2
3
4
5
6
7
8
9
10
11
<select id="selectUserWithDept" resultType="UserDeptVO">
SELECT
u.id AS userId,
u.name AS userName,
u.age,
d.name AS deptName
FROM user u
LEFT JOIN dept d ON u.dept_id = d.id
<!-- 关键!让Wrapper的条件生效 -->
${ew.customSqlSegment}
</select>

步骤4:Service层调用

1
2
3
4
5
6
7
public List<UserDeptVO> searchUsers(String name, Long deptId, Integer minAge) {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StringUtils.isNotBlank(name), User::getName, name)
.eq(deptId != null, User::getDeptId, deptId)
.ge(minAge != null, User::getAge, minAge);
return userMapper.selectUserWithDept(wrapper);
}

效果

  • 复用Wrapper的条件生成逻辑
  • 自定义SQL处理联表查询
  • VO对象封装返回结果

四、避坑指南:血的教训总结

坑1:参数绑定问题

错误写法

1
2
@Select("SELECT * FROM user WHERE name = #{name} AND age = #{age}")
User findUser(String name, Integer age);

症状:当参数为null时,MyBatis可能抛出BindingException

正确姿势

1
2
@Select("SELECT * FROM user WHERE name = #{name} AND age = #{paramAge}") // 用@Param别名
User findUser(@Param("name") String name, @Param("paramAge") Integer age);
坑2:分页插件失效

现象:自定义SQL的分页total总是0

解决方案

1
2
3
4
5
6
// Service层
Page<UserDeptVO> page = new Page<>(1, 10);
LambdaQueryWrapper<User> wrapper = ...;
userMapper.selectUserWithDept(page, wrapper); // 把page参数传进去

// XML中不需要改SQL,分页插件自动拦截
坑3:SQL注入风险

危险操作

1
2
@Select("SELECT * FROM user WHERE name = '${name}'") // 用${}拼接参数
User findUser(@Param("name") String name);

攻击测试:传参name = "admin' OR '1'='1",直接暴库

安全写法

1
2
@Select("SELECT * FROM user WHERE name = #{name}") // 用#{}预编译
User findUser(@Param("name") String name);

五、高阶玩法:当MyBatis遇到黑科技

玩法1:动态表名

场景:分表存储数据,比如user_2023user_2024

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 自定义表名处理器
public class DynamicTableNameParser implements ITableNameHandler {
@Override
public String dynamicTableName(String sql, String tableName) {
return tableName + "_" + LocalDate.now().getYear(); // 自动替换为user_2024
}
}

// 配置
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new DynamicTableNameInnerInterceptor(new DynamicTableNameParser()));
return interceptor;
}
玩法2:字段自动填充+自定义SQL

需求:记录数据修改人和修改时间,但只在自定义SQL中生效

步骤

  1. 实体类加注解
1
2
3
4
5
6
7
public class User {
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updater;

@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
  1. XML中直接写字段名,自动填充器会生效
1
2
3
4
5
6
7
<update id="updateUser">
UPDATE user SET
name = #{name},
age = #{age},
<!-- 这里不用写updater和update_time -->
WHERE id = #{id}
</update>

六、总结:什么时候该用自定义SQL?

场景 用Wrapper还是SQL 理由
单表简单查询 Wrapper 代码简洁,不用写SQL
多表联查 自定义SQL Wrapper不支持Join
复杂统计(Group By) 自定义SQL Wrapper的聚合功能有限
存储过程/函数 自定义SQL 必须手写
SQL优化需求 自定义SQL 自动生成的SQL可能不够高效

最后划重点
👉 不要排斥XML,复杂SQL放XML更清爽
👉 善用${ew.customSqlSegment}结合Wrapper条件
👉 安全!安全!安全!禁止随便用${}拼接参数