SpringBoot實(shí)現(xiàn)接口防抖的實(shí)戰(zhàn)方案大全
一、什么是接口防抖?(又名:救救那個(gè)手抖的程序員)
想象一下這個(gè)場(chǎng)景:用戶小張?jiān)谔峤挥唵螘r(shí),因?yàn)榫W(wǎng)絡(luò)延遲,他以為沒點(diǎn)中那個(gè)“提交”按鈕,于是瘋狂連擊了10次!結(jié)果...10個(gè)一模一樣的訂單誕生了!
接口防抖 就像是給按鈕加上了一層“冷靜期”——“兄弟,你點(diǎn)太快了,先冷靜3秒再說!”
防止重復(fù)提交 則是更嚴(yán)格的保安大哥——“同樣的身份證(請(qǐng)求)只能進(jìn)一次,想蒙混過關(guān)?沒門!”
下面我來教你在SpringBoot中布下天羅地網(wǎng),攔截這些“手抖攻擊”!
二、實(shí)戰(zhàn)方案大集合
方案1:前端防抖 + 后端令牌鎖(雙保險(xiǎn))
前端防抖代碼(JavaScript版):
// 給按鈕加個(gè)“冷靜debuff”
let isSubmitting = false;
function submitOrder() {
if (isSubmitting) {
alert("客官您點(diǎn)得太快了,喝口茶歇歇~");
return;
}
isSubmitting = true;
// 提交請(qǐng)求...
// 3秒后才能再次點(diǎn)擊
setTimeout(() => {
isSubmitting = false;
}, 3000);
}
后端令牌鎖實(shí)現(xiàn):
步驟1:創(chuàng)建防抖注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicateSubmit {
/**
* 防抖時(shí)間(秒),默認(rèn)3秒
*/
int lockTime() default 3;
/**
* 鎖的key,支持SpEL表達(dá)式
*/
String key() default "";
/**
* 提示信息
*/
String message() default "請(qǐng)勿重復(fù)提交";
}
步驟2:實(shí)現(xiàn)AOP切面
@Aspect
@Component
@Slf4j
public class DuplicateSubmitAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private HttpServletRequest request;
@Pointcut("@annotation(preventDuplicateSubmit)")
public void pointcut(PreventDuplicateSubmit preventDuplicateSubmit) {
}
@Around("pointcut(preventDuplicateSubmit)")
public Object around(ProceedingJoinPoint joinPoint,
PreventDuplicateSubmit preventDuplicateSubmit) throws Throwable {
// 1. 構(gòu)造鎖的key
String lockKey = buildLockKey(joinPoint, preventDuplicateSubmit);
// 2. 嘗試加鎖(setnx操作)
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "LOCKED",
preventDuplicateSubmit.lockTime(), TimeUnit.SECONDS);
if (Boolean.TRUE.equals(success)) {
// 加鎖成功,執(zhí)行方法
try {
return joinPoint.proceed();
} finally {
// 可以根據(jù)業(yè)務(wù)決定是否立即刪除鎖
// redisTemplate.delete(lockKey);
}
} else {
// 加鎖失敗,說明重復(fù)提交了
throw new RuntimeException(preventDuplicateSubmit.message());
}
}
private String buildLockKey(ProceedingJoinPoint joinPoint,
PreventDuplicateSubmit annotation) {
StringBuilder keyBuilder = new StringBuilder("SUBMIT:LOCK:");
// 如果有自定義key
if (StringUtils.isNotBlank(annotation.key())) {
keyBuilder.append(parseKey(joinPoint, annotation.key()));
} else {
// 默認(rèn)使用:方法名 + 用戶ID + 參數(shù)hash
keyBuilder.append(joinPoint.getSignature().toShortString());
// 加上用戶ID(如果有登錄)
String userId = getCurrentUserId();
if (userId != null) {
keyBuilder.append(":").append(userId);
}
// 加上參數(shù)摘要
Object[] args = joinPoint.getArgs();
if (args.length > 0) {
String argsHash = DigestUtils.md5DigestAsHex(
Arrays.deepToString(args).getBytes()
).substring(0, 8);
keyBuilder.append(":").append(argsHash);
}
}
return keyBuilder.toString();
}
private String getCurrentUserId() {
// 從Token或Session中獲取用戶ID
// 這里簡(jiǎn)化處理
return (String) request.getSession().getAttribute("userId");
}
}
步驟3:使用示例
@RestController
@RequestMapping("/order")
public class OrderController {
@PostMapping("/create")
@PreventDuplicateSubmit(lockTime = 5, message = "訂單正在處理中,請(qǐng)勿重復(fù)提交")
public ApiResult createOrder(@RequestBody OrderDTO orderDTO) {
// 業(yè)務(wù)邏輯
orderService.create(orderDTO);
return ApiResult.success("下單成功");
}
@PostMapping("/pay")
@PreventDuplicateSubmit(
key = "'PAY:' + #orderNo + ':' + T(com.example.util.UserUtil).getCurrentUserId()",
lockTime = 10,
message = "支付請(qǐng)求已提交,請(qǐng)勿重復(fù)操作"
)
public ApiResult payOrder(String orderNo) {
// 支付邏輯
return ApiResult.success("支付成功");
}
}
方案2:數(shù)據(jù)庫唯一約束(最硬核的方案)
有時(shí)候,最簡(jiǎn)單的最有效!
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 業(yè)務(wù)唯一號(hào):時(shí)間戳 + 用戶ID + 隨機(jī)數(shù)
@Column(name = "order_no", unique = true, nullable = false)
private String orderNo;
// 或者使用請(qǐng)求ID作為防重
@Column(name = "request_id", unique = true)
private String requestId;
// ...其他字段
}
@Service
@Slf4j
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) {
// 生成唯一請(qǐng)求ID(前端傳遞或后端生成)
String requestId = dto.getRequestId();
if (StringUtils.isBlank(requestId)) {
requestId = UUID.randomUUID().toString();
}
// 檢查是否已處理過該請(qǐng)求
if (orderRepository.existsByRequestId(requestId)) {
log.warn("重復(fù)請(qǐng)求被攔截:{}", requestId);
throw new BusinessException("訂單已提交,請(qǐng)勿重復(fù)操作");
}
// 創(chuàng)建訂單
Order order = new Order();
order.setRequestId(requestId);
order.setOrderNo(generateOrderNo());
// ...設(shè)置其他字段
try {
orderRepository.save(order);
} catch (DataIntegrityViolationException e) {
// 捕獲唯一約束異常
throw new BusinessException("訂單已存在,請(qǐng)勿重復(fù)提交");
}
}
}
方案3:本地Guava緩存(輕量級(jí)方案)
適合單機(jī)部署,簡(jiǎn)單快捷!
@Component
public class LocalDuplicateChecker {
// Guava緩存,3秒自動(dòng)過期
private final Cache<String, Boolean> submitCache = CacheBuilder.newBuilder()
.expireAfterWrite(3, TimeUnit.SECONDS)
.maximumSize(10000)
.build();
/**
* 檢查是否重復(fù)提交
* @param key 請(qǐng)求唯一標(biāo)識(shí)
* @return true=重復(fù)提交, false=首次提交
*/
public boolean isDuplicate(String key) {
try {
// 如果key不存在,則放入緩存并返回null
// 如果key存在,則返回緩存的值
return submitCache.get(key, () -> {
// 這個(gè)lambda只在key不存在時(shí)執(zhí)行
return false;
});
} catch (ExecutionException e) {
return true;
}
}
/**
* 手動(dòng)放入緩存(用于防止并發(fā)時(shí)多次通過檢查)
*/
public void markAsSubmitted(String key) {
submitCache.put(key, true);
}
}
// 使用方式
@RestController
public class ApiController {
@Autowired
private LocalDuplicateChecker duplicateChecker;
@PostMapping("/api/submit")
public ApiResult submitData(@RequestBody SubmitData data,
HttpServletRequest request) {
// 構(gòu)造唯一key:IP + 用戶ID + 數(shù)據(jù)摘要
String clientIp = request.getRemoteAddr();
String userId = getCurrentUserId();
String dataHash = DigestUtils.md5DigestAsHex(
JSON.toJSONString(data).getBytes()
).substring(0, 8);
String lockKey = String.format("SUBMIT:%s:%s:%s",
clientIp, userId, dataHash);
if (duplicateChecker.isDuplicate(lockKey)) {
return ApiResult.error("請(qǐng)勿重復(fù)提交");
}
// 標(biāo)記為已提交
duplicateChecker.markAsSubmitted(lockKey);
// 執(zhí)行業(yè)務(wù)邏輯
return processData(data);
}
}
方案4:Token令牌機(jī)制(最經(jīng)典的方案)
這個(gè)方案就像發(fā)門票,一張票只能進(jìn)一個(gè)人!
步驟1:生成Token
@RestController
public class TokenController {
@GetMapping("/api/getToken")
public ApiResult getToken() {
String token = UUID.randomUUID().toString();
// 存入Redis,有效期5分鐘
redisTemplate.opsForValue().set(
"SUBMIT_TOKEN:" + token,
"VALID",
5, TimeUnit.MINUTES
);
return ApiResult.success(token);
}
}
步驟2:驗(yàn)證Token
@Aspect
@Component
public class TokenCheckAspect {
@Pointcut("@annotation(needTokenCheck)")
public void pointcut(NeedTokenCheck needTokenCheck) {
}
@Around("pointcut(needTokenCheck)")
public Object checkToken(ProceedingJoinPoint joinPoint,
NeedTokenCheck needTokenCheck) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
String token = request.getHeader("X-Submit-Token");
if (StringUtils.isBlank(token)) {
throw new RuntimeException("提交令牌缺失");
}
String redisKey = "SUBMIT_TOKEN:" + token;
String value = (String) redisTemplate.opsForValue().get(redisKey);
if (!"VALID".equals(value)) {
throw new RuntimeException("無效的提交令牌");
}
// 刪除令牌(一次性使用)
redisTemplate.delete(redisKey);
return joinPoint.proceed();
}
}
步驟3:前端配合
// 提交前先獲取令牌
async function submitWithToken(data) {
// 1. 獲取令牌
const token = await fetch('/api/getToken').then(r => r.json());
// 2. 攜帶令牌提交
const result = await fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Submit-Token': token
},
body: JSON.stringify(data)
});
return result;
}
三、方案對(duì)比總結(jié)
| 方案 | 優(yōu)點(diǎn) | 缺點(diǎn) | 適用場(chǎng)景 |
|---|---|---|---|
| AOP + Redis鎖 | 靈活可控,支持復(fù)雜規(guī)則 | 依賴Redis,增加系統(tǒng)復(fù)雜度 | 分布式系統(tǒng),需要精細(xì)控制 |
| 數(shù)據(jù)庫唯一約束 | 絕對(duì)可靠,永不漏網(wǎng) | 對(duì)數(shù)據(jù)庫有壓力,需要設(shè)計(jì)唯一鍵 | 核心業(yè)務(wù)(如支付、訂單) |
| 本地緩存 | 性能極高,零延遲 | 僅限單機(jī),集群無效 | 單體應(yīng)用,高頻但非核心接口 |
| Token機(jī)制 | 安全性高,前端可控 | 需要兩次請(qǐng)求,增加交互 | 表單提交,需要嚴(yán)格防重 |
四、防抖策略選擇指南
根據(jù)業(yè)務(wù)重要性選擇:
- 金融支付 → 數(shù)據(jù)庫唯一約束 + Redis鎖(雙重保險(xiǎn))
- 普通表單 → Token機(jī)制或AOP鎖
- 查詢接口 → 本地緩存防抖
根據(jù)系統(tǒng)架構(gòu)選擇:
- 單機(jī)應(yīng)用 → 本地緩存最香
- 分布式集群 → Redis是王道
- 微服務(wù) → 考慮分布式鎖服務(wù)
實(shí)用小貼士:
// 最佳實(shí)踐:組合拳!
@PostMapping("/important/submit")
@PreventDuplicateSubmit(lockTime = 5)
@Transactional(rollbackFor = Exception.class)
public ApiResult importantSubmit(@RequestBody @Valid RequestDTO dto) {
// 1. 檢查請(qǐng)求ID是否重復(fù)
checkRequestId(dto.getRequestId());
// 2. 執(zhí)行業(yè)務(wù)
// 3. 數(shù)據(jù)庫唯一約束兜底
return ApiResult.success();
}
五、最后
- 不要過度設(shè)計(jì):簡(jiǎn)單的業(yè)務(wù)用簡(jiǎn)單的方案,殺雞不要用牛刀
- 用戶體驗(yàn)很重要:防抖提示要友好,別讓用戶一臉懵逼
- 監(jiān)控不能少:記錄被攔截的請(qǐng)求,分析用戶行為
- 前端也要防:前后端雙重防護(hù)才是王道
防抖的目的不是為難用戶,而是保護(hù)系統(tǒng)和數(shù)據(jù)的安全。就像給你的接口穿上防彈衣,既能抵擋"手抖攻擊",又能讓正常請(qǐng)求暢通無阻!
程序員防抖口訣:
前端防抖先出手,后端加鎖不能少。
令牌機(jī)制來幫忙,唯一約束最可靠。
根據(jù)場(chǎng)景選方案,系統(tǒng)穩(wěn)定沒煩惱。
用戶手抖不可怕,我有妙招來護(hù)駕!
以上就是SpringBoot實(shí)現(xiàn)接口防抖的實(shí)戰(zhàn)方案大全的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot實(shí)現(xiàn)接口防抖的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
JAVA中excel導(dǎo)出一對(duì)多合并具體實(shí)現(xiàn)
項(xiàng)目中經(jīng)常會(huì)使用到導(dǎo)出功能,有導(dǎo)出Word,有導(dǎo)出Excel的,下面這篇文章主要給大家介紹了關(guān)于JAVA中excel導(dǎo)出一對(duì)多合并具體實(shí)現(xiàn)的相關(guān)資料,需要的朋友可以參考下2023-09-09
Eureka源碼閱讀解析Server服務(wù)端啟動(dòng)流程實(shí)例
這篇文章主要為大家介紹了Eureka源碼閱讀解析Server服務(wù)端啟動(dòng)流程實(shí)例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-10-10
Java文件字符輸入流FileReader讀取txt文件亂碼的解決
這篇文章主要介紹了Java文件字符輸入流FileReader讀取txt文件亂碼的解決方案,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-09-09
JDK9的新特性之String壓縮和字符編碼的實(shí)現(xiàn)方法
這篇文章主要介紹了JDK9的新特性之String壓縮和字符編碼的實(shí)現(xiàn)方法,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-05-05
Java ThreadLocal類應(yīng)用實(shí)戰(zhàn)案例分析
這篇文章主要介紹了Java ThreadLocal類應(yīng)用,結(jié)合具體案例形式分析了java ThreadLocal類的功能、原理、用法及相關(guān)操作注意事項(xiàng),需要的朋友可以參考下2019-09-09

