對應給定的keys到他們相應的values上。只要有一個key已經存在,MSETNX一個操作都不會執行。由於這種特性,MSETNX可以實現要麼所有的操作都成功,要麼一個都不執行,這樣可以用來設定不同的key,來表示一個唯一的對象的不同欄位。
在 Redis 裡,所謂 SETNX,是「SET if Not eXists」的縮寫,也就是只有不存在的時候才設定,可以利用它來實現鎖的效果,不過很多人沒有意識到 SETNX 有陷阱。
比如說:某個查詢資料庫的介面,因為調用量比較大,所以加了緩衝,並設定緩衝到期後重新整理,問題是當並發量比較大的時候,如果沒有鎖機制,那麼緩衝到期的瞬間,大量並發請求會穿透緩衝直接查詢資料庫,造成雪崩效應,如果有鎖機制,那麼就可以控制只有一個請求去更新緩衝,其它的請求視情況要麼等待,要麼使用到期的緩衝。
下面以目前 PHP 社區裡最流行的 PHPRedis 擴充為例,實現一段示範代碼:
<?php
$ok = $redis->setNX($key, $value);
if ($ok) {
$cache->update();
$redis->del($key);
}
?>
緩衝到期時,通過 SetNX 擷取鎖,如果成功了,那麼更新緩衝,然後刪除鎖。看上去邏輯非常簡單,可惜有問題:如果請求執行因為某些原因意外退出了,導致建立了鎖但是沒有刪除鎖,那麼這個鎖將一直存在,以至於以後緩衝再也得不到更新。於是乎我們需要給鎖加一個到期時間以防不測:
<?php
$redis->multi();
$redis->setNX($key, $value);
$redis->expire($key, $ttl);
$redis->exec();
?>
因為 SetNX 不具備設定到期時間的功能,所以我們需要藉助 Expire 來設定,同時我們需要把兩者用 Multi/Exec 包裹起來以確保請求的原子性,以免 SetNX 成功了 Expire 卻失敗了。 可惜還有問題:當多個請求到達時,雖然只有一個請求的 SetNX 可以成功,但是任何一個請求的 Expire 卻都可以成功,如此就意味著即便擷取不到鎖,也可以重新整理到期時間,如果請求比較密集的話,那麼到期時間會一直被重新整理,導致鎖一直有效。於是乎我們需要在保證原子性的同時,有條件的執行 Expire,接著便有了如下 Lua 代碼:
local key = KEYS[1]
local value = KEYS[2]
local ttl = KEYS[3]
local ok = redis.call('setnx', key, value)
if ok == 1 then
redis.call('expire', key, ttl)
end
return ok
沒想到實現一個看起來很簡單的功能還要用到 Lua 指令碼,著實有些麻煩。其實 Redis 已經考慮到了大家的疾苦,從 2.6.12 起,SET 涵蓋了 SETEX 的功能,並且 SET 本身已經包含了設定到期時間的功能,也就是說,我們前面需要的功能只用 SET 就可以實現。
<?php
$ok = $redis->set($key, $value, array('nx', 'ex' => $ttl));//注意這裡控制了不存在才設定 以及逾時時間
if ($ok) {
$cache->update();
$redis->del($key);
}
?>
如上代碼是完美的嗎。答案是還差一點。設想一下,如果一個請求更新緩衝的時間比較長,甚至比鎖的有效期間還要長,導致在緩衝更新過程中,鎖就失效了,此時另一個請求會擷取鎖,但前一個請求在緩衝更新完畢的時候,如果不加以判斷直接刪除鎖,就會出現誤刪除其它請求建立的鎖的情況,所以我們在建立鎖的時候需要引入一個隨機值:
<?php
$ok = $redis->set($key, $random, array('nx', 'ex' => $ttl));
if ($ok) {
$cache->update();
if ($redis->get($key) == $random) {
$redis->del($key);
}
}
?>
如此基本實現了單機鎖,假如要實現分布鎖,請參考:Distributed locks with Redis,這裡就不深入討論了,總結:避免掉入 SETNX 陷阱的最好方法就是永遠不要使用它
所謂並發控制,就是指系統必須能夠對並行作業之間的相互作用加以控制,正確協調並行作業的執行以獲得正確的結果。若操所是串列執行的,那麼肯定不會發生並發執行的衝突,因此,並發控制機制的本質就是讓衝突的並行作業在某個地方得到序列化。
退款系統採用無管理節點的對等叢集結構,統一對外提供退款業務,所有節點的作用是一樣的,均可獨立完成單次退款請求,因此,無法通過管理節點來執行統一調度,實現並發控制,只能通過使用協議或規則來保證序列化,即所謂的並發控制規則,如果這個規則被每個並行作業所遵守,那麼就將確保所有參與的並行作業是序列化的。 2.1 分布式鎖服務
談到並發控制,首先想到的當然是鎖服務。退款系統是一個叢集系統,面臨多個節點間的鎖問題,因此,需要的是一種可供叢集不同節點使用的鎖服務,即分布式鎖服務。
另外,目前退款系統中對於單次退款請求是同步處理,若無法擷取鎖需立即返回,即只能使用非阻塞模式鎖(non blocking lock),以免較長時間等待導致退款調用逾時,通過退款補流程指令碼來非同步完成後續工作。 2.1.1 用Redis實現分布式鎖服務
Redis是一個開源的使用C語言編寫、支援網路、可基於記憶體亦可持久化的日誌型、Key-Value資料庫。它本身沒有鎖的概念,利用其單進程單線程結構,採用隊列模式將並發訪問變為串列訪問,從而實現分布式鎖服務。 2.1.1.1 實現原理
Redis的SETNX命令(SET if Not eXists)可以用作加鎖原語(locking primitive)。SETNX key value命令,將key的值設為value ,若且唯若key不存在;若給定的key已經存在,則SETNX不做任何動作,成功返回1,失敗返回0。
比如說,要對某資源(key)Bank_Id_4001加鎖,退款節點可以嘗試以下方式:
SETNX Bank_Id_4001 <current Unix time + lock timeout + 1>
如果SETNX返回1,說明該退款節點獲得鎖,key設定的時間戳記則指定了鎖失效的時間,即逾時時間,之後可以通過DEL Bank_Id_4001來釋放鎖。
如果SETNX返回0,說明key已經被其他節點上鎖了。 2.1.1.2 處理死結(deadlock)
上面的鎖定邏輯面臨一個問題:如果一個持有鎖的退款節點失敗、崩潰或者執行逾時了不能釋放鎖,該怎麼解決。
可以通過key對應的逾時時間戳記來判斷這種情況是否發生了,如果目前時間戳已經大於key值的逾時時間戳記,說明該鎖已失效,可以被重新使用。
但是,發生這種情況時,不能簡單粗暴地DEL死結的key,再用SETNX上鎖,因為當有多個節點同時檢測一個鎖是否到期並嘗試釋放它的時候,競爭條件(race condition)已經形成了:
1. 請求0持有鎖,但崩潰了。
2. 請求1和請求2發送GET key獲得時間戳記,檢查發現已逾時。
3. 請求1發送DEL key。
4. 請求1發送SETNX key並成功,請求1獲得鎖。
5. 請求2發送DEL key。
6. 請求2發送SETNX key並成功,請求2獲得鎖。
因為競爭條件的關係,請求1 和 請求2 兩個節點都獲得了鎖。對於該問題,可通過以下操作避免:
1. 請求1發送SETNX key想要獲得鎖,由於請求0還持有鎖,所以Redis返回0
2. 請求1發送GET key以檢查鎖是否逾時了,如果沒逾時,則返回調用。
3. 反之,如果已逾時,請求1繼續通過下面的操作來嘗試獲得鎖:
GETSET key <current Unix time + lock timeout + 1>,該命令將給定key的值設為value ,並返回key的舊值(old value);當 key沒有舊值時,也即是,key不存在時,返回nil
4. 通過GETSET,請求1拿到的時間戳記如果仍然是逾時的,那就說明,請求1如願以償拿到鎖了。
5. 如果在請求1之前,有其他請求比請求1快一步執行了上面的操作,那麼請求1拿到的時間戳記是未到期的,這時,請求1沒有如期獲得鎖。注意,儘管請求1沒拿到鎖,但它改寫了其他請求設定的key的逾時時間戳記,不過這一點非常微小的誤差帶來的影響可以忽略不計。
注意:為了讓分布式鎖的演算法更穩鍵些,持有鎖的節點在解鎖之前應該再檢查一次自己的鎖是否已經逾時,再去做DEL操作,因為可能節點因為某個耗時的操作而掛起,操作完的時候鎖因為逾時已經被別人獲得,這時就不必解鎖了。 2.1.1.3 優劣勢
優勢:實現簡單,直接部署Redis即可使用;橫向擴充容易,可任意增加資源key,只需在緩衝中儲存該Key-Value對。
劣勢:Redis只能單機部署,出現故障將導致整個鎖服務不可用。如果部署成對等叢集結構,還需要另外的機制來保證Redis叢集的資料一致性,增大了實現難度。 2.1.1 用zookeeper節點名稱的唯一性實現分布式共用鎖定
zookeeper是一個基於google chubby原理開發的,針對大型分布式系統的可靠協調系統,提供的功能包括:配置維護、名字服務、分布式同步、組服務等 2.1.1.1 實現原理
ZooKeeper抽象出來的節點結構是一個和unix檔案系統類似的小型的樹狀的目錄結構,並規定:同一個目錄下只能有一個唯一的檔案節點名。例如:兩個用戶端想要在Zookeeper的/lock目錄下建立一個key為Bank_Id_4001的節點,只有一個能夠成功。
Zookeeper中還有一種特殊節點:臨時節點,由某個用戶端建立,當用戶端與ZooKeeper叢集中斷連線,則該臨時節點自動被刪除。
利用Zookeeper的名稱唯一性和臨時節點這兩個特性,即可實現分布式鎖服務:
1. 用戶端在某個lock目錄下建立key子節點,類型為EPHEMERAL(臨時節點)。
2. 若建立成功,則表示獲得鎖,處理完成之後刪除該節點即可解鎖。
3. 若建立失敗,表示已被其他請求擷取,則返回調用。 2.1.1.2 優劣勢
優勢:實現簡單,部署即可使用,其正確性和可靠性由ZooKeeper機制保證;無Redis的單點問題;當獲得鎖的退款節點崩潰或宕機時,不會產生死結問題,臨時節點會被自動刪除。橫向擴充容易,根據不同key建立不同子節點即可。
劣勢:需要增加額外的服務叢集,增加了實現成本;無逾時機制,擷取鎖的退款節點不能長時間持有鎖,否則會導致其他需要使用該資源的請求被延遲。 2.1.2 利用資料庫實現共用鎖定 2.1.2.1 實現原理
Mysql資料庫的InnoDB引擎帶有行鎖功能,可利用該功能實現分布式鎖服務: 首先,在資料庫中建立一張擁有鎖標識的表,建立表的SQL語句如下:
CREATE TABLE MBS_LOCKS
(
LOCK_KEY VARCHAR2(40) NOT NULL,
IMARY KEY (LOCK_KEY)
) 表建立好之後,對於比較固定的通道ID和商戶ID key,可以在使用之前插入一些記錄;對於交易單key,無法在退款之前準確獲知,只能在退款請求執行中插入記錄。注意,錢包系統保證同類型的key不會出現重複問題,但為了防止不同類型的key重複,可以在key前加上一些特殊類型字串,比如trans_id,band_id等等 當節點需要擷取鎖時,先去該表中查詢操作相關key對應的鎖,執行查詢的SQL形如
select * from MBS_LOCKS where t.lock_key='TRANS_ID_XXX' for update; 若執行成功,則獲得鎖;若執行失敗,則表示鎖已被擷取,返回調用。 2.1.2.2 優劣勢
優勢:簡單方便,可建立在已有系統之上,無需添加額外的系統實現,對於退款系統的改動少,實現成本低;橫向擴充容易,當增加key時,只需要在資料庫中增加一條記錄即可。
劣勢:對於資料庫的訪問較大,特別是根據交易單來進行並發控制時,幾乎每一筆交易的部分退款都需要去訪問資料庫。由於線上資料庫不支援刪除操作,因此,需要定期對已經退款的交易單key進行刪除,因為退款完成之後,對應的交易單key就不會被再次使用。 2.2 分布式隊列
既然並發控制的本質是序列化並發衝突操作,那麼先進先出隊列當然也是一個不錯的選擇,與鎖服務類似,退款系統依然需要的是可在叢集之間使用的分布式隊列 2.2.1 用zookeeper順序節點實現分布式隊列 2.2.2 實現原理
Zookeeper中有一種節點叫做順序節點,故名思議,假如在/queue_fifo/key目錄下建立3個子節點,ZooKeeper叢集會按照提起建立的順序來建立節點,節點分別為/queue_fifo/key/0000000001、/queue_fifo/key/0000000002、/queue_fifo/key/0000000003。
利用該順序節點的特性,即可實現分布式隊列: 退款節點接收進程擷取到退款請求之後,在對應隊列目錄下建立子節點,類型SEQUENTIAL(順序節點),以保證所有成員排入佇列時都是有編號的。 退款節點消費進程擷取各隊列目錄下的所有子節點元素。若存在子節點,則消費節點序號最小的請求並刪除該子節點,以保證FIFO;若不存在,則等待。
注意:退款叢集節點共同消費分布式隊列,要做到全域的間隔發送,需要為每個請求子節點增加一個時間欄位,用於標示前一個請求的執行時間,每當從隊列中取出一條請求,就將目前時間記錄到後一個請求的時間欄位中,即可實現並發衝突操作的序列化。 2.2.2.1 優劣勢
優點:退款系統除了並發控制之外,還需要實現非同步化的處理。單次同步退款請求執行過程中涉及的RPC調用太多,導致耗時較長,需要在執行到某個階段之後,該同步請求就立刻返回,由退款系統內部來保證非同步完成後續的工作。因此,使用分布式隊列的方式,即可實現非同步化處理,也可實現並發控制。對於無需並發控制的退款請求,添加到普通隊列中;需要並發控制的請求,則添加到間隔發送隊列中。
缺點:需要添加額外的服務叢集,增加了實現成本;不易橫向擴充,增加資源key時,需要建立一個新的隊列目錄。 三 後記
目前退款業務中的並發控制仍然處於調研階段,暫無定論。上面提到的四種並發控制機制各有優劣,需綜合考慮各種因素才能制定具體的實現方案。下表僅從部署難易程度、成本、可靠性等佔比較重因素對四種方案進行了比較:
| 並發控制實現機制 |
部署難易程度 |
部署成本 |
是否易於橫向擴充 |
可靠性 |
對現有系統影響程度 |
| Redis分布式鎖服務 |
較小 |
較小 |
易 |
較可靠 |
小 |
| Zookeeper分布式鎖服務 |
較大 |
較大 |
較易 |
可靠 |
小 |
| 資料庫分布式鎖服務 |
小 |
小 |
易 |
可靠 |
較小 |
| Zookeeper分布式佇列服務 |
較大 |
較大 |
較難 |
可靠 |
大(同時滿足了非同步化需求) |