redis优惠券秒杀、setnx分布式锁的实现和优化、Redisson、Redisson重入和超时以及重试锁的原理、redis优惠券秒杀优化、redis实现消息队列

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


全局唯一ID

在这里插入图片描述

id规律性太强,用户可以通过id猜到下单量
如果数据量大的时候,就得分表,自增ID只能在一个表中维护

在这里插入图片描述

在使用redis实现全局唯一时,为了考虑安全性,就不能1.2.3.4.这种增加

在这里插入图片描述

实现全局ID,redis不是唯一方案,主要考虑分布式系统中,如何做到数据共享的问题,这里采用redis也仅仅只是一个方案,未必适合于你的业务场景,因为redis是一个AP型的,数据一致性不是很强,你可以根据你的业务场景设置ID增长策略,但是不推荐,因为不便于后期维护;也可基于Zookeeper实现,Zookeeper是CP型的,一致性相对较高,当然分布式系统中,实现的方式有很多,不仅仅如此

在这里插入图片描述

@Component
public class RedisIdWorker {


    private static final long BEGIN_TIMESTAMP = 1640995200L;

    /*
      序列号的位数
     */
    private static final int  COUNT_BITS =32;
    private StringRedisTemplate stringRedisTemplate;
    public long nextId(String keyPrefix) {
        //1、生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = nowSecond - BEGIN_TIMESTAMP;

        /**
         * key值不要创建唯一的keyPrefix
         * 我们key的生成策略中,序列号:32bit才表示真正的数,只有2^32,单量大的话,很容易用完
         * 可以在key后面追加日期,变为统计某天或者某月的单量
         */
        //2、生成序列号
        //2.1、获取日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //2.2 自增长
        Long count = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + ":" + date);
        //拼接并返回
        return timeStamp << COUNT_BITS | count ;
    }


    public static void main(String[] args) {
        LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0);
        long second = time.toEpochSecond(ZoneOffset.UTC);
        System.out.println(second);
    }
}

测试

@SpringBootTest
class HmDianPingApplicationTests {

    @Resource
    RedisIdWorker redisIDWorker;

    private ExecutorService es = Executors.newFixedThreadPool(500);

    @Test
    public void ShowID() throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(300);
        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                long id = redisIDWorker.nextId("order");
                System.out.println("id = " + id);
            }
            countDownLatch.countDown();
        };
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 300; i++) {
            es.submit(task);
        }
        countDownLatch.await();
        long end = System.currentTimeMillis();
        System.out.println("time = " + (end - startTime));
    }

}

实现优惠券秒杀下单

@Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1、查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2、判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始");
        }
        //3、判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }
        //4、判断库存是否充足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
        //5、扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucher).update();
        if (!success) {
            return Result.fail("库存不足");
        }
        //6、创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1创建订单Id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //6.2用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //6.3 代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7、返回订单
        return Result.ok(orderId);
    }

超卖问题

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

 @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1、查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2、判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始");
        }
        //3、判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }
        //4、判断库存是否充足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
        //5、扣减库存(CAS,只有库存大于0时才扣减)
        boolean success = seckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucher).gt("stock",0).update();
        if (!success) {
            return Result.fail("库存不足");
        }
        //6、创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1创建订单Id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //6.2用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //6.3 代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7、返回订单
        return Result.ok(orderId);
    }

一人一单

在这里插入图片描述



@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private RedisIdWorker redisIdWorker;

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1、查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2、判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始");
        }
        //3、判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }
        //4、判断库存是否充足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
        //5、扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucher).gt("stock",0).update();
        if (!success) {
            return Result.fail("库存不足");
        }

        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()){    //要把事务锁在里面,不然事务会存在安全性问题
            IVoucherOrderService proxy =(IVoucherOrderService) AopContext.currentProxy();//同一个service中的事务方法的调用,要用代理对象呀
            return proxy.createVoucherOrder(voucherId);

        }


    }
    @Transactional
    public Result createVoucherOrder(Long voucherId) {

        //5、一人一单
        Long userId = UserHolder.getUser().getId();

        //5.1查询订单
        int count = query().eq("user_id",userId).eq("voucher_id",voucherId).count();
        if (count>0){
            return Result.fail("用户已经购买过一次");
        }

        //6、创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1创建订单Id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //6.2用户id
        voucherOrder.setUserId(userId);
        //6.3 代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7、返回订单
        return Result.ok(orderId);
    }
}

