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,当计数器超过限流阈值时,就拒绝该请求。计数器算法可以很好地控制请求速率,但存在明显的局限性,例如对于短时间内突发请求的场景无法很好地适应。

漏斗算法则是一种比较灵活的限流算法,它可以动态地调整请求速率,从而更好地适应突发请求的场景。漏斗算法模拟了一个漏斗,请求相当于水滴,漏斗的容量代表系统的处理能力,漏嘴的大小代表请求的消费速率。漏斗中的水滴会以固定速率进入漏斗中,当漏斗不够大时,水会流失掉,这样就可以实现限流的效果。漏斗算法既可以用于网络流量控制,也可以用于分布式系统中的流量控制。

总的来说,限流算法是一个非常重要的技术,它可以帮助我们保护系统,防止过载和崩溃,并提供更好的用户体验。在使用限流算法时,我们需要充分了解不同算法的特点和局限性,并根据实际情况进行选择和优化,以便在不同的业务场景中发挥最佳的效果。

05-31 16:33