循序漸進 Redis 分散式鎖(以及何時不用它)

林子er發表於2022-06-20

場景

假設我們有個批處理服務,實現邏輯大致是這樣的:

  1. 使用者在管理後臺向批處理服務投遞任務;
  2. 批處理服務將該任務寫入資料庫,立即返回;
  3. 批處理服務有啟動單獨執行緒定時從資料庫獲取一批未處理(或處理失敗)的任務,投遞到訊息佇列中;
  4. 批處理服務啟動多個消費執行緒監聽佇列,從佇列中拿到任務並處理;
  5. 消費執行緒處理完成(成功或者失敗)後修改資料庫中相應任務的狀態;

流程如圖:

image-20220617163115681

現在我們單獨看看上圖中虛線框中的內容(3~6):批處理服務從資料庫拉取任務列表投遞到訊息佇列。

生產環境中,為了高可用,都會部署至少兩臺批處理伺服器,也就是說至少有兩個程式在執行虛線框中的流程。

有什麼問題呢?

假設這兩個程式同時去查任務表(這是很有可能的),它倆很可能會得到同一批任務列表,於是這批任務都會入列兩次。

當然,這不是說一個任務入列兩次就一定會導致任務被重複執行——我們可以通過多引入一個狀態值來解決此問題。

消費者執行緒從佇列中獲取到任務後,再次用如下 SQL 更新任務狀態:

-- status:1-待處理;2-已入列;3-處理中;4-失敗待重試;5-徹底失敗(不可重試);

update tasks set status=3 where status=2 and id=$id;

由於 where 條件有 status=2,即只有原先狀態是“已入列”的才能變成“處理中”,如果多個執行緒同時拿到同一個任務,一定只有一個執行緒能執行成功上面的語句,進而繼續後續流程(其實這就是通過資料庫實現的簡單的分散式鎖——樂觀鎖)。

不過,當定時程式多了後,大量的重複資料仍然會帶來效能等其他問題,所以有必要解決重複入列的問題。

有個細節:請注意上圖中步驟 5、6,是先改資料庫狀態為“已入列”,再將訊息投遞到訊息佇列中——這和常規邏輯是反過來的。

能否顛倒 5 和 6 的順序,先入列,再改資料庫狀態呢?

不能。從邏輯上來說確實應該如此,但它會帶來問題。消費執行緒從佇列中拿到任務後,會執行如下 SQL 語句:

update tasks set status=3 where status=2 and id=$id;

這條 SQL 依賴於前面(第 5 步)產生的狀態值,所以它要求在執行該語句的時候,第 5 步的 SQL 語句(將狀態改為“已入列”)一定已經執行完了。如果將 5 和 6 顛倒(先入列,再改狀態值),就有可能出現下圖的執行順序,導致消費者執行緒修改狀態失敗,進而執行不下去:

image-20220617174423619

上圖中,任務入列後立即被消費執行緒獲取到並去修改資料庫,而此時定時執行緒的 SQL 可能還沒執行(可能網路延遲),這就出問題了。

定時執行緒先將狀態改為“已入列”帶來的問題是,如果改狀態後(入列前)程式掛了,會導致任務一直處於已入列狀態(但實際上未入列),所以還需要搭配其它的超時重試機制。

上圖虛線框中那段邏輯在併發原語中有個專門名稱叫“臨界區”——我們要做的就是讓多個操作者(程式、執行緒、協程)必須一個一個地(而不能一窩蜂地)去執行臨界區內部的邏輯,手段就是加鎖:

var lock = newLock()

// 加鎖
lock.lock()

// 執行臨界區的邏輯

// 釋放鎖
lock.unlock()

所謂鎖,就是多個參與者(程式、執行緒)爭搶同一個共享資源(術語叫“訊號量”),誰搶到了就有資格往下走,沒搶到的只能乖乖地等(或者放棄)。鎖的本質是兩點:

  1. 它是一種共享資源,對於多方參與者來說,只有一個,就好比籃球場上只有一個籃球,所有人都搶這一個球;
  2. 對該資源的操作(加鎖、解鎖)是原子性的。雖然大家一窩蜂都去搶一個球,但最終這個球只會屬於某一個人,不可能一半在張三手上,另一半在李四手上。只有搶到球的一方才可以執行後續流程(投籃),另一方只能繼續搶;

在單個程式中,以上兩點很容易實現:同一個程式中的執行緒之間天然是共享程式記憶體空間的;原子性也直接由 CPU 指令保證。所以單個程式中,我們直接用程式語言提供的鎖即可。

程式之間呢?