为了实现一人一旦就得在下单前,判断这个人是否已经下过单了,但是一定要加锁,我们的下单业务的原子性和并发下的安全性,把之前的下单逻辑封装成了一个事务方法,并在事务外,包了一个锁,实现了事务的并发安全

分布式锁

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

其实分布式锁主要就是怎么解决分布式系统的数据共享问题(这也是我对分布式系统一个浅显的理解,仅仅代表个人观点),mysql实现分布式锁,我个人不是很推荐,利用mysql的事务机制,势必能够实现锁的,但是会给数据库造成额外的压力,估计你们项目经理也未必同意,个人推荐在redis和zookeeper中选一个,如果你是CP场景,推荐zookeeper,如果你是AP场景,推荐redis。总之,这取决于项目的集群特点,能实现分布式锁的中间件有很多,只要能做到进程间数据共享即可。

基于Redis实现分布式锁初级版本

public interface ILock {
    /**
     * 尝试获取锁
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return  true代表获取锁成功;false代表获取锁失败
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unlock();
}

public class SimpleRedisLock implements ILock {

    private String name;

    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX="lock:";

    @Override
    public boolean tryLock(long timeoutSec) {
        //获取线程标记
        long threadId = Thread.currentThread().getId();
        //获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.MILLISECONDS);
        return Boolean.TRUE.equals(success);  //一定要注意,凡是拆箱都可能存在null,要避免空指针异常

    }

    @Override
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX+name);
    }
}
 //创建锁对象
        SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);

        boolean isLock = lock.tryLock(1200);
        if (!isLock){
            //获取锁失败
            return Result.fail("不允许重复下单");
        }

       try {
            IVoucherOrderService proxy =(IVoucherOrderService) AopContext.currentProxy();//同一个service中的事务方法的调用,要用代理对象呀
            return proxy.createVoucherOrder(voucherId);

        }finally {
           //释放锁
           lock.unlock();
       }

改进Redis的分布式锁

解决锁的误删

分析下面一个场景:

  • 线程1获取到redis锁,但是线程1由于执行某个场景阻塞了很长时间,以至于redis锁超时释放了
  • 线程2在redis锁释放后,获取到redis锁,执行自己的业务逻辑,此时redis还在阻塞
  • 如果线程2在执行过程中,线程1苏醒了,但是线程2还在执行,线程1苏醒后开始去删除锁,此时我们上面写的第一个版本就会出现线程1把线程2的锁释放了
    在这里插入图片描述
    解决办法:
    1、在获取锁时存入线程标识(可以用UUID表示)
    2、在释放锁时,先获取锁中的线程标识,判断是否与当前线程标识一样
    • 如果一致则释放锁
    • 如果不一致则不释放

在这里插入图片描述


public class SimpleRedisLock implements ILock {

    private String name;

    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX="lock:";
    
    private static final String ID_PREFIX = UUID.randomUUID().toString(true);
    @Override
    public boolean tryLock(long timeoutSec) {
        /**
         * 不要直接用线程ID区分不同线程
         * 因为线程的ID是操作系统产生的
         * 在分布式系统中,肯能不同系统中ID相同的,这样就无法在redis里区分不同的线程
         * 我们是用hutool包中的UUID作为前缀解决上述问题
         */
        //获取线程标记
        String threadId = ID_PREFIX+Thread.currentThread().getId();
        //获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId , timeoutSec, TimeUnit.MILLISECONDS);
        return Boolean.TRUE.equals(success);  
        //一定要注意,凡是拆箱都可能存在null,要避免空指针异常

    }

    @Override
    public void unlock() {
        //获取来解锁的线程的标识
        String threadId =  ID_PREFIX+ Thread.currentThread().getId();
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        if (threadId.equals(id)){
            stringRedisTemplate.delete(KEY_PREFIX+name);      
        }
      
    }
}

使用Lua脚本解决锁原子性问题

