MyBatis的灵活性是一把双刃剑。本文将详细剖析我在使用MyBatis过程中遇到的几个典型深坑,包括参数传递的奥秘、动态SQL的性能陷阱、复杂结果映射的诡异行为,以及分页插件的原理误区。每个坑都配有详细的代码对比和原理分析。

坑1:#{}${}的混淆与SQL注入

  • #{}​:​​ 是​参数占位符​。MyBatis会将其替换为?,然后使用PreparedStatementset方法安全地设置参数。可以防止SQL注入。
  • ${}​:​​ 是​字符串拼接​。MyBatis会直接将${}内的内容替换为对应的参数值。存在SQL注入风险。

错误示例(排序场景):

<!-- 错误:使用 ${} 接收用户输入的排序字段,极不安全! -->
<select id="selectUsers" resultType="User">
    SELECT * FROM user
    ORDER BY ${sortField} ${sortOrder}
</select>

如果前端传入 sortField=id; DROP TABLE user--,会导致灾难性后果。

​正确做法:​​ 对于表名、列名等非值参数,必须使用${},但需要进行​白名单校验​。

<!-- 在Java代码中进行白名单校验 -->
<select id="selectUsers" resultType="User">
    SELECT * FROM user
    ORDER BY ${sortField} ${sortOrder}
</select>
// Service层代码
public List<User> selectUsers(String sortField, String sortOrder) {
    // 白名单校验,防止SQL注入
    List<String> allowedFields = Arrays.asList("id", "username", "create_time");
    if (!allowedFields.contains(sortField)) {
        sortField = "id"; // 默认字段
    }
    if (!"asc".equalsIgnoreCase(sortOrder) && !"desc".equalsIgnoreCase(sortOrder)) {
        sortOrder = "asc";
    }
    return userMapper.selectUsers(sortField, sortOrder);
}

坑2:动态SQL<if>标签与WHERE关键字

错误示例(多余的AND):

<select id="findUser" parameterType="UserQuery" resultType="User">
    SELECT * FROM user
    WHERE
    <if test="username != null and username != ''">
        username = #{username}
    </if>
    <if test="email != null and email != ''">
        AND email = #{email}
    </if>
</select>

username为空而email不为空时,生成的SQL会是:SELECT * FROM user WHERE AND email = ?,语法错误!

解决方案1:使用<where>标签

<select id="findUser" parameterType="UserQuery" resultType="User">
    SELECT * FROM user
    <where>
        <if test="username != null and username != ''">
            AND username = #{username}
        </if>
        <if test="email != null and email != ''">
            AND email = #{email}
        </if>
    </where>
</select>

<where>标签会智能地处理:

  • 只有当标签内有内容时,才会插入WHERE关键字。
  • 如果标签内内容以ANDOR开头,它会将其去除。

解决方案2:使用1=1技巧

<select id="findUser" parameterType="UserQuery" resultType="User">
    SELECT * FROM user
    WHERE 1=1
    <if test="username != null and username != ''">
        AND username = #{username}
    </if>
    <if test="email != null and email != ''">
        AND email = #{email}
    </if>
</select>

坑3:一对多查询(<collection>)的N+1查询问题

​场景:​​ 查询部门及其下的所有员工。

V1.0 错误示范(触发N+1查询):

<!-- 1. 先查询所有部门 -->
<select id="selectAllDepartments" resultMap="DepartmentMap">
    SELECT id, name FROM department
</select>

<resultMap id="DepartmentMap" type="Department">
    <id property="id" column="id"/>
    <result property="name" column="name"/>
    <!-- 2. 对每个部门,执行一次查询员工的SQL -->
    <collection property="employees" ofType="Employee" column="id" select="selectEmployeesByDeptId"/>
</resultMap>

<select id="selectEmployeesByDeptId" resultType="Employee">
    SELECT id, name FROM employee WHERE dept_id = #{id}
</select>

如果查询出N个部门,就会产生1(查询部门) + N(查询每个部门的员工)条SQL,性能极差。

V1.1 正确做法:使用连接查询<resultMap>

<select id="selectAllDepartmentsWithEmployees" resultMap="DepartmentWithEmployeesMap">
    SELECT
        d.id as dept_id,
        d.name as dept_name,
        e.id as emp_id,
        e.name as emp_name
    FROM department d
    LEFT JOIN employee e ON d.id = e.dept_id
    ORDER BY d.id
</select>

<resultMap id="DepartmentWithEmployeesMap" type="Department">
    <id property="id" column="dept_id"/>
    <result property="name" column="dept_name"/>
    <!-- 使用结果集直接映射,而非嵌套查询 -->
    <collection property="employees" ofType="Employee">
        <id property="id" column="emp_id"/>
        <result property="name" column="emp_name"/>
    </collection>
</resultMap>

​原理:​​ 通过一条SQL连接查询出所有数据,MyBatis的结果集映射器会根据<id>标签进行分组,将同一个部门下的所有员工数据组装到一个集合中。虽然SQL结果集可能有多条重复的部门信息,但内存中只有一个Department对象。用一次复杂的SQL查询代替多次简单的查询,这是解决N+1问题的关键。

坑4:PageHelper分页插件的“内存分页”陷阱

错误用法:

// 在查询之后才设置分页参数? 完全错误!
List<User> users = userMapper.selectAllUsers(); // 1. 先执行查询,查出了所有数据
PageHelper.startPage(1, 10); // 2. 再startPage,为时已晚!
// 此时PageHelper拦截到的是空,分页信息并未生效

​正确用法:​PageHelper.startPage必须紧跟在MyBatis的查询方法之前调用。

// 在Service层
public PageInfo<User> getUsersByPage(int pageNum, int pageSize) {
    // 紧跟在查询方法前调用,顺序至关重要!
    PageHelper.startPage(pageNum, pageSize);
    // 这个查询会被PageHelper自动拦截并改写SQL,加上LIMIT
    List<User> users = userMapper.selectAllUsers();
    // 用PageInfo包装结果,可以获取总条数等分页信息
    return new PageInfo<>(users);
}

​原理:​​ PageHelper利用MyBatis的插件(Interceptor)机制,在SQL执行前进行拦截。startPage方法将分页参数(页码,大小)存入ThreadLocal。当执行查询的Mapper方法时,插件会从ThreadLocal中获取参数,动态改写原始SQL(例如,SELECT * FROM user被改写为 SELECT * FROM user LIMIT 0, 10)。

总结:

MyBatis的强大在于其灵活性,但这也要求开发者对其底层原理有更深入的了解。两年里,我通过不断踩坑和总结,明白了​“怎么用”和“为什么这么用”​​ 同样重要。理解#{}${}的区别、动态SQL的生成逻辑、结果映射的组装过程以及插件的工作原理,才能写出高效、安全的数据库访问代码。

如果觉得我的文章对你有用,请随意赞赏