Loading...
墨滴

舒米勒

2021/05/05  阅读:80  主题:橙心

事务篇(三):分享一个隐性事务失效场景

大家好,我是杰哥

事务篇,又回来啦!第三篇,与大家共同分享一下项目组中曾经遇到过的一个真实问题案例,来再次深层次的了解一下 Spring 事务

一、开篇

初始情况下,我们的表格中只有一条id2的数据,姓名为wangjie,年龄设置为0岁

image.png
image.png

1 不添加synchronized关键字

首先,大家先来看看这个程序有没有什么问题

    @Transactional
    public  void transactionalMethod(){
        User user = userDao.findOne(2);
        user.setAge(user.getAge()+1);
        userDao.updateUser(user);

    }

说明

transactionalMethod()方法,首先通过 findOne() 方法获取到id为2的用户记录,然后重新为该条用户记录的age加1,再通过 updateUser() 方法,更新到t_user表中 即,每次方法调用之后,实现的效果就是为id为2的用户加一岁

这是一个更新操作,想想,这个方法存在什么问题呢?

对,因为没有加锁,所以在并发的情况下,很可能出现线程不安全的情况,导致执行结果与预期不一定一致的情况

我们来测试验证一下:

@GetMapping("/transactionalMethod")
    public void transactionalMethod()  {
        final CountDownLatch latch = new CountDownLatch(1000);
          try {
              for (int i = 0; i < latch.getCount() ; i++) {
                  new Thread(() -> {
                      userService.transactionalMethod();
                      latch.countDown();
                  }).start();

              }
          }catch (Exception e){
              System.out.println(e.getMessage());
          }finally {
              latch.countDown();
          }

    }
    

说明

controller 类中,创建 transactionalMethod 方法。方法中开启 1000 个线程,来模拟 1000 个并发请求

预期:年龄被更新为1000岁

启动之后,我们访问 /transactionalMethod 接口,结果如下:

结果1

image.png
image.png

即,wangjie 用户的年龄被更新为了 93 岁,与预期时不一致的

再来看看执行日志

image.png
image.png

发现出现多条一样的数据,验证了在执行过程中的确发生了并发问题,导致多个线程同时获取到相同的值,执行得到脏数据

那么,接下来,我们为方法 transactionalMethod 加锁,即加上 synchronized 关键字:

2 添加 synchronized 关键字

    @Transactional
    public  synchronized void transactionalWithSynchronized(){
        User user = userDao.findOne(2);
        user.setAge(user.getAge()+1);
        userDao.updateUser(user);
        

说明

在原方法的基础上添加 synchronized 关键字

上层调用中,改为调用 transactionalWithSynchronized 方法

@GetMapping("/transactionalMethod")
    public void transactionalMethod()  {
        final CountDownLatch latch = new CountDownLatch(1000);
          try {
              for (int i = 0; i < latch.getCount() ; i++) {
                  new Thread(() -> {
                      userService.transactionalWithSynchronized();
                      latch.countDown();
                  }).start();

              }
          }catch (Exception e){
              System.out.println(e.getMessage());
          }finally {
              latch.countDown();
          }

    }
    

依旧是 1000 个线程的并发执行,结果如下:

结果2

image.png
image.png

咦?结果为 817,依旧不是 1000 ?这又是为何呢?

我们可以看到sql执行过程中,存在重复的 age,说明产生了并发现象

image.png
image.png

这不科学呀,明明加了 synchronized 关键字,居然还出现了并发冲突?这岂不是属于一个很奇怪的 bug ? 当时一个同事说他起初都有点怀疑人生了

那怎么破?遇到了问题,就得想办法解决呀,不符合常理的 bug ,很多人往往会通过与其他正常使用场景进行对比来寻找答案,这种方法虽然没有太高的技术含量,但个人认为是效率比较高的一种方式。先找出不同的地方在哪里,再考虑问题原因,然后入手解决就行

于是我们也是先跟其他正常使用 synchronized 的方法进行对比,发现唯一不同的一点就是:transactionalWithSynchronized 方法上有 @Transactional 注解

那我们去掉 @Transactional 注解试试

3 添加 synchronized(去掉 @Transactional 注解)

    public synchronized void transactionalWithSynchronized(){
        User user = userDao.findOne(2);
        user.setAge(user.getAge()+1);
        userDao.updateUser(user);

    }

结果3

image.png
image.png

这样,就正常了,就符合常理啦。对于当前这个场景的话呢,我们的问题就算是解决啦!

也就是说,问题现象是,同时加上 @Transactional 注解和 synchronized 关键字,并发问题依旧存在

但是,你肯定会想,这究竟是为什么呢?

二、原理解析

1 原因分析

Spring 声明式事务,其实是采用 SpringAOP 思想,在目标方法执行之前开启事务,在目标方法执行之后提交或者回滚事务

由于 SpringAOP 事务机制,添加了 @Transactional 注解的方法的事务是由 spring 生成的一个代理类来处理的,当一个线程执行完该方法并释放锁后,代理类还并没有提交事务。也就是说,线程在进入 synchronized 之前会开启事务,然后再使用 synchronized 为方法加锁

我们来分析一下,带有添加 @Transactional 注解 的 synchronized 方法的请求过程

image.png
image.png

那么,对于图中的线程A来说,它执行完代码还未提交事务时,在并发请求的情况下,很容易出现线程 B 也过来请求。那么这个时候就会出现 线程 A 和线程 B 在同一个事务中的情况,也就发生了 mysql 重复读的问题

2 解决方案

问题的原因找到了,那么,我们应该如何解决这个问题呢?

上面说到,针对当前这个问题,我们只需要去掉我们可以在 方法上添加的 @Transactional 注解即可,因为对于当前这个方法来说,它去掉事务之后,效果也是一样的

但是,当然不能取巧,我们需要考虑一种通用的解决方案,来解决一定需要添加事务,并且需要控制并发的场景

问题的原因在于,线程在提交事务之前,便释放了锁,导致其他线程与自己处在同一事务的情况

那么,我们只需要在 transactionalWithSynchronized() 方法之前,即调用该方法的方法上加上 synchronized 关键字。也就是说,在还没有开事务之间就加锁,那么就可以保证线程同步

比如,在controller 类中的 testTransactionalWithSynchronized()方法上添加 synchronized 关键字

 private synchronized  void testTransactionalWithSynchronized() {
        userService.transactionalWithSynchronized();
    }

    @GetMapping("/testTransactionalWithSynchronized")
    public  void invokeMethod()  {
        final CountDownLatch latch = new CountDownLatch(1000);
        try {
            for (int i = 0; i < latch.getCount() ; i++) {
                new Thread(() -> {
                    testTransactionalWithSynchronized();
                    latch.countDown();
                }).start();

            }
        }catch (Exception e){
            System.out.println(e.getMessage());
        }finally {
            latch.countDown();
        }
    }

然后在 invokeMethod()中并发调用 testTransactionalWithSynchronized()方法,观察结果:

结果4

image.png
image.png

正如预期的那样,年龄变成了 1000 ,并且日志中也并未出现 各个线程获取到的 age 的值相同的情况

三、总结

今天,为大家演示了 方法中同时加上 @Transactional 注解和 synchronized 关键字,并发问题依旧存在的问题,我们发现原因在于:线程在提交事务之前,便释放了锁,导致其他线程与自己处在同一事务的情况

可以通过在调用这个方法的方法 (可以理解为父级方法)上添加 synchronized 关键字,使得线程在释放锁之前,就提交事务来解决即可

之前的文章中其实也有提到过,Spring 中使用 @Transactional 注解的原理

若大家真正了解了这一点的话,这个问题应该是很快就会发现的,要是相反,那类似这样的问题肯定就会存在并且变为难题咯~

其实,道理就是这样,知识范围之外东西的就是会觉得很难;在知识范围之内的自然会觉得很容易,容易有成就感

想要让自己成就感多一点的话,那就跟着 青梅主码 一起坚持学习,拓宽自己的知识范围吧~

舒米勒

2021/05/05  阅读:80  主题:橙心

作者介绍

舒米勒

深信服-架构师