AI摘要

文章以订单金额脏读事故切入,深度剖析MyBatis一二级缓存机制:一级缓存SqlSession级默认开启,跨事务隔离致脏读;二级缓存Namespace级需显式配置,事务提交前仅写事务缓存。给出禁用缓存、合并事务、SELECT FOR UPDATE、自定义Redis缓存等方案,并总结分布式同步、Spring Cache集成及Key设计等最佳实践,强调理解缓存生命周期与事务边界的重要性。

一、事故回顾:订单金额显示异常的背后

1.1 问题场景重现

先来看看当时有问题的代码:

@Service
@Transactional
public class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    public void processOrder(Long orderId) {
        // 第一次查询:获取订单信息
        Order order = orderMapper.selectById(orderId);
        System.out.println("原始金额: " + order.getAmount());
        
        // 模拟复杂的业务逻辑处理
        processBusinessLogic(order);
        
        // 第二次查询:理论上应该重新从数据库获取
        Order order2 = orderMapper.selectById(orderId);
        System.out.println("缓存中的金额: " + order2.getAmount());
        
        // 这里可能出现不一致!
        if (!order.getAmount().equals(order2.getAmount())) {
            log.error("数据不一致!订单ID: {}", orderId);
        }
    }
    
    private void processBusinessLogic(Order order) {
        // 模拟其他服务更新了数据库
        updateOrderAmountInNewTransaction(order.getId());
    }
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateOrderAmountInNewTransaction(Long orderId) {
        // 在新事务中更新订单金额
        orderMapper.updateAmount(orderId, new BigDecimal("999.00"));
    }
}

在高压环境下,偶尔会出现order.getAmount()order2.getAmount()不一致的情况,尽管数据库中的金额已经被更新。

1.2 问题的根源

问题的根源在于对MyBatis缓存机制理解不足:

// 在同一个SqlSession中,查询逻辑大致如下:
public class DefaultSqlSession implements SqlSession {
    
    private final Executor executor;
    
    @Override
    public <T> T selectOne(String statement, Object parameter) {
        // 先查缓存,再查数据库
        return executor.query(statement, parameter, RowBounds.DEFAULT, 
                            Executor.NO_RESULT_HANDLER);
    }
}

二、MyBatis缓存体系全景解析

2.1 缓存架构总览

MyBatis的缓存分为两级,构成了一个完整的缓存体系:

应用程序
    │
    └── MyBatis缓存体系
         │
         ├── 一级缓存 (本地缓存)
         │     ├── SqlSession级别
         │     ├── 默认开启
         │     └── 生命周期与SqlSession相同
         │
         └── 二级缓存 (全局缓存)
               ├── Mapper级别/Namespace级别
               ├── 需要手动开启
               └── 应用生命周期

2.2 源码中的缓存关键接口

public interface Cache {
    String getId();  // 缓存标识
    void putObject(Object key, Object value);  // 存入缓存
    Object getObject(Object key);  // 获取缓存
    Object removeObject(Object key);  // 移除缓存
    void clear();  // 清空缓存
    int getSize();  // 缓存大小
}

三、一级缓存深度剖析:SqlSession级别的缓存机制

3.1 一级缓存的工作机制

一级缓存是MyBatis默认开启的缓存机制,它的生命周期与SqlSession绑定:

public abstract class BaseExecutor implements Executor {
    
    // 一级缓存实现(PerpetualCache)
    protected PerpetualCache localCache;
    
    @Override
    public <E> List<E> query(MappedStatement ms, Object parameter, 
                            RowBounds rowBounds, ResultHandler resultHandler) {
        // 1. 生成缓存Key
        CacheKey key = createCacheKey(ms, parameter, rowBounds);
        
        // 2. 查询一级缓存
        List<E> list = (List<E>) localCache.getObject(key);
        if (list != null) {
            return list;
        }
        
        // 3. 缓存未命中,查询数据库
        list = queryFromDatabase(ms, parameter, rowBounds, resultHandler);
        
        // 4. 结果存入一级缓存
        localCache.putObject(key, list);
        return list;
    }
}

3.2 一级缓存的Key生成策略

缓存Key的生成决定了缓存的命中率:

public class CacheKey implements Cloneable, Serializable {
    
    private final int multiplier;  // 乘数,默认37
    private int hashcode;          // 哈希值
    private long checksum;         // 校验和
    private int count;             // 参数个数
    private List<Object> updateList;  // 参与计算的对象列表
    