程式之間的記憶體空間是獨立的。兩個程式(可能在兩臺不同的物理機上)建立的鎖資源自然也是獨立的——這就好比兩個籃球場上的兩個籃球之間毫不相干。

那怎樣讓兩個籃球場上的兩隊人比賽呢?只能讓他們去同一個地方搶同一個球——這在程式設計中叫“分散式鎖”。

有很多實現分散式鎖的方案(關聯式資料庫、zookeeper、etcd、Redis 等),本篇單講用 Redis 來實現分散式鎖。


小試牛刀

之所以能用 Redis 實現分散式鎖,依賴於其兩個特性:

  1. Redis 作為獨立的儲存,其資料天然可以被多程式共享;
  2. Redis 的指令是單執行緒執行的,所以不會出現多個指令併發地讀寫同一塊資料;
  3. Redis 指令是純記憶體操作,速度是微妙級的(不考慮網路時延),效能足夠高;

有些人一想到“單執行緒-高效能”就條件反射地回答 IO 多路複用,其實 Redis 高效能最主要就是純記憶體操作。

Redis 分散式鎖的大體呼叫框架是這樣的:

image-20220618165057778

多個程式的多個執行緒爭搶同一把 Redis 鎖。

說到 Redis 分散式鎖,大部分人都會想到 setnx 指令:

// setnx 使用方式
SETNX key value

意思是:如果 key 不存在(Not eXists),則將 key 設定為 value 並返回 1,否則啥也不做並返回 0——也就是說, key 只會被設定一次,利用這個特性就可以實現鎖(如果返回 1 表示加鎖成功,0 則說明別人已經加鎖了,本次加鎖失敗)。

我們寫下虛擬碼:

// 獲取 redis client 單例
var redis = NewRedisClient(redisConf);

// 通過 SETNX 指令加鎖
func lock(string lockKey) bool {
    result = redis.setnx(lockKey, 1);
    return bool(result);
}

// 通過 DEL 指令解鎖
func unlock(string lockKey) {
    redis.del(lockKey);
}

上面的定時任務程式中這樣使用:

var lockKey = "batch:task:list"

// 上鎖
if (!lock(lockKey)) {
    // 獲取鎖失敗,直接返回
    return false;
}

try {
    // 查詢資料庫獲取待處理任務列表
    // 更新任務狀態
    // 入列
} finally {
    // 解鎖
    unlock(lockKey);	
}

很簡單!半小時搞定,上線!


第一次懵逼

上線沒跑幾天就出問題了:任務無緣無故地不執行了,訊息佇列中很長時間沒接收到訊息了。

分析了半天,我們發現 Redis 中一直存在 batch:task:list 這條記錄,沒人去刪除它!

盯著程式碼我們突然發現問題所在:這個 key 壓根沒有過期時間!也就是說,如果程式不 DEL 它就永遠存在。

估計某程式在執行 unlock 之前崩潰了(或者哪個愣頭青執行了 kill -9 ?),或者 unlock 時發生了網路問題,或者 Redis 當機了?總之 DEL 沒執行,於是這個鎖永遠得不到釋放!

好辦,加上過期時間唄:

...

// 通過 SETNX 指令加鎖
// 加上過期時間,單位毫秒
func lock(string lockKey, int ttl = 3000) bool {
    // 加鎖
    result = redis.setnx(lockKey, 1);
    // 設定過期時間(毫秒)
    redis.pexpire(lockKey, ttl);
	
    return bool(result);
}

...

這段程式碼有什麼問題呢?

這裡通過兩次網路請求執行了兩條 Redis 指令:setnx 設定 KV,expire 設定超時時間——我們前面說鎖操作必須具備原子性,但這兩條操作誰也不能保證要麼都成功要麼都失敗啊。假如第一條指令(setnx)執行成功了,但 expire 由於網路原因或者程式崩潰導致執行失敗了呢?此時同樣會出現上面那個懵逼的問題啊。

我們可以用 Lua 指令碼實現 setnx 和 expire 操作的原子性,不過 Redis 2.6.12 版本後可以用 SET 指令搞定:

// 2.6.12 後的 SET 指令格式
// 現在的 SET 指令相當強大也相當複雜,可以替代 SETNX, SETEX, PSETEX, GETSET, 此處只寫出跟分散式鎖有關的
// 其中兩個可選引數:
// -- NX 表示 Not eXists,就是 SETNX 的意思;
// -- PX 是 PEXPIRE 的意思,表示設定 key 的過期時間(毫秒);

SET key value [NX] [PX milliseconds]

改下 Lock 程式碼:

