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

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) 。 

-- 用戶庫查詢某個身份證號的用戶名
select user_name from t_user where id_no ='xxx';
-- 用戶庫刪除某個身份證號的用戶
delete from t_user where id_no ='xxx';

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

避免插入同樣信息的髒數據。

比如:中秋節到了,淘寶上線某款限量版的月餅,每個用戶都只能購買一盒月餅,如何防止用戶被創建多條月餅訂單數據,可以給月餅銷售表中的用戶 ID 加唯一索引(不允許被索引的數據列包含重複的值),

保證一個用戶只能創建成功一條月餅訂單記錄。

CREATE UNIQUE INDEX uni_user_userid ON t_user(userid);
唯一索引或唯一組合索引來防止新增數據出現髒數據(當表存在唯一索引,併發執行時,先進入的執行成功,後進入的會執行失敗,說明該數據已經存在了,返回結果即可)。如下圖所示。

回到我們上面的哪個會議室預訂,也可以是一樣的方式,可以用會議室編號(該編號具有唯一標識)作爲唯一索引,但是他的實際情況更復雜。 

3.3 token 機制

防止頁面重複提交而導致的數據重複

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

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

解決方法: 

處理步驟:

  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 改成下面兩個會好很多。 

update t_name set name=#name#,version=version+1 where id=#id# and version=#version#;
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、時間段、日期,建立唯一組合索引,防止新增髒數據,保證不會有兩條一樣的會議室預定記錄插入 

CREATE UNIQUE CLUSTERED INDEX [ClusteredIndex_A9_MeetingReser] ON A9_MeetingReser
(
[timespan] ASC,
[roomid] ASC,
[sdate] ASC
)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]

執行會議預訂的事務腳本,如下,當數據庫中存在一樣的會議室信息時,會返回錯誤(被佔用)的狀態值。

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

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

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

5 總結

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

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

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/n41RPI-6GrcHwM1plWH5Rg