架構與思維:高併發下冪等性解決方案

翁智華發表於2021-12-31

1 背景

我們的雲辦公系統有一個會議預定模組,每個月最後一個工作日的下午三點,會啟動對下個月會議室的可用預定。
公司的會議室大約200個,但是需求量遠不止於此,所以會形成會議室搶訂的場面(搶訂大軍為行政助理、人事助理、開發經理、產品運營等對會議室有剛性需求的人)。
程式團隊,經常會接到投訴,A同學和B同學搶了同一個會議室,前端頁面顯示為兩個佔點陣圖片,從資料庫看,是插入了兩條同一個會議位置的資料,這兩條資料的發起人員分別是A和B。
這就牽扯出一個數學與計算機學概念:冪等
 
在計算機系統操作中,有很多種行為,需要保證無論執行多少次,都應該產生一樣的效果或返回一樣的結果。 
比如:
1、前端重複點選提交表單選中的資料,在後臺應該只能有一個資料錄入到資料庫;
2、傳送同一個訊息,也應該只發一次,使用者不會收到多條一樣的資料;
3、建立業務訂單,一次業務請求只能建立一個,如果程式沒有保證冪等,建立出多條訂單資料,就混亂了。
4、在高併發情況下,對於單一的資料,不可以多次使用,比如一張確定位置的電影票,不會被多次預訂成功。同理的,同一時間的一個會議室資訊,不會被多次預訂。
etc.很多重要的場景都需要冪等的特性來支援。

2 冪等性概念

冪等(idempotent)是一個數學與計算機學概念,常見於抽象代數中。

在我們的開發過程中,保證冪等性就是保證你的程式的無論執行多少次,影響均與第一次執行的影響是一致的,產生的結果也是一樣的。

而冪等函式(冪等方法),是指使用相同的引數結構重複執行,產生相同的結果的函式,重複執行冪等函式不會影響系統的狀態或者造成改變。

例如,"getUserName(String uCode)" 和 "delUser(String uCode)" 函式就是典型的冪等函式,而更復雜的冪等保證是類似 高併發場景下的訂單號(流水號)或者 秒殺場景下的唯一有效資料 等。

所以,冪等就是一個操作,不論執行多少次,產生的效果和返回的結果都是一樣的。

3 冪等性問題的常見解決方案 

3.1 查詢操作和刪除操作

查詢一次和查詢多次,在資料不變的情況下,查詢結果是一樣的,所以嚴格來說,select是天然的冪等操作
刪除也是一樣的,對於單條資料來說,刪除一次和刪除多次都是把資料刪除,影響和結果都是一樣(當然,程式上 的執行的返回結果可能會不一樣,比如運算元據庫的時候,刪除的資料不存在,返回0,正常刪除成功,返回1) 。 
1 -- 使用者庫查詢某個身份證號的使用者名稱
2 select user_name from t_user where id_no ='xxx';
3 
4 -- 使用者庫刪除某個身份證號的使用者
5 delete from t_user where id_no ='xxx'

3.2 使用唯一索引 或者唯一組合索引

避免插入同樣資訊的髒資料。
比如:中秋節到了,淘寶上線某款限量版的月餅,每個使用者都只能購買一盒月餅,如何防止使用者被建立多條月餅訂單資料,可以給月餅銷售表中的使用者ID加唯一索引(不允許被索引的資料列包含重複的值),
保證一個使用者只能建立成功一條月餅訂單記錄。
1 CREATE UNIQUE INDEX uni_user_userid ON t_user(userid)
唯一索引或唯一組合索引來防止新增資料出現髒資料(當表存在唯一索引,併發執行時,先進入的執行成功,後進入的會執行失敗,說明該資料已經存在了,返回結果即可)。如下圖所示。
 
 
 
回到我們上面的哪個會議室預訂,也可以是一樣的方式,可以用會議室編號(該編號具有唯一標識)作為唯一索引,但是他的實際情況更復雜。 

