分佈式鎖的6個層次

2024年2月6日 36点热度 0人点赞

分佈式鎖的原則

  • 互斥性, 一次隻能有一個客戶端獲得鎖,
  • 不死鎖,客戶端如果獲得鎖之後,出現異常,能自動解鎖,資源不會被死鎖。
  • 一致性,redis 因為內部原因,發生了主從切換,鎖在切換到新的 master 後依然保持不變。

層次一

redis.setNX(ctx, key, "1")
defer redis.del(ctx, key)
  • 可以保持互斥性,但是沒有設置過期時間,不能保證不死鎖。

層次二

redis.setNX(ctx, key, "1",expiration)
defer redis.del(ctx, key)
  • 可以使用 lua 腳本保證 setnx 和 expiration 的原子性,可以做到不死鎖,但是不能保證一致性。

層次三

redis.SetNX(ctx, key, randomValue, expiration)
defer redis.del(ctx, key, randomValue)
// 以下為del的lua腳本
if redis.call("get",KEYS[1]) == ARGV[1] then
   return redis.call("del",KEYS[1])
else
   return 0
end
// 在公司的redis-v6包已經支持cad,因而lua腳本可以精簡為以下代碼
// 公司的cas/cad命令見 擴展命令 - CAS/CAD/XDECRBY Extended commands - CAS/CAD/XDECRBY 
redis.cad(ctx, key, randomValue)

獲得鎖和刪除鎖是一個協程,避免程序運行時間長時刪除別的協程的鎖,做到一定程度的一致性。

層次四

func myFunc() (errCode *constant.ErrorCode) {
    errCode := DistributedLock(ctx, key, randomValue, LockTime)
    defer DelDistributedLock(ctx, key, randomValue)
    if errCode != nil {
       return errCode
    }
    // doSomeThing
}
// 註意,以下代碼還不能用cas優化,因為公司的redis-v6還不支持oldvalue是nil
func DistributedLock(ctx context.Context, key, value string, expiration time.Duration) (errCode *constant.ErrorCode) {
   ok, err := redis.SetNX(ctx, key, value, expiration)
   if err == nil {
      if !ok {
         return constant.ERR_MISSION_GOT_LOCK
      }
      return nil
   }
   // 應對超時且成功場景,先get一下看看情況
   time.Sleep(DistributedRetryTime)
   v, err := redis.Get(ctx, key)
   if err != nil {
      return constant.ERR_CACHE
   }
   if v == value {
      // 說明超時且成功
      return nil
   } else if v != "" {
      // 說明被別人搶了
      return constant.ERR_MISSION_GOT_LOCK
   }
   // 說明鎖還沒被別人搶,那就再搶一次
   ok, err = redis.SetNX(ctx, key, value, expiration)
   if err != nil {
      return constant.ERR_CACHE
   }
   if !ok {
      return constant.ERR_MISSION_GOT_LOCK
   }
   return nil
}
// 以下為del的lua腳本
if redis.call("get",KEYS[1]) == ARGV[1] then
   return redis.call("del",KEYS[1])
else
   return 0
end
// 在公司的redis-v6包已經支持cad,因而lua腳本可以精簡為以下代碼
// redis.cad(ctx, key, randomValue)
func DelDistributedLock(ctx context.Context, key, value string) (errCode *constant.ErrorCode) {
   v, err := redis.Cad(ctx, key, value)
   if err != nil {
      return constant.ERR_CACHE
   }
   return nil
}

解決了超時且成功的問題,寫入超時且成功是偶現的,災難性的問題。

還存在的問題:

  • 單點問題,單 master 有問題,如果主從復制,主從復制有問題,也存在問題。
  • 鎖過期後沒有完成流程怎麼辦?

層次五

啟動定時器,鎖過期,但是還沒完成流程時,續租,隻能續當前協程搶占的鎖。

// 以下為續租的lua腳本,實現CAS(compare and setif redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("expire",KEYS[1], ARGV[2])
else
    return 0
end
// 在公司的redis-v6包已經支持cas,因而lua腳本可以精簡為以下代碼
redis.Cas(ctx, key, value, value)

能保障鎖過期的一致性,但是解決不了單點問題 同時,可以發散思考一下,如果續租的方法失敗怎麼辦?我們如何解決“為了保證高可用而使用的高可用方法的高可用問題”這種套娃問題?開源類庫Redisson使用了看門狗的方式一定程度上解決了鎖續租的問題,但是這裡,個人建議不要做鎖續租,更簡潔優雅的方式是延長過期時間,由於我們分佈式鎖鎖住代碼塊的最大執行時長是可控的(依賴於RPC、DB、中間件等調用都設定超時時間),因而我們可以把超時時間設得大於最大執行時長即可簡潔優雅地保障鎖過期的一致性

層次六

如果 Redis 發生主從切換,主從切換如果數據丟失,並且丟失的數據和分佈式鎖有關系,會導致鎖機制出現問題,引起業務異常。

Redis的主從同步(replication)是異步進行的,如果向master發送請求修改了數據後master突然出現異常,發生高可用切換,緩沖區的數據可能無法同步到新的master(原replica)上,導致數據不一致。如果丟失的數據跟分佈式鎖有關,則會導致鎖的機制出現問題,從而引起業務異常。針對這個問題介紹兩種解法:

  • 使用紅鎖(RedLock)紅鎖是Redis作者提出的一致性解決方案。紅鎖的本質是一個概率問題:如果一個主從架構的Redis在高可用切換期間丟失鎖的概率是k%,那麼相互獨立的N個Redis同時丟失鎖的概率是多少?如果用紅鎖來實現分佈式鎖,那麼丟鎖的概率是(k%)^N。鑒於Redis極高的穩定性,此時的概率已經完全能滿足產品的需求。
    • 紅鎖的問題在於:
    • 加鎖和解鎖的延遲較大。
    • 難以在集群版或者標準版(主從架構)的Redis實例中實現。
    • 占用的資源過多,為了實現紅鎖,需要創建多個互不相關的雲Redis實例或者自建Redis。
  • 使用WAIT命令。Redis的WAIT命令會阻塞當前客戶端,直到這條命令之前的所有寫入命令都成功從master同步到指定數量的replica,命令中可以設置單位為毫秒的等待超時時間。客戶端在加鎖後會等待數據成功同步到replica才繼續進行其它操作。執行WAIT命令後如果返回結果是1則表示同步成功,無需擔心數據不一致。相比紅鎖,這種實現方法極大地降低了成本。
    • 需要註意的是:
    • WAIT隻會阻塞發送它的客戶端,不影響其它客戶端。
    • WAIT返回正確的值表示設置的鎖成功同步到了replica,但如果在正常返回前發生高可用切換,數據還是可能丟失,此時WAIT隻能用來提示同步可能失敗,無法保證數據不丟失。您可以在WAIT返回異常值後重新加鎖或者進行數據校驗。
    • 解鎖不一定需要使用WAIT,因為鎖隻要存在就能保持互斥,延遲刪除不會導致邏輯問題。