分布式系統中接口的冪等性
業務場景 #
某公司有個借貸的項目,具體業務類似于阿里的螞蟻借唄,用戶在平臺上借款,然后規定一個到期時間,在該時間內用戶需將借款還清并收取一定的手續費,如果規定時間逾期未還上,則會產生滯納金。
用戶發起借款因此會產生一筆借款訂單,用戶可通過支付寶或在系統中綁定銀行卡到期自動扣款等方式進行還款。還款流程都走支付系統,因此用戶還款是否逾期以及逾期天數、逾期費等都通過系統來計算。

但是在做訂單系統的時候,遇到這樣一個業務場景,由于業務原因允許用戶通過線下支付寶還款,即我們提供一個公司官方的支付寶二維碼,用戶掃碼還款,然后財務不定期的去拉取該支付寶賬戶下的還款清單并生成規范化的Excel表格錄入到支付系統。
支付系統將這些支付信息生成對應的支付訂單并落庫,同時針對每筆還款記錄生產一個消息信息到消息系統,消息的消費者就是訂單系統。訂單系統接受到消息后去結算當前用戶的金額清算:先還本金,本金還清再還滯納金,都還清則該筆訂單結清并提升可借貸額度,……,整個流程大致如下:

從上面的流程描述可以知道,相當于原來線上的支付現在轉移到線下進行,這會產生一個問題:支付結算的不及時。例如用戶的訂單在今天19-05-27到期,但是用戶在19-05-26還清,財務在19-05-27甚至更晚的時候從支付寶拉取清單錄入支付系統。這樣就造成了實際上用戶是未逾期還清借款而我們這邊卻記錄的是用戶未還清且產生了滯納金。
當然以上的是業務范疇的問題,我們今天要說的是支付系統發送消息到訂單系統的環節中的一個問題。大家都知道為了避免消息丟失或者訂單系統處理異常或者網絡問題等問題,我們設計消息系統的時候都需要考慮消息持久化和消息的失敗重試機制。

對于重試機制,假如訂單系統消費了消息,但是由于網絡等問題消息系統未收到反饋是否已成功處理。這時消息系統會根據配置的規則隔段時間就 retry 一次。你 retry 一次沒錯,是為了保證系統的處理正常性,但是如果這時網絡恢復正常,我第一次收到的消息成功處理了,這時我又收到了一條消息,如果沒有做一些防護措施,會產生如下情況:用戶付款一次但是訂單系統計算了兩次,這樣會造成財務賬單異常對不上賬的情況發生。那就可能用戶笑呵呵老板哭兮兮了。
接口冪等性 #
為了防止上述情況的發生,我們需要提供一個防護措施,對于同一筆支付信息如果我其中某一次處理成功了,我雖然又接收到了消息,但是這時我不處理了,即保證接口的 冪等性 。
維基百科上的定義:
- 冪等(idempotent、idempotence)是一個數學與計算機學概念,常見于抽象代數中。
在編程中一個冪等操作的特點是其任意多次執行所產生的影響均與一次執行的影響相同。冪等函數,或冪等方法,是指可以使用相同參數重復執行,并能獲得相同結果的函數。這些函數不會影響系統狀態,也不用擔心重復執行會對系統造成改變。例如,“setTrue()”函數就是一個冪等函數,無論多次執行,其結果都是一樣的,更復雜的操作冪等保證是利用唯一交易號(流水號)實現.
任意多次執行所產生的影響均與一次執行的影響相同,這是冪等性的核心特點。其實在我們編程中主要操作就是CURD,其中讀取(Retrieve)操作和刪除(Delete)操作是天然冪等的,受影響的就是創建(Create)、更新(Update)。
對于業務中需要考慮冪等性的地方一般都是接口的重復請求,重復請求是指同一個請求因為某些原因被多次提交。導致這個情況會有幾種場景:
- 前端重復提交 :提交訂單,用戶快速重復點擊多次,造成后端生成多個內容重復的訂單。
- 接口超時重試 :對于給第三方調用的接口,為了防止網絡抖動或其他原因造成請求丟失,這樣的接口一般都會設計成超時重試多次。
- 消息重復消費 :MQ消息中間件,消息重復消費。
對于一些業務場景影響比較大的,接口的冪等性是個必須要考慮的問題,例如金錢的交易方面的接口。否則一個錯誤的、考慮不周的接口可能會給公司帶來巨額的金錢損失,那么背鍋的肯定是程序員自己了。
冪等性實現方式 #
對于和web端交互的接口,我們可以在前端攔截一部分,例如防止表單重復提交,按鈕置灰、隱藏、不可點擊等方式。
但是前端做控制實際效益不是很高,懂點技術的都會模擬請求調用你的服務,所以安全的策略還是需要從后端的接口層來做。
那么后端要實現分布式接口的冪等性有哪些策略方式呢?主要可以從以下幾個方面來考慮實現:
Token機制 #
針對前端重復連續多次點擊的情況,例如用戶購物提交訂單,提交訂單的接口就可以通過 Token 的機制實現防止重復提交。

