外观
Spring Boot 接口幂等
约 2632 字大约 9 分钟
2025-08-16
一、接口幂等基础概念
1. 什么是幂等
幂等原先是数学中的一个概念,表示进行 1 次变换和进行 N 次变换产生的效果相同。
在接口设计中:以相同的请求调用这个接口一次和调用这个接口多次,对系统产生的影响是相同的。
2. 为什么需要接口幂等
常见导致重复请求的场景:
- 网络超时重试:客户端因网络问题未收到响应,重复发起请求
- 消息重复消费:消息队列中消息被重复消费
- 用户重复提交:用户因界面无响应多次点击提交按钮
- 负载均衡重试:Nginx 等负载均衡器自动重试失败请求
接口幂等 vs 防止重复提交:
- 接口幂等:关注后端如何处理已经发生的重复请求
- 防止重复提交:关注前端如何阻止用户发起重复请求
提示:即使前端做了防止重复提交,后端仍需实现接口幂等,因为网络问题和系统重试可能导致重复请求。
3. 哪些接口需要幂等
| HTTP 方法 | 是否需要幂等 | 说明 |
|---|---|---|
| GET | 不需要 | 天然幂等,只查询不修改数据 |
| HEAD | 不需要 | 天然幂等,只获取头信息 |
| OPTIONS | 不需要 | 天然幂等,获取支持的方法 |
| PUT | 需要 | 更新操作,应保证多次更新结果一致 |
| POST | 需要 | 创建操作,多次提交可能导致重复数据 |
| DELETE | 需要 | 删除操作,虽然数据不变,但返回结果可能不同 |
提示:实际开发中,POST 和 PUT 接口最需要保证幂等性。
二、常用幂等实现方案
1. Token 机制(最常用)
原理:
- 客户端请求获取唯一 Token
- 提交请求时携带 Token
- 服务端验证 Token 有效性
- 处理完成后删除 Token
优势:
- 通用性强,适用于各种场景
- 实现简单,与业务解耦
- 可防止用户重复提交
- 适合前后端分离架构
实现步骤:
- 生成 Token 接口
- 提交请求携带 Token
- 服务端验证 Token
- 业务处理完成后删除 Token
2. 唯一 ID/索引(数据库层面)
原理:
- 利用数据库唯一约束保证数据唯一性
- 通常使用分布式 ID 作为唯一标识
适用场景:
- 订单创建
- 支付请求
- 资源创建类操作
实现方式:
// 创建订单
@Transactional
public Order createOrder(Order order) {
// 检查订单号是否已存在(利用唯一索引)
if (orderRepository.existsByOrderNo(order.getOrderNo())) {
throw new DuplicateOrderException("订单已存在");
}
return orderRepository.save(order);
}数据库设计:
ALTER TABLE orders ADD UNIQUE INDEX idx_order_no (order_no);3. 乐观锁(数据库层面)
原理:
- 在更新操作中使用版本号控制
- 比较当前版本号与数据库中的版本号
- 版本号不一致则更新失败
适用场景:
- 订单状态更新
- 库存扣减
- 资源修改类操作
实现方式:
// 更新订单状态
@Transactional
public boolean updateOrderStatus(Long orderId, int newStatus, int expectedVersion) {
int updated = orderRepository.updateStatusWithVersion(orderId, newStatus, expectedVersion);
return updated > 0;
}SQL 示例:
UPDATE orders SET status = #{newStatus}, version = version + 1
WHERE id = #{id} AND version = #{expectedVersion}4. 分布式锁(高并发场景)
原理:
- 在执行关键操作前获取分布式锁
- 操作完成后释放锁
- 同一时间只允许一个请求执行
适用场景:
- 高并发下的库存扣减
- 支付回调处理
- 需要严格串行化的操作
实现方式:
public boolean processPayment(PaymentRequest request) {
String lockKey = "payment:lock:" + request.getTradeNo();
try {
// 尝试获取分布式锁(Redis实现)
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", 5, TimeUnit.SECONDS);
if (!locked) {
throw new DuplicateRequestException("请求处理中,请勿重复提交");
}
// 处理支付业务逻辑
return doPaymentProcess(request);
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}提示:90% 的实际场景中,Token 机制和唯一 ID/索引已经足够使用,乐观锁用于更新场景,分布式锁用于高并发场景。
三、Token 机制详细实现
1. 基本实现思路
流程:
客户端 --> 服务端: 1. 获取Token
服务端 --> 客户端: 2. 返回Token
客户端 --> 服务端: 3. 提交请求(携带Token)
服务端 --> 服务端: 4. 验证Token有效性
服务端 --> 服务端: 5. 处理业务逻辑
服务端 --> 服务端: 6. 删除Token
服务端 --> 客户端: 7. 返回结果2. 代码实现
Token 生成服务:
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public class TokenService {
private final StringRedisTemplate redisTemplate;
// Token有效期,建议5-10分钟
private static final long TOKEN_EXPIRE = 5 * 60;
// Redis中Token的前缀
private static final String TOKEN_PREFIX = "token:";
public TokenService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 生成唯一Token
*/
public String generateToken() {
String token = UUID.randomUUID().toString();
// 存入Redis,设置过期时间
redisTemplate.opsForValue().set(TOKEN_PREFIX + token, "1", TOKEN_EXPIRE, TimeUnit.SECONDS);
return token;
}
/**
* 验证并删除Token
*/
public boolean validateAndRemoveToken(String token) {
// 检查Token是否存在
Boolean hasKey = redisTemplate.hasKey(TOKEN_PREFIX + token);
if (hasKey != null && hasKey) {
// 原子操作:删除Token
redisTemplate.delete(TOKEN_PREFIX + token);
return true;
}
return false;
}
}拦截器实现:
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class IdempotentInterceptor implements HandlerInterceptor {
private final TokenService tokenService;
public IdempotentInterceptor(TokenService tokenService) {
this.tokenService = tokenService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
// 检查方法是否有@Idempotent注解
Idempotent idempotent = handlerMethod.getMethodAnnotation(Idempotent.class);
if (idempotent == null) {
return true;
}
// 从请求头获取Token
String token = request.getHeader("X-Request-Token");
if (token == null || token.isEmpty()) {
throw new IllegalArgumentException("缺少幂等Token");
}
// 验证Token
boolean isValid = tokenService.validateAndRemoveToken(token);
if (!isValid) {
throw new IllegalArgumentException("重复请求");
}
return true;
}
}自定义注解:
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
// 可添加自定义属性,如超时时间、重试次数等
long timeout() default 5000; // 默认5秒
}配置拦截器:
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final TokenService tokenService;
public WebConfig(TokenService tokenService) {
this.tokenService = tokenService;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new IdempotentInterceptor(tokenService))
.addPathPatterns("/**")
.excludePathPatterns("/token/*");
}
}3. 前后端交互示例
前端流程:
- 访问表单页面前,先请求
/token/generate获取 Token - 将 Token 存储在表单的隐藏字段或请求头中
- 提交表单时携带 Token
- 如果提交失败,重新获取 Token 再提交
后端接口:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private TokenService tokenService;
@Autowired
private OrderService orderService;
/**
* 获取幂等Token
*/
@GetMapping("/token")
public ResponseEntity<String> getToken() {
String token = tokenService.generateToken();
return ResponseEntity.ok(token);
}
/**
* 创建订单(需要幂等)
*/
@Idempotent
@PostMapping
public ResponseEntity<Order> createOrder(@RequestBody Order order) {
Order createdOrder = orderService.createOrder(order);
return ResponseEntity.ok(createdOrder);
}
}四、实用技巧与最佳实践
1. Token 机制优化
Token 有效期:
- 设置合理有效期(5-10 分钟)
- 根据业务场景调整(支付类可短,表单类可长)
- 避免过长导致资源占用
Token 存储优化:
- 使用 Redis 存储,设置自动过期
- 添加前缀区分环境(如
prod:token:) - 考虑使用 Redis 的 Hash 结构存储额外信息
Token 重试策略:
- 客户端收到"重复请求"响应后,应重新获取 Token 再提交
- 设置最大重试次数(如 3 次)
- 添加退避策略(如指数退避)
2. 幂等与业务结合
订单创建场景:
@Idempotent
@PostMapping("/orders")
public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request,
@RequestHeader("X-Request-Token") String token) {
// 1. 验证Token已在拦截器完成
// 2. 检查订单号是否已存在(双重保障)
if (orderService.existsByOrderNo(request.getOrderNo())) {
return ResponseEntity.ok(orderService.getOrderByOrderNo(request.getOrderNo()));
}
// 3. 创建新订单
return ResponseEntity.ok(orderService.createOrder(request));
}支付回调场景:
@PostMapping("/payment/callback")
public ResponseEntity<String> handlePaymentCallback(@RequestBody PaymentCallback callback) {
// 1. 验证签名
if (!paymentService.verifySignature(callback)) {
return ResponseEntity.badRequest().body("签名验证失败");
}
// 2. 检查交易号是否已处理(唯一索引)
if (paymentService.isTransactionProcessed(callback.getTransactionId())) {
return ResponseEntity.ok("回调已处理");
}
// 3. 处理支付结果(加分布式锁)
try {
String lockKey = "payment:lock:" + callback.getTransactionId();
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", 5, TimeUnit.SECONDS);
if (!locked) {
return ResponseEntity.accepted().body("处理中");
}
// 处理支付业务逻辑
paymentService.processPayment(callback);
return ResponseEntity.ok("处理成功");
} finally {
redisTemplate.delete("payment:lock:" + callback.getTransactionId());
}
}3. 错误处理与重试
幂等接口错误码:
400 Bad Request:缺少 Token 或 Token 无效409 Conflict:重复请求(业务上已存在)429 Too Many Requests:请求过于频繁
客户端重试策略:
// 伪代码:前端重试逻辑
async function submitOrder(orderData) {
const MAX_RETRIES = 3;
let retryCount = 0;
while (retryCount < MAX_RETRIES) {
try {
// 获取新Token
const token = await fetch('/api/orders/token').then(res => res.text());
// 提交订单
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Request-Token': token
},
body: JSON.stringify(orderData)
});
if (response.ok) {
return await response.json();
} else if (response.status === 400 || response.status === 409) {
// Token无效或重复请求,直接返回错误
throw new Error(await response.text());
} else {
// 其他错误,等待后重试
retryCount++;
await new Promise(resolve => setTimeout(resolve, Math.pow(2, retryCount) * 1000));
}
} catch (error) {
retryCount++;
if (retryCount >= MAX_RETRIES) {
throw error;
}
// 指数退避
await new Promise(resolve => setTimeout(resolve, Math.pow(2, retryCount) * 1000));
}
}
}五、常见问题与解决方案
1. Token 机制问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| Token 过期 | 有效期设置过短 | 根据业务场景调整有效期 |
| Token 冲突 | UUID 重复概率极低 | 一般无需担心,可添加业务前缀 |
| Token 丢失 | 前端未正确存储 | 前端使用 localStorage 或表单隐藏字段 |
| 并发请求 | 多个请求同时使用同一 Token | 服务端使用原子操作验证和删除 |
2. 数据库方案问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 唯一索引冲突 | 并发创建相同资源 | 捕获唯一索引异常,返回已有数据 |
| 乐观锁失败 | 版本号不匹配 | 返回特定错误码,前端重试 |
| 分布式锁失效 | 锁过期时间不足 | 根据业务耗时调整锁超时时间 |
3. 业务场景适配
支付场景:
- 使用交易号作为唯一标识
- 支付回调添加分布式锁
- 处理中状态需友好提示
下单场景:
- 前端获取 Token 后再展示提交按钮
- 提交后按钮置灰,防止重复点击
- 处理中跳转到等待页面
文件上传场景:
- 使用文件哈希值作为唯一标识
- 分片上传使用分片标识
- 断点续传支持
六、实用技巧速查表
| 场景 | 推荐方案 | 注意事项 |
|---|---|---|
| 表单提交 | Token 机制 | 设置合理有效期,前端正确存储 |
| 订单创建 | Token + 唯一索引 | 双重保障,避免重复创建 |
| 支付回调 | 唯一交易号 + 分布式锁 | 防止重复处理回调 |
| 数据更新 | 乐观锁 | 版本号控制,避免覆盖更新 |
| 高并发场景 | 分布式锁 | 注意锁超时和死锁问题 |
| 移动端请求 | Token 机制 | 考虑网络不稳定,适当延长有效期 |
| 消息队列消费 | 唯一消息ID | 消费前检查是否已处理 |
| 重试机制 | 指数退避重试 | 设置最大重试次数 |
