从Spring的AOP看Synchronized锁失效和事务失效的情况

765 阅读4分钟

这个锁失效是前年了遇到的一个bug,在一个方法上加了Synchronized,为了避免同一时间产生两条相同的单据,但是很明显,它失效了,不然也不会有今天的这篇文章。

出现这个bug的是一个老项目,而且这个bug很早之前就发现了,前人应该也发现了,对数据进行了所谓的特殊处理,所以一直也就那么运行下去。老项目本着能运行就不要乱动的原则,就没怎么处理了。后面需要对这个模块进行重构,就决定把这个问题解决了。

当时这个问题看了很久,也分析了很久,没有头绪,想着这个bean是单例的应该是不存在锁不住的情况。现在想想还是太年轻了。

产生的原因

我们来回顾一下spring进行事务处理的逻辑:如果一个方法需要开启声明式事务,会进行代理,在执行的逻辑之前和之后进行增强。

比如 我们需要更新数据库的数据,数据库现在的数据是0

image.png

模拟同时进行200次操作

    @GetMapping("test_tran")
    public R<Void> testTran() {

        ThreadUtil.execute(() -> {
            for (int i = 0; i < 10; i++) {
                new Thread(() -> {
                    for (int i1 = 0; i1 < 20; i1++) {
                        testBiz.testProxy();
                    }
                }).start();
            }
        });

        return R.ok();
    }
    
    // ========== testBiz ========== 
    // testBiz方法 然后操作的方法加了锁
    @Override
    @Transactional(rollbackFor = Exception.class)
    public synchronized int testProxy() {

        System.out.println(this.hashCode());
        // 之前的业务大致流程是 查询数据是否存在
        int num = sysConfigMapper.getNum();
        // 如果数据存在就直接返回 不存在就新增
        // 不太好模拟 这边改成获取数据 在+1好模拟一点
        sysConfigMapper.updateNum(num + 1);
        // 模拟业务耗时
        ThreadUtil.safeSleep(20);

       return num;
    }
    
    // ========== mapper 方法 ========== 
    @Select("select incr from test_sync where id = 1")
    int getNum();

    @Update("update test_sync set incr = #{i} where id = 1")
    void updateNum(int i);

如果正常的话,一次执行完数据库的数据应该是200

image.png

image.png

image.png

结果很多种情况。

出现这种情况有两种可能,第一是锁失效了,第二就是数据库查询的时候有问题,存在多次查询返回同一条数据的情况。之前项目情况就是查不到插入的数据,再次插入了,所以出现了重复数据。

首先是第一种情况,没有锁住,我们可以通过非线程安全的操作去验证一下。


    @GetMapping("test_tran")
    public R<Void> testTran() {
        // 上面有更新数据库日志 知道啥时候停了 就没加 CountDownLatch
        CountDownLatch countDownLatch = new CountDownLatch(200);
        ThreadUtil.execute(() -> {
            for (int i = 0; i < 10; i++) {
                new Thread(() -> {
                    for (int i1 = 0; i1 < 20; i1++) {
                        try {
                            testBiz.testProxy();
                        } finally {
                            countDownLatch.countDown();
                        }
                    }
                }).start();
            }
        });

        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return R.ok();
    }
    
    // 获取当前testBiz实现类中i的值
    @GetMapping("getI")
    public R<Integer> getI() {

        int i = testBiz.getI();
        System.out.println("i:" + i);
        return R.ok(i);
    }

    // ========== testBiz ========== 
    private int i = 0;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public synchronized int testProxy() {

//        System.out.println(this.hashCode());
//        // 之前的业务大致流程是 查询数据是否存在
//        int num = sysConfigMapper.getNum();
//        // 如果数据存在就直接返回 不存在就新增
//        // 不太好模拟 这边改成获取数据 在+1好模拟一点
//        sysConfigMapper.updateNum(num + 1);
//        // 模拟业务耗时
//        ThreadUtil.safeSleep(20);
        ++i;
        ThreadUtil.safeSleep(30);

       return 0;
    }
    
    @Override
    public int getI() {

        return i;
    }
    

如果没有锁住,那么每次调用test_tran应该是很大可能出现非200情况,但是结果是,锁住了

image.png

由于 synchronized 是在对象的方法内,锁的是当前对象,打断点也可以看到每次进入的都是同一个对象,所以肯定是锁住的。

image.png

image.png

这个大家都不陌生吧,我们常用的这个功能来实现一些方法的增强,就好比我们刚才的执行的逻辑都是在point.proceed()里面,前后的增强并不在锁的范围内,所以肯定是锁定不住的。这也是为什么我们后面有尝试使用Lock锁也失效的原因。

    @Around("pointCut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {

        try {
            // 执行方法前做点什么
            return point.proceed();
        } catch (Throwable t) {
            // 遇到异常做点什么
        } finally {
            // 执行结束做点什么
        }
    }

