Mybatis - 精巧的持久层框架-缓存机制的深刻理解

Mybatis缓存机制

Mybatis的缓存机制是其性能优化的核心,也是面试中的高频考点。理解它不仅能写出更高性能的代码,还能明白框架设计中对性能与数据一致性权衡的智慧。

此教程从概念到实战,从基础到企业应用,确保不仅能看懂,更能跟着动手实践,彻底掌握它。


Mybatis缓存机制深度解析与实战

引子:为什么需要缓存?

想象一下,你每次去图书馆借同一本《Java编程思想》,都得重新在前台办理一遍完整的借书手续。这显然效率低下。如果前台有个小架子,放着最近常被借阅的书,你来了直接拿走,效率是不是就高多了?

在数据库交互中,缓存(Cache) 就是这个“小架子”。它是一块内存区域,用于存储那些已经被查询过的数据。当下次再需要同样的数据时,程序可以直接从缓存中获取,而不必再次访问慢速的数据库,从而大幅提升应用性能。

Mybatis内置了两种缓存:一级缓存二级缓存


第一部分:一级缓存 (SqlSession级别)

1. 概念解析

  • 别名:本地缓存 (Local Cache)。

  • 作用域 (Scope):它的生命周期与 SqlSession 完全绑定。也就是说,每个SqlSession对象都有自己独立的一级缓存。当SqlSession被创建时,它的一级缓存就诞生了;当SqlSession被关闭时,它的一级缓存也随之销毁。

  • 工作状态默认开启,无法关闭。这是Mybatis的内置特性。

  • 工作原理(核心)

    1. 在一个SqlSession中,当你第一次执行某个查询时,Mybatis会从数据库获取数据,并将这份数据存入当前SqlSession的一级缓存中。
    2. 在该SqlSession未关闭未执行任何增删改操作的情况下,你再次执行完全相同的查询(SQL语句、参数都一样),Mybatis会直接从一级缓存中返回数据,而不会再次访问数据库。
  • 缓存失效的场景

    1. SqlSession被关闭 (session.close())。
    2. 在当前SqlSession中执行了任何增、删、改(DML)操作 (insert, update, delete)。因为这可能导致缓存中的数据与数据库不一致(“脏数据”),所以Mybatis会清空缓存以保证数据准确性。
    3. 手动调用session.clearCache()方法。

2. 动手实践:验证一级缓存

项目结构准备:我们将使用一个标准的Maven项目结构。

mybatis-cache-demo/
├── pom.xml
└── src/
    ├── main/
    │   ├── java/
    │   │   └── com/
    │   │       └── example/
    │   │           ├── entity/
    │   │           │   └── User.java      // 用户实体类
    │   │           ├── mapper/
    │   │           │   └── UserMapper.java  // Mapper接口
    │   │           └── test/
    │   │               └── L1CacheTest.java // 我们的一级缓存测试类
    │   └── resources/
    │       ├── mappers/
    │       │   └── UserMapper.xml     // SQL映射文件
    │       └── mybatis-config.xml         // Mybatis全局配置
    └── test/
        └── ... (我们这里为了方便,测试类也放在main下)

