天天看點

老大吩咐的可重入分布式鎖,終于完美的實作了!!!

重做永遠比改造簡單

最近在做一個項目,将一個其他公司的實作系統(下文稱作舊系統),完整的整合到自己公司的系統(下文稱作新系統)中,這其中需要将對方實作的功能完整在自己系統也實作一遍。

舊系統還有一批存量商戶,為了不影響存量商戶的體驗,新系統提供的對外接口,還必須得跟以前一緻。最後系統完整切換之後,功能隻運作在新系統中,這就要求舊系統的資料還需要完整的遷移到新系統中。

當然這些在做這個項目之前就有預期,想過這個過程很難,但是沒想到有那麼難。原本感覺排期大半年,時間還是挺寬裕,現在感覺就是大坑,還不得不在坑裡一點點去填。

老大吩咐的可重入分布式鎖,終于完美的實作了!!!

哎,說多都是淚,不吐槽了,等到下次做完再給大家複盤下真正心得體會。

回到正文,上篇文章Redis 分布式鎖,咱們基于 Redis 實作一個分布式鎖。這個分布式鎖基本功能沒什麼問題,但是缺少可重入的特性,是以這篇文章小黑哥就帶大家來實作一下可重入的分布式鎖。

本篇文章将會涉及以下内容:

  • 可重入
  • 基于 ThreadLocal 實作方案
  • 基于 Redis Hash 實作方案
先贊後看,養成習慣。微信搜尋「程式通事」,關注就完事了~

說到可重入鎖,首先我們來看看一段來自 wiki 上可重入的解釋:

若一個程式或子程式可以“在任意時刻被中斷然後作業系統排程執行另外一段代碼,這段代碼又調用了該子程式不會出錯”,則稱其為可重入(reentrant或re-entrant)的。即當該子程式正在運作時,執行線程可以再次進入并執行它,仍然獲得符合設計時預期的結果。與多線程并發執行的線程安全不同,可重入強調對單個線程執行時重新進入同一個子程式仍然是安全的。

當一個線程執行一段代碼成功擷取鎖之後,繼續執行時,又遇到加鎖的代碼,可重入性就就保證線程能繼續執行,而不可重入就是需要等待鎖釋放之後,再次擷取鎖成功,才能繼續往下執行。

用一段 Java 代碼解釋可重入:

public synchronized void a() {
    b();
}

public synchronized void b() {
    // pass
}
           

假設 X 線程在 a 方法擷取鎖之後,繼續執行 b 方法,如果此時不可重入,線程就必須等待鎖釋放,再次争搶鎖。

鎖明明是被 X 線程擁有,卻還需要等待自己釋放鎖,然後再去搶鎖,這看起來就很奇怪,我釋放我自己~

老大吩咐的可重入分布式鎖,終于完美的實作了!!!

可重入性就可以解決這個尴尬的問題,當線程擁有鎖之後,往後再遇到加鎖方法,直接将加鎖次數加 1,然後再執行方法邏輯。退出加鎖方法之後,加鎖次數再減 1,當加鎖次數為 0 時,鎖才被真正的釋放。

可以看到可重入鎖最大特性就是計數,計算加鎖的次數。是以當可重入鎖需要在分布式環境實作時,我們也就需要統計加鎖次數。

分布式可重入鎖實作方式有兩種:

首先我們看下基于 ThreadLocal 實作方案。

實作方式

Java 中

ThreadLocal

可以使每個線程擁有自己的執行個體副本,我們可以利用這個特性對線程重入次數進行技術。

下面我們定義一個

ThreadLocal

的全局變量

LOCKS

,記憶體存儲

Map

執行個體變量。

private static ThreadLocal<Map<String, Integer>> LOCKS = ThreadLocal.withInitial(HashMap::new);
           

每個線程都可以通過

ThreadLocal

擷取自己的

Map

執行個體,

Map

key

存儲鎖的名稱,而

value

存儲鎖的重入次數。

加鎖的代碼如下:

/**
 * 可重入鎖
 *
 * @param lockName  鎖名字,代表需要争臨界資源
 * @param request   唯一辨別,可以使用 uuid,根據該值判斷是否可以重入
 * @param leaseTime 鎖釋放時間
 * @param unit      鎖釋放時間機關
 * @return
 */
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
    Map<String, Integer> counts = LOCKS.get();
    if (counts.containsKey(lockName)) {
        counts.put(lockName, counts.get(lockName) + 1);
        return true;
    } else {
        if (redisLock.tryLock(lockName, request, leaseTime, unit)) {
            counts.put(lockName, 1);
            return true;
        }
    }
    return false;
}
           
