背景与问题
接口幂等性这个词,很多同学第一次听会觉得“有点抽象”,但业务里它其实非常接地气:
- 用户连点两次“提交订单”
- 支付平台回调重复通知
- 前端超时重试导致后端收到多次请求
- MQ 消息重复消费
- 网关重放请求
如果接口没有幂等保护,轻则重复插入数据,重则出现重复下单、重复扣款、库存错乱。
在 Spring Boot 项目里,幂等性经常有两种落地方式:
- 基于拦截器(Interceptor):适合在 Web 请求入口统一做校验,偏“请求级治理”
- 基于 AOP(Aspect):适合围绕业务方法增强,偏“方法级治理”
这两种方式不是对立关系,反而非常适合组合使用。我自己的经验是:
- 拦截器:负责“先拦住不合法请求”
- AOP:负责“围绕目标方法做幂等令牌获取、锁定、释放、结果处理”
如果只选一种,往往会在灵活性或统一性上吃亏。
背景中的典型场景
先把场景分清楚,不然幂等设计很容易“用错武器”。
| 场景 | 是否需要幂等 | 推荐方案 |
|---|---|---|
| 新增订单 | 必须 | token + Redis + 业务唯一索引 |
| 更新用户资料 | 通常建议 | 请求去重或版本号控制 |
| 扣减库存 | 必须 | 分布式锁 / 去重表 / 状态机 |
| 支付回调 | 必须 | 回调单号唯一约束 + 状态判断 |
| 查询接口 | 一般不强调 | 天然幂等 |
一个容易混淆的点:
HTTP 的 GET/PUT/DELETE 语义上的幂等,和业务上的幂等保障不是一回事。
比如一个 POST 创建订单,HTTP 语义上不是幂等,但业务上必须做到“只创建一次”。
核心原理
接口幂等性的本质,是让“同一请求的重复提交,只产生一次有效副作用”。
常见实现思路主要有三类:
- 数据库唯一约束
- 防重 token / requestId
- 分布式锁或状态机控制
本文重点讲第二类,并配合第一类兜底。
1. 幂等键是什么
要实现幂等,第一步是识别“什么叫同一请求”。
常见幂等键来源:
- 前端生成
requestId - 后端下发一次性
token - 业务字段拼接,如
userId + 商品ID + 时间窗口 - 第三方平台回调的唯一流水号
在 Web 系统中,最稳妥的做法通常是:
- 提交前先获取一个 idempotent token
- 客户端提交时把 token 放在请求头或参数中
- 服务端校验并“消费”这个 token
- 消费成功才允许业务继续执行
2. 为什么要用 Redis
因为幂等判断本质上需要一个跨实例共享状态的地方。
单机内存有两个问题:
- 多实例部署后失效
- 服务重启后状态丢失
Redis 很适合做这个事情,因为它支持:
- 高性能读写
- TTL 过期
- 原子命令
- 分布式部署
3. 拦截器和 AOP 分别解决什么问题
拦截器适合做:
- 提前拦截 HTTP 请求
- 读取 Header / Parameter
- 做通用参数校验
- 对指定接口统一启用幂等检查
AOP 适合做:
- 用注解声明哪些接口要幂等
- 围绕 Controller / Service 方法增强
- 统一处理“加锁、执行业务、异常回滚、结果返回”
我更推荐的架构是:
- 注解:声明式开启幂等
- 拦截器:前置校验 token 存在性
- AOP:原子消费 token 并执行业务
- 数据库唯一约束:兜底防线
方案对比与取舍分析
常见方案横向对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 数据库唯一索引 | 简单可靠 | 只能兜底,用户体验一般 | 订单号、流水号唯一 |
| Redis token | 性能高、体验好 | 需要客户端配合 | 表单提交、防重复点击 |
| 分布式锁 | 控制力强 | 设计复杂,容易误用 | 库存扣减、资源竞争 |
| 去重表 | 审计性好 | 表膨胀,需要清理 | MQ 消费幂等 |
| 状态机 | 最稳妥 | 实现成本高 | 支付、履约、退款 |
一个务实的选择
对于大多数 Spring Boot Web 应用,我建议优先采用:
“Redis token + 注解 + AOP + 数据库唯一索引兜底”
原因很简单:
- 业务侵入相对小
- 易于在多个接口复用
- 能兼顾体验和可靠性
- 出现边界问题时还有数据库兜底
整体架构设计
下面先看一个整体流程图。
flowchart TD
A[客户端请求获取 token] --> B[服务端生成 token 存入 Redis]
B --> C[客户端提交业务请求并携带 token]
C --> D[Spring MVC 拦截器预校验]
D --> E[AOP 切面原子消费 token]
E -->|成功| F[执行业务逻辑]
E -->|失败| G[返回重复提交]
F --> H[数据库唯一索引兜底]
H --> I[返回业务结果]
再看一次请求在系统里的调用时序。
sequenceDiagram
participant Client as 客户端
participant Controller as Controller
participant Interceptor as IdempotentInterceptor
participant Aspect as IdempotentAspect
participant Redis as Redis
participant Service as OrderService
participant DB as MySQL
Client->>Controller: POST /orders + token
Controller->>Interceptor: 进入请求
Interceptor->>Interceptor: 检查 token 是否存在
Interceptor-->>Controller: 放行
Controller->>Aspect: 调用目标方法
Aspect->>Redis: 原子删除/消费 token
alt token 有效
Redis-->>Aspect: 成功
Aspect->>Service: 执行业务
Service->>DB: 插入订单
DB-->>Service: 成功
Service-->>Aspect: 返回结果
Aspect-->>Client: 200 OK
else token 无效或已消费
Redis-->>Aspect: 失败
Aspect-->>Client: 409 重复提交
end
设计要点
1. token 必须“一次性消费”
如果 token 只是“校验存在”,而不是“校验后立刻原子删除”,就会有并发漏洞:
- 请求 A 校验通过
- 请求 B 也校验通过
- 两个请求同时进入业务逻辑
所以关键不是 get,而是原子消费。
2. 幂等失败要明确返回
不要简单返回 500。更合适的是:
- 409 Conflict
- 业务码提示“请勿重复提交”
3. 幂等不是锁一切
有些同学会把幂等做成“同一个用户一分钟只能调用一次接口”,这不叫幂等,更像限流。
幂等判断的是:
是否同一业务请求被重复执行
而不是:
是否短时间内重复访问
这两个目标不同,别混在一起。
实战代码(可运行)
下面给出一个可运行的 Spring Boot 示例,核心包含:
- 幂等注解
- 生成 token 接口
- 拦截器
- AOP 切面
- Redis 配置
- 示例下单接口
目录结构
src/main/java/com/example/idempotent
├── IdempotentApplication.java
├── config
│ └── WebMvcConfig.java
├── controller
│ ├── OrderController.java
│ └── TokenController.java
├── aspect
│ └── IdempotentAspect.java
├── interceptor
│ └── IdempotentInterceptor.java
├── annotation
│ └── Idempotent.java
├── service
│ └── OrderService.java
├── exception
│ └── BizException.java
└── util
└── RedisTokenService.java
1)Maven 依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
2)启动类
package com.example.idempotent;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class IdempotentApplication {
public static void main(String[] args) {
SpringApplication.run(IdempotentApplication.class, args);
}
}
3)幂等注解
package com.example.idempotent.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* token 在请求头中的名称
*/
String header() default "Idempotent-Token";
}
4)业务异常
package com.example.idempotent.exception;
public class BizException extends RuntimeException {
public BizException(String message) {
super(message);
}
}
5)Redis token 服务
这里为了保证“消费”动作原子性,直接用 Lua 脚本做删除判断。
package com.example.idempotent.util;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public class RedisTokenService {
private static final String TOKEN_PREFIX = "idem:token:";
private final StringRedisTemplate stringRedisTemplate;
public RedisTokenService(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public String createToken() {
String token = UUID.randomUUID().toString().replace("-", "");
String key = TOKEN_PREFIX + token;
stringRedisTemplate.opsForValue().set(key, "1", 10, TimeUnit.MINUTES);
return token;
}
public boolean exists(String token) {
return Boolean.TRUE.equals(stringRedisTemplate.hasKey(TOKEN_PREFIX + token));
}
public boolean consumeToken(String token) {
String key = TOKEN_PREFIX + token;
String scriptText =
"if redis.call('exists', KEYS[1]) == 1 then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(scriptText);
script.setResultType(Long.class);
Long result = stringRedisTemplate.execute(script, Collections.singletonList(key));
return result != null && result > 0;
}
}
6)拦截器:做前置校验
这里有个设计点:拦截器不直接消费 token,只检查“有没有带”,避免业务还没执行就把 token 删掉。
package com.example.idempotent.interceptor;
import com.example.idempotent.annotation.Idempotent;
import com.example.idempotent.exception.BizException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class IdempotentInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
Idempotent idempotent = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Idempotent.class);
if (idempotent == null) {
return true;
}
String token = request.getHeader(idempotent.header());
if (token == null || token.trim().isEmpty()) {
throw new BizException("缺少幂等 token");
}
return true;
}
}
7)注册拦截器
package com.example.idempotent.config;
import com.example.idempotent.interceptor.IdempotentInterceptor;
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 WebMvcConfig implements WebMvcConfigurer {
private final IdempotentInterceptor idempotentInterceptor;
public WebMvcConfig(IdempotentInterceptor idempotentInterceptor) {
this.idempotentInterceptor = idempotentInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(idempotentInterceptor).addPathPatterns("/**");
}
}
8)AOP:原子消费 token
真正关键的动作放在这里。
package com.example.idempotent.aspect;
import com.example.idempotent.annotation.Idempotent;
import com.example.idempotent.exception.BizException;
import com.example.idempotent.util.RedisTokenService;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Method;
@Aspect
@Component
public class IdempotentAspect {
private final RedisTokenService redisTokenService;
public IdempotentAspect(RedisTokenService redisTokenService) {
this.redisTokenService = redisTokenService;
}
@Around("@annotation(com.example.idempotent.annotation.Idempotent)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
throw new BizException("无法获取当前请求");
}
HttpServletRequest request = attributes.getRequest();
Method method = ((org.aspectj.lang.reflect.MethodSignature) joinPoint.getSignature()).getMethod();
Idempotent idempotent = AnnotationUtils.findAnnotation(method, Idempotent.class);
if (idempotent == null) {
return joinPoint.proceed();
}
String token = request.getHeader(idempotent.header());
if (token == null || token.trim().isEmpty()) {
throw new BizException("缺少幂等 token");
}
boolean consumed = redisTokenService.consumeToken(token);
if (!consumed) {
throw new BizException("请求重复提交,请勿重复操作");
}
return joinPoint.proceed();
}
}
9)业务 Service
为了便于演示,这里简单返回结果。实际项目里请在数据库中加唯一索引做兜底。
package com.example.idempotent.service;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Service
public class OrderService {
public Map<String, Object> createOrder(String userId, String productId) {
Map<String, Object> result = new HashMap<>();
result.put("orderNo", UUID.randomUUID().toString());
result.put("userId", userId);
result.put("productId", productId);
result.put("createTime", LocalDateTime.now().toString());
result.put("status", "SUCCESS");
return result;
}
}
10)获取 token 接口
package com.example.idempotent.controller;
import com.example.idempotent.util.RedisTokenService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class TokenController {
private final RedisTokenService redisTokenService;
public TokenController(RedisTokenService redisTokenService) {
this.redisTokenService = redisTokenService;
}
@GetMapping("/token")
public Map<String, String> token() {
String token = redisTokenService.createToken();
return Map.of("token", token);
}
}
11)下单接口
package com.example.idempotent.controller;
import com.example.idempotent.annotation.Idempotent;
import com.example.idempotent.service.OrderService;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
@Idempotent
public Map<String, Object> createOrder(@RequestParam String userId,
@RequestParam String productId) {
return orderService.createOrder(userId, productId);
}
}
12)统一异常返回
package com.example.idempotent.controller;
import com.example.idempotent.exception.BizException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public Map<String, Object> handleBizException(BizException e) {
return Map.of(
"code", 409,
"message", e.getMessage()
);
}
}
13)配置文件
server:
port: 8080
spring:
data:
redis:
host: localhost
port: 6379
如何验证
第一步:获取 token
curl http://localhost:8080/token
返回:
{"token":"7f1e1d1f2d7346ce8d0d7c2f6e8ab123"}
第二步:携带 token 提交订单
curl -X POST "http://localhost:8080/orders?userId=1001&productId=sku-01" \
-H "Idempotent-Token: 7f1e1d1f2d7346ce8d0d7c2f6e8ab123"
第一次返回成功。
第三步:重复提交同一请求
再次使用相同 token 提交:
curl -X POST "http://localhost:8080/orders?userId=1001&productId=sku-01" \
-H "Idempotent-Token: 7f1e1d1f2d7346ce8d0d7c2f6e8ab123"
会返回:
{
"code": 409,
"message": "请求重复提交,请勿重复操作"
}
进一步增强:结合数据库唯一索引兜底
仅靠 Redis token 还不够吗?
老实说,在核心交易场景不够。
因为仍然可能有这些边界情况:
- token 机制被绕过
- 内部服务调用没带 token
- 运维手动重试
- 极端情况下 Redis 数据异常
- 同一业务从多个入口进入
所以建议数据库层加一层唯一约束。比如订单表里引入 request_no:
CREATE TABLE t_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
request_no VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
product_id VARCHAR(64) NOT NULL,
create_time DATETIME NOT NULL,
UNIQUE KEY uk_request_no (request_no)
);
业务插入时:
- 有唯一索引冲突:说明已处理过
- 返回已有结果或提示重复提交
这是非常实用的“双保险”。
容量估算与工程考虑
做架构设计时,不只是“能跑”,还要看能不能在流量上扛得住。
Redis 容量估算
假设:
- 峰值每分钟生成 10 万个 token
- token 保存 10 分钟
- 同时在 Redis 中大约存在 100 万个 token
如果每个 token key 平均按 100~150 字节估算(包括 key/value/元数据开销),那么内存占用大致在:
100万 * 100B = 100MB- 到
100万 * 150B = 150MB
这个量通常是可接受的,但如果你的 token TTL 拉到 1 小时,内存会明显上升。
建议
- Web 表单防重:TTL 5~15 分钟足够
- 支付回调去重:可适当更长,但更建议落库
- 避免把大对象塞进 token value
常见坑与排查
这一部分我想多说一点,因为真正上线后,问题往往不在“不会写”,而在“以为自己写对了”。
坑 1:用 GET 判断 token,再 DELETE 删除
错误示意:
if (redisTemplate.hasKey(key)) {
redisTemplate.delete(key);
return true;
}
return false;
这个逻辑在并发下不是原子的,两个线程可能同时判断成功。
排查方式
- 压测同一个 token 并发请求
- 看是否出现两个请求都成功
正确做法
- 使用 Lua 脚本
- 或使用支持原子操作的命令组合
坑 2:在拦截器里提前删除 token
有些人会把 token 校验和删除都写到拦截器中,结果业务方法还没执行,token 就被消耗了。
如果后续业务因为参数校验失败、事务回滚、数据库异常而失败,用户再重试就会提示“重复提交”,体验很差。
建议
- 拦截器只做轻校验
- AOP 中做真正消费
- 业务失败是否允许重试,要根据场景决定
这里有边界条件:
如果你的业务是“只要请求到达就不能再重放”,那也可以在入口立即消费。
但大多数表单提交场景不建议这么做。
坑 3:AOP 切不到方法
典型原因:
- 方法不是
public - 同类内部调用导致代理失效
- 注解加在接口上,但切点没正确识别
- Spring AOP 代理模式理解不清
排查思路
- 确认切面是否被 Spring 扫描
- 确认目标方法是否通过代理调用
- 打日志输出切面是否进入
- 检查
@EnableAspectJAutoProxy是否需要显式开启
坑 4:只做了 token 幂等,没有业务唯一约束
结果就是:
- Web 请求挡住了重复提交
- 但内部补偿任务又插入了一条相同订单
- 最后还是重复数据
建议
永远记住一句话:
幂等校验在入口,唯一约束在落库。
入口层负责体验,存储层负责最终一致的底线。
坑 5:token 设计成和用户无关
如果 token 完全裸奔,不和用户、接口、业务上下文绑定,理论上可能被误用或重放到其他接口。
更稳妥的做法
可以在 Redis value 中记录:
- userId
- path
- method
- createTime
消费时做一致性校验。
安全/性能最佳实践
这一节很关键,很多实现“功能上能用”,但一上生产就开始露问题。
安全最佳实践
1. token 要有过期时间
不要生成永久 token。否则:
- Redis 键无限增长
- 重放窗口过大
- 安全风险提升
2. token 最好与用户绑定
例如 token 创建时,value 存储当前登录用户 ID。消费时校验:
- 该 token 是否属于当前用户
- 是否用于当前接口
这样可以防止 token 被其他人复用。
3. 重要接口叠加签名机制
对支付、转账、优惠券核销等高风险接口,仅有 token 不够,建议叠加:
- 用户身份校验
- 时间戳
- 请求签名
- 服务端业务状态校验
4. 错误信息不要过度暴露
不要告诉调用方“Redis key 不存在”这种实现细节,统一返回:
- 请勿重复提交
- 请求已失效,请刷新后重试
性能最佳实践
1. token key 要短小稳定
避免超长 key,Redis 内存开销会增大。建议:
idem:token:{token}
足够清晰,也方便排查。
2. 使用 Lua 脚本减少网络往返
相比先查再删,Lua 脚本一次请求就能完成判断和删除,性能和原子性都更好。
3. 不要对所有接口都启用幂等
幂等也有成本:
- 增加 Redis 访问
- 增加请求链路复杂度
- 可能影响故障排查
只在以下接口启用更合适:
- 创建类接口
- 扣减类接口
- 回调类接口
- 有明显副作用的接口
4. 降级策略要提前想好
如果 Redis 不可用怎么办?
我见过线上最尴尬的情况是:为了幂等保护,结果整个下单链路不可用了。
你至少要想清楚策略:
- 严格模式:Redis 挂了直接拒绝请求,保证安全
- 降级模式:依赖数据库唯一索引继续执行,保证可用性
具体选哪个,要看业务风险等级。
一个更完整的状态视角
从状态机角度看,token 实际上有自己的生命周期。
stateDiagram-v2
[*] --> Created
Created --> Submitted: 客户端携带 token 提交
Submitted --> Consumed: 服务端原子消费成功
Submitted --> Expired: token 过期
Created --> Expired: 长时间未使用
Consumed --> [*]
Expired --> [*]
如果你把这个状态想清楚,很多实现细节就不容易写偏:
Created:可用Consumed:不可重试Expired:需重新获取
拦截器与 AOP 的职责边界建议
我在项目里比较推荐下面这种职责分层:
拦截器负责
- 判断是否需要幂等校验
- 提前校验 token 是否缺失
- 做请求日志记录、traceId 透传
- 提前拦截明显非法请求
AOP 负责
- 根据注解读取 token 规则
- 调 Redis 做原子消费
- 包裹业务执行
- 统一幂等异常输出
Service/DAO 负责
- 业务唯一性校验
- 数据库唯一约束
- 事务控制
- 幂等后的“已处理结果”查询
这样分层的好处是:
- 代码职责清晰
- 容易测试
- 后续扩展到 MQ 消费幂等时,AOP 思路也能复用
什么时候不建议只靠这种方案
说实话,token 幂等不是银弹。下面几类场景,我不建议只靠拦截器 + AOP:
1. 支付、退款、履约状态流转
这类场景更适合:
- 业务单号唯一约束
- 状态机
- 事件表 / 流水表
- 补偿机制
2. MQ 消费幂等
MQ 没有 HTTP 请求,也没有拦截器这一说,更常见的是:
- 消息唯一 ID 去重
- 消费记录表
- 乐观锁 / 状态位
3. 高并发库存扣减
库存问题更核心的是“并发一致性”,幂等只是其中一环。通常要配合:
- Redis 预扣
- 数据库乐观锁
- 队列削峰
- 最终一致性补偿
总结
如果你要在 Spring Boot 里做一个实用、可维护的接口幂等方案,我建议按下面这个顺序落地:
- 用注解标识需要幂等的接口
- 用拦截器校验 token 是否携带
- 用 AOP 在业务执行前原子消费 token
- 用 Redis 存储 token,并设置合理 TTL
- 对核心表加数据库唯一索引做兜底
- 根据业务决定失败后是否允许重新申请 token 重试
最后给一个非常务实的结论:
对“防重复点击”这类场景,
拦截器 + AOP + Redis token 已经很好用。对“资金、支付、库存”这类强一致场景,
它只能算第一层保护,数据库唯一约束和业务状态机才是底盘。
如果你现在就准备开做,我建议先挑一个“创建类接口”试点,比如“创建订单”或“提交审批单”,先把注解、切面、Redis token 跑通,再逐步推广。这样最稳,也最容易在团队里形成统一规范。