我们上面解决了锁的误删问题,通过线程标识解决锁的误删问题,但是这还是不完美的,因为判断线程标识和释放锁的操作不是原子的:

  • 线程1在获取到redis锁之后,开始执行业务逻辑,直到释放锁时,发生了阻塞(发生了GC垃圾回收),而且这个阻塞的时间足够满足redis的锁超时自动释放锁操作
  • 线程2可能比线程1先被唤醒,此时线程2就可以拿到锁
  • 在线程2执行业务逻辑的时候,线程1被唤醒了,结果它就继续执行释放锁操作,因为之前已经判断了线程标识,它就顺利的把线程2的锁释放了
  • 这时线程3就获取到了锁,此时就发生了线程2和线程3并发执行,就会产生线程安全问题
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
-- 比较线程标识与锁中的标识是否一致
if(redis.call('get',KEYS[1])==ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0

这里lua需要idea装一个插件,百度即可

 private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }
 @Override
    public void unlock() {
        stringRedisTemplate.execute(UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX+name),
                ID_PREFIX+Thread.currentThread().getId());
    }

在这里插入图片描述
至此redis分布式锁相对已经完善了,但是还有以下问题需要考虑:

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

在这里插入图片描述
在这里插入图片描述

Redisson入门

在这里插入图片描述
在这里插入图片描述

1、引入依赖:

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

2、配置Redisson客户端

@Configuration
public class RedisConfig {

    @Bean
    public RedissonClient redissonClient() {
        // 配置类
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://192.168.150.101:6379").setPassword("123321");
        // 创建客户端
        return Redisson.create(config);
    }
}
@Resource
    private RedissonClient redissonClient;
//创建锁对象
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        boolean isLock = lock.tryLock();
        
        if (!isLock){
            //获取锁失败
            return Result.fail("不允许重复下单");
        }

       try {
            IVoucherOrderService proxy =(IVoucherOrderService) AopContext.currentProxy();//同一个service中的事务方法的调用,要用代理对象呀
            return proxy.createVoucherOrder(voucherId);

        }finally {
           //释放锁
           lock.unlock();
       }

Redisson可重入锁的原理

这个和ReentrantLock原理很像

setnx无法实现锁重入

在这里插入图片描述
setnx是无法实现redis的锁重入的,原因如下;

  • 首先setnx实现分布式锁是基于redis的String类型,key值为唯一的锁名称,value是线程标识
  • 线程Thread1 在第一次获取锁的时候,此时执行 SET lock Thread1 NX EX 10,因为之前key值不存在,所以第一次加锁成功
  • 在第二次获取锁,再次执行 SET lock Thread1 NX EX 10时,因为key值lock已经存在了,所以就会加锁失败

在这里插入图片描述

我们对比ReentrantLock实现可重入锁的原理,其实JUC中可重入锁的原理,就是在再次加锁时,同步状态state+1,来实现加锁的重入,释放锁的时候,state-1即可

Redisson也是这种方式实现锁重入的,那么在原来的基础上,就得增加一个值代表同步状态,现在就成了这样,一条数据既要有【唯一的锁名】、【线程标识】、【同步状态】,所以Redisson在加锁的时候为了解决可重入的操纵,它是基于redis的Hash实现的:

  • KEY:【唯一的锁名】
  • Filed:【线程标识】
  • value:【同步状态】

每次重入要value+1
相反地,释放锁value-1

加锁核心源码以及原理分析

在这里插入图片描述
在这里插入图片描述

 private RFuture<Boolean> tryAcquireOnceAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
        if (leaseTime != -1) {//leaseTime 代表锁自动释放的时间,我们没有传的话,就会是默认值-1
            return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
        }
        //加锁tryLockInnerAsync
        RFuture<Boolean> ttlRemainingFuture = tryLockInnerAsync(waitTime,
                                                    commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
                                                    TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
        ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
            if (e != null) {
                return;
            }

            // lock acquired
            if (ttlRemaining) {
                scheduleExpirationRenewal(threadId);
            }
        });
        return ttlRemainingFuture;
    }

加锁代码:

<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        internalLockLeaseTime = unit.toMillis(leaseTime);

        return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                "if (redis.call('exists', KEYS[1]) == 0) then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +
                        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +
                        "return redis.call('pttl', KEYS[1]);",
                Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
    }

