業務場景
公司有個借貸的專案,具體業務類似於阿里的螞蟻借唄,使用者在平臺上借款,然後規定一個到期時間,在該時間內使用者需將借款還清並收取一定的手續費,如果規定時間逾期未還上,則會產生滯納金。
使用者發起借款因此會產生一筆借款訂單,使用者可通過支付寶或在系統中繫結銀行卡到期自動扣款等方式進行還款。還款流程都走支付系統,因此使用者還款是否逾期以及逾期天數、逾期費等都通過系統來計算。
但是在做訂單系統的時候,遇到這樣一個業務場景,由於業務原因允許使用者通過線下支付寶還款,即我們提供一個公司官方的支付寶二維碼,使用者掃碼還款,然後財務不定期的去拉取該支付寶賬戶下的還款清單並生成規範化的Excel表格錄入到支付系統。
支付系統將這些支付資訊生成對應的支付訂單並落庫,同時針對每筆還款記錄生產一個訊息資訊到訊息系統,訊息的消費者就是訂單系統。訂單系統接受到訊息後去結算當前使用者的金額清算:先還本金,本金還清再還滯納金,都還清則該筆訂單結清並提升可借貸額度,……,整個流程大致如下:
從上面的流程描述可以知道,相當於原來線上的支付現在轉移到線下進行,這會產生一個問題:支付結算的不及時。例如使用者的訂單在今天19-05-27到期,但是使用者在19-05-26還清,財務在19-05-27甚至更晚的時候從支付寶拉取清單錄入支付系統。這樣就造成了實際上使用者是未逾期還清借款而我們這邊卻記錄的是使用者未還清且產生了滯納金。
當然以上的是業務範疇的問題,我們今天要說的是支付系統傳送訊息到訂單系統的環節中的一個問題。大家都知道為了避免訊息丟失或者訂單系統處理異常或者網路問題等問題,我們設計訊息系統的時候都需要考慮訊息持久化和訊息的失敗重試機制。
對於重試機制,假如訂單系統消費了訊息,但是由於網路等問題訊息系統未收到反饋是否已成功處理。這時訊息系統會根據配置的規則隔段時間就 retry 一次。你 retry 一次沒錯,是為了保證系統的處理正常性,但是如果這時網路恢復正常,我第一次收到的訊息成功處理了,這時我又收到了一條訊息,如果沒有做一些防護措施,會產生如下情況:使用者付款一次但是訂單系統計算了兩次,這樣會造成財務賬單異常對不上賬的情況發生。那就可能使用者笑呵呵老闆哭兮兮了。
介面冪等性
為了防止上述情況的發生,我們需要提供一個防護措施,對於同一筆支付資訊如果我其中某一次處理成功了,我雖然又接收到了訊息,但是這時我不處理了,即保證介面的 冪等性。
維基百科上的定義:
冪等(idempotent、idempotence)是一個數學與計算機學概念,常見於抽象代數中。
在程式設計中一個冪等操作的特點是其任意多次執行所產生的影響均與一次執行的影響相同。冪等函式,或冪等方法,是指可以使用相同引數重複執行,並能獲得相同結果的函式。這些函式不會影響系統狀態,也不用擔心重複執行會對系統造成改變。例如,“setTrue()”函式就是一個冪等函式,無論多次執行,其結果都是一樣的,更復雜的操作冪等保證是利用唯一交易號(流水號)實現.
任意多次執行所產生的影響均與一次執行的影響相同,這是冪等性的核心特點。其實在我們程式設計中主要操作就是CURD,其中讀取(Retrieve)操作和刪除(Delete)操作是天然冪等的,受影響的就是建立(Create)、更新(Update)。
對於一些業務場景影響比較大的,介面的冪等性是個必須要考慮的問題,例如金錢的交易方面的介面。否則一個錯誤的、考慮不周的介面可能會給公司帶來鉅額的金錢損失,那麼背鍋的肯定是程式設計師自己了。
冪等性實現方式
對於和web端互動的介面,我們可以在前端攔截一部分,例如防止表單重複提交,按鈕置灰、隱藏、不可點選等方式。
但是前端做控制實際效益不是很高,懂點技術的都會模擬請求呼叫你的服務,所以安全的策略還是需要從後端的介面層來做。
那麼後端要實現分散式介面的冪等性有哪些策略方式呢?主要可以從以下幾個方面來考慮實現:
資料庫去重表
往去重表裡插入資料的時候,利用資料庫的唯一索引特性,保證唯一的邏輯。唯一序列號可以是一個欄位,例如訂單的訂單號,也可以是多欄位的唯一性組合。例如設計如下的資料庫表。
CREATE TABLE `t_idempotent` (
`id` int(11) NOT NULL COMMENT 'ID',
`serial_no` varchar(255) NOT NULL COMMENT '唯一序列號',
`source_type` varchar(255) DEFAULT NULL COMMENT '資源型別',
`status` int(4) DEFAULT NULL COMMENT '狀態',
`remark` varchar(255) DEFAULT 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`) COMMENT '保證業務唯一性'
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='冪等性校驗表';
看幾個關鍵性欄位,serial_no:唯一序列號的值,在這裡我設定的是通過註解@IdempotentKey
來標識請求物件中的欄位,通過對他們Md5加密獲取對應的值。
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
private void generateIdempotentKey(Idempotent idempotent, Object... keyObj) {
if (keyObj.length == 0) {
logger.info("idempotentkey is empty,{}", keyObj);
return;
}
StringBuilder sb = new StringBuilder();
for (Object key : keyObj) {
sb.append(key.toString()).append("|");
}
idempotent.setRemark(sb.toString());
idempotent.setSerialNo(Md5Util.md5(sb.toString()));
}
一切準備就緒,則可對外提供冪等性校驗的介面方法,介面方法為:
public <T> void idempotentCheck(IdempotentTypeEnum idempotentType, T keyObj) throws IdempotentException {
Idempotent idempotent = new Idempotent();
getIdempotentKeys(keyObj, idempotentEvent);
if (StringUtils.isBlank(idempotentEvent.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(idempotentEvent);
}
}
當然這個介面的方法具體在專案中合理的使用就看專案要求了,可以通過@Autowire
註解注入到需要使用的地方,但是缺點就是每個地方都需要呼叫。我個人推薦的是自定義一個註解,在需要冪等性保證的介面上加上該註解,然後通過攔截器方法攔截使用。這樣簡單便不會造成程式碼侵入和汙染。
另外,使用資料庫防重表的方式它有個嚴重的缺點,那就是系統容錯性不高,如果冪等表所在的資料庫連線異常或所在的伺服器異常,則會導致整個系統冪等性校驗出問題。如果做資料庫備份來防止這種情況,又需要額外忙碌一通了啊。
Redis實現
上面介紹過防重表的設計方式和虛擬碼,也說過它的一個很明顯的缺點。所以我們另外介紹一個Redis的實現方式。
Redis實現的方式就是將唯一序列號作為Key,唯一序列號的生成方式和上面介紹的防重表的一樣,value可以是你想填的任何資訊。唯一序列號也可以是一個欄位,例如訂單的訂單號,也可以是多欄位的唯一性組合。具體校驗流程如下圖所示,實現程式碼也很簡單這裡就不寫了。
由於企業如果考慮在專案中使用 Redis,因為大部分會拿它作為快取來使用,那麼一般都會是叢集的方式出現,至少肯定也會部署兩臺Redis伺服器。所以我們使用Redis來實現介面的冪等性是最適合不過的了。