黑马点评day04-分布式锁及基于JVM阻塞队列的秒杀优化

分布式锁:

概念&&特性:

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路

 

那么分布式锁他应该满足一些什么样的条件呢?

可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思

互斥:互斥是分布式锁的最基本的条件,使得程序串行执行

高可用:程序不易崩溃,时时刻刻都保证较高的可用性

高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能

安全性:安全也是程序中必不可少的一环

三种锁方式:

基于Redis的分布式锁:

为了确保在添加锁之后不会由于系统宕机从而未能设值lock过期时间,采用set原子操作

案例:

修改单机模式下一人一单问题改进版2:

新建Ilock.java接口:

package com.hmdp.utils;
/** 
* 基于Redis的分布式锁 
*/
public interface ILock {
    /**     
    * 尝试获取锁     
    *     
    * @param timeoutSec 超时时间(秒)     
    * @return true:获取成功 false:获取失败     
    */    
    boolean trylock(long timeoutSec);    
    /**     
    * 释放锁     
    */    
    void unlock();
    }

Ilock实现类SimpleRedisLock.java: 

package com.hmdp.utils;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
    private String key;    
    private RedisUtil redisUtil;    
    public SimpleRedisLock( String key,RedisUtil redisUtil) {
        this.redisUtil = redisUtil;        
        this.key = key;    
        }
    private static final String KEY_PREFIX = "lock:";    
    @Override    
    public boolean trylock(long timeoutSec) {
//        获取线程id(作为value值)        
    long id = Thread.currentThread().getId();
//        尝试获取锁        
    Boolean success = redisUtil.setnx(KEY_PREFIX + key, id, timeoutSec, TimeUnit.SECONDS);        
    return Boolean.TRUE.equals(success);    
}

    @Override    
    public void unlock() {
//    释放锁        
    redisUtil.del(KEY_PREFIX + key);    
}
}

修改VoucherOrderServiceImpl:

同样参照day03的集群部署测试方法,成功实现单用户一单。

锁误删问题:

案例:

修改SimpleRedisLock.java:

package com.hmdp.utils;import cn.hutool.core.lang.UUID;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
    private String name;    
    private RedisUtil redisUtil;    
    public SimpleRedisLock(String name, RedisUtil redisUtil) {
        this.redisUtil = redisUtil;        
        this.name = name;   
        }

    private static final String KEY_PREFIX = "lock:";    
    String ID_PREFIX = UUID.randomUUID().toString(true) + "-";    
    @Override    
    public boolean trylock(long timeoutSec) {
//        获取线程id(作为value值)        
    String id = ID_PREFIX + Thread.currentThread().getId();        
    System.out.println("线程id:" + id);
//        尝试获取锁        
    Boolean success = redisUtil.setnx(name, id, timeoutSec, TimeUnit.SECONDS);        
    return Boolean.TRUE.equals(success);    
}

    @Override    public void unlock() 
    {
//        获取线程标识        
    String id = ID_PREFIX + Thread.currentThread().getId();
//        与锁中的线程标识比较,如果相同则释放锁        
    if (id.equals(redisUtil.get(name))) {
            //    释放锁            
            redisUtil.del(name);        }
    }
}

分布式锁的原子性问题:

上述:判断锁标识和释放锁是两个动作,之间可能会发生阻塞,所以要保证这两个动作的原子性。

基于上述,若有两个线程,在线程1判断线程值与redis一致成功后,在将要删除之前阻塞了。

结果删除了线程2的锁,又发生了误删

Lua脚本解决多条命令原子性问题:

 

 

释放锁的业务流程是这样的

​   1、获取锁中的线程标示

​   2、判断是否与指定的标示(当前线程标示)一致

​   3、如果一致则释放锁(删除)

​   4、如果不一致则什么都不做

 

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示

 

 

案例:

unlock.lua:

//    lua脚本    
private static DefaultRedisScript<Long> UNLOCK_SCRIPT;    
static {
        UNLOCK_SCRIPT=new DefaultRedisScript<>();        
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));        
        UNLOCK_SCRIPT.setResultType(Long.class);    }
 
//   改进,基于lua脚本实现原子性               
public void unlock() {      
 stringRedisTemplate.execute(UNLOCK_SCRIPT,Collections.singletonList(name),ID_PREFIX + Thread.currentThread().getId());
 }

笔者总结:我们一路走来,利用添加过期时间,防止死锁问题的发生,但是有了过期时间之后,可能出现误删别人锁的问题,这个问题我们开始是利用删之前 通过拿锁,比锁,删锁这个逻辑来解决的,也就是删之前判断一下当前这把锁是否是属于自己的,但是现在还有原子性问题,也就是我们没法保证拿锁比锁删锁是一个原子性的动作,最后通过lua表达式来解决这个问题