可以看出Redisson为了保证加锁操作的原子性,也是通过Lua脚本实现的,不过它是写死在代码中,Lua脚本就是加锁的具体逻辑,分析如下:

在这里插入图片描述

  • key(KEY[1])就是锁的名称、threadId(ARG(1))就是线程标识、releaseTime(ARGV[2]):锁的自动释放时间
  • 首先加锁前判断key释放已经存在,如果没有没在,说明,没有加过锁,那么就创建一个Hash类型的数据: key:锁名;field:线程标识、value:同步状态1,同时设置自动释放时间为releaseTime。加锁成功
  • 如果key已经存在,就得判断这个锁是不是当前线程加的
  • 如果field代表的线程标识是当前线程,那么就可以再次获取锁,实现【锁重入】:同步状态value的值+1,同时重置自动释放锁的时间
  • 如果field代表的线程标识不是当前线程,就加锁失败
解锁核心源码以及原理分析
 protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                        "return nil;" +
                        "end; " +
                        "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                        "if (counter > 0) then " +
                        "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                        "return 0; " +
                        "else " +
                        "redis.call('del', KEYS[1]); " +
                        "redis.call('publish', KEYS[2], ARGV[1]); " +//释放锁,发布一条消息通知
                        "return 1; " +
                        "end; " +
                        "return nil;",
                Arrays.asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
    }
    

同意Lua脚本翻译如下:
在这里插入图片描述

  • 参数不再解释,和加锁一样
  • 首先判断解锁的线程是不是自己,要同时满足两个条件:要解的锁名称和该锁的拥有者必须是当前线程的
  • 如果不是,解锁失败,直接返回
  • 如果是,同步状态value值-1,实现重入锁的释放
  • 如果减一后的value值大于0,说明当前线程持有的这个锁之前重入了多次,(此时减一后,锁还没释放)
  • 多次执行重入锁的释放后,如果同步状态value的值在-1后小于0了,说明这次释放锁时,之前重入的都释放了,那么就得删除这个锁,当前线程真正解锁成功

Redisson锁重试的原理

在这里插入图片描述
Redisson锁在tryLock()获取锁时,可以传3个参数:

  • waitTime:获取锁的最大等待时常,如果这个参数不为空,再获取锁失败的时候,线程不会立即返回,而是最大等待waitTime的时间,这期间不到的尝试获取锁
  • leaseTime:锁自动释放的时间
  • unit:时间单位

如果要实现锁重试机制,第一个参数一定要传的

// 2秒内,不断地尝试获取锁,获取到就立即返回true,如果时间到了,锁还未获取到,返回fasle
  boolean isLock = lock.tryLock(2, TimeUnit.SECONDS);

锁尝试的源码:

 @Override
    public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
        long time = unit.toMillis(waitTime);
        long current = System.currentTimeMillis();
        long threadId = Thread.currentThread().getId();
        Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId); //成功返回nil(null),失败返回ttl,锁的剩余有效期、
        // lock acquired
        if (ttl == null) {//成功返回true
            return true;
        }
        
        time -= System.currentTimeMillis() - current;//time减去获取锁的消耗时间等于剩余时间
        if (time <= 0) {
            acquireFailed(waitTime, unit, threadId);
            return false;
        }
        
        current = System.currentTimeMillis();
        RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);//订阅释放锁的信号(之前讲释放锁的Lua脚本的时候,锁释放会发布一个信号)
        if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {//等待释放锁的信号time时间
            if (!subscribeFuture.cancel(false)) {
                subscribeFuture.onComplete((res, e) -> {
                    if (e == null) {
                        unsubscribe(subscribeFuture, threadId);
                    }
                });
            }
            acquireFailed(waitTime, unit, threadId);
            return false;
        }

        try {
            time -= System.currentTimeMillis() - current;//time 减去 等待释放锁的信号消耗色时间为还应该再尝试的时间
            if (time <= 0) {
                acquireFailed(waitTime, unit, threadId);
                return false;
            }
        
            while (true) {//尝试获取锁
                long currentTime = System.currentTimeMillis();
                ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
                // lock acquired
                if (ttl == null) {
                    return true;
                }

                time -= System.currentTimeMillis() - currentTime;//每次都重新计算下次需要等大的时间
                if (time <= 0) {
                    acquireFailed(waitTime, unit, threadId);
                    return false;
                }

                // waiting for message
                currentTime = System.currentTimeMillis();
                if (ttl >= 0 && ttl < time) {
                    subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                } else {
                    subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
                }

                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(waitTime, unit, threadId);
                    return false;
                }
            }
        } finally {
            unsubscribe(subscribeFuture, threadId);
        }
