Redis分布式锁总结


Redis分布式锁总结

原子操作问题-Lua脚本

利用Lua脚本执行命令,可以保证命令式原子性执行。

eval执行时,是将脚本发送到Redis服务器上,Redis将其排入命令队列等待执行。

脚本会生成对应的SHA1校验和,evalsha执行时,发送SHA1校验和即可执行Redis上的脚本,省去网络开销。

redis.call('key',KEYS[1],ARGV[1],...):执行失败就返回。

redis.pcall('key',KEYS[1],ARGV[1],...):中间失败继续执行。

  • 编写Lua脚本
-- SET KEY VAL PX NX
-- Lock方式1
local result = redis.call('SETNX', KEYS[1], ARGV[1])
if result < 1
then
    return false
end
result = redis.call("PEXPIRE", KEYS[1], ARGV[2])
if result == 1
then
    return true
else
--  把锁删了
    redis.call("DEL", KEY[1])
    return false
end
-- SET KEY VAL PX NX
-- Lock方式2
return result = redis.call('SET', KEYS[1], ARGV[1],'EX',ARGV[2],'NX')
-- Unlock
local val = redis.call('GET',KEYS[1])
if val == ARGV[1]
then
    local result = redis.call('DEL', KEYS[1])
    if result == 1
    then
        return true
    else
        return false
    end
else
    return false;
end
  • 配置RedisScript
@Bean
public RedisScript<Boolean> redisLockScript() {
    DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
    redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lockScript.lua")));
    redisScript.setResultType(Boolean.class);
    return redisScript;
}
  • 通过RedisTemplate调用
redisTemplate.execute(redisLockScript,keys,token,timeout);

误删锁-Value指定唯一值

情景:若A线程业务代码执行超期,锁被释放,B线程获取到锁,等到A线程业务执行完后,执行unlock(),会把线程B的锁释放掉。

  • 为锁指定唯一token
String token = UUID.randomUUID().toString().replaceAll("-", "");
  • 先比较是否是对应的锁,再删除。
-- Unlock
local val = redis.call('GET',KEYS[1])
if val == ARGV[1]
then
    local result = redis.call('DEL', KEYS[1])
    if result == 1
    then
        return true
    else
        return false
    end
else
    return false;
end

超时并发

情景:由于线程执行时间太长导致多个线程的锁被释放,一起并发执行使得出现并发BUG。

  • 为线程开启守护线程,自动续加时间。

    根据业务,每X秒从Redis里找锁,查看锁剩余时间,如果小于一定阈值,expire key&pexpire key重新设置键的时间。

  • 为锁设置足够长的时间保证不会超时释放。

不可重入

  • 利用ThreadLocal,每次加锁,先看TheadLocal中有无,有加1,没有就put();解锁时,每解一次减1,直到计数为0。
  • 高效,但是考虑到过期时间和本地、Redis一致性的问题,会增加代码的复杂性。
private static ThreadLocal<Map<String, Integer>> LOCKERS = ThreadLocal.withInitial(HashMap::new);
// 加锁
public boolean lock(String key) {
  Map<String, Integer> lockers = LOCKERS.get();
  if (lockers.containsKey(key)) {
    lockers.put(key, lockers.get(key) + 1);
    return true;
  } else {
    if (SET key uuid NX EX 30) {
      lockers.put(key, 1);
      return true;
    }
  }
  return false;
}
// 解锁
public void unlock(String key) {
  Map<String, Integer> lockers = LOCKERS.get();
  if (lockers.getOrDefault(key, 0) <= 1) {
    lockers.remove(key);
    DEL key
  } else {
    lockers.put(key, lockers.get(key) - 1);
  }
}
  • Redishash数据结构实现(Redisson源码中的Lua脚本)
// 如果 lock_key 不存在
if (redis.call('exists', KEYS[1]) == 0)
then
    // 设置 lock_key 线程标识 1 进行加锁
    redis.call('hset', KEYS[1], ARGV[2], 1);
    // 设置过期时间
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
    end;
// 如果 lock_key 存在且线程标识是当前欲加锁的线程标识
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]);

线程等待

  • 轮询等待
  • 逻辑简单但并发量大时耗费资源
while (!disturbedLock(keyToken, expireMillSeconds, waitTime)) ;
  • 利用Redis发布订阅实现等待锁功能

不会,Redission已实现?

主从复制

主从状态下,主节点挂掉,从节点切换到主节点,主从复制过程中,新切换的节点还未同步锁,导致其他线程获取到锁,出现并发BUG。

集群脑裂

集群脑裂指因为网络问题,导致Redis master节点跟slave节点和sentinel集群处于不同的网络分区,因为sentinel集群无法感知到master的存在,所以将slave节点提升为master节点,此时存在两个不同的master节点。Redis Cluster集群部署方式同理。

简单实现

自造轮子 未基于Redssion

未实现守护线程续时,未实现发布订阅等待。

具备原子性,具备不被误删性,具备获取锁超时放弃特性。

