1 顶层方法@Transactional

开启事务的方法A调用方法B,方法B中插入一条数据,并立刻查询返回,结果Mybatis打印插入成功,但是查询失败,返回为null

  1. @Transactional
  2. public void A(){
  3. B();
  4. }
  5. public TeamConsumeAmountRecord B(String uid){
  6. Date beginOfMonth = DateUtils.beginOfMonth();
  7. TeamConsumeAmountRecordExample e = new TeamConsumeAmountRecordExample();
  8. e.createCriteria().andUserIdEqualTo(uid).andDateEqualTo(beginOfMonth);
  9. List<TeamConsumeAmountRecord> list = selectByExample(e);
  10. int returnId=0;
  11. //找不到记录或者和今天不是同一天,插入新记录
  12. if(list.size()==0 || !DateUtils.isSameMonth(list.get(0).getDate())) {
  13. returnId= insertOneNewTeamRecommendRecord(uid);
  14. log.debug("返回自增长id:{}",returnId);
  15. TeamConsumeAmountRecord res = selectByPrimaryKey(returnId + "");
  16. log.debug("搜索结果:{}",res);
  17. return res;
  18. }
  19. return list.get(0);
  20. }
  21. /**
  22. * 插入一个空团队推荐的记录,返回自增长id
  23. */
  24. public int insertOneNewTeamRecommendRecord(String uid){
  25. TeamConsumeAmountRecord record = new TeamConsumeAmountRecord();
  26. record.setAmount(new BigDecimal(0));
  27. record.setUserId(uid);
  28. record.setDate(DateUtils.beginOfMonth());
  29. teamConsumeAmountRecordMapper.insertSelectiveAndReturn(record);
  30. return record.getId();
  31. }

插入成功,但是查询失败,返回为null:


2021-02-12 23:03:22.116 -> [http-nio-8080-exec-4] -> DEBUG c.f.v.m.a.T.insertSelectiveAndReturn - ==>  Preparing: insert into c_team_consume_amount_record ( user_id, amount, date ) values ( ?, ?, ? ) 
2021-02-12 23:03:22.120 -> [http-nio-8080-exec-4] -> DEBUG c.f.v.m.a.T.insertSelectiveAndReturn - ==> Parameters: 12(String), 0(BigDecimal), 2021-02-01 00:00:00.0(Timestamp)
2021-02-12 23:03:22.178 -> [http-nio-8080-exec-4] -> DEBUG c.f.v.m.a.T.insertSelectiveAndReturn - <==    Updates: 1
2021-02-12 23:03:22.182 -> [http-nio-8080-exec-4] -> DEBUG com.fc.v2.service.TeamConsumeAmountRecordService - 返回自增长id:1
2021-02-12 23:03:22.183 -> [http-nio-8080-exec-4] -> DEBUG c.f.v.m.a.T.selectByPrimaryKey - ==>  Preparing: select id, user_id, amount, date from c_team_consume_amount_record where id = ? 
2021-02-12 23:03:22.183 -> [http-nio-8080-exec-4] -> DEBUG c.f.v.m.a.T.selectByPrimaryKey - ==> Parameters: 1(Integer)
2021-02-12 23:03:22.213 -> [http-nio-8080-exec-4] -> DEBUG c.f.v.m.a.T.selectByPrimaryKey - <==      Total: 0

2021年2月14日勘误:这里其实可能是因为返回的自增长id不对,所以查不到。。。实际上一个事务里就算是可重复读,自己的插入操作是可以查询到的。

2 PROPAGATION_REQUIRES_NEW 开启独立事务

查询过后,建议对B方法的插入开启独立事务,也就是在插入方法insertOneNewTeamRecommendRecord上新加@Transactional注解,同时传播级别为PROPAGATION_REQUIRES_NEW

//无论当前事务是否存在,都会创建新事务运行方法,这样新事务就可以拥有新的锁和隔离级别等特性,与当前事务相互独立 REQUIRES_NEW(TransactionDefinition.PROPAGATION_REQUIRES_NEW),
参考:事务隔离级别和传播行为以及@Transactional使用规范