但是目前还剩下一个问题锁不住,什么是锁不住呢,你想一想,如果当过期时间到了之后,我们可以给他续期一下,比如续个30s,就好像是网吧上网, 网费到了之后,然后说,来,网管,再给我来10块的,是不是后边的问题都不会发生了,那么续期问题怎么解决呢,可以依赖于我们接下来要学习redission啦

基于Redis分布式锁的优化:

自制分布式锁存在的问题:

基于setnx实现的分布式锁存在下面的问题:

**重入问题**:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。

**不可重试**:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。

**超时释放:**我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患

**主从一致性:** 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。

Redisson实现分布式锁:

引入依赖:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

配置Redisson客户端:

RedisConfig.java:

/**     
* 配置redisson     
*/    
@Bean    
public RedissonClient redissonClient() {
//        配置redisson        
    Config config=new Config();
//        使用单机版singleServer        
    config.useSingleServer().setAddress("redis://localhost:6379");
//        创建redissonClient对象        
    return Redisson.create(config);    
    }

修改voucherOrderServiceImpl.java的一人一单问题:

 

//        改动四:基于redis的分布式锁使用redisson
        //1. 普通的可重入锁
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        //        boolean islock = lock.tryLock(1,10, TimeUnit.SECONDS);  尝试获取锁,最多等待(重试时间)1秒,最多持有锁(超时时间TTL)10秒
        // 尝试拿锁1s后停止重试,返回false        
        // 没有Watch Dog ,30s后自动释放
        boolean islock = lock.tryLock();        
        System.out.println(islock);        
        if (!islock) {            
        return Result.fail("请勿重复下单");        
        }
        int i = 1;         
        try {            
        return voucherOrderService.createVoucherOrder(voucherId);        
    } catch (Exception e) {            
    throw new RuntimeException(e);        
    } finally {            
    lock.unlock();        
    }

测试:

使用Jmeter测试,同一用户token,50个线程同时请求;

stock减少1,http请求成功1

常见Redisson可重入锁:

private void redissonDoc() throws InterruptedException {    
//1. 普通的可重入锁    
RLock lock = redissonClient.getLock("generalLock");     
// 拿锁失败时会不停的重试    
// 具有Watch Dog 自动延期机制 默认续30s 每隔30/3=10 秒续到30s    
lock.lock(); 
    
// 尝试拿锁10s后停止重试,返回false    
// 具有Watch Dog 自动延期机制 默认续30s    
boolean res1 = lock.tryLock(10, TimeUnit.SECONDS); 
    
// 拿锁失败时会不停的重试    
// 没有Watch Dog ,10s后自动释放    
lock.lock(10, TimeUnit.SECONDS);
     
// 尝试拿锁100s后停止重试,返回false    
// 没有Watch Dog ,10s后自动释放    
boolean res2 = lock.tryLock(100, 10, TimeUnit.SECONDS);
 
//2. 公平锁 保证 Redisson 客户端线程将以其请求的顺序获得锁
    
RLock fairLock = redissonClient.getFairLock("fairLock");
     
//3. 读写锁 没错与JDK中ReentrantLock的读写锁效果一样
    
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("readWriteLock");    
readWriteLock.readLock().lock();    
readWriteLock.writeLock().lock();
}

Redisson可重入锁原理:

可重入:表示已经获取了锁,线程还是当前线程,表示发生了锁的重入。

获取锁的lua脚本:

释放锁的lua脚本:

Redisson分布式锁可重入、可重试、超时续约底层原理:

Redisson分布式锁解决主从一致性底层原理:

**主从一致性:** 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题

Redis集群可能发生主从一致性锁失效问题

为了提高redis的可用性,我们会搭建集群或者主从,现在以主从为例

此时我们去写命令,写在主机上, 主机会将数据同步给从机,但是假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就已经丢掉了。

Redisson分布式锁主从一致性问题解决:

当我们去设置了多个锁时,redission会将多个锁添加到一个集合中,然后用while循环去不停去尝试拿锁,但是会有一个总共的加锁时间,这个时间是用需要加锁的个数 * 1500ms ,假设有3个锁,那么时间就是4500ms,假设在这4500ms内,所有的锁都加锁成功, 那么此时才算是加锁成功,如果在4500ms有线程加锁失败,则会再次去进行重试.

联锁MultiLock:

只有全部节点加锁成功才可,区别于红锁,红锁是在联锁的基础上,只有过半成功加锁,才算成功。

案例:

(1)在config中配置三个redis节点,创建三个Redisson对象

(2)注入redission对象,各声明可重入锁Rlock

自动生成标识(UUID+ThreadID)Hset的field(小key):

hset REDLOCK_KEY uuid+threadId 1

(3)创建联锁

(4)业务

boolean isLock;
try {
    // isLock = lock.tryLock();
    // 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。
    isLock = lock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
    System.out.println("isLock = "+isLock);
    if (isLock) {
        //TODO if get lock success, do something;
    }
} catch (Exception e) {
} finally {
    // 无论如何, 最后都要解锁
    redLock.unlock();
}

