微服务-分布式锁(二)-Redis方案

1 LUA+SETNX+EXPIRE

先用setnx来抢锁,如果抢到之后,再用expire给锁设置一个过期时间,防止锁忘记了释放。

  • setnx(key, value)

    setnx 的含义就是 SET if Not Exists,该方法是原子的。如果 key 不存在,则设置当前 keyvalue 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0

  • expire(key, seconds)

    expire 设置过期时间,要注意的是 setnx 命令不能设置 key 的超时时间,只能通过 expire() 来对 key 设置。

1.1 使用Lua脚本(SETNX+EXPIRE)

可以使用Lua脚本来保证原子性(包含setnx和expire两条指令),加解锁代码如下:

/**
 * 使用Lua脚本,脚本中使用setnex+expire命令进行加锁操作
 */
public boolean lock(Jedis jedis, String key, String uniqueId, int seconds) {
    String luaScript = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
            		    "redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
    Object result = jedis.eval(luaScript, Collections.singletonList(key),
            Arrays.asList(uniqueId, String.valueOf(seconds)));
    return result.equals(1L);
}

/**
 * 使用Lua脚本进行解锁操纵,解锁的时候验证value值
 */
public boolean unlock(Jedis jedis, String key, String value) {
    String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then " +
           			    "return redis.call('del',KEYS[1]) else return 0 end";
    return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(value)).equals(1L);
}

1.2 STW

如果在写文件过程中,发生了 FullGC,并且其时间跨度较长, 超过了锁超时的时间, 那么分布式就自动释放了。在此过程中,client2 抢到锁,写了文件。client1 的FullGC完成后,也继续写文件,注意,此时 client1 的并没有占用锁,此时写入会导致文件数据错乱,发生线程安全问题。这就是STW导致的锁过期问题。STW导致的锁过期问题,如下图所示:

STW导致的锁过期问题,大概的解决方案有:

  • 方案一: 模拟CAS乐观锁的方式,增加版本号(如下图中的token)

​ 此方案如果要实现,需要调整业务逻辑,与之配合,所以会入侵代码。

  • 方案二:watch dog自动延期机制

    客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?简单!只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。Redission采用的就是这种方案, 此方案不会入侵业务代码。

2 SET-NX-EX

方案SET key value [EX seconds] [PX milliseconds] [NX|XX]

  • EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value
  • PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value
  • NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value
  • XX :只在键已经存在时,才对键进行设置操作

客户端执行以上的命令:

  • 如果服务器返回 OK ,那么这个客户端获得锁
  • 如果服务器返回 NIL ,那么客户端获取锁失败,可以在稍后再重试

① 加锁:使用redis命令 set key value NX EX max-lock-time 实现加锁

Jedis jedis = new Jedis("127.0.0.1", 6379);
private static final String SUCCESS = "OK";

 /**
  * 加锁操作
  * @param key 锁标识
  * @param value 客户端标识
  * @param timeOut 过期时间
  */
 public Boolean lock(String key,String value,Long timeOut){
     String var1 = jedis.set(key,value,"NX","EX",timeOut);
     if(LOCK_SUCCESS.equals(var1)){
         return true;
     }
     return false;
 }
  • 加锁操作 jedis.set(key,value,"NX","EX",timeOut)【保证加锁的原子操作】
  • keyrediskey值作为锁的标识,value在作为客户端的标识,只有key-value都比配才有删除锁的权利【保证安全性】
  • 通过timeout设置过期时间保证不会出现死锁【避免死锁】
  • NX:只有这个key不存才的时候才会进行操作,if not exists
  • EX:设置key的过期时间为秒,具体时间由第5个参数决定,过期时间设置的合理有效期需要根据业务具体决定,总的原则是任务执行time*3

② 解锁:使用redis命令 EVAL 实现解锁

Jedis jedis = new Jedis("127.0.0.1", 6379);
private static final String SUCCESS = "OK";

 /**
  * 加锁操作
  * @param key 锁标识
  * @param value 客户端标识
  * @param timeOut 过期时间
  */
 public Boolean lock(String key,String value,Long timeOut){
     String var1 = jedis.set(key,value,"NX","EX",timeOut);
     if(LOCK_SUCCESS.equals(var1)){
         return true;
     }
     return false;
 }
  • luaScript 这个字符串是个lua脚本,代表的意思是如果根据key拿到的value跟传入的value相同就执行del,否则就返回0【保证安全性】
  • jedis.eval(String,list,list);这个命令就是去执行lua脚本,KEYS的集合就是第二个参数,ARGV的集合就是第三参数【保证解锁的原子操作】

③ 重试

