redis分布式锁实现方案深入探讨(锁重试锁续租Redis ModuleRedissonRedLock)

Posted 徐同学呀

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了redis分布式锁实现方案深入探讨(锁重试锁续租Redis ModuleRedissonRedLock)相关的知识,希望对你有一定的参考价值。

文章目录

分布式锁一般有如下的特点:

  • 互斥(Mutual Exclusion): 同一时刻只能有一个线程持有锁
  • 同步:获取锁失败是可以阻塞,后续可被唤醒
  • 可重入性: 同一节点上的同一个线程如果获取了锁之后能够再次获取锁
  • 避免死锁(Dead lock free):和J.U.C中的锁一样支持锁超时,防止死锁
  • 容错(Fault tolerance): 避免单点故障,锁服务要有⼀定容错性

基本原理

Redis分布式锁,主要借助setnx和expire两个命令完成。

setnx 对一个key进行设置value时,如果这个key已经存在,就什么也不做,返回0;如果不存在,就在redis缓存里存储这个key-vlaue,并返回OK,表示获取锁成功。

setnx key “xxx”

释放锁的话就直接把这个key删除即可。

但是需要注意几个问题:

(1)持有锁的进程或线程因为异常,未释放锁,导致死锁。

(2)释放锁的时候,只有获取锁的那个进程才能删除这个key,其他进程不能。

给锁设置超时时间(setnx+expire)

第一个问题的解决方案就是给key设置一个超时时间,key超时之后自动删除就释放锁了。

setnx和expire是两个命令,虽说expire在setnx成功后执行,是线程安全的,但两个操作不是原子的,如果setnx成功,还没来得及expire,持有锁的进程就挂了,也是会造成死锁。

所以要保证setnx和expire是一个原子操作。

设置key的同时设置超时时间 set key value PX 100 NX

set key value [EX seconds] [PX milliseconds] [NX|XX]

EX seconds:设置失效时长,单位秒

PX milliseconds:设置失效时长,单位毫秒

NX:key不存在时设置value,成功返回OK,失败返回(nil)

XX:key存在时设置value,成功返回OK,失败返回(nil)

通过lua脚本将setnx和给key设置超时时间封装起来,用redis执行lua脚本达到原子性目的

使用lua脚本编写加减锁的逻辑,保证加锁和释放锁的原子性:

加锁的Lua脚本: lock.lua

--- -1 failed
--- 1 success
---
local key = KEYS[1]
local requestId = KEYS[2]
local ttl = tonumber(KEYS[3])
local result = redis.call('setnx', key, requestId)
if result == 1 then
    --PEXPIRE:以毫秒的形式指定过期时间
    redis.call('pexpire', key, ttl)
else
    result = -1;
    -- 如果value相同,则认为是同一个线程的请求,则认为重入锁
    local value = redis.call('get', key)
    if (value == requestId) then
        result = 1;
        redis.call('pexpire', key, ttl)
    end
end
--  如果获取锁成功,则返回 1
return result

释放锁逻辑(get+del)

释放锁的逻辑,必须是持有的锁的线程或进程才能释放锁,所以在加锁时,给key设置的value必须能唯一标识持有锁的对象,比如ip+port+threadId等

释放锁的时候先获取锁key的value,比对一下是不是和自己的唯一标识一样,一样就删除该key释放锁,不一样就什么也不做。

查询key和删除key,是两个动作,同样不是原子操作,所以可以通过lua脚本进行封装。

解锁的Lua脚本: unlock.lua:

--- -1 failed
--- 1 success

-- unlock key
local key = KEYS[1]
local requestId = KEYS[2]
local value = redis.call('get', key)
if value == requestId then
    redis.call('del', key);
    return 1;
end
return -1

锁续租

前面在加锁的时候,给key设置了超时时间,如果获取锁后,业务处理时间过长,还没处理完,锁就超时自动释放锁了,当前线程可能还不知道锁被超时释放,继续向下处理业务,另一个进程或者线程此时又获取锁成功,这样就出现了并发问题,锁基本没啥用。当然因为锁key设置value具有唯一性,所以不存在前一个线程把后一个线程获取到锁释放掉。

解决方法,就是可以先预估好业务处理需要的时长,然后预留多一点的超时时间给key。这种方式肯定是治标不治本,如果遇到fullgc,且时间过长,导致的stw,也会让预估的业务处理时长不准。

所以比较彻底的办法就是在加锁成功之后再起一个后台线程,定时检查锁是否过期,快过期的时候,延迟超时时间。

具体逻辑:

  1. 加锁成功,开启一个后台线程。
  2. 后台线程根据锁的超时时间,每隔3/4的超时时间去对锁进行一次锁key的超时重置。
  3. 续租时,需要先获取key的value看看是不是自己持有的锁,如果是才续租。
  4. 查看锁和续租封装在lua脚本里保证原子性。
if (redis.call('get', KEYS[1]) == ARGV[1]) then "
   return redis.call('pexpire', KEYS[1], ARGV[2]); "
else
   return nil; 
end;

锁重试

对同一把锁,假设一个进程加锁成功了,一个进程想再获取锁就得不断循环重试,这就相当于一个自旋锁,虽然没有线程上下文的切换,但是在锁竞争激烈的情况下,对cpu的占用和性能损耗是巨大的。

所以需要实现一种像java的synchronized或者AQS那样,在获取锁失败后,可以先自旋一段时间,如果还获取不到锁就进入同步队列阻塞等待。java的单进程锁,获取锁失败的线程阻塞可以由本进程持有锁的线程释放锁时唤醒,但是分布式锁,需要实现的是跨进程唤醒

