MyBatis:进阶 - 动态 SQL、关联查询与缓存

一、引言:中级开发的 MyBatis "卡壳" 时刻

做开发半年到两年的你,是不是常遇到这些问题:

  • 多条件查询时,写了一堆if-else拼接 SQL,还总因多了个AND或逗号报语法错;
  • 查订单列表要关联用户、商品表,结果要么字段映射混乱,要么查出来数据重复;
  • 同一页面频繁刷新,每次都查数据库,接口响应慢得被测试吐槽......

这些 "卡壳" 场景,本质是没掌握 MyBatis 的进阶能力。本文会从动态 SQL(解决复杂条件拼接) 、关联查询(处理多表数据) 、缓存机制(优化重复查询) 三个维度,用 "场景 + 代码 + 原理" 的方式拆解,帮你把 MyBatis 从 "能用" 升级到 "用好",应对 80% 的中级开发场景。

二、动态 SQL 深度解析:告别 "SQL 拼接地狱"

动态 SQL 是 MyBatis 的 "灵魂功能",能根据条件自动拼接 SQL 片段,避免手动拼接的冗余与错误。下面 6 个核心标签,从基础到进阶逐个拆解,每个标签都附实战场景与完整代码。

1. if 标签:最常用的 "条件判断"

场景:多条件查询(如电商列表页,用户可输入用户名、选择价格区间,非必填条件)

作用:满足test表达式时,才拼接标签内的 SQL 片段。

代码示例:根据用户名和年龄查询用户

