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,也会让预估的业务处理时长不准。
所以比较彻底的办法就是在加锁成功之后再起一个后台线程,定时检查锁是否过期,快过期的时候,延迟超时时间。
具体逻辑:
- 加锁成功,开启一个后台线程。
- 后台线程根据锁的超时时间,每隔3/4的超时时间去对锁进行一次锁key的超时重置。
- 续租时,需要先获取key的value看看是不是自己持有的锁,如果是才续租。
- 查看锁和续租封装在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的单进程锁,获取锁失败的线程阻塞可以由本进程持有锁的线程释放锁时唤醒,但是分布式锁,需要实现的是跨进程唤醒。
具体实现逻辑:
- 分布式锁可以在单进程里维护一个CLH,逻辑上跟AQS差不多,获取锁失败就进队列阻塞。如果是同进程的多个线程获取同一把锁被阻塞,可以达到同进程唤醒的效果。
- 当前进程没有线程持有锁,就需要跨进程唤醒,此时可以开启一个后台重试线程。
- 重试线程的逻辑就是,定时检查锁是否空闲,如果空闲就唤醒当前正在阻塞的线程。
- 获取锁成功后,就回收这个重试线程。
也可以使用带超时阻塞的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)的主要内容,如果未能解决你的问题,请参考以下文章