前言
冪等性钞啸,是開發(fā)人員在日常開發(fā)中必須要考慮的梯澜,尤其是轉(zhuǎn)賬、支付等涉及金額交易的場景枕赵,如果出現(xiàn)冪等性的問題猜欺,造成的后果是非常嚴(yán)重的。
本文將分享一下什么是冪等性以及如何保證冪等性拷窜。
什么是冪等性
冪等(idempotent开皿、idempotence)是一個數(shù)學(xué)與計(jì)算機(jī)學(xué)概念,常見于抽象代數(shù)中篮昧。
在編程中一個冪等操作的特點(diǎn)是其任意多次執(zhí)行所產(chǎn)生的影響均與一次執(zhí)行的影響相同赋荆。冪等函數(shù),或冪等方法懊昨,是指可以使用相同參數(shù)重復(fù)執(zhí)行窄潭,并能獲得相同結(jié)果的函數(shù)。這些函數(shù)不會影響系統(tǒng)狀態(tài)酵颁,也不用擔(dān)心重復(fù)執(zhí)行會對系統(tǒng)造成改變嫉你。
冪等性產(chǎn)生原因
前端未做限制,導(dǎo)致用戶重復(fù)提交
使用瀏覽器后退材义,或者按F5刷新均抽,或者使用歷史記錄嫁赏,重復(fù)提交表單
網(wǎng)絡(luò)波動其掂,引起重復(fù)請求
超時重試,引起接口重復(fù)調(diào)用
定時任務(wù)設(shè)置不合理潦蝇,導(dǎo)致數(shù)據(jù)重復(fù)處理
使用消息隊(duì)列時款熬,消息重復(fù)消費(fèi)
如何保證冪等性
1.前端處理
提交按鈕點(diǎn)擊置灰,或者增加loading
頁面重定向(PRG)攘乒,PRG模式即
POST-REDIRECT-GET
贤牛,當(dāng)用戶進(jìn)行表單提交時,會重定向到另外一個提交成功頁面则酝,而不是停留在原先的表單頁面殉簸。這樣就避免了用戶刷新導(dǎo)致重復(fù)提交。同時防止了通過瀏覽器按鈕前進(jìn)/后退導(dǎo)致表單重復(fù)提交沽讹。
2.先select后insert + 唯一索引沖突
在保存數(shù)據(jù)前般卑,我們需要先select一下數(shù)據(jù)是否存在。如果數(shù)據(jù)已存在爽雄,則返回失旘鸺臁(具體操作視業(yè)務(wù)情況而定),如果數(shù)據(jù)不存在挚瘟,則執(zhí)行insert操作叹谁。
但在高并發(fā)的場景下饲梭,可能會出現(xiàn)兩個請求select的時候,都沒有查到數(shù)據(jù)焰檩,然后都執(zhí)行了insert操作憔涉,所以此時會有重復(fù)數(shù)據(jù)產(chǎn)生,因此在數(shù)據(jù)庫中析苫,我們需要添加唯一索引來保證冪等监氢。
流程圖如下:
此方案適用于新增操作的接口,如用戶注冊藤违。
3.建去重表
某些業(yè)務(wù)場景浪腐,是允許重復(fù)數(shù)據(jù)存在的,僅在流程的某個環(huán)節(jié)才不允許出現(xiàn)重復(fù)數(shù)據(jù)顿乒,這種情況直接在表中添加唯一索引是不合適的议街,所以就需要創(chuàng)建一張去重表。
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 '創(chuàng)建時間',
PRIMARY KEY (`id`),
UNIQUE KEY `index_order_id` (`order_id`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='去重表';
流程圖如下:
特別注意璧榄,防重表與業(yè)務(wù)表必須在同一數(shù)據(jù)庫特漩,并且操作要在同一事務(wù)中。
此方案適用于在業(yè)務(wù)中有唯一標(biāo)識的插入場景中骨杂,比如在支付業(yè)務(wù)中涂身,若一個訂單只會支付一次,則訂單ID可以作為唯一標(biāo)識搓蚪。
4.使用悲觀鎖
悲觀鎖蛤售,正如其名,具有強(qiáng)烈的獨(dú)占和排他特性妒潭。它指的是對數(shù)據(jù)被外界(包括本系統(tǒng)當(dāng)前的其他事務(wù)悴能,以及來自外部系統(tǒng)的事務(wù)處理)修改持保守態(tài)度,因此雳灾,在整個數(shù)據(jù)處理過程中漠酿,將數(shù)據(jù)處于鎖定狀態(tài)。悲觀鎖的實(shí)現(xiàn)谎亩,往往依靠數(shù)據(jù)庫提供的鎖機(jī)制(也只有數(shù)據(jù)庫層提供的鎖機(jī)制才能真正保證數(shù)據(jù)訪問的排他性炒嘲,否則,即使在本系統(tǒng)中實(shí)現(xiàn)了加鎖機(jī)制匈庭,也無法保證外部系統(tǒng)不會修改數(shù)據(jù))夫凸。
在交易場景中,用戶賬戶余額有100元嚎花,轉(zhuǎn)出50元寸痢,正常情況下用戶的余額剩余50元。
update account set amount-50 where id = 123;
如果此時有多個相同的請求紊选,可能會導(dǎo)致用戶的金額變?yōu)樨?fù)數(shù)啼止。所以此時可以使用悲觀鎖道逗,將用戶的行數(shù)據(jù)鎖住,在同一時刻只允許一個請求獲得鎖献烦,其他請求等待滓窍。
select * from account where id = 123 for update;
流程圖如下:
需要特別注意的是:如果使用的是mysql數(shù)據(jù)庫,存儲引擎必須用innodb巩那,因?yàn)樗胖С质聞?wù)吏夯。此外,這里id字段一定要是主鍵或者唯一索引即横,不然會鎖住整張表噪生。
因?yàn)楸^鎖是需要在同一事務(wù)中鎖住一行數(shù)據(jù),所以如果事務(wù)比較長东囚,會造成大量請求等待跺嗽,影響接口性能。
5.使用樂觀鎖
樂觀鎖( Optimistic Locking ) 相對悲觀鎖而言页藻,樂觀鎖機(jī)制采取了更加寬松的加鎖機(jī)制桨嫁。悲觀鎖大多數(shù)情況下依靠數(shù)據(jù)庫的鎖機(jī)制實(shí)現(xiàn),以保證操作最大程度的獨(dú)占性份帐。但隨之而來的就是數(shù)據(jù)庫性能的大量開銷璃吧,特別是對長事務(wù)而言,這樣的開銷往往無法承受废境。而樂觀鎖機(jī)制在一定程度上解決了這個問題畜挨。樂觀鎖,大多是基于數(shù)據(jù)版本( Version )記錄機(jī)制實(shí)現(xiàn)彬坏。何謂數(shù)據(jù)版本朦促?即為數(shù)據(jù)增加一個版本標(biāo)識,在基于數(shù)據(jù)庫表的版本解決方案中栓始,一般是通過為數(shù)據(jù)庫表增加一個 “version” 字段來實(shí)現(xiàn)。讀取出數(shù)據(jù)時血当,將此版本號一同讀出幻赚,之后更新時,對此版本號加一臊旭。此時落恼,將提交數(shù)據(jù)的版本數(shù)據(jù)與數(shù)據(jù)庫表對應(yīng)記錄的當(dāng)前版本信息進(jìn)行比對,如果提交的數(shù)據(jù)版本號等于數(shù)據(jù)庫表當(dāng)前版本號离熏,則予以更新佳谦,否則認(rèn)為是過期數(shù)據(jù)。
樂觀鎖主要基于版本標(biāo)識(version)進(jìn)行操作滋戳,即每次操作查詢數(shù)據(jù)時都要先查詢出版本標(biāo)識(version)钻蔑,然后根據(jù)版本標(biāo)識(version)進(jìn)行update操作啥刻。
select id,amount,version from account id = 123;
update account set amount=amount-50,version=version+1 where id=123 and version = 1;
當(dāng)多個相同的請求查詢信息時,版本標(biāo)識是相同的咪笑,當(dāng)其中一個請求完成update操作可帽,后續(xù)請求影響條數(shù)均為0。
流程圖如下:
6.根據(jù)狀態(tài)機(jī)
很多時候窗怒,業(yè)務(wù)流程是有狀態(tài)流轉(zhuǎn)的映跟,這個時候可以使用狀態(tài)機(jī)來保證冪等性。
如訂單業(yè)務(wù)中扬虚,存在狀態(tài)「1-已下單努隙,2-已支付,3-已完成辜昵,4-已取消」剃法,按照業(yè)務(wù)流程,狀態(tài)是依次流轉(zhuǎn)的路鹰,所以在update操作時贷洲,我們就要根據(jù)本次的狀態(tài)來更新下一次的狀態(tài)。
update order_info set status = 3 where id = 123 and status = 2;
流程圖如下:
7.使用分布式鎖
分布式鎖的邏輯是晋柱,每次請求都通過業(yè)務(wù)唯一ID來嘗試獲取鎖优构,如果獲取成功,就進(jìn)行后續(xù)業(yè)務(wù)邏輯操作雁竞,如果獲取失敗钦椭,就舍棄請求直接返回。
分布式鎖通常是基于redis來實(shí)現(xiàn)的碑诉。
流程圖如下:
分布式鎖是通過設(shè)置redis的過期時間來進(jìn)行控制彪腔。如果過期時間設(shè)置太短,則無法有效防止重復(fù)請求进栽;如果過期時間設(shè)置太長德挣,則影響redis存儲空間,甚至?xí)绊懞罄m(xù)業(yè)務(wù)操作快毛。因此需要根據(jù)具體的業(yè)務(wù)情況格嗅,來設(shè)置合理的過期時間。
8.基于token機(jī)制
此方案包含兩個請求階段:
1.客戶端請求服務(wù)端申請獲取token
2.客戶端攜帶token再次請求唠帝,服務(wù)端校驗(yàn)token后進(jìn)行操作屯掖。
流程圖如下:
這里有一個注意的點(diǎn):
服務(wù)端驗(yàn)證token是否存在,要使用刪除key的方式襟衰,即redis.del(key)贴铜,刪除成功則表示校驗(yàn)token通過;
不能使用先查再刪的操作,即先redis.get(key)绍坝,后redis.del(key)徘意,這種方式在高并發(fā)下無法保證冪等。
參考資料