// 加鎖
func lock(string lockKey, int ttl = 3000) bool {
    // Set 函式引數對應上面的命令格式
    result = redis.set(lockKey, 1, "NX", "PX", ttl);
	
    return bool(result);
}

如此,加了過期時間防止鎖無法釋放,還保證了加鎖操作的原子性,妥了,上線!


第二次懵逼

第二次上線沒多久又出現了靈異事件:偶爾會出現一批任務重複入列——敢情這鎖加了個寂寞?

各種打日誌,終於發現了端倪:有個程式加鎖 3.5 秒後才解鎖,而且解鎖成功了——但我們設定的鎖超時時間是 3 秒啊!

也就是說,這個執行緒解的是別的執行緒的鎖!

// 通過 DEL 指令解鎖
// 這裡直接調 Redis 的 DEL 指令刪除 lockKey,並沒有判斷該 lockKey 的值是不是本程式設定的
// 所以在有 TTL 的情況下,刪的可能是別的執行緒加的鎖
func unlock(string lockKey) {
    redis.del(lockKey);
}

和程式內的本地鎖不同的是,Redis 分散式鎖加入超時機制後,鎖的釋放就存在兩種情況:

  1. 加鎖者主動釋放;
  2. 超時被動釋放;

所以解鎖(DEL)之前需要判斷鎖是不是自己加的,方法是在加鎖的時候生成一個唯一標識。之前我們 SET key value 時 value 給的是固定值 1,現在我們換成一個隨機值:

// Redis 分散式鎖
// 封裝成類
// 該類例項不具備執行緒安全性,不應跨執行緒使用
class Lock {
    private redis;
    private name;
    private token;
    private ttl;
    private status;
	
    const ST_UNLOCK = 1;
    const ST_LOCKED = 2;
    const ST_RELEASED = 3;
	
    public function Lock(Redis redis, string name, int ttl = 3000) {
        this.redis = redis;
	this.name = name;
	this.token = randStr(16);// 生成 16 位元組隨機字串
	this.ttl = ttl;
	this.status = self::ST_UNLOCK;
    }
	
    // 加鎖
    public function lock() bool {
	if (this.status != self::ST_UNLOCK) {
	    return false;
	}
		
	// 使用 SET 命令加鎖
	// value 不再傳 1,而是設定成建構函式中生成的隨機串
	try {
	    result = redis.set(this.name, this.token, "NX", "PX", this.ttl);
	    if (bool(result)) {
	        this.status = self::ST_LOCKED;
	        return true;
	    }
	} catch (Exception e) {
	    return false;
	}
	
	return false;
    }
	
    // 解鎖
    public function unlock() {
	if (this.status != self::ST_LOCKED) {
	    return;
	}
		
	// 執行 DEL 之前需要用 GET 命令判斷 KEY 的值是不是當前的 token
	// 由於需要執行 GET 和 DEL 兩條指令,而鎖操作必須保證原子性,需要用 Lua 指令碼
	// 指令碼中通過 redis.call() 執行 Redis 命令
	// 注意 Lua 指令碼陣列下標從 1 開始
	// 這段指令碼的意思是:
	// 如果 key 的值是 token,則 DEL key,否則啥也不做
	var lua = "
		if (redis.call('get', KEYS[1]) == ARGV[1]) then
                    redis.call('del', KEYS[1]);
                 end
                 return 1;
	";
		
	// 調 Redis 的 EVEL 指令執行 Lua 指令碼
	// EVAL 指令格式:
	// EVEL script numkeys key1,key2,arg1,arg2... 
	// -- script: Lua 指令碼
	// -- numkeys: 說明後面的引數中,有幾個是 key,這些 key 後面的都是引數
	// 比如:EVAL "redis.call('set', KEYS[1], ARGV[1])" 1 mykey hello
	// 等價於命令 SET mykey hello
	// 參見:https://redis.io/commands/eval/
	redis.eval(lua, 1, this.name, this.token);
	this.status = self::ST_RELEASED;
    }
}

業務呼叫:

lock = new Lock(redis, "batch:task:list");

try {
    if (!lock.lock()) {
	return false;
    }
	
    // 加鎖成功,執行業務
} finally {
    lock.unlock();
}

上面這段程式碼實現了:

  1. 加鎖的時候設定了過期時間,防止程式崩潰而導致鎖無法釋放;
  2. 解鎖的時候判斷了當前的鎖是不是自己加的,防止釋放別人的鎖;
  3. 加鎖和解鎖操作都具備原子性;

這段程式碼已經是生產可用了,第三次上線。

不過,還是有些優化需要做的。


優化一:鎖等待