3.3 token機制

防止頁面重複提交而導致的資料重複

業務現象: 頁面的資料只能被提交一次,或者提交多次的結果是一致的,不會產生多餘的髒資料

產生的原因: 由於系統卡頓導致的重複點選或網路重發,還有就是nginx重發等情況,導致的資料被重複提交;

解決方法: 

  • 叢集環境採用token加redis(redis單執行緒的,處理需要排隊);
  • 單JVM環境:採用token加redis或token加jvm記憶體。

處理步驟:

  1. 資料提交前要向服務的申請token,token放到redis或jvm記憶體,token需要設定有效時間,一般我們一個請求從request到respond時間是很短的,所以有效時間可以設定短一點;
  2. 提交後後臺校驗token,同時刪除token,返回執行結果。token特點:一次有效性,用完即刪,可以限流執行。
 
流程如下,注意:redis要用刪除操作來判斷token,刪除成功代表token校驗通過;
 
  

3.4 悲觀鎖

獲取資料的時候加鎖獲取。 select * from t_name where id='xxx' for update; 

注意:這邊的id欄位一定是主鍵或者唯一索引,不然會導致鎖表。悲觀鎖使用時一般會配合事務一起使用,資料鎖定時間可能會很長,根據實際情況選用。  

3.5 樂觀鎖

樂觀鎖只是在更新資料那一刻鎖表,其他時間不鎖表,所以相對於悲觀鎖,效率更高,適用於多讀少寫的型別,併發大的情況。

樂觀鎖的實現方式多種多樣,可以通過version或者其他狀態條件:

1. 通過版本號實現  update t_name set name=#{name},version=version+1 where version=#{version}; 

2. 通過條件限制  update t_name set avai_amount=avai_amount-#subAmount# where avai_amount-#subAmount# >= 0  

使用版本號的方式執行過程如下圖:

  
 
這邊需要注意:樂觀鎖的更新操作,如果加上主鍵或者唯一索引來作為條件, 更新時鎖的是行,否則更新時會鎖表,效能效率差很多。所以上面兩個sql改成下面兩個會好很多。 
1 update t_name set name=#name#,version=version+1 where id=#id# and version=#version#;
2 update t_name set avai_amount=avai_amount-#subAmount# where id=#id# and avai_amount-#subAmount# >= 0;  

 3.6 分散式鎖

如果是分佈是系統,構建全域性唯一索引比較困難,不同的鏈路業務可能分佈在不同的資料庫表中,所以唯一性的欄位沒法確定,這時候可以引入分散式鎖,通過第三方的系統(redis或zookeeper),

業務系統插入資料或者更新資料,獲取分散式鎖,然後做操作,完成業務操作之後,釋放鎖,這樣其實是把多執行緒併發的鎖的思路,引入多多個系統,也就是分散式系統中得解決思路

關鍵點:某個長流程處理過程要求不能併發執行,可以在流程執行之前根據某個標誌(使用者ID+字尾等)獲取分散式鎖,其他流程執行時獲取鎖就會失敗,也就是同一時間該流程只能有一個能執行成功,執行完成後,釋放分散式鎖(分散式鎖要第三方系統提供)。 

後面有一篇專門分析分散式鎖方案的文章,還在草稿箱,待整理。

3.7  select + insert

併發不高的後臺系統,或者一些簡單的執行任務,為了支援冪等,支援重複執行,簡單的處理方法是,先查詢下一些關鍵資料,判斷是否已經執行過,在進行業務處理,就可以了。

但是同樣有問題,核心高併發流程不便使用這種方法。因為他本質上還是兩個步驟,中間還有執行間隙的,在超高併發的情況還是會造成資料不一致的情況,這對於核心業務就是災難了。 

3.8 狀態機冪等