//        return get(tryLockAsync(waitTime, leaseTime, unit));
    }

锁重试的源码逻辑:

  • 如果获取锁失败,重新计算还需要再等待的时间(需要等待的时间time-获取锁的等待时间),订阅锁释放的信号,
  • 如果再等待重新计算的时间内,信号订阅成功,说明锁释放了,就尝试获取锁
  • 由于可能存在竞争,获取锁未必成功,因此,每次都需要重新计算需要再次等待的时间,如果再规定时间内获取到锁就返回true
  • 获取不到就返回false;

注意锁重试的两个重要关键点:1、订阅释放锁的信号量 (pubsub机机制)2、每次读重新计算等待时间
这也是锁重试的巧妙之处

Redisson超时续约的原理——看门狗机制

这里推荐一篇原文Redisson的看门狗机制

大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。

如果我们在tryLock方法中传入leaseTime,leaseTime代表的就是锁自动释放的时间,如果没有传这个时间leaseTime,那它的默认值就是-1,其实在加锁的时候,没有传leaseTime,Redisson会使用看门狗机制(lockWatchdogTimeout = 30 * 1000),另外起一个定时任务,每过三分之一lockWatchdogTimeout 时间(10秒),就会重置超时释放时间。这样就防止了线程还在执行中,超时自动释放锁的问题,当线程真正的执行unLock方式释放锁时,便会取消看门狗
在这里插入图片描述

Watchdog触发机制:

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();
}

只要传了leaseTime,看门狗就会失效

watch dog 的自动延期机制

如果拿到分布式锁的节点宕机,且这个锁正好处于锁住的状态时,会出现锁死的状态,为了避免这种情况的发生,锁都会设置一个过期时间。这样也存在一个问题,加入一个线程拿到了锁设置了30s超时,在30s后这个线程还没有执行完毕,锁超时释放了,就会导致问题,Redisson给出了自己的答案,就是 watch dog 自动延期机制。

注意这个30秒不是 你自己传release设置的,而是你没有传release时,默认设置的超时释放时间

Redisson提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期,也就是说,如果一个拿到锁的线程一直没有完成逻辑,那么看门狗会帮助线程不断的延长锁超时时间,锁不会因为超时而被释放。

默认情况下,看门狗的续期时间是30s,也可以通过修改Config.lockWatchdogTimeout来另行指定。

另外Redisson 还提供了可以指定leaseTime参数的加锁方法来指定加锁的时间。超过这个时间后锁便自动解开了,不会延长锁的有效期。

watch dog 核心源码解读

 // 直接使用lock无参数方法
public void lock() {
    try {
        lock(-1, null, false);
    } catch (InterruptedException e) {
        throw new IllegalStateException();
    }
}

// 进入该方法 其中leaseTime = -1
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
    long threadId = Thread.currentThread().getId();
    Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
    // lock acquired
    if (ttl == null) {
        return;
    }

   //...
}

// 进入 tryAcquire(-1, leaseTime, unit, threadId)
private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    return get(tryAcquireAsync(waitTime, leaseTime, unit, threadId));
}

// 进入 tryAcquireAsync
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    if (leaseTime != -1) {
        return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    //当leaseTime = -1 时 启动 watch dog机制
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime,
                                            commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
                                            TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    //执行完lua脚本后的回调
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e != null) {
            return;
        }

        if (ttlRemaining == null) {
            // watch dog 
            scheduleExpirationRenewal(threadId);
        }
    });
    return ttlRemainingFuture;
}

scheduleExpirationRenewal 方法开启监控:

private void scheduleExpirationRenewal(long threadId) {
    ExpirationEntry entry = new ExpirationEntry();
    //将线程放入缓存中
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    //第二次获得锁后 不会进行延期操作
    if (oldEntry != null) {
        oldEntry.addThreadId(threadId);
    } else {
        entry.addThreadId(threadId);
        
        // 第一次获得锁 延期操作
        renewExpiration();
    }
}

// 进入 renewExpiration()
private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    //如果缓存不存在,那不再锁续期
    if (ee == null) {
        return;
    }
    
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return;
            }
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return;
            }
            
            //执行lua 进行续期
            RFuture<Boolean> future = renewExpirationAsync(threadId);
            future.onComplete((res, e) -> {
                if (e != null) {
                    log.error("Can't update lock " + getName() + " expiration", e);
                    return;
                }
                
                if (res) {
                    //延期成功,继续循环操作
                    renewExpiration();
                }
            });
        }
        //每隔internalLockLeaseTime/3=10秒检查一次
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    
    ee.setTimeout(task);
}

//lua脚本 执行包装好的lua脚本进行key续期
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return 0;",
            Collections.singletonList(getName()),
            internalLockLeaseTime, getLockName(threadId));
}

关键结论
上述源码读过来我们可以记住几个关键情报:

  • watch dog 在当前节点存活时每10s给分布式锁的key续期 30s;
  • watch dog 机制启动,且代码中没有释放锁操作时,watch dog 会不断的给锁续期;
  • 从可2得出,如果程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally {} 中;
  • 看到3的时候,可能会有人有疑问,如果释放锁操作本身异常了,watch dog 还会不停的续期吗?下面看一下释放锁的源码,找找答案
// 锁释放
public void unlock() {
    try {
        get(unlockAsync(Thread.currentThread().getId()));
    } catch (RedisException e) {
        if (e.getCause() instanceof IllegalMonitorStateException) {
            throw (IllegalMonitorStateException) e.getCause();
        } else {
            throw e;
        }
    }
}

// 进入 unlockAsync(Thread.currentThread().getId()) 方法 入参是当前线程的id
public RFuture<Void> unlockAsync(long threadId) {
    RPromise<Void> result = new RedissonPromise<Void>();
    //执行lua脚本 删除key
    RFuture<Boolean> future = unlockInnerAsync(threadId);
    //回调函数
    future.onComplete((opStatus, e) -> {
        // 无论执行lua脚本是否成功 执行cancelExpirationRenewal(threadId) 方法来删除EXPIRATION_RENEWAL_MAP中的缓存
        cancelExpirationRenewal(threadId);

        if (e != null) {
            result.tryFailure(e);
            return;
        }

        if (opStatus == null) {
            IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                    + id + " thread-id: " + threadId);
            result.tryFailure(cause);
            return;
        }

        result.trySuccess(null);
    });

    return result;
}

// 此方法会停止 watch dog 机制
void cancelExpirationRenewal(Long threadId) {
    ExpirationEntry task = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (task == null) {
        return;
    }
    
    if (threadId != null) {
        task.removeThreadId(threadId);
    }

    if (threadId == null || task.hasNoThreads()) {
        Timeout timeout = task.getTimeout();
        if (timeout != null) {
            timeout.cancel();
        }
        EXPIRATION_RENEWAL_MAP.remove(getEntryName());
    }
}

释放锁的操作中 有一步操作是从 EXPIRATION_RENEWAL_MAP 中获取 ExpirationEntry 对象,然后将其remove,结合watch dog中的续期前的判断:

EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
    return;
}

可以得出结论:

如果释放锁操作本身异常了,watch dog 还会不停的续期吗?不会,因为无论释放锁操作是否成功,EXPIRATION_RENEWAL_MAP中的目标 ExpirationEntry 对象已经被移除了,watch dog 通过判断后就不会继续给锁续期了。

在这里插入图片描述

Redisson分布式锁的原理

下面就是Redisson怎么实现锁重入、锁重试、锁超时的原理
在这里插入图片描述

Redisson怎么解决主从一致性问题

