基于Redis的setnx的分布式锁的实现(结合具体案例)另加Redission的介绍

本文关于分布式锁的案例全部来自《黑马点评》项目,实现的具体功能是在分布式系统下如何实现一人一单的功能,在实现一人一单功能时,首先在该用户下单时会拿着用户ID去数据库查询是否已经有过下单数据,如果有则返回错误,如果没有则执行接下来的下单业务,也许到这儿看起来一切都正常呀!又何必去加分布式锁呢?其实如果在特殊情况下,比如,同一个用户通过某种手段使用多个线程同时下单,多个线程同时去判断该用户有无下单,显然返回的结果都是没有下单,那么这几个线程就都会去执行接下来的下单业务,那么这也就违背了“一人一单”,在之前的单机系统中,我们可以通过jdk自带的锁机制来阻止,但是现在是分布式系统,不止有一个服务器,那么jdk自带的锁就会出现问题,所以也就有了分布式锁的应用,本博文主要来实现基于Redis的setnx的分布式锁。

 

分布式锁概念:

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

基于Redis的分布式锁的实现思路:

  • 利用set nx ex获取锁,并设置过期时间。保存线程标示。
  • 释放锁时先判断线程标示是否与自己一致,一致则删除锁。

特性:

  • 利用set nx满足互斥性。
  • 利用set ex保障发生故障时,锁依然能释放,避免死锁,提高安全性。
  • 利用Redis集群保证高可用和高并发特性。

注意:本案例实现的功能是一人一单的功能,具体场景就是:在同一时刻一个用户通过多线程的方式去购买同一件商品;

逐步去实现基于setnx的分布式锁:

简单的分布式锁实现流程图:

 在执行下单功能时,首先每一个线程都要获取该分布式锁,因为分布式锁满足互斥性,也就是不同的用户就可以拿到不同的锁,但是同一用户即使使用多线程来访问,其也只有一个线程能获取到锁,一个线程得到锁,去执行下单流程,执行完以后锁释放,这时该用户的其他线程即使得到锁,再去执行下单业务时,因为已经有一个线程完整的执行过了,也就是数据库已经有了下单数据,所以此时查询数据库便会查询到该用户的下单数据,也就会直接返回错误。

但是此时该分布式锁可能还存在如下情况:

 也就是,当线程1去执行业务时,发生了业务阻塞,超过给锁设置的时效,其便会自动释放,此时线程2获取锁成功,在他去执行业务的过程中,线程1的业务完成,那么直接就释放了锁,而其实这把锁并不是线程1的,所以也就出现了误删锁的行为。所以在线程进行删除锁时就应该去判断该锁是否是自己的锁。

 这就需要我们在获取锁时,进行特殊的处理,在进行存储时就需要对把对应的value数据存储为可以辨别该线程的属性(比如线程ID),就拿存入线程ID来讲,单单存入一个线程ID够吗?不够!因为这是分布式系统,有多个服务器,每一个服务器产生的线程ID都是各自递增产生的,那么这就不能作为唯一的标识,所以我们考虑在其前又添加了UUID生成的字符串。

接下来即使在多线程的情况下也就不会发生上述情况了,详细分析图:

 上述分布式锁的实现代码:

public interface ILock {
  /*获取锁
  * timeoutSec代表超时自动释放锁
  * */
    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:";
    private static final String ID_PREFIX= UUID.randomUUID().toString(true)+"-";
    @Override
    public boolean tryLock(long timeoutSec) {
        //获取线程标示
        String threadId = ID_PREFIX+Thread.currentThread().getId();
        //获取锁
        Boolean success=stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX+name,threadId,timeoutSec, TimeUnit.SECONDS);
        //这里涉及到了一个自动装箱拆箱的问题,有自动拆箱就可能会有安全风险,万一success是一个null,
        //拆箱就会空指针异常,所以就采用了如下的方式
        return Boolean.TRUE.equals(success);
    }

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