java 复制代码
<!-- UserMapper.xml -->
<select id="selectUserByCondition" resultType="User">
    SELECT id, username, age, email 
    FROM user 
    WHERE 1=1  <!-- 占位符,避免后续if都不满足时出现"WHERE"后无条件的语法错 -->
    <!-- test表达式:判断参数是否非空,支持OGNL语法(如username != null and username != '') -->
    <if test="username != null and username != ''">
        AND username LIKE CONCAT('%', #{username}, '%')  <!-- 模糊查询拼接 -->
    </if>
    <if test="age != null">
        AND age > #{age}  <!-- 年龄大于传入值 -->
    </if>
</select>
java 复制代码
// UserMapper.java接口
List<User> selectUserByCondition(@Param("username") String username, @Param("age") Integer age);
// 测试代码
// 场景1:只传username,SQL会拼接"AND username LIKE '%张%'"
List<User> user1 = userMapper.selectUserByCondition("张", null);
// 场景2:只传age,SQL会拼接"AND age > 25"
List<User> user2 = userMapper.selectUserByCondition(null, 25);

关键注意点:

test表达式中,参数名要和@Param或实体类属性一致(如参数是User对象,就用user.age);

为什么加WHERE 1=1?如果所有if都不满足,SQL 会变成SELECT ... FROM user WHERE,语法错误;加了1=1,不满足时就是WHERE 1=1,合法。

2. where 标签:自动 "收拾" 多余的 AND/OR

场景:替代WHERE 1=1,更优雅地处理条件拼接

作用:自动去除标签内 SQL 片段开头的AND或OR,避免语法错误。

代码示例:优化上面的多条件查询

java 复制代码
<select id="selectUserByCondition" resultType="User">
    SELECT id, username, age, email 
    FROM user 
    <!-- where标签自动处理多余的AND -->
    <where>
        <if test="username != null and username != ''">
            AND username LIKE CONCAT('%', #{username}, '%')  <!-- 开头的AND会被自动去除 -->
        </if>
        <if test="age != null">
            AND age > #{age}
        </if>
    </where>
</select>

对比优势:

无需写WHERE 1=1,代码更简洁;

即使第一个if不满足,第二个if开头的AND也会被处理(比如只传 age,SQL 是WHERE age > 25,无多余 AND)。

3. choose(when/otherwise)标签:"二选一" 的分支逻辑

场景:互斥条件查询(如电商订单查询,"按订单号查" 和 "按用户 ID 查" 只能选一个,不能同时生效)

作用:类似 Java 的switch-case,只执行第一个满足条件的when,都不满足则执行otherwise。

代码示例:订单查询(订单号优先,无则按用户 ID 查)

java 复制代码
<select id="selectOrder" resultType="Order">
    SELECT id, order_no, user_id, amount 
    FROM `order` 
    <where>
        <choose>
            <!-- 第一个满足的条件生效:有订单号则按订单号查 -->
            <when test="orderNo != null and orderNo != ''">
                AND order_no = #{orderNo}
            </when>
            <!-- 订单号为空,按用户ID查 -->
            <when test="userId != null">
                AND user_id = #{userId}
            </when>
            <!-- 都为空,查近7天的订单(默认条件) -->
            <otherwise>
                AND create_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
            </otherwise>
        </choose>
    </where>
</select>

实战提醒:

choose是 "互斥" 逻辑,和多个if的 "并列" 逻辑区分开(多个if会同时生效,choose只生效一个);

otherwise可选,无默认条件时可省略,但建议加上,避免无条件查询全表。

4. set 标签:动态更新时 "去掉多余逗号"

场景:部分字段更新(如用户编辑,只改用户名和邮箱,不改年龄)

作用:自动去除标签内 SQL 片段结尾的逗号,避免UPDATE ... SET username='xxx', WHERE ...的语法错。

代码示例:更新用户信息(只更非空字段)

java 复制代码
<update id="updateUserSelective">
    UPDATE user 
    <!-- set标签自动处理多余逗号 -->
    <set>
        <if test="username != null and username != ''">
            username = #{username},  <!-- 结尾的逗号会被自动去除 -->
        </if>
        <if test="age != null">
            age = #{age},
        </if>
        <if test="email != null and email != ''">
            email = #{email}
        </if>
    </set>
    WHERE id = #{id}  <!-- 必须传id,否则更新全表! -->
</update>

避坑指南:

千万不要漏写WHERE id = #{id},否则会更新表中所有数据,生产环境必出事故;

如果所有if都不满足(没传任何要更新的字段),SQL 会变成UPDATE user SET WHERE id=?,语法错误,建议在 Service 层加参数校验(至少有一个字段非空)。

5. foreach 标签:遍历集合的 "批量操作神器"

场景:批量插入、批量删除、IN查询(如批量插入 10 个用户、删除 ID 为 1,2,3 的用户、查询 ID 在 [10,20] 的订单)

作用:遍历List/Array/Map,拼接成对应的 SQL 片段(如(1,2,3)或VALUES (?,?),(?,?))。

核心属性解析:

实战场景 1:批量插入用户(List 参数)

java 复制代码
<insert id="batchInsertUser">
    INSERT INTO user (username, age, email) 
    VALUES 
    <foreach collection="userList" item="user" separator=",">
        (#{user.username}, #{user.age}, #{user.email})
    </foreach>
</insert>
<!-- 对应的Mapper接口 -->
int batchInsertUser(@Param("userList") List<User> userList);
<!-- 测试代码:插入3个用户 -->
List<User> userList = new ArrayList<>();
userList.add(new User("李四", 28, "lisi@xxx.com"));
userList.add(new User("王五", 30, "wangwu@xxx.com"));
userMapper.batchInsertUser(userList);

实战场景 2:批量删除用户(Array 参数)

java 复制代码
<delete id="batchDeleteUser">
    DELETE FROM user 
    WHERE id IN 
    <foreach collection="array" item="id" open="(" close=")" separator=",">
        #{id}
    </foreach>
</delete>
<!-- 对应的Mapper接口 -->
int batchDeleteUser(Integer[] ids);
<!-- 测试代码:删除ID为1、2的用户 -->
Integer[] ids = {1,2};
userMapper.batchDeleteUser(ids);

性能优化:

批量插入时,若数据量超过 1000 条,建议分批次(如每次 500 条),避免 SQL 语句过长导致数据库执行超时;

collection取值易错:如果 Mapper 接口参数没加@Param,List 默认用list,Array 默认用array;加了@Param,就用@Param指定的名称(如@Param("userList")→collection="userList")。

6. trim 标签:自定义 SQL 拼接规则(万能标签)

场景:替代where/set,或实现更灵活的拼接(如给 SQL 加前缀、后缀,去除特定字符)

作用:通过prefix(前缀)、suffix(后缀)、prefixOverrides(去除开头字符)、suffixOverrides(去除结尾字符)自定义规则。

示例 1:用 trim 替代 where 标签

java 复制代码
<trim prefix="WHERE" prefixOverrides="AND|OR">
    <if test="username != null">
        AND username = #{username}
    </if>
    <if test="age != null">
        AND age > #{age}
    </if>
</trim>
<!-- 效果等同于where标签:开头加WHERE,去除多余AND/OR -->
示例 2:用 trim 替代 set 标签
<trim prefix="SET" suffixOverrides=",">
    <if test="username != null">
        username = #{username},
    </if>
    <if test="email != null">
        email = #{email},
    </if>
</trim>
<!-- 效果等同于set标签:开头加SET,去除结尾逗号 -->
示例 3:自定义拼接(给查询结果加固定条件)

<select id="selectUserWithStatus" resultType="User">
    SELECT id, username, age 
    FROM user 
    <trim prefix="WHERE" prefixOverrides="AND">
        <if test="username != null">
            AND username LIKE '%${username}%'
        </if>
        <!-- 强制拼接"status=1"(只查正常用户) -->
        AND status = 1
    </trim>
</select>

使用建议:

简单场景用where/set,复杂场景用trim;

trim灵活性高,但可读性稍差,团队协作时建议统一规则(如优先用where/set)。

三、复杂关联查询实现:搞定多表数据映射

实际项目中,很少只查单表(如查订单要带用户信息,查用户要带订单列表),这就需要关联查询。下面按 "一对一→一对多→多对多" 的顺序,讲清实现方式、优缺点与性能优化。

1. 一对一关联:用户与身份证(一个用户对应一个身份证)

场景:查询用户时,同时返回其身份证信息(如用户表user,身份证表id_card,通过 user.id = id_card.user_id 关联)。

两种实现方式:resultType(用 DTO 接收) vs resultMap(用 association 标签配置)。

方式 1:resultType(简单场景,用 DTO 接收关联结果)

步骤 1:创建 DTO 类(包含用户和身份证的所有字段)

java 复制代码
// UserWithIdCardDTO.java
@Data
public class UserWithIdCardDTO {
    // 用户表字段
    private Integer userId;
    private String username;
    private Integer age;
    // 身份证表字段(加前缀区分,避免字段名冲突)
    private Integer cardId;
    private String cardNo;  // 身份证号
    private String issueOrg;  // 发证机关
}

步骤 2:编写 Mapper 接口与 XML

java 复制代码
// UserMapper.java
List<UserWithIdCardDTO> selectUserWithIdCard();
<select id="selectUserWithIdCard" resultType="UserWithIdCardDTO">
    SELECT 
        u.id AS userId, 
        u.username, 
        u.age, 
        ic.id AS cardId, 
        ic.card_no AS cardNo, 
        ic.issue_org AS issueOrg 
    FROM user u
    LEFT JOIN id_card ic ON u.id = ic.user_id  <!-- 左连接,确保没有身份证的用户也能查到 -->
</select>

方式 2:resultMap(复杂场景,用 association 标签)

步骤 1:创建实体类(User 包含 IdCard 属性)

java 复制代码
// IdCard.java(身份证实体)
@Data
public class IdCard {
    private Integer id;
    private String cardNo;
    private String issueOrg;
    private Integer userId;  // 关联用户ID
}
// User.java(用户实体,包含IdCard)
@Data
public class User {
    private Integer id;
    private String username;
    private Integer age;
    private IdCard idCard;  // 一对一关联:用户有一个身份证
}

步骤 2:编写 resultMap 与 SQL

java 复制代码
<!-- 定义resultMap:关联User和IdCard -->
<resultMap id="UserWithIdCardMap" type="User">
    <!-- 用户表字段映射 -->
    <id column="user_id" property="id"/>  <!-- id标签:主键字段,提高映射效率 -->
    <result column="username" property="username"/>
    <result column="age" property="age"/>

    <!-- association标签:配置一对一关联 -->
    <association property="idCard" javaType="IdCard">  <!-- javaType:关联对象的类型 -->
        <id column="card_id" property="id"/>
        <result column="card_no" property="cardNo"/>
        <result column="issue_org" property="issueOrg"/>
    </association>
</resultMap>
<!-- 关联查询SQL -->
<select id="selectUserWithIdCard2" resultMap="UserWithIdCardMap">
    SELECT 
        u.id AS user_id, 
        u.username, 
        u.age, 
        ic.id AS card_id, 
        ic.card_no, 
        ic.issue_org 
    FROM user u
    LEFT JOIN id_card ic ON u.id = ic.user_id
</select>

两种方式对比:

延迟加载配置:

一对一关联时,若默认不查身份证(需要时才查),可开启延迟加载(减轻数据库压力):

全局配置(application.yml):

java 复制代码
mybatis:
  configuration:
    lazy-loading-enabled: true  # 开启全局延迟加载
    aggressive-lazy-loading: false  # 关闭"积极加载"(只加载需要的属性)

修改 association 标签,添加select和column:

java 复制代码
<association 
    property="idCard" 
    javaType="IdCard"
    select="com.example.mapper.IdCardMapper.selectIdCardByUserId"  <!-- 关联查询的Mapper方法 -->
    column="user_id">  <!-- 传递给关联方法的参数(用户ID) -->
</association>

编写 IdCardMapper 的查询方法:

java 复制代码
// IdCardMapper.java
IdCard selectIdCardByUserId(Integer userId);
<select id="selectIdCardByUserId" resultType="IdCard">
    SELECT * FROM id_card WHERE user_id = #{userId}
</select>

效果:查询用户时,只查user表;当调用user.getIdCard()时,才会执行id_card表的查询(按需加载)。

2. 一对多关联:用户与订单(一个用户对应多个订单)

场景:查询用户时,同时返回其所有订单(如用户表user,订单表order,通过user.id = order.user_id关联)。

核心标签:collection(配置集合关联,对应实体类中的List属性)。

实现步骤:

创建实体类(User 包含 List)

java 复制代码
// Order.java(订单实体)
@Data
public class Order {
    private Integer id;
    private String orderNo;  // 订单号
    private BigDecimal amount;  // 金额
    private Integer userId;  // 关联用户ID
}
// User.java(用户实体,包含订单列表)
@Data
public class User {
    private Integer id;
    private String username;
    private Integer age;
    private List<Order> orderList;  // 一对多关联:用户有多个订单
}

编写 resultMap 与 SQL

java 复制代码
<!-- 定义resultMap:关联User和Order -->
<resultMap id="UserWithOrderMap" type="User">
    <!-- 用户表字段映射 -->
    <id column="user_id" property="id"/>
    <result column="username" property="username"/>
    <result column="age" property="age"/>

    <!-- collection标签:配置一对多关联 -->
    <collection 
        property="orderList"  <!-- 实体类中的集合属性名 -->
        ofType="Order"        <!-- 集合中元素的类型(注意不是javaType) -->
        column="user_id">     <!-- 关联的外键字段(用户ID) -->

        <!-- 订单表字段映射 -->
        <id column="order_id" property="id"/>  <!-- 订单主键,避免数据重复 -->
        <result column="order_no" property="orderNo"/>
        <result column="amount" property="amount"/>
    </collection>
</resultMap>
<!-- 关联查询SQL -->
<select id="selectUserWithOrder" resultMap="UserWithOrderMap">
    SELECT 
        u.id AS user_id, 
        u.username, 
        u.age, 
        o.id AS order_id, 
        o.order_no, 
        o.amount 
    FROM user u
    LEFT JOIN `order` o ON u.id = o.user_id
    ORDER BY u.id, o.id  <!-- 按用户ID和订单ID排序,避免数据混乱 -->
</select>

关键注意点:

  • ofType vs javaType:collection用ofType指定集合元素类型(如Order),association用javaType指定关联对象类型(如IdCard),别搞混;
  • 避免数据重复:必须给主表(user)和从表(order)的主键加id标签,MyBatis 会通过主键判断是否为同一对象,否则会重复生成用户对象;
  • 嵌套查询 vs 嵌套结果:
    上面的示例是 "嵌套结果"(一次 SQL 联表查询),优点是效率高,缺点是 SQL 复杂;
    "嵌套查询"(先查用户,再查订单)类似一对一延迟加载,需配置select属性,但可能出现 "N+1 问题"(查 1 个用户查 1 次,查 N 个用户查 N 次订单,共 N+1 次查询),数据量大时不推荐。

3. 多对多关联:用户与角色(一个用户有多个角色,一个角色有多个用户)

场景:查询用户时,同时返回其所有角色(需中间表user_role关联,三张表:user、role、user_role)。

实现思路:先通过user和user_role联表,再关联role,用collection标签映射角色列表。

实现步骤:

创建实体类(User 包含 List)

java 复制代码
// Role.java(角色实体)
@Data
public class Role {
    private Integer id;
    private String roleName;  // 角色名(如"管理员""普通用户")
}
// User.java(用户实体,包含角色列表)
@Data
public class User {
    private Integer id;
    private String username;
    private Integer age;
    private List<Role> roleList;  // 多对多关联:用户有多个角色
}

编写 resultMap 与 SQL

java 复制代码
<!-- 定义resultMap:关联User和Role -->
<resultMap id="UserWithRoleMap" type="User">
    <!-- 用户表字段映射 -->
    <id column="user_id" property="id"/>
    <result column="username" property="username"/>
    <result column="age" property="age"/>

    <!-- collection标签:配置多对多关联(角色列表) -->
    <collection 
        property="roleList" 
        ofType="Role"
        column="user_id">

        <id column="role_id" property="id"/>  <!-- 角色主键,避免重复 -->
        <result column="role_name" property="roleName"/>
    </collection>
</resultMap>
<!-- 多表联查SQL:user → user_role → role -->
<select id="selectUserWithRole" resultMap="UserWithRoleMap">
    SELECT 
        u.id AS user_id, 
        u.username, 
        u.age, 
        r.id AS role_id, 
        r.role_name 
    FROM user u
    LEFT JOIN user_role ur ON u.id = ur.user_id  <!-- 中间表关联 -->
    LEFT JOIN role r ON ur.role_id = r.id        <!-- 角色表关联 -->
    WHERE u.id = #{userId}  <!-- 按用户ID查询,避免返回所有用户 -->
</select>

性能优化:

多对多查询本质是 "两次一对多"(用户→用户角色,用户角色→角色),联表时注意索引(user_role.user_id和user_role.role_id加索引);

若只需查询用户的角色 ID,可不用关联role表,直接查user和user_role,减少表连接次数。

四、MyBatis 缓存机制:从 "重复查库" 到 "缓存复用"

缓存是优化接口性能的关键 ------ 同一数据频繁查询时,从缓存取比查数据库快 10~100 倍。MyBatis 有两级缓存,再加上第三方缓存(如 Redis),能覆盖绝大多数场景。

一级缓存(SqlSession 级缓存):默认开启,"会话内有效"

原理:MyBatis 默认在SqlSession(数据库会话)内缓存数据,同一SqlSession执行相同的 SQL(相同的 SQL 语句 + 参数),第一次查数据库并缓存,第二次直接从缓存取,无需查库。

实战验证:一级缓存命中与失效场景

java 复制代码
// 测试代码:同一SqlSession内的缓存命中
@Test
public void testFirstLevelCache() {
    // 1. 获取SqlSession(Spring环境下可通过SqlSessionTemplate获取)
    SqlSession sqlSession = sqlSessionFactory.openSession();
    UserMapper userMapper = sqlSession.getMapper(UserMapper.class);

    // 2. 第一次查询:查数据库,缓存数据
    User user1 = userMapper.selectUserById(1);
    System.out.println("第一次查询:" + user1);

    // 3. 第二次查询:相同SQL+参数,从缓存取(不查库)
    User user2 = userMapper.selectUserById(1);
    System.out.println("第二次查询:" + user2);
    System.out.println("是否同一对象:" + (user1 == user2));  // 结果:true(缓存命中)

    // 4. 关闭SqlSession:缓存清空
    sqlSession.close();

    // 5. 新的SqlSession:缓存失效,重新查库
    SqlSession newSqlSession = sqlSessionFactory.openSession();
    UserMapper newUserMapper = newSqlSession.getMapper(UserMapper.class);
    User user3 = newUserMapper.selectUserById(1);
    System.out.println("新SqlSession查询:" + user3);
    System.out.println("是否同一对象:" + (user1 == user3));  // 结果:false(缓存失效)
}

一级缓存失效场景(必须掌握):

实战提醒:

Spring 整合 MyBatis 时,默认每个 Service 方法是一个SqlSession(事务提交后关闭),所以一级缓存在 Service 方法内有效,跨方法失效;

不要在多线程中共享SqlSession,会导致缓存数据被覆盖,出现数据一致性问题(如线程 A 查 id=1,线程 B 查 id=2,缓存可能混乱)。

2. 二级缓存(Mapper 级缓存):手动开启,"跨会话共享"

原理:二级缓存是Mapper接口级别的缓存(如UserMapper的缓存独立于OrderMapper),不同SqlSession执行同一Mapper的相同 SQL,可共享缓存,缓存数据存储在Mapper对应的Cache对象中。

开启二级缓存的 3 个步骤:

全局配置开启(application.yml)

java 复制代码
mybatis:
  configuration:
    cache-enabled: true  # 开启全局二级缓存(默认true,可省略,但建议显式配置)

在 Mapper XML 中添加标签(关键)

java 复制代码
<!-- UserMapper.xml:开启当前Mapper的二级缓存 -->
<cache 
    eviction="LRU"        <!-- 缓存回收策略:LRU(最近最少使用) -->
    flushInterval="60000" <!-- 缓存刷新间隔:60秒(60000毫秒),到期自动清空 -->
    size="1024"           <!-- 缓存最大条目:最多存1024条数据 -->
    readOnly="true"/>     <!-- 只读:true(返回缓存对象的引用,性能高);false(返回副本,安全) -->

实体类实现Serializable接口(否则缓存报错)

java 复制代码
// User.java必须实现Serializable,因为二级缓存会序列化数据
@Data
public class User implements Serializable {
    private Integer id;
    private String username;
    private Integer age;
    // ...其他字段
}

二级缓存核心参数解析(标签):

二级缓存生效与失效场景:

  • 生效:不同SqlSession执行同一Mapper的相同 SQL(如UserMapper.selectUserById(1)),第二次从缓存取;
  • 失效:
    执行当前Mapper的增删改操作(会清空该Mapper的二级缓存);
    缓存到期(flushInterval时间到)或达到size上限(触发回收策略);
    方法上加@Options(useCache = false)(禁用当前方法的二级缓存)。

示例:禁用某个方法的二级缓存

java 复制代码
// UserMapper.java:查询用户列表时,禁用二级缓存(数据变化频繁)
@Options(useCache = false)
List<User> selectAllUsers();

二级缓存 vs 一级缓存对比:

3. 整合第三方缓存(Redis):解决分布式缓存共享问题

问题:二级缓存默认是内存缓存,分布式环境下(多台服务器),各服务器的缓存不共享(如服务器 A 查了 id=1,服务器 B 查 id=1 仍需查库),需用 Redis 实现分布式缓存。

整合步骤(SpringBoot+Redis):
1. 引入依赖(pom.xml)

java 复制代码
<!-- Redis依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MyBatis-Redis整合依赖(提供RedisCache实现) -->
<dependency>
    <groupId>org.mybatis.caches</groupId>
    <artifactId>mybatis-redis</artifactId>
    <version>1.0.0-beta2</version>
</dependency>

2. 配置 Redis(application.yml)

java 复制代码
spring:
  redis:
    host: localhost  # Redis服务器地址
    port: 6379       # Redis端口
    password:        # Redis密码(无则空)
    database: 0      # Redis数据库索引(默认0)
    timeout: 3000    # 连接超时时间(毫秒)

3. 修改 Mapper XML 的标签,指定 Redis 缓存实现

java 复制代码
<!-- UserMapper.xml:使用Redis作为二级缓存 -->
<cache 
    type="org.mybatis.caches.redis.RedisCache"  <!-- RedisCache的全类名 -->
    eviction="LRU"
    flushInterval="60000"
    size="1024"
    readOnly="true"/>

自定义 Redis 缓存配置(可选,解决默认配置问题):

MyBatis-Redis 默认缓存时间是永久的,可自定义RedisCache类,设置过期时间:

java 复制代码
// 自定义RedisCache,继承MyBatis的RedisCache
public class CustomRedisCache extends RedisCache {
    // 缓存过期时间:30分钟(1800000毫秒)
    private static final long EXPIRE_TIME = 1800000;
    public CustomRedisCache(String id) {
        super(id);  // 调用父类构造器
    }
    // 重写putObject方法,添加过期时间
    @Override
    public void putObject(Object key, Object value) {
        RedisManager redisManager = RedisConfigurationBuilder.getInstance().build();
        try (Jedis jedis = redisManager.getResource()) {
            jedis.set(Objects.toString(key), SerializeUtil.serialize(value));
            jedis.expire(Objects.toString(key), (int) (EXPIRE_TIME / 1000));  // 设置过期时间(秒)
        }
    }
}

然后在 Mapper XML 中引用自定义缓存:

java 复制代码
<cache type="com.example.cache.CustomRedisCache"/>

五、实战案例:电商订单查询模块(整合所有进阶技能)

用一个真实场景,把动态 SQL、关联查询、缓存串起来,让你看到 "学以致用" 的效果。

1. 需求分析

功能:查询订单列表,支持多条件筛选(订单号、用户 ID、时间范围、订单状态);

关联:订单需显示用户信息(一对一)、订单商品列表(一对多);

性能:查询结果缓存,订单增删改后清空缓存。

2. 技术设计

表结构:order(订单表)、user(用户表)、order_item(订单商品表);

核心技能:动态 SQL(多条件筛选)、关联查询(订单→用户、订单→商品)、二级缓存(缓存订单列表)。

3. 代码实现

实体类与 DTO 设计

java 复制代码
// 订单商品实体(OrderItem.java)
@Data
public class OrderItem implements Serializable {
    private Integer id;
    private Integer orderId;  // 关联订单ID
    private String productName;  // 商品名
    private Integer quantity;    // 数量
    private BigDecimal price;    // 单价
}
// 订单实体(Order.java,包含用户和商品列表)
@Data
public class Order implements Serializable {
    private Integer id;
    private String orderNo;
    private Integer userId;
    private BigDecimal amount;
    private Integer status;  // 订单状态:0-待支付,1-已支付
    private Date createTime;

    // 关联用户(一对一)
    private User user;
    // 关联订单商品(一对多)
    private List<OrderItem> orderItemList;
}
// 订单查询DTO(OrderQueryDTO.java,接收前端筛选条件)
@Data
public class OrderQueryDTO {
    private String orderNo;        // 订单号(模糊查询)
    private Integer userId;        // 用户ID
    private Date startTime;        // 开始时间
    private Date endTime;          // 结束时间
    private Integer status;        // 订单状态
}

Mapper 接口与 XML

java 复制代码
// OrderMapper.java
public interface OrderMapper {
    // 多条件查询订单列表(带关联)
    List<Order> selectOrderList(OrderQueryDTO queryDTO);

    // 新增订单(用于测试缓存失效)
    int insertOrder(Order order);
}
java 复制代码
<!-- OrderMapper.xml -->
<!-- 1. 开启二级缓存(用Redis) -->
<cache type="com.example.cache.CustomRedisCache"/>
<!-- 2. 定义resultMap:关联订单、用户、商品 -->
<resultMap id="OrderWithUserAndItemMap" type="Order">
    <!-- 订单表字段 -->
    <id column="order_id" property="id"/>
    <result column="order_no" property="orderNo"/>
    <result column="user_id" property="userId"/>
    <result column="amount" property="amount"/>
    <result column="status" property="status"/>
    <result column="create_time" property="createTime"/>

    <!-- 关联用户(一对一) -->
    <association property="user" javaType="User">
        <id column="u_id" property="id"/>
        <result column="username" property="username"/>
        <result column="email" property="email"/>
    </association>

    <!-- 关联订单商品(一对多) -->
    <collection property="orderItemList" ofType="OrderItem">
        <id column="item_id" property="id"/>
        <result column="product_name" property="productName"/>
        <result column="quantity" property="quantity"/>
        <result column="price" property="price"/>
    </collection>
</resultMap>
<!-- 3. 动态SQL:多条件查询订单列表 -->
<select id="selectOrderList" resultMap="OrderWithUserAndItemMap">
    SELECT 
        o.id AS order_id, 
        o.order_no, 
        o.user_id, 
        o.amount, 
        o.status, 
        o.create_time, 
        -- 用户表字段
        u.id AS u_id, 
        u.username, 
        u.email, 
        -- 订单商品表字段
        oi.id AS item_id, 
        oi.product_name, 
        oi.quantity, 
        oi.price 
    FROM `order` o
    LEFT JOIN user u ON o.user_id = u.id
    LEFT JOIN order_item oi ON o.id = oi.order_id
    <where>
        <!-- 动态条件:订单号模糊查询 -->
        <if test="orderNo != null and orderNo != ''">
            AND o.order_no LIKE CONCAT('%', #{orderNo}, '%')
        </if>
        <!-- 用户ID -->
        <if test="userId != null">
            AND o.user_id = #{userId}
        </if>
        <!-- 时间范围:创建时间 >= 开始时间 -->
        <if test="startTime != null">
            AND o.create_time >= #{startTime}
        </if>
        <!-- 时间范围:创建时间 <= 结束时间 -->
        <if test="endTime != null">
            AND o.create_time <= #{endTime}
        </if>
        <!-- 订单状态 -->
        <if test="status != null">
            AND o.status = #{status}
        </if>
    </where>
    ORDER BY o.create_time DESC
</select>
<!-- 4. 新增订单:执行后会清空当前Mapper的二级缓存 -->
<insert id="insertOrder" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO `order` (order_no, user_id, amount, status, create_time)
    VALUES (#{orderNo}, #{userId}, #{amount}, #{status}, #{createTime})
</insert>

Service 层(整合缓存与业务逻辑)

java 复制代码
@Service
public class OrderService {
    @Autowired
    private OrderMapper orderMapper;

    // 查询订单列表(缓存生效)
    public List<Order> getOrderList(OrderQueryDTO queryDTO) {
        return orderMapper.selectOrderList(queryDTO);
    }

    // 新增订单(执行后缓存失效)
    @Transactional
    public void addOrder(Order order) {
        order.setCreateTime(new Date());
        orderMapper.insertOrder(order);
        // 新增订单后,OrderMapper的二级缓存会自动清空,下次查列表会重新查库
    }
}

效果验证

第一次调用getOrderList:查数据库,结果存入 Redis 缓存;

第二次调用getOrderList(相同条件):从 Redis 取,接口响应时间从 500ms→50ms;

调用addOrder新增订单:OrderMapper的二级缓存清空;

第三次调用getOrderList:重新查数据库,获取最新订单列表。

六、性能优化与最佳实践

掌握进阶技能后,还要知道 "怎么用才更好",下面是动态 SQL、关联查询、缓存的实战优化建议。

1. 动态 SQL 优化

避免冗余条件:相同的条件判断(如username != null and username != '')可抽成 SQL 片段,用和复用:

java 复制代码
<!-- 定义SQL片段 -->
<sql id="usernameCondition">
    <if test="username != null and username != ''">
        AND username LIKE CONCAT('%', #{username}, '%')
    </if>
</sql>
<!-- 引用片段 -->
<select id="selectUser" resultType="User">
    SELECT * FROM user
    <where>
        <include refid="usernameCondition"/>
        <if test="age != null">
            AND age > #{age}
        </if>
    </where>
</select>

批量操作分批次:批量插入 / 删除超过 1000 条时,分批次处理(如每次 500 条),避免 SQL 过长导致数据库执行超时。

2. 关联查询优化

减少表连接数量:多表联查尽量控制在 3 张表以内,超过 3 张可拆分成多次查询(用延迟加载);

避免笛卡尔积:联表时确保关联条件正确(如 u.id = o.user_id),否则会出现数据爆炸(如 100 个用户 ×100 个订单 = 10000 条重复数据);

用索引优化联表:关联字段(如order.user_id、user_role.role_id)必须加索引,否则联表查询会全表扫描,性能极差。

3. 缓存优化

缓存高频、少变的数据:如字典表、商品分类表(更新频率低),避免缓存高频变化的数据(如订单表、用户余额表);

不缓存大对象:如包含大量商品的订单详情(数据量大,序列化耗时),可只缓存关键信息(如订单 ID、金额),详情按需查库;

分布式缓存用 Redis:单机用二级缓存,分布式环境必须用 Redis,避免缓存不共享问题;

缓存一致性保障:增删改操作后,确保缓存被清空(MyBatis 二级缓存会自动清空当前 Mapper 的缓存,Redis 需手动或通过自定义缓存处理)。

相关推荐
星马梦缘1 天前
数据库作战记录1
数据库·sql·mysql
麦聪聊数据1 天前
QuickAPI 在系统数据 API 化中的架构选型与集成
数据库·sql·低代码·微服务·架构
敲代码的嘎仔1 天前
Java后端面试——SSM框架面试题
java·面试·职场和发展·mybatis·ssm·springboot·八股
ruanyongjing1 天前
SpringBoot3 整合 Mybatis 完整版
mybatis
Arva .1 天前
Spring 的三级缓存,两级够吗
java·spring·缓存
山峰哥1 天前
查询优化案例:从慢查询到闪电般的查询速度
数据库·sql·性能优化·编辑器·深度优先
杨云龙UP1 天前
Oracle ASM磁盘组空间分配与冗余理解
linux·运维·数据库·sql·oracle
微学AI1 天前
一款数据库SQL防火墙:可以拦截99.99%,可以阻止恶意SQL
数据库·sql
haixingtianxinghai1 天前
Redis真的是单线程吗?
数据库·redis·缓存
尽兴-1 天前
Redis7 底层数据结构解析
数据结构·数据库·缓存·redis7