更新時(shí)間:2022年12月01日14時(shí)39分 來(lái)源:傳智教育 瀏覽次數(shù):
1-面試&實(shí)際開發(fā)場(chǎng)景
1-1面試場(chǎng)景題目
分布式服務(wù)接口的冪等性如何設(shè)計(jì)(比如不能重復(fù)扣款)?
1-2 題目分析
一個(gè)分布式系統(tǒng)中的某個(gè)接口,要保證冪等性,如何保證?這個(gè)事,其實(shí)是你做分布式系統(tǒng)的時(shí)候必須要考慮的一個(gè)生產(chǎn)環(huán)境的技術(shù)問(wèn)題,為什么呢?
實(shí)際案例1:
假如你有個(gè)服務(wù)提供一個(gè)付款業(yè)務(wù)的接口,而這個(gè)服務(wù)分別部署在5臺(tái)服務(wù)器上,然后用戶在前端操作時(shí),不知道為啥,一個(gè)訂單不小心發(fā)起了兩次支付請(qǐng)求,然后這倆請(qǐng)求分散在了這個(gè)服務(wù)部署的不同的服務(wù)器上,這下好了,一個(gè)訂單扣款扣了兩次。
實(shí)際案例2:
訂單系統(tǒng)調(diào)用支付系統(tǒng)進(jìn)行支付,結(jié)果不消息網(wǎng)絡(luò),然后訂單系統(tǒng)走了前面我們看到的重試retry機(jī)制,那就給你重試一次吧,那么支付系統(tǒng)收到了一個(gè)支付請(qǐng)求兩次,而且因?yàn)樨?fù)載均衡算法落在了不同的機(jī)器上。
小結(jié):
所以你必須得知道這事,否則你做出來(lái)的分布式系統(tǒng)恐怕很容易埋坑!
2-冪等性介紹
2-1-概念:
用戶對(duì)于同一操作發(fā)起的一次請(qǐng)求或者多次請(qǐng)求的結(jié)果是一致的,不會(huì)因?yàn)槎啻吸c(diǎn)擊而產(chǎn)生了副作用。
舉個(gè)簡(jiǎn)單的例子:那就是支付,用戶購(gòu)買商品后支付,支付扣款成功,但是返回結(jié)果的時(shí)候網(wǎng)絡(luò)異常了,此時(shí)錢已經(jīng)扣了,用戶再次點(diǎn)擊按鈕,此時(shí)會(huì)進(jìn)行第二次扣款,返回結(jié)果成功,用戶查詢余額發(fā)現(xiàn)多扣錢了,流水記錄也變成了兩條。在以前的單應(yīng)用系統(tǒng)中,我們只需要對(duì)數(shù)據(jù)操作加入事務(wù)即可,發(fā)生錯(cuò)誤的時(shí)候立即回滾,但是再響應(yīng)客戶端的時(shí)候也有可能網(wǎng)絡(luò)中斷或者異常等等情況。
2-2- 產(chǎn)生冪等性問(wèn)題的原因:
- 網(wǎng)絡(luò)問(wèn)題/用戶誤操作/惡意操作,用戶點(diǎn)擊了多次
- 網(wǎng)絡(luò)問(wèn)題,微服務(wù)重試retry
- 網(wǎng)絡(luò)問(wèn)題很常見(jiàn),100次請(qǐng)求,都o(jì)k;1萬(wàn)次請(qǐng)求可能1次超時(shí)會(huì)重試;10萬(wàn)次可能10次超時(shí)會(huì)重試,100萬(wàn)次可能100次超時(shí)會(huì)重試;如果100個(gè)請(qǐng)求重復(fù)了,你沒(méi)處理,導(dǎo)致訂單扣款2次,100個(gè)訂單都扣錯(cuò)了,每天被100個(gè)用戶投訴,一個(gè)月被3000個(gè)用戶投訴。
2-3- 使用冪等性的場(chǎng)景
- 前端重復(fù)提交:前端瞬時(shí)點(diǎn)擊多次造成表單重復(fù)提交
- 接口超時(shí)重試:接口可能會(huì)因?yàn)槟承┰蚨{(diào)用失敗,處于容錯(cuò)性考慮會(huì)加上失敗重試的機(jī)制。如果接口調(diào)用一半,再次調(diào)用就會(huì)因?yàn)榕K數(shù)據(jù)的存在而產(chǎn)生異常
- 消息重復(fù)消費(fèi):在使用消息中間件來(lái)處理消息隊(duì)列,且手動(dòng)ack確認(rèn)消息被正常消費(fèi)時(shí)。如果消費(fèi)者突然斷開鏈接,那么已經(jīng)執(zhí)行了一半的消息會(huì)重新放回隊(duì)列。被其他消費(fèi)者重新消費(fèi)時(shí)就會(huì)導(dǎo)致結(jié)果異常,如數(shù)據(jù)庫(kù)重復(fù)數(shù)據(jù), 數(shù)據(jù)庫(kù)數(shù)據(jù)沖突,資源重復(fù)等。
- 請(qǐng)求重發(fā):網(wǎng)絡(luò)抖動(dòng)引發(fā)的nginx重發(fā)請(qǐng)求,造成重復(fù)調(diào)用。
3-冪等性的解決方案
3-1- Insert接口冪等性
1.使用分布式鎖保證冪等性
秒殺場(chǎng)景下,一個(gè)用戶只能購(gòu)買同一商品一次的解決方法:采用用戶ID+商品ID,存儲(chǔ)到redis中,使用redis中的setNX操作,等待自然過(guò)期。
2.使用token機(jī)制保證冪等性
用戶注冊(cè)時(shí),用戶點(diǎn)擊注冊(cè)按鈕多次,是不是會(huì)注冊(cè)多個(gè)用戶?我們可以在用戶進(jìn)入注冊(cè)頁(yè)面后由后臺(tái)生成一個(gè)token,傳給前端頁(yè)面,用戶在點(diǎn)擊提交時(shí),將token帶給后臺(tái),后臺(tái)使用該token作為分布式鎖,setNX操作,執(zhí)行成功后不釋放鎖,等待自然過(guò)期。
3.使用mysql unique key 保證冪等性
用戶注冊(cè)時(shí),用戶點(diǎn)擊注冊(cè)按鈕多次,是不是會(huì)注冊(cè)多個(gè)用戶? 我們可以使用手機(jī)號(hào)作為mysql用戶表唯一key。也就是一個(gè)手機(jī)號(hào)只能注冊(cè)一次。
3-2- Update接口冪等性
update操作可能存在冪等性的問(wèn)題:
1.用戶更改個(gè)人信息,瘋狂點(diǎn)擊按鈕,不會(huì)發(fā)生冪等性問(wèn)題,因?yàn)閿?shù)據(jù)始終為修改后的數(shù)據(jù)。
2.用戶購(gòu)買商品,用戶在點(diǎn)擊后,網(wǎng)絡(luò)出現(xiàn)問(wèn)題,可能再次點(diǎn)擊,這樣就會(huì)出現(xiàn)冪等性問(wèn)題,導(dǎo)致購(gòu)買了多次,可以使用樂(lè)觀鎖。
update order set count=count-1,version=version+1 where id=1 and version=1
3-3- Delete接口冪等性
根據(jù)唯一id刪除不會(huì)出現(xiàn)冪等性問(wèn)題,因?yàn)榈诙蝿h除的時(shí)候mysql中已經(jīng)不存在該數(shù)據(jù)
3-4- Select接口冪等性
查詢操作不會(huì)改變數(shù)據(jù),所以是天然的冪等性操作。
3-5- 混合操作(一個(gè)接口包含多種操作)
使用`Token`機(jī)制,或使用`Token` + 分布式鎖的方案來(lái)解決冪等性問(wèn)題。
4-冪等性解決方案實(shí)現(xiàn)思路
4-1- Token機(jī)制實(shí)現(xiàn)
通過(guò)`Token` 機(jī)制實(shí)現(xiàn)接口的冪等性,這是一種比較通用性的實(shí)現(xiàn)方法。
具體流程步驟:
1.客戶端會(huì)先發(fā)送一個(gè)請(qǐng)求去獲取`Token`,服務(wù)端會(huì)生成一個(gè)全局唯一的`ID`作為`Token`保存在`Redis`中,同時(shí)把這個(gè)`ID`返回給客戶端;
2. 客戶端第二次調(diào)用業(yè)務(wù)請(qǐng)求的時(shí)候必須攜帶這個(gè)`Token`;
3. 服務(wù)端會(huì)校驗(yàn)這個(gè) `Token`,如果校驗(yàn)成功,則執(zhí)行業(yè)務(wù),并刪除`Redis`中的 `Token`;
4. 如果校驗(yàn)失敗,說(shuō)明`Redis`中已經(jīng)沒(méi)有對(duì)應(yīng)的 `Token`,則表示重復(fù)操作,直接返回指定的結(jié)果給客戶端。
4-2 基于MySQL實(shí)現(xiàn)
通過(guò)`MySQL`唯一索引的特性實(shí)現(xiàn)接口的冪等性。
具體流程步驟:
1.建立一張去重表,其中某個(gè)字段需要建立唯一索引;
2. 客戶端去請(qǐng)求服務(wù)端,服務(wù)端會(huì)將這次請(qǐng)求的一些信息插入這張去重表中;
3. 因?yàn)楸碇心硞€(gè)字段帶有唯一索引,如果插入成功,證明表中沒(méi)有這次請(qǐng)求的信息,則執(zhí)行后續(xù)的業(yè)務(wù)邏輯;
4. 如果插入失敗,則代表已經(jīng)執(zhí)行過(guò)當(dāng)前請(qǐng)求,直接返回。
4-3- 基于Redis實(shí)現(xiàn)
通過(guò)`Redis`的`SETNX`命令實(shí)現(xiàn)接口的冪等性。
> `SETNX key value`:當(dāng)且僅當(dāng)`key`不存在時(shí)將`key`的值設(shè)為`value`;若給定的`key`已經(jīng)存在,則`SETNX`不做任何動(dòng)作。設(shè)置成功時(shí)返回`1`,否則返回`0`。
具體流程步驟:
1.客戶端先請(qǐng)求服務(wù)端,會(huì)拿到一個(gè)能代表這次請(qǐng)求業(yè)務(wù)的唯一字段;
2. 將該字段以`SETNX`的方式存入`Redis`中,并根據(jù)業(yè)務(wù)設(shè)置相應(yīng)的超時(shí)時(shí)間;
3. 如果設(shè)置成功,證明這是第一次請(qǐng)求,則執(zhí)行后續(xù)的業(yè)務(wù)邏輯;
4. 如果設(shè)置失敗,則代表已經(jīng)執(zhí)行過(guò)當(dāng)前請(qǐng)求,直接返回。
5-冪等性解決方案案例實(shí)現(xiàn)
5-1-基于Token機(jī)制的實(shí)現(xiàn)
5-1-1-實(shí)現(xiàn)思路
為需要保證冪等性的每一次請(qǐng)求創(chuàng)建一個(gè)唯一的標(biāo)識(shí)token,先獲取token,并將此token存入到redis,請(qǐng)求接口時(shí),將此token放在header或者作為請(qǐng)求參數(shù)請(qǐng)求接口,后端接口判斷redis中是否存在此token;
- 如果存在,則正常處理業(yè)務(wù)邏輯,并從redis中刪除此token,那么,如果是重復(fù)請(qǐng)求,由于token已經(jīng)被刪除,則不能能夠通過(guò)校驗(yàn),返回重復(fù)提交。
- 如果不存在,說(shuō)明參數(shù)不合法或者是重復(fù)請(qǐng)求,返回提示即可。
5-1-2-請(qǐng)求流程
- 當(dāng)頁(yè)面加載的時(shí)候通過(guò)接口獲取token
- 當(dāng)訪問(wèn)接口時(shí),會(huì)經(jīng)過(guò)**攔截器**,如果發(fā)現(xiàn)該接口中有**自定義的冪等性注解**,說(shuō)明該接口需要驗(yàn)證冪等性(查看請(qǐng)求頭里是否有key=token的值,如果有,并且刪除成功,那么接口就訪問(wèn)成功,否則為重復(fù)提交;
- 如果發(fā)現(xiàn)該接口沒(méi)有自定義的冪等性注解,則放行。
5-1-3-代碼演示
1、使用的技術(shù)
- springBoot
- redis
- 自定義冪等性注解+攔截器請(qǐng)求攔截
- Jmeter壓測(cè)工具
2、創(chuàng)建項(xiàng)目
3、導(dǎo)入pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>org.example</groupId> <artifactId>springBoot-idempotent</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> </properties> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.2.RELEASE</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies> </project>
4、自定義注解
該注解的目的是為了實(shí)現(xiàn)冪等性的校驗(yàn),即添加了該注解的接口要實(shí)現(xiàn)冪等性驗(yàn)證
package com.ldp.idempotent.annotation; import java.lang.annotation.*; /** * 自定義注解 * 說(shuō)明:添加了該注解的接口要實(shí)現(xiàn)冪等性驗(yàn)證 */ @Target({ElementType.TYPE,ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ApiIdempotentAnn { boolean value() default true; }
5、冪等性攔截器
package com.ldp.idempotent.intceptor; import com.ldp.idempotent.annotation.ApiIdempotentAnn; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.PrintWriter; import java.lang.reflect.Method; /** * 冪等性攔截器 */ @Component public class ApiIdempotentInceptor extends HandlerInterceptorAdapter { @Autowired private StringRedisTemplate redisTemplate; /** * 前置攔截器 *在方法被調(diào)用前執(zhí)行。在該方法中可以做類似校驗(yàn)的功能。如果返回true,則繼續(xù)調(diào)用下一個(gè)攔截器。如果返回false,則中斷執(zhí)行, * 也就是說(shuō)我們想調(diào)用的方法 不會(huì)被執(zhí)行,但是你可以修改response為你想要的響應(yīng)。 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //如果hanler不是和HandlerMethod類型,則返回true if (!(handler instanceof HandlerMethod)) { return true; } //轉(zhuǎn)化類型 final HandlerMethod handlerMethod = (HandlerMethod) handler; //獲取方法類 final Method method = handlerMethod.getMethod(); // 判斷當(dāng)前method中是否有這個(gè)注解 boolean methodAnn = method.isAnnotationPresent(ApiIdempotentAnn.class); //如果有冪等性注解 if (methodAnn && method.getAnnotation(ApiIdempotentAnn.class).value()) { // 需要實(shí)現(xiàn)接口冪等性 //檢查token //1.獲取請(qǐng)求的接口方法 //查看當(dāng)前接口的方法之上是否有自定義的注解@ApiIdempotentAnn //如果說(shuō)包含了,則認(rèn)為該接口是要進(jìn)行冪等性校驗(yàn)的接口 //檢驗(yàn)token //如果說(shuō)有,則訪問(wèn)成功,執(zhí)行邏輯業(yè)務(wù),要?jiǎng)h除redis中的token //如果說(shuō)沒(méi)有,則表示重復(fù)調(diào)用 //如果說(shuō)沒(méi)有包含了,則直接放行 checkToken(request); //如果token有值,說(shuō)明是第一次調(diào)用 if (result) { //則放行 return super.preHandle(request, response, handler); } else {//如果token沒(méi)有值,則表示不是第一次調(diào)用,是重復(fù)調(diào)用 response.setContentType("application/json; charset=utf-8"); PrintWriter writer = response.getWriter(); writer.print("重復(fù)調(diào)用"); writer.close(); response.flushBuffer(); return false; } } //否則沒(méi)有該自定義冪等性注解,則放行 return super.preHandle(request, response, handler); } //檢查token private boolean checkToken(HttpServletRequest request) { //從請(qǐng)求頭對(duì)象中獲取token String token = request.getHeader("token"); //如果不存在,則返回false,說(shuō)明是重復(fù)調(diào)用 if(token==null || " ".equals(token)){ return false; } //否則就是存在,存在則把redis里刪除token return redisTemplate.delete(token); } //后置,暫時(shí)沒(méi)用 @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { super.postHandle(request, response, handler, modelAndView); } }
6、MVC配置文件
package com.ldp.idempotent.config; import com.ldp.idempotent.intceptor.ApiIdempotentInceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** * mvc配置 */ @Configuration public class MvcConfig implements WebMvcConfigurer { @Autowired private ApiIdempotentInceptor apiIdempotentInceptor; /* 添加自定義攔截器到Springmvc配置中,攔截所有請(qǐng)求 addInterceptor 需要一個(gè)實(shí)現(xiàn)HandlerInterceptor接口的攔截器實(shí)例 addPathPatterns 用于設(shè)置攔截器的過(guò)濾路徑規(guī)則;addPathPatterns("/**")對(duì)所有請(qǐng)求都攔截 excludePathPatterns:用于設(shè)置不需要攔截的過(guò)濾規(guī)則 */ @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(apiIdempotentInceptor).addPathPatterns("/**"); } }
7、接口實(shí)現(xiàn)
package com.ldp.idempotent.controller; import com.ldp.idempotent.annotation.ApiIdempotentAnn; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; @RestController public class ApiController { @Autowired private StringRedisTemplate stringRedisTemplate; /** * 前端獲取token,然后把該token放入請(qǐng)求的header中 * * @return */ @GetMapping("/getToken") public String getToken() { String token = UUID.randomUUID().toString().substring(1, 9); stringRedisTemplate.opsForValue().set(token, "1"); return token; } //定義int類型的原子類的類 AtomicInteger num=new AtomicInteger(100); /** * 主業(yè)務(wù)邏輯,num--,并且加了自定義接口 * * @return */ @GetMapping("/submit") @ApiIdempotentAnn public String submit() { // num-- num.decrementAndGet(); return "success"; } /** * 查看num的值 * * @return */ @GetMapping("/getNum") public String getNum() { return String.valueOf(num.get()); } }
8、PostMan測(cè)試
- 獲取token
瀏覽器訪問(wèn):http://localhost:9090/getToken,獲取token的值
- 執(zhí)行冪等性業(yè)務(wù)接口
- 第一次,在postman中調(diào)用當(dāng)前接口,并在請(qǐng)求頭中設(shè)置token
- 第二次,再次postman中訪問(wèn)該業(yè)務(wù)接口,顯示**重復(fù)調(diào)用**的提示
- 查看num的值得接口
瀏覽器訪問(wèn):http://localhost:9090/getNum
9-Jmeter壓力測(cè)試工具測(cè)試
使用方法參考**Jmeter壓力測(cè)試工具使用說(shuō)明v1.0
10-小結(jié)
通過(guò)以上代碼演示了解到,本案例對(duì)submit接口方法使用了基于token的冪等性解決方案,也就是當(dāng)前submit接口方法只能調(diào)用一次,如果由于網(wǎng)絡(luò)抖動(dòng)或者網(wǎng)絡(luò)異常出現(xiàn)多點(diǎn)或者點(diǎn)擊多次的情況,就會(huì)出現(xiàn)報(bào)錯(cuò)提示,不允許調(diào)用當(dāng)前接口,那么也就解決了當(dāng)前業(yè)務(wù)接口冪等性的問(wèn)題。
北京校區(qū)