SpringBoot接口防抖(防重复提交)
概念
Spring Boot接口防抖(Debouncing)的概念是指在处理请求时,通过一定的机制来防止用户频繁触发同一接口请求,以防止重复提交或频繁请求的情况发生。
在Web应用中,用户可能会因为网络延迟、操作失误或者意外多次点击提交按钮,导致相同的请求被发送多次,从而引发数据的重复处理或者系统资源的浪费。接口防抖的目的就是在一定程度上限制这种重复请求的发生,保证系统的稳定性和数据的一致性。
接口防抖通常可以通过以下几种方式实现:
- 前端防抖: 在前端页面通过JavaScript等客户端技术实现,对用户的操作进行控制,例如利用定时器或者延迟执行的方式来合并多个相同操作,确保只发送一次请求。
- 后端防抖: 在后端服务器端实现,通过拦截器、过滤器等机制对相同请求的执行频率进行控制,拦截并处理重复的请求,防止其继续向下执行。
接口防抖通常需要考虑以下几个方面:
- 时间间隔设置: 确定两次相同请求之间的时间间隔,即防抖的时间阈值,通常以毫秒为单位。
- 处理方式: 当检测到重复请求时,需要确定如何处理,可以是直接忽略、返回错误提示或者采取其他适当的措施。
- 线程安全: 如果应用是多线程的或者是分布式的,需要考虑线程安全和分布式环境下的数据共享和同步问题,确保防抖机制的正确性和可靠性。
如何确定接口是重复
确定接口是否重复,一般可以通过以下几种方式:
- 请求参数比较: 比较接口请求的参数是否完全相同。如果接口的请求参数都一致,那么可以认为是相同的请求。
- 请求路径和请求方法比较: 比较接口的请求路径(URL)和请求方法(GET、POST等)是否完全相同。如果请求路径和请求方法都一致,那么可以认为是相同的请求。
- 请求头比较: 比较接口的请求头信息是否完全相同。请求头包含了很多关于请求的元数据,如用户代理、授权信息等。如果请求头信息完全相同,那么可以认为是相同的请求。
- 请求体比较: 对于具有请求体的POST、PUT等请求,可以比较请求体的内容是否完全相同。如果请求体内容一致,那么可以认为是相同的请求。
- IP地址和用户标识比较: 可以通过客户端的IP地址和用户标识来判断请求是否来自同一个客户端。如果两个请求具有相同的IP地址和用户标识,那么可以认为是相同的请求。
根据时间戳来防抖
DebounceController.java
package com.sin.controller;// 需要先在pom.xml中添加Spring Web依赖
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.concurrent.ConcurrentHashMap;
/**
* @createTime 2024/6/4 11:17
* @createAuthor SIN
* @use 时间戳防抖
*/
@Controller
@RequestMapping("/api")
public class DebounceController {
// 用于存储接口请求的时间戳
private final ConcurrentHashMap<String, Long> requestTimestamps = new ConcurrentHashMap<>();
@PostMapping("/submit")
@ResponseBody
public String submit() {
// 接口路径为"/api/submit",模拟防抖处理
String key = "/api/submit";
// 获取当前时间戳
long currentTimestamp = System.currentTimeMillis();
// 上一次请求的时间戳
Long lastTimestamp = requestTimestamps.get(key);
// 如果上一次请求时间不为空,并且与当前时间间隔小于5000毫秒(5秒),则认为是重复请求,直接返回提示
if (lastTimestamp != null && currentTimestamp - lastTimestamp < 5000) {
return "重复提交,请稍后再试!";
}
// 记录当前请求时间戳
requestTimestamps.put(key, currentTimestamp);
// 返回处理结果
return "提交成功!";
}
}
- 第一次提交
- 第二次提交
分布式下如何做防抖
在分布式环境下,防抖(防重复提交)需要考虑多个节点之间的数据同步和并发控制。以下是一种在分布式环境下实现防抖的方法:
- 使用分布式缓存: 可以使用分布式缓存来存储接口请求的时间戳信息。常见的分布式缓存系统包括Redis、Memcached等。通过在缓存中存储请求的时间戳,并设置适当的过期时间,可以实现简单的防抖功能。
- 使用分布式锁: 在处理防抖逻辑时,可以使用分布式锁来确保同一时刻只有一个节点可以执行特定的代码块。当某个节点获取到锁时,执行防抖逻辑并更新缓存中的时间戳信息,其他节点在尝试获取锁时可以判断缓存中的时间戳信息,从而避免重复提交。
分布式缓存
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
application.yml
spring:
data:
redis:
host: 192.168.226.134
password: 123456
RedisDebounceController.java
package com.sin.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
/**
* @createTime 2024/6/4 11:17
* @createAuthor SIN
* @use 分布式缓存(Redis)防抖
*/
@RestController
@RequestMapping("/api")
public class RedisDebounceController {
private static final String REQUEST_KEY = "submit:request";
@Autowired
private RedisTemplate<String, String> redisTemplate;
@PostMapping("/redisSubmit")
public String submit() {
// 检查Redis中是否存在请求标记
if (redisTemplate.hasKey(REQUEST_KEY)) {
return "重复提交,请稍后再试!";
}
// 将请求标记写入Redis,并设置过期时间
redisTemplate.opsForValue().set(REQUEST_KEY, "1", 5, TimeUnit.SECONDS);
// 返回处理结果
return "提交成功!";
}
}
- 第一次提交
- 第二次提交
使用了固定的键名"submit:request"来存储接口请求的标记,Redis中是否存在请求标记,如果存在则认为是重复提交,直接返回提示信息。如果不存在请求标记,则将请求标记写入Redis,并设置过期时间为5秒,以确保在此时间内同一个接口不能重复提交
分布式锁
RedisLockDebounceController.java
package com.sin.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* @createTime 2024/6/4 11:29
* @createAuthor SIN
* @use 使用分布式锁防抖
*/
@RestController
@RequestMapping("/api")
public class RedisLockDebounceController {
private static final long LOCK_EXPIRE_TIME = 10000L; // 锁的过期时间,单位毫秒
private static final long DEBOUNCE_TIME = 10000L; // 防抖时间,单位毫秒
@Autowired
private RedisTemplate<String, String> redisTemplate;
@PostMapping("/redis/lock")
public String acquireLock(String key) {
String lockKey = key; // 锁的键名为传入的 key 参数
String requestId = String.valueOf(System.currentTimeMillis()); // 请求 ID 为当前时间戳的字符串形式
/**
* Lua 脚本的作用是尝试获取分布式锁。它通过 SETNX 命令尝试在 Redis 中设置一个键的值,如果设置成功,则进一步设置该键的过期时间,并返回 true 表示获取锁成功;如果设置失败,则表示锁已被其他客户端获取,返回 false 表示获取锁失败。
* RedisScript<Boolean>: Spring Data Redis 提供的用于执行 Lua 脚本的接口
* DefaultRedisScript<>(script,Boolean.class):RedisScript 的实例化操作,
* script 参数是一个字符串类型的 Lua 脚本,表示要执行的 Redis 操作。
* Boolean.class 参数指定了脚本执行后的返回类型为布尔值。
* if redis.call('SETNX', KEYS[1], ARGV[1]) == 1 then: Redis 的 SETNX 命令,用于在 Redis 中设置一个键的值,但只有在该键不存在时才设置成功。
* KEYS[1] 表示 Lua 脚本中传入的键的数组,这里取第一个键。
* ARGV[1] 表示 Lua 脚本中传入的参数的数组,这里取第一个参数。
* 如果 SETNX 返回值为 1,表示设置成功,即之前该键不存在,执行 then 代码块中的操作。
* redis.call('PEXPIRE', KEYS[1], ARGV[2]):如果 SETNX 操作成功,接着调用了 Redis 的 PEXPIRE 命令,用于设置键的过期时间。
* KEYS[1] 表示要设置过期时间的键,
* ARGV[2] 表示传入的第二个参数,即锁的过期时间。
* return true:如果 SETNX 操作成功,并且设置了过期时间,最终返回 Lua 脚本执行结果为 true,表示获取锁成功。
* end:结束 if 条件语句块。
* return false:如果 SETNX 操作失败,即之前该键已存在,或者设置过程中出现异常,最终返回 Lua 脚本执行结果为 false,表示获取锁失败。
*/
RedisScript<Boolean> script = new DefaultRedisScript<>(
"if redis.call('SETNX', KEYS[1], ARGV[1]) == 1 then " +
"redis.call('PEXPIRE', KEYS[1], ARGV[2]) " +
"return true " +
"end " +
"return false", Boolean.class);
// 创建一个包含元素的列表,该元素时LockKey即为锁的键名
List<String> keys = Collections.singletonList(lockKey);
/**
* 执行redis的操作
* script:之前创建的RedisScript的对象,用于执行Lua脚本
* keys:Lua脚本中的Keys参数,即为键的数组,只有一个键,即锁的键名
* requestId:Lua 脚本中的 ARGV 参数,即参数的数组,传入了请求 ID,用于标识这次获取锁的请求
* String.valueOf(LOCK_EXPIRE_TIME):Lua 脚本中的 ARGV 参数,即参数的数组。传入了锁的过期时间,以毫秒为单位
*/
Boolean result = redisTemplate.execute(script, keys, requestId, String.valueOf(LOCK_EXPIRE_TIME));
// 如果 result 不为 null,并且为真(即成功获取了锁)
if (result != null && result) {
try {
// 模拟处理逻辑
Thread.sleep(1000);
// 检查是否在防抖时间内有重复请求
if (isDuplicateRequest(key)) {
return "重复提交,请稍后再试!";
}
// 返回处理结果
return "获取锁成功!";
//捕获可能发生的线程中断异常,
} catch (InterruptedException e) {
// 将当前线程重新标记为中断状态
Thread.currentThread().interrupt();
return "获取锁时发生异常:" + e.getMessage();
} finally {
// 释放锁
releaseLock(lockKey, requestId);
}
} else {
return "获取锁失败,请稍后再试!";
}
}
/**
* 防止重复请求
* @param key 键,即锁的键名
* @return
*/
private boolean isDuplicateRequest(String key) {
// 检查是否在防抖时间内有重复请求
String lastRequestTime = redisTemplate.opsForValue().get("lastRequestTime:" + key); // 获取上次请求时间
long currentTime = System.currentTimeMillis(); // 当前时间戳
// 如果上次请求时间不为 null(即 Redis 中存在上次请求时间),且当前时间距离上次请求时间小于防抖时间 DEBOUNCE_TIME(10000L),则认为发生了重复请求,返回 true。
if (lastRequestTime != null && currentTime - Long.parseLong(lastRequestTime) < DEBOUNCE_TIME) { // 如果防抖时间内有重复请求,则返回 true
return true;
} else {
// 如果没有发生重复请求,则将当前时间戳保存到 Redis 中,作为上次请求时间。同时设置了过期时间 DEBOUNCE_TIME(10000L),以毫秒为单位。
redisTemplate.opsForValue().set("lastRequestTime:" + key, String.valueOf(currentTime), DEBOUNCE_TIME, TimeUnit.MILLISECONDS); // 否则将当前时间作为上次请求时间并设置过期时间,返回 false
return false;
}
}
/**
* 释放锁
* @param lockKey 接受锁的键
* @param requestId 请求标识作为参数
*/
private void releaseLock(String lockKey, String requestId) {
// 释放锁。脚本中的 KEYS[1] 和 ARGV[1] 会分别被传入 keys 和 requestId 参数替换
String releaseLockScript = "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
"return redis.call('DEL', KEYS[1]) " +
"else " +
"return 0 " +
"end";
// 将 Lua 脚本字符串转换为 RedisScript 对象,指定了返回类型为 Long
RedisScript<Long> script = new DefaultRedisScript<>(releaseLockScript, Long.class);
// 创建了一个包含锁键的列表,作为 Lua 脚本的 KEYS 参数。
List<String> keys = Collections.singletonList(lockKey);
// 执行 Lua 脚本,传入了脚本对象、键列表和请求标识作为参数,从而释放了锁
redisTemplate.execute(script, keys, requestId);
}
}
- 第一次访问
- 第二次访问