防止数据重复
1.本文大部分内容主要来源于ChatGPT,本人仅对极少部分内容做了规整补充
防止数据重复提交是Web应用程序开发中一个常见的问题,特别是在表单数据提交场景下。为了解决这个问题,以下从前端和后端的角度列举几个方式
前端
以下以vue为示例:
1.禁用按钮
在表单提交时,可以将提交按钮设置为禁用状态,避免用户重复点击按钮提交表单。
<template>
<button :disabled="loading" @click="submit">提交</button>
</template>
<script>
export default {
data() {
return {
loading: false
}
},
methods: {
async submit() {
this.loading = true
// 处理表单数据
this.loading = false
}
}
}
</script>
2.防抖和节流
在使用 JavaScript 处理表单提交时,可以对提交事件进行防抖或节流处理,避免用户频繁触发表单提交事件
1.防抖(Debounce)
在一段时间内,只有最后一次操作会触发事件。可以使用 Lodash 中的 debounce 函数实现。
<template>
<button @click="debounceSubmit">提交</button>
</template>
<script>
import { debounce } from 'lodash'
export default {
methods: {
async submit() {
// 处理表单数据
},
debounceSubmit: debounce(function() {
this.submit()
}, 1000)
}
}
</script>
2.节流(Throttle)
在一段时间内,每隔一定时间间隔触发一次事件。可以使用 Lodash 中的 throttle 函数实现
<template>
<button @click="throttleSubmit">提交</button>
</template>
<script>
import { throttle } from 'lodash'
export default {
methods: {
async submit() {
// 处理表单数据
},
throttleSubmit: throttle(function() {
this.submit()
}, 1000)
}
}
</script>
3.Token 验证
使用 Token 防止表单重复提交同样适用于 Vue,在请求提交前生成 Token,将其储存在客户端 Cookie 中或者在请求头中携带,服务器端验证 Token 是否有效,避免重复提交表单。
4.Ref 标记
可以使用 Vue 的 Ref 标记,获取表单元素,对表单进行操作,避免操作错误和重复操作。
<template>
<form ref="myForm">
<!-- 表单元素 -->
</form>
<button @click="submit">提交</button>
</template>
<script>
export default {
methods: {
async submit() {
const myForm = this.$refs.myForm
// 获取表单数据并处理
}
}
}
</script>
5.拦截器
在 Vue 中,可以使用 Axios 的拦截器,对表单提交请求进行处理,避免重复提交。例如,在请求发送之前,设置一个 loading 状态,提交请求后将该状态置为 false,避免用户在请求发送期间多次点击提交按钮。
<template>
<button @click="submit" :disabled="isSubmitting">{{ isSubmitting ? '提交中...' : '提交' }}</button>
</template>
<script>
import axios from 'axios'
export default {
data() {
return {
isSubmitting: false
}
},
methods: {
async submit() {
if (this.isSubmitting) {
return
}
this.isSubmitting = true
try {
// 发送表单数据
const response = await axios.post('/api/submit', formData)
// 处理响应数据
} catch (error) {
console.error(error)
}
this.isSubmitting = false
}
}
}
</script>
6.Hash 标记
在表单提交成功后,通过修改路由的 Hash 标记,让页面重新加载,防止用户刷新或后退浏览器时重新提交表单。
<template>
<form @submit.prevent="submit">
<!-- 表单元素 -->
<button type="submit">提交</button>
</form>
</template>
<script>
export default {
methods: {
async submit() {
// 处理表单数据
window.location.hash = Date.now()
}
}
}
</script>
7.Vuex 状态管理
可以使用 Vuex 管理页面状态,避免由于用户多次提交表单导致页面状态混乱或数据错误的情况。
<template>
<button @click="submit" :disabled="isSubmitting || isSuccess">{{ isSubmitting ? '提交中...' : isSuccess ? '提交成功' : '提交' }}</button>
</template>
<script>
import { mapState } from 'vuex'
export default {
computed: {
...mapState(['isSubmitting', 'isSuccess'])
},
methods: {
async submit() {
if (this.isSubmitting) {
return
}
this.$store.commit('SET_SUBMITTING', true)
try {
// 发送表单数据
const response = await axios.post('/api/submit', formData)
// 处理响应数据
this.$store.commit('SET_SUCCESS', true)
} catch (error) {
console.error(error)
}
this.$store.commit('SET_SUBMITTING', false)
}
}
}
</script>
8.FormData 对象
使用 FormData 对象提交表单时,可以使用 set 方法为每个表单项设置唯一的 name 属性,防止重复提交并保证数据的准确性。
<template>
<form @submit.prevent="submit">
<input type="text" v-model="username" name="username">
<input type="password" v-model="password" name="password">
<button type="submit">登录</button>
</form>
</template>
<script>
export default {
data() {
return {
username: '',
password: ''
}
},
methods: {
async submit() {
const formData = new FormData()
formData.set('username', this.username)
formData.set('password', this.password)
// 发送表单数据
const response = await axios.post('/api/login', formData)
// 处理响应数据
}
}
}
</script>
后端
以springboot为示例
1.AOP
Spring Boot 中的重复提交问题可以通过 AOP 切片的方式进行解决。在 Spring Boot 中,切片(Aspect)是一种对方法拦截和增强的技术。
1.定义一个注解,用于标记需要防止重复提交的方法。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AvoidDuplicateSubmit {
}
2.实现一个切面(Aspect),在该切面中实现重复提交的校验逻辑。
@Aspect
@Component
public class AvoidDuplicateSubmitAspect {
private static final Map<String, Object> LOCK_MAP = new ConcurrentHashMap<>();
@Pointcut("@annotation(com.example.demo.annotation.AvoidDuplicateSubmit)")
public void avoidDuplicateSubmit() {}
@Before("avoidDuplicateSubmit()")
public void before(Joinpoint joinpoint) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
HttpSession session = request.getSession();
String sessionId = session.getId();
// 获取当前请求的方法名和参数列表
MethodSignature signature = (MethodSignature) joinpoint.getSignature();
String methodName = signature.getMethod().getName();
Object[] args = joinpoint.getArgs();
// 生成请求的唯一标识
String lockKey = sessionId + ":" + methodName + Arrays.toString(args);
synchronized (LOCK_MAP.computeIfAbsent(lockKey, key -> new Object())) {
// 判断是否重复提交
if (session.getAttribute(lockKey) != null) {
throw new Exception("请勿重复提交!");
}
// 标记已提交
session.setAttribute(lockKey, lockKey);
}
}
@AfterReturning("avoidDuplicateSubmit()")
public void afterReturning(JoinPoint joinpoint) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
HttpSession session = request.getSession();
// 获取当前请求的方法名和参数列表
MethodSignature signature = (MethodSignature) joinpoint.getSignature();
String methodName = signature.getMethod().getName();
Object[] args = joinpoint.getArgs();
// 生成请求的唯一标识
String lockKey = session.getId() + ":" + methodName + Arrays.toString(args);
session.removeAttribute(lockKey);
LOCK_MAP.remove(lockKey);
}
}
3.在需要防止重复提交的方法上,使用刚刚定义的注解。
@RestController
public class OrderController {
@AvoidDuplicateSubmit
@PostMapping("/orders")
public void createOrder(@RequestBody Order order) {
// 处理订单逻辑
}
}
这样就可以在 Spring Boot 中实现防止表单重复提交了。当用户多次点击提交按钮时,系统会拦截后续请求,并抛出异常,避免重复提交。同时,由于每个请求都是基于一个唯一的锁进行处理的,因此可以防止并发问题的产生。
上述代码中,通过 @Aspect 注解标记了该类为一个切面,并使用 @Component 注解将其标记为 Spring Bean。@Pointcut 注解定义了切入点,即需要拦截的目标方法。
在 @Before 和 @AfterReturning 注解标记的方法中,分别实现了前置和后置通知。before 方法实现了重复提交的校验逻辑,在方法执行前首先获取当前请求的相关信息,包括会话 ID、方法名和参数列表等,然后使用这些信息生成一个唯一的锁,用于防止并发提交。如果当前请求已经被处理过了,则抛出异常,否则记录已处理标记,防止重复提交。afterReturning 方法则是在目标方法执行完毕后清除已处理标记和锁,避免占用系统资源。
2.利用Redis + AOP(推荐
)
关于 Redis 实现防止表单重复提交,可以参考以下代码:
1.引入 Redis 相关依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
2.定义一个注解,用于标记需要防止重复提交的方法。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AvoidDuplicateSubmit {
}
3,实现一个切面(Aspect),在该切面中实现重复提交的校验逻辑。
@Aspect
@Component
public class AvoidDuplicateSubmitAspect {
private static final Logger LOGGER = LoggerFactory.getLogger(AvoidDuplicateSubmitAspect.class);
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Pointcut("@annotation(com.example.demo.annotation.AvoidDuplicateSubmit)")
public void avoidDuplicateSubmit() {}
@Before("avoidDuplicateSubmit()")
public void before(Joinpoint joinpoint) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 获取当前请求的方法名和参数列表
MethodSignature signature = (MethodSignature) joinpoint.getSignature();
String methodName = signature.getMethod().getName();
Object[] args = joinpoint.getArgs();
// 生成请求的唯一标识
String lockKey = generateLockKey(request, methodName, args);
// 判断是否重复提交
if (!redisTemplate.opsForValue().setIfAbsent(lockKey, "1")) {
LOGGER.warn("重复提交请求:{}", lockKey);
throw new Exception("请勿重复提交!");
}
// 设置过期时间,避免死锁
redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
}
@AfterReturning("avoidDuplicateSubmit()")
public void afterReturning(JoinPoint joinpoint) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 获取当前请求的方法名和参数列表
MethodSignature signature = (MethodSignature) joinpoint.getSignature();
String methodName = signature.getMethod().getName();
Object[] args = joinpoint.getArgs();
// 生成请求的唯一标识
String lockKey = generateLockKey(request, methodName, args);
// 删除锁
redisTemplate.delete(lockKey);
}
private String generateLockKey(HttpServletRequest request, String methodName, Object[] args) {
String sessionId = request.getSession().getId();
String uri = request.getRequestURI();
return String.format("%s:%s:%s", sessionId, uri, Arrays.toString(args));
}
}
4.在 Redis 配置文件中添加 Redis 相关配置
spring.redis.host=localhost
spring.redis.port=6379
5.在需要防止重复提交的方法上,使用刚刚定义的注解。
@RestController
public class OrderController {
@AvoidDuplicateSubmit
@PostMapping("/orders")
public void createOrder(@RequestBody Order order) {
// 处理订单逻辑
}
}
这样就可以使用 Redis 实现防止表单重复提交了。当用户多次点击提交按钮时,系统会拦截后续请求,并抛出异常,避免重复提交。由于 Redis 是一个高性能、可靠的分布式缓存,因此不会产生 Session 作为锁时容易出现的问题,同时也可以支持分布式部署场景,并发能力更强。
3.数据库
除了 Redis,还可以使用数据库实现防止重复提交。例如,可以在数据库中创建一张表,用于存储请求的信息,并使用唯一索引来保证请求的唯一性。
下面是一个示例:
1.定义一个注解,用于标记需要防止重复提交的方法。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AvoidDuplicateSubmit {
}
2.实现一个切面(Aspect),在该切面中实现重复提交的校验逻辑。
@Aspect
@Component
public class AvoidDuplicateSubmitAspect {
private static final Logger LOGGER = LoggerFactory.getLogger(AvoidDuplicateSubmitAspect.class);
@Autowired
private JdbcTemplate jdbcTemplate;
@Pointcut("@annotation(com.example.demo.annotation.AvoidDuplicateSubmit)")
public void avoidDuplicateSubmit() {}
@Before("avoidDuplicateSubmit()")
public void before(Joinpoint joinpoint) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 获取当前请求的方法名和参数列表
MethodSignature signature = (MethodSignature) joinpoint.getSignature();
String methodName = signature.getMethod().getName();
Object[] args = joinpoint.getArgs();
// 判断是否重复提交
String sql = "INSERT INTO request_log(session_id, uri, method_name, params) VALUES (?, ?, ?, ?)";
String sessionId = request.getSession().getId();
String uri = request.getRequestURI();
String paramsJson = GsonUtils.toJson(args);
try {
jdbcTemplate.update(sql, sessionId, uri, methodName, paramsJson);
} catch (DuplicateKeyException e) {
LOGGER.warn("重复提交请求:{}, {}, {}", sessionId, uri, paramsJson);
throw new Exception("请勿重复提交!");
}
}
@AfterReturning("avoidDuplicateSubmit()")
public void afterReturning(JoinPoint joinpoint) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 获取当前请求的方法名和参数列表
MethodSignature signature = (MethodSignature) joinpoint.getSignature();
String methodName = signature.getMethod().getName();
Object[] args = joinpoint.getArgs();
// 删除请求记录
String sql = "DELETE FROM request_log WHERE session_id = ? AND uri = ? AND method_name = ? AND params = ?";
String sessionId = request.getSession().getId();
String uri = request.getRequestURI();
String paramsJson = GsonUtils.toJson(args);
jdbcTemplate.update(sql, sessionId, uri, methodName, paramsJson);
}
}
3.在数据库中创建 request_log 表
CREATE TABLE `request_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`session_id` varchar(255) NOT NULL,
`uri` varchar(255) NOT NULL,
`method_name` varchar(255) NOT NULL,
`params` text NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_request` (`session_id`,`uri`,`method_name`,`params`(100))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
4.在需要防止重复提交的方法上,使用刚刚定义的注解。
@RestController
public class OrderController {
@AvoidDuplicateSubmit
@PostMapping("/orders")
public void createOrder(@RequestBody Order order) {
// 处理订单逻辑
}
}
这样就可以使用数据库实现防止表单重复提交了。当用户多次点击提交按钮时,系统会拦截后续请求,并抛出异常,避免重复提交。由于使用的是唯一索引来保证请求的唯一性,因此避免了 Session 作为锁容易出现的线程安全问题,并且支持分布式场景。但是,需要注意的是,使用数据库实现防重复提交会增加数据库的负载,因此在高并发场景下需要使用数据库连接池等技术来提升性能。
4.Token
除了 Redis 和数据库,还可以使用 Token 防止重复提交。具体实现步骤如下:
1.在前端页面中,生成一个随机的 Token,并将其携带在表单中。
<form id="myForm" action="/submit" method="post">
<input type="text" name="username">
<input type="hidden" name="token" value="这里是随机生成的 Token">
<button type="submit">提交</button>
</form>
2.在服务端接收到请求时,校验 Token 是否正确。
@RestController
public class MyController {
private final String TOKEN_KEY = "TOKEN_"; // Token 缓存键前缀
@Autowired
private RedisTemplate<String, String> redisTemplate;
@PostMapping("/submit")
public String submit(HttpServletRequest request) {
// 校验 Token 是否正确
String token = request.getParameter("token");
String tokenKey = TOKEN_KEY + token;
Boolean exists = redisTemplate.hasKey(tokenKey);
if (exists != null && exists) {
redisTemplate.delete(tokenKey);
// 处理表单提交逻辑
return "提交成功";
} else {
return "请勿重复提交";
}
}
@GetMapping("/token")
public String getToken() {
// 生成随机的 Token
String token = UUID.randomUUID().toString();
// 将 Token 存入 Redis,并设置过期时间为 5 分钟
String tokenKey = TOKEN_KEY + token;
redisTemplate.opsForValue().set(tokenKey, token, Duration.ofMinutes(5));
return token;
}
}
3.在服务端开启定时任务,定期清理已经过期的 Token。
@Component
public class TokenCleanTask {
private final String TOKEN_KEY = "TOKEN_"; // Token 缓存键前缀
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Scheduled(fixedDelayString = "${token.clean.delay:600000}")
public void cleanExpiredToken() {
long startTime = System.currentTimeMillis();
// 构建匹配模式,获取所有以 TOKEN_ 开头的键
String pattern = TOKEN_KEY + "*";
Set<String> keys = redisTemplate.keys(pattern);
int count = 0;
if (keys != null && !keys.isEmpty()) {
// 删除已经过期的 Token
List<String> tokenList = redisTemplate.opsForValue().multiGet(keys);
List<String> expiredKeys = new ArrayList<>();
for (int i = 0; i < keys.size(); i++) {
if (tokenList.get(i) == null) {
expiredKeys.add(keys.toArray()[i].toString());
count++;
}
}
if (!expiredKeys.isEmpty()) {
redisTemplate.delete(expiredKeys);
}
}
long endTime = System.currentTimeMillis();
log.info("清理 {} 个过期 Token,耗时 {} 毫秒", count, endTime - startTime);
}
}
这样就可以使用 Token 防止表单重复提交了。当用户多次点击提交按钮时,系统会拦截后续请求,并抛出异常,避免重复提交。由于使用的是 Redis 缓存,因此支持分布式场景,并且在高并发场景下性能也很好。但是,需要注意的是,如果前端页面中生成的 Token 被恶意攻击者获取并恶意提交,仍然有可能出现表单重复提交的情况,因此应该在服务端再次校验表单数据的合法性。
5.Token Bucket(令牌桶)
Token Bucket(令牌桶)算法是一种流量控制算法,用于控制系统的请求速率。该算法的工作原理是,系统以固定速率产生 Token(令牌),并将 Token 存储在一个令牌桶中。当用户发起请求时,系统会从令牌桶中取出一个 Token,如果取出的 Token 数量达到系统规定的阈值,则拒绝后续请求。当 Token 输送完毕后,系统会等待一段时间后再次产生 Token。
下面是一个示例:
1.定义一个注解,用于标记需要限制请求速率的方法。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LimitRequestSpeed {
int value() default 10; // 默认每秒允许 10 个请求
}
2.实现一个切面(Aspect),在该切面中实现请求速率的限制逻辑。
@Aspect
@Component
public class LimitRequestSpeedAspect {
private static final Logger log = LoggerFactory.getLogger(LimitRequestSpeedAspect.class);
private Map<String, TokenBucket> tokenBucketMap = new ConcurrentHashMap<>();
@Autowired
private HttpServletRequest request;
@Pointcut("@annotation(com.example.demo.annotation.LimitRequestSpeed)")
public void limitRequestSpeed() {}
@Around("limitRequestSpeed()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
LimitRequestSpeed limitRequestSpeed = signature.getMethod().getAnnotation(LimitRequestSpeed.class);
int speed = limitRequestSpeed.value(); // 允许的最大速率
String key = request.getRequestURI() + ":" + speed; // 用请求 URI 和速率作为缓存键
TokenBucket tokenBucket = tokenBucketMap.get(key);
if (tokenBucket == null) {
synchronized (this) {
if (tokenBucket == null) {
tokenBucket = new TokenBucket(speed, speed);
tokenBucketMap.put(key, tokenBucket);
log.info("创建令牌桶:{}", key);
}
}
}
boolean acquired = tokenBucket.tryAcquire();
if (acquired) {
return joinPoint.proceed();
} else {
throw new Exception("请求过于频繁,请稍后再试!");
}
}
/**
* 令牌桶实现类
*/
private static class TokenBucket {
private final int capacity; // 桶容量
private final int refillTokens; // 每次填充的 Token 数量
private long lastRefillTime; // 上一次填充时间
private int tokensInBucket; // 桶中剩余的 Token 数量
TokenBucket(int capacity, int refillTokens) {
this.capacity = capacity;
this.refillTokens = refillTokens;
this.lastRefillTime = System.currentTimeMillis();
this.tokensInBucket = capacity;
}
public synchronized boolean tryAcquire() {
refill();
if (tokensInBucket > 0) {
tokensInBucket--;
return true;
} else {
return false;
}
}
private void refill() {
long now = System.currentTimeMillis();
long elapsedTime = now - lastRefillTime;
int tokensToAdd = (int) (elapsedTime * refillTokens / 1000);
if (tokensToAdd > 0) {
tokensInBucket = Math.min(tokensInBucket + tokensToAdd, capacity);
lastRefillTime = now;
}
}
}
}
3.在需要限制请求速率的方法上,使用刚刚定义的注解。
@RestController
public class MyController {
@LimitRequestSpeed(10) // 每秒允许 10 个请求
@PostMapping("/submit")
public String submit(@RequestBody User user) {
// 处理表单提交逻辑
return "提交成功";
}
}
这样就可以使用 Token Bucket 算法控制请求速率了。当用户发起请求时,系统会先从令牌桶中获取一个 Token,如果桶中没有 Token 或者已经达到限制速率,则拒绝后续请求。由于使用了缓存来存储 Token 桶,因此支持分布式场景,并且在高并发场景下性能也很好。但是,需要注意的是,该算法对于突发流量的处理能力比较弱,因为 Token 桶中 Token 的数量是固定的,如果突然有大量请求涌入,则可能会造成请求失败或者延迟较长时间才能响应。
6.漏桶算法
除了 Token Bucket 算法,还有一种常见的限流算法是漏桶算法。
漏桶算法是一种固定容量的桶,水滴以恒定速率流向漏口,如果水滴涌入速率超过了漏出速率,则多余的水滴将会被直接丢弃或排队缓存。在实际应用中,漏桶算法经常用来控制网络流量。例如,某个网络服务设备能够处理每秒 1000 个请求,而唯一的入口带宽为 1 Mbps,那么为了确保服务的正常运行,我们需要限制每个请求的大小不超过 1 KB,并使用漏桶算法控制每秒的请求速率不超过 1000 个。
下面是一个示例:
public class LeakyBucket {
private final int capacity; // 当前桶的容量
private final int leakingRate; // 漏出速率(每秒可以漏出的水量)
private int waterCount; // 当前桶中的水滴数量
private long lastLeakTimestamp; // 上次漏水的时间戳
public LeakyBucket(int capacity, int leakingRate) {
this.capacity = capacity;
this.leakingRate = leakingRate;
this.waterCount = 0;
this.lastLeakTimestamp = System.currentTimeMillis();
}
/**
* 向桶中注入水滴
*/
public synchronized boolean inject(int count) {
// 漏桶已满,注水失败
if (waterCount + count > capacity) {
return false;
}
waterCount += count;
return true;
}
/**
* 漏水,返回漏水的水滴数量
*/
public synchronized int leak() {
long now = System.currentTimeMillis();
// 计算上次漏水到现在,一共漏了多少滴水
int leakedCount = (int) ((now - lastLeakTimestamp) / 1000 * leakingRate);
// 更新上次漏水时间戳
lastLeakTimestamp = now;
// 漏桶中的水滴数量减少
waterCount = Math.max(0, waterCount - leakedCount);
return leakedCount;
}
/**
* 获取桶中的水滴数量
*/
public synchronized int getWaterCount() {
return waterCount;
}
}
在使用漏桶算法控制请求速率时,我们需要首先创建一个固定容量的漏桶,然后向漏桶中注入请求。每次请求会消耗漏桶中的一定数量的水滴(即漏出速率),如果漏桶中的水滴数量不足,则拒绝后续请求。
下面是一个示例:
@RestController
public class MyController {
private final LeakyBucket leakyBucket = new LeakyBucket(10, 1); // 容量为 10,每秒漏出 1 个元素
@PostMapping("/submit")
public String submit(@RequestBody User user) {
if (leakyBucket.inject(1)) {
// 处理表单提交逻辑
leakyBucket.leak(); // 请求处理完毕后漏水
return "提交成功";
} else {
throw new RuntimeException("请求过于频繁,请稍后再试!");
}
}
}
使用漏桶算法控制请求速率时需要注意,如果漏桶容量过小或者漏出速率过低,则可能会导致大量请求被排队等待,从而增加用户的等待时间。因此,在实际应用中需要根据具体情况合理设置漏桶容量和漏出速率。
7.令牌环
令牌环算法和 Token Bucket 算法类似,也是维护一个令牌桶,但是不同的是,令牌环算法将其抽象成了一个环形的缓冲区,令牌会以恒定速率往环中装入,当请求到来时,就从环中取出一个令牌,若令牌数量为 0,则拒绝该请求。
下面是一个简单的 Java 实现:
public class TokenBucket {
private final int capacity; // 当前桶的容量
private final int producingRate; // 令牌生成速率(每秒生成的令牌数)
private int tokenCount; // 当前桶中剩余的令牌数量
private int consumePosition; // 消费位置
public TokenBucket(int capacity, int producingRate) {
this.capacity = capacity;
this.producingRate = producingRate;
this.tokenCount = 0;
this.consumePosition = 0;
new Thread(() -> {
while (true) {
synchronized (this) {
// 每秒钟向桶中增加 producingRate 个令牌,直到桶满为止
while (tokenCount < capacity) {
int countToAdd = Math.min(producingRate, capacity - tokenCount);
tokenCount += countToAdd;
}
// 唤醒等待的线程
notifyAll();
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
break;
}
}
}).start();
}
/**
* 从桶中消费一个令牌,若没有令牌,则返回 false
*/
public synchronized boolean consume() {
// 等待桶中有令牌
while (tokenCount == 0) {
try {
wait();
} catch (InterruptedException e) {
return false;
}
}
// 消费一个令牌
tokenCount--;
// 更新消费位置
consumePosition = (consumePosition + 1) % capacity;
return true;
}
/**
* 获取桶中剩余的令牌数量
*/
public synchronized int getTokenCount() {
return tokenCount;
}
/**
* 获取当前的消费位置
*/
public synchronized int getConsumePosition() {
return consumePosition;
}
}
2.在使用令牌环算法控制请求速率时,我们需要首先创建一个固定容量的令牌环,然后在每秒钟向环中注入一定数量的令牌。每次请求会从令牌环中取出一个令牌,如果令牌数量为 0,则拒绝后续请求。
下面是一个示例:
@RestController
public class MyController {
private final TokenBucket tokenBucket = new TokenBucket(10, 1); // 容量为 10,每秒生成 1 个令牌
@PostMapping("/submit")
public String submit(@RequestBody User user) {
if (tokenBucket.consume()) {
// 处理表单提交逻辑
return "提交成功";
} else {
throw new RuntimeException("请求过于频繁,请稍后再试!");
}
}
}
使用令牌环算法控制请求速率时,需要注意生成速率和环的容量的设置。如果生成速率过低或者容量过小,则可能导致无法满足高峰期的请求流量,从而影响用户体验。同时,在具体的业务场景中,我们需要根据实际情况来选择合适的限流算法。
当然,和 Token Bucket 算法一样,令牌环算法也有一些局限性。
首先,令牌环算法需要维护一个大小固定的环形缓冲区,因此对于突发请求,它可能无法满足短时间内产生大量请求的情况。例如,在秒杀等活动中,会存在短时间内用户数量爆发,如果仅使用令牌环算法进行限流,则可能无法有效地控制请求速率,从而导致系统崩溃。
其次,令牌环算法可能会存在令牌浪费的问题。在某些情况下,令牌环中可能会积累很多未被消费的令牌,这些令牌不会被使用,但是仍然会占用内存资源。同时,由于令牌是以恒定速率生成的,因此在低流量期间,令牌可能会过度积累,造成浪费。
最后,令牌环算法的实现相对比较复杂,需要考虑线程同步、缓冲区满溢等问题。如果实现不当,则可能引入死锁、竞态条件等风险。因此,在实际应用中,我们需要仔细评估令牌环算法的适用场景,并采用严格的开发和测试流程来确保其可靠性和稳定性。
除了 Token Bucket 算法、漏桶算法和令牌环算法,还有一些常见的限流算法,例如计数器算法、漏斗算法等。
计数器算法是最简单的限流算法之一,它会维护一个计数器,每当一个请求到达时,就将计数器加 1,当计数器超过限流阈值时,就拒绝该请求。计数器算法可以很好地控制请求速率,但存在明显的局限性,例如对于短时间内突发请求的场景无法很好地适应。
漏斗算法则是一种比较灵活的限流算法,它可以动态地调整请求速率,从而更好地适应突发请求的场景。漏斗算法模拟了一个漏斗,请求相当于水滴,漏斗的容量代表系统的处理能力,漏嘴的大小代表请求的消费速率。漏斗中的水滴会以固定速率进入漏斗中,当漏斗不够大时,水会流失掉,这样就可以实现限流的效果。漏斗算法既可以用于网络流量控制,也可以用于分布式系统中的流量控制。
总的来说,限流算法是一个非常重要的技术,它可以帮助我们保护系统,防止过载和崩溃,并提供更好的用户体验。在使用限流算法时,我们需要充分了解不同算法的特点和局限性,并根据实际情况进行选择和优化,以便在不同的业务场景中发挥最佳的效果。