如果在业务中去拿锁如果没有拿到是应该阻塞着一直等待还是直接返回,这个问题其实可以写一个重试机制,根据重试次数和重试时间做一个循环去拿锁,当然这个重试的次数和时间设多少合适,是需要根据自身业务去衡量的。

/**
 * 重试机制
 * @param key 锁标识
 * @param value 客户端标识
 * @param timeOut 过期时间
 * @param retry 重试次数
 * @param sleepTime 重试间隔时间
 * @return
 */
public Boolean lockRetry(String key,String value,Long timeOut,Integer retry,Long sleepTime){
    Boolean flag = false;
    try {
        for (int i=0;i<retry;i++){
            flag = lock(key,value,timeOut); 
            if(flag){
                break; 
            } 
            Thread.sleep(sleepTime); 
        } 
    }catch (Exception e){ 
        e.printStackTrace(); 
    } 
    return flag; 
}

3 Redisson

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock、联锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)等,还提供了许多分布式服务。

3.1 特性功能

  • 支持 Redis 单节点(single)模式、哨兵(sentinel)模式、主从(Master/Slave)模式以及集群(Redis Cluster)模式
  • 程序接口调用方式采用异步执行和异步流执行两种方式
  • 数据序列化,Redisson 的对象编码类是用于将对象进行序列化和反序列化,以实现对该对象在 Redis 里的读取和存储
  • 单个集合数据分片,在集群模式下,Redisson 为单个 Redis 集合类型提供了自动分片的功能
  • 提供多种分布式对象,如:Object Bucket,Bitset,AtomicLong,Bloom Filter 和 HyperLogLog 等
  • 提供丰富的分布式集合,如:Map,Multimap,Set,SortedSet,List,Deque,Queue 等
  • 分布式锁和同步器的实现,可重入锁(Reentrant Lock),公平锁(Fair Lock),联锁(MultiLock),红锁(Red Lock),信号量(Semaphonre),可过期性信号锁(PermitExpirableSemaphore)等
  • 提供先进的分布式服务,如分布式远程服务(Remote Service),分布式实时对象(Live Object)服务,分布式执行服务(Executor Service),分布式调度任务服务(Schedule Service)和分布式映射归纳服务(MapReduce)

3.2 Watch dog

总体的Redisson框架的分布式锁类型大致如下:

  • 可重入锁
  • 公平锁
  • 联锁
  • 红锁
  • 读写锁
  • 信号量
  • 可过期信号量
  • 闭锁(/倒数闩)

 

3.3 实现方案

添加依赖

<!-- 方式一:redisson-java -->
<dependency>	
    <groupId>org.redisson</groupId>	
    <artifactId>redisson</artifactId>	
    <version>3.11.4</version>	
</dependency>

<!-- 方式二:redisson-springboot -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.11.4</version>
</dependency>

定义接口

import org.redisson.api.RLock;
import java.util.concurrent.TimeUnit;

public interface DistributedLocker {

    RLock lock(String lockKey);

    RLock lock(String lockKey, int timeout);

    RLock lock(String lockKey, TimeUnit unit, int timeout);

    boolean tryLock(String lockKey, TimeUnit unit, int waitTime, int leaseTime);

    void unlock(String lockKey);

    void unlock(RLock lock);
    
}

实现分布式锁

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;

import java.util.concurrent.TimeUnit;

public class RedissonDistributedLocker implements DistributedLocker{

    private RedissonClient redissonClient;

    @Override
    public RLock lock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock();
        return lock;
    }

    @Override
    public RLock lock(String lockKey, int leaseTime) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(leaseTime, TimeUnit.SECONDS);
        return lock;
    }

    @Override
    public RLock lock(String lockKey, TimeUnit unit ,int timeout) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(timeout, unit);
        return lock;
    }

    @Override
    public boolean tryLock(String lockKey, TimeUnit unit, int waitTime, int leaseTime) {
        RLock lock = redissonClient.getLock(lockKey);
        try {
            return lock.tryLock(waitTime, leaseTime, unit);
        } catch (InterruptedException e) {
            return false;
        }
    }

    @Override
    public void unlock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.unlock();
    }

    @Override
    public void unlock(RLock lock) {
        lock.unlock();
    }

    public void setRedissonClient(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }
    
}

3.4 高可用的RedLock(红锁)原理

RedLock算法思想是不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁,n / 2 + 1,必须在大多数redis节点上都成功创建锁,才能算这个整体的RedLock加锁成功,避免说仅仅在一个redis实例上加锁而带来的问题。

更多JAVA、高并发、微服务、架构、解决方案、中间件的总结在:https://github.com/yu120/lemon-guide

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章