上面的 lock() 方法中,如果獲取鎖失敗則直接返回 false,結束執行流,這可能不能滿足某些業務場景。

在本地鎖場景中,如果獲取鎖失敗,執行緒會進入阻塞等待狀態——我們希望分散式鎖也能提供該功能。

我們在加鎖失敗時增加重試功能:

class Lock {
    // 重試間隔:1 秒
    const RETRY_INTERVAL = 1000;
    // ...
    // 重試次數(包括首次)
    private retryNum;
	
    // retryNum: 預設只執行一次(不重試)
    public function Lock(Redis redis, string name, int ttl = 3000, int retryNum = 1) {
        ...
	// 做下防禦
	if (retryNum < 0 || retryNum > 20) {
	    retryNum = 1;
	}
	this.retryNum = retryNum;
    }
	
    // 加鎖
    public function lock() bool {
        if (this.status != self::ST_UNLOCK) {
	    return false;
	}
		
        // 使用 SET 命令加鎖
	// 加入重試機制
	for (i = 0; i < this.retryNum; i++) {
	    try {
                result = redis.set(this.name, this.token, "NX", "PX", this.ttl);
		if (bool(result)) {
		    // 加鎖成功,返回	
		    this.status = self::ST_LOCKED;
		    return true;
		}
	    } catch (Exception e) {
            }
			
	    // 加鎖失敗了,等待一定的時間後重試
	    // 當前執行緒/協程進入休眠
	    sleep(self::RETRY_INTERVAL);
	}

	return false;
    }
}

優化二:鎖超時

我們再回頭看看上面的加鎖邏輯,其核心程式碼如下:

public function lock() bool {
   // ...
    result = redis.set(this.name, this.token, "NX", "PX", this.ttl);
    if (bool(result)) {
        // 加鎖成功,返回	
        this.status = self::ST_LOCKED;
	return true;
    }
    //...
}

這段程式碼有沒有什麼問題呢?

想象如下的加鎖場景:

// 鎖超時時間是 2 秒
var lock = new Lock(redis, name, 2000);

if (lock.lock()) {
    // 加鎖成功,加鎖用時 2.5 秒
    try {
	// 執行業務邏輯
    } finally {
	// 解鎖
	lock.unlock();
    }
}

如上,我們建立一個有效期 2 秒的鎖,然後調 Redis 命令加鎖,該過程花了 2.5 秒(可能網路抖動)。

對於本執行緒來說,得到加鎖成功的返回值,繼續往下執行。

但此時該 lockKey 在 Redis 那邊可能已經過期了,如果此時另一個執行緒去拿鎖,也會成功拿到鎖——如此鎖的作用便失效了。

image-20220618150108303

所以,在 lock() 方法中,調 Redis 上鎖成功後,需要判斷上鎖用時,如果時間超過了鎖的有效期,則應視為上鎖無效,如果有重試機制,則重試:

class Lock {
    // 加鎖
    public function lock() bool {
        if (this.status != self::ST_UNLOCK) {
	    return false;
	}
		
	for (i = 0; i < this.retryNum; i++) {
	    try {
	        // 上鎖之前,儲存當前毫秒數
		var startTime = getMillisecond();
		// 上鎖
		result = redis.set(this.name, this.token, "NX", "PX", this.ttl);
		// 上鎖後,計算使用的時間
		var useTime = getMillisecond() - startTime;
				
		// 加鎖成功條件:Redis 上鎖成功,且所用的時間小於鎖有效期
		if (bool(result) && useTime < this.ttl) {
	            // 加鎖成功,返回	
		    this.status = self::ST_LOCKED;
		    return true;
		}
	    } catch (Exception e) {}
			
	    // 加鎖失敗了,等待一定的時間後重試
	    // 當前執行緒/協程進入休眠
	    sleep(self::RETRY_INTERVAL);
        }

	return false;
    }
}

如上,在判斷條件中增加了加鎖用時的判斷。

這段程式碼還有問題嗎?

有的。

我們用 Redis 的 SET NX 命令加鎖,該命令如果發現 key 已經存在,則直接返回 0,加鎖失敗。

在上面的失敗重試邏輯中,如果是因為加鎖用時超限導致的失敗(鎖有效期是 2 秒,結果加鎖操作用了 2.5 秒),此時我們並不能切確知道在 Redis 那邊該 key 是否真的已經失效了,如果沒有失效(比如來去網路用時各 1.24 秒,此時該 key 並沒有失效),那麼下一次的重試會因 SET NX 的機制而失敗。

所以我們不能用 SET NX 加鎖,只能用普通的 SET + Lua 指令碼來實現:

