在线上系统中,重复提交是引发数据不一致和资损的常见风险,绝非偶发问题。例如,因网络抖动或用户误操作导致的重复下单、重复支付扣款,其本质在于接口未实现幂等性。本文将系统梳理从前端到后端,从单机到分布式的完整防重方案。
为什么需要前后端协同防护?
一个基本共识是:前端防重旨在优化体验、减少误操作,而后端防重才是保障数据一致性的最终安全防线。仅依赖前端防护,系统将面临巨大风险。
前端防重策略(体验优化层)
前端方案的核心是提升交互友好性,降低无效请求。
-
提交后禁用按钮
这是最简单直接的体验优化方案,在用户点击后立即将按钮置为禁用状态。
function submitForm() {
const btn = document.getElementById(“submitBtn”);
if (btn.disabled) return;
btn.disabled = true;
btn.innerText = “提交中...”;
// 发起请求
fetch(“/api/submit”, { method: “POST” })
.finally(() => {
btn.disabled = false;
btn.innerText = “提交”;
});
}
缺点:可通过浏览器开发者工具轻松绕过,属于“礼貌性防御”。
-
函数防抖
通过限制函数在短时间内被频繁触发,确保一段时间内只执行一次。
function debounce(func, wait) {
let timeout;
return function () {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, arguments), wait);
};
}
const submitForm = debounce(() => { /* 提交逻辑 */ }, 1000);
-
请求层拦截
在Axios等请求库的拦截器中实现全局请求去重。
const pendingRequests = new Map();
axios.interceptors.request.use(config => {
const key = config.url + JSON.stringify(config.data);
if (pendingRequests.has(key)) {
return Promise.reject(new Error(‘请求重复’));
}
pendingRequests.set(key, true);
return config;
});
axios.interceptors.response.use(
response => {
const key = response.config.url + JSON.stringify(response.config.data);
pendingRequests.delete(key);
return response;
},
error => { /* 错误处理中同样需删除key */ }
);
总结:前端方案能有效提升用户体验,但绝不能作为安全兜底措施。
后端防重核心方案(安全兜底层)
后端是防重复提交的主战场,尤其对于分布式系统。
方案一:Token令牌机制
这是经典的单体应用解决方案。流程为:服务端生成唯一Token下发给前端,前端提交时携带,服务端校验后立即使Token失效。
// 生成并存储Token
public String generateToken(HttpServletRequest request) {
String token = UUID.randomUUID().toString();
request.getSession().setAttribute(“FORM_TOKEN”, token);
return token;
}
// 校验Token
public boolean validateToken(HttpServletRequest request) {
String clientToken = request.getParameter(“token”);
String serverToken = (String) request.getSession().getAttribute(“FORM_TOKEN”);
if (!Objects.equals(clientToken, serverToken)) {
return false;
}
request.getSession().removeAttribute(“FORM_TOKEN”);
return true;
}
优点:实现简单,安全性好。
缺点:依赖Session,在分布式或微服务架构中需要解决Session共享问题,可借助Redis等中间件实现。
方案二:AOP + Redis(分布式场景首选)
这是目前生产环境最推荐的无侵入式方案,通过自定义注解和AOP切面,结合Redis的原子操作实现分布式锁。
-
自定义防重注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
int lockTime() default 5; // 锁持有时间,单位秒
}
-
AOP切面实现
核心思想是生成一个唯一Key(例如:用户标识+接口+参数摘要),利用Redis的SET key value NX EX命令尝试加锁。
@Aspect
@Component
public class NoRepeatSubmitAspect {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Around(“@annotation(noRepeatSubmit)”)
public Object around(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
String userId = getUserId(request); // 获取用户唯一标识
String uri = request.getRequestURI();
String args = DigestUtils.md5DigestAsHex(JSON.toJSONString(joinPoint.getArgs()).getBytes());
String redisKey = “repeat_submit:” + userId + “:” + uri + “:” + args;
// 关键步骤:原子性加锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(redisKey, “1”, noRepeatSubmit.lockTime(), TimeUnit.SECONDS);
if (Boolean.FALSE.equals(locked)) {
throw new RuntimeException(“操作过于频繁,请稍后再试”);
}
try {
return joinPoint.proceed();
} finally {
// 可选:业务执行完成后主动删除key,或等待自动过期
// redisTemplate.delete(redisKey);
}
}
}
在实际的SpringBoot项目中,通过setIfAbsent方法可以安全地在分布式环境下实现互斥锁。
方案三:数据库唯一索引
对于创建类业务(如订单号),可在数据库层为关键字段建立唯一索引,作为最终防线。但此方法仅能防止数据最终落库时的重复,请求已进入应用层,会消耗系统资源。
高并发场景下的优化
在极端高并发下,为了确保“判断-加锁”操作的原子性,避免临界区问题,可使用Redis Lua脚本。
-- Lua 脚本:原子化执行加锁
if redis.call(‘set’, KEYS[1], ARGV[1], ‘EX’, ARGV[2], ‘NX’) then
return 1
else
return 0
end
在Java中通过redisTemplate.execute(RedisScript)方法调用,能有效防止因网络延迟或客户端超时导致的锁状态判断竞态条件。
方案对比与最佳实践
| 方案 |
推荐度 |
适用场景 |
优点 |
缺点 |
| 前端禁用按钮 |
辅助 |
所有表单交互 |
体验好,实现简单 |
极易绕过,不安全 |
| Session Token |
推荐 |
单体应用 |
经典、安全 |
分布式环境复杂 |
| AOP + Redis |
强烈推荐 |
微服务/分布式系统 |
无侵入、分布式友好、性能好 |
依赖Redis |
| 数据库唯一索引 |
辅助/兜底 |
强一致性约束场景 |
绝对可靠 |
请求已进入系统,无法前置拦截 |
架构实践建议:
- 前后端结合:前端负责体验优化,后端负责安全兜底。
- 方案选型:现代分布式系统首选 AOP + Redis 方案,利用Spring AOP实现优雅切面。
- 锁时效:锁持有时间(
lockTime)不宜过长,通常5-10秒足以覆盖一次正常业务请求。
- 友好提示:拦截到重复请求时,应返回明确的业务提示(如“请勿重复提交”),而非生硬的服务器错误。
- 明确概念:防重复提交是实现接口幂等性的一种常用手段,但对于复杂业务(如扣减库存),需要设计更完整的幂等方案。
总结
防重复提交是构建稳定、可靠线上系统的基础能力,其核心逻辑是在业务执行前,通过一个原子操作判断请求的唯一性。一个成熟的系统设计,应允许用户因各种原因“乱点”,但必须保证系统内部逻辑不乱、数据一致。从Token机制到分布式Redis锁,技术的演进都是为了在复杂的网络与部署环境下,守护这一份确定性。
|