如果redis是单节点的话,那么这台redis服务器宕机后,势必会影响整个基于redis实现分布式锁的系统,为了解决这一问题,redis必须使用集群模式,搭建主从节点(主节点负责写,从节点负责读),主节点数据向从节点同步数据的时候,势必会存在延迟

  • 如果一个线程加了锁后,主节点保存了这个锁的数据,但是还没来及向从节点同步数据,主节点由于不可抗拒的原因,挂掉了,那么哨兵就会重新选一个节点作为主节点,此时另外一个线程也会成功获取到锁,这样就产生了线程安全问题
  • 这就是主从一致性导致的一致性问题

在这里插入图片描述
主从关系是导致一致性问题发生的原因,那干脆不要主从方式的redis集群了,所有的节点都是独立的节点,没有主从,那么获取锁的方式也就改变了:必须多有的redis节点都执行setnx成功,才算加锁成功
在这里插入图片描述

不管你是几个redis节点,获取锁时都要从各个节点获取,只有都成功了,才算获取锁成,这样不仅仅解决了主从一致性问题,要保证了可用性,如果你想要更高的可用性,也可以给多个redis的节点配置从节点:
在这里插入图片描述
如果一个主节点宕机了,分两种情况:
在这里插入图片描述

  • 如果该主节点的锁已经同步到了从节点,那么也满足了三个节点都setnx成功,也就获取锁成功了
  • 如果没有来及同步,此时有另外一个线程尝试setnx获取锁,那么即使这个新的主节点获取锁成功,那另外两个节点也是失败,最终该线程也是获取锁失败
  • 所以,只有有一个节点存活者,其他节点就不能拿到锁,这样就保证了高可用,也解决了主从一致问题
  • 这种锁叫multiLock(连锁)
  • 所谓的连锁,每一个锁其实都是之前的一个独立的锁,只不过利用的redis集群的特点,来获取锁

实现下图Redis集群的案例:

在这里插入图片描述

配置与redis节点相同的客户端数:分别连接各个redis服务器
1、集群搭建,省略了
2、配置各个redis节点的客户端

@Configuration
public class RedisConfig {

    @Bean
    public RedissonClient redissonClient() {
        // 配置类
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://192.168.150.101:6379").setPassword("123321");
        // 创建客户端
        return Redisson.create(config);
    }@Bean
    public RedissonClient redissonClient1() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.150.101:6380");
        return Redisson.create(config);
    }
    @Bean
    public RedissonClient redissonClient2() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.150.101:6381");
        return Redisson.create(config);
    }
}

3、注入客户端

   @Resource
    private RedissonClient redissonClient;
    @Resource
    private RedissonClient redissonClient1;
    @Resource
    private RedissonClient redissonClient2;

4、创建连锁并使用


//创建锁对象
RLock lock1= redissonClient.getLock("lock:order:" + userId);
RLock lock2 = redissonClient1.getLock("lock:order:" + userId);
RLock lock3 = redissonClient2.getLock("lock:order:" + userId);
        
//创建连锁
//这里用哪个client调用都可以,也可以使用
RLock lock = redissonClient.getMultiLock(lock1, lock2, lock3);

//获取锁
boolean isLock = lock.tryLock(2, TimeUnit.SECONDS);

multiLock锁的原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功

在这里插入图片描述

Redis优化秒杀

我们之前实现的秒杀,逻辑是这样子
在这里插入图片描述
思考一下,这样能做到“秒”吗?显然是不可以的,因为整个秒杀的逻辑都是同步的,而且在查询优惠券、查询订单、查库存、减库存、创建订单都需要连接数据库进行读或者写操作,而数据库的并发场景的是很差的,所以同步执行是不可取得,必须异步秒杀,传统得实现秒杀得方式是,基于mq消息队列和redis等缓存件实现的,本文我们仅仅基于redis实现一个简单的异步秒杀,仅仅提供思路

首先我们要解决的问题:

1、秒杀资格判断:
这里简单说两个方面,一个是库存的校验,另一个是一人一单的校验

以前,我们校验库存是基于分布式锁从数据库取库存,然后校验库存,一人一单的验证也是如此,先从数据库取一下,判断是不是已经下过单了,这样并发度很低。

