首頁(yè)技術(shù)文章正文

Java培訓(xùn):Java接口冪等性設(shè)計(jì)場(chǎng)景解決方案v1.0

更新時(shí)間:2022-10-14 來源:黑馬程序員 瀏覽量:

  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ù)問題,為什么呢?

  實(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é):

   所以你必須得知道這事,否則你做出來的分布式系統(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ǎng)絡(luò)問題/用戶誤操作/惡意操作,用戶點(diǎn)擊了多次

  - 網(wǎng)絡(luò)問題,微服務(wù)重試retry

  - 網(wǎng)絡(luò)問題很常見,100次請(qǐng)求,都o(jì)k;1萬次請(qǐng)求可能1次超時(shí)會(huì)重試;10萬次可能10次超時(shí)會(huì)重試,100萬次可能100次超時(shí)會(huì)重試;如果100個(gè)請(qǐng)求重復(fù)了,你沒處理,導(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):在使用消息中間件來處理消息隊(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操作,等待自然過期。

  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í)行成功后不釋放鎖,等待自然過期。

  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操作可能存在冪等性的問題:

       1.用戶更改個(gè)人信息,瘋狂點(diǎn)擊按鈕,不會(huì)發(fā)生冪等性問題,因?yàn)閿?shù)據(jù)始終為修改后的數(shù)據(jù)。

  2.用戶購(gòu)買商品,用戶在點(diǎn)擊后,網(wǎng)絡(luò)出現(xiàn)問題,可能再次點(diǎn)擊,這樣就會(huì)出現(xiàn)冪等性問題,導(dǎo)致購(gòu)買了多次,可以使用樂觀鎖。

update order set count=count-1,version=version+1 where id=1 and version=1

  3-3- Delete接口冪等性

  根據(jù)唯一id刪除不會(huì)出現(xiàn)冪等性問題,因?yàn)榈诙蝿h除的時(shí)候mysql中已經(jīng)不存在該數(shù)據(jù)

  3-4- Select接口冪等性

  查詢操作不會(huì)改變數(shù)據(jù),所以是天然的冪等性操作。

  3-5- 混合操作(一個(gè)接口包含多種操作)

  使用`Token`機(jī)制,或使用`Token` + 分布式鎖的方案來解決冪等性問題。

  4-冪等性解決方案實(shí)現(xiàn)思路

  4-1- Token機(jī)制實(shí)現(xiàn)

  通過`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)失敗,說明`Redis`中已經(jīng)沒有對(duì)應(yīng)的 `Token`,則表示重復(fù)操作,直接返回指定的結(jié)果給客戶端。

  4-2 基于MySQL實(shí)現(xiàn)

  通過`MySQL`唯一索引的特性實(shí)現(xiàn)接口的冪等性。

  具體流程步驟:

       1.建立一張去重表,其中某個(gè)字段需要建立唯一索引;

  
       2. 客戶端去請(qǐng)求服務(wù)端,服務(wù)端會(huì)將這次請(qǐng)求的一些信息插入這張去重表中;

  3. 因?yàn)楸碇心硞€(gè)字段帶有唯一索引,如果插入成功,證明表中沒有這次請(qǐng)求的信息,則執(zhí)行后續(xù)的業(yè)務(wù)邏輯;

  4. 如果插入失敗,則代表已經(jīng)執(zhí)行過當(dāng)前請(qǐng)求,直接返回。

  4-3- 基于Redis實(shí)現(xiàn)

  通過`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í)行過當(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)被刪除,則不能能夠通過校驗(yàn),返回重復(fù)提交。

  - 如果不存在,說明參數(shù)不合法或者是重復(fù)請(qǐng)求,返回提示即可。

  5-1-2-請(qǐng)求流程

  - 當(dāng)頁(yè)面加載的時(shí)候通過接口獲取token

  - 當(dāng)訪問接口時(shí),會(huì)經(jīng)過**攔截器**,如果發(fā)現(xiàn)該接口中有**自定義的冪等性注解**,說明該接口需要驗(yàn)證冪等性(查看請(qǐng)求頭里是否有key=token的值,如果有,并且刪除成功,那么接口就訪問成功,否則為重復(fù)提交;

  - 如果發(fā)現(xiàn)該接口沒有自定義的冪等性注解,則放行。

  5-1-3-代碼演示

  1、使用的技術(shù)

  - springBoot

  - redis

  - 自定義冪等性注解+攔截器請(qǐng)求攔截

  - Jmeter壓測(cè)工具

  2、創(chuàng)建項(xiàng)目

1665734755318_1.jpg

  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.*;

/**
 * 自定義注解
 * 說明:添加了該注解的接口要實(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í)行,
     * 也就是說我們想調(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

        //如果說包含了,則認(rèn)為該接口是要進(jìn)行冪等性校驗(yàn)的接口
            //檢驗(yàn)token
                //如果說有,則訪問成功,執(zhí)行邏輯業(yè)務(wù),要?jiǎng)h除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) {
        //從請(qǐng)求頭對(duì)象中獲取token
        String token = request.getHeader("token");
        //如果不存在,則返回false,說明是重復(fù)調(diào)用
        if(token==null || " ".equals(token)){
            return false;
        }
        //否則就是存在,存在則把redis里刪除token
        return redisTemplate.delete(token);

    }
    //后置,暫時(shí)沒用
    @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ī)則;addPathPatterns("/**")對(duì)所有請(qǐng)求都攔截
        excludePathPatterns:用于設(shè)置不需要攔截的過濾規(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

  瀏覽器訪問:http://localhost:9090/getToken,獲取token的值

1665735799716_2.jpg

  - 執(zhí)行冪等性業(yè)務(wù)接口

  - 第一次,在postman中調(diào)用當(dāng)前接口,并在請(qǐng)求頭中設(shè)置token

 

1665735839686_3.jpg

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

1665735882138_4.jpg

  - 查看num的值得接口

  瀏覽器訪問:http://localhost:9090/getNum

1665736002465_5.jpg

  9-Jmeter壓力測(cè)試工具測(cè)試

  使用方法參考**Jmeter壓力測(cè)試工具使用說明v1.0

  10-小結(jié)

   通過以上代碼演示了解到,本案例對(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ù)接口冪等性的問題。

分享到:
在線咨詢 我要報(bào)名
和我們?cè)诰€交談!