这样的接口幂等实现我认为最为优雅(防重复提交)
好东西我喜欢先分享,所以先说我的方案
基于token(和网上大部分不一样)
对比
网上对于token的实现大部分是在第一阶段进行redis存入这个token,然后将这个token返回给前端,在第二个请求的时候携带这个token
我仅在这里表名一下我的想法,欢迎大家指正
我认为这种做法并不是很好,因为它的侧重点不在保存上,而在创建订单上,而且它创建订单的时候就进行redis保存token,可能会造成token的无用存储(不保存)
我的方案是恰好相反,也就是直接在第二步的时候进行token的保存,第一步只返回一个随机的UUID(也可以自定义)
构思
是否所有的接口都需要做到接口幂等性取决于具体的业务需求和场景。接口幂等性虽然是设计接口时的一种良好实践,但并非所有情况下都是必须的。以下是对这一问题的分析:
需要幂等性的场景
-
数据写操作:
- 创建订单、支付等关键操作:当用户提交订单或进行支付时,如果因网络问题或其他原因导致请求被重复发送,系统应该能够识别这些重复请求并避免产生重复的订单或支付。此类操作通常需要幂等性。
- 更新操作:例如更新用户信息、修改配置等操作。幂等性确保多次相同的请求只会对系统状态产生一次性影响,避免因重复操作导致数据不一致。
-
系统对外暴露的接口:
- 公共API:如果你的接口是对外公开的API,可能会有多种不同的客户端调用它们。为了保证系统的稳定性和安全性,确保接口幂等性是非常必要的。
-
可能受网络问题影响的场景:
- 在一些不稳定的网络环境中,重复请求很容易发生。幂等性可以防止由于网络重试导致的数据问题。
不需要幂等性的场景
-
纯粹的查询操作:
- GET 请求:通常用于查询数据的GET请求天然是幂等的,无论执行多少次查询操作,都不会改变系统的状态。因此,这类请求不需要特别设计幂等性。
-
某些非关键的数据写入操作:
- 日志记录、访问统计等:例如向数据库中写入日志信息、更新访问统计次数等,这些操作即使重复多次,也不会对系统的核心数据产生重大影响。因此,不一定需要做到幂等。
-
内部私有接口:
- 如果接口仅用于内部服务之间的通信,并且调用逻辑受控(如在事务中执行的操作),则不一定需要特别考虑幂等性。
因此,虽然并非所有接口都需要实现幂等性,但对于很多关键接口来说,幂等性仍然是必要的。如果每个接口都单独实现生成幂等令牌的逻辑,显得有些重复和冗余。
基于注解+AOP
注解
package fun.acowbo.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author xiaobo
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
// 模块名称
String module() default "";
// Token 过期时间,单位为分钟
long expiryTime() default 10;
}
注解中设置这两个参数 module
和 expiryTime
,具有以下好处:
-
模块化管理(
module
参数):- 清晰的业务区分:通过指定模块名称,可以在处理幂等性逻辑时,对不同的业务模块进行区分。这有助于根据不同模块的需求进行定制化处理,避免全局统一处理带来的灵活性不足。
- 易于维护:在项目扩展时,可以方便地查看和管理不同模块的幂等性设置,增强代码的可维护性和可读性。
-
灵活的过期时间控制(
expiryTime
参数):- 动态调整:通过设置不同的方法过期时间,允许根据业务需求动态调整幂等令牌的有效期。例如,对于某些操作,幂等性可能只需要短时间内有效,而对于另一些操作,则可能需要更长的有效期。
- 安全性提升:过期时间限制了幂等令牌的生命周期,降低了长期存在的幂等令牌可能被滥用的风险,进一步增强了接口的安全性。
这两个参数使得幂等性控制更加灵活和可配置,适应了不同的业务需求和安全要求。
AOP
package fun.acowbo.aspect;
import fun.acowbo.annotation.Idempotent;
import fun.acowbo.utils.BoRedisUtil;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.concurrent.TimeUnit;
/**
* @author <a href="https://acowbo.fun">acowbo</a>
* @since 2024/9/3
*/
@Aspect
@Component
public class IdempotentAspect {
@Resource
private HttpServletRequest request;
@Around("@annotation(idempotent)")
public Object handleIdempotency(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 获取请求 Token(例如从请求头或方法参数中获取)
String token = extractTokenFromRequest();
// 检查 Token 是否已存在
boolean tokenStored = BoRedisUtil.setIfAbsent(idempotent.module() + ":" + token, "1", idempotent.expiryTime(), TimeUnit.MINUTES);
if (!tokenStored) {
// 如果 Token 已存在,则表示请求重复,返回错误或结果
throw new RuntimeException("订单已经存在了!");
}
// 如果 Token 存储成功,则执行目标方法
return joinPoint.proceed();
}
private String extractTokenFromRequest() {
// 从请求头中提取 Token
String token = request.getHeader("Idempotency-Token");
if (token == null || token.isEmpty()) {
throw new RuntimeException("没有请求token");
}
return token;
}
}
虽然这段代码看似简单,但需要特别强调的是 setIfAbsent 方法,它实际上是 Redis 的 SETNX 命令。这是一种原子操作,意味着在高并发的情况下,多个线程或请求同时执行 setIfAbsent 时,Redis 会确保只有第一个请求能够成功设置键值,其余的都会失败。正是因为这种原子性,才保证了幂等性控制在并发环境下的可靠性,避免了重复提交的问题。
controller层
package fun.acowbo.controller;
import fun.acowbo.annotation.Idempotent;
import fun.acowbo.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* @author <a href="https://acowbo.fun">acowbo</a>
* @since 2024/9/3
*/
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@GetMapping("/create/{orderNo}")
@Idempotent(module = "order", expiryTime = 3)
public String createOrder(@PathVariable String orderNo) {
return orderService.createOrder(orderNo);
}
}
service层
package fun.acowbo.service;
import org.springframework.stereotype.Service;
/**
* @author <a href="https://acowbo.fun">acowbo</a>
* @since 2024/9/3
*/
@Service
public class OrderService {
public String createOrder(String orderNo) {
// 处理创建订单的逻辑
// 此处省略实际的业务处理代码
return "订单创建成功: " + orderNo;
}
}
测试并展示结果
这里我使用的是wrk进行的测试,感兴趣的可以看我下面这篇文章
上图可以看出我进行压测是用10个线程100并发持续了30s16157个请求,失败了16156个,也就是仅有一个成功了。
前言
在数字化时代,用户体验和系统稳定性是每个开发者都必须关注的核心问题。想象一下,如果你的应用在用户点击一次提交按钮后,竟然多次执行了相同的操作,这不仅可能导致数据混乱,还可能引发一系列连锁反应,最终让用户失望离去。这时,接口幂等性就显得尤为重要。本文将带你深入了解接口幂等性这一神秘的设计原则,揭开它的面纱,学习如何通过它来提升你的系统可靠性,并确保每一次请求都如你所愿!
什么是接口幂等性
接口幂等性(Idempotency)指的是在网络请求或调用中,无论请求被执行多少次,结果都是相同的,不会产生副作用或额外的影响。这意味着,即使相同的操作被重复执行多次,系统的状态不会发生变化。幂等性是设计和实现API时的重要考虑因素,尤其是在分布式系统中,确保接口的幂等性能够提高系统的可靠性和一致性。
-
网络请求重试:在网络不稳定的情况下,客户端可能会重试请求。如果接口不是幂等的,重复的请求可能会导致重复的操作(如多次扣款或重复数据插入)。幂等接口可以确保无论请求被重试多少次,结果都是一致的。
-
错误处理:当出现系统错误或异常时,幂等性可以防止由于错误导致的重复操作。即使出现问题导致操作被执行多次,也不会对系统状态产生不一致的影响。
-
系统一致性:在分布式系统中,网络延迟和节点故障可能会导致请求被重复发送。幂等性可以帮助确保系统的最终状态是一致的,无论请求被处理多少次。
什么是重复提交
重复提交是指用户在短时间内多次发送相同的请求或提交相同的数据,导致服务器多次处理该请求,可能会引发一系列问题。例如,用户在点击“提交订单”按钮后,由于网络延迟或系统响应缓慢,再次点击了按钮,导致系统生成了多张相同的订单。
- 网络延迟或错误:用户在提交表单或请求时,由于网络问题或服务器响应缓慢,可能会误以为请求没有被处理,因而多次提交相同的请求。
2. 页面刷新:用户提交表单后,如果页面没有正确跳转或提示,用户可能会刷新页面,导致表单数据再次提交。
3. 双击或多次点击:用户在提交按钮上双击或多次点击,可能会导致相同请求被多次发送到服务器。
重复提交的影响
- 数据重复:例如,在电商平台上重复提交订单可能会导致用户购买同一商品多次,从而产生多个重复的订单。
- 资源浪费:服务器需要处理多次相同的请求,消耗了不必要的计算资源。
- 用户体验差:用户可能会感到困惑或不满,尤其是在重复提交导致数据错误或需要额外操作来取消重复的请求时。
- 数据不一致:重复提交可能会导致数据库中出现不一致的数据,例如多次插入相同的记录。
重复提交前端解决
按钮禁用
按钮禁用是防止重复提交的常见且简单的方法之一,主要通过在用户点击提交按钮后,立即禁用按钮或隐藏按钮,使用户无法再次点击,从而避免多次提交相同请求。
按钮禁用的工作原理
当用户点击提交按钮时,通常会触发一个事件,例如表单提交或数据发送到服务器。在这个事件处理过程中,按钮会被禁用或隐藏,直到服务器返回响应或操作完成。这种方法可以有效避免用户在等待响应时因为误操作而多次点击按钮,导致重复提交。
实现步骤
-
监听点击事件:在提交按钮上绑定一个点击事件,捕获用户点击按钮的动作。
-
禁用按钮:一旦捕获到点击事件,立即禁用按钮。这可以通过设置按钮的
disabled
属性或修改按钮的样式来实现。 -
发送请求或处理表单:在按钮被禁用的同时,开始处理表单提交或发送请求。
-
恢复按钮状态(可选):当服务器返回响应或者操作完成后,可以根据需要恢复按钮的状态,使其再次可点击。这通常用于防止用户在提交失败后无法再次提交。
示例代码
下面是一个简单的JavaScript示例,展示了如何在表单提交时禁用按钮:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>按钮禁用示例</title>
</head>
<body>
<form id="myForm">
<input type="text" name="username" placeholder="Enter your name" required>
<button type="submit" id="submitButton">Submit</button>
</form>
<script>
document.getElementById("myForm").addEventListener("submit", function(event) {
var submitButton = document.getElementById("submitButton");
// 禁用按钮
submitButton.disabled = true;
// 模拟表单提交过程
setTimeout(function() {
// 在这里通常是发送表单数据到服务器,完成后可以恢复按钮状态
// submitButton.disabled = false;
alert("Form submitted!");
}, 2000); // 模拟2秒的网络延迟
});
</script>
</body>
</html>
详解
-
监听提交事件:在上面的代码中,
addEventListener
被用来监听表单的submit
事件。当用户点击“Submit”按钮时,表单会触发submit
事件。 -
禁用按钮:
submitButton.disabled = true;
禁用了提交按钮,使其在提交过程结束之前无法再次点击。 -
模拟表单提交:通过
setTimeout
模拟一个表单提交过程,在实际应用中,这一部分可以替换为发送 AJAX 请求或其他异步操作。 -
恢复按钮状态(可选):在某些情况下,可能需要在提交失败时恢复按钮状态,允许用户再次提交。这可以通过设置
submitButton.disabled = false;
来实现。
按钮禁用的优缺点
优点:
- 简单易行:实现方式简单,只需要前端代码即可实现。
- 用户友好:禁用按钮后,用户直观地知道请求正在处理中,避免误操作。
缺点:
- 覆盖有限:如果用户在网络非常慢的情况下直接刷新页面,禁用按钮无法防止重复提交。
- 用户体验问题:如果按钮禁用后没有恢复,并且没有其他提示,用户可能会感到困惑,认为操作未成功。
最佳实践
-
按钮禁用与Loading提示结合:在禁用按钮的同时,显示加载动画或提示信息,告知用户请求正在处理中。
-
防止意外刷新:在处理关键操作时,配合使用唯一令牌(Token)或其他后端机制,防止用户通过刷新页面或其他手段重复提交。
-
错误处理:确保在提交失败时,按钮能够恢复可点击状态,并提示用户重新提交。
通过这些实践,可以有效减少重复提交的问题,并改善用户体验。
拦截器实现
在 Vue 项目中实现前端拦截器,可以在每次调用后端接口时拦截请求,确保如果前一个请求未返回状态(如请求仍在处理中或失败),则不会再次调用相同的接口。可以使用 axios
拦截器来实现这一功能。以下是具体的实现步骤,以及这种方式的优点和缺点。
实现步骤
-
安装 Axios:如果你的项目还没有安装 Axios,可以通过以下命令进行安装:
npm install axios
-
设置请求拦截器:在项目中全局配置 Axios 的请求拦截器和响应拦截器。在拦截器中,你可以检查请求是否已被处理或仍在处理中,并采取相应的操作。
-
阻止重复请求:在请求拦截器中,可以为每个请求生成一个唯一的标识符(如 URL 和请求参数的组合),并记录该请求的状态。在响应拦截器中,更新请求状态或移除标识符。
-
示例代码:
import axios from 'axios'; // 存储正在进行的请求 const pendingRequests = new Map(); // 生成唯一标识符 function generateRequestKey(config) { const { method, url, params, data } = config; return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&'); } // 添加请求拦截器 axios.interceptors.request.use( config => { const requestKey = generateRequestKey(config); if (pendingRequests.has(requestKey)) { // 如果请求已经存在,抛出错误或取消请求 return Promise.reject({ message: '重复请求' }); } // 记录请求 pendingRequests.set(requestKey, true); return config; }, error => { return Promise.reject(error); } ); // 添加响应拦截器 axios.interceptors.response.use( response => { const requestKey = generateRequestKey(response.config); // 请求完成后移除记录 pendingRequests.delete(requestKey); return response; }, error => { const requestKey = generateRequestKey(error.config || {}); // 请求失败时也移除记录 pendingRequests.delete(requestKey); return Promise.reject(error); } );
优点
-
防止重复提交:这种方式能够有效防止用户在前一个请求尚未完成时再次触发相同的请求,从而减少重复提交的问题。
-
提高性能:减少了不必要的重复请求,从而降低了服务器负载,提高了前端和后端的交互效率。
-
全局控制:通过全局拦截器,可以统一管理所有请求,无需在每个请求中单独处理。
缺点
-
复杂性增加:引入拦截器会增加项目的复杂性,尤其是在处理不同场景下的请求逻辑时,可能需要额外的代码管理请求状态。
-
状态管理问题:如果请求拦截器逻辑没有设计好,可能会导致请求状态无法正确更新,进而引发一些难以调试的问题。
-
处理并发请求的困难:在某些情况下,允许多个相同请求并发进行是合理的(例如分页加载数据)。这种拦截机制可能会阻碍这种场景的实现。
注意事项
-
灵活配置:对于一些请求,可能需要允许并发执行。在这种情况下,你可以通过在请求配置中添加自定义标识,来控制是否启用拦截器。
-
错误处理:确保在请求失败时,能够正确处理并恢复请求状态,避免影响后续的请求。
通过这些措施,可以在 Vue 项目中有效地防止重复请求,提升应用的稳定性和用户体验。
接口幂等后端实现
除了一开始我提到的基于token的实现,还有以下几种
- 数据库层面的唯一索引
- 状态机制
- 分布式锁
- 乐观锁
- 点赞
- 收藏
- 关注作者
评论(0)