在設計單據相關的業務,或者是任務相關的業務,肯定會涉及到狀態機(狀態變更圖),就是業務單據上面有個狀態,狀態在不同的情況下會發生變更,一般情況下存在有限狀態機,
這時候,如果狀態機已經處於下一個狀態,這時候來了一個上一個狀態的變更,理論上是不能夠變更的,這樣的話,保證了有限狀態機的冪等。
注意:訂單等單據類業務,存在很長的狀態流轉,一定要深刻理解狀態機,對業務系統設計能力提高有很大幫助  

3.9 保證Api介面的冪等性

如銀聯提供的付款介面:需要接入商戶提交付款請求時附帶:source來源,seq序列號 ,source+seq在資料庫裡面做唯一索引,防止多次付款(併發時,只能處理一個請求) 。

關鍵點:核心業務功能,對外提供介面為了支援冪等呼叫,介面有兩個欄位必須傳,一個是來源source,一個是來源方序列號seq,這個兩個欄位在提供方系統裡面做聯合唯一索引,這樣當第三方呼叫時,

先在本方系統裡面查詢一下,是否已經處理過,返回相應處理結果;沒有處理過,進行相應處理,返回結果。為了冪等友好,最好先查詢一下,是否處理過該筆業務,不查詢直接插入業務系統,會報錯,而實際是已經處理過了。  

4 會議室的解決方案

將每天的會議預定按照半個小時1位做48位佔用位符預算,建立快取機制,進行高效率的佔位判斷,並反寫到預定表;啟動額外排程服務做最終的預定持久化;

採用唯一聯合索引保障高併發下的冪等性策略。將會議室ID、時間段、日期,建立唯一組合索引,防止新增髒資料,保證不會有兩條一樣的會議室預定記錄插入 

1 CREATE UNIQUE CLUSTERED INDEX [ClusteredIndex_A9_MeetingReser] ON A9_MeetingReser
2 (
3 [timespan] ASC,
4 [roomid] ASC,
5 [sdate] ASC
6 )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, IGNORE_DUP_KEY = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] 

執行會議預訂的事務指令碼,如下,當資料庫中存在一樣的會議室資訊時,會返回錯誤(被佔用)的狀態值。

1  BEGIN TRAN T_Add;  
2  DECLARE @code INT; DECLARE @occupyMeeing TABLE ( sMeetCode INT ); 
3  DECLARE @resutlTable TABLE ( lType TINYINT,/*返回型別0為失敗型別,1為成功型別*/ resutlValue NVARCHAR(60)/*返回的資訊*/ ); 
4  -- Todo 業務邏輯 寫入資料庫操作,即會議號和佔用的時間段標識為聯合索引,不可重複插入,重複插入報錯 
5  IF @@ERROR!=0 goto w_err;  
6  COMMIT TRAN T_Add ; 
7  goto w_end   w_err:  
8  ROLLBACK TRAN T_Add ;  
9  w_end:  SELECT * FROM @resutlTable

原來從預定到判斷佔用到寫庫會耗時0.5~1s,優化後整個流程執行效能提升到50ms左右,避免了會議室預定衝突的情況。

結果:根據會議室預定記錄的統計,優化釋出之後再未發生過預定衝突的問題。免除了會議管理員與預定人員溝通協調會議室的成本,解決了長期困擾他們的問題。 

5 總結

冪等本質上與系統是否分散式、高併發,業務執行頻率高不高,沒有直接的關係。關鍵是程式的操作過程是不是冪等的。

典型的冪等操作就是:把某個變數設定為1這種行為,不管執行多少次都是冪等的,你在進行網際網路支付的時候,即使系統卡頓,你提交多次,也只支付一次。

要做到冪等性,從介面設計上來說不設計任何非冪等的操作即可。特別在類似支付寶,銀行,網際網路金融公司等涉及的網上資金系統,既要高效,資料也要準確,不能出現多扣款,多打款,產生金錢交易不一致等問題。

 

相關文章