更新時間:2022年12月01日14時39分 來源:傳智教育 瀏覽次數(shù):
1-面試&實際開發(fā)場景
1-1面試場景題目
分布式服務接口的冪等性如何設計(比如不能重復扣款)?
1-2 題目分析
一個分布式系統(tǒng)中的某個接口,要保證冪等性,如何保證?這個事,其實是你做分布式系統(tǒng)的時候必須要考慮的一個生產(chǎn)環(huán)境的技術問題,為什么呢?
實際案例1:
假如你有個服務提供一個付款業(yè)務的接口,而這個服務分別部署在5臺服務器上,然后用戶在前端操作時,不知道為啥,一個訂單不小心發(fā)起了兩次支付請求,然后這倆請求分散在了這個服務部署的不同的服務器上,這下好了,一個訂單扣款扣了兩次。
實際案例2:
訂單系統(tǒng)調(diào)用支付系統(tǒng)進行支付,結(jié)果不消息網(wǎng)絡,然后訂單系統(tǒng)走了前面我們看到的重試retry機制,那就給你重試一次吧,那么支付系統(tǒng)收到了一個支付請求兩次,而且因為負載均衡算法落在了不同的機器上。
小結(jié):
所以你必須得知道這事,否則你做出來的分布式系統(tǒng)恐怕很容易埋坑!
2-冪等性介紹
2-1-概念:
用戶對于同一操作發(fā)起的一次請求或者多次請求的結(jié)果是一致的,不會因為多次點擊而產(chǎn)生了副作用。
舉個簡單的例子:那就是支付,用戶購買商品后支付,支付扣款成功,但是返回結(jié)果的時候網(wǎng)絡異常了,此時錢已經(jīng)扣了,用戶再次點擊按鈕,此時會進行第二次扣款,返回結(jié)果成功,用戶查詢余額發(fā)現(xiàn)多扣錢了,流水記錄也變成了兩條。在以前的單應用系統(tǒng)中,我們只需要對數(shù)據(jù)操作加入事務即可,發(fā)生錯誤的時候立即回滾,但是再響應客戶端的時候也有可能網(wǎng)絡中斷或者異常等等情況。
2-2- 產(chǎn)生冪等性問題的原因:
- 網(wǎng)絡問題/用戶誤操作/惡意操作,用戶點擊了多次
- 網(wǎng)絡問題,微服務重試retry
- 網(wǎng)絡問題很常見,100次請求,都ok;1萬次請求可能1次超時會重試;10萬次可能10次超時會重試,100萬次可能100次超時會重試;如果100個請求重復了,你沒處理,導致訂單扣款2次,100個訂單都扣錯了,每天被100個用戶投訴,一個月被3000個用戶投訴。
2-3- 使用冪等性的場景
- 前端重復提交:前端瞬時點擊多次造成表單重復提交
- 接口超時重試:接口可能會因為某些原因而調(diào)用失敗,處于容錯性考慮會加上失敗重試的機制。如果接口調(diào)用一半,再次調(diào)用就會因為臟數(shù)據(jù)的存在而產(chǎn)生異常
- 消息重復消費:在使用消息中間件來處理消息隊列,且手動ack確認消息被正常消費時。如果消費者突然斷開鏈接,那么已經(jīng)執(zhí)行了一半的消息會重新放回隊列。被其他消費者重新消費時就會導致結(jié)果異常,如數(shù)據(jù)庫重復數(shù)據(jù), 數(shù)據(jù)庫數(shù)據(jù)沖突,資源重復等。
- 請求重發(fā):網(wǎng)絡抖動引發(fā)的nginx重發(fā)請求,造成重復調(diào)用。
3-冪等性的解決方案
3-1- Insert接口冪等性
1.使用分布式鎖保證冪等性
秒殺場景下,一個用戶只能購買同一商品一次的解決方法:采用用戶ID+商品ID,存儲到redis中,使用redis中的setNX操作,等待自然過期。
2.使用token機制保證冪等性
用戶注冊時,用戶點擊注冊按鈕多次,是不是會注冊多個用戶?我們可以在用戶進入注冊頁面后由后臺生成一個token,傳給前端頁面,用戶在點擊提交時,將token帶給后臺,后臺使用該token作為分布式鎖,setNX操作,執(zhí)行成功后不釋放鎖,等待自然過期。
3.使用mysql unique key 保證冪等性
用戶注冊時,用戶點擊注冊按鈕多次,是不是會注冊多個用戶? 我們可以使用手機號作為mysql用戶表唯一key。也就是一個手機號只能注冊一次。
3-2- Update接口冪等性
update操作可能存在冪等性的問題:
1.用戶更改個人信息,瘋狂點擊按鈕,不會發(fā)生冪等性問題,因為數(shù)據(jù)始終為修改后的數(shù)據(jù)。
2.用戶購買商品,用戶在點擊后,網(wǎng)絡出現(xiàn)問題,可能再次點擊,這樣就會出現(xiàn)冪等性問題,導致購買了多次,可以使用樂觀鎖。
update order set count=count-1,version=version+1 where id=1 and version=1
3-3- Delete接口冪等性
根據(jù)唯一id刪除不會出現(xiàn)冪等性問題,因為第二次刪除的時候mysql中已經(jīng)不存在該數(shù)據(jù)
3-4- Select接口冪等性
查詢操作不會改變數(shù)據(jù),所以是天然的冪等性操作。
3-5- 混合操作(一個接口包含多種操作)
使用`Token`機制,或使用`Token` + 分布式鎖的方案來解決冪等性問題。
4-冪等性解決方案實現(xiàn)思路
4-1- Token機制實現(xiàn)
通過`Token` 機制實現(xiàn)接口的冪等性,這是一種比較通用性的實現(xiàn)方法。
具體流程步驟:
1.客戶端會先發(fā)送一個請求去獲取`Token`,服務端會生成一個全局唯一的`ID`作為`Token`保存在`Redis`中,同時把這個`ID`返回給客戶端;
2. 客戶端第二次調(diào)用業(yè)務請求的時候必須攜帶這個`Token`;
3. 服務端會校驗這個 `Token`,如果校驗成功,則執(zhí)行業(yè)務,并刪除`Redis`中的 `Token`;
4. 如果校驗失敗,說明`Redis`中已經(jīng)沒有對應的 `Token`,則表示重復操作,直接返回指定的結(jié)果給客戶端。
4-2 基于MySQL實現(xiàn)
通過`MySQL`唯一索引的特性實現(xiàn)接口的冪等性。
具體流程步驟:
1.建立一張去重表,其中某個字段需要建立唯一索引;
2. 客戶端去請求服務端,服務端會將這次請求的一些信息插入這張去重表中;
3. 因為表中某個字段帶有唯一索引,如果插入成功,證明表中沒有這次請求的信息,則執(zhí)行后續(xù)的業(yè)務邏輯;
4. 如果插入失敗,則代表已經(jīng)執(zhí)行過當前請求,直接返回。
4-3- 基于Redis實現(xiàn)
通過`Redis`的`SETNX`命令實現(xiàn)接口的冪等性。
> `SETNX key value`:當且僅當`key`不存在時將`key`的值設為`value`;若給定的`key`已經(jīng)存在,則`SETNX`不做任何動作。設置成功時返回`1`,否則返回`0`。
具體流程步驟:
1.客戶端先請求服務端,會拿到一個能代表這次請求業(yè)務的唯一字段;
2. 將該字段以`SETNX`的方式存入`Redis`中,并根據(jù)業(yè)務設置相應的超時時間;
3. 如果設置成功,證明這是第一次請求,則執(zhí)行后續(xù)的業(yè)務邏輯;
4. 如果設置失敗,則代表已經(jīng)執(zhí)行過當前請求,直接返回。
5-冪等性解決方案案例實現(xiàn)
5-1-基于Token機制的實現(xiàn)
5-1-1-實現(xiàn)思路
為需要保證冪等性的每一次請求創(chuàng)建一個唯一的標識token,先獲取token,并將此token存入到redis,請求接口時,將此token放在header或者作為請求參數(shù)請求接口,后端接口判斷redis中是否存在此token;
- 如果存在,則正常處理業(yè)務邏輯,并從redis中刪除此token,那么,如果是重復請求,由于token已經(jīng)被刪除,則不能能夠通過校驗,返回重復提交。
- 如果不存在,說明參數(shù)不合法或者是重復請求,返回提示即可。
5-1-2-請求流程
- 當頁面加載的時候通過接口獲取token
- 當訪問接口時,會經(jīng)過**攔截器**,如果發(fā)現(xiàn)該接口中有**自定義的冪等性注解**,說明該接口需要驗證冪等性(查看請求頭里是否有key=token的值,如果有,并且刪除成功,那么接口就訪問成功,否則為重復提交;
- 如果發(fā)現(xiàn)該接口沒有自定義的冪等性注解,則放行。
5-1-3-代碼演示
1、使用的技術
- springBoot
- redis
- 自定義冪等性注解+攔截器請求攔截
- Jmeter壓測工具
2、創(chuàng)建項目
3、導入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、自定義注解
該注解的目的是為了實現(xiàn)冪等性的校驗,即添加了該注解的接口要實現(xiàn)冪等性驗證
package com.ldp.idempotent.annotation; import java.lang.annotation.*; /** * 自定義注解 * 說明:添加了該注解的接口要實現(xià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í)行。在該方法中可以做類似校驗的功能。如果返回true,則繼續(xù)調(diào)用下一個攔截器。如果返回false,則中斷執(zhí)行, * 也就是說我們想調(diào)用的方法 不會被執(zhí)行,但是你可以修改response為你想要的響應。 */ @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(); // 判斷當前method中是否有這個注解 boolean methodAnn = method.isAnnotationPresent(ApiIdempotentAnn.class); //如果有冪等性注解 if (methodAnn && method.getAnnotation(ApiIdempotentAnn.class).value()) { // 需要實現(xiàn)接口冪等性 //檢查token //1.獲取請求的接口方法 //查看當前接口的方法之上是否有自定義的注解@ApiIdempotentAnn //如果說包含了,則認為該接口是要進行冪等性校驗的接口 //檢驗token //如果說有,則訪問成功,執(zhí)行邏輯業(yè)務,要刪除redis中的token //如果說沒有,則表示重復調(diào)用 //如果說沒有包含了,則直接放行 checkToken(request); //如果token有值,說明是第一次調(diào)用 if (result) { //則放行 return super.preHandle(request, response, handler); } else {//如果token沒有值,則表示不是第一次調(diào)用,是重復調(diào)用 response.setContentType("application/json; charset=utf-8"); PrintWriter writer = response.getWriter(); writer.print("重復調(diào)用"); writer.close(); response.flushBuffer(); return false; } } //否則沒有該自定義冪等性注解,則放行 return super.preHandle(request, response, handler); } //檢查token private boolean checkToken(HttpServletRequest request) { //從請求頭對象中獲取token String token = request.getHeader("token"); //如果不存在,則返回false,說明是重復調(diào)用 if(token==null || " ".equals(token)){ return false; } //否則就是存在,存在則把redis里刪除token return redisTemplate.delete(token); } //后置,暫時沒用 @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配置中,攔截所有請求 addInterceptor 需要一個實現(xiàn)HandlerInterceptor接口的攔截器實例 addPathPatterns 用于設置攔截器的過濾路徑規(guī)則;addPathPatterns("/**")對所有請求都攔截 excludePathPatterns:用于設置不需要攔截的過濾規(guī)則 */ @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(apiIdempotentInceptor).addPathPatterns("/**"); } }
7、接口實現(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放入請求的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è)務邏輯,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測試
- 獲取token
瀏覽器訪問:http://localhost:9090/getToken,獲取token的值
- 執(zhí)行冪等性業(yè)務接口
- 第一次,在postman中調(diào)用當前接口,并在請求頭中設置token
- 第二次,再次postman中訪問該業(yè)務接口,顯示**重復調(diào)用**的提示
- 查看num的值得接口
瀏覽器訪問:http://localhost:9090/getNum
9-Jmeter壓力測試工具測試
使用方法參考**Jmeter壓力測試工具使用說明v1.0
10-小結(jié)
通過以上代碼演示了解到,本案例對submit接口方法使用了基于token的冪等性解決方案,也就是當前submit接口方法只能調(diào)用一次,如果由于網(wǎng)絡抖動或者網(wǎng)絡異常出現(xiàn)多點或者點擊多次的情況,就會出現(xiàn)報錯提示,不允許調(diào)用當前接口,那么也就解決了當前業(yè)務接口冪等性的問題。