Spring事务陷阱:MyBatis一级缓存引发的主键冲突问题


一、问题背景

某次开发中,我需要处理一个批量插入提醒数据的业务。核心代码如下:

//@Transactional(rollbackFor = Exception.class)
public void processRemindData(String batchNo) {
    // 1.查询待处理数据
    List<CuSixNoTranTemp> list = queryData(batchNo);
    
    // 2.为每个数据生成日志对象并设置UUID主键
    List<BatchNoteSendLog> logs = list.stream()
         .map(item -> {
             BatchNoteSendLog log = convertToLog(item);
             log.setUuid(sequenceService.getNext()); //获取序列值
             return log;
         }).collect(Collectors.toList());
    
    // 3.批量更新状态并插入日志
    updateData(list);
    batchNoteSendLogDao.saveBatch(logs);
}

在未添加@Transactional注解时,程序运行正常。但当添加事务注解后,批量插入时却出现了主键冲突异常,且所有日志的UUID值完全相同。


二、问题分析

1. 事务与MyBatis一级缓存的关系

当添加@Transactional后,整个方法会在同一个数据库会话(Session)中执行。MyBatis默认启用一级缓存(SqlSession级别),在同一会话中重复的查询会直接返回缓存结果。

查看获取序列值的实现:

<!-- SequenceMapper.xml -->
<select id="getNext" resultType="java.lang.Long">
    SELECT my_sequence.nextval FROM dual
</select>

2. 缓存导致的序列重复

未开启事务时,每次getNext()调用都会创建新会话,查询直接命中数据库。而开启事务后:

  1. 所有getNext()调用共享同一会话
  2. 第一次查询my_sequence.nextval后,结果被缓存
  3. 后续查询直接从缓存返回相同值
  4. 最终所有日志对象使用相同UUID导致主键冲突

三、解决方案

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

对于XML映射方式:

<select id="getNext" resultType="java.lang.Long" 
        flushCache="true" useCache="false">
    SELECT my_sequence.nextval FROM dual
</select>

对于注解方式:

@Select("SELECT to_char(sysdate,'yyyyMMdd')||(LPAD( ${sequenceName}.nextval, 10 ,'0' )) FROM dual")
@Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false)
String getNext(@Param("sequenceName") String sequenceName);
  • @Options(flushCache = true):执行后清空缓存
  • useCache = false:禁止缓存本次结果

方案2:调整事务范围

@Service
public class TransactionService {
    @Transactional
    public void executeInTransaction(List<CuSixNoTranTemp> list, 
                                    List<BatchNoteSendLog> logs) {
        // 更新和插入操作
    }
}

// 在业务类中注入事务服务
@Autowired
private TransactionService transactionService;

public void processRemindData(String batchNo) {
    // 非事务操作...
    transactionService.executeInTransaction(list, logs); // 跨类调用事务方法
}

四、知识扩展

1. @Transactional使用规范

  • 方法必须为public:Spring AOP代理无法拦截私有方法
  • 避免同类调用:通过注入自身代理或拆分到不同类中解决

2. MyBatis缓存机制详解

特性 一级缓存 二级缓存
作用范围 SqlSession级别 Mapper(Namespace)级别
生命周期 随会话关闭销毁 应用生命周期内有效
缓存清除策略 执行更新操作自动清除 需手动配置过期策略

3. 事务传播机制

当使用@Transactional(propagation = Propagation.REQUIRES_NEW)时,即使存在外层事务,也会创建新事务。可用于分离关键操作。


五、最佳实践建议

  1. 序列查询强制走数据库
    无论使用XML还是注解方式,务必添加缓存控制参数:

    @Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false)
    
  2. 事务方法分层设计

    @Service
    public class DataService {
        // 非事务方法
        public void prepareData() { ... }
    }
    
    @Service
    public class TransactionService {
        // 事务方法
        @Transactional
        public void saveData() { ... }
    }
    
  3. 高并发场景优化

    // 使用分布式ID生成器
    @Component
    public class SnowflakeIdGenerator {
        public String nextId() {
            // 实现雪花算法...
        }
    }
    

六、总结

通过这个案例,我们串联起三个关键技术点:

  1. Spring事务的代理机制决定了事务方法必须通过代理调用
  2. MyBatis缓存设计需要与业务场景匹配
  3. 序列生成方式必须考虑会话生命周期

最终的解决方案体现了两个重要思想:

  • 关注点分离:将事务操作与非事务操作分层
  • 显式控制:对关键查询主动声明缓存策略

在日后的开发中,建议:

  1. 对核心数据库操作添加单元测试
  2. 在预发环境进行并发压力测试
  3. 定期Review缓存配置与事务边界

技术细节决定系统稳定性,只有深入理解框架原理,才能写出健壮的代码。希望这个案例能帮助大家少走弯路!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容