記一次自定義Redis分散式鎖導致的故障
theme: smartblue
背景
企微報警群裡連續發出生產環境報錯警告,報錯核心資訊如下:
redis setNX error java.lang.NumberFormatException: For input string: "null"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Long.parseLong(Long.java:589)
at java.lang.Long.parseLong(Long.java:631)
......
經異常資訊定位,發現是專案中自定義的Redis分散式鎖報錯,並且該異常是在最近需求上線後突然出現,並且伴隨該異常出現的,還有需求涉及的業務資料出現部分錯亂的問題。
問題分析
老規矩,先貼涉及程式碼
//切面
public class RedisLockAspect{
public void around(ProceedingJoinPoint pjp) {
String key = "...";
try {
//阻塞,直到獲取鎖為止
while (!JedisUtil.lock(key, timeOut)) {
Thread.sleep(10);
}
//執行業務邏輯
pjp.proceed();
}finally {
JedisUtil.unLock(key);
}
}
}
以上為自定義Redis分散式鎖的切面,不看細節,只看整體邏輯,問題不大,那再看實際加鎖方法。
public class JedisUtil{
public static boolean lock(String key, long timeOut){
long currentTimeMillis = System.currentTimeMillis();
long newExpireTime = currentTimeMillis + timeOut;
RedisConnection connection = null;
try {
connection = getRedisTemplate().getConnectionFactory().getConnection();
Boolean setNxResult = connection.setNX(key.getBytes(StandardCharsets.UTF_8), String.valueOf(newExpireTime).getBytes(StandardCharsets.UTF_8));
//位置1
if(setNxResult){
expire(key,timeOut, TimeUnit.MILLISECONDS);
return true;
}
//位置2
Object objVal = getRedisTemplate().opsForValue().get(key);
String currentValue = String.valueOf(objVal);
//位置3,異常位置為if判斷中Long.parseLong(currentValue),currentValue為null的字串
if (currentValue != null && Long.parseLong(currentValue) < currentTimeMillis) {
String oldExpireTime = (String) getAndSet(key, String.valueOf(newExpireTime));
if (oldExpireTime != null && oldExpireTime.equals(currentValue)) {
return true;
}
}
}
return false;
}
public static void unLock(String key){
getRedisTemplate().delete(key);
}
}
有經驗的大佬看到這段程式碼,估計會忍不住爆粗,但咱先不管,先看錯誤位置。
異常資訊可以看出,currentValue的值為字串“null”,即String.valueOf(objVal)中的objVal物件為null,也就是在Redis中,key對應的value不存在,此時思考一下,key對應的value不存在,無非以下兩種情況:
- key被主動刪除;
- key過期了。
繼續跟著程式碼往上走,發現前面執行了setNx命令,並且返回setNxResult表示是否成功。正常來說,當setNxResult為false的時候,加鎖失敗,此時程式碼時不應該往下走的,但在本段程式碼中,卻繼續往下走!問了下相關同事,說是為了做可重入鎖......(弱弱吐槽下,可重入鎖也不是這樣乾的啊...)
其實分析到這,已經可以知道是什麼原因導致的異常故障了,即上面說的,key被主動刪除、key過期導致,下面假設有兩個執行緒,對同一個key加鎖,分別對應以上兩種情況:
key被主動刪除的情況,發生於分散式鎖加鎖邏輯執行完後,呼叫unlock方法,見以上RedisLockAspect類中finally部分,如下圖:
key過期的情況,主要線上程加鎖並設定過期時間後,執行業務程式碼耗費的時間超過設定的鎖過期時間,並且在鎖過期前,未對鎖進行續期:
解決方案
從上面的程式碼看來,這已經不是簡單的Long.parseLong("null")問題了,這是整個Redis分散式鎖實現的問題,並且該分散式鎖在整個專案中大量使用,可想而知其實問題非常嚴重,如果只是解決Long.parseLong("null")的問題,無疑就是隔靴撓癢,沒有任何意義的。
一般情況下,自定義Redis分散式鎖容易出現以下幾大問題:
- setNx鎖釋放問題;
- setNx Expire 原子性問題;
- 鎖過期問題;
- 多執行緒釋放鎖問題;
- 可重入問題;
- 大量失敗時自旋鎖問題;
- 主從架構下鎖資料同步問題;
結合以上故障程式碼,可以發現專案中的Redis分散式鎖實現幾乎未對Redis分散式鎖問題進行考慮,以下為主要問題以及對應解決方案:
- setNx 和 expire 原子操作:使用Lua指令碼,在一次Lua指令碼命令中,執行setNx 與 expire命令,保證原子性;
- 鎖過期問題:為防止鎖自動過期,可在鎖過期前,定時對鎖過期時間進行續期。
- 可重入問題:可重入設計粒度需到執行緒級別,可在鎖上加上執行緒唯一id。
- 鎖自旋問題:參考JDK中AQS設計,實現獲取鎖時最大等待時長。
對於專案中的問題以及每個問題的解決方案實現,baidu一下就有大量參考,此處不再介紹。目前比較成熟的綜合解決方案為使用Redisson客戶端,以下為簡單虛擬碼demo:
public class RedisLockAspect{
@Autowired
private Redisson redisson;
public void around(ProceedingJoinPoint pjp) {
String key = "...";
Long waitTime = 3000L;
//獲取鎖
RLock lock = redisson.getLock(key);
boolean lockSuccess = false;
try {
//加鎖設定超時時間,防止無限自旋。預設啟用看門狗功能(自動對鎖進行續期)
lockSuccess = lock.tryLock(waitTime);
//執行業務邏輯
pjp.proceed();
}finally {
//解鎖,防止釋放其他執行緒鎖
if (lock.isLocked() && lock.isHeldByCurrentThread() && lockSuccess){
lock.unlock();
}
}
}
}
使用Redisson可以快速解決目前專案中Redis分散式鎖存在的問題。除此之外,對於Redis主從架構下資料同步導致的鎖問題,對應的解決方案RedLock,也提供了相應的實現。
更多使用文件詳見官方文件https://github.com/liulongbiao/redisson-doc-cn
總結
對於分散式鎖來說,可實現方案其實遠遠不止Redis這個實現途徑,比如基於Zookeeper、基於Etcd等方案,但其實對於目的來說,都是殊途同歸,重點在於,如何安全、正確的使用這些方案,保證業務正常。
對於研發團隊來說,針對類似的問題,需要對技術小夥伴進行培訓,不斷提升技術,更需要重視codereview工作,及時識別風險,避免發生故障造成嚴重損失(本次故障造成髒資料修復耗時一個多星期)。
敬畏技術,忠於業務。