《最佳化介面設計的思路》系列:第六篇—介面防抖(防重複提交)的一些方式

sum墨發表於2023-12-05

一、前言

大家好!我是sum墨,一個一線的底層碼農,平時喜歡研究和思考一些技術相關的問題並整理成文,限於本人水平,如果文章和程式碼有表述不當之處,還請不吝賜教。

作為一名從業已達六年的老碼農,我的工作主要是開發後端Java業務系統,包括各種管理後臺和小程式等。在這些專案中,我設計過單/多租戶體系系統,對接過許多開放平臺,也搞過訊息中心這類較為複雜的應用,但幸運的是,我至今還沒有遇到過線上系統由於程式碼崩潰導致資損的情況。這其中的原因有三點:一是業務系統本身並不複雜;二是我一直遵循某大廠程式碼規約,在開發過程中儘可能按規約編寫程式碼;三是經過多年的開發經驗積累,我成為了一名熟練工,掌握了一些實用的技巧。

本文參考專案原始碼地址:summo-springboot-interface-demo

二、啥是防抖

所謂防抖,一是防使用者手抖,二是防網路抖動。在Web系統中,表單提交是一個非常常見的功能,如果不加控制,容易因為使用者的誤操作或網路延遲導致同一請求被髮送多次,進而生成重複的資料記錄。要針對使用者的誤操作,前端通常會實現按鈕的loading狀態,阻止使用者進行多次點選。而對於網路波動造成的請求重發問題,僅靠前端是不行的。為此,後端也應實施相應的防抖邏輯,確保在網路波動的情況下不會接收並處理同一請求多次。

一個理想的防抖元件或機制,我覺得應該具備以下特點:

  1. 邏輯正確,也就是不能誤判;
  2. 響應迅速,不能太慢;
  3. 易於整合,邏輯與業務解耦;
  4. 良好的使用者反饋機制,比如提示“您點選的太快了”

三、思路解析

前面講了那麼多,我們已經知道介面的防抖是很有必要的了,但是在開發之前,我們需要捋清楚幾個問題。

1. 哪一類介面需要防抖?

介面防抖也不是每個介面都需要加,一般需要加防抖的介面有這幾類:

  • 使用者輸入類介面:比如搜尋框輸入、表單輸入等,使用者輸入往往會頻繁觸發介面請求,但是每次觸發並不一定需要立即傳送請求,可以等待使用者完成輸入一段時間後再傳送請求。

  • 按鈕點選類介面:比如提交表單、儲存設定等,使用者可能會頻繁點選按鈕,但是每次點選並不一定需要立即傳送請求,可以等待使用者停止點選一段時間後再傳送請求。

  • 滾動載入類介面:比如下拉重新整理、上拉載入更多等,使用者可能在滾動過程中頻繁觸發介面請求,但是每次觸發並不一定需要立即傳送請求,可以等待使用者停止滾動一段時間後再傳送請求。

2. 如何確定介面是重複的?

防抖也即防重複提交,那麼如何確定兩次介面就是重複的呢?首先,我們需要給這兩次介面的呼叫加一個時間間隔,大於這個時間間隔的一定不是重複提交;其次,兩次請求提交的引數比對,不一定要全部引數,選擇標識性強的引數即可;最後,如果想做的更好一點,還可以加一個請求地址的對比。

3. 分散式部署下如何做介面防抖?

有兩個方案:

(1)使用共享快取

流程圖如下:

(2)使用分散式鎖

流程圖如下:

常見的分散式元件有Redis、Zookeeper等,但結合實際業務來看,一般都會選擇Redis,因為Redis一般都是Web系統必備的元件,不需要額外搭建。

四、具體實現

現在有一個儲存使用者的介面

@PostMapping("/add")
@RequiresPermissions(value = "add")
@Log(methodDesc = "新增使用者")
public ResponseEntity<String> add(@RequestBody AddReq addReq) {
        return userService.add(addReq);
}

**AddReq.java **

package com.summo.demo.model.request;

import java.util.List;

import lombok.Data;

@Data
public class AddReq {

    /**
     * 使用者名稱稱
     */
    private String userName;

    /**
     * 使用者手機號
     */
    private String userPhone;

    /**
     * 角色ID列表
     */
    private List<Long> roleIdList;
}

目前資料庫表中沒有對userPhone欄位做UK索引,這就會導致每呼叫一次add就會建立一個使用者,即使userPhone相同。

1. 🔐請求鎖

根據上面的要求,我定了一個註解@RequestLock,使用方式很簡單,把這個註解打在介面方法上即可。
RequestLock.java

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