/**
     * 插入一个空团队推荐的记录,返回自增长id
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public int insertOneNewTeamRecommendRecord(String uid){
        TeamConsumeAmountRecord record = new TeamConsumeAmountRecord();
        record.setAmount(new BigDecimal(0));
        record.setUserId(uid);
        record.setDate(DateUtils.beginOfMonth());
        teamConsumeAmountRecordMapper.insertSelectiveAndReturn(record);
        return record.getId();
    }

但是无效,记录没有被插入,其他操作被回滚,因此也看不到变化。

3 service自己注入自己,再调用内部方法

查询到,如果在一个类里调用这个类的方法,其上的@Transactional会无效,因为不会对这个方法进行代理。

参考:spring 事务REQUIRES_NEW 不起作用的解决方法 spring 的事务传播这边就不提了,各种可百度到。但在用REQUIRES_NEW的时候,发现没有起作用。 原因:spring的事务管理通过切面实现,如果直接使用this.方法()或者方法(),不会触发切面中对事务的管理。应使用该方法所在的类的实例.方法()。 解决方案1:需要将两个方法分别写在不同的类里。 解决方案2:方法写在同一个类里,但调用B方法的时候,将service自己注入自己,用这个注入对象来调用B方法。 使用方案2解决了我遇到的问题 **

参考:事务的传播无效,required_new无效,动态代理给spring事务传播留下的坑 / required_new spring事务传播行为无效碰到的坑! 只有代理对象proxy直接调用的那个方法才是真正的走代理的,嵌套的方法实际上就是 直接把嵌套的代码移动到代理的方法里面。 所以,嵌套的事务都不能生效。


使用方案2,发现
插入新数据成功,**但是查询仍旧失败,其他操作回滚。


2021-02-13 21:12:28.081 -> [http-nio-8080-exec-2] -> DEBUG c.f.v.m.a.T.insertSelectiveAndReturn - ==>  Preparing: insert into c_team_consume_amount_record ( user_id, amount, date ) values ( ?, ?, ? ) 
2021-02-13 21:12:28.083 -> [http-nio-8080-exec-2] -> DEBUG c.f.v.m.a.T.insertSelectiveAndReturn - ==> Parameters: 15(String), 0(BigDecimal), 2021-02-01 00:00:00.0(Timestamp)
2021-02-13 21:12:28.139 -> [http-nio-8080-exec-2] -> DEBUG c.f.v.m.a.T.insertSelectiveAndReturn - <==    Updates: 1
2021-02-13 21:12:28.162 -> [http-nio-8080-exec-2] -> DEBUG com.fc.v2.service.TeamConsumeAmountRecordService - 返回自增长id:21
2021-02-13 21:12:28.163 -> [http-nio-8080-exec-2] -> DEBUG c.f.v.m.a.T.selectByPrimaryKey - ==>  Preparing: select id, user_id, amount, date from c_team_consume_amount_record where id = ? 
2021-02-13 21:12:28.165 -> [http-nio-8080-exec-2] -> DEBUG c.f.v.m.a.T.selectByPrimaryKey - ==> Parameters: 21(Integer)
2021-02-13 21:12:28.173 -> [http-nio-8080-exec-2] -> DEBUG c.f.v.m.a.T.selectByPrimaryKey - <==      Total: 0
2021-02-13 21:12:28.174 -> [http-nio-8080-exec-2] -> WARN  com.fc.v2.service.TeamConsumeAmountRecordService - null:获取团队消费业绩记录失败

4 设置未提交读

查询以为是隔离级别的问题,在A方法上设置未提交读,这样可以读取到未提交的数据:

2.隔离级别详解

我们 主要针对第二种丢失更新,为了压制丢失更新,数据库标准提出了4类不同的隔离级别,分别为未提交读(read uncommitted)、读写提交(read commited)、可重复读和串行化。提出4种不同的隔离级别是出于性能的考虑 未提交读(read uncommitted)会产生脏读 未提交读是最低的隔离级别,其含义是允许一个事务读取另外一个事务没有提交的数据。未提交读是一种危险的隔离级别,所以一般在我们实际的开发中应用不广 , 但是它的优点在于并发能力高,适合那些对数据一致性没有要求而追求高并发的场景 ,它的最大坏处是出现脏读 。 读写提交(read committed)会产生不可重复读 读写提交隔离级别,是指一个事务只能读取另一个事务已经提交的数据,不能读取未提交的数据。 可重复读 会产生幻读 可重复读的目标是克服读写提交中出现的不可重复读的现象,因为在读写提交的时候,可能出现一些值的变化, 影响当前事务的执行,如上述的库存是个变化的值,这个时候数据库提出 了可重复读的隔离级别 串行化(Serializable) 串行化(Serializable)是数据库最高的隔离级别,它会要求所有的 SQL 都会按照顺序执行,这样就可以克服上述隔离级别出现的各种问题,所以它能够完全保证数据的一致性 。 参考:Springboot中的数据库事务

A方法修改如下:


@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public void A(){
  B();
}

但是无效。
**
怀疑事务中的查询操作没法查询到未提交的插入数据。但是前面的操作都无效,可能是配置有问题。

2021年2月14日更新:
如果想要查询到,除了设置A为未提交读,查询操作应该放在


5 罪魁祸首:快照读

MySQL的innodb引擎默认采用可重复读隔离级别,Spring的事务管理采用默认DEFAULT隔离级别时自动获取为可重复读。为了实现可重复读,mysql使用两种技术:第一个是mvcc(多版本控制),第二个是next-key技术(间隙锁)
可以简单理解为mvcc实现快照读,next-key实现当前读。使用的语句不同,采用技术不同:

  1. 快照读(snapshot read) 简单的select操作(不包括 select … lock in share mode, select … for update) 2.当前读(current read)

select … lock in share mode

select … for update

insert

update

delete 参考:当前读和快照读

spring隔离级别
DEFAULT :默认值,表示使用底层数据库的默认隔离级别。大部分数据库为READ_COMMITTED(**MySql默认隔离级别为REPEATABLE**)

回到上文,REQUIRES_NEW会开启新事务,挂起旧事务,旧事务抛出异常时,新事务不会回滚,因此可以看到插入的新纪录。
但是由于旧事务是RR(可重复读)级别,第一次普通查询后生成快照,之后的普通查询都是按照快照查询,因此看不到新插入的数据,返回null。

参考:mysql事务提交后为什么看不到最新的记录 这个问题的原因是因为select是属于快照查询,当你开启一个事务之后,进行了第一次查询,这时候mysql就会记录下当前的事务版本,后面的查询都会只查询这个版本的数据,而其他事务里面进行了insert,update,delete操作会产生一个新的版本数据,所以才会查不出来

解决方法:使用当前读而不是快照读

插入新记录后的查询操作,修改为:

TeamConsumeAmountRecord res = selectByPrimaryKeyOnLock(returnId + "");

selectByPrimaryKeyOnLock的sql语句改为:

select *  from c_team_consume_amount_record 
where id = #{id,jdbcType=INTEGER}
FOR UPDATE

最重要的是在最后加上了FOR UPDATE,那么这个select就是当前读,可以获取当前数据库的真实数据。

最后操作成功!


贴上全部代码:

    //自己注入自己
    @Autowired
    private TeamConsumeAmountRecordService teamConsumeAmountRecordService;

//对A所有数据库操作开启一个事务
@Transactional
public void A(String uid){
  B(uid);
}
//B方法
public TeamConsumeAmountRecord B(String uid){
        Date beginOfMonth = DateUtils.beginOfMonth();
        TeamConsumeAmountRecordExample e = new TeamConsumeAmountRecordExample();
        e.createCriteria().andUserIdEqualTo(uid).andDateEqualTo(beginOfMonth);

        List<TeamConsumeAmountRecord> list = selectByExample(e);
        int returnId=0;
        if(list.size()==0 || !DateUtils.isSameMonth(list.get(0).getDate())) {
      //开启独立事务的插入方法
            returnId = teamConsumeAmountRecordService.insertOneNewTeamRecommendRecord(uid);
            log.debug("返回自增长id:{}",returnId);
        }

        if(list.size()!=0){
            return list.get(0);
        }else{
      //使用当前读的查询方法
            TeamConsumeAmountRecord res = selectByPrimaryKeyOnLock(returnId + "");
            log.warn("null:获取团队消费业绩记录失败");
            return res;
        }

    }

/**
     * 插入一个空团队推荐的记录,返回自增长id,这里开启新事务
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public int insertOneNewTeamRecommendRecord(String uid){
        TeamConsumeAmountRecord record = new TeamConsumeAmountRecord();
        record.setAmount(new BigDecimal(0));
        record.setUserId(uid);
        record.setDate(DateUtils.beginOfMonth());
        teamConsumeAmountRecordMapper.insertSelectiveAndReturn(record);
        return record.getId();
    }


selectByPrimaryKeyOnLock(id)执行的sql:
select *  from c_team_consume_amount_record 
where id = #{id,jdbcType=INTEGER}
FOR UPDATE

实验

开启两个查询,先执行:

查询1:

begin;
select * from t_test;

结果为:
image.png

再执行:

查询2:
begin;
UPDATE t_test set name="b" where id = 1;
select * from t_test;

结果为:更新成功,但是没提交。
image.png
这时候再执行查询1的select * from t_test;语句,结果为:
image.png
和之前查询1的结果一样。证明实现了可重复读。
但是如果查询和更新(插入)放在同一个事务,那么怎么样都是可以获取最新结果的。

思考

mysql快照生成时间?
RR级别的事务第一次普通查询后生成快照。
参考:Mysql可重复读(1) —— 快照何时创建

因此事务中,先普通查询一次,再进行插入操作,之后的普通查询都是快照读,无法获取数据库真实数据(看不到刚刚插入的数据)。
解决方法:第二次查询使用当前读,最简单的是select ... for update
如果需要某个数据库操作不会被其他错误回滚,那么可以开启新事务@Transactional(propagation = Propagation.REQUIRES_NEW)