mysql:悲观锁与乐观锁

为什么会使用到数据库级别的锁?

你可能会有这么一个疑问:现在的程序已经提供了很完善的锁机制来保证多线程的安全问题,还需要用到数据库级别的锁吗?我觉得还是需要的,为什么呢?理由很简单,我们再编程中使用的大部分锁都是单机,尤其是现在分布式集群的流行,这种单机的锁机制就保证不了线程安全了,这个时候,你可能又会想到使用redis的setNX分布式锁或者zookeeper的强一致性来保证线程安全,但是这里我们需要考虑到一个问题,那就是成本问题,有的时候使用redis分布式锁以及zookeeper会增加维护的成本,结合实际出发,再说没有百分百安全的程序,所以再数据库层加锁,也能将安全再提升一级,所以还是有必要的。

什么是悲观锁

悲观锁,正如其名,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。

通俗的讲:开启一个事务之后开启悲观锁,这时候数据库将会锁着你需要查询的某条数据或者某张表,其他事务中的查询将会处于阻塞状态,开启悲观锁事务里面操作不会被阻塞,这点有点类似java中的互斥锁(其中的可重入锁),那什么时候锁记录?什么时候锁整张表呢?接着往下看。

mysql悲观锁如何使用?

1.在查询后加:for update

2.需要先开启事务,否者悲观锁无效

3.执行完查询之后一定要接上update语句,否者其他事物会一直处于阻塞状态,直到第一个事务抛出异常为止。

我们看一个例子,假如用户现在有100块钱,买充电器需要100,买耳机也需要100,这时候用户同时买下这两款商品,会发生什么事情呢?

我们分别说一下正常情况和加了悲观锁的情况,这里暂时不讨论程序锁的问题,如果想了解程序中的锁,请参考:java并发编程之synchronized、java并发编程之ReentrantReadWriteLock读写锁等等。

我在数据库新建了一张表:

 表比较简单,我们只需要关注用户id和用户余额,我们等会会用到,我们现在就来模拟一下同时扣款100元,会发生什么情况,直接上代码

单元测试代码:

 @Resource
    private IUserWalletService userWalletService;

@Test
    void deductMoney() throws InterruptedException {
        //需要扣除的金额
        BigDecimal meney = BigDecimal.valueOf(100l);

        //新建第一个线程t1
        Thread t1 = new Thread(() ->{
            //线程1:让用户1扣除100元
            userWalletService.deductMoney(1, meney);
        });
        //新建第一个线程t2
        Thread t2 = new Thread(() ->{
            //线程2:让用户1扣除100元
            userWalletService.deductMoney(1, meney);
        });
        //启动线程1
        t1.start();
        //启动线程1
        t2.start();
        //让线程同步
        t1.join();
        t2.join();
        System.out.println("执行完毕");

    }

service代码:

private UserWalletMapper userWalletMapper;


    UserWalletServiceImpl(UserWalletMapper userWalletMapper){
        this.userWalletMapper = userWalletMapper;
    }

    @Override
    @Transactional
    public void deductMoney(int userId, BigDecimal money) {
        //获取线程名
        String threadName = Thread.currentThread().getName();
        //查询当前用户钱包信息
        UserWallet userWallet = userWalletMapper.getWalletByUserId(userId);
        log.info("线程:{},用户id:{},钱包余额:{}",threadName,userId,userWallet.getBalance());
        //判断当前用户是否存在
        if(null == userWallet){
            log.info("线程:{},用户id:{},不存在",threadName,userId);
            return ;
        }
        //判断用户的金额是否足够扣除
        if(userWallet.getBalance().subtract(money).compareTo(BigDecimal.ZERO) < 0){
            log.info("线程:{},用户id:{},余额不足",threadName,userId);
            return ;
        }
        //修改余额
        userWallet.setBalance(userWallet.getBalance().subtract(money));
        //扣钱,修改数据库
        userWalletMapper.update(userWallet,new UpdateWrapper<UserWallet>().lambda().eq(UserWallet::getUId,userId));
        //获取用户扣款之后的余额
        UserWallet wallet = userWalletMapper.selectOne(new QueryWrapper<UserWallet>().lambda().eq(UserWallet::getUId,userId));
        log.info("线程:{},用户id:{},扣钱之后余额:{}",threadName,userId,wallet.getBalance());
    }

mapper代码:

/**
     * 通过用户id查询用户钱包新消息
     * @param userId
     * @return
     */
    UserWallet getWalletByUserId(int userId);

与mapper对应的xml代码:

