事务篇:Spring事务并发问题解决

Posted 青梅主码

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了事务篇:Spring事务并发问题解决相关的知识,希望对你有一定的参考价值。


大家好,我是杰哥

上一篇,讲到了项目中使用 Spring 开发中,带有 @Transactional 注解和 synchronized 关键字的方法,运行时依旧存在的问题

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

一 解决方案

于是乎,我们上次也顺着这个原因,直接给出了其中的最直接的一种解决方案:

在开启事务之前,便对操作加锁。即将该方法的 sychronized 关键字,移到父级方法上,具体实现如下方法一所示:

1、为父级方法加悲观锁

1)事务方法:

/**
     * 方法一:为上层方法加锁:synchronized
     */
    @Transactional
    public void transactionalMethod(){
        User user = userDao.findOne(2);
        if (user !=null){
            user.setAge(user.getAge()+1);
            userDao.updateUser(user);
        }
    }

取消掉 transactionalMethod()方法上的 synchronized 关键字,将其添加至其调用方法上

2)父级方法

/**
     * 1-直接调用加了锁的方法:testTransactionalWithSynchronized()
     */
    @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();
        }
    }
    
    /**
     * 加锁,然后调用transactionalMethod()方法
     */
    private synchronized void testTransactionalWithSynchronized() {
        userService.transactionalMethod();
    }
    

testTransactionalWithSynchronized() 为直接调用事务的方法,为该方法添加 synchronized 关键字,然后在 invokeMethod() 中构造并发请求,直接调 testTransactionalWithSynchronized() 方法即可

最终,正如我们看到的那样,成功解决并发问题

那么,除了这种直接在方法加锁的方案,还有没有其他什么方案呢?

分析一下,实际上我们只需要保证的是,每个线程获取到的数据,必须是上一个线程提交事务之后的数据。顺着调整锁位置的这个思路走下去,我们想到,实际上也可以在数据库上加锁啊,也就是说在sql语句查询中添加关键字 for update

一起来验证一下:

2、为查询语句加悲观锁

1)为查询语句加 for update

原来的 findOne() 方法

 /**
     * 根据id获取用户记录
     */
    public User findOne(Integer id) {
        //采用RowMapper的方式,获取结果对象。

        String sql = "select * from t_user where id = "+id;
        User user = jdbcTemplate.queryForObject(sql, (rs, rowNum) -> User.builder()
                .id(rs.getLong(1))
                .name(rs.getString("name"))
                .age(rs.getInt("age"))
                .build());

        return user;
    }

更新后的  findOne() 方法

 /**
     * 根据id获取用户记录
     */
    public User findOne(Integer id) {
        //采用RowMapper的方式,获取结果对象。

        String sql = "select * from t_user where id = "+id +" for update";
        User user = jdbcTemplate.queryForObject(sql, (rs, rowNum) -> User.builder()
                .id(rs.getLong(1))
                .name(rs.getString("name"))
                .age(rs.getInt("age"))
                .build());

        return user;
    }