准备代码

  1. pom.xml (依赖)

    <dependencies>
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis</artifactId>
            <version>3.5.9</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.28</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.24</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>
    
  2. mybatis-config.xml (全局配置)

    <!-- src/main/resources/mybatis-config.xml -->
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
    <configuration>
        <environments default="development">
            <environment id="development">
                <transactionManager type="JDBC"/>
                <dataSource type="POOLED">
                    <property name="driver" value="com.mysql.cj.jdbc.Driver"/>
                    <property name="url" value="jdbc:mysql://localhost:3306/your_db?useSSL=false&amp;serverTimezone=UTC"/>
                    <property name="username" value="root"/>
                    <property name="password" value="your_password"/>
                </dataSource>
            </environment>
        </environments>
        <mappers>
            <mapper resource="mappers/UserMapper.xml"/>
        </mappers>
    </configuration>
    
  3. User.java (实体类)

    // src/main/java/com/example/entity/User.java
    package com.example.entity;
    
    import lombok.Data;
    import lombok.ToString;
    
    @Data // 使用Lombok简化代码
    public class User {
        private Integer id;
        private String username;
        private String password;
    
        // 我们特意添加一个构造函数,方便观察对象是否被重新创建
        public User() {
            System.out.println("User对象被创建了!(A new User object was created!)");
        }
    }
    
  4. UserMapper.javaUserMapper.xml

    // src/main/java/com/example/mapper/UserMapper.java
    package com.example.mapper;
    import com.example.entity.User;
    public interface UserMapper {
        User findById(Integer id);
        int updateUsername(User user);
    }
    
    <!-- src/main/resources/mappers/UserMapper.xml -->
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.example.mapper.UserMapper">
        <select id="findById" resultType="com.example.entity.User">
            SELECT * FROM user WHERE id = #{id}
        </select>
        <update id="updateUsername">
            UPDATE user SET username = #{username} WHERE id = #{id}
        </update>
    </mapper>
    
  5. L1CacheTest.java (核心测试代码)

    // src/main/java/com/example/test/L1CacheTest.java
    package com.example.test;
    
    import com.example.entity.User;
    import com.example.mapper.UserMapper;
    import org.apache.ibatis.io.Resources;
    import org.apache.ibatis.session.SqlSession;
    import org.apache.ibatis.session.SqlSessionFactory;
    import org.apache.ibatis.session.SqlSessionFactoryBuilder;
    import java.io.IOException;
    import java.io.InputStream;
    
    public class L1CacheTest {
        public static void main(String[] args) throws IOException {
            String resource = "mybatis-config.xml";
            InputStream inputStream = Resources.getResourceAsStream(resource);
            SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    
            // 使用同一个SqlSession
            try (SqlSession session = sqlSessionFactory.openSession(true)) {
                UserMapper mapper = session.getMapper(UserMapper.class);
    
                System.out.println("--- 场景1:验证一级缓存的存在 ---");
                System.out.println("第一次查询ID为1的用户...");
                User user1 = mapper.findById(1);
                System.out.println(user1);
    
                System.out.println("\n第二次查询ID为1的用户 (在同一个session中)...");
                User user2 = mapper.findById(1);
                System.out.println(user2);
                System.out.println("user1 == user2 ? " + (user1 == user2)); // 验证是否是同一个对象
    
                System.out.println("\n--- 场景2:验证DML操作会清空一级缓存 ---");
                System.out.println("执行更新操作...");
                user1.setUsername("admin_updated");
                mapper.updateUsername(user1);
    
                System.out.println("\n更新后,再次查询ID为1的用户...");
                User user3 = mapper.findById(1);
                System.out.println(user3);
                System.out.println("user1 == user3 ? " + (user1 == user3));
            }
        }
    }
    

预期输出与分析:

--- 场景1:验证一级缓存的存在 ---
第一次查询ID为1的用户...
User对象被创建了!(A new User object was created!)  <-- 第一次查询,创建了对象
User(id=1, username=admin, password=...)

第二次查询ID为1的用户 (在同一个session中)...
User(id=1, username=admin, password=...)  <-- 第二次查询,没有打印“User对象被创建了”
user1 == user2 ? true  <-- 证明了第二次是从缓存中拿的同一个对象!

--- 场景2:验证DML操作会清空一级缓存 ---
执行更新操作...

更新后,再次查询ID为1的用户...
User对象被创建了!(A new User object was created!) <-- DML后,缓存失效,重新查询数据库,创建了新对象
User(id=1, username=admin_updated, password=...)
user1 == user3 ? false <-- 证明了缓存被清空,拿到了新的对象

3. 企业级思考

一级缓存非常有用,它能有效减少单个业务逻辑单元(例如一个Service方法内部)的数据库查询次数。但在典型的Web应用中,每个用户请求通常会创建一个新的SqlSession,执行完后就关闭。这意味着一级缓存无法跨请求共享数据。为了解决这个问题,二级缓存应运而生。


第二部分:二级缓存 (SqlSessionFactory级别)

1. 概念解析

  • 别名:全局缓存 (Global Cache)。
  • 作用域 (Scope):它的生命周期与 SqlSessionFactory 绑定,或者说它是在Mapper的命名空间Namespace级别共享的。这意味着,所有SqlSession都可以共享同一个Mapper的二级缓存
  • 工作状态默认关闭,需要手动开启
  • 工作原理(核心)
    1. 当一个SqlSession执行完查询并提交/关闭 (commit/close)后,它的一级缓存中的数据会被转移到对应Mapper的二级缓存中。
    2. 另一个新的SqlSession来执行相同的查询时,它会先去二级缓存中查找数据。
    3. 如果找到了,就直接返回数据;如果没找到,再走“查询数据库 -> 放入自己的一级缓存”的老路。
  • 开启二级缓存的三个步骤(缺一不可)
    1. mybatis-config.xml中开启全局缓存开关。
    2. 在需要缓存的Mapper.xml文件中添加<cache/>标签。
    3. 需要被缓存的实体类(POJO)必须实现 java.io.Serializable 接口。因为二级缓存可能将对象存储在硬盘或通过网络传输,这需要序列化。

2. 动手实践:开启并验证二级缓存