解决方法后面也很简单,在调用事务方法之前上锁,再去调用该方法即可。


    @GetMapping("test_tran")
    public R<Void> testTran() {
        // 上面有更新数据库日志 知道停 就没加 CountDownLatch
        CountDownLatch countDownLatch = new CountDownLatch(200);
        ThreadUtil.execute(() -> {
            for (int i = 0; i < 10; i++) {
                new Thread(() -> {
                    for (int i1 = 0; i1 < 20; i1++) {
                        try {
                            // 我们把调用开事务方法改成调用其他同步方法 其他同步方法再去调用原先的 testProxy()
                            // testBiz.testProxy();
                            testBiz.test();
                        } finally {
                            countDownLatch.countDown();
                        }
                    }
                }).start();
            }
        });

        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return R.ok();
    }
    
    // ========== testBiz ========== 
    // testBiz 添加一个test同步方法 通过test去调用 testProxy
    @Override
    public synchronized int test() {

        TestBiz o = (TestBiz)AopContext.currentProxy();
        return o.testProxy();
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public int testProxy() {

        System.out.println(this.hashCode());
        // 之前的业务大致流程是 查询数据是否存在
        int num = sysConfigMapper.getNum();
        // 如果数据存在就直接返回 不存在就新增
        // 不太好模拟 这边改成获取数据 在+1好模拟一点
        sysConfigMapper.updateNum(num + 1);
        // 模拟业务耗时
        ThreadUtil.safeSleep(20);

        return 0;
    }

第一次调用test_tran

image.png

第二次调用test_tran

image.png

第三次调用test_tran

image.png

可以看到锁住了,数据也正常了,到这里其实就可以结束了,已经很好的解决了之前的问题。但是大家发现了吗,test中使用了TestBiz o = (TestBiz)AopContext.currentProxy(); ,调用使用的是o.testProxy()而不是this.testProxy()。那么为什么需要多这一步呢?使用this去调用有什么问题?

我们再测试一些使用this去调用testProxy(),稍微改造一下


    @Override
    public synchronized int test() {

        // TestBiz o = (TestBiz)AopContext.currentProxy();
        return this.testProxy();
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public int testProxy() {

        System.out.println(this.hashCode());
        // 之前的业务大致流程是 查询数据是否存在
        int num = sysConfigMapper.getNum();
        // 如果数据存在就直接返回 不存在就新增
        // 不太好模拟 这边改成获取数据 在+1好模拟一点
        sysConfigMapper.updateNum(num + 1);
        // 模拟业务耗时
        ThreadUtil.safeSleep(20);
        if (true) {
            throw new RuntimeException("test");
        }

       return 0;
    }

现在我们数据库的数据是600

image.png

方法调用后应该还是600,因为全部数据都抛出了异常,但是结果却是610

image.png

我们回顾一下controller的方法

        ThreadUtil.execute(() -> {
            for (int i = 0; i < 10; i++) {
                new Thread(() -> {
                    for (int i1 = 0; i1 < 20; i1++) {
                        try {
                            // 我们把调用开事务方法改成调用其他同步方法 其他同步方法再去调用原先的 testProxy()
                            // testBiz.testProxy();
                            testBiz.test();
                        } finally {
                            countDownLatch.countDown();
                        }
                    }
                }).start();
            }
        });

开了10个线程,每个线程循环20次,正常调用一次数据库增加200没错,这边改成抛出异常,只执行10次,数据库也更新了10次,但是我们明明使用了@Transactional(rollbackFor = Exception.class)为什么数据还是更新了,没有回滚,spring事务管理出问题了吗???

我们再把test()的调用改成AopContext.currentProxy()获取的对象去调用。

image.png

调用一次后数据没有变成620,还是610,说明事务生效了。

这也会引出另一个问题,事务失效,spring的事务是通过AOP去代理的,通过this去调用是不会走代理的,这个时候即使我们testProxy使用了@Transactional(rollbackFor = Exception.class, propagation = Propagation.NEVER)也是不会报错的,因为不会进行增强逻辑,我们可以通过打断点来验证一下。可以看到this13179

image.png

o13181 并且可以看到经过了cglib进行代理,也就是进行了代理增强,因此事务正常生效

image.png

而不使用代理是真实对象,是不具备事务相关功能的。除了通过AopContext.currentProxy()我们也可以在当前类(TestBizImpl)再注入一遍,通过注入对象去调用,


    @Autowired
    private TestBiz testBiz;
    @Override
    public synchronized int test() {

        return testBiz.testProxy();
    }

image.png

同样的效果,事务生效了,至此,我们可以愉快的在一个类里根据不同的传播行为进行逻辑的处理了。