冪等、分散式鎖
前臺操作去抖動和防快速操作的措施,我們首先會想到在前端做一層控制。當前端觸發操作時,或彈出確認介面,或disable入口並倒計時等等。
但前端的限制僅能解決少部分問題,且不夠徹底,後端自有的防重複處理措施必不可少。
查詢類的介面幾乎總是冪等的,但在包含諸如資料插入,多模組資料更新時,達到冪等性會比較難,尤其是高併發時的冪等性要求。比如第三方支付前臺回撥和後臺回撥,第三方支付批量回調,慢效能業務邏輯(如使用者提交退款申請,商家同意退貨/退款等)或慢網路環境時,是重複處理的高發場景。
一、冪等性
冪等(idempotent、idempotence)是一個數學與計算機學概念,常見於抽象代數中。
在程式設計中.一個冪等操作的特點是其任意多次執行所產生的影響均與一次執行的影響相同。冪等函式,或冪等方法,是指可以使用相同引數重複執行,並能獲得相同結果的函式。這些函式不會影響系統狀態,也不用擔心重複執行會對系統造成改變。
更復雜的操作冪等保證是利用唯一交易號(流水號)實現.
冪等就是一個操作,不論執行多少次,產生的效果和返回的結果都是一樣的.
1.1 防範POST重複提交
HTTP POST 操作既不是安全的,也不是冪等的(至少在HTTP規範裡沒有保證)。 當我們因為反覆重新整理瀏覽器導致多次提交表單,多次發出同樣的POST請求,導致遠端伺服器重複創建出了資源。
所以,對於電商應用來說,第一對應的後端 WebService 一定要做到冪等性,第二伺服器端收到 POST 請求,在操作成功後必須302跳轉到另外一個頁面,這樣即使使用者重新整理頁面,也不會重複提交表單。
1.2 介面api的冪等性支援
對外提供介面為了支援冪等呼叫,介面有兩個欄位必須傳,一個是來源source,一個是來源方序列號seq,這個兩個欄位在提供方系統裡面做聯合唯一索引,這樣當第三方呼叫時,先在本方系統裡面查詢一下,是否已經處理過,返回相應處理結果;沒有處理過,進行相應處理,返回結果。注意,為了冪等友好,一定要先查詢一下,是否處理過該筆業務,不查詢直接插入業務系統,會報錯,但實際已經處理了。
1.3 冪等的技術方案
1.3.1 唯一索引,防止新增髒資料
唯一索引或唯一組合索引來防止新增資料存在髒資料
(當表存在唯一索引,併發時新增報錯時,再查詢一次就可以了,資料應該已經存在了,返回結果即可)
1.3.2 token機制,防止頁面重複提交
- 資料提交前要向服務的申請token,token放到redis或jvm記憶體,token有效時間
- 提交後後臺校驗token,同時刪除token,生成新的token返回
redis要用刪除操作來判斷token,刪除成功代表token校驗通過,如果用select+delete來校驗token,存在併發問題,不建議使用 。
1.3.3 使用唯一id解決重複提交問題(類似redis的刪除token判斷)
使用類似樂觀鎖的version機制實現;
分散式鎖(redis的setnx);
2.1.2 使用唯一id解決重複交易的冪等性問題(類似redis存token)
基於冪等性的解決方案中一個完整的取錢流程被分解成了兩個步驟:
1.呼叫create_ticket()獲取ticket_id;
2.呼叫 idempotent_withdraw(ticket_id, account_id, amount)。
雖然create_ticket不是冪等的,但在這種設計下,它對系統狀態的影響可以忽略,加上idempotent_withdraw 是冪等的,所以任何一步由於網路等原因失敗或超時,客戶端都可以重試,直到獲得結果。
1.3.4 悲觀鎖
獲取資料的時候加鎖獲取
select * from table_xxx where id=’xxx’ for update;
注意:id欄位一定是主鍵或者唯一索引,不然是鎖表,會死人的
悲觀鎖使用時一般伴隨事務一起使用,資料鎖定時間可能會很長,根據實際情況選用
1.3.5 樂觀鎖
樂觀鎖只是在更新資料那一刻鎖表,其他時間不鎖表,所以相對於悲觀鎖,效率更高。
樂觀鎖的實現方式多種多樣可以通過version或者其他狀態條件:
1. 通過版本號實現
update table_xxx set name=#name#,version=version+1 where version=#version#
- 通過條件限制
update table_xxx set avai_amount=avai_amount-#subAmount# where avai_amount-#subAmount# >= 0
要求:quality-#subQuality# >= ,這個情景適合不用版本號,只更新是做資料安全校驗,適合庫存模型,扣份額和回滾份額,效能更高。
注意:樂觀鎖的更新操作,最好用主鍵或者唯一索引來更新,這樣是行鎖,否則更新時會鎖表,上面兩個sql改成下面的兩個更好
update table_xxx set name=#name#,version=version+1 where id=#id# and version=#version#
update table_xxx set avai_amount=avai_amount-#subAmount# where id=#id# and avai_amount-#subAmount# >= 0
1.3.6 分散式鎖
如果是分佈是系統,構建全域性唯一索引比較困難,例如唯一性的欄位沒法確定,這時候可以引入分散式鎖,通過第三方的系統(redis或zookeeper),在業務系統插入資料或者更新資料,獲取分散式鎖,然後做操作,之後釋放鎖,這樣其實是把多執行緒併發的鎖的思路,引入多多個系統,也就是分散式系統中得解決思路。
二、分散式鎖
分散式鎖是控制分散式系統之間同步訪問共享資源的一種方式。在分散式系統中,常常需要協調他們的動作。如果不同的系統或是同一個系統的不同主機之間共享了一個或一組資源,那麼訪問這些資源的時候,往往需要互斥來防止彼此干擾來保證一致性,在這種情況下,便需要使用到分散式鎖。分散式鎖是一個在很多環境中非常有用的原語,它是不同的系統或是同一個系統的不同主機之間互斥操作共享資源的有效方法。如在電商系統中,需要保證整個分散式系統內,對一個重要事物(訂單,賬戶等)的有效操作執行緒 ,同一時間內有且只有一個。比如交易中心有N臺伺服器,訂單中心有M臺伺服器,如何保證一個訂單的同一筆支付處理,一個賬戶的同一筆充值操作是原子性的。
分散式鎖在分散式應用當中是要經常用到的,主要是解決分散式資源訪問衝突的問題。傳統的鎖ReentrantLock在去實現的時候是有問題的,ReentrantLock的lock和unlock要求必須是在同一執行緒進行,而分散式應用中,lock和unlock是兩次不相關的請求,因此肯定不是同一執行緒,因此導致無法使用ReentrantLock。
2.1 Redis的SETNX
通過setnx和getset實現分散式鎖
- 通過setnx(lockkey,expiresStr)返回1,表示獲取到鎖;
如果返回0,通過get(lockkey)獲取expiresStr,判斷是否鎖到期失效(防止執行緒異常退出未刪除key),如果未到期失效,則sleep繼續等待,如果已到期,獲取鎖;
程式碼例項:
/**
* Acquire lock.
*
* @param jedis
* @return true if lock is acquired, false acquire timeouted
* @throws InterruptedException
* in case of thread interruption
*/
public synchronized boolean acquire(Jedis jedis) throws InterruptedException {
int timeout = timeoutMsecs;
while (timeout >= 0) {
long expires = System.currentTimeMillis() + expireMsecs + 1;
String expiresStr = String.valueOf(expires); //鎖到期時間
if (jedis.setnx(lockKey, expiresStr) == 1) {
// lock acquired
locked = true;
return true;
}
String currentValueStr = jedis.get(lockKey); //redis裡的時間
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
//判斷是否為空,不為空的情況下,如果被其他執行緒設定了值,則第二個條件判斷是過不去的
// lock is expired
String oldValueStr = jedis.getSet(lockKey, expiresStr);
//獲取上一個鎖到期時間,並設定現在的鎖到期時間,
//只有一個執行緒才能獲取上一個線上的設定時間,因為jedis.getSet是同步的
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
//如過這個時候,多個執行緒恰好都到了這裡,但是隻有一個執行緒的設定值和當前值相同,他才有權利獲取鎖
// lock acquired
locked = true;
return true;
}
}
timeout -= 100;
Thread.sleep(100);
}
return false;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
通過setnx和和失效時間
某個查詢資料庫的介面,因為呼叫量比較大,所以加了快取,並設定快取過期後重新整理,問題是當併發量比較大的時候,如果沒有鎖機制,那麼快取過期的瞬間,大量併發請求會穿透快取直接查詢資料庫,造成雪崩效應,如果有鎖機制,那麼就可以控制只有一個請求去更新快取,其它的請求視情況要麼等待,要麼使用過期的快取。
下面以目前 PHP 社群裡最流行的 PHPRedis 擴充套件為例,實現一段演示程式碼:
<?php
$ok = $redis->setNX($key, $value);
if ($ok) {
$cache->update();
$redis->del($key);
}
?>
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
快取過期時,通過 SetNX 獲取鎖,如果成功了,那麼更新快取,然後刪除鎖。看上去邏輯非常簡單,可惜有問題:如果請求執行因為某些原因意外退出了,導致建立了鎖但是沒有刪除鎖,那麼這個鎖將一直存在,以至於以後快取再也得不到更新。於是乎我們需要給鎖加一個過期時間以防不測:
<?php
$redis->multi();
$redis->setNX($key, $value);
$redis->expire($key, $ttl);
$redis->exec();
?>
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
因為 SetNX 不具備設定過期時間的功能,所以我們需要藉助 Expire 來設定,同時我們需要把兩者用 Multi/Exec 包裹起來以確保請求的原子性,以免 SetNX 成功了 Expire 卻失敗了。 可惜還有問題:當多個請求到達時,雖然只有一個請求的 SetNX 可以成功,但是任何一個請求的 Expire 卻都可以成功,如此就意味著即便獲取不到鎖,也可以重新整理過期時間,如果請求比較密集的話,那麼過期時間會一直被重新整理,導致鎖一直有效。於是乎我們需要在保證原子性的同時,有條件的執行 Expire,接著便有了 Lua 程式碼。
2.2 Redis+Lua
2.3 Redisson
2.4 Zookeeper
左邊的整個區域表示一個Zookeeper叢集,locker是Zookeeper的一個持久節點,node_1、node_2、node_3是locker這個持久節點下面的臨時順序節點。client_1、client_2、client_n表示多個客戶端,Service表示需要互斥訪問的共享資源。
2.4.1 獲取分散式鎖的總體思路
在獲取分散式鎖的時候在locker節點下建立臨時順序節點,釋放鎖的時候刪除該臨時節點。
客戶端呼叫createNode方法在locker下建立臨時順序節點,然後呼叫getChildren(“locker”)來獲取locker下面的所有子節點,注意此時不用設定任何Watcher。客戶端獲取到所有的子節點path之後,如果發現自己在之前建立的子節點序號最小,那麼就認為該客戶端獲取到了鎖。如果發現自己建立的節點並非locker所有子節點中最小的,說明自己還沒有獲取到鎖,此時客戶端需要找到比自己小的那個節點,然後對其呼叫exist()方法,同時對其註冊事件監聽器。之後,讓這個被關注的節點刪除,則客戶端的Watcher會收到相應通知,此時再次判斷自己建立的節點是否是locker子節點中序號最小的,如皋是則獲取到了鎖,如果不是則重複以上步驟繼續獲取到比自己小的一個節點並註冊監聽。當前這個過程中還需要許多的邏輯判斷。
2.4.2 獲取分散式鎖的核心演算法流程
下面同個一個流程圖來分析獲取分散式鎖的完整演算法,如下:
解釋:客戶端A要獲取分散式鎖的時候首先到locker下建立一個臨時順序節點(node_n),然後立即獲取locker下的所有(一級)子節點。
此時因為會有多個客戶端同一時間爭取鎖,因此locker下的子節點數量就會大於1。對於順序節點,特點是節點名稱後面自動有一個數字編號,
先建立的節點數字編號小於後建立的,因此可以將子節點按照節點名稱字尾的數字順序從小到大排序,這樣排在第一位的就是最先建立的順序節點,
此時它就代表了最先爭取到鎖的客戶端!此時判斷最小的這個節點是否為客戶端A之前創建出來的node_n,如果是則表示客戶端A獲取到了鎖,
如果不是則表示鎖已經被其它客戶端獲取,因此客戶端A要等待它釋放鎖,也就是等待獲取到鎖的那個客戶端B把自己建立的那個節點刪除。
此時就通過監聽比node_n次小的那個順序節點的刪除事件來知道客戶端B是否已經釋放了鎖,如果是,此時客戶端A再次獲取locker下的所有子節點,
再次與自己建立的node_n節點對比,直到自己建立的node_n是locker的所有子節點中順序號最小的,此時表示客戶端A獲取到了鎖!