【数据库】mysql:悲观锁与乐观锁
为什么会使用到数据库级别的锁?你可能会有这么一个疑问:现在的程序已经提供了很完善的锁机制来保证多线程的安全问题,还需要用到数据库级别的锁吗?我觉得还是需要的,为什么呢?理由很简单,我们再编程中使用的大部分锁都是单机,尤其是现在分布式集群的流行,这种单机的锁机制就保证不了线程安全了,这个时候,你可能又会想到使用redis的setNX分布式锁或者zookeeper的强一致性来保证线程安全,但是这里..
为什么会使用到数据库级别的锁?
你可能会有这么一个疑问:现在的程序已经提供了很完善的锁机制来保证多线程的安全问题,还需要用到数据库级别的锁吗?我觉得还是需要的,为什么呢?理由很简单,我们再编程中使用的大部分锁都是单机,尤其是现在分布式集群的流行,这种单机的锁机制就保证不了线程安全了,这个时候,你可能又会想到使用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的原子类操作很相似,悲观锁会降低系统的可用性(阻塞超时等等),乐观锁会降低系统的强一致性(很多无效请求),我们在选择悲观锁与乐观锁的时候需要结合自己的实际项目,因为他们都不是完美的,看系统愿意舍弃哪一种。
更多推荐
所有评论(0)