Spring Boot 中基于 Redis + 注解实现接口幂等性的实战方案
接口幂等性这个话题,很多人第一次接触时会觉得“是不是重复提交拦一下就行了”。但真到了线上,你会发现事情没这么简单:用户手抖连续点两次、前端超时重试、网关重放、消息补偿、支付回调重复通知……这些都可能让一个“应该只成功一次”的接口,被执行多次。
这篇文章我不讲太虚的概念,直接带你用 Spring Boot + Redis + 自定义注解 做一套实用方案。目标是:
- 业务代码尽量少侵入
- 对 Controller 方法做到开箱即用
- 能应对高并发下的重复请求
- 代码可运行、好排查、方便扩展
背景与问题
先看一个很常见的场景:下单接口。
用户点击“提交订单” -> 网络卡顿 -> 用户又点一次 -> 服务端创建两笔订单
或者:
客户端没收到响应 -> 自动重试 -> 服务端重复扣库存
如果接口没有幂等控制,后果通常是:
- 重复下单
- 重复扣款
- 重复发券
- 重复发送通知
- 状态机推进错乱
什么叫接口幂等性?
简单说就是:
同一个请求,无论执行一次还是多次,最终结果应当一致。
注意,这里的“一致”不一定是每次都重新处理,而是后续重复请求不能把系统搞乱。
常见实现方式
幂等性常见做法有几类:
- 数据库唯一索引
- 适合强业务唯一性,例如订单号、流水号唯一
- 简单可靠,但不够通用
- Token 机制
- 请求前先拿 token,提交时消费 token
- 适合防表单重复提交
- 状态机控制
- 通过状态流转避免重复处理
- 常见于支付、审核、工作流
- Redis 分布式锁 / 去重键
- 适合通用接口幂等拦截
- 本文重点
这篇文章的方案属于第 4 类:用 Redis 保存幂等键,再通过注解+AOP 把逻辑抽出来。
前置知识与环境准备
环境版本
下面示例基于:
- JDK 8+
- Spring Boot 2.6.x / 2.7.x
- Spring AOP
- Spring Data Redis
- Redis 6.x
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>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
application.yml
server:
port: 8080
spring:
redis:
host: 127.0.0.1
port: 6379
timeout: 3000ms
核心原理
先把整体思路说清楚,再写代码会更顺。
方案思路
- 在接口方法上加一个自定义注解,比如
@Idempotent - AOP 拦截这个注解
- 根据请求特征生成一个幂等 key
- 用 Redis 的
SETNX(即 setIfAbsent)尝试写入 - 如果写入成功,说明是第一次请求,允许执行业务
- 如果写入失败,说明短时间内已有相同请求,直接拦截
为什么 Redis 能做这个事?
因为 Redis 提供了原子操作:
SET key value NX EX 10
含义是:
- key 不存在时才写入
- 并设置过期时间 10 秒
这个操作是原子的,非常适合做“短时间去重”。
一张流程图看懂
flowchart TD
A[客户端发起请求] --> B[Controller 方法上有 @Idempotent]
B --> C[AOP 拦截请求]
C --> D[生成幂等 Key]
D --> E[Redis SETNX + EX]
E -->|成功| F[执行业务逻辑]
E -->|失败| G[返回重复请求提示]
F --> H[返回处理结果]
幂等 key 应该怎么设计?
这是成败关键之一。
通常可以由以下信息拼接:
- 用户标识:userId / token / tenantId
- 请求路径:URI
- 请求参数:body/query
- 业务唯一字段:如 orderNo、requestId
推荐规则:
idem:{接口标识}:{用户标识}:{请求摘要}
比如:
idem:/order/create:1001:md5(请求体)
这样能保证:
- 不同用户互不影响
- 同一接口不同参数不冲突
- 同一用户同一请求短时间内只处理一次
时序图
sequenceDiagram
participant C as Client
participant A as IdempotentAspect
participant R as Redis
participant S as OrderService
C->>A: POST /order/create
A->>R: SET key value NX EX 10
alt key 不存在
R-->>A: OK
A->>S: 执行业务
S-->>A: 返回结果
A-->>C: 成功响应
else key 已存在
R-->>A: null
A-->>C: 重复请求,已拦截
end
实战代码(可运行)
下面我们从零开始搭一个最小可用版本。
第一步:定义幂等注解
package com.example.demo.idempotent;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* key 前缀
*/
String prefix() default "idem";
/**
* 过期时间,单位秒
*/
long expireSeconds() default 10;
/**
* 提示信息
*/
String message() default "请求重复,请稍后再试";
}
这个注解先保持简单,足够实战。
第二步:定义统一异常
package com.example.demo.idempotent;
public class IdempotentException extends RuntimeException {
public IdempotentException(String message) {
super(message);
}
}
第三步:封装 Redis 操作
这里用 StringRedisTemplate,因为做幂等控制时字符串最直接。
package com.example.demo.idempotent;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class RedisIdempotentService {
private final StringRedisTemplate stringRedisTemplate;
public RedisIdempotentService(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public boolean tryLock(String key, String value, long expireSeconds) {
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(key, value, expireSeconds, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
}
第四步:获取请求内容并生成 key
幂等 key 的生成要尽量稳定。这里我们选择:
- 请求 URI
- 请求方法
- 当前用户标识(示例先从请求头取)
- 请求参数摘要
package com.example.demo.idempotent;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.stream.Collectors;
@Component
public class IdempotentKeyGenerator {
private final ObjectMapper objectMapper;
public IdempotentKeyGenerator(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public String generate(String prefix, Object[] args) {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
throw new IllegalStateException("无法获取当前请求上下文");
}
HttpServletRequest request = attributes.getRequest();
String uri = request.getRequestURI();
String method = request.getMethod();
// 示例:从请求头读取用户标识,实际项目可对接登录态
String userId = request.getHeader("X-User-Id");
if (userId == null || userId.trim().isEmpty()) {
userId = "anonymous";
}
String argsJson = serializeArgs(args);
String argDigest = DigestUtils.md5DigestAsHex(argsJson.getBytes(StandardCharsets.UTF_8));
return String.format("%s:%s:%s:%s:%s", prefix, method, uri, userId, argDigest);
}
private String serializeArgs(Object[] args) {
try {
return Arrays.stream(args)
.map(arg -> {
try {
if (arg == null) {
return "null";
}
return objectMapper.writeValueAsString(arg);
} catch (Exception e) {
return String.valueOf(arg);
}
})
.collect(Collectors.joining(","));
} catch (Exception e) {
throw new RuntimeException("生成幂等 key 失败", e);
}
}
}
这里我刻意没有把
HttpServletRequest本身序列化进去,不然 key 会很乱,甚至报错。这个坑我自己就踩过。
第五步:AOP 切面拦截注解
package com.example.demo.idempotent;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.UUID;
@Aspect
@Component
public class IdempotentAspect {
private final RedisIdempotentService redisIdempotentService;
private final IdempotentKeyGenerator keyGenerator;
public IdempotentAspect(RedisIdempotentService redisIdempotentService,
IdempotentKeyGenerator keyGenerator) {
this.redisIdempotentService = redisIdempotentService;
this.keyGenerator = keyGenerator;
}
@Around("@annotation(com.example.demo.idempotent.Idempotent)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Idempotent idempotent = method.getAnnotation(Idempotent.class);
String key = keyGenerator.generate(idempotent.prefix(), joinPoint.getArgs());
String value = UUID.randomUUID().toString();
boolean success = redisIdempotentService.tryLock(key, value, idempotent.expireSeconds());
if (!success) {
throw new IdempotentException(idempotent.message());
}
return joinPoint.proceed();
}
}
这版实现的语义是:
- 在过期时间窗口内,相同请求只允许一次通过
- 后续重复请求直接报错
这对于“防重复提交”场景非常合适。
第六步:统一异常返回
package com.example.demo.web;
import com.example.demo.idempotent.IdempotentException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.LinkedHashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IdempotentException.class)
public Map<String, Object> handleIdempotentException(IdempotentException e) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("code", 409);
result.put("message", e.getMessage());
return result;
}
@ExceptionHandler(Exception.class)
public Map<String, Object> handleException(Exception e) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("code", 500);
result.put("message", e.getMessage());
return result;
}
}
第七步:编写示例业务接口
请求对象
package com.example.demo.order;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
public class CreateOrderRequest {
@NotBlank
private String productCode;
@Min(1)
private Integer quantity;
public String getProductCode() {
return productCode;
}
public void setProductCode(String productCode) {
this.productCode = productCode;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
}
Controller
package com.example.demo.order;
import com.example.demo.idempotent.Idempotent;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/order")
public class OrderController {
@PostMapping("/create")
@Idempotent(prefix = "order:create", expireSeconds = 5, message = "订单正在处理中,请勿重复提交")
public Map<String, Object> create(@Validated @RequestBody CreateOrderRequest request) throws InterruptedException {
// 模拟耗时业务
Thread.sleep(2000);
Map<String, Object> result = new LinkedHashMap<>();
result.put("orderNo", UUID.randomUUID().toString());
result.put("productCode", request.getProductCode());
result.put("quantity", request.getQuantity());
result.put("status", "SUCCESS");
return result;
}
}
第八步:启动类
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class IdempotentDemoApplication {
public static void main(String[] args) {
SpringApplication.run(IdempotentDemoApplication.class, args);
}
}
逐步验证清单
服务启动后,可以用 curl 验证。
第一次请求
curl --location --request POST 'http://localhost:8080/order/create' \
--header 'Content-Type: application/json' \
--header 'X-User-Id: 1001' \
--data-raw '{
"productCode": "SKU-10001",
"quantity": 1
}'
预期返回:
{
"orderNo": "xxx",
"productCode": "SKU-10001",
"quantity": 1,
"status": "SUCCESS"
}
在 5 秒内立刻重复提交
curl --location --request POST 'http://localhost:8080/order/create' \
--header 'Content-Type: application/json' \
--header 'X-User-Id: 1001' \
--data-raw '{
"productCode": "SKU-10001",
"quantity": 1
}'
预期返回:
{
"code": 409,
"message": "订单正在处理中,请勿重复提交"
}
不同用户相同请求
如果 X-User-Id 改成 1002,则应该允许执行,因为幂等范围按用户隔离了。
进阶:幂等状态的两种策略
到这里你已经有一个能跑的版本了。但真正上线前,还得想清楚:到底拦“处理中重复请求”,还是“永久语义幂等”?
这是两个不同方向。
策略一:短时防重
特点:
- 重点解决用户连点、前端瞬时重试
- Redis key 设置较短 TTL
- 超时后同样请求可再次进入
适合:
- 提交表单
- 创建工单
- 普通业务提交接口
策略二:业务级幂等
特点:
- 请求必须携带业务唯一号,如
requestId - 服务端记录“这个业务请求已经处理过”
- 重复请求直接返回历史结果或告知已处理
适合:
- 支付下单
- 转账
- 发券
- 回调通知
下面这张状态图比较直观:
stateDiagram-v2
[*] --> 首次请求
首次请求 --> 锁定成功: Redis SETNX 成功
首次请求 --> 重复请求: Redis SETNX 失败
锁定成功 --> 执行业务
执行业务 --> 返回成功
重复请求 --> 返回拦截提示
返回成功 --> [*]
返回拦截提示 --> [*]
很多项目一开始只做了“短时防重”,后来遇到支付回调,才发现不够。所以你要根据业务边界选方案,别一套代码打天下。
常见坑与排查
这一部分很重要。我把最容易出问题的地方拎出来说。
1. 幂等 key 设计不合理
现象
- 不同请求被误判成重复
- 相同请求没拦住
常见原因
- key 没带用户维度
- key 只用了 URI,没带参数
- 参数序列化不稳定
- 请求体字段顺序不同导致摘要不同
排查建议
- 打印生成的 key
- 核对是否包含用户、接口、参数摘要
- 检查是否把无关字段也带进去了,比如时间戳、随机数
我一般建议:尽量基于真正决定业务唯一性的字段生成 key,而不是“见什么拼什么”。
2. 过期时间设置不合适
现象
- TTL 太短:业务还没处理完,key 就过期,第二次请求又进来了
- TTL 太长:用户合法重试也被挡住
建议
TTL 至少覆盖:
接口平均耗时 + 抖动时间 + 重试缓冲
举例:
- 平均耗时 2 秒
- 高峰抖动 1 秒
- 缓冲 2 秒
那你可以先设成 5 秒或 10 秒。
3. 异常后是否删除 key?
这是个非常常见的争议点。
当前示例的行为
- 一旦
SETNX成功,key 在 TTL 内存在 - 即使业务异常,也不会立刻删除
这意味着什么?
如果业务抛异常,用户短时间内重试可能还是被拦住。
怎么选?
要看场景:
- 防重复提交场景:通常不删,避免失败瞬间被疯狂重试打爆
- 必须允许失败后立即重试场景:可以在异常时删除 key
但如果你要“异常删除 key”,要注意这又会带来新的风险:
业务虽然失败了,但可能部分副作用已经发生,这时立刻放开重试,反而更危险。
所以别把“异常删 key”当成默认正确答案。
4. AOP 没生效
现象
- 加了
@Idempotent,接口还是没拦截
排查项
- 是否引入了
spring-boot-starter-aop - 注解是否加在
public方法上 - 是否是 Spring 容器管理的 Bean
- 是否存在同类内部调用,导致代理失效
典型坑:
public class OrderService {
public void outer() {
inner(); // 同类内部调用,AOP 可能不生效
}
@Idempotent
public void inner() {
}
}
这种情况要么把方法拆到别的 Bean,要么调整调用方式。
5. 请求参数无法正确读取
现象
- key 总是不稳定
- 不同请求生成相同 key
- 序列化异常
原因
- 参数里包含
HttpServletRequest、HttpServletResponse、文件流 - 参数对象有循环引用
- 文件上传接口不适合直接序列化全部参数
建议
生成 key 时过滤掉这些类型,只保留业务参数。
安全/性能最佳实践
这部分是从“能跑”走向“能上线”。
1. 优先使用业务唯一号
如果你的接口本身天然有唯一请求号,比如:
requestIdbizNoorderNo
那最好的办法是直接把它作为幂等核心键,而不是仅依赖参数摘要。
比如:
idem:pay:{requestId}
这是比“整个请求体 md5”更稳的方案。
2. 不要把完整敏感数据放进 Redis key
错误示例:
idem:/pay:13800138000:身份证号:银行卡号
这会泄露敏感信息。
正确做法:
- key 里只放必要标识
- 敏感内容做摘要,例如 md5/sha256
- 值也不要存敏感明文
3. 控制 Redis key 的粒度和数量
如果你的 QPS 很高,而每个请求都生成一个全新 key,Redis 压力会明显上升。
建议:
- TTL 不要过长
- 前缀按业务归类,方便排查
- 对无幂等需求的接口不要滥用
- 高峰场景评估 key 数量增长
一个简单估算:
每秒 2000 请求,TTL 10 秒
理论同时存在的幂等 key 约 2 万个
这个量对普通 Redis 不算夸张,但也不能无限堆。
4. 给日志加上幂等 key
线上排查时非常有用。
建议记录:
- 请求 URI
- 用户标识
- 生成的幂等 key
- Redis 设置结果
- 业务耗时
这样当用户说“我明明只点了一次”,你至少能顺着日志看到真相。
5. 和数据库唯一约束配合使用
这是我最推荐的实战组合:
- 第一层:Redis 幂等拦截
- 挡住大多数重复请求
- 第二层:数据库唯一索引
- 兜底保证最终一致性
因为 Redis 幂等不是万能保险箱。
例如 Redis 短暂故障、key 过期边界、跨系统重试,最终还是要靠数据库约束守住底线。
6. 对关键接口返回历史结果,而不是只报“重复”
对于支付、创建订单这类关键场景,更好的体验往往是:
- 第一次请求:正常执行
- 第二次相同请求:直接返回第一次的处理结果
这就不是本文这个最简版“拦截即报错”能完全覆盖的了。你需要额外存储:
- 请求唯一号
- 处理状态
- 响应结果快照
如果业务已经进入这个阶段,建议从“防重”升级到“完整幂等中心”。
方案对比:为什么这里选注解驱动?
很多人会问:这事写在拦截器里不也行吗?
可以,但注解驱动有几个明显优势:
1. 使用粒度更细
只在需要的接口上加:
@Idempotent
不用全局拦截后再写一堆排除规则。
2. 业务语义更清楚
看到方法声明,开发者就知道这个接口需要幂等控制。
3. 易于扩展
后面你可以继续给注解加属性,比如:
keyStrategyscenerequireUserspEl
甚至支持:
@Idempotent(key = "#req.orderNo", expireSeconds = 30)
这样会更灵活。
可扩展方向
如果你打算把这套方案做成团队通用组件,我建议往这几个方向升级:
支持 SpEL 自定义 key
例如:
@Idempotent(prefix = "pay", key = "#request.bizNo")
这样开发者不用依赖“全参数摘要”,而是显式指定业务唯一字段。
支持不同重复策略
比如:
REJECT:直接拒绝WAIT:等待前一个请求完成RETURN_LAST_RESULT:返回历史结果
支持状态记录
把幂等状态区分成:
- PROCESSING
- SUCCESS
- FAILED
而不是只有“有没有 key”。
支持多节点统一幂等
Redis 本身就天然支持分布式节点共享,这是这套方案适合微服务部署的重要原因。
总结
我们这篇文章完成了一套 Spring Boot 中基于 Redis + 注解实现接口幂等性 的实战方案,核心链路是:
- 自定义
@Idempotent注解 - 用 AOP 拦截目标接口
- 基于请求信息生成幂等 key
- 使用 Redis
SETNX + 过期时间做原子去重 - 重复请求直接拦截
这套方案特别适合:
- 防止按钮重复点击
- 前端短时间重复提交
- 网关或客户端瞬时重试
- 多实例部署下的统一幂等控制
但它也有边界:
- 它更偏“短时防重”,不是所有业务场景下的“永久语义幂等”
- 对支付、转账、回调类接口,最好结合业务唯一号、数据库唯一约束,甚至结果缓存一起做
- Redis 只是第一道防线,不是最终一致性的唯一保障
如果你现在就要落地,我的建议很直接:
- 普通提交类接口:先上本文这套注解 + Redis 方案
- 关键资金类接口:必须叠加业务流水号 + DB 唯一约束
- 要提升体验的场景:重复请求尽量返回历史结果,而不是简单报错
先把 80% 的重复请求问题解决掉,再逐步演进成更完整的幂等体系,这通常是成本和效果最平衡的做法。