class Lock {
    // 加鎖
    public function lock() bool {
        if (this.status != self::ST_UNLOCK) {
	    return false;
	}
		
	// 加鎖的 Lua 指令碼
	// 注意 Lua 中的註釋不是用 // 或者 /**/,而是用 --
	// 引數說明:
	// KEYS[1]: lockKey
	// ARGV[1]: token
	// ARGV[2]: ttl 毫秒
	var lua = "
		local val = redis.call('get', KEYS[1]);
		if (not val) then
		    -- 沒有設定,則直接設定
		    return redis.call('set', KEYS[1], ARGV[1], 'PX', ARGV[2]);
		else
		    -- 存在,則比較 val 是否等於 token
		    if (val == ARGV[1] ) then
		        -- 該 key 就是當前執行緒設定的
			-- 延長其 TTL
			return redis.call('pexpire', KEYS[1], ARGV[2]);
		    else
		        -- 其他執行緒上的鎖
			return 0;
		    end
		end
	";
		
	for (i = 0; i < this.retryNum; i++) {
            // 加鎖邏輯同上
	}

	return false;
    }
}

如此,便解決了加鎖超時導致的競態問題——但只解決了一半。

設想這樣的場景:

程式 A 加了一個有效期 5 秒的鎖,加鎖成功後執行業務邏輯,業務邏輯執行耗時 10 秒——就是說,在業務邏輯執行到差不多一半的時候鎖就失效了,此時別的程式就可以搶到鎖了,這就會導致競態問題。

有兩種解決方案:

  1. 設定個較長的過期時間。這是最簡單的(而且也很有效)。比如我們預估 99% 的處理時間不超過 2 秒,則將鎖有效期設定為 10 秒。該方案最大的缺點是一旦程式崩潰導致無法主動釋放鎖,就會導致其他程式在很長一段時間內(如 10 秒)無法獲得鎖,這在某些場景下可能是非常嚴重的。
  2. 搞個定時任務執行緒,定時延長鎖的有效期。

方案二虛擬碼如下:

// 帶 Refresh 版本的分散式鎖
class Lock {
    private redis;
    private name;
    private ttl;
    private token;
    private retryNum;
    private status;
    // 定時器
    private timer;
	
    // 鎖狀態:1 未加鎖;2 已加鎖;3 已釋放
    const ST_UNLOCK = 1;
    const ST_LOCKED = 2;
    const ST_RELEASED = 3;
	
    // 重新整理狀態:
    //	4 重新整理成功;
    //	5 非法(key 不存在或者不是本執行緒加的鎖)
    //	6 重新整理失敗(Redis 不可用)
    const RF_SUC = 4;
    const RF_INVALID = 5;
    const RF_FAIL = 6;
	
    // 建構函式
    public function Lock(Redis redis, string name, int ttl = 2000, int retryNum = 1) {
        ...
    }
	
    // 加鎖
    // 加鎖成功後啟動定時器
    public function lock() bool {
        if (this.status != self::ST_UNLOCK) {
            return false;
	}
		
	// 加鎖的 Lua 指令碼,同前面的
	lua = "...";
		
	for (i = 0; i < this.retryNum; i++) {
	    var startTime = getMillisecond();
	    try {
	        // 執行 Lua 指令碼上鎖
		result = this.redis.eval(lua, 1, this.name, this.token, this.ttl);
		var useTime = getMillisecond() - startTime;
				
		if (bool(result) && useTime < this.ttl) {
		    // 加鎖成功
		    this.status = self::ST_LOCKED;
		    // 啟動定時器
		    this.tick();
					
		    return true;
		}
	    } catch (Exception e) {
		// Redis 不可用
	    }
			
	    // 失敗重試
	    sleep(RETRY_INTERVAL);
        }

	return false;
    }
	
    // 啟動定時器,定時重新整理過期時間
    private function tick() {
        this.timer = startTimerInterval(
	    this.ttl / 3,
	    function () {
	        result = this.refresh();
		if (result == self::RF_INVALID) {
		    // key 不存在,或者該鎖被其他執行緒佔用
		    // 停掉定時器
		    this.timer.stop();
		}
	    }
	);
    }
	
    // 釋放鎖
    // 需要停掉定時器
    public function unlock() {
        if (this.status != self::ST_LOCKED) {
	    return;
	}
		
        // 釋放鎖的 Lua 指令碼,同前
	var lua = "...";
		
	try {
	    this.redis.eval(lua, 1, this.name, this.token);
        } catch (Exception e) {} finally {
	    this.status = self::ST_RELEASED;
	    // 停掉定時器
	    this.timer.stop();
	}
    }
	