@Override
public void run() {
    String keyToken = Thread.currentThread().getName() + "-" + CommunityUtil.generateUUID();
    int waitTime = (int) (Math.random() * 1000 + 999);
    //尝试获取锁
    while (!disturbedLock(keyToken, 1000, waitTime)) ;
    try {
        long id = (long) redisTemplate.opsForValue().get("user1");
        for (int i = 0; i < 1000000000; i++) {
            ++id;
            redisTemplate.opsForValue().set("user1", id);
        }
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        //释放锁
        disturbedUnlock(keyToken);
    }
}

 public boolean disturbedLock(String keyToken, long expireMillSeconds, long waitTimeout) {
    long start = System.currentTimeMillis();
    String lock = "test:lock";
    List<String> keys = new ArrayList<>();
    keys.add(lock);
    while (true) {
        if ((boolean) redisTemplate.execute(redisLockScript, keys, keyToken, expireMillSeconds)) {
            //获取到了锁但超过了等待时间
            if (System.currentTimeMillis() - start > waitTimeout) {
                throw new IllegalArgumentException(Thread.currentThread().getName() + " timeout");
            }
            return true;
        }
        //未获取到锁且超过等待时间
        if (System.currentTimeMillis() - start > expireMillSeconds) {
            throw new IllegalArgumentException(Thread.currentThread().getName() + " timeout");
        }
    }
}   

    public void disturbedUnlock(String token) {
    String unlock = "test:lock";
    List<String> keys = new ArrayList<>();
    keys.add(unlock);
    System.out.println(Thread.currentThread().getName() + "-" + token + " del " + redisTemplate.execute(redisUnlockScript, keys, token));
}

Redission

Redlock算法

In the distributed version of the algorithm we assume we have N Redis masters. Those nodes are totally independent, so we don’t use replication or any other implicit coordination system. We already described how to acquire and release the lock safely in a single instance. We take for granted that the algorithm will use this method to acquire and release the lock in a single instance. In our examples we set N=5, which is a reasonable value, so we need to run 5 Redis masters on different computers or virtual machines in order to ensure that they’ll fail in a mostly independent way.

  1. It gets the current time in milliseconds.
  2. It tries to acquire the lock in all the N instances sequentially, using the same key name and random value in all the instances. During step 2, when setting the lock in each instance, the client uses a timeout which is small compared to the total lock auto-release time in order to acquire it. For example if the auto-release time is 10 seconds, the timeout could be in the ~ 5-50 milliseconds range. This prevents the client from remaining blocked for a long time trying to talk with a Redis node which is down: if an instance is not available, we should try to talk with the next instance ASAP.
  3. The client computes how much time elapsed in order to acquire the lock, by subtracting from the current time the timestamp obtained in step 1. If and only if the client was able to acquire the lock in the majority of the instances (at least 3), and the total time elapsed to acquire the lock is less than lock validity time, the lock is considered to be acquired.
  4. If the lock was acquired, its validity time is considered to be the initial validity time minus the time elapsed, as computed in step 3.
  5. If the client failed to acquire the lock for some reason (either it was not able to lock N/2+1 instances or the validity time is negative), it will try to unlock all the instances (even the instances it believed it was not able to lock).

假设现在有5个Redis实例,这5个实例可以是5个互不相干的主节点;5个哨兵或5不同集群中的节点。

  • 首先获取当前时间,毫秒

  • 生成相同的key和不同的随机value,按序从5个实例尝试获取锁。当然还会设置一个超时时间。

    如果一直获取成功,直到获取完5把锁后,看下总共获取锁的时间是否超过的设置超时时间。

    若没超过,即获取成功,执行业务代码。若超过时间,释放所有获取到的锁,获取失败。

    如果一个Redis获取锁失败,就赶紧尝试获取下一个节点的锁。

    如果此时获取到锁的数量>3,耗费时间小于超时时间,立即返回获取成功,大于就返回失败。

    如果此时获取失败的次数>=3,耗费时间大于超时时间,返回获取失败,小于就重新开始新一轮获取锁。

  • 如果获取失败,就要删除所有已获得的锁。

疑问:若trylock成功,但返回超时,redisson会认为获取失败,但是Redis上确实有对应的锁。这样会造成其他线程尝试获取锁失败。为什么不在获取失败时删除所有节点上的锁,而只删除已获取的锁?

redlock.unlock()会把所有锁给释放。

原子性保证,误删除保证,可重入保证,锁等待保证。

若指定锁超时时间,业务超时导致锁释放会出现并发BUG。watchdog只有在不指定超时时间时才会使用,watchdog的时间会被设置到锁中。

隐患1:业务代码中出现死锁,造成watchdog一直为该锁续时,其他线程无法获取到锁饿死。要想办法感知死锁。

隐患2:master在同步salve的时候master 挂了salve 并没有同步到锁,这时候一个服务实例刚好获取到了新的master锁,这个时候怎么处理?

被锁的服务操作如果是核心服务要保证操作幂等性,这样子可以保证不出现业务逻辑的重复执行导致错误。

RedLock继承了multiLock

redission默认锁时间是30s

参考


文章作者: Wendell
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Wendell !
评论
  目录