
前期我在《【分布式】越不過去的分布式鎖》一文中,提到過Redis實作分布式鎖的正常思路,即基于SETNX的實作,裡面提到Redis 官方站提出了一種權威的基于 Redis 實作分布式鎖的方式名叫 Redlock,可彌補redis正常手段實作的天生缺陷。那麼本篇,我們将對這一種更進階的分布式鎖的實作方式Redlock進行實驗并進一步探讨。
什麼是RedlockRedis 官方站這篇文章提出了一種權威的基于 Redis 實作分布式鎖的方式名叫 Redlock,此種方式比原先的單節點的方法更安全。它可以保證以下特性:
- 安全特性:互斥通路,即永遠隻有一個 client 能拿到鎖
- 避免死鎖:最終 client 都可能拿到鎖,不會出現死鎖的情況,即使原本鎖住某資源的 client crash 了或者出現了網絡分區
- 容錯性:隻要大部分 Redis 節點存活就可以正常提供服務
參見:
- https://redis.io/topics/distlock
- https://github.com/antirez/redis-doc/blob/master/topics/distlock.md
Redlock算法大概是這樣的:
在Redis的分布式環境中,我們假設有N個Redis master。這些節點完全互相獨立,不存在主從複制或者其他叢集協調機制。我們確定将在N個執行個體上使用與在Redis單執行個體下相同方法擷取和釋放鎖。現在我們假設有5個Redis master節點,分布在不同的機房盡量保證可用性。為了獲得鎖,client 會進行如下操作:
- 得到目前的時間,機關毫秒
- 嘗試順序地在 5 個執行個體上申請鎖,當然需要使用相同的 key 和 random value,這裡一個 client 需要合理設定與 master 節點溝通的 timeout 大小,避免長時間和一個 fail 了的節點浪費時間
- 當 client 在大于等于 3 個 master 上成功申請到鎖的時候,且它會計算申請鎖消耗了多少時間,這部分消耗的時間采用獲得鎖的當下時間減去第一步獲得的時間戳得到,如果鎖的持續時長(lock validity time)比流逝的時間多的話,那麼鎖就真正擷取到了。
- 如果鎖申請到了,那麼鎖真正的 lock validity time 應該是 origin(lock validity time) - 申請鎖期間流逝的時間
- 如果 client 申請鎖失敗了,那麼它就會在少部分申請成功鎖的 master 節點上執行釋放鎖的操作,重置狀态
當然,上面描述的隻是擷取鎖的過程,而釋放鎖的過程比較簡單:用戶端向所有Redis節點發起釋放鎖的操作,不管這些節點當時在擷取鎖的時候成功與否。
分布式鎖實作redisson已經有對Redlock算法封裝,接下來對其用法進行簡單介紹,并對核心源碼進行分析(目前沒有過多精力做redis叢集,暫時使用單機模式,RedissonClient預設是支援單機,主從,哨兵,叢集等模式的,可自定義配置)。
POM依賴
<!-- Redisson JDK 1.8+ compatible -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.9.0</version>
</dependency>
用法 基于springboot,RedissonClient自動裝載配置:
@Configuration
@ConditionalOnClass(Config.class)
public class RedissonAutoConfiguration {
@Bean
public RedissonClient getRedisson() {
//支援單機,主從,哨兵,叢集等模式
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6377")
.setTimeout(2000)
.setConnectionPoolSize(50)
.setConnectionMinimumIdleSize(10);
RedissonClient redisson = Redisson.create(config);
try {
System.out.println("檢測是否配置完成:"+redisson.getConfig().toJSON().toString());
} catch (IOException e) {
e.printStackTrace();
}
return redisson;
}
}
Redlock接口實作類,我們可以看到redission封裝的redlock算法實作的分布式鎖用法,非常簡單,跟重入鎖(ReentrantLock)有點類似:
/**
* Redlock 實作類
*/
@Component
public class RedissonDistributedLocker implements DistributedLocker {
@Autowired
private RedissonClient redissonClient;
/**
* 拿不到lock就不罷休,不然線程就一直block
* @param lockKey
* @return
*/
@Override
public RLock lock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
lock.lock();
return lock;
}
/**
*
* @param lockKey
* @param timeout 加鎖時間 機關為秒
* @return
*/
@Override
public RLock lock(String lockKey, long timeout) {
RLock lock = redissonClient.getLock(lockKey);
lock.lock(timeout, TimeUnit.SECONDS);
return lock;
}
/**
*
* @param lockKey
* @param unit 時間機關
* @param timeout 加鎖時間
* @return
*/
@Override
public RLock lock(String lockKey, TimeUnit unit, long timeout) {
RLock lock = redissonClient.getLock(lockKey);
lock.lock(timeout, unit);
return lock;
}
/**
* tryLock(),馬上傳回,拿到lock就傳回true,不然傳回false。
* 帶時間限制的tryLock(),拿不到lock,就等一段時間,逾時傳回false.
* @param lockKey
* @param unit
* @param waitTime
* @param leaseTime
* @return
*/
@Override
public boolean tryLock(String lockKey, TimeUnit unit, long waitTime, long 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();
}
}
測試方法:
@Autowired
private DistributedLocker distributedLocker;
@Test
public void readLockTest() throws Exception {
String key = "redisson_key";
for (int i = 0; i < 100; i++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
try {
System.err.println("===線程開啟===" + Thread.currentThread().getName());
/*
//直接加鎖,擷取不到鎖則一直等待擷取鎖
distributedLocker.lock(key,10L);
//獲得鎖之後可以進行相應的處理
Thread.sleep(100);
System.err.println("===獲得鎖後進行相應的操作==="+Thread.currentThread().getName());
//解鎖
distributedLocker.unlock(key);
System.err.println("==="+Thread.currentThread().getName());
*/
//嘗試擷取鎖,等待5秒,自己獲得鎖後一直不解鎖則10秒後自動解鎖
boolean isGetLock = distributedLocker.tryLock(key, TimeUnit.SECONDS, 5L, 10L);
if (isGetLock) {
Thread.sleep(100); //獲得鎖之後可以進行相應的處理
System.err.println("===獲得鎖後進行相應的操作===" + Thread.currentThread().getName());
//distributedLocker.unlock(key);
System.err.println("===" + Thread.currentThread().getName());
}
} catch (Exception e) {
e.printStackTrace();
}
}
});
t.start();
}
}
代碼位址:
hi_leon/leon-ever-onwardgitee.com
注:由于換了新環境,github源碼太大下載下傳不下來,copy到碼雲了暫時。
源碼分析 唯一ID實作分布式鎖的一個非常重要的點就是set的value要具有唯一性,redisson的value是怎樣保證value的唯一性呢?答案是UUID+threadId。入口在redissonClient.getLock("REDLOCK_KEY"),源碼在Redisson.java和RedissonLock.java中:
final UUID id;
protected String getLockName(long threadId) {
return this.id + ":" + threadId;
}
擷取鎖 擷取鎖的代碼為redLock.tryLock()或者redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS),兩者的最終核心源碼都是下面這段代碼,隻不過前者擷取鎖的預設租約時間(leaseTime)是LOCK_EXPIRATION_INTERVAL_SECONDS,即30s:
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
// 擷取鎖時需要在redis執行個體上執行的lua指令
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
// 首先分布式鎖的KEY不能存在,如果确實不存在,那麼執行hset指令(hset REDLOCK_KEY uuid+threadId 1),并通過pexpire設定失效時間(也是鎖的租約時間)
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
// 如果分布式鎖的KEY已經存在,并且value也比對,表示是目前線程持有的鎖,那麼重入次數加1,并且設定失效時間
"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; " +
// 擷取分布式鎖的KEY的失效時間毫秒數
"return redis.call('pttl', KEYS[1]);",
// 這三個參數分别對應KEYS[1],ARGV[1]和ARGV[2]
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
擷取鎖的指令中,
- KEYS[1]就是Collections.singletonList(getName()),表示分布式鎖的key,即REDLOCK_KEY;
- ARGV[1]就是internalLockLeaseTime,即鎖的租約時間,預設30s;
- ARGV[2]就是getLockName(threadId),是擷取鎖時set的唯一值,即UUID+threadId:
釋放鎖的代碼為redLock.unlock(),核心源碼如下:
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
// 釋放鎖時需要在redis執行個體上執行的lua指令
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
// 如果分布式鎖KEY不存在,那麼向channel釋出一條消息
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
// 如果分布式鎖存在,但是value不比對,表示鎖已經被占用,那麼直接傳回
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
// 如果就是目前線程占有分布式鎖,那麼将重入次數減1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
// 重入次數減1後的值如果大于0,表示分布式鎖有重入過,那麼隻設定失效時間,還不能删除
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
// 重入次數減1後的值如果為0,表示分布式鎖隻擷取過1次,那麼删除這個KEY,并釋出解鎖消息
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
// 這5個參數分别對應KEYS[1],KEYS[2],ARGV[1],ARGV[2]和ARGV[3]
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}
Redlock存在的問題 由于N個Redis節點中的大多數能正常工作就能保證Redlock正常工作,是以理論上它的可用性更高。我們前期讨論的單Redis節點的分布式鎖在failover的時候鎖失效的問題,在Redlock中不存在了(解決了遺留問題1),但如果有節點發生崩潰重新開機,還是會對鎖的安全性有影響的。具體的影響程度跟Redis對資料的持久化程度有關。
根據上述提出的算法,當N個節點中有一個節點當機,仍然存在鎖的安全性問題。具體的影響跟redis的持久化程度有關。
假設一共有5個Redis節點:A, B, C, D, E。設想發生了如下的事件序列:
- 用戶端1成功鎖住了A, B, C,擷取鎖成功(但D和E沒有鎖住)。
- 節點C崩潰重新開機了,但用戶端1在C上加的鎖沒有持久化下來,丢失了。
- 節點C重新開機後,用戶端2鎖住了C, D, E,擷取鎖成功。
這樣,用戶端1和用戶端2同時獲得了鎖(針對同一資源)。
在預設情況下,Redis的AOF持久化方式是每秒寫一次磁盤(即執行fsync),是以最壞情況下可能丢失1秒的資料。為了盡可能不丢資料,Redis允許設定成每次修改資料都進行fsync,但這會降低性能。當然,即使執行了fsync也仍然有可能丢失資料(這取決于系統而不是Redis的實作)。是以,上面分析的由于節點重新開機引發的鎖失效問題,總是有可能出現的。為了應對這一問題,antirez又提出了延遲重新開機(delayed restarts)的概念。也就是說,一個節點崩潰後,先不立即重新開機它,而是等待一段時間再重新開機,這段時間應該大于鎖的有效時間(lock validity time)。這樣的話,這個節點在重新開機前所參與的鎖都會過期,它在重新開機後就不會對現有的鎖造成影響。
關于Redlock還有一點細節值得拿出來分析一下:
在最後釋放鎖的時候,antirez在算法描述中特别強調,用戶端應該向所有Redis節點發起釋放鎖的操作。也就是說,即使當時向某個節點擷取鎖沒有成功,在釋放鎖的時候也不應該漏掉這個節點。
這是為什麼呢?設想這樣一種情況,用戶端發給某個Redis節點的擷取鎖的請求成功到達了該Redis節點,這個節點也成功執行了SET操作,但是它傳回給用戶端的響應包卻丢失了。這在用戶端看來,擷取鎖的請求由于逾時而失敗了,但在Redis這邊看來,加鎖已經成功了。是以,釋放鎖的時候,用戶端也應該對當時擷取鎖失敗的那些Redis節點同樣發起請求。實際上,這種情況在異步通信模型中是有可能發生的:用戶端向伺服器通信是正常的,但反方向卻是有問題的。
其它問題1、仍然存在用戶端長時間阻塞,導緻獲得的鎖釋放,通路的共享資源不受保護的問題。
2、在Redlock的算法中,我們可以看到第3步,當擷取鎖耗時太多,留給用戶端的通路共享資源的時間很短,這種情況若來不及操作,是不是要釋放鎖呢?且到底剩下多少時間才算短?這又是一個選擇難題。
3、Redlock算法對時鐘依賴性太強,若N個節點中的某個節點發生時間跳躍,也可能會引此而引發鎖安全性問題。
結束語關于分布式鎖,先告一段落,最近過于忙碌,新公司的技術棧又過于老舊,dubbo、spring2.x、struts2、JDBC、MongoDB、Redis、memecache...整個一個大雜燴,我的這兩周的心情猶如萬隻羊駝在奔騰。隻能期待未來的日子,在技術選型上,可以有發揮的餘地。
三十而立的年紀,修煉成佛!
參考:
- https://redis.io/topics/distlock
- redis分布式鎖的安全性探讨(二):分布式鎖Redlock https://blog.csdn.net/hh1sdfsf56456/article/details/79474434
- Redlock:Redis分布式鎖最牛逼的實作https://www.jianshu.com/p/7e47a4503b87
【分布式】基于Redis實作分布式事務鎖mp.weixin.qq.com