一文理解如何實現(xiàn)接口的冪等性
冪等,這個詞來源自數(shù)學領(lǐng)域。冪等性衍生到軟件工程中,它的語義是指:函數(shù)/接口可以使用相同的參數(shù)重復(fù)執(zhí)行, 不應(yīng)該影響系統(tǒng)狀態(tài),也不會對系統(tǒng)造成改變。
舉一個簡單的例子:正常設(shè)計的查詢接口,不管調(diào)用多少次,都不會破壞當前的系統(tǒng)或數(shù)據(jù),這就是一個冪等操作。
冪等的業(yè)務(wù)場景
在分布式系統(tǒng)中, 由于分布式天然特性的時序問題以及網(wǎng)絡(luò)的不可靠性(機器、機架、機房故障、電纜被挖斷等等), 重復(fù)請求很常見,接口冪等性設(shè)計就顯得尤為重要。

冪等需要考慮的場景有很多,例如系統(tǒng)A是處理用戶客戶端發(fā)送過來的請求,無論是前端bug、腳本惡意發(fā)包、用戶重復(fù)點擊又或是網(wǎng)絡(luò)超時導(dǎo)致的網(wǎng)絡(luò)重發(fā),都會造成系統(tǒng)A收到相同參數(shù)的網(wǎng)絡(luò)請求。

對于處理消息隊列請求的系統(tǒng)B和處理服務(wù)上游發(fā)送請求的系統(tǒng)C,也都存在網(wǎng)絡(luò)超時導(dǎo)致的網(wǎng)絡(luò)重發(fā),所以要考慮接口的冪等性。

保障冪等性的原理
對于分布式系統(tǒng)來說,在JVM層面的鎖已經(jīng)失去作用,所以保證系統(tǒng)冪等性需要滿足3個條件:
請求唯一標識:每一個請求必須有一個唯一標識。
處理唯一標識:每次處理完請求之后,必須有一個記錄標識這個請求處理過了。
邏輯判斷處理:每次接收請求需要進行判斷之前是否處理過的邏輯處理。根據(jù)請求唯一標識查詢是否存在處理唯一標識。
實際執(zhí)行中要結(jié)合自身業(yè)務(wù)。
冪等性實現(xiàn)方案
1. token機制
針對客戶端重復(fù)連續(xù)多次點擊的情況,例如用戶購物提交訂單,提交訂單的接口就可以通過token機制實現(xiàn)防止重復(fù)提交。

主要流程就是:
服務(wù)端提供生成請求token的接口。在存在冪等問題的業(yè)務(wù)執(zhí)行前,向服務(wù)器獲取請求token,服務(wù)器會把token保存到Redis中。
然后調(diào)用業(yè)務(wù)接口請求時,把請求token攜帶過去,一般放在請求頭部。
服務(wù)器判斷請求token是否存在redis中:存在則表示第一次請求,這時把Redis中的token刪除,繼續(xù)執(zhí)行業(yè)務(wù);如果判斷token不存在redis中,就表示是重復(fù)操作,直接返回重復(fù)標記給client,這樣就保證了業(yè)務(wù)代碼,不被重復(fù)執(zhí)行。
這里要結(jié)合業(yè)務(wù)考慮這種場景:如果請求處理失敗,前端是否需要重新申請token進行重試(因為此時token在服務(wù)端已經(jīng)被刪除)。
2. 數(shù)據(jù)庫唯一索引
往數(shù)據(jù)庫表里插入數(shù)據(jù)的時候,利用數(shù)據(jù)庫的唯一索引特性,保證唯一的邏輯。唯一序列號可以是一個字段,例如訂單的訂單號,也可以是多字段的唯一性組合。
事務(wù)中包含多表數(shù)據(jù)的更新,業(yè)務(wù)要考慮處理事務(wù)回滾的問題。
3. Redis實現(xiàn)
Redis實現(xiàn)的方式就是將唯一序列號作為Key存入Redis,在請求處理之前,先查看Key是否存在。唯一序列號可以是一個字段,例如訂單的訂單號,也可以是多字段的唯一性組合。當然這里需要設(shè)置一個key的過期時間,否則Redis中會存在過多的key。具體校驗流程如下圖所示:

如果想要基于Redis實現(xiàn)冪等性防重框架,需要考慮如下兩個問題:
如果第一次請求失敗了,客戶端重試,是否需要放行?
網(wǎng)絡(luò)請求可能是get或者post(內(nèi)部rpc協(xié)議除外),唯一序列號參數(shù)可能在url或是在body體里。則使用防重框架的新接口以及之前老業(yè)務(wù)接口能否做到版本兼容性?
建議業(yè)務(wù)使用方最好針對指定業(yè)務(wù)進行Redis的冪等方案。
Zookeeper同樣也能實現(xiàn)上述功能,但由于Zookeeper是CP模型,性能不如Redis,另外針對防重場景,也并不需要Zookeeper高可靠性,所以優(yōu)先推薦Redis。
4. ON DUPLICATE KEY UPDATE
有些業(yè)務(wù)場景是先根據(jù)索引從表中查詢數(shù)據(jù)是否存在,如果存在則更新狀態(tài),不存在才插入數(shù)據(jù)。
這種情況下在并發(fā)量不大的時候沒有問題,但是在高并發(fā)場景,可能會出現(xiàn)同時插入兩條相同索引的情況,導(dǎo)致"Duplicate entry for key 'PRIMARY'"問題。
解決方法首先想到的當然是分布式鎖。但分布式鎖降低了吞吐量而且分布式鎖依賴的組件,如Zookeeper或Redis如果出現(xiàn)網(wǎng)絡(luò)超時,同樣會影響在線服務(wù)。
所以一個簡單的解決方法是使用mysql的INSERT INTO ...ON DUPLICATE KEY UPDATE語法,從而保證了接口的冪等性。
5. 狀態(tài)機
對于很多業(yè)務(wù),都存在業(yè)務(wù)流轉(zhuǎn)狀態(tài)的,每個狀態(tài)都有前置狀態(tài)以及最后的結(jié)束狀態(tài)。
以訂單為例,已支付的狀態(tài)的前置狀態(tài)只能是待支付,而取消狀態(tài)的前置狀態(tài)只能是待支付,通過這種狀態(tài)機的流轉(zhuǎn)就可以控制請求的冪等。假設(shè)當前狀態(tài)是已支付,這時候如果支付接口又接收到了支付請求,則會拋異常或拒絕此次請求。
public enum OrderStatusEnum {
UN_SUBMIT(0, 0, "待提交"),
UN_PADING(0, 1, "待支付"),
PAYED(1, 2, "已支付待發(fā)貨"),
DELIVERING(2, 3, "已發(fā)貨"),
COMPLETE(3, 4, "已完成"),
CANCEL(0, 5, "已取消"),
;
//前置狀態(tài)
private int preStatus;
//狀態(tài)值
private int status;
//狀態(tài)描述
private String desc;
OrderStatusEnum(int preStatus, int status, String desc) {
this.preStatus = preStatus;
this.status = status;
this.desc = desc;
}
//...
}
6. MVCC方案
這個方案嚴格上并不是解決冪等問題,更確切來說是解決并發(fā)問題。但高并發(fā)場景下,也是一種必須的保障措施。
多版本并發(fā)控制,該策略主要使用update with condition來保證多次外部請求調(diào)用對系統(tǒng)的影響是一致的。在系統(tǒng)設(shè)計的過程中,合理的使用樂觀鎖,通過version或者updateTime(timestamp)等其他條件,來做樂觀鎖的判斷條件,這樣保證更新操作即使在并發(fā)的情況下,也不會有太大的問題。例如
select * from tablename where condition=@condition //取出要更新的對象,帶有版本versoin
update tableName set name=#name#,version=version+1 where version=@version
在更新的過程中利用version來防止其他操作對對象的并發(fā)更新。如果直接拒絕是不理想的操作,則服務(wù)端需要一定的事務(wù)回滾與重試機制。
7. 分布式鎖
有關(guān)分布式鎖的講解,可以查看博客《一文理解分布式鎖的實現(xiàn)方式》
分布式鎖同樣可以實現(xiàn)接口的冪等性,但由于分布式鎖對系統(tǒng)負擔來說相對要重一些,可以結(jié)合業(yè)務(wù)場景進行技術(shù)選型。
參考文檔:
https://zh.wikipedia.org/wiki/冪等
