使用Redis控制表单重复提交和控制接口访问频率

本文介绍借助Redis语法实现控制表单重复提交和接口调用频率的方案。对于表单防重提交,介绍了前端按钮置灰及后端在gateway网关、AOP切面控制的优缺点,并给出实现代码;对于接口调用频率控制,以短信验证码发送为例说明背景,未详细给出代码。

场景一:控制表单重复提交

防重提交有很多方案,从前端的按钮置灰,到后端synchronize锁、Lock锁、借助Redis语法实现简单锁、Redis+Lua分布式锁、Redisson分布式锁,再到DB的悲观锁、乐观锁、借助表唯一索引等等都可以实现防重提交,以保证数据的安全性。
这篇文章我们介绍其中一种方案–借助Redis语法实现简单锁,最终实现防重提交。

背景:

我们项目中,为了控制表单重复提交问题,会在点击页面按钮(向后端发起业务请求)后就会置灰按钮,直到后端响应后解除按钮置灰。通过按钮置灰来防止重启提交问题。但Postman、Jmeter和其他服务调用(绕过前端页面)呢?所以后端接口也要根据控制表单重复提交的问题。

后端代码可以在2个位置做控制:
一是放在gateway网关做
好处是只在一个地方加上控制代码,就可以控制所有接口的重复提交问题。坏处是控制的范围太广(比如查询接口无需控制,控制了反而多余)、定义重复提交的时间段不能灵活调整。
二是放在AOP切面做
好处是只有需要的地方才会被控制(哪里需要引用一下自定义注解即可),另外也能灵活调整定义重复提交的时间段(自定义注解里定义时间字段开放给使用者填写)。坏处是每个需要控制的地方都要加注解,会有侵入性和一定的工作量。

实现代码

1、添加自定义注解

package com.xxx.annotations;

import java.lang.annotation.*;

/**
 * 自定义注解防止表单重复提交
 *
 * @Author WANGLINGQIANG
 * @Date 2023/9/6 10:11
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {

    /**
     * 过期时间,单位毫秒
     */
    long expireTime() default 500L;

}

2、添加AOP切面

package com.xxx.aop;

import com.xxx.annotations.RepeatSubmit;
import com.xxx.exception.ServiceException;
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.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

/**
 * 防止表单重复提交切面
 *
 * @Author WANGLINGQIANG
 * @Date 2023/9/6 10:13
 */
@Slf4j
@Aspect
@Component
public class RepeatSubmitAspect {
    private static final String KEY_PREFIX = "repeat_submit:";
    @Resource
    private RedisTemplate redisTemplate;

    @Pointcut("@annotation(com.xxx.annotations.RepeatSubmit)")
    public void repeatSubmit() {}

    @Around("repeatSubmit()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
    	//joinPoint获取方法对象
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        //获取方法上的@RepeatSubmit注解
        RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
        //获取HttpServletRequest对象,以获取请求uri
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = requestAttributes.getRequest();
        String uri = request.getRequestURI();
        //拼接Redis的key,这里只是简单根据uri来判断是否重复提交。可以根据自己业务调整,比如根据用户id或者请求token等
        String cacheKey = KEY_PREFIX.concat(uri);
        Boolean flag = null;
        try {
            //借助setIfAbsent(),key不存在才能设值成功
            flag = redisTemplate.opsForValue().setIfAbsent(cacheKey, "", annotation.expireTime(), TimeUnit.MILLISECONDS);
        } catch (Exception e) {
            //如果Redis不可用,则打印日志记录,但依然对请求放行
            log.error("", e);
            return joinPoint.proceed();
        }
        //Redis可用的情况,如果flag=true说明单位时间内这是第一次请求,放行
        if (flag) {
            return joinPoint.proceed();
        } else {
            //进入else说明单位时间内进行了多次请求,则拦截请求并提示稍后重试
            throw new ServiceException("系统繁忙,请稍后重试");
        }
    }
}

这里利用redisTemplate的setIfAbsent()实现的,如果存在就不能set成功,set的同时设置过期时间,可以是用使用默认,也可以自己根据业务调整。
另外,cacheKey的定义,也可以根据自己的需要去调整,比如根据当前登录用户的userId、当前登录的token等。

3、使用

@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

	@RepeatSubmit
    @PostMapping
    public AjaxResult add(@Validated @RequestBody SysUser user) {
    	//....
    }


场景二:控制接口调用频率

背景:

忘记密码后通过发送手机验证码找回密码的场景。因为每发一条短信都需要收费,所以要控制发短信的频率。比如,同一个手机号在3分钟内只能发送3次短信,超过3次后则提示用户“短信发送过于频繁,请10分钟后再试”。