    public void update(Object object) {
        // 基于对象哈希码、类名、内容等生成唯一Key
        int baseHashCode = object == null ? 1 : 
            ArrayUtil.hashCode(object);
        
        count++;
        checksum += baseHashCode;
        baseHashCode *= count;
        
        hashcode = multiplier * hashcode + baseHashCode;
        
        updateList.add(object);
    }
}

缓存Key的组成要素​​:

  • MappedStatement的id
  • 分页参数(RowBounds)
  • SQL语句
  • 参数值
  • 环境id(Environment)

3.3 一级缓存的失效时机

一级缓存会在以下情况下被清空:

public abstract class BaseExecutor implements Executor {
    
    @Override
    public int update(MappedStatement ms, Object parameter) {
        // 执行更新操作前清空本地缓存
        clearLocalCache();
        return doUpdate(ms, parameter);
    }
    
    @Override
    public void clearLocalCache() {
        if (!closed) {
            localCache.clear();
            localOutputParameterCache.clear();
        }
    }
}

具体的失效场景​:

  1. 执行update/insert/delete操作
  2. 执行commit/rollback操作
  3. 执行flushCache="true"的查询
  4. SqlSession关闭

四、二级缓存深度解析:Namespace级别的共享缓存

4.1 二级缓存的配置与启用

二级缓存需要显式开启,并支持丰富的配置选项:

<!-- 在mybatis-config.xml中启用二级缓存 -->
<settings>
    <setting name="cacheEnabled" value="true"/>
</settings>

<!-- 在Mapper XML中配置缓存 -->
<mapper namespace="com.example.OrderMapper">
    <cache 
        eviction="LRU"
        flushInterval="60000"
        size="512"
        readOnly="true"
        type="org.mybatis.caches.ehcache.EhcacheCache"/>
    
    <select id="selectById" resultType="Order" useCache="true">
        SELECT * FROM orders WHERE id = #{id}
    </select>
</mapper>

4.2 二级缓存的工作机制

二级缓存的实现比一级缓存复杂得多,涉及事务同步机制:

public class CachingExecutor implements Executor {
    
    private final Executor delegate;  // 被装饰的Executor
    private final TransactionalCacheManager tcm = new TransactionalCacheManager();
    
    @Override
    public <E> List<E> query(MappedStatement ms, Object parameter, 
                            RowBounds rowBounds, ResultHandler resultHandler) {
        // 获取BoundSql(包含解析后的SQL)
        BoundSql boundSql = ms.getBoundSql(parameter);
        
        // 创建缓存Key
        CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
        
        return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
    }
    
    private <E> List<E> query(MappedStatement ms, Object parameter,
                             RowBounds rowBounds, ResultHandler resultHandler, 
                             CacheKey key, BoundSql boundSql) {
        // 获取二级缓存
        Cache cache = ms.getCache();
        
        if (cache != null) {
            // 根据需要刷新缓存
            flushCacheIfRequired(ms);
            
            if (ms.isUseCache() && resultHandler == null) {
                // 从二级缓存查询
                List<E> list = (List<E>) tcm.getObject(cache, key);
                if (list != null) {
                    return list;
                }
            }
        }
        
        // 缓存未命中,委托给真正的Executor查询
        list = delegate.query(ms, parameter, rowBounds, resultHandler, key, boundSql);
        
        if (cache != null && ms.isUseCache()) {
            // 将结果存入二级缓存
            tcm.putObject(cache, key, list);
        }
        
        return list;
    }
}

4.3 二级缓存的事务同步机制

这是二级缓存最复杂的部分,也是脏读问题的根源:

public class TransactionalCacheManager {
    
    private final Map<Cache, TransactionalCache> transactionalCaches = 
        new HashMap<>();
    
    public Object getObject(Cache cache, CacheKey key) {
        // 获取对应缓存的事务视图
        return getTransactionalCache(cache).getObject(key);
    }
    
    public void putObject(Cache cache, CacheKey key, Object value) {
        // 在事务提交前,数据只存在于事务缓存中
        getTransactionalCache(cache).putObject(key, value);
    }
    
    public void commit() {
        for (TransactionalCache txCache : transactionalCaches.values()) {
            txCache.commit();
        }
    }
    
    public void rollback() {
        for (TransactionalCache txCache : transactionalCaches.values()) {
            txCache.rollback();
        }
    }
}

五、事故分析:脏读问题的技术根源

