教育行業(yè)A股IPO第一股(股票代碼 003032)

全國咨詢/投訴熱線:400-618-4000

Java接口冪等性設計場景解決方案v1.0

更新時間:2022年12月01日14時39分 來源:傳智教育 瀏覽次數(shù):

好口碑IT培訓

  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)建項目

1665734755318_1.jpg

  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的值

1665735799716_2.jpg

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

  - 第一次,在postman中調(diào)用當前接口,并在請求頭中設置token

 

1665735839686_3.jpg

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

1665735882138_4.jpg

  - 查看num的值得接口

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

1665736002465_5.jpg

  9-Jmeter壓力測試工具測試

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

  10-小結(jié)

   通過以上代碼演示了解到,本案例對submit接口方法使用了基于token的冪等性解決方案,也就是當前submit接口方法只能調(diào)用一次,如果由于網(wǎng)絡抖動或者網(wǎng)絡異常出現(xiàn)多點或者點擊多次的情況,就會出現(xiàn)報錯提示,不允許調(diào)用當前接口,那么也就解決了當前業(yè)務接口冪等性的問題。

0 分享到:
和我們在線交談!