具体实现逻辑:

  1. 分布式锁可以在单进程里维护一个CLH,逻辑上跟AQS差不多,获取锁失败就进队列阻塞。如果是同进程的多个线程获取同一把锁被阻塞,可以达到同进程唤醒的效果。
  2. 当前进程没有线程持有锁,就需要跨进程唤醒,此时可以开启一个后台重试线程。
  3. 重试线程的逻辑就是,定时检查锁是否空闲,如果空闲就唤醒当前正在阻塞的线程。
  4. 获取锁成功后,就回收这个重试线程。

也可以使用带超时阻塞的LockSupport.parkNanos,在获取锁的主线程里进行重试逻辑。但是为了代码解耦,所以单独开启一个重试线程。也是为了防止惊群效应。

codis 没有pub/sub功能,如果想使用监听+远程唤醒的功能,需要用redis cluster

用zk和etcd实现的分布式锁,都可以使用watcher机制。

但是redis cluster、zk、etcd v2 的发布订阅都会存在惊群效应,一个事件变更,如果有大量的客户端监听,需要一个个远程通知,对这些集群的负载影响很大。

etcd v3,实现了类似java的AQS的clh,有多个监听会形成一个链表,不会一下子唤醒,而是依托前驱唤醒后继。

百度CAS/CAD

因为Lua 脚本的相关问题,性能、内存占用等,百度内部方案:SET + CAS/CAD Module,去掉了对Lua脚本的依赖。

加锁:

SET KEY VALUE NX PX 30000

续租:

CAS KEY VALUE VALUE PX 30000

释放锁:

CAD KEY VALUE

SET + CAS/CAD 的优点:

  • 命令简单,单条命令即可实现加锁、续约、解锁的功能。

  • 避免 Lua 脚本的问题:热点问题,扩缩容时间脚本丢失,以及 Lua 内存占用问题。

缺点:

  • Redis 的主从复制是异步的,failover 过程中可能丧失锁的安全性。
  • 锁可重入的问题

https://github.com/alibaba/alibabacloud-tairjedis-sdk

https://github.com/baidu/dlock

Redisson 分布式锁实现方案

Redisson 是架设在 Redis 基础上的一个 Java 驻内存数据网格(In-Memory Data Grid)

  • 可重入锁 RedissonLock

  • 读写锁 RedissonReadWriteLock

  • 公平锁 RedissonFairLock

  • 自旋锁 RedissonSpinLock

  • 联锁、红锁 RedissonMultiLock、RedissonRedLock

整个加锁流程:

Lua脚本源码:

Redisson 加锁的数据结构是一把锁对应的hash表,外围的大key是锁名称,里面的小key客户端唯一标识,value是锁重入次数,然后给这个哈希表设置超时时间。

**KEYS[1]**代表的是你加锁的那个key,比如说:

RLock lock = redisson.getLock(“DISLOCK”);

这里你自己设置了加锁的那个锁key就是“DISLOCK”。

**ARGV[1]**代表的就是锁key的默认生存时间

调用的时候,传递的参数为 internalLockLeaseTime ,该值默认30秒。

**ARGV[2]**代表的是加锁的客户端的ID,类似于下面这样:

01a6d806-d282-4715-9bec-f51b9aa98110:1

Redis Hincrby 命令用于为哈希表中的字段值加上指定增量值。

增量也可以为负数,相当于对指定字段进行减法操作。

如果哈希表的 key 不存在,一个新的哈希表被创建并执行 HINCRBY 命令。


Redisson优点:

  • 完整的解决方案,使用简单

  • 可重入、远程通知机制、自动续约(watch dog)

需要注意:Redisson 的 watchDog 只有在未显式指定加锁时间时才会生效。

缺点:

  • Redis 的主从复制是异步的,failover 过程中可能丧失锁的安全性

  • LUA 脚本的相关问题,性能,内存占用等

  • PUB/SUB 机制在集群模式下负载较重,spinlock对其进行了优化

RedLock

RedLock 是 Redis 官方针对 Redis 异步复制机制在 failover 过程可能导致数据丢失,而设计的一种分布式锁算法。RedLock也是Redisson中一种锁实现方案。

RedLock算法思想:

  • 获取锁的过程需要在一个奇数个redis实例集群上,获取超过一半以上实例的锁,才算是成功获取锁。
  • 释放锁的过程也是需要在所有获取到锁的实例上一个个释放,才是真正释放锁。
  • 获取锁失败,即获取锁的redis失败少于一半,那么需要把从其他实例获取的锁释放。

RedLock除了安全,再无其他优点:

  • 性能低,加锁和释放锁都需要操作多台redis
  • 部署实施复杂
  • 如果一台redis加锁成功,还没有持久化就宕机了,此时重启,另一个进程或线程又可以获取锁,所以要求redis宕机延迟重启,延迟时长为锁过期时间,但是redis里锁那么多,到底以哪个过期时间为准,不好判断。

使用redis分布式锁,是追求高性能, 在cap理论中,追求的是 ap 而不是cp。

所以,如果追求高可用,建议使用 zookeeper或者etcd实现分布式锁。

redis分布式锁可能导致的数据不一致性,建议使用业务补偿的方式去弥补。所以,不太建议使用红锁。

以上是关于redis分布式锁实现方案深入探讨(锁重试锁续租Redis ModuleRedissonRedLock)的主要内容,如果未能解决你的问题,请参考以下文章

redis分布式锁实现方案深入探讨(锁重试锁续租Redis ModuleRedissonRedLock)

Redis分布式锁Redisson原理

七种方案!探讨Redis分布式锁的正确使用姿势

RedisZookeeper实现分布式锁——原理与实践

七种方案!探讨Redis分布式锁的正确使用姿势

分布式锁不是控制并发幂等的方式