背景
最近在小組同學卷的受不了的情況下,我決定換一個方向卷去,在算法上還是認命吧,跟他們差距太大了, 在最近一段時間偶然看到網(wǎng)上關于接口冪等性校驗的文章,在我一番思索下,發(fā)現(xiàn)他們的實現(xiàn)原理各有不同而且每種實現(xiàn)原理各有不同,加之最近恰好在學設計模式,我就在想怎樣利用設計模式讓我們可以隨意選擇不同的實現(xiàn)方式。在此聲明一下,筆者僅僅是一個學生,對于正式的業(yè)務流程開發(fā)并不太懂,只是利用自己現(xiàn)有的知識儲備,打造一個讓自己使用起來更方便的小demo, 如果有大佬覺得哪兒有問題,歡迎指出。
什么是冪等性
在數(shù)學領域中對于冪等性的解釋是 f(f(x)) = f(x) 即冪等元素x在函數(shù)f的多次作用下,其效果和在f的一次作用下相同。在編程上可以理解為,如果某個函數(shù)(方法)或接口被調(diào)用多次其行為結果和被調(diào)用一次相同,則這種函數(shù)或接口就具有冪等性。簡單舉個例子,==天然冪等性==:
假設對象Person中有個name屬性,有個
setName(Stringname){
this.name=name
}
的方法,那這個方法就是天然冪等的哦,你輸入相同的“小明”參數(shù),不論你重復調(diào)用多少次都是將名字設置為“小明”,其對對象Person的影響都是一樣的。這就是天然冪等性。
==非冪等性==:還是拿對象Person舉例子,假設對象中有個age屬性,有個
increaseAge(){
this.age++;
}
方法,我們按正常的步驟一次一次調(diào)用是不會有問題的,如果調(diào)用者沒有控制好邏輯,一次流程重復調(diào)用好幾次,這時候影響效果和一次是有非常大區(qū)別,代碼編寫者以為它只會調(diào)用一次,結果出現(xiàn)了意外調(diào)用了很多次,恰好方法不具有冪等性,于是就會出現(xiàn)和預期不一樣的效果。這個方法本身是不具備冪等性的,我們可以修改這個方法,讓其傳入一個標識符,每一次重復的請求會有相同的標識符,方法內(nèi)部可以根據(jù)標識符查數(shù)據(jù)庫是不是已經(jīng)處理過,如果處理過就不重復處理。這樣方法就具備了冪等性。
更通俗一點就是:當在進行轉(zhuǎn)賬的時候,我們分了兩個系統(tǒng)來處理這個轉(zhuǎn)賬的流程:
①系統(tǒng)A負責收集轉(zhuǎn)賬人和接收人還有金額的信息然后傳給系統(tǒng)B進行轉(zhuǎn)賬,將控制邏輯留在系統(tǒng)A。
②系統(tǒng)B讀取系統(tǒng)A傳過來的信息,負責更改數(shù)據(jù)庫的金額。如果操作成功,就回復系統(tǒng)A成功,如果失敗就回復系統(tǒng)A失敗。
③系統(tǒng)A可以接受系統(tǒng)B操作成功或失敗的回復,但是我們知道,系統(tǒng)A這個交易流程是有等待時間的,如果等待超時,它不確認是否是轉(zhuǎn)賬成功或失敗,于是系統(tǒng)A會重試調(diào)用直到得到一個明確的回復。
這是系統(tǒng)大致的交易流程。這個流程是有問題的,系統(tǒng)B提供的操作接口不是冪等性的,因為A會重復調(diào)用接口,導致出現(xiàn)一個接口被同一個數(shù)據(jù)源發(fā)送相同數(shù)據(jù)切想要達到請求一次接口的效果的現(xiàn)象。
常見請求方式的冪等性
√ 滿足冪等
x 不滿足冪等
可能滿足也可能不滿足冪等,根據(jù)實際業(yè)務邏輯有關
| 方法類型 | 是否冪等 | 描述 |
|---|---|---|
| Get | √ | Get 方法用于獲取資源。其一般不會也不應當對系統(tǒng)資源進行改變,所以是冪等的。 |
| Post | x | Post 方法一般用于創(chuàng)建新的資源。其每次執(zhí)行都會新增數(shù)據(jù),所以不是冪等的。 |
| Put | _ | Put 方法一般用于修改資源。該操作則分情況來判斷是不是滿足冪等,更新操作中直接根據(jù)某個值進行更新,也能保持冪等。不過執(zhí)行累加操作的更新是非冪等。 |
| Delete | _ | Delete 方法一般用于刪除資源。該操作則分情況來判斷是不是滿足冪等,當根據(jù)唯一值進行刪除時,刪除同一個數(shù)據(jù)多次執(zhí)行效果一樣。不過需要注意,帶查詢條件的刪除則就不一定滿足冪等了。例如在根據(jù)條件刪除一批數(shù)據(jù)后,這時候新增加了一條數(shù)據(jù)也滿足條件,然后又執(zhí)行了一次刪除,那么將會導致新增加的這條滿足條件數(shù)據(jù)也被刪除。 |
為什么要實現(xiàn)冪等性校驗
在接口調(diào)用時一般情況下都能正常返回信息不會重復提交,不過在遇見以下情況時可以就會出現(xiàn)問題,如:
前端重復提交表單:在填寫一些表格時候,用戶填寫完成提交,很多時候會因網(wǎng)絡波動沒有及時對用戶做出提交成功響應,致使用戶認為沒有成功提交,然后一直點提交按鈕,這時就會發(fā)生重復提交表單請求。
用戶惡意進行刷單:例如在實現(xiàn)用戶投票這種功能時,如果用戶針對一個用戶進行重復提交投票,這樣會導致接口接收到用戶重復提交的投票信息,這樣會使投票結果與事實嚴重不符。
接口超時重復提交:很多時候 HTTP 客戶端工具都默認開啟超時重試的機制,尤其是第三方調(diào)用接口時候,為了防止網(wǎng)絡波動超時等造成的請求失敗,都會添加重試機制,導致一個請求提交多次。
消息進行重復消費:當使用 MQ 消息中間件時候,如果發(fā)生消息中間件出現(xiàn)錯誤未及時提交消費信息,導致發(fā)生重復消費。
使用冪等性最大的優(yōu)勢在于使接口保證任何冪等性操作,免去因重試等造成系統(tǒng)產(chǎn)生的未知的問題。
如何實現(xiàn)接口的冪等性校驗
網(wǎng)上流傳最多的應該是四種方式去實現(xiàn)接口的冪等性校驗,接下來我們來一個個盤點。
數(shù)據(jù)庫唯一主鍵
「方案描述」 數(shù)據(jù)庫唯一主鍵的實現(xiàn)主要是利用數(shù)據(jù)庫中主鍵唯一約束的特性,一般來說唯一主鍵比較適用于“插入”時的冪等性,其能保證一張表中只能存在一條帶該唯一主鍵的記錄。使用數(shù)據(jù)庫唯一主鍵完成冪等性時需要注意的是,該主鍵一般來說并不是使用數(shù)據(jù)庫中自增主鍵,而是使用分布式 ID 充當主鍵(或者使用其他算法生成的全局唯一的id),這樣才能能保證在分布式環(huán)境下 ID 的全局唯一性。
「適用操作:」 插入操作 刪除操作
「使用限制:」 需要生成全局唯一主鍵 ID;
「主要流程:」 ① 客戶端執(zhí)行創(chuàng)建請求,調(diào)用服務端接口。② 服務端執(zhí)行業(yè)務邏輯,生成一個分布式 ID,將該 ID 充當待插入數(shù)據(jù)的主鍵,然后執(zhí)數(shù)據(jù)插入操作,運行對應的 SQL 語句。③ 服務端將該條數(shù)據(jù)插入數(shù)據(jù)庫中,如果插入成功則表示沒有重復調(diào)用接口。如果拋出主鍵重復異常,則表示數(shù)據(jù)庫中已經(jīng)存在該條記錄,返回錯誤信息到客戶端。
數(shù)據(jù)庫樂觀鎖
「方案描述:」 數(shù)據(jù)庫樂觀鎖方案一般只能適用于執(zhí)行“更新操作”的過程,我們可以提前在對應的數(shù)據(jù)表中多添加一個字段,充當當前數(shù)據(jù)的版本標識。這樣每次對該數(shù)據(jù)庫該表的這條數(shù)據(jù)執(zhí)行更新時,都會將該版本標識作為一個條件,值為上次待更新數(shù)據(jù)中的版本標識的值。「適用操作:」 更新操作
「使用限制:」 需要數(shù)據(jù)庫對應業(yè)務表中添加額外字段;
防重 Token 令牌
「方案描述:」 針對客戶端連續(xù)點擊或者調(diào)用方的超時重試等情況,例如提交訂單,此種操作就可以用 Token 的機制實現(xiàn)防止重復提交。簡單的說就是調(diào)用方在調(diào)用接口的時候先向后端請求一個全局 ID(Token),請求的時候攜帶這個全局 ID 一起請求(Token 最好將其放到 Headers 中),后端需要對這個 Token 作為 Key,用戶信息作為 Value 到 Redis 中進行鍵值內(nèi)容校驗,如果 Key 存在且 Value 匹配就執(zhí)行刪除命令,然后正常執(zhí)行后面的業(yè)務邏輯。如果不存在對應的 Key 或 Value 不匹配就返回重復執(zhí)行的錯誤信息,這樣來保證冪等操作。
「適用操作:」 插入操作 更新操作 刪除操作
「使用限制:」 需要生成全局唯一 Token 串;需要使用第三方組件 Redis 進行數(shù)據(jù)效驗;
redis
「方案描述:」
第四種是我覺著用著挺方便的,但是實用性應該不大,而且和第三種類似,我們可以把接口名加請求參數(shù)通過算法生成一個全局唯一的id,然后 存到redis中,如果在一定時間請求多次,我們就直接拒絕。
「適用操作:」 插入操作 更新操作 刪除操作
「使用限制:」 需要使用第三方組件 Redis 進行數(shù)據(jù)效驗;
如何將這幾種方式都組裝到一起
我使用了Java自帶的注解以及設計模式中的策略模式,我們可以在注解中直接指定冪等性校驗的方式,當然也可以在配置文件中指定,但是直接在注解中指定更加靈活。
但是,由于最近時間比較忙,天天被某些人卷,很少有時間去完善,目前只是實現(xiàn)了redis和防重 Token 令牌兩種方式的。以下是部分代碼
「自定義注解」
packageorg.example.annotation; importjava.lang.annotation.*; /** *@authorzrq */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented public@interfaceRequestMany{ /** *策略 *@return */ Stringvalue()default""; /** *過期時間 *@return */ longexpireTime()default0; }
「定義切面」
packageorg.example.aop; importorg.aspectj.lang.ProceedingJoinPoint; importorg.aspectj.lang.annotation.Around; importorg.aspectj.lang.annotation.Aspect; importorg.aspectj.lang.reflect.MethodSignature; importorg.example.annotation.RequestMany; importorg.example.factory.RequestManyStrategy; importorg.springframework.beans.factory.annotation.Autowired; importorg.springframework.stereotype.Component; importorg.springframework.util.DigestUtils; importorg.springframework.web.context.request.RequestContextHolder; importorg.springframework.web.context.request.ServletRequestAttributes; importjavax.servlet.http.HttpServletRequest; importjava.lang.reflect.Method; importjava.util.Arrays; importjava.util.Map; importjava.util.stream.Collectors; /** *@authorzrq *@ClassNameRequestManyValidationAspect *@date2023/11/229:14 *@DescriptionTODO */ @Aspect @Component publicclassRequestManyValidationAspect{ @Autowired privateMapidempotentStrategies; @Around("@annotation(org.example.annotation.RequestMany)") publicObjectvalidateIdempotent(ProceedingJoinPointjoinPoint)throwsThrowable{ MethodSignaturemethodSignature=(MethodSignature)joinPoint.getSignature(); Methodmethod=methodSignature.getMethod(); RequestManyrequestMany=method.getAnnotation(RequestMany.class); Stringstrategy=requestMany.value();//獲取注解中配置的策略名稱 Integertime=(int)requestMany.expireTime();//獲取注解中配置的策略名稱 if(!idempotentStrategies.containsKey(strategy)){ thrownewIllegalArgumentException("Invalididempotentstrategy:"+strategy); } Stringkey=generateKey(joinPoint);//根據(jù)方法參數(shù)等生成唯一的key RequestManyStrategyidempotentStrategy=idempotentStrategies.get(strategy); idempotentStrategy.validate(key,time); returnjoinPoint.proceed(); } privateStringgenerateKey(ProceedingJoinPointjoinPoint){ //獲取類名 StringclassName=joinPoint.getTarget().getClass().getSimpleName(); //獲取方法名 MethodSignaturemethodSignature=(MethodSignature)joinPoint.getSignature(); StringmethodName=methodSignature.getMethod().getName(); //獲取方法參數(shù) Object[]args=joinPoint.getArgs(); StringargString=Arrays.stream(args) .map(Object::toString) .collect(Collectors.joining(",")); //獲取請求攜帶的Token HttpServletRequestrequest=((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest(); Stringtoken=request.getHeader("token"); //生成唯一的key Stringkey=className+":"+methodName+":"+argString+":"+token; Stringmd5Password=DigestUtils.md5DigestAsHex(key.getBytes()); returnmd5Password; } }
「處理異常」
packageorg.example.exception; /** *運行時異常 *@authorbinbin.hou *@since0.0.1 */ publicclassRequestManyValidationExceptionextendsRuntimeException{ publicRequestManyValidationException(){ } publicRequestManyValidationException(Stringmessage){ super(message); } publicRequestManyValidationException(Stringmessage,Throwablecause){ super(message,cause); } publicRequestManyValidationException(Throwablecause){ super(cause); } publicRequestManyValidationException(Stringmessage,Throwablecause,booleanenableSuppression,booleanwritableStackTrace){ super(message,cause,enableSuppression,writableStackTrace); } }
「模式工廠」
packageorg.example.factory;
importorg.example.exception.RequestManyValidationException;
/**
*@authorzrq
*@ClassNameRequestManyStrategy
*@date2023/11/229:04
*@DescriptionTODO
*/
publicinterfaceRequestManyStrategy{
voidvalidate(Stringkey,Integertime)throwsRequestManyValidationException;
}
「模式實現(xiàn)01」
packageorg.example.factory.impl;
importorg.example.exception.RequestManyValidationException;
importorg.example.factory.RequestManyStrategy;
importorg.example.utils.RedisCache;
importorg.springframework.stereotype.Component;
importjavax.annotation.Resource;
importjava.util.concurrent.TimeUnit;
/**
*@authorzrq
*@ClassNameRedisIdempotentStrategy
*@date2023/11/229:07
*@DescriptionTODO
*/
@Component
publicclassRedisIdempotentStrategyimplementsRequestManyStrategy{
@Resource
privateRedisCacheredisCache;
@Override
publicvoidvalidate(Stringkey,Integertime)throwsRequestManyValidationException{
if(redisCache.hasKey(key)){
thrownewRequestManyValidationException("請求次數(shù)過多");
}else{
redisCache.setCacheObject(key,"1",time,TimeUnit.MINUTES);
}
}
}
「模式實現(xiàn)02」
packageorg.example.factory.impl;
importorg.example.exception.RequestManyValidationException;
importorg.example.factory.RequestManyStrategy;
importorg.example.utils.RedisCache;
importorg.springframework.data.redis.connection.RedisConnectionFactory;
importorg.springframework.data.redis.connection.jedis.JedisConnectionFactory;
importorg.springframework.data.redis.core.RedisTemplate;
importorg.springframework.data.redis.serializer.StringRedisSerializer;
importorg.springframework.stereotype.Component;
importorg.springframework.web.context.request.RequestContextHolder;
importorg.springframework.web.context.request.ServletRequestAttributes;
importjavax.annotation.Resource;
importjavax.servlet.http.HttpServletRequest;
/**
*@authorzrq
*@ClassNameTokenIdempotentStrategy
*@date2023/11/229:13
*@DescriptionTODO
*/
@Component
publicclassTokenIdempotentStrategyimplementsRequestManyStrategy{
@Resource
privateRedisCacheredisCache;
@Override
publicvoidvalidate(Stringkey,Integertime)throwsRequestManyValidationException{
HttpServletRequestrequest=((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
Stringtoken=request.getHeader("token");
if(token==null||token.isEmpty()){
thrownewRequestManyValidationException("未授權的token");
}
//根據(jù)key和token執(zhí)行冪等性校驗
booleanisDuplicateRequest=performTokenValidation(key,token);
if(!isDuplicateRequest){
thrownewRequestManyValidationException("多次請求");
}
}
privatebooleanperformTokenValidation(Stringkey,Stringtoken){
//執(zhí)行根據(jù)Token進行冪等性校驗的邏輯
//這里可以使用你選擇的合適的方法,比如將Token存儲到數(shù)據(jù)庫或緩存中,然后檢查是否已存在
StringstoredToken=redisCache.getCacheObject(key);
//比較存儲的Token和當前請求的Token是否一致
returntoken.equals(storedToken);
}
}
「redisutil類」
packageorg.example.utils; importlombok.extern.slf4j.Slf4j; importorg.springframework.beans.factory.annotation.Autowired; importorg.springframework.data.redis.connection.BitFieldSubCommands; importorg.springframework.data.redis.core.*; importorg.springframework.stereotype.Component; importjava.util.*; importjava.util.concurrent.TimeUnit; @SuppressWarnings(value={"unchecked","rawtypes"}) @Component @Slf4j publicclassRedisCache { @Autowired publicRedisTemplateredisTemplate; @Autowired privateStringRedisTemplatestringRedisTemplate; /** *緩存基本的對象,Integer、String、實體類等 * *@paramkey緩存的鍵值 *@paramvalue緩存的值 */ publicvoidsetCacheObject(finalStringkey,finalTvalue) { redisTemplate.opsForValue().set(key,value); } /** *緩存基本的對象,Integer、String、實體類等 * *@paramkey緩存的鍵值 *@paramvalue緩存的值 *@paramtimeout時間 *@paramtimeUnit時間顆粒度 */ public voidsetCacheObject(finalStringkey,finalTvalue,finalIntegertimeout,finalTimeUnittimeUnit) { redisTemplate.opsForValue().set(key,value,timeout,timeUnit); } /** *設置有效時間 * *@paramkeyRedis鍵 *@paramtimeout超時時間 *@returntrue=設置成功;false=設置失敗 */ publicbooleanexpire(finalStringkey,finallongtimeout) { returnexpire(key,timeout,TimeUnit.SECONDS); } publicbooleanhasKey(finalStringkey) { returnBoolean.TRUE.equals(redisTemplate.hasKey(key)); } /** *設置有效時間 * *@paramkeyRedis鍵 *@paramtimeout超時時間 *@paramunit時間單位 *@returntrue=設置成功;false=設置失敗 */ publicbooleanexpire(finalStringkey,finallongtimeout,finalTimeUnitunit) { returnredisTemplate.expire(key,timeout,unit); } /** *獲得緩存的基本對象。 * *@paramkey緩存鍵值 *@return緩存鍵值對應的數(shù)據(jù) */ public TgetCacheObject(finalStringkey) { ValueOperations operation=redisTemplate.opsForValue(); returnoperation.get(key); } /** *刪除單個對象 * *@paramkey */ publicbooleandeleteObject(finalStringkey) { returnredisTemplate.delete(key); } /** *刪除集合對象 * *@paramcollection多個對象 *@return */ publiclongdeleteObject(finalCollectioncollection) { returnredisTemplate.delete(collection); } /** *緩存List數(shù)據(jù) * *@paramkey緩存的鍵值 *@paramdataList待緩存的List數(shù)據(jù) *@return緩存的對象 */ public longsetCacheList(finalStringkey,finalList dataList) { Longcount=redisTemplate.opsForList().rightPushAll(key,dataList); returncount==null?0:count; } /** *獲得緩存的list對象 * *@paramkey緩存的鍵值 *@return緩存鍵值對應的數(shù)據(jù) */ public List getCacheList(finalStringkey) { returnredisTemplate.opsForList().range(key,0,-1); } /** *緩存Set * *@paramkey緩存鍵值 *@paramdataSet緩存的數(shù)據(jù) *@return緩存數(shù)據(jù)的對象 */ public BoundSetOperations setCacheSet(finalStringkey,finalSet dataSet) { BoundSetOperations setOperation=redisTemplate.boundSetOps(key); Iterator it=dataSet.iterator(); while(it.hasNext()) { setOperation.add(it.next()); } returnsetOperation; } /** *獲得緩存的set * *@paramkey *@return */ public Set getCacheSet(finalStringkey) { returnredisTemplate.opsForSet().members(key); } /** *緩存Map * *@paramkey *@paramdataMap */ public voidsetCacheMap(finalStringkey,finalMap dataMap) { if(dataMap!=null){ redisTemplate.opsForHash().putAll(key,dataMap); } } /** *獲得緩存的Map * *@paramkey *@return */ public Map getCacheMap(finalStringkey) { returnredisTemplate.opsForHash().entries(key); } /** *往Hash中存入數(shù)據(jù) * *@paramkeyRedis鍵 *@paramhKeyHash鍵 *@paramvalue值 */ public voidsetCacheMapValue(finalStringkey,finalStringhKey,finalTvalue) { redisTemplate.opsForHash().put(key,hKey,value); } /** *獲取Hash中的數(shù)據(jù) * *@paramkeyRedis鍵 *@paramhKeyHash鍵 *@returnHash中的對象 */ public TgetCacheMapValue(finalStringkey,finalStringhKey) { HashOperations opsForHash=redisTemplate.opsForHash(); returnopsForHash.get(key,hKey); } /** *刪除Hash中的數(shù)據(jù) * *@paramkey *@paramhkey */ publicvoiddelCacheMapValue(finalStringkey,finalStringhkey) { HashOperationshashOperations=redisTemplate.opsForHash(); hashOperations.delete(key,hkey); } /** *獲取多個Hash中的數(shù)據(jù) * *@paramkeyRedis鍵 *@paramhKeysHash鍵集合 *@returnHash對象集合 */ public List getMultiCacheMapValue(finalStringkey,finalCollection
「配置文件」

如果要實現(xiàn)其他方式的話只需要實現(xiàn)下RequestManyStrategy模板方法,然后編寫自己的校驗邏輯就可以。
以上代碼已經(jīng)上傳到github :https://github.com/Lumos-i/tools-and-frameworks
結語
大學過的可真快,轉(zhuǎn)眼就大三了,自己的技術還是不行,跟別人的差距還有很大距離,希望自己能在有限的時間里學到更多有用的知識,同時也希望在明年的這個時候可以坐在辦公室里敲代碼。突然想到高中時中二的一句話“聽聞少年二字,應與平庸相斥”,誰不希望這樣呢,奈何身邊大佬太多,現(xiàn)在只能追趕別人的腳步。。。
審核編輯:黃飛
-
接口
+關注
關注
33文章
9519瀏覽量
157020 -
數(shù)據(jù)庫
+關注
關注
7文章
4019瀏覽量
68339 -
Redis
+關注
關注
0文章
392瀏覽量
12185
原文標題:策略模式實現(xiàn)接口的冪等性校驗
文章出處:【微信號:芋道源碼,微信公眾號:芋道源碼】歡迎添加關注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
離線計算中的冪等和DataWorks中的相關事項
循環(huán)冗余校驗碼的單片機及CPLD 實現(xiàn)
容錯系統(tǒng)中的自校驗技術及實現(xiàn)方法
電量測量裝置校驗中接口電路的實現(xiàn)方法
在高并發(fā)下怎么保證接口的冪等性?
CRC校驗原理及實現(xiàn)
什么是冪等性?關于接口冪等性的解決方案
分析解決冪等(性)的方法
Spring Boot實現(xiàn)接口冪等性的4種方案
什么是冪等性?冪等的實現(xiàn)原理
FPGA奇偶校驗的基本原理及實現(xiàn)方法
一個注解,優(yōu)雅的實現(xiàn)接口冪等性!
探索LabVIEW編程接口冪等性原理與實踐
如何秒級實現(xiàn)接口間“冪等”補償:一款輕量級仿冪等數(shù)據(jù)校正處理輔助工具
為什么要實現(xiàn)冪等性校驗 如何實現(xiàn)接口的冪等性校驗
評論