記一次自定義Redis分散式鎖導致的故障

語言: CN / TW / HK

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不存在,無非以下兩種情況:

  1. key被主動刪除;
  2. key過期了。

繼續跟著程式碼往上走,發現前面執行了setNx命令,並且返回setNxResult表示是否成功。正常來說,當setNxResult為false的時候,加鎖失敗,此時程式碼時不應該往下走的,但在本段程式碼中,卻繼續往下走!問了下相關同事,說是為了做可重入鎖......(弱弱吐槽下,可重入鎖也不是這樣乾的啊...)

其實分析到這,已經可以知道是什麼原因導致的異常故障了,即上面說的,key被主動刪除、key過期導致,下面假設有兩個執行緒,對同一個key加鎖,分別對應以上兩種情況:

key被主動刪除的情況,發生於分散式鎖加鎖邏輯執行完後,呼叫unlock方法,見以上RedisLockAspect類中finally部分,如下圖:

key被主動刪除.png

key過期的情況,主要線上程加鎖並設定過期時間後,執行業務程式碼耗費的時間超過設定的鎖過期時間,並且在鎖過期前,未對鎖進行續期:

key自動過期.png

解決方案

從上面的程式碼看來,這已經不是簡單的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工作,及時識別風險,避免發生故障造成嚴重損失(本次故障造成髒資料修復耗時一個多星期)。

敬畏技術,忠於業務。