前言
冪等性,是開發人員在日常開發中必須要考慮的,尤其是轉賬、支付等涉及金額交易的場景,如果出現冪等性的問題,造成的后果是非常嚴重的。
本文將分享一下什么是冪等性以及如何保證冪等性。
什么是冪等性
冪等(idempotent、idempotence)是一個數學與計算機學概念,常見于抽象代數中。
在編程中一個冪等操作的特點是其任意多次執行所產生的影響均與一次執行的影響相同。冪等函數,或冪等方法,是指可以使用相同參數重復執行,并能獲得相同結果的函數。這些函數不會影響系統狀態,也不用擔心重復執行會對系統造成改變。
冪等性產生原因
前端未做限制,導致用戶重復提交
使用瀏覽器后退,或者按F5刷新,或者使用歷史記錄,重復提交表單
網絡波動,引起重復請求
超時重試,引起接口重復調用
定時任務設置不合理,導致數據重復處理
使用消息隊列時,消息重復消費
如何保證冪等性
1.前端處理
提交按鈕點擊置灰,或者增加loading
頁面重定向(PRG),PRG模式即
POST-REDIRECT-GET
,當用戶進行表單提交時,會重定向到另外一個提交成功頁面,而不是停留在原先的表單頁面。這樣就避免了用戶刷新導致重復提交。同時防止了通過瀏覽器按鈕前進/后退導致表單重復提交。
2.先select后insert + 唯一索引沖突
在保存數據前,我們需要先select一下數據是否存在。如果數據已存在,則返回失敗(具體操作視業務情況而定),如果數據不存在,則執行insert操作。
但在高并發的場景下,可能會出現兩個請求select的時候,都沒有查到數據,然后都執行了insert操作,所以此時會有重復數據產生,因此在數據庫中,我們需要添加唯一索引來保證冪等。
流程圖如下:
此方案適用于新增操作的接口,如用戶注冊。
3.建去重表
某些業務場景,是允許重復數據存在的,僅在流程的某個環節才不允許出現重復數據,這種情況直接在表中添加唯一索引是不合適的,所以就需要創建一張去重表。
CREATE TABLE `table_name` (
`id` bigint(15) NOT NULL AUTO_INCREMENT COMMENT '自增主鍵',
`order_id` varchar(100) NOT NULL COMMENT '訂單號',
`create_time` datetime DEFAULT NULL COMMENT '創建時間',
PRIMARY KEY (`id`),
UNIQUE KEY `index_order_id` (`order_id`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='去重表';
流程圖如下:
特別注意,防重表與業務表必須在同一數據庫,并且操作要在同一事務中。
此方案適用于在業務中有唯一標識的插入場景中,比如在支付業務中,若一個訂單只會支付一次,則訂單ID可以作為唯一標識。
4.使用悲觀鎖
悲觀鎖,正如其名,具有強烈的獨占和排他特性。它指的是對數據被外界(包括本系統當前的其他事務,以及來自外部系統的事務處理)修改持保守態度,因此,在整個數據處理過程中,將數據處于鎖定狀態。悲觀鎖的實現,往往依靠數據庫提供的鎖機制(也只有數據庫層提供的鎖機制才能真正保證數據訪問的排他性,否則,即使在本系統中實現了加鎖機制,也無法保證外部系統不會修改數據)。
在交易場景中,用戶賬戶余額有100元,轉出50元,正常情況下用戶的余額剩余50元。
update account set amount-50 where id = 123;
如果此時有多個相同的請求,可能會導致用戶的金額變為負數。所以此時可以使用悲觀鎖,將用戶的行數據鎖住,在同一時刻只允許一個請求獲得鎖,其他請求等待。
select * from account where id = 123 for update;
流程圖如下:
需要特別注意的是:如果使用的是mysql數據庫,存儲引擎必須用innodb,因為它才支持事務。此外,這里id字段一定要是主鍵或者唯一索引,不然會鎖住整張表。
因為悲觀鎖是需要在同一事務中鎖住一行數據,所以如果事務比較長,會造成大量請求等待,影響接口性能。
5.使用樂觀鎖
樂觀鎖( Optimistic Locking ) 相對悲觀鎖而言,樂觀鎖機制采取了更加寬松的加鎖機制。悲觀鎖大多數情況下依靠數據庫的鎖機制實現,以保證操作最大程度的獨占性。但隨之而來的就是數據庫性能的大量開銷,特別是對長事務而言,這樣的開銷往往無法承受。而樂觀鎖機制在一定程度上解決了這個問題。樂觀鎖,大多是基于數據版本( Version )記錄機制實現。何謂數據版本?即為數據增加一個版本標識,在基于數據庫表的版本解決方案中,一般是通過為數據庫表增加一個 “version” 字段來實現。讀取出數據時,將此版本號一同讀出,之后更新時,對此版本號加一。此時,將提交數據的版本數據與數據庫表對應記錄的當前版本信息進行比對,如果提交的數據版本號等于數據庫表當前版本號,則予以更新,否則認為是過期數據。
樂觀鎖主要基于版本標識(version)進行操作,即每次操作查詢數據時都要先查詢出版本標識(version),然后根據版本標識(version)進行update操作。
select id,amount,version from account id = 123;
update account set amount=amount-50,version=version+1 where id=123 and version = 1;
當多個相同的請求查詢信息時,版本標識是相同的,當其中一個請求完成update操作,后續請求影響條數均為0。
流程圖如下:
6.根據狀態機
很多時候,業務流程是有狀態流轉的,這個時候可以使用狀態機來保證冪等性。
如訂單業務中,存在狀態「1-已下單,2-已支付,3-已完成,4-已取消」,按照業務流程,狀態是依次流轉的,所以在update操作時,我們就要根據本次的狀態來更新下一次的狀態。
update order_info set status = 3 where id = 123 and status = 2;
流程圖如下:
7.使用分布式鎖
分布式鎖的邏輯是,每次請求都通過業務唯一ID來嘗試獲取鎖,如果獲取成功,就進行后續業務邏輯操作,如果獲取失敗,就舍棄請求直接返回。
分布式鎖通常是基于redis來實現的。
流程圖如下:
分布式鎖是通過設置redis的過期時間來進行控制。如果過期時間設置太短,則無法有效防止重復請求;如果過期時間設置太長,則影響redis存儲空間,甚至會影響后續業務操作。因此需要根據具體的業務情況,來設置合理的過期時間。
8.基于token機制
此方案包含兩個請求階段:
1.客戶端請求服務端申請獲取token
2.客戶端攜帶token再次請求,服務端校驗token后進行操作。
流程圖如下:
這里有一個注意的點:
服務端驗證token是否存在,要使用刪除key的方式,即redis.del(key),刪除成功則表示校驗token通過;
不能使用先查再刪的操作,即先redis.get(key),后redis.del(key),這種方式在高并發下無法保證冪等。
參考資料