视频看了几百小时还迷糊?关注我,几分钟让你秒懂!
🧩 一、为什么需要防重复提交?
用户在以下场景容易多次点击:
- 提交订单(怕没成功,狂点“支付”);
- 发送短信验证码(点一次没反应,再点);
- 表单保存(网络慢,以为没提交)。
后果很严重:
- 订单重复创建 → 库存超卖、财务对不上;
- 短信轰炸 → 被运营商封号、用户投诉;
- 数据重复插入 → 数据库脏数据。
✅ 目标:同一个用户,在短时间内,对同一接口只能提交一次!
🔐 二、设计思路:基于 Token + Redis
我们采用 “请求唯一标识 + 分布式锁” 方案:
- 如果 token 不存在 or 已使用 → 拒绝;
- 如果 token 有效 → 放行,并立即删除 token(防止重用)。
💡 为什么不用 IP + URL + 参数做 key?
- 用户可能切换网络(IP 变);
- 参数可能含时间戳(每次不同);
- Token 机制更精准、可控!
🛠️ 三、完整实现(Spring Boot + Redis)
1. 添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId> <!– 连接池 –>
</dependency>
2. 自定义注解
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicateSubmit {
/**
* 有效期(秒),默认 10 秒
*/
int expire() default 10;
}
3. 全局异常(用于提示重复提交)
public class DuplicateSubmitException extends RuntimeException {
public DuplicateSubmitException(String message) {
super(message);
}
}
4. AOP 切面实现核心逻辑
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.concurrent.TimeUnit;
@Aspect
@Component
public class PreventDuplicateSubmitAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(prevent)")
public Object around(ProceedingJoinPoint joinPoint, PreventDuplicateSubmit prevent) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
// 1. 从 header 或参数中获取 token
String token = request.getHeader("X-Submit-Token");
if (token == null || token.trim().isEmpty()) {
throw new DuplicateSubmitException("缺少防重提交令牌");
}
// 2. 构建 Redis Key(建议加上用户标识,防止跨用户攻击)
String userId = getCurrentUserId(request); // 伪代码,实际从 token/session 获取
String redisKey = "submit:token:" + userId + ":" + token;
// 3. 尝试获取并删除 token(原子操作)
Boolean exists = redisTemplate.opsForValue().setIfAbsent(redisKey, "used", prevent.expire(), TimeUnit.SECONDS);
if (Boolean.FALSE.equals(exists)) {
// token 不存在或已使用
throw new DuplicateSubmitException("请勿重复提交");
}
// 4. 放行
return joinPoint.proceed();
}
// 伪代码:获取当前用户 ID(根据你的认证体系调整)
private String getCurrentUserId(HttpServletRequest request) {
// 示例:从 JWT 或 Session 中获取
// return (String) request.getSession().getAttribute("userId");
return "user_123"; // 仅用于演示
}
}
🔑 关键点:
- setIfAbsent = SETNX,原子性保证线程安全;
- token 使用后立即失效(即使 expire=10s,也只允许用一次);
- key 中包含 userId,避免 A 用户的 token 被 B 用户使用。
🧪 四、前端配合流程
1. 进入页面时获取 token
// 页面加载时
fetch('/api/submit-token')
.then(res => res.json())
.then(data => {
localStorage.setItem('submitToken', data.token);
});
2. 提交时带上 token
const token = localStorage.getItem('submitToken');
fetch('/api/order/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Submit-Token': token // 👈 关键!
},
body: JSON.stringify(orderData)
})
.then(res => {
if (res.ok) {
// 成功后清空 token,防止再次使用
localStorage.removeItem('submitToken');
}
});
⚙️ 五、提供获取 token 的接口
@RestController
@RequestMapping("/api")
public class SubmitTokenController {
@Autowired
private StringRedisTemplate redisTemplate;
@GetMapping("/submit-token")
public Map<String, String> getSubmitToken() {
String token = java.util.UUID.randomUUID().toString().replace("-", "");
// 可选:预存 token(非必须,因为 AOP 会 setIfAbsent)
// redisTemplate.opsForValue().set("submit:prepare:" + token, "1", 60, TimeUnit.SECONDS);
return Map.of("token", token);
}
}
💡 token 无需提前存入 Redis!AOP 中的 setIfAbsent 会自动创建。
❌ 六、反例 & 常见错误
反例 1:用 session 存 flag(不支持分布式)
// ❌ 单机可用,集群环境下 session 不共享!
session.setAttribute("submitted", true);
✅ 正确:必须用 Redis 等分布式存储。
反例 2:只校验不删除 token
// ❌ token 10 秒内可无限次使用!
if (redis.hasKey(token)) {
return error;
}
✅ 正确:使用即失效(通过 setIfAbsent 原子操作实现)。
反例 3:token 没绑定用户
String redisKey = "submit:token:" + token; // ❌ 任何人都能用这个 token!
💥 风险:恶意用户获取 token 后,可阻止其他用户提交!
✅ 正确:key 必须包含用户唯一标识。
⚠️ 七、注意事项 & 增强建议
| Token 泄露 | HTTPS 传输,header 不要存敏感信息 |
| 用户体验 | 前端提交后禁用按钮,避免用户狂点 |
| 高并发 | Redis 本身支持高并发,SETNX 是原子操作 |
| 降级方案 | Redis 宕机时,可临时关闭防重(记录日志告警) |
| 监控 | 统计“重复提交”次数,发现异常行为 |
🎯 八、总结:防重提交核心流程
前端 后端
│ │
├─1. GET /submit-token ────→ │
│ ←── {token: "abc123"} ─────┤
│ │
├─2. POST /order (带 token) →│
│ ├─ AOP 拦截
│ ├─ 检查 Redis: submit:token:user123:abc123
│ ├─ 不存在 → 拒绝(重复提交)
│ ├─ 存在 → 删除 + 放行
│ ←── 成功/失败 ──────────────┤
✅ 优点:
- 精准:按用户 + 接口级别控制;
- 安全:token 一次有效;
- 高效:Redis 操作微秒级;
- 通用:通过注解一键开启。
视频看了几百小时还迷糊?关注我,几分钟让你秒懂!
网硕互联帮助中心







评论前必须登录!
注册