主要流程就是:
- 服務端提供了發送token的接口。我們在分析業務的時候,哪些業務是存在冪等問題的,就必須在執行業務前,先去獲取token,服務器會把token保存到redis中。(微服務肯定是分布式了,如果單機就適用jvm緩存)。
- 然后調用業務接口請求時,把token攜帶過去,一般放在請求頭部。
- 服務器判斷token是否存在redis中,存在表示第一次請求,這時把redis中的token刪除,繼續執行業務。
- 如果判斷token不存在redis中,就表示是重復操作,直接返回重復標記給client,這樣就保證了業務代碼,不被重復執行。
數據庫去重表 #
往去重表里插入數據的時候,利用數據庫的唯一索引特性,保證唯一的邏輯。唯一序列號可以是一個字段,例如訂單的訂單號,也可以是多字段的唯一性組合。例如設計如下的數據庫表。
- CREATE TABLE `t_idempotent` (
- `id` int(11) NOT NULL COMMENT 'ID',
- `serial_no` varchar(255) NOT NULL COMMENT '唯一序列號',
- `source_type` varchar(255) NOT NULL COMMENT '資源類型',
- `status` int(4) DEFAULT NULL COMMENT '狀態',
- `remark` varchar(255) NOT NULL COMMENT '備注',
- `create_by` bigint(20) DEFAULT NULL COMMENT '創建人',
- `create_time` datetime DEFAULT NULL COMMENT '創建時間',
- `modify_by` bigint(20) DEFAULT NULL COMMENT '修改人',
- `modify_time` datetime DEFAULT NULL COMMENT '修改時間',
- PRIMARY KEY (`id`)
- UNIQUE KEY `key_s` (`serial_no`,`source_type`, `remark`) COMMENT '保證業務唯一性'
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='冪等性校驗表';
我們注意看如下這幾個關鍵性字段,
- @IdempotentKey
由于數據建立了 serial_no , source_type , remark 三個字段組合構成的唯一索引,所以可以通過這個來去重達到接口的冪等性,具體的代碼設計如下,
- public class PaymentOrderReq {
- /**
- * 支付寶流水號
- */
- @IdempotentKey(order=1)
- private String alipayNo;
- /**
- * 支付訂單ID
- */
- @IdempotentKey(order=2)
- private String paymentOrderNo;
- /**
- * 支付金額
- */
- private Long amount;
- }
因為支付寶流水號和訂單號在系統中是唯一的,所以唯一序列號可由他們組合 MD5 生成,具體的生成方式如下:
- private void getIdempotentKeys(Object keySource, Idempotent idempotent) {
- TreeMap<Integer, Object> keyMap = new TreeMap<Integer, Object>();
- for (Field field : keySource.getClass().getDeclaredFields()) {
- if (field.isAnnotationPresent(IdempotentKey.class)) {
- try {
- field.setAccessible(true);
- keyMap.put(field.getAnnotation(IdempotentKey.class).order(),
- field.get(keySource));
- } catch (IllegalArgumentException | IllegalAccessException e) {
- logger.error("", e);
- return;
- }
- }
- }
- generateIdempotentKey(idempotent, keyMap.values().toArray());
- }
生成冪等Key,如果有多個key可以通過分隔符 "|" 連接,
- private void generateIdempotentKey(Idempotent idempotent, Object... keyObj) {
- if (keyObj.length == 0) {
- logger.info("idempotentkey is empty,{}", keyObj);
- return;
- }
- StringBuilder serialNo= new StringBuilder();
- for (Object key : keyObj) {
- serialNo.append(key.toString()).append("|");
- }
- idempotent.setRemark(serialNo.toString());
- idempotent.setSerialNo(md5(serialNo));
- }
一切準備就緒,則可對外提供冪等性校驗的接口方法,接口方法為:
- public <T> void idempotentCheck(IdempotentTypeEnum idempotentType, T keyObj) throws IdempotentException {
- Idempotent idempotent = new Idempotent();
- getIdempotentKeys(keyObj, idempotent );
- if (StringUtils.isBlank(idempotent.getSerialNo())) {
- throw new ServiceException("fail to get idempotentkey");
- }
- idempotentEvent.setSourceType(idempotentType.name());
- try {
- idempotentMapper.saveIdempotent(idempotent);
- } catch (DuplicateKeyException e) {
- logger.error("idempotent check fail", e);
- throw new IdempotentException(idempotent);
- }
- }
當然這個接口的方法具體在項目中合理的使用就看項目要求了,可以通過 @Autowire 注解注入到需要使用的地方,但是缺點就是每個地方都需要調用。我個人推薦的是自定義一個注解,在需要冪等性保證的接口上加上該注解,然后通過攔截器方法攔截使用。這樣簡單便不會造成代碼侵入和污染。
另外,使用數據庫防重表的方式它有個嚴重的缺點,那就是系統容錯性不高,如果冪等表所在的數據庫連接異常或所在的服務器異常,則會導致整個系統冪等性校驗出問題。如果做數據庫備份來防止這種情況,又需要額外忙碌一通了啊。
Redis實現 #
上面介紹過防重表的設計方式和偽代碼,也說過它的一個很明顯的缺點。所以我們另外介紹一個Redis的實現方式。
Redis實現的方式就是將唯一序列號作為Key,唯一序列號的生成方式和上面介紹的防重表的一樣,value可以是你想填的任何信息。唯一序列號也可以是一個字段,例如訂單的訂單號,也可以是多字段的唯一性組合。當然這里需要設置一個 key 的過期時間,否則 Redis 中會存在過多的 key。具體校驗流程如下圖所示,實現代碼也很簡單這里就不寫了。

由于企業如果考慮在項目中使用 Redis,因為大部分會拿它作為緩存來使用,那么一般都會是集群的方式出現,至少肯定也會部署兩臺Redis服務器。所以我們使用Redis來實現接口的冪等性是最適合不過的了。
狀態機 #
對于很多業務是有一個業務流轉狀態的,每個狀態都有前置狀態和后置狀態,以及最后的結束狀態。例如流程的待審批,審批中,駁回,重新發起,審批通過,審批拒絕。訂單的待提交,待支付,已支付,取消。
以訂單為例,已支付的狀態的前置狀態只能是待支付,而取消狀態的前置狀態只能是待支付,通過這種狀態機的流轉我們就可以控制請求的冪等。
- public enum OrderStatusEnum {
- UN_SUBMIT(0, 0, "待提交"),
- UN_PADING(0, 1, "待支付"),
- PAYED(1, 2, "已支付待發貨"),
- DELIVERING(2, 3, "已發貨"),
- COMPLETE(3, 4, "已完成"),
- CANCEL(0, 5, "已取消"),
- ;
- //前置狀態
- private int preStatus;
- //狀態值
- private int status;
- //狀態描述
- private String desc;
- OrderStatusEnum(int preStatus, int status, String desc) {
- this.preStatus = preStatus;
- this.status = status;
- this.desc = desc;
- }
- //...
- }
假設當前狀態是已支付,這時候如果支付接口又接收到了支付請求,則會拋異常或拒絕此次處理。
總結
通過以上的了解我們可以知道,針對不同的業務場景我們需要靈活的選擇冪等性的實現方式。
例如防止類似于前端重復提交、重復下單的場景就可以通過 Token 的機制實現,而那些有狀態前置和后置轉換的場景則可以通過狀態機的方式實現冪等性,對于那些重復消費和接口重試的場景則使用數據庫唯一索引的方式實現更合理。