修改代码 (在之前的基础上)

  1. 修改 mybatis-config.xml

    <configuration>
        <!-- 开启全局缓存开关 -->
        <settings>
            <setting name="cacheEnabled" value="true"/>
        </settings>
        <!-- 其他配置... -->
    </configuration>
    
  2. 修改 UserMapper.xml

    <mapper namespace="com.example.mapper.UserMapper">
        <!-- 开启当前Mapper的二级缓存 -->
        <cache></cache>
        <!-- 其他SQL... -->
    </mapper>
    
  3. 修改 User.java

    // src/main/java/com/example/entity/User.java
    import java.io.Serializable; // 引入接口
    
    @Data
    public class User implements Serializable { // 实现Serializable接口
        // ... 内容不变
    }
    
  4. 创建 L2CacheTest.java (新的测试类)

    // src/main/java/com/example/test/L2CacheTest.java
    package com.example.test;
    
    import com.example.entity.User;
    import com.example.mapper.UserMapper;
    import org.apache.ibatis.io.Resources;
    import org.apache.ibatis.session.SqlSession;
    import org.apache.ibatis.session.SqlSessionFactory;
    import org.apache.ibatis.session.SqlSessionFactoryBuilder;
    import java.io.IOException;
    import java.io.InputStream;
    
    public class L2CacheTest {
        public static void main(String[] args) throws IOException {
            String resource = "mybatis-config.xml";
            InputStream inputStream = Resources.getResourceAsStream(resource);
            SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    
            System.out.println("--- 验证二级缓存 ---");
    
            User user1 = null;
            // 第一个 session
            try (SqlSession session1 = sqlSessionFactory.openSession(true)) {
                UserMapper mapper1 = session1.getMapper(UserMapper.class);
                System.out.println("Session 1: 第一次查询...");
                user1 = mapper1.findById(1);
                System.out.println(user1);
            } // session1关闭时,数据会从它的一级缓存刷新到二级缓存
    
            System.out.println("\nSession 1 已关闭。\n");
    
            User user2 = null;
            // 第二个 session
            try (SqlSession session2 = sqlSessionFactory.openSession(true)) {
                UserMapper mapper2 = session2.getMapper(UserMapper.class);
                System.out.println("Session 2: 再次查询相同数据...");
                user2 = mapper2.findById(1);
                System.out.println(user2);
            }
    
            System.out.println("\nuser1.equals(user2) ? " + user1.equals(user2));
            System.out.println("user1 == user2 ? " + (user1 == user2));
        }
    }
    

预期输出与分析:

--- 验证二级缓存 ---
Session 1: 第一次查询...
User对象被创建了!(A new User object was created!) <-- 第一个session查询,创建对象
User(id=1, username=admin_updated, password=...)

Session 1 已关闭。

Session 2: 再次查询相同数据...
User(id=1, username=admin_updated, password=...) <-- 第二个session查询,没有打印“User对象被创建了”
                                                <-- 这证明了数据来自缓存,而不是数据库!

user1.equals(user2) ? true   <-- 内容相同
user1 == user2 ? false  <-- 但对象不同!因为二级缓存返回的是序列化后再反序列化的副本,不是原对象。

这个false的结果是理解二级缓存的关键,它与一级缓存的true形成鲜明对比。

3. 企业级应用与思考

  • 适用场景:二级缓存非常适合读多写少数据不常变化的场景。

    • 绝佳例子:系统配置表、国家/地区/省份代码表、商品分类信息、用户角色权限。这些数据被频繁读取,但很少修改。为它们开启二级缓存能极大地提升性能。
    • 不适用例子:商品库存、用户余额、订单状态。这些数据变化频繁,如果使用缓存,很容易出现数据不一致的问题。
  • 缓存击穿与第三方缓存:Mybatis自带的二级缓存功能相对基础。在大型分布式系统中,为了解决缓存击穿、雪崩等问题,以及实现更精细的缓存控制(如设置过期时间),企业通常会整合专业的第三方缓存框架,如 RedisEhcache

    • 企业实践:在Mapper.xml<cache>标签中,可以通过type属性指定使用Redis作为二级缓存的实现。这样做的好处是,缓存由独立的Redis服务管理,可以被多个应用实例共享,并且应用重启后缓存依然存在。

总结与对比

特性 一级缓存 (L1) 二级缓存 (L2)
作用域 SqlSession SqlSessionFactory (或Mapper Namespace)
生命周期 SqlSession共存亡 与应用共存亡
默认状态 默认开启,无法关闭 默认关闭,需手动开启
共享性 不共享,SqlSession之间隔离 所有SqlSession共享
数据一致性 强,DML操作自动清空 弱,依赖于配置和DML刷新
对象引用 返回同一个对象 (==true) 返回对象的副本 (反序列化,==false)
核心用途 优化单个业务流程内的重复查询 优化跨业务、跨请求的全局热点数据查询

核心记忆点缓存是性能和数据一致性之间的一种权衡。 一级缓存牺牲了小部分内存,换取了单个会话内的性能提升,且能保证强一致性。二级缓存牺牲了更强的实时一致性,换取了全局范围的巨大性能提升。理解这个核心思想,你就真正掌握了Mybatis的缓存机制。

posted @ 2025-07-02 17:16  清明雨上~  阅读(308)  评论(0)    收藏  举报