回到我们开头的事故,现在可以深入分析问题的技术根源:

5.1 问题发生的完整流程

// 事务1:订单处理流程
@Transactional
public void processOrder(Long orderId) {
    // 步骤1:查询订单(存入一级缓存)
    Order order = orderMapper.selectById(orderId);
    
    // 步骤2:在新事务中更新金额
    updateOrderAmountInNewTransaction(orderId);
    
    // 步骤3:再次查询(从一级缓存读取旧数据)
    Order order2 = orderMapper.selectById(orderId);
    // 这里读取到的是缓存中的旧数据!
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateOrderAmountInNewTransaction(Long orderId) {
    // 新事务更新数据库
    orderMapper.updateAmount(orderId, new BigDecimal("999.00"));
    // 更新操作会清空新事务的一级缓存,但不影响原事务的缓存
}

5.2 缓存与事务的交互问题

问题的核心在于​不同SqlSession的一级缓存是隔离的​:

// 在Spring管理的事务中,事务边界与SqlSession边界的关系
public class SpringManagedTransaction implements Transaction {
    
    private Connection connection;
    
    // 同一个事务使用同一个SqlSession
    // 不同事务使用不同的SqlSession
}

当新事务更新数据时,它只能清空自己SqlSession的一级缓存,无法影响原事务的缓存。

六、解决方案:多维度避免脏读问题

基于对缓存机制的深入理解,我总结了几种解决方案:

6.1 方案1:禁用特定查询的缓存

// Mapper接口中禁用缓存
public interface OrderMapper {
    @Options(flushCache = FlushCachePolicy.TRUE)
    Order selectById(Long id);
}

// XML配置中禁用缓存
<select id="selectById" resultType="Order" flushCache="true">
    SELECT * FROM orders WHERE id = #{id}
</select>

6.2 方案2:合理设计事务边界

@Service
public class OrderService {
    
    // 将查询和更新放在同一个事务中
    @Transactional
    public void processOrder(Long orderId) {
        // 在更新前查询,避免跨事务的缓存问题
        updateOrderAmount(orderId);
        
        // 如果需要最新数据,重新查询(此时缓存已被清空)
        Order latestOrder = orderMapper.selectById(orderId);
    }
    
    private void updateOrderAmount(Long orderId) {
        orderMapper.updateAmount(orderId, new BigDecimal("999.00"));
        // 更新操作会清空当前事务的一级缓存
    }
}

6.3 方案3:使用SELECT FOR UPDATE避免并发问题

public interface OrderMapper {
    // 使用悲观锁确保数据一致性
    @Select("SELECT * FROM orders WHERE id = #{id} FOR UPDATE")
    Order selectByIdForUpdate(Long id);
}

@Service
public class OrderService {
    
    @Transactional
    public void processOrder(Long orderId) {
        // 使用行锁确保数据一致性
        Order order = orderMapper.selectByIdForUpdate(orderId);
        
        // 处理业务逻辑
        processBusinessLogic(order);
        
        // 这里读取的一定是最新数据
        Order latestOrder = orderMapper.selectById(orderId);
    }
}

6.4 方案4:自定义缓存策略解决分布式环境问题

// 自定义缓存实现,解决分布式环境下的缓存一致性问题
public class RedisCustomCache implements Cache {
    
    private final String id;
    private final RedisTemplate<String, Object> redisTemplate;
    
    public RedisCustomCache(String id) {
        this.id = id;
        this.redisTemplate = createRedisTemplate();
    }
    
    @Override
    public void putObject(Object key, Object value) {
        // 使用Redis事务确保缓存一致性
        redisTemplate.opsForValue().set(getKeyString(key), value);
        
        // 发布缓存更新消息
        redisTemplate.convertAndSend("cache-update-channel", 
            new CacheUpdateMessage(id, key));
    }
    
    @Override
    public Object getObject(Object key) {
        return redisTemplate.opsForValue().get(getKeyString(key));
    }
}

七、最佳实践:MyBatis缓存使用指南

7.1 一级缓存使用建议

@Service
public class OrderService {
    
    // 场景1:只读操作,充分利用一级缓存
    @Transactional(readOnly = true)
    public Order getOrderDetails(Long orderId) {
        // 同一个方法内多次查询相同数据,利用一级缓存提升性能
        Order order1 = orderMapper.selectById(orderId);
        // ... 一些业务逻辑
        Order order2 = orderMapper.selectById(orderId); // 命中缓存
        
        return order2;
    }
    
    // 场景2:读写混合操作,注意缓存失效时机
    @Transactional
    public void updateOrder(Order order) {
        // 先查询
        Order existing = orderMapper.selectById(order.getId());
        
        // 更新操作会清空一级缓存
        orderMapper.update(order);
        
        // 如果需要最新数据,重新查询
        Order latest = orderMapper.selectById(order.getId());
    }
}

7.2 二级缓存配置建议

<!-- 合理的二级缓存配置 -->
<cache
    eviction="LRU"               <!-- 使用LRU淘汰策略 -->
    flushInterval="300000"      <!-- 5分钟自动刷新 -->
    size="1024"                 <!-- 最多缓存102个对象 -->
    readOnly="false"            <!-- 非只读,支持数据更新 -->
    blocking="true"             <!-- 防止缓存击穿 -->
/>

<!-- 针对特定查询调整缓存策略 -->
<select id="selectActiveOrders" resultType="Order" useCache="true">
    SELECT * FROM orders WHERE status = 'ACTIVE'
</select>

<select id="selectFinancialData" resultType="Order" useCache="false" flushCache="true">
    SELECT * FROM orders WHERE amount > 1000  <!-- 财务查询禁用缓存 -->
</select>

7.3 复杂查询场景的缓存处理

// 复杂查询对象的缓存处理
@Data
public class OrderQuery {
    private Long orderId;
    private Date startTime;
    private Date endTime;
    private List<String> statusList;
    
    // 重写equals和hashCode方法,确保缓存Key正确
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        OrderQuery that = (OrderQuery) o;
        return Objects.equals(orderId, that.orderId) &&
               Objects.equals(startTime, that.startTime) &&
               Objects.equals(endTime, that.endTime) &&
               Objects.equals(statusList, that.statusList);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(orderId, startTime, endTime, statusList);
    }
}

