Java接口防抖/冪等性解決方案(redis)
一、核心區(qū)別
| 特性 | 接口防抖(Debouncing) | 接口冪等性(Idempotency) |
|---|---|---|
| 目的 | 減少資源浪費(fèi):防止短時(shí)間內(nèi)多次觸發(fā)同一操作(如用戶頻繁點(diǎn)擊、網(wǎng)絡(luò)抖動(dòng)導(dǎo)致重復(fù)請求)。 | 保證結(jié)果一致性:確保同一請求無論調(diào)用一次還是多次,最終結(jié)果相同,避免重復(fù)操作導(dǎo)致的數(shù)據(jù)異常。 |
| 作用層面 | 前端/后端均可實(shí)現(xiàn):前端優(yōu)化用戶體驗(yàn),后端過濾重復(fù)請求。 | 后端核心邏輯:依賴業(yè)務(wù)邏輯和數(shù)據(jù)層設(shè)計(jì),確保操作的唯一性。 |
| 關(guān)注點(diǎn) | 時(shí)間窗口內(nèi)的重復(fù)請求:只處理最后一次或首次請求。 | 請求的唯一性標(biāo)識(shí):通過唯一標(biāo)識(shí)符(如請求ID、業(yè)務(wù)參數(shù))判斷是否重復(fù)。 |
| 典型場景 | 用戶搜索輸入、按鈕多次點(diǎn)擊、無限滾動(dòng)加載。 | 支付接口、訂單創(chuàng)建、數(shù)據(jù)修改等需避免重復(fù)操作的場景。 |
二、實(shí)現(xiàn)方式
接口防抖:
核心思想:在指定時(shí)間窗口內(nèi),僅允許最后一次(或首次)請求生效。
1.前端畫面每次請求添加loading遮罩層(接口響應(yīng)時(shí)間過長就會(huì)導(dǎo)致用戶體驗(yàn)不好)
2.使用redis每次將請求主要參數(shù)和請求人綁定起來,放入指定的緩存時(shí)間,第二次再請求看到是同一個(gè)接口和同一個(gè)人操作則提示:操作頻繁,稍后重試!
(推薦,做成自定義注解的方式,實(shí)現(xiàn)簡單)
3.前端發(fā)送請求時(shí),在指定時(shí)間窗口內(nèi),延遲發(fā)送請求
(不推薦,畢竟會(huì)延遲發(fā)送請求,影響接口速度)
let timeout;
function handleSearchInput(event) {
clearTimeout(timeout);
timeout = setTimeout(() => {
// 發(fā)送請求
fetch('/search', { query: event.target.value });
}, 300); // 300ms防抖間隔
}接下來聊聊第二種方式,自定義注解:
1.AOP (攔截請求,并獲取請求具體信息,將url,接口主要參數(shù),用戶id存入Redis中)
package com.qeoten.sms.edu.config;
import com.qeoten.sms.util.api.R;
import com.qeoten.sms.util.auth.AuthUtil;
import com.qeoten.sms.util.util.DigestUtil;
import com.qeoten.sms.util.util.RedisUtil;
import io.lettuce.core.dynamic.support.ReflectionUtils;
import lombok.extern.slf4j.Slf4j;
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.stereotype.Component;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.concurrent.TimeUnit;
/**
* 接口防抖aop
*/
@Aspect
@Component
@Slf4j
public class AntiShakeAOP {
@Autowired
private RedisUtil redisUtil;
private static final String prefix = "RepeatSubmit";
@Around(value = "@annotation(com.qeoten.sms.edu.config.RepeatClick)")
public Object antiShake(ProceedingJoinPoint pjp) throws Throwable {
// 獲取調(diào)用方法的信息和簽名信息
MethodSignature signature = (MethodSignature) pjp.getSignature();
// 獲取方法
Method method = signature.getMethod();
// 獲取注解中的參數(shù)
RepeatClick annotation = method.getAnnotation(RepeatClick.class);
String key = getLockKey(pjp);
// 查詢r(jià)edis中是否存在對應(yīng)關(guān)系
if (!redisUtil.hasKey(key)) {
redisUtil.setKeyAndExpire(key, null, annotation.value(), TimeUnit.MILLISECONDS);
return pjp.proceed();
} else {
log.error(annotation.message());
return R.fail(annotation.message());
}
}
public static String getLockKey(ProceedingJoinPoint joinPoint) {
//獲取連接點(diǎn)的方法簽名對象
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
//Method對象
Method method = methodSignature.getMethod();
String className = method.getDeclaringClass().getName();
//獲取Method對象上的注解對象
//獲取方法參數(shù)
final Object[] args = joinPoint.getArgs();
//獲取Method對象上所有的注解
final Parameter[] parameters = method.getParameters();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < parameters.length; i++) {
final RepeatClick keyParam = parameters[i].getAnnotation(RepeatClick.class);
if (keyParam == null) {
//如果屬性不是RepeatSubmit注解,則獲取方法的參數(shù)名
sb.append(args[i]).append("&");
} else {
final Object object = args[i];
//獲取注解類中所有的屬性字段
final Field[] fields = object.getClass().getDeclaredFields();
for (Field field : fields) {
//判斷字段上是否有RepeatSubmit注解
final RepeatClick annotation = field.getAnnotation(RepeatClick.class);
//如果沒有,跳過
if (annotation == null) {
continue;
}
//如果有,設(shè)置Accessible為true(為true時(shí)可以使用反射訪問私有變量,否則不能訪問私有變量)
field.setAccessible(true);
//如果屬性是RepeatSubmit注解,則拼接 連接符" & + RepeatSubmit"
sb.append(ReflectionUtils.getField(field, object)).append("&");
}
}
}
//返回指定前綴的key
return prefix + ":" + className + ":" + method.getName() + ":" + AuthUtil.getUserId() + ":" + DigestUtil.md5Hex((sb.toString()));
}
}
2.自定義注解模板(配置緩存時(shí)間,和指定提示消息)
package com.qeoten.sms.edu.config;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author QT-PC-0021
*/
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatClick {
/**
* 默認(rèn)的防抖時(shí)間ms
*
* @return
*/
long value() default 1000;
String message() default "操作太頻繁,請稍后再試!";
}
3.在需要進(jìn)行操作表的接口上,添加自定義注解,實(shí)現(xiàn)功能
@GetMapping("/advancePaper")
@ApiOperationSupport(order = 2)
@ApiOperation(value = "交卷", notes = "傳入考試id")
@RepeatClick
public R<MyExamVo> advancePaper(@RequestParam Long examId){
// 接口邏輯,可能頻繁操作表
}接口冪等性:
核心思想:通過唯一標(biāo)識(shí)符(如請求ID、業(yè)務(wù)參數(shù))確保同一請求只處理一次。
1.數(shù)據(jù)庫唯一索引:
數(shù)據(jù)庫設(shè)置唯一索引重復(fù)提交時(shí),插表就會(huì)直接報(bào)錯(cuò)重復(fù)
(不推薦,畢竟壓力直接進(jìn)入數(shù)據(jù)庫了)
2.數(shù)據(jù)庫樂觀鎖:(數(shù)據(jù)修改時(shí)間 / 版本號) => 比對
查詢列表畫面時(shí),將數(shù)據(jù)的修改時(shí)間(毫秒級)記錄一下,下次請求增刪改接口時(shí),將數(shù)據(jù)原本的修改時(shí)間傳入接口,接口第一步判斷當(dāng)前數(shù)據(jù)的修改時(shí)間是否和畫面上傳入的修改時(shí)間一致,一致就代表沒有人修改做此數(shù)據(jù),否則就提示此數(shù)據(jù)已被他人修改,請稍后再試!
最后更新記錄時(shí),帶入版本號或者修改時(shí)間進(jìn)去,
update xxx set name = xxx where id = xxx and updateTime = xxx
(并發(fā)量小的時(shí)候可以,并發(fā)大的時(shí)候存在重復(fù)修改問題)
3.唯一值+緩存:
其實(shí)也就是接口防抖中的第二個(gè)實(shí)現(xiàn)方案的變化版本
上面提到將接口的主要參數(shù)+用戶id作為唯一標(biāo)識(shí)存入redis并記錄指定的緩存時(shí)間,那么這次存入redis不記錄時(shí)間,并且在接口結(jié)束時(shí)清除掉此緩存。
(推薦,但是當(dāng)服務(wù)異常掛掉時(shí),或者某些原因接口沒有正常執(zhí)行完成時(shí),redis緩存一直都會(huì)在,不好維護(hù),浪費(fèi)資源)
4.分布式鎖(redisson)
業(yè)務(wù)開始時(shí)候去tryLock,嘗試獲取鎖(鎖的參數(shù)可以是本次操作的對象id,假如說本次要給某個(gè)商品增加扣減庫存,那么參數(shù)可以是商品id),保障在接口的最后一步,釋放鎖即可。
RLock lock = redissonClient.getLock("my-distributed-lock");
// 嘗試獲取鎖:等待最多 10 秒,鎖自動(dòng)續(xù)期 30 秒
boolean isLocked = lock.tryLock(10, 30, TimeUnit.SECONDS);這樣每次拿到鎖的線程才會(huì)繼續(xù)進(jìn)行接口邏輯操作。
5.手動(dòng)實(shí)現(xiàn)鎖
其實(shí)原理和第4點(diǎn)一樣,就是需要考慮手動(dòng)實(shí)現(xiàn)鎖的復(fù)雜性
- 加鎖時(shí)如何保證加鎖和給鎖設(shè)置有效期的一致性
- 鎖的過期時(shí)間,鎖需要釋放
- 鎖不能提前釋放,防止其他線程獲取到此鎖
- 怎樣給將要過期的鎖加過期時(shí)間
- 釋放鎖的時(shí)候,如何保證釋放的是同一個(gè)鎖,防止錯(cuò)釋放
- 保證釋放鎖時(shí)的原子性
1. 加鎖時(shí)setnx命令,設(shè)置其lock資源名稱 + value(一般為threadId / 時(shí)間戳) + 過期時(shí)間
2. 進(jìn)行后續(xù)業(yè)務(wù)操作
3. 最后需要用lua腳本來釋放鎖(先獲取鎖的value確保是當(dāng)前的lock,使用腳本釋放鎖)
總結(jié)
- 防抖:重點(diǎn)是減少請求次數(shù),通過時(shí)間戳、緩存實(shí)現(xiàn)。
- 冪等性:重點(diǎn)是保證結(jié)果唯一,通過唯一標(biāo)識(shí)符、數(shù)據(jù)庫約束或鎖或業(yè)務(wù)校驗(yàn)實(shí)現(xiàn)。
- 實(shí)際應(yīng)用:通常需要結(jié)合兩者,例如:
前端防抖:減少無效請求。
后端冪等性:即使防抖失效,也能保證最終結(jié)果一致。
根據(jù)業(yè)務(wù)需求選擇合適的方案,例如:
- 高頻非敏感操作(如普通的修改或者刪除接口):使用本地緩存或 Redis 防抖。
- 敏感操作(如支付):結(jié)合 Redis 唯一標(biāo)識(shí)符和數(shù)據(jù)庫唯一索引確保冪等。
到此這篇關(guān)于Java接口防抖/冪等性解決(redis)的文章就介紹到這了,更多相關(guān)Java接口防抖冪等性內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
詳解spring boot實(shí)現(xiàn)多數(shù)據(jù)源代碼實(shí)戰(zhàn)
本篇文章主要介紹了詳解spring boot實(shí)現(xiàn)多數(shù)據(jù)源代碼實(shí)戰(zhàn),小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-07-07
利用棧使用簡易計(jì)算器(Java實(shí)現(xiàn))
這篇文章主要為大家詳細(xì)介紹了Java利用棧實(shí)現(xiàn)簡易計(jì)算器,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-09-09
使用jekins自動(dòng)構(gòu)建部署java maven項(xiàng)目的方法步驟
這篇文章主要介紹了使用jekins自動(dòng)構(gòu)建部署java maven項(xiàng)目的方法步驟,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-01-01
Java中樹的存儲(chǔ)結(jié)構(gòu)實(shí)現(xiàn)示例代碼
本篇文章主要介紹了Java中樹的存儲(chǔ)結(jié)構(gòu)實(shí)現(xiàn)示例代碼,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-09-09
Java中Stream實(shí)現(xiàn)List排序的六個(gè)核心技巧總結(jié)
這篇文章主要介紹了Java中Stream實(shí)現(xiàn)List排序的六個(gè)核心技巧,分別是自然序排序、反向排序、空值安全處理、多字段組合排序、并行流加速、原地排序等,文中通過代碼介紹的非常詳細(xì),需要的朋友可以參考下2025-04-04

