这已经是我第三次看这个项目了,第一次看这个是七八个月以前,第二次看是三个月以前,现在为了简历内容,我打算第三次再回顾一遍这个项目,不得不说这个项目对我学习redis真的是很有帮助。
这文章也不是正经的按照视频内容从上到下全部记录(这样的笔记黑马官方已经通过了MD文件了,感谢),这里知识记录我认为重要、有趣、或者有我学习的时候有困难的地方,之后再拿来复习。
在黑马课程中,一共讲了几种锁
即java本身自带的锁,synchronized、lock这一类的锁,它们适用于非集群分布式的单机情况下,在集群情况下,每个服务器都有各自的单机锁,因此并不能做到真正的线程隔离。
比如同一个用户可能在多个服务器中同时下单,这样一个人就能抢到多张秒杀券。
分布式锁即用在分布式环境下的锁,所有服务器共用一个锁,因此即使是不同的服务器也能做到线程隔离。
提供分布式锁的实现方法很多,Redis、mysql、zookeeper。
zookeeper不熟悉,因此这里不多讲。
mysql比起redis,很明显有几个缺点。1是操作mysql的效率比起redis要花更多时间。2是mysql的分布式锁是通过往数据库插入和删除数据来模拟锁的,因此插入失败后不能尝试再次获得锁(这里不确定,是看java小抄上面说的,先记着)
1.要设置过期时间,防止系统宕机后锁无法释放
2.线程的获取锁和设置锁ttl的操作必须是原子操作
3.释放锁之前要先检查当前持有者是否是自己,检查完成后再释放锁,且检查和释放锁也必须是原子操作(第三点很重要)。
黑马课程中用分布式锁也分成了几种用法
1)使用redis实现分布式锁
解决问题1和问题2,redis直接提供了set key nx ex time的方法,将获取锁和设置时间合并为一个命令,完成原子性操作
而解决问题3,则是使用了lua脚本,将检查和删除命令写成一个lua脚本命令来一起执行。
而使用以上方法会有一些问题无法解决:1.锁是不可重入的。 2.获取锁失败后无法尝试再次获得锁 3.若线程运行时间过长,超过锁的ttl的问题
2)使用redission实现分布式锁
redission对redis进行了封装,专门提供使用分布式锁的方法。并解决了上面三个问题
redission在执行tryLock()方法时,可以手动设置获取锁的时间和锁过期时间。我们来看看redission是怎么解决上面三个问题的
问题1:redission内部存储锁是以hash的形式存储的,其中filed对应线程id,value对应锁的获取次数。每次获取锁value自增1,释放锁value减少1,value为0才可以释放锁。
问题2:redission获取锁失败后,会发布订阅,订阅别的线程发布的释放锁的通知,当其他线程释放锁后会发出信号量,此时才会去尝试获得锁。(且获取锁的时间没有用完)
问题3:当我们手动设置锁过期时间后,时间一到锁自动删除,没有额外操作。若我们不设置,则redission将过期时间自动设置为30秒,且启动一个很重要的机制看门狗机制。看门狗运行时,每隔10秒会将锁的过期时间重新设置为30秒,这样一来业务若因为某些问题阻塞而延长,它也不会自动释放锁。
那看门狗情况下锁何时会释放?1.业务执行完执行unlock()方法释放锁。2.系统宕机,此时不断刷新锁ttl的线程也挂了,锁过了30秒后就被自动删除了。
1.在视频P70之前,项目处理这两个问题所用思路为:
超卖问题:
下单代码一开始时就先从mysql中读取订单数量查看是否大于0。准备往数据库插入下单信息时,使用乐观锁的思想,防止超卖
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
//…………………………………………
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
}
用户多单问题:
使用了Redission分布式锁,用户下单前先获得锁,获得锁后下单,并且在数据库中插入订单信息后才释放锁(就算过了一段时间用户再次下单,获得了锁,此时想往数据库插入信息前会先检查一下数据库,防止重复下单)。
public Result seckillVoucher(Long voucherId) {
// // 5.一人一单逻辑
// // 5.1.用户id
//以下通过redis加分布式锁
Long userId = SecurityUserUtil.getUserDto().getId();
//创建锁对象,Redission锁
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁对象
boolean isLock = lock.tryLock();
//加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
//用户下单,乐观锁防止超卖
@Transactional
@Override
public Result createVoucherOrder(Long voucherId) {
Long userId = SecurityUserUtil.getUserDto().getId();
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
………………………………
}
以上两个步骤共同写在一个 seckillVoucher方法里面,明显存在一些问题:
1.需要多次访问数据库,在方法开始执行时要访问一次数据库(查询库存是否有剩),插入订单消息时再访问一次数据库(查询用户是否下单过),效率低下(以上用到了两张表)
2.业务可分离,判断库存剩余量和用户是否下单过其实能和后面的插入数据库业务拆分开来。判断结束后立刻返回结果给前端,后续插入订单信息业务用一个消息队列发送给其他服务异步执行
2.视频P70对秒杀业务的改进:
1)将判断能否购买(库存数量和重复下单)和插入购买信息到数据库操作异步执行,判断能否购买只需要查询redis即可,查询完直接返回结果给前端,加快客户端响应速度。插入数据库操作则通过队列发给别的服务进行
2)进行购买判断时不使用分布式锁了,但使用lua脚本将数量判断和重复下单判断两个操作当做原子操作来执行。