背景

已经写了两节的redis的高性能数据结构了点击查看,今天换个口味,今天我们看一下redis在分布式系统中的应用,使用redis做分布式锁,这可以说是老生常谈的问题了。

redis分布式锁

分布式锁解决的问题

说到锁,第一反应就是线程阻塞,在这里需要注意的是这里的维度会上升一个层次,不单单是一个服务(进程)的线程之间,是多个服务之间的并发安全问题,也可以这么说吧多个进程(这两个进程之间是分别在两个服务上的)之间的并发问题。所以说这里使用线程之间的锁是不能解决问题的如(reentrantLock,Sychronized,CyclicBarrier,CountDownlotch,Semaphore,volatile)这些JUC包和JDK提供的锁机制。他们只能处理同一个进程不同线程之间的并发问题。所以为了解决不同进程,不同server时间的并发安全问题就创造出了redis分布式锁。这里说这么多主要还是区分一下分布式锁和线程锁(这样称呼不知道是否合适,或者说是对象锁)的区别。

根据图理解一下。两个client操作数据库同一条数据进行修改。

redisson blockingqueue take阻塞 redis阻塞锁_可重入

实现

  1. 分布式锁的本质就是,不同服务间或同一个服务间的线程在redis里面争抢坑位,当一个线程占用了这个坑位,门一锁,那么其他线程就得放弃或者重试等待了。当一个线程占坑结束使用del指令释放坑位,也就是厕所的门是打开状态。实际使用方法是,给这个坑位设置一个name,这个那么有值说明就被占用
    占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑。先来先占, 用
    完了,再调用 del 指令释放茅坑。
// 这里的冒号:就是一个普通的字符,没特别含义,它可以是任意其它字符,不要误解
> setnx lock:codehole true
OK
... do something critical ...
> del lock:codehole
(integer) 1
  1. 但是上面的方案是有问题的,就是当这个线程执行异常了,导致实行del释放坑位命令没有生效那就有问题了。但是有人会说我在java中使用finally块里进行释放坑位。这确实也是一种办法,但是如果这个使用的服务直接挂掉了怎么办呢?
//伪代码
try{
            
        }catch (Exception e){
            e.printStackTrace();
        }finally {
        //就算异常了也会执行,删除操作
            delKey;
        }
    }
  1. 那就是给这个锁(坑位)来个时间限定,你到时间就得自动进行释放。那么实现方式是先获取锁,然后再设置定时时间:
> setnx lock:codehole true
OK
> expire lock:codehole 5
... do something critical ...
> del lock:codehole
(integer) 1
  1. 由于上面所说的这个方式不是操作过程不是原子性(要全部执行,要么全部不执行。不会出现执行一半)的所以也会出现拿到锁后。然后出现这只锁超时时间异常或者服务挂掉的问题。这样也就回到了最初的问题了。
  2. 那我们想想这样有什么好的办法处理呢? 说到原子性,这不就是事物的四大特性之一吗。那我们就使用redis的事物来处理这个问题呗。将指令 setnx 和 expire放在同一个事物里面进行执行。 就在redis 2.8的时候处理了这个问题,那就是这两个命令是可以一块执行。这也是我们使用分布式锁的奥义所在。
> set lock:codehole true ex 5 nx OK ... do something critical ... 
> del lock:codehole
  1. 那由于线程执行时间过长锁超时了怎么办呢?

分布式锁过期

  1. Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至
    于超出了锁的超时限制,就会出现问题。因为这时候锁过期了,第二个线程重新持有了这把锁,
    但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了,第三个线程就会在第二个线程逻
    辑执行完之间拿到了锁。
  2. 首先这种问题我们得规避,产生的原因就是业务逻辑执行时间太长,那么就要在使用的时候尽量用于时间短的,尽量避业务存在夸服务调用,还有就是设置尽量合理的过期时间尽量大与业务执行时间。
  3. 还有一个更加安全的方案就是给每一个锁都放一个值,在进行del的时候我们可以先对比一下是不是之前放的value。这带点乐观锁的意思了。compare and swap 对比后替换。但是匹配和key删除不是一个原子性操作,操心。还好redis提供一个脚本执行方式,且脚本是具有原子性的,使用lua脚本达到原子性的目标。(在使用阿里云的redis sharding模式得注意一下。他们sharding模式对lua脚本的支持有问题。我踩的坑。使用redission的RmapCache时其源码有lua脚本导致最后运行失败
# delifequals
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

可重入性

  1. 可重入性指的是在持有锁的情况下再次请求加锁,仍然可以 再次请求加锁,那么这个锁就是可重入的。例如java中的reentrantLock 和 sychronized都是可重入锁。
  2. 他们实现的大概原理是通过乐观锁去修改他们的锁状态,如果是同一个线程id那就会更改成功,可以获取到锁,如果不是同一个线程ID那就回锁升级。那我们使用redis如何实现呢?同一个道理呗。我们可以把这个线程ID 或者是这个任务ID,可以是线程维度,也可以是任务维度。进行可重入。

中间件的使用

  1. jedis(在使用的时,在连接池回收有问题,应该是我自己的代码写的有问题,最后没有解决)
  2. redission (更换了redssion后回收连接池的问题解决了,但是将原来的redis单机版升sharding模式时出现了lua脚本的问题)

上面是两个redis的中间件用起来很舒服,括号里面是我踩的坑。

真实案例分享

  • 需求
    微服务间调用,对外暴露接口,为防止接口短时间内被同一个任务ID重复调用(保证前一个任务执行完成)。防止同时操作一条数据。
  • 解决方案:使用redis分布式锁,使用taskId作为锁。设置过期时间为2s,等待时间为2秒。
  • 2s的意思是,业务执行最长时间为2s。执行不完就释放。
  • 3s的意思是锁超时我等待的这个任务一定能执行。
    使用的是:redssion封装好的:
@Override
    public Response discernCommon(DiscernCallBackResultTo discernCallBackResultTo) {
        //防止并发,通过同一个TSKID做锁 重复新增发票,锁过期时间3秒,等待时间为2秒
        boolean lock = RedissonLockUtils.tryLock(discernCallBackResultTo.getTaskId(), 2, 3);
        if (lock) {
            try {
                dealDiscernResult(discernCallBackResultTo);
            } catch (Exception e) {
                LOGGER.error(e.getMessage(), Coder.of(ErrorCode.DISCERN_DEAL_EXCEPTION), e);
            } finally {
             RedissonLockUtils.unlock(discernCallBackResultTo.getTaskId());
            }
        } else {
            EventMessage confilct = new EventMessage(EventEnum.EVENT_CONFILC_TASKID.getEventId())
                    .addExtValue("taskId", discernCallBackResultTo.getTaskId());
            Tracker.getInstance().record(eventMessage);
        }
        return Response.ok("回调成功!");
    }

总结

  • redis分布式锁的实现和演进
  • redis锁过期导致锁释放错误,通过加任务好的方式,先对比在删除
  • redis分布式锁的可重入锁的实现,通过乐观锁的方式,对其线程iD 或者任务ID进行可重入设计。

参考