因此,我建议把库存的校验和一人一单的校验放到redis中,而redis的并发度远大于mysql,那怎么存放库存和下单人的信息?,库存采用redis的string类型,下单人和优惠券属于多对一的关系,而且为实现一人一单,我推荐用set类型。

把库存的校验和一人一单的校验写道Lua脚本中即可,利用Lua脚本的原子性,保证线程安全

2、异步

经过秒杀资格判断后,就需要创建订单,为了提高系统的吞吐量,必须要把资格校验和创建订单的逻辑实现异步。

我们知道创建订单在生产中是比较复杂的逻辑,而且多数场景都比较慢,秒杀活动要求响应的及时性,所以在经过资格判断后,如果通过,就创建预订单保存在消息队列中(推荐mq,没有mq也可以用redis实现,后面我会说仅有redis怎么实现),直接可以反馈用户秒杀成功,后续有监控线程取消息队列的预订单数据,创建订单。。。

以上就是一个简单的秒杀系统的设计思路,逻辑流程如下图所示
在这里插入图片描述
代码逻辑流程
在这里插入图片描述

1、首先实现Lua脚本,脚本是用于校验秒杀资格的,这里涉及到库存校验和一人一旦的校验

--1 参数列表
-- 1.1优惠券Id
local voucherId = ARGC[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))) then
    -- 库存不足 返回1
    return 1
end

-- 3.2 判断用户是否下单 SISMEMBER orderKey userId
if (redis.call('sismember', orderKey, userId)) 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)
-- 返回0,表示秒杀成功
return 0   

基于阻塞队列实现秒杀(不推荐)

  private BlockingQueue<VoucherOrder> orderTask = new ArrayBlockingQueue<>(1024 * 1024);

    private static ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    @PostConstruct
    private void init() {
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }


    private class VoucherOrderHandler implements Runnable {
        @Override
        public void run() {


            try {
                //获取队列的订单信息
                VoucherOrder voucherOrder = orderTask.take();
                //创建订单
                handleVouvherOrder(voucherOrder);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        
    }

在这里插入图片描述

Redis消息队列实现异步秒杀

在这里插入图片描述在这里插入图片描述

redis基于List结构模拟消息队列

在这里插入图片描述
在这里插入图片描述

主要在于LPOP和RPOP都是取出消息后,都把redis中的消息删除了,如果消息处理异常,就没办法重试

redis的发布订阅实现消息队列

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

redis的发布订阅不支持持久化,不适合对可靠性要求高的场景

基于Stream的消息队列

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

Stream 消息是不会删除的,所以可以回溯

Stream 消费者组模式

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
生成者:

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

--1.3 订单id
local orderId =ARGV[3]
-- 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))) then
    -- 库存不足 返回1
    return 1
end

-- 3.2 判断用户是否下单 SISMEMBER orderKey userId
if (redis.call('sismember', orderKey, userId)) 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)
-- 3.6 发布消息
redis.call('xadd','stream_orders','*','userId',userId,'voucherId',voucherId,'Id',orderId)
-- 返回0,表示秒杀成功
return 0

消费者:

while(true){
	try{
		//1.获取消息
		 List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("group1", "consumer1"),
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                            StreamOffset.create("message.queue", ReadOffset.lastConsumed())
	//2.判断消息是否存在
	if(list == null || list.isEmpty()){
		continue;
	}
	//3.解析消息
	Map map = list.get(0).getValue();
	ENtity entity = BeanUtil.fillBeanWithMap(map,new Entity(),true);
	//4.处理消息
	handleMsg(entity);
	//5.对消息进行确认
	ack(queue_name,group,id)
	}catch(Exception e){
		while(true){
			try{
				//1.获取消息
				 List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
		                            Consumer.from("group1", "consumer1"),
		                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
		                            StreamOffset.create("message.queue", ReadOffset.from("0"))
				//2.判断消息是否存在
				if(list == null || list.isEmpty()){
					break;
				}
				//3.解析消息
				Map map = list.get(0).getValue();
				ENtity entity = BeanUtil.fillBeanWithMap(map,new Entity(),true);
				//4.处理消息
				handleMsg(entity);
				//5.对消息进行确认
				ack(queue_name,group,id)
				}catch(e){
					log.debug("消息处理异常");
				}
			}
		}
	}
}

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值