/**
 * @description 請求防抖鎖,用於防止前端重複提交導致的錯誤
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestLock {
    /**
     * redis鎖字首
     *
     * @return 預設為空,但不可為空
     */
    String prefix() default "";

    /**
     * redis鎖過期時間
     *
     * @return 預設2秒
     */
    int expire() default 2;

    /**
     * redis鎖過期時間單位
     *
     * @return 預設單位為秒
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * redis  key分隔符
     *
     * @return 分隔符
     */
    String delimiter() default "&";
}

@RequestLock註解定義了幾個基礎的屬性,redis鎖字首、redis鎖時間、redis鎖時間單位、key分隔符。其中前面三個引數比較好理解,都是一個鎖的基本資訊。key分隔符是用來將多個引數合併在一起的,比如userName是張三,userPhone是123456,那麼完整的key就是"張三&123456",最後再加上redis鎖字首,就組成了一個唯一key。

2. 唯一key生成

這裡有些同學可能就要說了,直接拿引數來生成key不就行了嗎?
額,不是不行,但我想問一個問題:如果這個介面是文章釋出的介面,你也打算把內容當做key嗎?要知道,Redis的效率跟key的大小息息相關。所以,我的建議是選取合適的欄位作為key就行了,沒必要全都加上

要做到引數可選,那麼用註解的方式最好了,註解如下
RequestKeyParam.java

package com.example.requestlock.lock.annotation;

import java.lang.annotation.*;

/**
 * @description 加上這個註解可以將引數設定為key
 */
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestKeyParam {

}

這個註解加到引數上就行,沒有多餘的屬性。

接下來就是lockKey的生成了,程式碼如下
RequestKeyGenerator.java

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;

public class RequestKeyGenerator {
    /**
     * 獲取LockKey
     *
     * @param joinPoint 切入點
     * @return
     */
    public static String getLockKey(ProceedingJoinPoint joinPoint) {
        //獲取連線點的方法簽名物件
        MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
        //Method物件
        Method method = methodSignature.getMethod();
        //獲取Method物件上的註解物件
        RequestLock requestLock = method.getAnnotation(RequestLock.class);
        //獲取方法引數
        final Object[] args = joinPoint.getArgs();
        //獲取Method物件上所有的註解
        final Parameter[] parameters = method.getParameters();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < parameters.length; i++) {
            final RequestKeyParam keyParam = parameters[i].getAnnotation(RequestKeyParam.class);
            //如果屬性不是RequestKeyParam註解,則不處理
            if (keyParam == null) {
                continue;
            }
            //如果屬性是RequestKeyParam註解,則拼接 連線符 "& + RequestKeyParam"
            sb.append(requestLock.delimiter()).append(args[i]);
        }
        //如果方法上沒有加RequestKeyParam註解
        if (StringUtils.isEmpty(sb.toString())) {
            //獲取方法上的多個註解(為什麼是兩層陣列:因為第二層陣列是隻有一個元素的陣列)
            final Annotation[][] parameterAnnotations = method.getParameterAnnotations();
            //迴圈註解
            for (int i = 0; i < parameterAnnotations.length; i++) {
                final Object object = args[i];
                //獲取註解類中所有的屬性欄位
                final Field[] fields = object.getClass().getDeclaredFields();
                for (Field field : fields) {
                    //判斷欄位上是否有RequestKeyParam註解
                    final RequestKeyParam annotation = field.getAnnotation(RequestKeyParam.class);
                    //如果沒有,跳過
                    if (annotation == null) {
                        continue;
                    }
                    //如果有,設定Accessible為true(為true時可以使用反射訪問私有變數,否則不能訪問私有變數)
                    field.setAccessible(true);
                    //如果屬性是RequestKeyParam註解,則拼接 連線符" & + RequestKeyParam"
                    sb.append(requestLock.delimiter()).append(ReflectionUtils.getField(field, object));
                }
            }
        }
        //返回指定字首的key
        return requestLock.prefix() + sb;
    }
}
> 由於``@RequestKeyParam``可以放在方法的引數上,也可以放在物件的屬性上,所以這裡需要進行兩次判斷,一次是獲取方法上的註解,一次是獲取物件裡面屬性上的註解。

3. 重複提交判斷

(1)Redis快取方式

RedisRequestLockAspect.java

import java.lang.reflect.Method;
import com.summo.demo.exception.biz.BizException;
import com.summo.demo.model.response.ResponseCodeEnum;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.types.Expiration;
import org.springframework.util.StringUtils;

/**
 * @description 快取實現
 */
@Aspect
@Configuration
@Order(2)
public class RedisRequestLockAspect {

    private final StringRedisTemplate stringRedisTemplate;