<!-- 通用查询映射结果 -->
    <resultMap id="BaseResultMap" type="com.aspect.entity.UserWallet">
        <id column="id" property="id" />
        <result column="balance" property="balance" />
        <result column="u_id" property="uId" />
        <result column="version" property="version" />
    </resultMap>

    <!-- 通用查询结果列 -->
    <sql id="Base_Column_List">
        id, balance, u_id, version
    </sql>


    <select id="getWalletByUserId" resultType="com.aspect.entity.UserWallet">
        select <include refid="Base_Column_List" /> from user_wallet  where u_id =#{userId}

    </select>

实体类:

package com.aspect.entity;

import java.math.BigDecimal;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.baomidou.mybatisplus.annotation.TableId;

import java.io.Serializable;

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;

/**
 * <p>
 *
 * </p>
 *
 * @author yaomaoyang
 * @since 2020-01-10
 */
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class UserWallet extends Model<UserWallet> {

    private static final long serialVersionUID = 1L;

    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    private BigDecimal balance;

    private Long uId;

    private Long version;


    @Override
    protected Serializable pkVal() {
        return this.id;
    }

}

userWalletMapper.update、userWalletMapper.selectOne属于mybatis-plus语法。

好了,正常的写法应该是这样的吧,那到底会不会出现问题呢?我们先再数据库给用户id为1的一个初始化数据:100元

好了,准备工作已经做完,我们运行deductMoney()

预期结果:第一次扣钱成功,第二次提示余额不足

实际结果:

结果却不是我们想要的,如果这要是出现在我们的生产环境,那是要背大锅的,那如何解决呢?肯定有人想到了:互斥锁,这个肯定能解决,那如果有两个服务呢?其实也能解决,感兴趣的可以自行研究,文章开头已经说了解决方向,还有什么方式能解决呢?我们的mysql悲观锁就应该要登场了。

使用悲观锁优化代码:

之前的代码不动,只需要修改一处代码即可,那就是查询用户钱包信息的sql语句:

<select id="getWalletByUserId" resultType="com.aspect.entity.UserWallet">
        select <include refid="Base_Column_List" /> 
        
        from user_wallet  where u_id =#{userId} 

        for update

    </select>

我们将用户的余额重新修改为100,然后再运行单元测试代码:

 发现没有,线程2与线程3居然串行化了,并且也变成了我们预期的结果,虽然悲观锁可以实现线程的安全,但是弊端也很明显,那就是效率会很慢,有时候用的不好,会导致系统崩溃。

我们再来说说悲观锁锁住的到底是什么?

一共有两种
1.锁定指定行:查询对象为主键、字段有索引
2.锁定整张表:其他

查询对象为主键这个我就不演示了,这里我来展示一下另外一种情况,需求如下:
同时扣除用户id:1与用户id:2 的账户100元。

在数据库新增一条用户id等于2的数据,初始金额100

u_id字段我们暂时还没有加索引,所以是一个普通字段,为了让你们看的清楚,我们再改造一下代码:

单元测试代码

 @Test
    void deductMoney2() throws InterruptedException {
        //需要扣除的金额
        BigDecimal meney = BigDecimal.valueOf(100l);

        //新建第一个线程t1
        Thread t1 = new Thread(() ->{
            //线程1:让用户1扣除100元
            userWalletService.deductMoney(1, meney);
        });
        //新建第一个线程t2
        Thread t2 = new Thread(() ->{
            //线程2:让用户1扣除100元
            userWalletService.deductMoney(2, meney);
        });
        //启动线程1
        t1.start();
        //启动线程1
        t2.start();
        //让线程同步
        t1.join();
        t2.join();
        System.out.println("执行完毕");

    }

与第一个单元测试的差别仅仅只是修改了一个线程的用户id,接着再service中让程序休眠1秒,这样我们可以更直观的看结果