一人一单实现下单业务代码:

    //实现下单业务
    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        //一人一单
        Long userId=UserHolder.getUser().getId();
        //查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if(count>0){
            //用户已经购买过了
            return Result.fail("请勿重复购买");
        }
        boolean result = seckillVoucherService.update().setSql("stock=stock-1")
                .eq("voucher_id", voucherId).gt("stock",0).update();
        if (!result){
            return Result.fail("库存不足");
        }
        //创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //生成订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        Long id = UserHolder.getUser().getId();
        voucherOrder.setUserId(id);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        return Result.ok(orderId);
    }

下单业务代码(controller层直接调用的代码): 

    @Override
    public Result seckillVoucher(Long voucherId) {
        //查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
            //尚未开始
            return Result.fail("秒杀尚未开始");
        }
        //判断是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            //已经结束
            return Result.fail("秒杀已经结束");
        }
        //判断是否充足
        if (voucher.getStock()<1){
            return Result.fail("库存不足");
        }
        Long id = UserHolder.getUser().getId();
    /*    synchronized (id.toString().intern()){
            //获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }*/
        //创建锁对象
        SimpleRedisLock lock = new SimpleRedisLock("order" + id, stringRedisTemplate);
        boolean isLock = lock.tryLock(5000);
        if(!isLock){
            //获取锁失败 返回错误或重试
            return Result.fail("请勿重复下单");
        }
        try {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            lock.unlock();
        }


    }

上述实现分布式锁的代码还是存在一定的问题,具体为当多个线程并发执行时,只有一个线程得到锁,执行完业务后,开始获取锁标识,并且判断是否一致,返回一致,但就是在这时,正要释放锁时又发生了阻塞,并且已经超过了锁设置的有效期,那么该锁会自动释放,其他线程也就又可以得到该锁了,其他线程又去执行业务,但是此时阻塞的线程又恢复了,由于已经做了判断,那他就又会直接释放锁,也就是又发生了误删锁的情况,具体情况如下:

 如何解决该问题呢?也就是要确保判断是否一致和释放锁要么都执行,要么别执行,这也就是redis中实现原子性,这需要使用到Lua脚本来实现。

lua脚本代码:

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('del', KEYS[1])
end
-- 不一致,则直接返回
return 0

释放锁代码:

    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() {
        //调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX+name),
                ID_PREFIX+Thread.currentThread().getId());
    }

至此,分布式锁已经基本完善,这里我很纠结一个问题,本来应用分布式锁就是实现一人一单的功能的,但是现在假设一种场景:多个线程同时请求下单,有一个线程得到锁,执行下单业务,该线程在判断完用户是否下单后发生了阻塞,锁达到设置的时间,自动释放,此时如果有其他线程也就会得到锁,得到锁以后也去执行下单业务,执行完判断该用户是否已经下单的代码后,上一个线程不再阻塞,那么接下来这两个线程就都会去执行对应的下单业务了,显然通过上述的实现,即使第一个线程执行的快也不会释放第二个线程的锁了,可这也就已经发生了一人下两单的情况了呀?

我通过debug代码,按照上述步骤发现确实会在数据库出现一人下两单的情形,那么如何去解决呢?我初步想到的实现方案是:将释放锁的代码加到下单业务里,如果释放锁时没有成功,则抛出异常,因为添加了@Transactional注解,所以事务就会回滚,从而避免了上述情形。

上述实现的锁存在的问题:

  1. 不可重入:同一个线程无法多次获取同一把锁;
  2. 不可重试:获取锁只尝试一次就返回false,没有重试机制;
  3. 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患;
  4. 主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主机宕机时,如果从并没有同步主机中的锁数据,则会出现问题;

基于上述问题,又引出了Redission:

Redission:Redission是一个在Redis基础上实现的java驻内存数据网格,它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现;
另附:

Redission可重入锁的原理:

Redission解决重试和超时释放的原理:

Redission解决主从一致性的方案是利用multiLock锁;

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值