public interface OrderMapper {
    List<Order> selectByComplexQuery(OrderQuery query);
}

八、高级话题:分布式环境下的缓存一致性

在微服务架构下,MyBatis缓存面临新的挑战:

8.1 多服务实例的缓存同步问题

// 使用Redis Pub/Sub实现缓存同步
@Component
public class CacheSyncListener {
    
    @Autowired
    private SqlSessionFactory sqlSessionFactory;
    
    @EventListener
    public void handleCacheEvictEvent(CacheEvictEvent event) {
        // 接收到缓存失效消息,清空本地缓存
        Cache cache = sqlSessionFactory.getConfiguration()
                      .getCache(event.getNamespace());
        if (cache != null) {
            cache.clear();
        }
    }
}

8.2 与Spring Cache的集成策略

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        // 使用Redis作为二级缓存的存储后端
        RedisCacheManager cacheManager = RedisCacheManager
            .builder(redisConnectionFactory())
            .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(30)))
            .build();
        return cacheManager;
    }
}

// 在Service层使用Spring Cache,MyBatis层使用一级缓存
@Service
public class OrderService {
    
    @Cacheable(value = "orders", key = "#orderId")
    public Order getOrderWithSpringCache(Long orderId) {
        // 这个方法会被Spring Cache拦截
        return orderMapper.selectById(orderId);
    }
}

总结

经过这次深刻的事故分析和源码研究,我对MyBatis缓存机制有了全新的认识:

  1. 一级缓存是SqlSession级别的​:生命周期短,适用于同一会话内的重复查询
  2. 二级缓存是Namespace级别的​:生命周期长,需要谨慎处理数据一致性
  3. 缓存Key的生成很关键​:影响缓存的命中率和正确性
  4. 事务边界决定缓存有效性​:不同事务间的缓存是隔离的

最重要的启示​:缓存不是简单的性能优化工具,而是需要深入理解其工作机制的复杂系统。错误的使用缓存比不使用缓存更危险。

这次经历让我养成了在涉及数据一致性的场景下谨慎使用缓存的习惯,也让我在设计系统时更加注重缓存策略的合理性。真正的技术成长来自于解决实际问题的深度思考,而不是表面的API记忆。

版权声明 ▶ 本网站名称:黄磊的博客
▶ 本文标题:MyBatis一级缓存与二级缓存深度解析:从一场数据脏读事故说起
▶ 本文链接:https://www.huangleicole.com/backend-related/50.html
▶ 转载本站文章需要遵守:商业转载请联系站长,非商业转载请注明出处!!

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