实现代码

@Slf4j
@RestController
@RequestMapping("/sms")
public class SmsController {
    @Resource
    private ISmsService smsService;
    @Resource
    public RedisTemplate redisTemplate;

    @PostMapping("/sendValidCode")
    public Result sendValidCode(@RequestBody @Valid SmsDTO smsDTO) {
        //验证手机号格式
        checkPhoneNumber(smsDTO.getPhoneNumber());
        
        //...其他验证
        
		//拼接Redis的key(key为手机号,以控制一个手机号有限时间内容发送的次数)
        String cacheKey = "sms:code:resetPwd:"+smsDTO.getPhoneNumber();
        //验证发送短信次数,超过则拦截(阈值是3次,超时时间是3分钟,重试时间是10分钟)
        checkSendCount(cacheKey, THRESHOLD, TIMEOUT, RETRY_TIME);
        return smsService.sendMsg(smsDTO);
    }
    
    /**
     * 验证发送短信次数,超过则拦截
     * 该方法用lua脚本替换实现更好
     */
    private void checkSendCount(String cacheKey, Long threshold, Long timeout, String retryTime) {
   		//首先进方法就先+1
        Long count = redisTemplate.opsForValue().increment(cacheKey);
        //然后比较次数,是否超过阈值
        if (count > threshold) {
            //超过则设置过期时间为10分钟,并提示10分钟后重试
            redisTemplate.expire(cacheKey, 10L, TimeUnit.MINUTES);
            throw new ServiceException("短信发送过于频繁,请" + retryTime + "分钟后再试");
        } else {
            //没超过3次,则累加上这一次
            redisTemplate.expire(cacheKey, timeout, TimeUnit.MINUTES);
        }
    }

}

本章完结。