分布式锁总结:

基于JVM阻塞队列的秒杀优化:

业务问题:

在这六步操作中,又有很多操作是要去操作数据库的,而且还是一个线程串行执行, 这样就会导致我们的程序执行的很慢,所以我们需要异步程序执行

优化:

流程:

优化方案:我们将耗时比较短的逻辑判断放入到redis中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,我们直接给用户返回成功, 再在后台开一个线程,后台线程慢慢的去执行queue里边的消息,这样程序不就超级快了吗?而且也不用担心线程池消耗殆尽的问题,因为这里我们的程序中并没有手动使用任何线程池,当然这里边有两个难点

第一个难点是我们怎么在redis中去快速校验一人一单,还有库存判断

第二个难点是由于我们校验和tomct下单是两个线程,那么我们如何知道到底哪个单他最后是否成功,或者是下单完成,为了完成这件事我们在redis操作完之后,我们会将一些信息返回给前端,同时也会把这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询我们tomcat中的下单逻辑是否完成了。

案例:

我们现在来看看整体思路:当用户下单之后,判断库存是否充足只需要导redis中去根据key找对应的value是否大于0即可,如果不充足,则直接结束,如果充足,继续在redis中判断用户是否可以下单,如果set集合中没有这条数据,说明他可以下单,如果set集合中没有这条记录,则将userId和优惠卷存入到redis中,并且返回0,整个过程需要保证是原子性的,我们可以使用lua来操作

当以上判断逻辑走完之后,我们可以判断当前redis中返回的结果是否是0 ,如果是0,则表示可以下单,则将之前说的信息存入到到queue中去,然后返回,然后再来个线程异步的下单,前端可以通过返回的订单id来判断是否下单成功。

1、修改新增优惠券代码,实现同时添加优惠券库存到Redis
//        秒杀优化
//        保存优惠券到redis,采用string存储        
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
2、编写lua脚本,实现判断秒杀库存和一人一单问题判断
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by 80954.
--- DateTime: 2023/7/1 17:53
---

-- 1.参数列表
-- 1.1优惠券idlocal voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]

-- 2.数据key
-- 2.1.库存key
local stockKey = "seckill:stock:" .. voucherId
-- 2.2.订单key
local orderKey = "seckill:order:" .. voucherId

-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if (tonumber(redis.call("get", stockKey)) <= 0) 
then    
-- 3.2.库存不足,返回1    
    return 1
end

-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if (redis.call("sismember", orderKey, userId) == 1) 
then    
-- 3.3.存在,说明是重复下单,返回2    
    return 2
end

-- 3.4.扣库存 incrby stockKey -1
redis.call("incrby", stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call("sadd", orderKey, userId)
return 0
3、载入lua脚本和改写seckillvocher.java:
private static DefaultRedisScript<Long> SECKILL_SCRIPT;    
static {
        SECKILL_SCRIPT = new DefaultRedisScript<>();        
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        //        UNLOCK_SCRIPT.setScriptText("if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end");        
        SECKILL_SCRIPT.setResultType(Long.class);    
   }
/**     
* 秒杀优化后代码     
* @param voucherId     
* @return     
*/    
@Override    
public Result seckillVoucher(Long voucherId) {
//        获取用户id        
    Long userId = UserHolder.getUser().getId();
//        1.执行lua脚本        
    Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,                
    Collections.emptyList(), voucherId.toString(), userId.toString());
//        2.判断结果是否为0        
    int r = result.intValue();        
    if (r != 0) {
//        2.1 不为0,代表没有购买资格            
            return Result.fail(r == 1 ? "库存不足" : "重复下单");        
            }
//        2.2 为0,有购买资格,把下单信息保存到阻塞队列        
long orderId = redisIdWorker.nextId("order");
//        TODO  保存到阻塞队列        
return Result.ok(0);    
}

redis已经成功获取

4、如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
* 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列

* 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
修改下单动作,现在我们去下单时,是通过lua表达式去原子执行判断逻辑,如果判断我出来不为0 ,则要么是库存不足,要么是重复下单,返回错误信息,如果是0,则把下单的逻辑保存到队列中去,然后异步执行
//新建阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);

//        2.2 r为0,有购买资格,把下单信息保存到阻塞队列        
VoucherOrder voucherOrder = new VoucherOrder();        
//2.3.订单id        
long orderId = redisIdWorker.nextId("order");        
voucherOrder.setId(orderId);        
// 2.4用户id        
voucherOrder.setUserId(UserHolder.getUser().getId());        
// 2.5优惠券id        
voucherOrder.setVoucherId(voucherId);        
// 2.6放入阻塞队列        
orderTasks.add(voucherOrder);
5、开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

新建阻塞队列和单线程池:

新建init方法和匿名多线程内部类

新建下订单函数:

修改seckillvoucher和createVoucherOrder代码

测试:

redis和数据库都成功修改

总结:

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值