Java Web 开发中基于 Spring Boot + Redis 实现接口幂等性的实战方案
在 Java Web 项目里,“幂等性”几乎是一个迟早会碰到的话题。尤其是下单、支付、发券、创建工单这类写操作接口,只要前端重复点击、网络超时重试、消息重复投递,后台就可能出现“同一业务被执行多次”的问题。
我自己做业务系统时,最常见的翻车现场就是:
- 用户点了两次“提交订单”,生成了两笔订单
- 支付回调被重复通知,余额被加了两次
- 前端因为超时自动重试,服务端又完整执行了一遍
这篇文章不讲太虚的概念,直接带你用 Spring Boot + Redis 实现一套可落地的接口幂等方案,并把常见坑、排查方法和性能安全建议一起说清楚。
背景与问题
先明确一个常见误区:
幂等性不是“接口只能调用一次”,而是“同样的请求调用多次,结果应当和调用一次一致”。
比如创建订单接口:
- 用户第一次请求:成功创建订单
- 用户第二次发起完全相同的请求:不应该再创建新订单,而应该返回第一次的处理结果,或者明确告诉客户端“请求已处理”
为什么会出现重复请求
常见来源有:
- 前端重复点击
- 网关/客户端超时重试
- 消息队列重复消费
- 第三方回调重复通知
- 分布式环境下并发请求同时落到不同实例
如果你只是做一个 synchronized,或者在单机内存里放个 Set,在多实例部署后基本就失效了。所以这里用 Redis,因为它天然适合做:
- 分布式共享状态
- 原子判断与写入
- 带过期时间的临时幂等记录
前置知识与环境准备
本文示例环境:
- JDK 8+
- Spring Boot 2.x
- Redis 6.x
- Maven
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>
</dependencies>
application.yml
server:
port: 8080
spring:
redis:
host: localhost
port: 6379
timeout: 3000
核心原理
接口幂等,最常见的实现思路是:
- 客户端为每次业务请求携带一个 唯一幂等键,例如
Idempotency-Key - 服务端先去 Redis 判断这个键是否存在
- 如果不存在,则原子写入 Redis,标记“处理中”
- 业务执行成功后,更新状态为“已完成”,并缓存处理结果
- 如果后续收到相同的幂等键:
- 若状态是“处理中”,返回“请勿重复提交”
- 若状态是“已完成”,直接返回之前结果
一句话理解
Redis 充当“请求去重登记簿”,同一个业务请求只允许被真正处理一次。
方案流程图
flowchart TD
A[客户端发起请求<br/>携带 Idempotency-Key] --> B[服务端读取 Redis]
B -->|不存在| C[SETNX 写入处理中状态]
B -->|已存在且处理中| D[返回重复提交提示]
B -->|已存在且已完成| E[返回历史结果]
C -->|成功| F[执行业务逻辑]
F --> G[保存结果到 Redis 并标记已完成]
G --> H[返回成功结果]
C -->|失败| D
幂等记录的状态设计
很多文章只讲一个“有/没有”的键值判断,但在实战里这不够用。更稳定的设计一般会有至少两个状态:
PROCESSING:请求已抢占,正在处理SUCCESS:请求已成功处理,可返回历史结果
必要时你还可以扩展:
FAILED:处理失败EXPIRED:过期,由 TTL 自动淘汰
状态流转图
stateDiagram-v2
[*] --> PROCESSING: 首次请求抢占成功
PROCESSING --> SUCCESS: 业务处理成功
PROCESSING --> [*]: 处理异常且删除幂等键
SUCCESS --> [*]: TTL 到期自动过期
方案设计要点
1. 幂等键从哪里来
推荐优先级:
- 前端生成唯一请求号
- 服务端基于业务字段生成
- 消息消费使用消息唯一 ID
例如下单请求可以让前端传:
Idempotency-Key: 9d5e44c4-5e42-4d94-ae2b-8c2f65d0f001
2. TTL 该设置多长
这个值要结合业务:
- 防重复提交:30 秒 ~ 5 分钟
- 支付回调:数小时甚至一天
- MQ 消费:看消息重试窗口
我一般建议:
PROCESSING的 TTL 不要太短,避免业务还没执行完键就过期SUCCESS的 TTL 可以相对长一点,方便重复请求直接拿结果
3. 为什么要缓存结果
因为“幂等”最好不是简单报错,而是:
对于已成功处理过的相同请求,尽量返回第一次成功的结果
这样前端体验会更好,也能减少不必要的错误重试。
实战代码(可运行)
下面我们用一个“创建订单”的例子来演示完整实现。
第一步:定义幂等注解
package com.example.demo.idempotent;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* 幂等键请求头名称
*/
String header() default "Idempotency-Key";
/**
* 过期时间,单位秒
*/
long expireSeconds() default 60;
}
第二步:定义幂等状态对象
package com.example.demo.idempotent;
public class IdempotentRecord {
/**
* PROCESSING / SUCCESS
*/
private String status;
/**
* 缓存的返回结果 JSON
*/
private String response;
public IdempotentRecord() {
}
public IdempotentRecord(String status, String response) {
this.status = status;
this.response = response;
}
public String getStatus() {
return status;
}
public String getResponse() {
return response;
}
public void setStatus(String status) {
this.status = status;
}
public void setResponse(String response) {
this.response = response;
}
}
第三步:封装 Redis 操作服务
package com.example.demo.idempotent;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class IdempotentService {
private static final String KEY_PREFIX = "idempotent:";
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
public IdempotentService(StringRedisTemplate stringRedisTemplate, ObjectMapper objectMapper) {
this.stringRedisTemplate = stringRedisTemplate;
this.objectMapper = objectMapper;
}
public boolean tryProcessing(String key, long expireSeconds) {
try {
IdempotentRecord record = new IdempotentRecord("PROCESSING", null);
String value = objectMapper.writeValueAsString(record);
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + key, value, expireSeconds, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
} catch (Exception e) {
throw new RuntimeException("写入 Redis 幂等标记失败", e);
}
}
public IdempotentRecord getRecord(String key) {
try {
String value = stringRedisTemplate.opsForValue().get(KEY_PREFIX + key);
if (value == null) {
return null;
}
return objectMapper.readValue(value, IdempotentRecord.class);
} catch (Exception e) {
throw new RuntimeException("读取 Redis 幂等记录失败", e);
}
}
public void markSuccess(String key, Object response, long expireSeconds) {
try {
String responseJson = objectMapper.writeValueAsString(response);
IdempotentRecord record = new IdempotentRecord("SUCCESS", responseJson);
String value = objectMapper.writeValueAsString(record);
stringRedisTemplate.opsForValue().set(KEY_PREFIX + key, value, expireSeconds, TimeUnit.SECONDS);
} catch (Exception e) {
throw new RuntimeException("更新 Redis 幂等成功记录失败", e);
}
}
public void delete(String key) {
stringRedisTemplate.delete(KEY_PREFIX + key);
}
}
第四步:定义统一返回对象
package com.example.demo.common;
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public ApiResponse() {
}
public ApiResponse(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(0, "success", data);
}
public static <T> ApiResponse<T> fail(String message) {
return new ApiResponse<>(-1, message, null);
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
public T getData() {
return data;
}
public void setCode(int code) {
this.code = code;
}
public void setMessage(String message) {
this.message = message;
}
public void setData(T data) {
this.data = data;
}
}
第五步:编写 AOP 切面
这一步是关键:把幂等控制抽出来,业务代码就干净很多。
package com.example.demo.idempotent;
import com.example.demo.common.ApiResponse;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
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 javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
@Component
@Aspect
public class IdempotentAspect {
private final HttpServletRequest request;
private final IdempotentService idempotentService;
private final ObjectMapper objectMapper;
public IdempotentAspect(HttpServletRequest request,
IdempotentService idempotentService,
ObjectMapper objectMapper) {
this.request = request;
this.idempotentService = idempotentService;
this.objectMapper = objectMapper;
}
@Around("@annotation(com.example.demo.idempotent.Idempotent)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
Idempotent idempotent = method.getAnnotation(Idempotent.class);
String headerName = idempotent.header();
long expireSeconds = idempotent.expireSeconds();
String key = request.getHeader(headerName);
if (key == null || key.trim().isEmpty()) {
return ApiResponse.fail("缺少幂等请求头: " + headerName);
}
IdempotentRecord record = idempotentService.getRecord(key);
if (record != null) {
if ("PROCESSING".equals(record.getStatus())) {
return ApiResponse.fail("请求正在处理中,请勿重复提交");
}
if ("SUCCESS".equals(record.getStatus()) && record.getResponse() != null) {
JavaType returnType = objectMapper.getTypeFactory().constructType(method.getGenericReturnType());
return objectMapper.readValue(record.getResponse(), returnType);
}
}
boolean locked = idempotentService.tryProcessing(key, expireSeconds);
if (!locked) {
return ApiResponse.fail("请求重复,请稍后再试");
}
try {
Object result = joinPoint.proceed();
idempotentService.markSuccess(key, result, expireSeconds);
return result;
} catch (Exception e) {
idempotentService.delete(key);
throw e;
}
}
}
第六步:编写订单请求与响应对象
package com.example.demo.order;
public class CreateOrderRequest {
private String userId;
private String productId;
private Integer amount;
public String getUserId() {
return userId;
}
public String getProductId() {
return productId;
}
public Integer getAmount() {
return amount;
}
public void setUserId(String userId) {
this.userId = userId;
}
public void setProductId(String productId) {
this.productId = productId;
}
public void setAmount(Integer amount) {
this.amount = amount;
}
}
package com.example.demo.order;
public class OrderDTO {
private String orderNo;
private String userId;
private String productId;
private Integer amount;
public OrderDTO() {
}
public OrderDTO(String orderNo, String userId, String productId, Integer amount) {
this.orderNo = orderNo;
this.userId = userId;
this.productId = productId;
this.amount = amount;
}
public String getOrderNo() {
return orderNo;
}
public String getUserId() {
return userId;
}
public String getProductId() {
return productId;
}
public Integer getAmount() {
return amount;
}
public void setOrderNo(String orderNo) {
this.orderNo = orderNo;
}
public void setUserId(String userId) {
this.userId = userId;
}
public void setProductId(String productId) {
this.productId = productId;
}
public void setAmount(Integer amount) {
this.amount = amount;
}
}
第七步:模拟业务服务
package com.example.demo.order;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
public class OrderService {
public OrderDTO createOrder(CreateOrderRequest request) {
// 模拟耗时业务
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
String orderNo = "ORD-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
return new OrderDTO(orderNo, request.getUserId(), request.getProductId(), request.getAmount());
}
}
第八步:编写 Controller
package com.example.demo.order;
import com.example.demo.common.ApiResponse;
import com.example.demo.idempotent.Idempotent;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
@Idempotent(expireSeconds = 120)
public ApiResponse<OrderDTO> createOrder(@RequestBody CreateOrderRequest request) {
OrderDTO order = orderService.createOrder(request);
return ApiResponse.success(order);
}
}
调用示例
第一次请求
curl --location --request POST 'http://localhost:8080/orders' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: demo-order-001' \
--data-raw '{
"userId": "u1001",
"productId": "p2001",
"amount": 2
}'
返回:
{
"code": 0,
"message": "success",
"data": {
"orderNo": "ORD-8a4d21e3b7f1",
"userId": "u1001",
"productId": "p2001",
"amount": 2
}
}
在业务执行期间重复提交
返回可能是:
{
"code": -1,
"message": "请求正在处理中,请勿重复提交",
"data": null
}
执行完成后再次提交同一个幂等键
会直接返回第一次的成功结果,而不是再次创建新订单。
请求时序图
sequenceDiagram
participant C as Client
participant A as Spring Boot
participant R as Redis
participant S as OrderService
C->>A: POST /orders + Idempotency-Key
A->>R: GET idempotent:key
R-->>A: null
A->>R: SETNX idempotent:key = PROCESSING
R-->>A: success
A->>S: createOrder()
S-->>A: OrderDTO
A->>R: SET idempotent:key = SUCCESS + response
R-->>A: ok
A-->>C: 返回成功结果
C->>A: 再次请求相同 Key
A->>R: GET idempotent:key
R-->>A: SUCCESS + response
A-->>C: 返回历史结果
逐步验证清单
如果你想自己跑一遍,我建议按下面顺序验证:
验证 1:正常请求
- 启动 Redis
- 启动 Spring Boot
- 用唯一的
Idempotency-Key发起请求 - 确认返回成功
验证 2:并发重复提交
可以用两个终端同时请求同一个 Key:
curl --location --request POST 'http://localhost:8080/orders' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: demo-order-002' \
--data-raw '{
"userId": "u1002",
"productId": "p3001",
"amount": 1
}'
预期结果:
- 一个请求成功创建订单
- 另一个请求返回“处理中”或直接返回历史结果
验证 3:不同 Key 是否允许正常创建多单
- 使用不同
Idempotency-Key - 请求体相同也应该分别成功
- 因为幂等是基于“同一个业务请求标识”,不是单纯按请求内容去重
验证 4:异常场景
你可以故意在 createOrder() 里抛异常,观察:
- Redis 里的幂等 key 是否被删除
- 下次请求是否还能重新执行
常见坑与排查
这一部分很重要,很多项目不是不会写,而是写出来后边界行为不稳定。
坑 1:只判断“有没有 key”,不区分状态
如果你只是:
- 有 key:拒绝
- 没 key:执行
会有一个问题:
- 第一次请求成功了
- 第二次请求来了
- 你只能告诉它“重复提交”,但拿不到第一次结果
这种实现太粗糙,用户体验也差。建议至少存储 PROCESSING 和 SUCCESS 两种状态。
坑 2:业务异常后没有删除 key
后果是:
- 第一次请求执行失败
- Redis 里还保留着“处理中”标记
- 后续相同请求永远不能再进来
排查方式:
redis-cli
keys idempotent:*
get idempotent:demo-order-001
如果发现异常后 key 还在,就说明异常分支没清理干净。
坑 3:过期时间设置太短
比如业务需要 10 秒,但你 TTL 只给了 3 秒:
- 第一个请求执行到一半,key 过期了
- 第二个相同请求进来,又能抢到锁
- 最终业务执行两次
这类问题特别隐蔽。我当时就踩过一次,接口压测时偶发重复单,最后发现就是 TTL 太短。
建议:
- TTL 至少覆盖接口最长处理时间
- 耗时不稳定的业务,可以做续期机制,或把 TTL 适当放宽
坑 4:幂等键设计不合理
如果前端每次重试都重新生成一个新 key,那后端根本无法识别是“同一请求”。
正确做法是:
- 一次业务操作从发起到结束,始终使用同一个幂等 key
- 重试请求必须复用原 key
坑 5:把幂等当成防刷
幂等和防刷不是一回事:
- 幂等:同一请求多次执行,结果一致
- 防刷/限流:限制单位时间内请求次数
不要用幂等机制去替代限流,否则规则会变得很奇怪。
坑 6:只做 Redis 幂等,不做数据库唯一约束
这也是很现实的问题。Redis 方案能挡住大部分重复请求,但在极端情况下,底层数据库仍应有最后一道防线。
例如订单号、业务流水号,最好加唯一索引:
CREATE TABLE t_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
UNIQUE KEY uk_order_no (order_no)
);
如果你的业务天然有唯一业务号,比如“支付流水号”“外部请求号”,更应该落到数据库唯一约束上。
常见排查思路
当你怀疑幂等失效时,可以按这个顺序查:
- 客户端是否真的复用了同一个 Idempotency-Key
- Redis 是否成功写入了 PROCESSING
- TTL 是否足够长
- 异常时是否清理 key
- 是否有多实例部署,但 Redis 配置不一致
- 业务层/数据库层是否还有其他重复写入入口
推荐观察日志字段
建议在日志里打印:
- traceId
- idempotencyKey
- 请求 URI
- Redis key
- 幂等状态
- 业务结果
例如:
traceId=xxx idempotencyKey=demo-order-001 uri=/orders status=PROCESSING
traceId=xxx idempotencyKey=demo-order-001 uri=/orders status=SUCCESS
这样线上排查会轻松很多。
安全/性能最佳实践
幂等方案不仅要“能用”,还要考虑安全和性能。
1. 不要信任任意客户端传入的 key
如果接口完全暴露给外部,攻击者可能构造大量随机 key,造成 Redis 键膨胀。
建议:
- key 长度限制,比如不超过 64 或 128
- 校验格式,只允许字母、数字、短横线、下划线
- 搭配限流使用
示例校验:
if (!key.matches("^[a-zA-Z0-9_-]{1,64}$")) {
return ApiResponse.fail("非法的幂等键");
}
2. key 最好带业务前缀
不要直接用裸 key,建议:
idempotent:createOrder:demo-order-001
好处:
- 便于排查
- 不同业务隔离
- 避免 key 冲突
3. 核心写入一定要用原子操作
这里我们用的是 Redis 的 SETNX + EXPIRE 一体能力,也就是 Spring 的:
setIfAbsent(key, value, expire, TimeUnit.SECONDS)
这很关键。如果你拆成:
SETNXEXPIRE
中间一旦异常,就可能产生没有过期时间的死 key。
4. 成功结果别存太大
如果返回体很大,直接把整个响应 JSON 放 Redis,会导致:
- 网络开销变大
- Redis 内存增高
- 序列化/反序列化耗时上升
建议:
- 只缓存必要字段
- 或缓存业务唯一 ID,再回查数据库
例如只存:
{
"status": "SUCCESS",
"orderNo": "ORD-xxx"
}
5. 关键业务要 Redis + DB 双保险
适用场景:
- 支付
- 发券
- 扣库存
- 清结算
推荐组合:
- Redis 做请求入口幂等拦截
- 数据库唯一索引做最终一致性兜底
这样即使 Redis 因为异常、网络抖动、TTL 配置问题出现漏网之鱼,数据库也能守住最后一道线。
6. 对长事务接口考虑“处理中返回码”
对于执行时间长的接口,如果重复请求一律返回错误,不一定友好。更合理的是:
- 返回特定状态码或业务码,表示“处理中”
- 客户端稍后轮询结果接口
这样体验比“请勿重复提交”更可控。
方案边界与取舍
这个方案并不是万能的,适合的前提是:
- 请求能够识别“同一次业务操作”
- 客户端或上游系统能够稳定提供唯一 key
- 业务允许短期缓存处理结果
不太适合的情况:
- 完全无状态、无唯一请求标识
- 幂等判断必须依赖复杂业务快照比对
- 超长流程跨多个系统且持续数小时以上
这类情况就需要进一步引入:
- 业务唯一号
- 状态机
- 数据库去重表
- 消息去重表
也就是说,Redis 幂等适合做第一层快速拦截,不一定能替代完整的业务一致性设计。
可进一步优化的方向
如果你准备把这个方案用于生产,我建议继续往下做两点增强。
优化 1:Lua 脚本增强原子性
比如你想把“读取状态 + 抢占处理”做成一步,可以用 Lua 脚本,减少并发下的窗口问题。
优化 2:结果查询接口
对于耗时任务,可以设计:
- 提交接口:只负责发起任务并返回请求号
- 查询接口:根据请求号查询处理状态和结果
这样幂等与异步处理会更自然。
总结
这套 Spring Boot + Redis 接口幂等方案,核心思路并不复杂:
- 客户端携带唯一幂等键
- Redis 原子写入“处理中”状态
- 业务执行成功后写入“成功”状态和结果
- 相同 key 的重复请求直接返回历史结果或处理中提示
如果你只想记住最关键的几点,我建议记这 5 条:
- 幂等键必须稳定复用
- Redis 写入必须是原子操作
- 至少区分 PROCESSING 和 SUCCESS
- 异常时及时清理,成功时缓存结果
- 关键业务务必叠加数据库唯一约束兜底
最后给一个实战建议:
- 普通表单重复提交:Redis 幂等就够用
- 下单、支付、扣减库存:Redis 幂等 + DB 唯一约束
- 回调、消息消费:优先使用业务唯一流水号做幂等
如果你的系统已经有分布式部署、前端重试、第三方回调这些场景,那么这套方案非常值得尽早接入。它不花哨,但非常实用,而且一旦缺失,线上问题往往来得很直接。