<think>我们正在讨论的是分布式防重提交服务中结合Guava本地缓存与Redis的优势。防重提交的核心目标是在分布式环境下防止用户重复提交请求,通常通过检查请求的唯一标识(如用户ID+操作类型)来实现。结合本地缓存(如Guava Cache)分布式缓存(如Redis)的设计,可以带来以下优势: 1. **性能提升(低延迟)**: - 本地缓存(Guava Cache)存储在应用进程的内存中,访问速度极快(纳秒级),无需网络开销。对于高频的防重检查请求,先访问本地缓存可以大幅减少响应时间。 - 例如,当用户连续快速点击提交按钮时,第一次请求会触发Redis检查并更新本地缓存,后续请求在本地缓存有效期内(如1秒)直接由本地拦截,避免重复访问Redis[^1][^2]。 2. **减轻Redis负载(高并发场景)**: - 在分布式系统中,所有节点都直接访问Redis进行防重检查会导致Redis成为瓶颈。通过本地缓存拦截大部分重复请求,只有第一次请求或缓存失效的请求才会访问Redis,显著降低Redis的QPS网络流量。 - 例如,假设有10个应用节点,每个节点每秒收到100次重复提交。若无本地缓存,Redis需处理1000 QPS;若本地缓存命中率90%,则Redis仅需处理100 QPS[^1][^3]。 3. **提高系统可用性**: - 当Redis出现故障或网络波动时,本地缓存仍能提供基础防护(尽管无法完全避免分布式重复,但可降低重复概率)。这种降级能力增强了系统的韧性。 - 例如,配置本地缓存过期时间为1秒,即使Redis宕机,1秒内的重复提交仍能被本地拦截[^1]。 4. **成本优化**: - 减少Redis调用次数可降低云服务商对Redis的计费(如AWS ElastiCache按请求数计费),同时节省服务器网络带宽。 5. **实现分层防护**: - **第一层(本地缓存)**:快速拦截短时间(如1秒)内的重复请求。 - **第二层(Redis分布式锁)**:确保跨节点的请求唯一性,解决分布式一致性问题。 - 分层设计符合“快速失败”原则,无效请求在本地层即被终止,避免穿透到分布式层[^1][^3]。 ### 潜在挑战与应对 - **数据一致性**:本地缓存可能导致不同节点短暂状态不一致(如节点A缓存未过期,节点B已过期)。但防重提交通常允许短暂(如1秒)的误差,因为用户无法在毫秒级重复操作。 - **内存管理**:需限制本地缓存大小(如`maximumSize(10_000)`),避免OOM。Guava Cache的LRU淘汰策略可自动清理旧数据[^1][^3]。 ### 示例场景 ```mermaid sequenceDiagram participant User participant AppNode as 应用节点(本地缓存) participant Redis User->>AppNode: 提交请求1 AppNode->>AppNode: 检查本地缓存(未命中) AppNode->>Redis: 获取分布式锁(成功) AppNode->>AppNode: 更新本地缓存 Redis-->>AppNode: 返回成功 AppNode-->>User: 提交成功 User->>AppNode: 提交请求2(0.5秒内) AppNode->>AppNode: 检查本地缓存(命中) AppNode-->>User: 拒绝重复提交(无需访问Redis) ``` ### 结论 结合Guava Cache与Redis的防重提交方案,通过**本地缓存加速****分布式锁保证全局一致性**,实现了高性能与高可靠的平衡。适用于电商下单、表单提交、支付接口等高并发场景[^1][^2][^3]。 §§相关问题§§ 1. 如何设计防重提交的键(Key)才能避免不同用户或操作的冲突? 2. 本地缓存过期时间设置多久比较合理? 3. 除了GuavaRedis,还有哪些技术可以实现分布式防重?</think>### 分布式防重提交服务中结合Guava本地缓存与Redis的优势 在分布式防重提交服务中,同时使用Guava本地缓存Redis分布式缓存(如示例代码所示)能带来显著的性能提升系统稳定性优化。以下是核心优势分析: #### 1. **极速响应能力(本地缓存核心优势)** - **纳秒级访问**:Guava Cache作为本地内存缓存,数据访问延迟在**10-100纳秒级别**,远低于Redis的网络请求(通常0.1-1ms)[^1] - **高频拦截场景**:对于用户连续快速点击(如秒杀场景),本地缓存可立即拦截99%的重复请求,无需访问Redis ```java // 示例:本地缓存命中检查(无需网络) Boolean isAccessRecorded = localCache.getIfPresent(userKey); // 纳秒级响应 ``` #### 2. **分布式一致性保障(Redis核心价值)** - **跨节点同步**:Redis作为分布式锁(`setIfAbsent`)确保集群中所有节点对"重复提交"的判定一致 - **全局状态同步**:当新节点加入或服务重启时,Redis提供全局状态视图 ```java // 分布式锁实现(跨节点互斥) if (redisUtil.setIfAbsent(lockKey, true, 1, TimeUnit.SECONDS)) { // 执行唯一性操作 } ``` #### 3. **分层防护架构(核心设计优势)** ```mermaid graph TD A[用户请求] --> B{本地缓存检查} B -->|命中| C[立即拒绝] B -->|未命中| D[Redis分布式锁] D -->|获取成功| E[执行业务] D -->|获取失败| F[拒绝请求] E --> G[更新本地缓存] ``` - **第一层(本地缓存)**:处理90%+的高频重复请求 - **第二层(Redis)**:处理跨节点/缓存失效场景 - 流量过滤比:1000 QPS请求 → 本地拦截900+ → Redis仅处理<100 [^2] #### 4. **资源消耗优化** | 指标 | 纯Redis方案 | 本地+Redis方案 | |--------------|---------------------|---------------------| | Redis QPS | 100% | 5%-10% | | 网络带宽 | 高(每次请求交互) | 极低(仅缓存未命中时)| | 服务端CPU | 高(序列化/网络IO) | 低(本地内存操作) | #### 5. **系统韧性增强** - **Redis故障降级**:当Redis不可用时,本地缓存仍能提供基础防护(需适当延长本地过期时间) - **流量洪峰缓冲**:突发流量被本地缓存吸收,避免Redis被击穿 ```java // 本地缓存配置:适当延长过期时间应对Redis故障 .expireAfterWrite(redisDown ? 30 : 1, TimeUnit.SECONDS) ``` #### 6. **精准频率控制** - **短周期控制(本地缓存)**:应对毫秒/秒级暴力请求(如`infraUserTimeUnit=500ms`) - **长周期控制Redis)**:管理分钟/小时级重复(如订单30分钟防重) ```java // 多级时间窗口控制 localCache.expireAfterWrite(500, TimeUnit.MILLISECONDS); // 短周期 redisUtil.expire(lockKey, 30, TimeUnit.MINUTES); // 长周期 ``` ### 典型应用场景 1. **金融支付**:支付请求本地快速拦截 → 分布式锁保证扣款唯一性 2. **表单提交**:前端防抖+本地缓存 → Redis保证数据最终一致 3. **秒杀系统**:本地缓存过滤99%重复点击 → Redis库存扣减 > **关键设计原则**:本地缓存用于**高频临时状态**,Redis用于**持久化全局状态**,两者通过`tryLock`缓存更新实现状态同步[^3]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值