    @Autowired
    public RedisRequestLockAspect(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Around("execution(public * * (..)) && @annotation(com.summo.demo.config.requestlock.RequestLock)")
    public Object interceptor(ProceedingJoinPoint joinPoint) {
        MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        RequestLock requestLock = method.getAnnotation(RequestLock.class);
        if (StringUtils.isEmpty(requestLock.prefix())) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "重複提交字首不能為空");
        }
        //獲取自定義key
        final String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
        // 使用RedisCallback介面執行set命令,設定鎖鍵;設定額外選項:過期時間和SET_IF_ABSENT選項
        final Boolean success = stringRedisTemplate.execute(
            (RedisCallback<Boolean>)connection -> connection.set(lockKey.getBytes(), new byte[0],
                Expiration.from(requestLock.expire(), requestLock.timeUnit()),
                RedisStringCommands.SetOption.SET_IF_ABSENT));
        if (!success) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,請稍後重試");
        }
        try {
            return joinPoint.proceed();
        } catch (Throwable throwable) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系統異常");
        }
    }
}

這裡的核心程式碼是stringRedisTemplate.execute裡面的內容,正如註釋裡面說的“使用RedisCallback介面執行set命令,設定鎖鍵;設定額外選項:過期時間和SET_IF_ABSENT選項”,有些同學可能不太清楚SET_IF_ABSENT是個啥,這裡我解釋一下:SET_IF_ABSENT 是 RedisStringCommands.SetOption 列舉類中的一個選項,用於在執行 SET 命令時設定鍵值對的時候,如果鍵不存在則進行設定,如果鍵已經存在,則不進行設定。

(2)Redisson分散式方式

Redisson分散式需要一個額外依賴,引入方式

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.10.6</version>
</dependency>

由於我之前的程式碼有一個RedisConfig,引入Redisson之後也需要單獨配置一下,不然會和RedisConfig衝突
RedissonConfig.java


import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        // 這裡假設你使用單節點的Redis伺服器
        config.useSingleServer()
            // 使用與Spring Data Redis相同的地址
            .setAddress("redis://127.0.0.1:6379");
        // 如果有密碼
        //.setPassword("xxxx");
        // 其他配置引數
        //.setDatabase(0)
        //.setConnectionPoolSize(10)
        //.setConnectionMinimumIdleSize(2);
        // 建立RedissonClient例項
        return Redisson.create(config);
    }
}

配好之後,核心程式碼如下
RedissonRequestLockAspect.java

mport java.lang.reflect.Method;

import com.summo.demo.exception.biz.BizException;
import com.summo.demo.model.response.ResponseCodeEnum;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.util.StringUtils;

/**
 * @description 分散式鎖實現
 */
@Aspect
@Configuration
@Order(2)
public class RedissonRequestLockAspect {

    private RedissonClient redissonClient;

    @Autowired
    public RedissonRequestLockAspect(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    @Around("execution(public * * (..)) && @annotation(com.summo.demo.config.requestlock.RequestLock)")
    public Object interceptor(ProceedingJoinPoint joinPoint) {
        MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        RequestLock requestLock = method.getAnnotation(RequestLock.class);
        if (StringUtils.isEmpty(requestLock.prefix())) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "重複提交字首不能為空");
        }
        //獲取自定義key
        final String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
        // 使用Redisson分散式鎖的方式判斷是否重複提交
        RLock lock = redissonClient.getLock(lockKey);
        boolean isLocked = false;
        try {
            //嘗試搶佔鎖
            isLocked = lock.tryLock();
            //沒有拿到鎖說明已經有了請求了
            if (!isLocked) {
                throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,請稍後重試");
            }
            //拿到鎖後設定過期時間
            lock.lock(requestLock.expire(), requestLock.timeUnit());
            try {
                return joinPoint.proceed();
            } catch (Throwable throwable) {
                throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系統異常");
            }
        } catch (Exception e) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,請稍後重試");
        } finally {
            //釋放鎖
            if (isLocked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }

    }
}

Redisson的核心思路就是搶鎖,當一次請求搶到鎖之後,對鎖加一個過期時間,在這個時間段內重複的請求是無法獲得這個鎖,也不難理解。

4. 測試一下

  • 第一次提交,"新增使用者成功"

  • 短時間內重複提交,"BIZ-0001:您的操作太快了,請稍後重試"

  • 過幾秒後再次提交,"新增使用者成功"

從測試的結果上看,防抖是做到了,但是隨著快取消失、鎖失效,還是可以發起同樣的請求,所以要真正做到介面冪等性,還需要業務程式碼的判斷、設定資料庫表的UK索引等操作。
我在文章裡面說到生成唯一key的時候沒有加使用者相關的資訊,比如使用者ID、IP屬地等,真實生產環境建議加上這些,可以更好地減少誤判。