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

- 執(zhí)行冪等性業(yè)務(wù)接口
- 第一次,在postman中調(diào)用當(dāng)前接口,并在請求頭中設(shè)置token

- 第二次,再次postman中訪問該業(yè)務(wù)接口,顯示**重復(fù)調(diào)用**的提示

- 查看num的值得接口
瀏覽器訪問:http://localhost:9090/getNum

9-Jmeter壓力測試工具測試
使用方法參考**Jmeter壓力測試工具使用說明v1.0
10-小結(jié)
通過以上代碼演示了解到,本案例對submit接口方法使用了基于token的冪等性解決方案,也就是當(dāng)前submit接口方法只能調(diào)用一次,如果由于網(wǎng)絡(luò)抖動或者網(wǎng)絡(luò)異常出現(xiàn)多點或者點擊多次的情況,就會出現(xiàn)報錯提示,不允許調(diào)用當(dāng)前接口,那么也就解決了當(dāng)前業(yè)務(wù)接口冪等性的問題。
1024首播|39歲程序員逆襲記:不被年齡定義,AI浪潮里再迎春天
2025-10-241024程序員節(jié)丨10年同行,致敬用代碼改變世界的你
2025-10-24【AI設(shè)計】北京143期畢業(yè)僅36天,全員拿下高薪offer!黑馬AI設(shè)計連續(xù)6期100%高薪就業(yè)
2025-09-19【跨境電商運營】深圳跨境電商運營畢業(yè)22個工作日,就業(yè)率91%+,最高薪資達(dá)13500元
2025-09-19【AI運維】鄭州運維1期就業(yè)班,畢業(yè)14個工作日,班級93%同學(xué)已拿到Offer, 一線均薪資 1W+
2025-09-19【AI鴻蒙開發(fā)】上海校區(qū)AI鴻蒙開發(fā)4期5期,距離畢業(yè)21天,就業(yè)率91%,平均薪資14046元
2025-09-19