ps:

redisLock#tryLock

為上一篇文章實作的分布鎖。

由于公号外鍊無法直接跳轉,關注『程式通事』,回複分布式鎖擷取源代碼。

加鎖方法首先判斷目前線程是否已經已經擁有該鎖,若已經擁有,直接對鎖的重入次數加 1。

若還沒擁有該鎖,則嘗試去 Redis 加鎖,加鎖成功之後,再對重入次數加 1 。

釋放鎖的代碼如下:

/**
 * 解鎖需要判斷不同線程池
 *
 * @param lockName
 * @param request
 */
public void unlock(String lockName, String request) {
    Map<String, Integer> counts = LOCKS.get();
    if (counts.getOrDefault(lockName, 0) <= 1) {
        counts.remove(lockName);
        Boolean result = redisLock.unlock(lockName, request);
        if (!result) {
            throw new IllegalMonitorStateException("attempt to unlock lock, not locked by lockName:+" + lockName + " with request: "
                    + request);
        }

    } else {
        counts.put(lockName, counts.get(lockName) - 1);
    }
}
           

釋放鎖的時首先判斷重入次數,若大于 1,則代表該鎖是被該線程擁有,是以直接将鎖重入次數減 1 即可。

若目前可重入次數小于等于 1,首先移除

Map

中鎖對應的 key,然後再到 Redis 釋放鎖。

這裡需要注意的是,當鎖未被該線程擁有,直接解鎖,可重入次數也是小于等于 1 ,這次可能無法直接解鎖成功。

ThreadLocal

使用過程要記得及時清理内部存儲執行個體變量,防止發生記憶體洩漏,上下文資料串用等問題。

下次咱來聊聊最近使用

ThreadLocal

寫的 Bug。

相關問題

使用

ThreadLocal

這種本地記錄重入次數,雖然真的簡單高效,但是也存在一些問題。

過期時間問題

上述加鎖的代碼可以看到,重入加鎖時,僅僅對本地計數加 1 而已。這樣可能就會導緻一種情況,由于業務執行過長,Redis 已經過期釋放鎖。

而再次重入加鎖時,由于本地還存在資料,認為鎖還在被持有,這就不符合實際情況。

如果要在本地增加過期時間,還需要考慮本地與 Redis 過期時間一緻性的,代碼就會變得很複雜。

不同線程/程序可重入問題

狹義上可重入性應該隻是對于同一線程的可重入,但是實際業務可能需要不同的應用線程之間可以重入同把鎖。

ThreadLocal

的方案僅僅隻能滿足同一線程重入,無法解決不同線程/程序之間重入問題。

不同線程/程序重入問題就需要使用下述方案 Redis Hash 方案解決。

基于 Redis Hash 可重入鎖

ThreadLocal

的方案中我們使用了

Map

記載鎖的可重入次數,而 Redis 也同樣提供了 Hash (哈希表)這種可以存儲鍵值對資料結構。是以我們可以使用 Redis Hash 存儲的鎖的重入次數,然後利用

lua

腳本判斷邏輯。

加鎖的 lua 腳本如下:

---- 1 代表 true
---- 0 代表 false

if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end ;
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 1;
end ;
return 0;
           
如果 KEYS:[lock],ARGV[1000,uuid]

不熟悉 lua 語言同學也不要怕,上述邏輯還是比較簡單的。

加鎖代碼首先使用 Redis

exists

指令判斷目前 lock 這個鎖是否存在。

如果鎖不存在的話,直接使用

hincrby

建立一個鍵為

lock

hash 表,并且為 Hash 表中鍵為

uuid

初始化為 0,然後再次加 1,最後再設定過期時間。

如果目前鎖存在,則使用

hexists

判斷目前

lock

對應的 hash 表中是否存在

uuid

這個鍵,如果存在,再次使用

hincrby

加 1,最後再次設定過期時間。

最後如果上述兩個邏輯都不符合,直接傳回。

加鎖代碼如下:

// 初始化代碼

String lockLuaScript = IOUtils.toString(ResourceUtils.getURL("classpath:lock.lua").openStream(), Charsets.UTF_8);
lockScript = new DefaultRedisScript<>(lockLuaScript, Boolean.class);

