日常開發中,基于 Redis 天然支持分布式鎖,大家在線上分布式專案中都使用過 Redis 鎖,本文主要針對日常開發中加鎖程序中某些例外場景進行講解與分析,本文講解示例代碼都在 https://github.com/wayn111/newbee-mall-pro 專案 test 目錄下 RedisLockTest 類中,
版本宣告:
Spring Boot版本 3.0.2- 演示專案地址:https://github.com/wayn111/newbee-mall-pro
- github地址:http://github.com/wayn111 歡迎大家關注,點個star
一、任務超時,鎖已經過期
這個例外場景說實話發生概率很低,大部分情況下加鎖時任務執行都會很快,鎖還沒到期,任務自己就會洗掉鎖,除非說任務呼叫第三方介面不穩定導致超時、資料庫查詢突然變得非常慢就可能會產生這個例外場景,
那怎么處理這個例外嘞?大部分人可能都會回答添加一個定時任務,在定時任務內檢測鎖快過期時,進行續期操作,OK,這么做好像是可以解決這個例外,那么博主在這里給出自己的見解,
1.1 先說一個暴論:如果料想到有這類例外產生,為什么不在加鎖時,就把加鎖過期時間設定大一點
不管所續期還是增大加鎖時長,都會導致一個問題,其他執行緒會遲遲獲取不到鎖,一直被阻塞,那結果都一樣,為什么不直接增大加鎖時間?
想法是好的,但是實際上,加鎖時間的設定是我們主觀臆斷的,我們無法保證這個加鎖代碼的執行時間一定在我們的鎖過期時間內,作為一個嚴謹的程式員,我們需要對我們的代碼有客觀認知,任務執行可能幾千上億萬次都是正常,但就是那么一次它執行超時了,可能由于外部依賴、當前運行環境的例外導致,
1.2 直接不設定過期時間,任務不執行完,不釋放鎖
如果在加鎖時就不設定過期時間的話,理論上好像是可以解決這個問題,任務不執行完,鎖就不會釋放,但是作為程式員,總覺得哪里怪怪的,任務不執行完,鎖就不會釋放!
仔細想想,我們一般在 try 中進行加鎖 在 finally 進行鎖釋放,這個好像也沒毛病哦,但是實際針對一些極端例外場景下,如果任務執行程序中,服務器宕機、程式突然被殺掉、網路斷連等都可能造成這個鎖釋放不了,另一個任務就一直獲取不到鎖,
這個方案程式正常的情況下,可以滿足我們的要求,但是一旦發生例外將導致鎖無法釋放的后果,也就是說只要我們解決這個鎖在例外場景下無法釋放的問題,這個方案還是OK的,博主這里直接給出方案:
在不設定過期時間的加鎖操作成功時,給一個默認過期時間比如三十秒,同時啟動一個定時任務,給我們的鎖進行自動續期,每隔 默認過期時間 / 3 秒后執行一次續期操作,發生鎖剩余時長小于 默認過期時間 / 2 就重新賦值過期時長為三十秒,這樣的話,可以保證鎖必須由任務執行完才能釋放,當程式例外發生時,仍然能保證鎖會在三十秒內釋放,
1.3 設定過期時間,任務不執行完,不釋放鎖
這個方案本質上與方案二的解決方案相同,還是啟動定時任務進行續期操作,流程這里不做多余講述,需要注意的就是加鎖指定過期時間會比較符合我們的客觀認知,實際上他的底層邏輯跟方案二相同,無非就是定時任務執行間隔,鎖剩余時長續期判斷要根據過期時間來計算,
綜合來看:方案三會最合適,符合我們的客觀認知,跟我們之前對 Redis 的使用邏輯較為相近,
二、執行緒B加鎖執行中未釋放鎖,執行緒A釋放了執行緒B的鎖
說實話我仔細思考了一下這個例外場景,發現這個例外是個偽命題,如果執行緒 B 正在執行時,執行緒 A 怎么能獲取到執行緒B的鎖!執行緒 A 獲取不到執行緒 B 的鎖,談何來去釋放執行緒 B 的鎖!如果執行緒 A 能獲取到執行緒 B 的鎖那么這個分布式鎖的代碼一開始就已經錯了,
這里回到這個例外場景本身,我們可以給每個執行緒設定請求ID,加鎖成功將請求ID設定為加鎖 key 的對應 value,執行緒釋放鎖時需要判斷當前執行緒的請求ID與
加鎖 key 的對應 value 是否相同,相同則可以釋放鎖,不相同則不允許釋放,
三、執行緒加鎖成功后繼續申請加鎖
這個場景主要發生在加鎖代碼內部呼叫堆疊過深,比如說加鎖成功執行方法 a,在方法 a 內又重復申請了同一把鎖,導致執行緒把自己鎖住了,這個業界的主流叫法是叫鎖的可重入性,
解決方式有兩種,一是修改方法內的加鎖邏輯,不要加同一把鎖,修改方法 a 內的加鎖 key 名稱,二是針對加鎖邏輯做修改,實作可重入性,
這里簡單介紹如何實作可重入性,給每個執行緒設定請求ID,加鎖成功將請求ID設定為加鎖 key 的對應 value,針對同一個執行緒的重復加鎖,判斷當前執行緒已存在請求ID的情況下,請求ID直接與加鎖 key 的對應 value 相比較,相同則直接回傳加鎖成功,
四、 代碼實踐
4.1 加鎖自動續期實踐
設定鎖過期時間為10秒,然后該任務執行15秒,代碼如下:
ps: 以下代碼都可以在 https://github.com/wayn111/newbee-mall-pro 專案
test目錄下RedisLockTest類中找到
@Slf4j
@SpringBootTest
@RunWith(SpringRunner.class)
public class RedisLockTest {
@Autowired
private RedisLock redisLock;
@Test
@Test
public void redisLockNeNewTest() {
String key = "test";
try {
log.info("---申請加鎖");
if (redisLock.lock(key, 10)) {
// 模擬任務執行15秒
log.info("---加鎖成功");
Thread.sleep(15000);
log.info("---執行完畢");
}
} catch (Exception e) {
log.error(e.getMessage(), e);
} finally {
redisLock.unLock(key);
}
}
}
執行如下:
可以看出就算任務執行超過過期時間也能通過自動續期讓代碼正常執行,
4.2 多執行緒下其他執行緒無法共同申請到同一把鎖實踐
啟動兩個執行緒,執行緒 A 先加鎖, 執行緒 B 后枷鎖
@Test
public void redisLockReleaseSelfTest() throws IOException {
new Thread(() -> {
String key = "test";
try {
log.info("---申請加鎖");
if (redisLock.lock(key, 10)) {
// 模擬任務執行15秒
log.info("---加鎖成功");
Thread.sleep(15000);
log.info("---執行完畢");
} else {
log.info("---加鎖失敗");
}
} catch (Exception e) {
log.error(e.getMessage(), e);
} finally {
redisLock.unLock(key);
}
}, "thread-A").start();
new Thread(() -> {
String key = "test";
try {
Thread.sleep(100L);
log.info("---申請加鎖");
if (redisLock.lock(key, 10)) {
// 模擬任務執行15秒
log.info("---加鎖成功");
Thread.sleep(15000);
log.info("---執行完畢");
} else {
log.info("---加鎖失敗");
}
} catch (Exception e) {
log.error(e.getMessage(), e);
} finally {
redisLock.unLock(key);
}
}, "thread-B").start();
System.in.read();
}
結果如下:
可以看到,執行緒 A 先申請到鎖,執行緒 B 后申請鎖,結果執行緒 B 申請加鎖失敗,
4.3 鎖得可重入性實踐
當前執行緒加鎖成功后,在執行緒執行中繼續申請同一把鎖,代碼如下:
@Test
public void redisLockReEntryTest() {
String key = "test";
try {
log.info("---申請加鎖");
if (redisLock.lock(key, 10)) {
// 模擬任務執行15秒
log.info("---加鎖第一次成功");
if (redisLock.lock(key, 10)) {
// 模擬任務執行15秒
log.info("---加鎖第二次成功");
Thread.sleep(15000);
log.info("---加鎖第二次執行完畢");
} else {
log.info("---加鎖第二次失敗");
}
Thread.sleep(15000);
log.info("---加鎖第一次執行完畢");
} else {
log.info("---加鎖第一次失敗");
}
} catch (Exception e) {
log.error(e.getMessage(), e);
} finally {
redisLock.unLock(key);
}
}
結果如下:
4.4 加鎖邏輯講解
直接貼出本文最核心 RedisLock 類全部代碼:
@Slf4j
@Component
public class RedisLock {
@Autowired
public RedisTemplate redisTemplate;
/**
* 默認鎖過期時間20秒
*/
public static final Integer DEFAULT_TIME_OUT = 30;
/**
* 保存執行緒id-ThreadLocal
*/
private ThreadLocal<String> stringThreadLocal = new ThreadLocal<>();
/**
* 保存定時任務(watch-dog)-ThreadLocal
*/
private ThreadLocal<ExecutorService> executorServiceThreadLocal = new ThreadLocal<>();
/**
* 加鎖,不指定過期時間
*
* @param key key名稱
* @return boolean
*/
public boolean lock(String key) {
return lock(key, null);
}
/**
* 加鎖
*
* @param key key名稱
* @param timeout 過期時間
* @return boolean
*/
public boolean lock(String key, Integer timeout) {
Integer timeoutTmp;
if (timeout == null) {
timeoutTmp = DEFAULT_TIME_OUT;
} else {
timeoutTmp = timeout;
}
String nanoId;
if (stringThreadLocal.get() != null) {
nanoId = stringThreadLocal.get();
} else {
nanoId = IdUtil.nanoId();
stringThreadLocal.set(nanoId);
}
RedisScript<Long> redisScript = new DefaultRedisScript<>(buildLuaLockScript(), Long.class);
Long execute = (Long) redisTemplate.execute(redisScript, Collections.singletonList(key), nanoId, timeoutTmp);
boolean flag = execute != null && execute == 1;
if (flag) {
ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
executorServiceThreadLocal.set(scheduledExecutorService);
scheduledExecutorService.scheduleWithFixedDelay(() -> {
RedisScript<Long> renewRedisScript = new DefaultRedisScript<>(buildLuaRenewScript(), Long.class);
Long result = (Long) redisTemplate.execute(renewRedisScript, Collections.singletonList(key), nanoId, timeoutTmp);
if (result != null && result == 2) {
ThreadUtil.shutdownAndAwaitTermination(scheduledExecutorService);
}
}, 0, timeoutTmp / 3, TimeUnit.SECONDS);
}
return flag;
}
/**
* 釋放鎖
*
* @param key key名稱
* @return boolean
*/
public boolean unLock(final String key) {
String nanoId = stringThreadLocal.get();
RedisScript<Long> redisScript = new DefaultRedisScript<>(buildLuaUnLockScript(), Long.class);
Long execute = (Long) redisTemplate.execute(redisScript, Collections.singletonList(key), nanoId);
boolean flag = execute != null && execute == 1;
if (flag) {
if (executorServiceThreadLocal.get() != null) {
ThreadUtil.shutdownAndAwaitTermination(executorServiceThreadLocal.get());
}
}
return flag;
}
private String buildLuaLockScript() {
return """
local key = KEYS[1]
local value = https://www.cnblogs.com/wayn111/archive/2023/03/03/ARGV[1]
local time_out = ARGV[2]
local result = redis.call('get', key)
if result == value then
return 1;
end
local lock_result = redis.call('setnx', key, value)
if tonumber(lock_result) == 1 then
redis.call('expire', key, time_out)
return 1;
else
return 0;
end
""";
}
private String buildLuaUnLockScript() {
return """
local key = KEYS[1]
local value = https://www.cnblogs.com/wayn111/archive/2023/03/03/ARGV[1]
local result = redis.call('get', key)
if result ~= value then
return 0;
else
redis.call('del', key)
end
return 1;
""";
}
private String buildLuaRenewScript() {
return """
local key = KEYS[1]
local value = https://www.cnblogs.com/wayn111/archive/2023/03/03/ARGV[1]
local timeout = ARGV[2]
local result = redis.call('get', key)
if result ~= value then
return 2;
end
local ttl = redis.call('ttl', key)
if tonumber(ttl) < tonumber(timeout) / 2 then
redis.call('expire', key, timeout)
return 1;
else
return 0;
end
""";
}
}
加鎖邏輯:這里我把加鎖邏輯分解成三步展示給大家
- 加鎖前:先判斷當前執行緒是否存在請求ID,不存在則生成,存在就直接使用
- 加鎖中:通過 lua 腳本執行原子加鎖操作,
加鎖時先判斷當前執行緒ID與加鎖 key 得 value 是否相等,相等則是同一個執行緒的鎖重入,直接返加鎖成功,不相等則設定加鎖 value 為請求ID以及過期時間, - 加鎖后:啟動一個定時任務,每隔
過期時間 / 3秒后執行一次續期操作,發現鎖剩余時間不足過期時間 / 2秒后,通過 lua 腳本進行續期操作,
解鎖邏輯:這里我把解鎖邏輯分解成兩步展示給大家
- 解鎖中:通過 lua 腳本執行解鎖操作,先判斷加鎖 key 的 value 是否與自身請求ID相同,相同則讓解鎖,不相同則不讓解鎖,
- 解鎖后:洗掉定時任務,
五、總結
其實本文得核心邏輯有許多都是參考 Redission 客戶端而寫,對于這些常見得坑點,博主結合自身思考,業界知識總結并自己實作一個分布式鎖得工具類,希望大家看了有所識訓,對日常業務中 Redis 分布式鎖的使用能有更深的理解,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/545703.html
標籤:其他
上一篇:《程式員的自我修養》學習筆記——揭秘源檔案到可執行檔案的編譯程序【第一彈】
下一篇:Problems caused by variable without initialization value
