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 需手动或通过自定义缓存处理)。

相关推荐
cici158745 小时前
MyBatis注解的运用于条件搜索实践
java·tomcat·mybatis
fenglllle9 小时前
mybatis-plus SQL 注入漏洞导致版本升级引发的问题
数据库·sql·mybatis
呆呆小金人10 小时前
SQL字段对齐:性能优化与数据准确的关键
大数据·数据仓库·sql·数据库开发·etl·etl工程师
learning-striving10 小时前
SQL server创建数据表
数据库·sql·mysql·sql server
切糕师学AI10 小时前
SQL中的函数索引/表达式索引
数据库·sql·mysql·postgresql·oracle
武子康10 小时前
Java-166 Neo4j 安装与最小闭环 | 10 分钟跑通 + 远程访问 Docker neo4j.conf
java·数据库·sql·docker·系统架构·nosql·neo4j
驾数者14 小时前
Flink SQL入门指南:从零开始搭建流处理应用
大数据·sql·flink
tuokuac16 小时前
ps -ef | grep redis
数据库·redis·缓存
⑩-16 小时前
如何保证Redis和Mysql数据缓存一致性?
java·数据库·redis·mysql·spring·缓存·java-ee