/**
 * 可重入鎖
 *
 * @param lockName  鎖名字,代表需要争臨界資源
 * @param request   唯一辨別,可以使用 uuid,根據該值判斷是否可以重入
 * @param leaseTime 鎖釋放時間
 * @param unit      鎖釋放時間機關
 * @return
 */
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
    long internalLockLeaseTime = unit.toMillis(leaseTime);
    return stringRedisTemplate.execute(lockScript, Lists.newArrayList(lockName), String.valueOf(internalLockLeaseTime), request);
}
           
Spring-Boot 2.2.7.RELEASE

隻要搞懂 Lua 腳本加鎖邏輯,Java 代碼實作還是挺簡單的,直接使用 SpringBoot 提供的

StringRedisTemplate

即可。

解鎖的 Lua 腳本如下:

-- 判斷 hash set 可重入 key 的值是否等于 0
-- 如果為 0 代表 該可重入 key 不存在
if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
    return nil;
end ;
-- 計算目前可重入次數
local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1);
-- 小于等于 0 代表可以解鎖
if (counter > 0) then
    return 0;
else
    redis.call('del', KEYS[1]);
    return 1;
end ;
return nil;
           

首先使用

hexists

判斷 Redis Hash 表是否存給定的域。

如果 lock 對應 Hash 表不存在,或者 Hash 表不存在 uuid 這個 key,直接傳回

nil

若存在的情況下,代表目前鎖被其持有,首先使用

hincrby

使可重入次數減 1 ,然後判斷計算之後可重入次數,若小于等于 0,則使用

del

删除這把鎖。

解鎖的 Java 代碼如下:

// 初始化代碼:


String unlockLuaScript = IOUtils.toString(ResourceUtils.getURL("classpath:unlock.lua").openStream(), Charsets.UTF_8);
unlockScript = new DefaultRedisScript<>(unlockLuaScript, Long.class);

/**
 * 解鎖
 * 若可重入 key 次數大于 1,将可重入 key 次數減 1 <br>
 * 解鎖 lua 腳本傳回含義:<br>
 * 1:代表解鎖成功 <br>
 * 0:代表鎖未釋放,可重入次數減 1 <br>
 * nil:代表其他線程嘗試解鎖 <br>
 * <p>
 * 如果使用 DefaultRedisScript<Boolean>,由于 Spring-data-redis eval 類型轉化,<br>
 * 當 Redis 傳回  Nil bulk, 預設将會轉化為 false,将會影響解鎖語義,是以下述使用:<br>
 * DefaultRedisScript<Long>
 * <p>
 * 具體轉化代碼請檢視:<br>
 * JedisScriptReturnConverter<br>
 *
 * @param lockName 鎖名稱
 * @param request  唯一辨別,可以使用 uuid
 * @throws IllegalMonitorStateException 解鎖之前,請先加鎖。若為加鎖,解鎖将會抛出該錯誤
 */
public void unlock(String lockName, String request) {
    Long result = stringRedisTemplate.execute(unlockScript, Lists.newArrayList(lockName), request);
    // 如果未傳回值,代表其他線程嘗試解鎖
    if (result == null) {
        throw new IllegalMonitorStateException("attempt to unlock lock, not locked by lockName:+" + lockName + " with request: "
                + request);
    }
}
           

解鎖代碼執行方式與加鎖類似,隻不過解鎖的執行結果傳回類型使用

Long

。這裡之是以沒有跟加鎖一樣使用

Boolean

,這是因為解鎖 lua 腳本中,三個傳回值含義如下:

  • 1 代表解鎖成功,鎖被釋放
  • 0 代表可重入次數被減 1
  • null

    代表其他線程嘗試解鎖,解鎖失敗

如果傳回值使用

Boolean

,Spring-data-redis 進行類型轉換時将會把

null

轉為 false,這就會影響我們邏輯判斷,是以傳回類型隻好使用

Long

以下代碼來自

JedisScriptReturnConverter

老大吩咐的可重入分布式鎖,終于完美的實作了!!!

spring-data-redis 低版本問題

如果 Spring-Boot 使用 Jedis 作為連接配接用戶端,并且使用Redis Cluster 叢集模式,需要使用 2.1.9 以上版本的spring-boot-starter-data-redis,不然執行過程中将會抛出:

org.springframework.dao.InvalidDataAccessApiUsageException: EvalSha is not supported in cluster environment.
           

如果目前應用無法更新

spring-data-redis

也沒關系,可以使用如下方式,直接使用原生 Jedis 連接配接執行 lua 腳本。

以加鎖代碼為例:

public boolean tryLock(String lockName, String reentrantKey, long leaseTime, TimeUnit unit) {
    long internalLockLeaseTime = unit.toMillis(leaseTime);
    Boolean result = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> {
        Object innerResult = eval(connection.getNativeConnection(), lockScript, Lists.newArrayList(lockName), Lists.newArrayList(String.valueOf(internalLockLeaseTime), reentrantKey));
        return convert(innerResult);
    });
    return result;
}

private Object eval(Object nativeConnection, RedisScript redisScript, final List<String> keys, final List<String> args) {

    Object innerResult = null;
    // 叢集模式和單點模式雖然執行腳本的方法一樣,但是沒有共同的接口,是以隻能分開執行
    // 叢集
    if (nativeConnection instanceof JedisCluster) {
        innerResult = evalByCluster((JedisCluster) nativeConnection, redisScript, keys, args);
    }
    // 單點
    else if (nativeConnection instanceof Jedis) {
        innerResult = evalBySingle((Jedis) nativeConnection, redisScript, keys, args);
    }
    return innerResult;
}
           

資料類型轉化問題

如果使用 Jedis 原生連接配接執行 Lua 腳本,那麼可能又會碰到資料類型的轉換坑。

老大吩咐的可重入分布式鎖,終于完美的實作了!!!

可以看到

Jedis#eval

傳回

Object

,我們需要具體根據 Lua 腳本的傳回值的,再進行相關轉化。這其中就涉及到 Lua 資料類型轉化為 Redis 資料類型。

下面主要我們來講下 Lua 資料轉化 Redis 的規則中幾條比較容易踩坑:

1、Lua number 與 Redis 資料類型轉換

Lua 中 number 類型是一個雙精度的浮點數,但是 Redis 隻支援整數類型,是以這個轉化過程将會丢棄小數位。

老大吩咐的可重入分布式鎖,終于完美的實作了!!!

2、Lua boolean 與 Redis 類型轉換

這個轉化比較容易踩坑,Redis 中是不存在 boolean 類型,是以當Lua 中

true

将會轉為 Redis 整數 1。而 Lua 中

false

并不是轉化整數,而是轉化 null 傳回給用戶端。

老大吩咐的可重入分布式鎖,終于完美的實作了!!!

3、Lua nil 與 Redis 類型轉換

Lua nil 可以當做是一個空值,可以等同于 Java 中的 null。在 Lua 中如果 nil 出現在條件表達式,将會當做 false 處理。

是以 Lua nil 也将會 null 傳回給用戶端。

其他轉化規則比較簡單,詳情參考:

http://doc.redisfans.com/script/eval.html

總結

可重入分布式鎖關鍵在于對于鎖重入的計數,這篇文章主要給出兩種解決方案,一種基于

ThreadLocal

實作方案,這種方案實作簡單,運作也比較高效。但是若要處理鎖過期的問題,代碼實作就比較複雜。

另外一種采用 Redis Hash 資料結構實作方案,解決了

ThreadLocal

的缺陷,但是代碼實作難度稍大,需要熟悉 Lua 腳本,以及Redis 一些指令。另外使用 spring-data-redis 等操作 Redis 時不經意間就會遇到各種問題。

幫助

https://www.sofastack.tech/blog/sofa-jraft-rheakv-distributedlock/

https://tech.meituan.com/2016/09/29/distributed-system-mutually-exclusive-idempotence-cerberus-gtis.html

最後說兩句(求關注)

看完文章,哥哥姐姐們點個贊吧,周更真的超累,不知覺又寫了兩天,拒絕白嫖,來點正回報呗~。

最後感謝各位的閱讀,才疏學淺,難免存在纰漏,如果你發現錯誤的地方,可以留言指出。如果看完文章還有其他不懂的地方,歡迎加我,互相學習,一起成長~

最後謝謝大家支援~

最最後,重要的事再說一篇~

快來關注我呀~

歡迎關注我的公衆号:程式通事,獲得日常幹貨推送。如果您對我的專題内容感興趣,也可以關注我的部落格:studyidea.cn