    // 重新整理鎖過期時間
    private function refresh() int {
        if (this.status != self::ST_LOCKED) {
	    return self::RF_INVALID;
	}
		
	var lua = "
	    -- key 存在而且其值等於 token 才重新整理過期時間
	    if (redis.call("get", KEYS[1]) == ARGV[1]) then
	        return redis.call("pexpire", KEYS[1], ARGV[2])
	    else
	        return 0
	    end
	";
		
        try {
	    result = this.redis.eval(lua, 1, this.name, this.token, this.ttl);
	    if (result == 0) {
	        // key 不存在或者是別人加的鎖
		return self::RF_INVALID;
	    } else {
	        // 重新整理成功
		return self::RF_SUC;
	    }
	} catch (Exception e) {
	    // Redis 不可用
	    return self::RF_FAIL;
	}
    }
}

如上,加鎖成功後建立一個單獨的定時器(獨立的執行緒/協程)重新整理鎖的 TTL,只要鎖沒被主動釋放(而且程式沒有崩潰),就會不停地續命,保證不會過期。此時,我們就能在加鎖時選擇一個比較小的過期時間(比如 2 秒),一旦程式崩潰,其他程式也能較快獲得鎖。

上面定時器時間為何選擇 ttl/3 呢?

假設鎖過期時間(ttl)為 6 秒,由上面 lock() 函式邏輯可知,加鎖耗時不可能超過 6 秒(超過就會判定為加鎖失敗)。我們假設某次加鎖耗時比 6 秒小那麼一丟丟(也就是近似 6 秒),接下來什麼時候發起第一次重新整理才能保證 Redis 那邊的 key 不過期呢?極端情況下必須立即重新整理(如果考慮重新整理時的網路時延,就算立即重新整理也不一定能保證)。

不過我們考慮的是一般情況。我們可以認為 6 秒耗時都花在網路上(Redis 本身執行時間可以忽略不計),然後再近似認為這 6 秒被來去均攤,各花 3 秒,因而當我們接收到 Redis 的響應時,該 key 在 Redis 那邊的 TTL 已經用掉了一半,所以定時間隔必須小於 ttl/2,再將重新整理時的網路時延考慮進去,取 ttl/3 或者 ttl/4 比較合適。

就算有了 refresh 機制,也不能說是萬無一失了。

考慮 Redis 當機或者網路不通的情況。

假設執行緒 A 加鎖(ttl=2s)後不久 Redis 就當機了(或者該業務服所在網路發生分割槽導致網路不通),當機期間 refresh 會失敗。2s 後 Redis 重啟恢復正常,此時執行緒 A 設定的那個 key 已經過期了,其他執行緒就能夠獲取鎖,如果執行緒 A 的執行時間超過 2s,就和其他執行緒產生競態。

refresh 機制解決不了該問題,要用其他手段來保證 Redis 和鎖的高可用性,如 Redis 叢集、官方提供的 Redlock 方案等。


可重入性

一些語言(如 java)內建可重入鎖,一些語言(如 go)則不支援。

我們通過程式碼說下可重入鎖是什麼:

var lock = newLock();

// 在同一個執行緒中, foo() 調 bar()
// 函式 foo() 和 bar() 都在競爭同一把鎖

function foo() {
    lock.lock();
    ...
    bar();
    ...
    lock.unlock();
}

function bar() {
    lock.lock();
    // do something
    lock.unlock();
}

如上,同一個執行緒中 foo() 調 bar(),由於 foo() 調 bar() 之前加了鎖,因而 bar() 中再競爭該鎖時就會一直等待,導致 bar() 函式執行不下去,進而導致 foo() 函式無法解鎖,於是造成死鎖。

如果上面的 lock 是一把可重入鎖,bar() 就會加鎖成功。

實現原理是:加鎖的 lock() 方法中會判斷當前這把鎖被哪個執行緒持有,如果持有鎖的執行緒和現在搶鎖的執行緒是同一個執行緒,則視為搶鎖成功(這鎖本來就是被它持有的嘛,搶啥呢)。

由於 foo() 和 bar() 是在同一個執行緒中呼叫的,所以他倆都會加鎖成功。

鎖是加成功了,解鎖呢?bar() 中的 unlock() 要怎麼處理呢?直接把鎖釋放掉?不行啊,foo() 中的 unlock() 還沒執行呢,bar() 雖然用完鎖了,但 foo() 還沒用完啊,你 bar() 三下五除二把鎖給釋放了,其他執行緒拿到鎖,不就和 foo() 中程式碼構成競態了嗎?