调用说明:

 /**
     * 2-直接调用transactionalMethod()方法,解决并发问题
     */
    @GetMapping("/transactionalWithUpdate")
    public  void transactionalWithUpdate()  {
        final CountDownLatch latch = new CountDownLatch(10);
        try {
            for (int i = 0; i < latch.getCount() ; i++) {
                new Thread(() -> {
                    userService.transactionalWithUpdate();
                    latch.countDown();
                }).start();

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

    }

测试结果,年龄被更新为 1000 ,没毛病~

image.png

ok,问题再次被解决

对比一下直接在方法中加锁,这种方案的话,能够直接在底层就保证了执行是线程安全的。也就是说,在当前事务未提交之前,是不会允许其他事务来访问该条记录的。这样的话,即使以后其他的方法对这个表有类似的更新操作,便不用再在方法上考虑并发的控制问题啦~


再来分析一下,你会发现,这两种都采用的是 悲观锁

先来普及一下,悲观锁乐观锁的概念吧~

悲观锁,顾名思义,总是比较悲观,它认为数据总是有可能被更新,所以在每个线程进行更新的时候,都会先加上锁,再更新。比如说java中的 sychronized 关键字,就是最明显的悲观锁了

乐观锁,就是比较乐观了。它保持着一种侥幸心理:认为在自己更新的时候,暂时是没有其他线程来更新的,于是便大胆地直接进行更新。只是在提交的时候,需要判断一下当前的资源是否有其他线程更新,若没有,那就直接提交成功了~ 也不用加锁,也就省了耗费加锁、释放锁的步骤;若资源发生了变化,那就没辙了,只能提示更新失败了。

比如说,git、svn的提交,就是这样的思想了,先让你修改自己的代码,只是提交的时候再去判断有没有其他人更新你所修改文件的部分。若是有,就会发生冲突,需要处理冲突之后再去提交;若没有,那么就直接提交成功了

总的来说,

对于读多于写的场景,冲突几率小,就可以采用乐观锁

对于写多于读的场景,冲突几率大,就可以采用悲观锁

而真正的业务中,往往总是读多于写的,所以有时候可能更适合于使用乐观锁机制

那么,如何实现乐观锁呢?

一般情况是采用 版本号机制来实现乐观锁的,实际上这个方法应该是大家最常用以及最靠谱的方法了

为资源加一个版本号 version,在更新之前记录这个 version 值 ,提交的时候判断最新的vversion 与这个 version 的值是否相等,若相等,表示没有其他人更新;否则就说明自己在更新过程中,已经有其他人抢先更新了一版了,自己拿到的数据不是最新的,只能提示失败了

那么,我们也按照这种思想来实现一下:乐观锁方式

3、采用乐观锁

1)为 t_user 表添加version 字段

事务篇(四):Spring事务并发问题解决
image.png

表的初始状态:

事务篇(四):Spring事务并发问题解决
image.png

即:version 和 age 的值都为 0

2)每次更新字段,也要判断其 version 字段 是否发生变化,并同时更新 version 的值

    /**
     * 更新用户(t_user,判断version)
     */
    public int updateUserWithVersion(User user) {
        String sql = "update t_user set age = ?,version = version + 1  where id = ? " +" and version = ?";
        int result  = jdbcTemplate.update(sql, user.getAge(), user.getId(),user.getVersion());
        return result;
    }

3)若 version 字段发生了变化,则抛出异常;否则,提示更新成功

/**
     * 方法三:使用乐观锁(Optimistic lock)
     */
    @Transactional
    @Override
    public  void transactionalWithOptimisticLock(){
        User user = userDao.findOne(2);
        if (user !=null){
            user.setAge(user.getAge()+1);
            int result = userDao.updateUserWithVersion(user);
            if (result == 0){
                log.error("更新用户年龄时,出现异常");
                throw new RuntimeException("更新用户年龄时,出现异常");
            }
            log.info("成功更新用户:{},年龄为:{}",user.getName(),user.getAge());
        }

    }

调用 updateUserWithVersion()方法,若更新记录为 0,表示未更新成功,则抛出异常;若更新结果不为 0,则记录日志:更新成功

好了,理论上没啥问题,我们继续构造 10 个并发请求,看看结果

事务篇(四):Spring事务并发问题解决
image.png

部分日志截取如下:

事务篇(四):Spring事务并发问题解决
image.png

正如我们预期的那样,乐观锁虽然不能保证每个线程都执行成功,但是可以保证不出现混乱更新但对于更新不那么频繁的场景,使用乐观锁实际上就跟悲观锁的效果一样了~

二 总结

好了,针对这个问题,我们列出了三个方案,一起来总结一下这三个方案吧

事务篇(四):Spring事务并发问题解决
image.png

当然,从悲观锁与乐观锁的角度出发,还会有很多其他的解决方案。需要大家根据不同的业务场景去进行选型了~

嗯,就这样。每天学习一点,时间会见证你的强大~



事务篇(四):Spring事务并发问题解决

往期精彩回顾


事务篇章





Cloud篇章


Spring Boot篇章
翻译


.........
职业、生活感悟



..........


欢迎大家关注们的公众号,一起持续性学习吧~


     



以上是关于事务篇:Spring事务并发问题解决的主要内容,如果未能解决你的问题,请参考以下文章

spring如何保证并发的同时保证事务

一次bug修复,重新认识spring事务传播机制—破案篇

SOA并不能解决高并发事务

Spring Boot微服务如何集成fescar解决分布式事务?

Spring事务的隔离级别

spring源码之事务上篇