轉(zhuǎn)載:https://testerhome.com/articles/29399
「欲野發(fā)表于 TesterHome 」
原文:
因果
最近在做公司接口測(cè)試的時(shí)候符喝,發(fā)現(xiàn)公司接口在應(yīng)對(duì)重復(fù)請(qǐng)求的懊渡,處理不是很好轴猎,當(dāng)時(shí)問(wèn)研發(fā)小哥腮郊,研發(fā)小哥說(shuō)對(duì)重復(fù)提交請(qǐng)求的深啤,當(dāng)前項(xiàng)目沒(méi)有做接口的冪等性的校驗(yàn)巍沙,說(shuō)實(shí)話神凑,當(dāng)時(shí)感覺(jué)一臉懵逼谈山,就在思考什么是冪等性,自己一直在做接口測(cè)試忆某,都不知道有這個(gè)東西点待,于是就自己換位思考,決定站在研發(fā)的角度弃舒,去學(xué)習(xí)以下如何實(shí)現(xiàn)冪等性的實(shí)現(xiàn)癞埠,畢竟知道如何實(shí)現(xiàn)的,在接下來(lái)的業(yè)務(wù)場(chǎng)景中聋呢,才能更好的測(cè)試苗踪。
業(yè)務(wù)場(chǎng)景
公司有個(gè)借貸的項(xiàng)目,具體業(yè)務(wù)類似于阿里的螞蟻借唄削锰,用戶在平臺(tái)上借款徒探,然后規(guī)定一個(gè)到期時(shí)間,在該時(shí)間內(nèi)用戶需將借款還清并收取一定的手續(xù)費(fèi)喂窟,如果規(guī)定時(shí)間逾期未還上,則會(huì)產(chǎn)生滯納金央串。
用戶發(fā)起借款因此會(huì)產(chǎn)生一筆借款訂單磨澡,用戶可通過(guò)支付寶或在系統(tǒng)中綁定銀行卡到期自動(dòng)扣款等方式進(jìn)行還款。還款流程都走支付系統(tǒng)质和,因此用戶還款是否逾期以及逾期天數(shù)稳摄、逾期費(fèi)等都通過(guò)系統(tǒng)來(lái)計(jì)算。
但是在做訂單系統(tǒng)的時(shí)候饲宿,遇到這樣一個(gè)業(yè)務(wù)場(chǎng)景厦酬,由于業(yè)務(wù)原因允許用戶通過(guò)線下支付寶還款,即我們提供一個(gè)公司官方的支付寶二維碼瘫想,用戶掃碼還款仗阅,然后財(cái)務(wù)不定期的去拉取該支付寶賬戶下的還款清單并生成規(guī)范化的 Excel 表格錄入到支付系統(tǒng)。
支付系統(tǒng)將這些支付信息生成對(duì)應(yīng)的支付訂單并落庫(kù)国夜,同時(shí)針對(duì)每筆還款記錄生產(chǎn)一個(gè)消息信息到消息系統(tǒng)减噪,消息的消費(fèi)者就是訂單系統(tǒng)。訂單系統(tǒng)接受到消息后去結(jié)算當(dāng)前用戶的金額清算:先還本金车吹,本金還清再還滯納金筹裕,都還清則該筆訂單結(jié)清并提升可借貸額度,……窄驹,整個(gè)流程大致如下:
- 從上面的流程描述可以知道朝卒,相當(dāng)于原來(lái)線上的支付現(xiàn)在轉(zhuǎn)移到線下進(jìn)行,這會(huì)產(chǎn)生一個(gè)問(wèn)題:支付結(jié)算的不及時(shí)乐埠。例如用戶的訂單在今天 19-05-27 到期抗斤,但是用戶在 19-05-26 還清囚企,財(cái)務(wù)在 19-05-27 甚至更晚的時(shí)候從支付寶拉取清單錄入支付系統(tǒng)。這樣就造成了實(shí)際上用戶是未逾期還清借款而我們這邊卻記錄的是用戶未還清且產(chǎn)生了滯納金豪治。
- 當(dāng)然以上的是業(yè)務(wù)范疇的問(wèn)題洞拨,我們今天要說(shuō)的是支付系統(tǒng)發(fā)送消息到訂單系統(tǒng)的環(huán)節(jié)中的一個(gè)問(wèn)題。大家都知道為了避免消息丟失或者訂單系統(tǒng)處理異掣耗猓或者網(wǎng)絡(luò)問(wèn)題等問(wèn)題烦衣,我們?cè)O(shè)計(jì)消息系統(tǒng)的時(shí)候都需要考慮消息持久化和消息的失敗重試機(jī)制。
- 對(duì)于重試機(jī)制掩浙,假如訂單系統(tǒng)消費(fèi)了消息花吟,但是由于網(wǎng)絡(luò)等問(wèn)題消息系統(tǒng)未收到反饋是否已成功處理。這時(shí)消息系統(tǒng)會(huì)根據(jù)配置的規(guī)則隔段時(shí)間就 retry 一次厨姚。你 retry 一次沒(méi)錯(cuò)衅澈,是為了保證系統(tǒng)的處理正常性,但是如果這時(shí)網(wǎng)絡(luò)恢復(fù)正常谬墙,我第一次收到的消息成功處理了今布,這時(shí)我又收到了一條消息,如果沒(méi)有做一些防護(hù)措施拭抬,會(huì)產(chǎn)生如下情況:用戶付款一次但是訂單系統(tǒng)計(jì)算了兩次部默,這樣會(huì)造成財(cái)務(wù)賬單異常對(duì)不上賬的情況發(fā)生。那就可能用戶笑呵呵老板哭兮兮了造虎。
接口冪等性
為了防止上述情況的發(fā)生傅蹂,我們需要提供一個(gè)防護(hù)措施,對(duì)于同一筆支付信息如果我其中某一次處理成功了算凿,我雖然又接收到了消息份蝴,但是這時(shí)我不處理了,即保證接口的 冪等性氓轰。
維基百科上的定義:
冪等(idempotent婚夫、idempotence)是一個(gè)數(shù)學(xué)與計(jì)算機(jī)學(xué)概念,常見(jiàn)于抽象代數(shù)中署鸡。
在編程中一個(gè)冪等操作的特點(diǎn)是其任意多次執(zhí)行所產(chǎn)生的影響均與一次執(zhí)行的影響相同请敦。冪等函數(shù),或冪等方法储玫,是指可以使用相同參數(shù)重復(fù)執(zhí)行侍筛,并能獲得相同結(jié)果的函數(shù)。這些函數(shù)不會(huì)影響系統(tǒng)狀態(tài)撒穷,也不用擔(dān)心重復(fù)執(zhí)行會(huì)對(duì)系統(tǒng)造成改變匣椰。例如,“setTrue()” 函數(shù)就是一個(gè)冪等函數(shù),無(wú)論多次執(zhí)行端礼,其結(jié)果都是一樣的禽笑,更復(fù)雜的操作冪等保證是利用唯一交易號(hào) (流水號(hào)) 實(shí)現(xiàn).
任意多次執(zhí)行所產(chǎn)生的影響均與一次執(zhí)行的影響相同入录,這是冪等性的核心特點(diǎn)。其實(shí)在我們編程中主要操作就是 CURD佳镜,其中讀攘鸥濉(Retrieve)操作和刪除(Delete)操作是天然冪等的,受影響的就是創(chuàng)建(Create)蟀伸、更新(Update)蚀同。
對(duì)于業(yè)務(wù)中需要考慮冪等性的地方一般都是接口的重復(fù)請(qǐng)求,重復(fù)請(qǐng)求是指同一個(gè)請(qǐng)求因?yàn)槟承┰虮欢啻翁峤话√汀?dǎo)致這個(gè)情況會(huì)有幾種場(chǎng)景:
- 前端重復(fù)提交:提交訂單蠢络,用戶快速重復(fù)點(diǎn)擊多次,造成后端生成多個(gè)內(nèi)容重復(fù)的訂單迟蜜。
- 接口超時(shí)重試:對(duì)于給第三方調(diào)用的接口刹孔,為了防止網(wǎng)絡(luò)抖動(dòng)或其他原因造成請(qǐng)求丟失,這樣的接口一般都會(huì)設(shè)計(jì)成超時(shí)重試多次娜睛。
- 消息重復(fù)消費(fèi):MQ 消息中間件髓霞,消息重復(fù)消費(fèi)。 對(duì)于一些業(yè)務(wù)場(chǎng)景影響比較大的畦戒,接口的冪等性是個(gè)必須要考慮的問(wèn)題方库,例如金錢的交易方面的接口。否則一個(gè)錯(cuò)誤的兢交、考慮不周的接口可能會(huì)給公司帶來(lái)巨額的金錢損失,那么背鍋的肯定是程序員自己了笼痹。
冪等性實(shí)現(xiàn)方式
對(duì)于和 web 端交互的接口配喳,我們可以在前端攔截一部分,例如防止表單重復(fù)提交凳干,按鈕置灰晴裹、隱藏、不可點(diǎn)擊等方式救赐。
但是前端做控制實(shí)際效益不是很高涧团,懂點(diǎn)技術(shù)的都會(huì)模擬請(qǐng)求調(diào)用你的服務(wù),所以安全的策略還是需要從后端的接口層來(lái)做经磅。
那么后端要實(shí)現(xiàn)分布式接口的冪等性有哪些策略方式呢泌绣?主要可以從以下幾個(gè)方面來(lái)考慮實(shí)現(xiàn):
Token 機(jī)制
針對(duì)前端重復(fù)連續(xù)多次點(diǎn)擊的情況,例如用戶購(gòu)物提交訂單预厌,提交訂單的接口就可以通過(guò) Token 的機(jī)制實(shí)現(xiàn)防止重復(fù)提交阿迈。
主要流程就是:
服務(wù)端提供了發(fā)送 token 的接口。我們?cè)诜治鰳I(yè)務(wù)的時(shí)候轧叽,哪些業(yè)務(wù)是存在冪等問(wèn)題的苗沧,就必須在執(zhí)行業(yè)務(wù)前刊棕,先去獲取 token,服務(wù)器會(huì)把 token 保存到 redis 中待逞。(微服務(wù)肯定是分布式了甥角,如果單機(jī)就適用 jvm 緩存)。
然后調(diào)用業(yè)務(wù)接口請(qǐng)求時(shí)识樱,把 token 攜帶過(guò)去嗤无,一般放在請(qǐng)求頭部。
服務(wù)器判斷 token 是否存在 redis 中牺荠,存在表示第一次請(qǐng)求翁巍,這時(shí)把 redis 中的 token 刪除,繼續(xù)執(zhí)行業(yè)務(wù)休雌。
如果判斷 token 不存在 redis 中灶壶,就表示是重復(fù)操作,直接返回重復(fù)標(biāo)記給 client杈曲,這樣就保證了業(yè)務(wù)代碼驰凛,不被重復(fù)執(zhí)行。
數(shù)據(jù)庫(kù)去重表
往去重表里插入數(shù)據(jù)的時(shí)候担扑,利用數(shù)據(jù)庫(kù)的唯一索引特性恰响,保證唯一的邏輯。唯一序列號(hào)可以是一個(gè)字段涌献,例如訂單的訂單號(hào)胚宦,也可以是多字段的唯一性組合。例如設(shè)計(jì)如下的數(shù)據(jù)庫(kù)表燕垃。
CREATE TABLE `t_idempotent` (
`id` int(11) NOT NULL COMMENT 'ID',
`serial_no` varchar(255) NOT NULL COMMENT '唯一序列號(hào)',
`source_type` varchar(255) NOT NULL COMMENT '資源類型',
`status` int(4) DEFAULT NULL COMMENT '狀態(tài)',
`remark` varchar(255) NOT NULL COMMENT '備注',
`create_by` bigint(20) DEFAULT NULL COMMENT '創(chuàng)建人',
`create_time` datetime DEFAULT NULL COMMENT '創(chuàng)建時(shí)間',
`modify_by` bigint(20) DEFAULT NULL COMMENT '修改人',
`modify_time` datetime DEFAULT NULL COMMENT '修改時(shí)間',
PRIMARY KEY (`id`)
UNIQUE KEY `key_s` (`serial_no`,`source_type`, `remark`) COMMENT '保證業(yè)務(wù)唯一性'
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='冪等性校驗(yàn)表';
我們注意看如下這幾個(gè)關(guān)鍵性字段枢劝,
serial_no:唯一序列號(hào)的值治专,在這里我設(shè)置的是通過(guò)注解@IdempotentKey來(lái)標(biāo)識(shí)請(qǐng)求對(duì)象中的字段怖辆,通過(guò)對(duì)他們 MD5 加密獲取對(duì)應(yīng)的值剃氧。
source_type:業(yè)務(wù)類型脂崔,區(qū)分不同的業(yè)務(wù)餐曹,訂單憋他,支付等国章。
remark:是由標(biāo)識(shí)字段的拼接成的字符串褐隆,拼接符為 “|”侦副。
由于數(shù)據(jù)建立了 serial_no,source_type, remark 三個(gè)字段組合構(gòu)成的唯一索引侦锯,所以可以通過(guò)這個(gè)來(lái)去重達(dá)到接口的冪等性,具體的代碼設(shè)計(jì)如下秦驯,
public class PaymentOrderReq {
/** * 支付寶流水號(hào) */
@IdempotentKey(order=1)
private String alipayNo;
/** * 支付訂單ID */
@IdempotentKey(order=2)
private String paymentOrderNo;
/** * 支付金額 */
private Long amount;
}
因?yàn)橹Ц秾毩魉?hào)和訂單號(hào)在系統(tǒng)中是唯一的率触,所以唯一序列號(hào)可由他們組合 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,如果有多個(gè) key 可以通過(guò)分隔符 "|" 連接葱蝗,
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));
}
一切準(zhǔn)備就緒穴张,則可對(duì)外提供冪等性校驗(yàn)的接口方法,接口方法為:
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);
}
}
當(dāng)然這個(gè)接口的方法具體在項(xiàng)目中合理的使用就看項(xiàng)目要求了两曼,可以通過(guò)@Autowired注解注入到需要使用的地方皂甘,但是缺點(diǎn)就是每個(gè)地方都需要調(diào)用。我個(gè)人推薦的是自定義一個(gè)注解悼凑,在需要冪等性保證的接口上加上該注解偿枕,然后通過(guò)攔截器方法攔截使用。這樣簡(jiǎn)單便不會(huì)造成代碼侵入和污染户辫。
另外渐夸,使用數(shù)據(jù)庫(kù)防重表的方式它有個(gè)嚴(yán)重的缺點(diǎn),那就是系統(tǒng)容錯(cuò)性不高渔欢,如果冪等表所在的數(shù)據(jù)庫(kù)連接異衬顾或所在的服務(wù)器異常,則會(huì)導(dǎo)致整個(gè)系統(tǒng)冪等性校驗(yàn)出問(wèn)題奥额。如果做數(shù)據(jù)庫(kù)備份來(lái)防止這種情況苫幢,又需要額外忙碌一通了啊。
Redis 實(shí)現(xiàn)
上面介紹過(guò)防重表的設(shè)計(jì)方式和偽代碼垫挨,也說(shuō)過(guò)它的一個(gè)很明顯的缺點(diǎn)韩肝。所以我另外介紹一個(gè) Redis 的實(shí)現(xiàn)方式。
Redis 實(shí)現(xiàn)的方式就是將唯一序列號(hào)作為 Key九榔,唯一序列號(hào)的生成方式和上面介紹的防重表的一樣哀峻,value 可以是你想填的任何信息。唯一序列號(hào)也可以是一個(gè)字段哲泊,例如訂單的訂單號(hào)剩蟀,也可以是多字段的唯一性組合。當(dāng)然這里需要設(shè)置一個(gè) key 的過(guò)期時(shí)間攻旦,否則 Redis 中會(huì)存在過(guò)多的 key喻旷。具體校驗(yàn)流程如下圖所示生逸,實(shí)現(xiàn)代碼也很簡(jiǎn)單這里就不寫(xiě)了牢屋。
由于企業(yè)如果考慮在項(xiàng)目中使用 Redis,因?yàn)榇蟛糠謺?huì)拿它作為緩存來(lái)使用槽袄,那么一般都會(huì)是集群的方式出現(xiàn)烙无,至少肯定也會(huì)部署兩臺(tái) Redis 服務(wù)器。所以我們使用 Redis 來(lái)實(shí)現(xiàn)接口的冪等性是最適合不過(guò)的了遍尺。
狀態(tài)機(jī)
對(duì)于很多業(yè)務(wù)是有一個(gè)業(yè)務(wù)流轉(zhuǎn)狀態(tài)的截酷,每個(gè)狀態(tài)都有前置狀態(tài)和后置狀態(tài),以及最后的結(jié)束狀態(tài)乾戏。例如流程的待審批迂苛,審批中三热,駁回,重新發(fā)起三幻,審批通過(guò)就漾,審批拒絕。訂單的待提交念搬,待支付抑堡,已支付,取消朗徊。
以訂單為例首妖,已支付的狀態(tài)的前置狀態(tài)只能是待支付,而取消狀態(tài)的前置狀態(tài)只能是待支付爷恳,通過(guò)這種狀態(tài)機(jī)的流轉(zhuǎn)我們就可以控制請(qǐng)求的冪等有缆。
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;
}
//...
}
假設(shè)當(dāng)前狀態(tài)是已支付,這時(shí)候如果支付接口又接收到了支付請(qǐng)求舌仍,則會(huì)拋異扯拭玻或拒絕此次處理。
總結(jié)
通過(guò)以上的了解我們可以知道铸豁,針對(duì)不同的業(yè)務(wù)場(chǎng)景我們需要靈活的選擇冪等性的實(shí)現(xiàn)方式灌曙。
例如防止類似于前端重復(fù)提交、重復(fù)下單的場(chǎng)景就可以通過(guò) Token 的機(jī)制實(shí)現(xiàn)节芥,而那些有狀態(tài)前置和后置轉(zhuǎn)換的場(chǎng)景則可以通過(guò)狀態(tài)機(jī)的方式實(shí)現(xiàn)冪等性在刺,對(duì)于那些重復(fù)消費(fèi)和接口重試的場(chǎng)景則使用數(shù)據(jù)庫(kù)唯一索引的方式實(shí)現(xiàn)更合理。
我個(gè)人讀后感:測(cè)試做久了头镊,文中提到訂單蚣驼,支付業(yè)務(wù)中涉及到的 重復(fù)請(qǐng)求,消息重復(fù)消費(fèi)相艇,重試機(jī)制颖杏,唯一性機(jī)制,這些功能點(diǎn)都知道需要去處理坛芽,但是重來(lái)沒(méi)有發(fā)現(xiàn)這些都屬于一種類型的問(wèn)題留储。知道冪等性后,才知道這些可以歸類一個(gè)問(wèn)題去處理咙轩,并且給出了 Token 機(jī)制获讳,數(shù)據(jù)庫(kù)去重表,Redis 實(shí)現(xiàn)活喊,狀態(tài)機(jī) 等方式具體落實(shí)丐膝。很棒