所以可重入鎖採用訊號量的思想,在內部維持了兩個屬性:threadid 表示哪個執行緒持有鎖;lockNum 表示持有執行緒加了幾次鎖。同一個執行緒,每 lock() 一次 lockNum 加 1,每 unlock() 一次 lockNum 減 1,只有 lockNum 變成 0 了才表示這把鎖真正釋放了,其他執行緒才能用。

原理講完了,但你不覺得上面的程式碼很怪嗎?

既然 foo() 已經加鎖了,bar() 為何還要加同一把鎖呢?

在某些情況下這樣做可能是有原因的,但大多數情況下,這個問題可以從設計上解決,而不是非要引入可重入鎖。

比如我們可以將 bar() 宣告為非執行緒安全的,將加鎖工作交給呼叫者,同時限制 bar() 的可見域,防止其被濫用。

go 語言不支援可重入鎖的理由就是:當你的程式碼需要用可重入鎖了,你首先要做的是審視你的設計是否有問題。

可重入鎖的便捷性可能會帶來程式碼設計上的問題。

所以本篇並不打算去實現可重入能力——雖然實現起來並不難,無非是將上面講的原理在 Redis 上用 Lua 指令碼實現一遍而已。


不是銀彈

有了錘子,全世界都是釘子。

分散式鎖看似是顆銀彈,但有些問題用其他方案會比分散式鎖要好。

我們看看秒殺扣庫存的例子。

網上很多講分散式鎖的文章都拿秒殺扣庫存來舉例。

秒殺場景為了應對高併發,一般會將秒殺商品庫存提前寫入到 Redis 中,我們假設就用字串型別存商品庫存:

// Redis 命令,設定商品 id=1234 的庫存 100 件
set seckill.stock.1234 100

另外一個使用者只能參加一次秒殺,所以扣庫存前需要判斷該使用者是否已經參加了(防止羊毛黨薅羊毛)。

扣庫存邏輯是這樣的:

var stockKey = "seckill.stock.1234";
var userKey = "seckill.ordered.users";
var lock = new Lock(redis, "seckill");

// 此處省略活動時間的判斷

try {
    // 加分散式鎖
    lock.lock();
	
    // 判斷庫存
    var stockNum = redis.get(stockKey);
    if (stockNum <= 0) {
        // 庫存不足
        return false;
    }
	
    // 判斷使用者是否已經參加過
    if (redis.sismember(userKey, userId)) {
        return false;
    }
	
    // 扣庫存
    if (redis.decr(stockKey) >= 0) {
        // 下單
	...
    } else {
        return false;
    }
	
    // 將使用者加入到已參加集合中
    redis.sadd(userKey, userId);
    return true;
} catch (Exception e) {
    // 異常
} finally {
    // 解鎖
    lock.unlock();
}

以上邏輯為何要用分散式鎖呢?

假設不用分散式鎖,羊毛黨同時發了十個請求(同一個使用者),由於 redis.sismember(userKey, userId) 判斷都會返回 0,於是都能扣庫存下單,羊毛薅了一地。

但該場景有沒有更優的解決方案呢?

我們使用分散式鎖是為了保證臨界區程式碼(lock 保護的區域)執行的原子性——不過 Redis 的原子性還可以通過 Lua 指令碼來實現吧。

上面程式碼一共進行了 6 次 Redis 互動,假設每次用時 50ms,光 Redis 互動這塊就用了 0.3s 的時間。

如果我們將這些邏輯封裝成 Lua 指令碼,只需要一次 Redis 互動就能保證原子性:

var lua = "
    -- 引數說明:
    --	KEYS[1]: actKey
    --	KEYS[2]: userKey
    --	KEYS[3]: stockKey
    --	ARGV[1]: userId

    -- 判斷活動時間
    -- (事先將活動的關鍵資訊儲存到 Redis hash 中)
    -- 取活動的開始和結束時間
    local act = redis.call('hmget', KEYS[1], 'start', 'end');
    local now = redis.call('time')[1];
    if (not act[1] or now < act[1] or now >= act[2])
    then
        return 0;
    end
	
    -- 判斷庫存
    local stock = redis.call('get', KEYS[3]);
    if (not stock or tonumber(stock) <= 0)
    then
        return 0;
    end
	
    -- 判斷使用者是否已經參與過
    if (redis.call('sismember', KEYS[2], ARGV[1]) == 1)
    then
        return 0;
    end
	
    -- 扣庫存
    if (redis.call('decr', KEYS[3]) >= 0)
    then
        -- 加入使用者
        return redis.call('sadd', KEYS[2], ARGV[1]);
    else
        return 0;
    end