@Override
    @Transactional
    public void deductMoney(int userId, BigDecimal money) {
        //获取线程名
        String threadName = Thread.currentThread().getName();
        //查询当前用户钱包信息
        UserWallet userWallet = userWalletMapper.getWalletByUserId(userId);
        log.info("线程:{},用户id:{},钱包信息:{}", threadName, userId, userWallet);
         try {
            //休眠一秒
            log.info("线程:{},用户id:{},休眠开始", threadName, userId);
            TimeUnit.SECONDS.sleep(1);
            log.info("线程:{},用户id:{},休眠结束", threadName, userId);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //判断当前用户是否存在
        if (null == userWallet) {
            log.info("线程:{},用户id:{},不存在", threadName, userId);
            return;
        }
        //判断用户的金额是否足够扣除
        if (userWallet.getBalance().subtract(money).compareTo(BigDecimal.ZERO) < 0) {
            log.info("线程:{},用户id:{},余额不足", threadName, userId);
            return;
        }
        //修改余额
        userWallet.setBalance(userWallet.getBalance().subtract(money));
        //扣钱,修改数据库

       
        userWalletMapper.update(userWallet,new UpdateWrapper<UserWallet>().lambda().eq(UserWallet::getUId,userId));
        //获取用户扣款之后的余额
        UserWallet wallet = userWalletMapper.selectOne(new QueryWrapper<UserWallet>().lambda().eq(UserWallet::getUId, userId));
        log.info("线程:{},用户id:{},扣钱之后余额:{}", threadName, userId, wallet.getBalance());
    }

运行单元测试

发现没有,程序先扣除了用户id等于2的金额,然后再扣除用户id等于1的金额,两个完全不相干的用户居然阻塞了?这就是我们刚刚说的普通字段悲观锁 锁定的整张表 ,我们希望的是同个用户的操作互斥,不同用户的操作并行,该如何实现呢?加入索引即可

我们再执行一次刚刚的单元测试:

此时,不同的用户扣钱操作就变成了并行,提高了一点点效率,主键也能保证并行,这里就不做演示了,你需要注意一点那就是mysql的悲观锁需要配合事务一起使用,否则无效

那在数据库层面有没有比读写锁更快的一种锁呢?答案肯定是有的,就是接下来需要说到的乐观锁。

乐观锁

乐观锁机制采取了更加宽松的加锁机制。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库 性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。相对悲观锁而言,乐观锁更倾向于开发运用。

简单来说,就是在修改时做一个版本判断,符合要求则修改,否则不修改,修改的同时改变版本号。

这种方式严格来说不算锁,java程序中也有这样类似的操作,有一个专业术语:CAS(Compare and Swap),比如:Atomic的原子类操作都是无锁,实现机制就和乐观锁很相像,比较和赋值,这里就不多说了,感兴趣的可以自行百度。

那应该如何改造之前的代码?

第一步:删除sql中的 for update 悲观锁

 <select id="getWalletByUserId" resultType="com.aspect.entity.UserWallet">
        select <include refid="Base_Column_List" />
        from user_wallet  where u_id =#{userId}
    </select>

第二步:修改更新数据库金额的语句:

 @Override
    @Transactional
    public void deductMoney(int userId, BigDecimal money) {
        //获取线程名
        String threadName = Thread.currentThread().getName();
        //查询当前用户钱包信息
        UserWallet userWallet = userWalletMapper.getWalletByUserId(userId);
        log.info("线程:{},用户id:{},钱包余额:{}", threadName, userId, userWallet.getBalance());
        //判断当前用户是否存在
        if (null == userWallet) {
            log.info("线程:{},用户id:{},不存在", threadName, userId);
            return;
        }
        //判断用户的金额是否足够扣除
        if (userWallet.getBalance().subtract(money).compareTo(BigDecimal.ZERO) < 0) {
            log.info("线程:{},用户id:{},余额不足", threadName, userId);
            return;
        }
        //修改余额
        userWallet.setBalance(userWallet.getBalance().subtract(money));
        //扣钱,修改数据库
        Integer update = userWalletMapper.updateByVersion(userWallet);
        if(update == 0){
            log.info("线程:{},用户id:{},修改金额失败", threadName, userId);
            return ;
        }
        
        //userWalletMapper.update(userWallet,new UpdateWrapper<UserWallet>().lambda().eq(UserWallet::getUId,userId));
        //获取用户扣款之后的余额
        UserWallet wallet = userWalletMapper.selectOne(new QueryWrapper<UserWallet>().lambda().eq(UserWallet::getUId, userId));
        log.info("线程:{},用户id:{},扣钱之后余额:{}", threadName, userId, wallet.getBalance());
    }

第三步:修改mapper代码:

/**
     * 扣钱
     * @param userWallet
     */
    Integer updateByVersion(UserWallet userWallet);

第四步:修改与mapper对应的xml:

<update id="updateByVersion">

        update user_wallet  balance = #{balance},version = version+1 where u_id = #{uId} and version = #{version}
    </update>

记住,修改的同时一定要改变version的值,我这里做了+1处理(最好做累加处理,防止出现ABA问题)。

第四步:单元测试还是修改同一个用户的金额,部分代码如下:

//新建第一个线程t1
        Thread t1 = new Thread(() ->{
            //线程1:让用户1扣除100元
            userWalletService.deductMoney(1, meney);
        });
        //新建第一个线程t2
        Thread t2 = new Thread(() ->{
            //线程2:让用户1扣除100元
            userWalletService.deductMoney(1, meney);
        });

运行结果:

线程2扣钱成功,金额变成了0,线程3扣除失败,符合预期要求。

总结

悲观锁强调互斥,与java的锁很类似,乐观锁强调对比,与java的原子类操作很相似,悲观锁会降低系统的可用性(阻塞超时等等),乐观锁会降低系统的强一致性(很多无效请求),我们在选择悲观锁与乐观锁的时候需要结合自己的实际项目,因为他们都不是完美的,看系统愿意舍弃哪一种。

发布了41 篇原创文章 · 获赞 79 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_33220089/article/details/103920324