";

var actKey = "seckill.act."+actId;
var userKey = actKey + ".users";
var stockKey = actKey + ".stock." + goodsId;

if (redis.eval(lua, 3, actKey, userKey, stockKey, userId)) {
    // 扣庫存成功,下單
    ...
}

上面的指令碼還可以先快取到 Redis 伺服器中,然後用 evalsha 命令執行,這樣客戶端就不用每次都傳這麼一大坨程式碼,進一步提升傳輸效能。


總結

本篇我們從 setnx 命令開始實現了一個最簡單的分散式鎖,而後通過實際使用發現其存在各種缺陷並逐步增強其實現,主要涉及到以下幾個方面:

  1. 被動釋放。程式崩潰後,程式本地鎖自然會銷燬,但 Redis 鎖不會。所以要加 TTL 機制,防止因加鎖者崩潰而導致鎖無法釋放;
  2. 屬主。執行緒不能釋放別的執行緒的鎖;
  3. 鎖等待。加鎖失敗時可以等待一段時間並重試,而不是立即返回;
  4. 保活。通過定時重新整理鎖的 TTL 防止被動釋放;

不難發現,分散式鎖比程式內本地鎖要複雜得多,也重得多(本地鎖操作是納秒級別,分散式鎖操作是毫秒級別),現實中,在使用分散式鎖之前我們要思考下有沒有其它更優方案,比如樂觀鎖、Lua 指令碼等。

另外需要注意的是,分散式鎖只能解決多程式之間的併發問題,並不能實現資料操作的冪等性。一個例子是增減積分的操作。

增加積分的例子:

// 給使用者增加積分
// sourceType、sourceId:積分來源標識,如消費贈送積分場景的 orderCode
// 冪等性:同樣的 userId-sourceType-sourceId 不能重複加積分
function addBonus(userId, sourceType, sourceId, bonus) {
    // 加分散式鎖
    var lock = new Lock(...);
	
    try {
        if (!lock.lock()) {
            return false;
        }
		
        // 檢查是否重複
        if (isRepeat(userId, sourceType, sourceId)) {
            return false;
        }
		
        // 加積分
        add(userId, sourceType, sourceId, bonus);
    } finally {
        lock.unlock();
    }
}

上面分散式鎖的作用是防止併發請求(呼叫端 bug?薅羊毛?),而該操作的冪等性是由 isRepeat() 保證的(查資料庫)。

保障冪等性一般有悲觀鎖和樂觀鎖兩種模式。

上面這種屬於悲觀鎖模式(把整個操作鎖起來),另一種樂觀鎖實現方式是給 userId-sourceType-sourceId 加上組合唯一鍵約束,此時就不需要加分散式鎖,也不需要 isRepeat() 檢測,直接 add(userId, sourceType, sourceId, bonus) 就能搞定。

最後說下文中為啥使用虛擬碼(而不是用具體某一門程式語言實現)。

用虛擬碼的最主要目的是省去語言特定的實現細節,將關注點放在邏輯本身。

比如 redis 客戶端,不同語言有不同的使用方式,就算同一門語言的不同類庫用法也不同,有些語言的類庫用起來又臭又長,影響心情。

虛擬碼不受特定語言約束,用起來自由自在,本文中 redis 客戶端的使用方式和 Redis 官方的原始命令格式完全一致,沒有額外的心智負擔。

再比如生成 token 的隨機字串函式 randStr(),go 語言要這樣寫:

func randStr(size int) (string, error) {
    sl := make([]byte, size)
    if _, err := io.ReadFull(rand.Reader, sl); err != nil {
        return "", err
    }
    return base64.RawURLEncoding.EncodeToString(sl), nil
}

程式碼雖然不多,但沒玩過 go 的小夥伴看到這兒心裡是不是要起伏那麼兩三下?但這玩意怎麼實現跟本文的主題沒半毛錢關係。

相反,本文的 lua 指令碼都是貨真價實的,測試通過的——因為這是本文的核心啊。

虛擬碼的缺點是它不能“拎包入住”,但本文的重點並不是要寫個原始碼庫——我們沒必要真的自己寫一個,直接用 redission 或者其他什麼庫不香嗎?

本文的重點在於分析 Redis 分散式鎖的原理,分散式鎖面臨哪些問題?解決思路是什麼?使用時要注意什麼?知其然知其所以然。

當你不知其所以然時,很多東西顯得特高大上,什麼“看門狗”,搞得神乎其神,當搞明白其原理和目的時,也就那麼回事。




相關文章