跳转到内容
123xiao | 无名键客

《Java Web 开发中基于 Spring Boot + Redis 实现接口幂等与重复提交防护实战》

字数: 0 阅读时长: 1 分钟

Java Web 开发中基于 Spring Boot + Redis 实现接口幂等与重复提交防护实战

在 Java Web 项目里,“重复提交”几乎是每个后端都会遇到的问题:
用户连点两次按钮、前端超时重试、网关重复转发、消息补偿、甚至爬虫误调用,都会让同一个业务请求被执行多次。

如果接口刚好是“创建订单”“扣减库存”“发放优惠券”这类操作,多执行一次,后果往往不是日志多一条那么简单,而是数据错乱、资金风险、库存穿透

这篇文章我不讲太空泛的概念,而是带你从一个常见的 Spring Boot 项目出发,用 Redis + AOP + 自定义注解 实现一套可运行的接口幂等与重复提交防护方案。你看完可以直接改到自己的项目里。


背景与问题

先明确两个很容易混淆的概念:

  • 重复提交防护:主要解决“短时间内同一个请求被提交多次”的问题
  • 接口幂等:主要解决“同一个请求执行一次和执行多次,结果应该一致”的问题

它们相关,但不完全一样。

常见重复请求来源

我在项目里最常见的有这几类:

  1. 用户手抖连点
  2. 前端请求超时后自动重试
  3. 移动端弱网导致同一请求重复发送
  4. 网关、代理层重放
  5. 消费端补偿或定时任务重复触发
  6. 分布式环境下,多实例同时处理同一业务

为什么不能只靠前端防抖

很多团队会先做按钮置灰、前端防抖,这当然有用,但只能算第一层。

因为:

  • 前端逻辑可以被绕过
  • 抓包工具可以直接重放请求
  • 弱网重试不受前端按钮控制
  • 分布式系统里,真正的“一次性语义”必须后端兜底

所以,前端防抖可以做,但后端幂等必须有。


核心原理

这类问题常见有三种处理思路:

  1. 数据库唯一约束
  2. 业务状态机控制
  3. Redis 分布式防重令牌/幂等键

这篇重点讲第 3 种,因为它实现成本低、性能高,也适合大多数 Web 接口。

方案思路

核心流程很简单:

  • 客户端请求到达接口
  • 服务端根据“用户 + 接口 + 请求参数”生成一个唯一幂等 Key
  • 用 Redis 执行 SET key value NX EX seconds
  • 如果设置成功,说明是第一次请求,继续执行业务
  • 如果设置失败,说明短时间内这个请求已经来过,直接拦截
  • 业务执行完成后,根据策略决定是否删除 Key

这里面有两个关键点:

1. 为什么用 SET NX EX

因为它是原子操作:

  • NX:只有 key 不存在才设置成功
  • EX:设置过期时间,避免死锁/脏 key

这比“先查 Redis 再 set”安全得多。后者在并发下会产生竞态条件。

2. Key 应该怎么设计

幂等 Key 一般要包含:

  • 调用人标识:用户 ID、设备 ID、API Key 等
  • 接口路径:避免不同接口互相影响
  • 业务参数摘要:避免同一用户不同请求被错误拦截

例如:

idempotent:10001:/order/create:8f14e45fceea167a5a36dedd4bea2543

其中最后一段一般是对请求参数做摘要,比如 MD5/SHA-256。


整体流程图

flowchart TD
    A[客户端发起请求] --> B[拦截器/AOP 获取注解]
    B --> C[提取用户标识 接口路径 请求参数]
    C --> D[生成幂等 Key]
    D --> E{Redis SET NX EX 成功?}
    E -- 是 --> F[执行业务逻辑]
    F --> G[返回成功结果]
    E -- 否 --> H[拦截请求 返回重复提交提示]

前置知识与环境准备

本文示例环境:

  • JDK 8+
  • Spring Boot 2.x
  • Spring Web
  • Spring AOP
  • Spring Data Redis
  • Redis 5.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>
</dependencies>

application.yml

spring:
  redis:
    host: 127.0.0.1
    port: 6379
    timeout: 3000ms

实战代码(可运行)

下面我们做一个最常见的实现方式:

  • 自定义注解 @Idempotent
  • AOP 切面统一拦截
  • Redis 原子写入防重
  • Controller 演示订单提交接口

第一步:定义返回结果对象

package com.example.demo.common;

public class ApiResponse<T> {

    private boolean success;
    private String message;
    private T data;

    public ApiResponse() {
    }

    public ApiResponse(boolean success, String message, T data) {
        this.success = success;
        this.message = message;
        this.data = data;
    }

    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, "success", data);
    }

    public static <T> ApiResponse<T> fail(String message) {
        return new ApiResponse<>(false, message, null);
    }

    public boolean isSuccess() {
        return success;
    }

    public void setSuccess(boolean success) {
        this.success = success;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

第二步:定义业务异常

package com.example.demo.common;

public class BizException extends RuntimeException {

    public BizException(String message) {
        super(message);
    }
}

第三步:定义幂等注解

这里支持两个常用参数:

  • expireSeconds:防重时间窗
  • message:重复请求时的提示文案
package com.example.demo.idempotent;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {

    int expireSeconds() default 5;

    String message() default "请求重复,请稍后再试";
}

第四步: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 RedisIdempotentSupport {

    private final StringRedisTemplate stringRedisTemplate;

    public RedisIdempotentSupport(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);
    }

    public void delete(String key) {
        stringRedisTemplate.delete(key);
    }
}

第五步:构造幂等 Key

这个步骤很关键。很多文章只写个“按 URL 防重”,实际项目里经常不够。

我们这里把:

  • 当前用户
  • 请求 URI
  • 请求参数 JSON

拼起来做摘要。

package com.example.demo.idempotent;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;

import javax.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;

@Component
public class IdempotentKeyGenerator {

    private final ObjectMapper objectMapper = new ObjectMapper();

    public String generate(String userId, HttpServletRequest request, Object[] args) {
        String uri = request.getRequestURI();
        String argsJson = toJson(args);
        String source = userId + ":" + uri + ":" + argsJson;
        String md5 = DigestUtils.md5DigestAsHex(source.getBytes(StandardCharsets.UTF_8));
        return "idempotent:" + userId + ":" + uri + ":" + md5;
    }

    private String toJson(Object[] args) {
        try {
            return objectMapper.writeValueAsString(filterArgs(args));
        } catch (Exception e) {
            return Arrays.toString(args);
        }
    }

    private Object[] filterArgs(Object[] args) {
        return Arrays.stream(args)
                .filter(arg -> !(arg instanceof HttpServletRequest))
                .filter(arg -> !(arg instanceof javax.servlet.http.HttpServletResponse))
                .toArray();
    }
}

这里我故意过滤了 HttpServletRequest/Response,因为它们序列化后会很乱,而且没业务意义。


第六步:AOP 切面实现统一拦截

package com.example.demo.idempotent;

import com.example.demo.common.BizException;
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;
import java.util.UUID;

@Component
@Aspect
public class IdempotentAspect {

    private final RedisIdempotentSupport redisSupport;
    private final IdempotentKeyGenerator keyGenerator;
    private final HttpServletRequest request;

    public IdempotentAspect(RedisIdempotentSupport redisSupport,
                            IdempotentKeyGenerator keyGenerator,
                            HttpServletRequest request) {
        this.redisSupport = redisSupport;
        this.keyGenerator = keyGenerator;
        this.request = request;
    }

    @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 userId = getCurrentUserId();
        String key = keyGenerator.generate(userId, request, joinPoint.getArgs());
        String value = UUID.randomUUID().toString();

        boolean locked = redisSupport.tryLock(key, value, idempotent.expireSeconds());
        if (!locked) {
            throw new BizException(idempotent.message());
        }

        return joinPoint.proceed();
    }

    private String getCurrentUserId() {
        String userId = request.getHeader("X-User-Id");
        if (userId == null || userId.trim().isEmpty()) {
            userId = "anonymous";
        }
        return userId;
    }
}

这里为什么不在 finally 里删 Key?

这是个非常重要的点。

如果你的目的是“防止短时间内重复点击”,那通常不应该立即删除 Key
因为你一删掉,用户第二次请求马上又能进来,防重窗口就失效了。

也就是说,这套代码更适合:

  • 防连点
  • 防短时重复提交
  • 防前端重试风暴

如果你追求的是“业务级严格幂等”,比如同一个订单号永远只能创建一次,那么 key 的生命周期和业务状态就要更严格绑定,后面我会讲。


第七步:统一异常处理

package com.example.demo.common;

import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BizException.class)
    public ApiResponse<Void> handleBizException(BizException e) {
        return ApiResponse.fail(e.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public ApiResponse<Void> handleException(Exception e) {
        return ApiResponse.fail("系统异常:" + e.getMessage());
    }
}

第八步:编写示例接口

模拟一个创建订单接口。
这里为了演示效果,我让线程 sleep 2 秒。

package com.example.demo.order;

public class OrderCreateRequest {

    private String productId;
    private Integer quantity;
    private Long amount;

    public String getProductId() {
        return productId;
    }

    public void setProductId(String productId) {
        this.productId = productId;
    }

    public Integer getQuantity() {
        return quantity;
    }

    public void setQuantity(Integer quantity) {
        this.quantity = quantity;
    }

    public Long getAmount() {
        return amount;
    }

    public void setAmount(Long amount) {
        this.amount = amount;
    }
}
package com.example.demo.order;

import com.example.demo.common.ApiResponse;
import com.example.demo.idempotent.Idempotent;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;

@RestController
@RequestMapping("/orders")
public class OrderController {

    @PostMapping("/create")
    @Idempotent(expireSeconds = 5, message = "订单正在处理中,请勿重复提交")
    public ApiResponse<String> createOrder(@RequestBody OrderCreateRequest request) throws Exception {
        Thread.sleep(2000);
        String orderNo = "ORD-" + UUID.randomUUID().toString().replace("-", "");
        return ApiResponse.success(orderNo);
    }
}

请求时序图

sequenceDiagram
    participant C as Client
    participant A as AOP
    participant R as Redis
    participant S as OrderService

    C->>A: POST /orders/create
    A->>R: SET key value NX EX 5
    alt 首次请求
        R-->>A: OK
        A->>S: 执行业务逻辑
        S-->>A: 返回订单号
        A-->>C: success
    else 重复请求
        R-->>A: null/false
        A-->>C: 请求重复,请稍后再试
    end

启动与验证

启动 Redis,然后运行 Spring Boot 项目。

测试请求

curl --location --request POST 'http://localhost:8080/orders/create' \
--header 'Content-Type: application/json' \
--header 'X-User-Id: 1001' \
--data-raw '{
  "productId": "P10001",
  "quantity": 1,
  "amount": 19900
}'

如果你在 5 秒内连续发送两次相同请求:

  • 第一次:成功创建订单
  • 第二次:返回“订单正在处理中,请勿重复提交”

逐步验证清单

你可以按这个顺序验证,比较稳:

  1. 同一用户 + 同一参数 + 5 秒内重复提交
    预期:第二次被拦截

  2. 同一用户 + 不同参数
    预期:允许通过

  3. 不同用户 + 相同参数
    预期:允许通过

  4. 同一请求在 5 秒后再次提交
    预期:允许通过

  5. 并发压测同一请求
    预期:仅一个成功进入业务,其余被拦截


核心原理再深入一层:防重提交 vs 业务幂等

上面的方案其实更偏“时间窗防重”。

但在实际项目里,我建议你分两层看:

第一层:接口层防重

目标是挡住这些请求:

  • 短时间重复点击
  • 前端重试
  • 网络抖动重放

常用方式:

  • Redis SET NX EX
  • 时间窗一般 3~10 秒

第二层:业务层幂等

目标是保证业务结果只生效一次,比如:

  • 同一个支付流水只能处理一次
  • 同一个订单号只能创建一次
  • 同一张券不能重复发放

常用方式:

  • 业务唯一单号
  • 数据库唯一索引
  • 状态机流转校验
  • 幂等表记录请求号

这两层最好一起上
只做 Redis 防重,在极端情况下仍可能不够;只做数据库唯一约束,用户体验又会比较差。


更稳妥的业务幂等模型

如果你的业务要求更强,比如“同一个请求号只能执行一次,而且要能返回第一次结果”,推荐引入 Idempotency-Key 模型。

典型流程

  1. 客户端生成一个唯一请求号
  2. 请求头带上 Idempotency-Key
  3. 服务端先检查这个 key 是否处理过
  4. 已处理则直接返回历史结果
  5. 未处理则加锁执行,并缓存结果

这个模型在支付、下单、回调处理里更常见。

stateDiagram-v2
    [*] --> INIT
    INIT --> PROCESSING: 收到新请求并加锁
    PROCESSING --> SUCCESS: 业务成功并保存结果
    PROCESSING --> FAIL: 业务失败并记录状态
    SUCCESS --> SUCCESS: 相同请求号再次到达,直接返回历史结果
    FAIL --> FAIL: 根据策略决定是否允许重试

常见坑与排查

这一部分很重要,很多方案不是不会写,而是上线后会在细节上翻车。

1. 用 if (exists) return; else set 代替原子操作

这是经典坑。

错误写法:

if (!redisTemplate.hasKey(key)) {
    redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.SECONDS);
    // 执行业务
}

问题在于:

  • 两个并发请求都可能在 hasKey 时看到不存在
  • 然后都 set 成功前进入业务
  • 幂等彻底失效

必须使用原子 setIfAbsent


2. Key 设计过粗,误伤正常请求

比如只按用户 ID + URL 做 key:

idempotent:1001:/orders/create

那用户连续下两笔不同订单,也会被拦掉。

解决办法:

  • 把业务参数摘要带进去
  • 或者由前端传唯一业务请求号

3. Key 设计过细,导致拦不住

如果参数里包含这些字段:

  • 时间戳
  • nonce
  • 随机串
  • 排序不稳定的 JSON

那么同一个业务请求每次算出来的 key 都不一样,防重等于没做。

解决办法:

  • 只提取真正有业务意义的字段参与摘要
  • 对 JSON 做字段排序或固定序列化
  • 排除随机字段

这个坑我自己踩过一次:前端每次都会带一个 requestTime,结果同样的下单请求怎么都防不住。


4. 过期时间设置不合理

  • 过短:业务还没执行完,key 已经失效,重复请求又进来了
  • 过长:用户真正想再次提交时被误拦

经验建议:

  • 普通表单提交:3~10 秒
  • 较慢业务接口:10~30 秒
  • 强幂等业务:不要只靠过期时间,要结合业务唯一号

5. 业务失败后是否允许重试

这点没有统一答案,要看业务。

场景 A:失败后允许马上重试

例如:

  • 下单前参数校验失败
  • 外部接口临时超时

可以考虑在失败时删除 Key,但要谨慎。

场景 B:失败后也不允许立刻重试

例如:

  • 支付处理中
  • 库存锁定中
  • 正在调用不可重入外部系统

这时通常保留 Key 到过期更安全。

建议:按接口做策略区分,不要一刀切。


6. 分布式环境下只用本地锁

单机 synchronizedReentrantLock 在单实例测试里看起来有效,但一上多实例部署就失效。

原因很简单:

  • A 机器锁不住 B 机器
  • 请求被负载均衡打到不同节点就重复执行了

所以跨实例的防重,至少要 Redis、数据库或分布式协调组件


7. Redis 序列化和 key 可读性问题

如果你用的是 RedisTemplate<Object, Object>,可能会得到一堆不直观的序列化 key,不利于排查。

更推荐:

  • StringRedisTemplate
  • key 统一前缀
  • value 记录请求标识或 traceId

例如:

idempotent:1001:/orders/create:ae34c...

排查时一眼就能看懂。


安全/性能最佳实践

幂等不只是“能用”,上线后还要考虑安全性和吞吐量。

1. 优先使用客户端业务请求号

对于关键接口,推荐客户端在请求头中传:

Idempotency-Key: 7d3c7d18-7f74-4f57-9ef6-c0dbbaf2c001

好处:

  • 语义清晰
  • 避免服务端自己拼接参数的不确定性
  • 便于问题追踪和日志串联

2. 服务端要校验用户身份与幂等 key 绑定

不要只看 Idempotency-Key 本身,还要结合:

  • 用户 ID
  • 租户 ID
  • 应用 ID

否则理论上可能出现 key 碰撞或恶意复用。

推荐 key 结构:

idempotent:{tenantId}:{userId}:{api}:{idempotencyKey}

3. 不要把整个大对象都序列化参与摘要

大参数会带来:

  • CPU 开销增加
  • 网络开销增加
  • key 不稳定

建议只挑核心业务字段,比如:

  • 商品 ID
  • 数量
  • 金额
  • 业务单号

4. 配合数据库唯一索引兜底

这是我最推荐的组合:

  • Redis:负责接口层快速拦截
  • 数据库唯一约束:负责最终一致性兜底

例如订单表可以对 biz_order_no 建唯一索引:

create table t_order (
    id bigint primary key auto_increment,
    biz_order_no varchar(64) not null,
    user_id bigint not null,
    amount bigint not null,
    create_time datetime not null,
    unique key uk_biz_order_no (biz_order_no)
);

即使 Redis 因异常失效,数据库仍能挡住重复插入。


5. 日志一定要带幂等 key

建议打印这些字段:

  • traceId
  • userId
  • requestUri
  • idempotentKey
  • 请求参数摘要
  • Redis 返回结果
  • 执行耗时

这样线上排查时非常省时间。


6. 注意热点 key 与恶意刷接口

如果某些接口被恶意重复调用,Redis 会承受较高访问压力。可以结合:

  • 限流
  • 用户/IP 维度风控
  • 网关层拦截
  • 验签机制

幂等不是安全防护的全部,它只是其中一层。


7. 超关键业务建议保存“请求结果”

像支付回调、券发放、订单创建这类高价值接口,更好的做法是:

  • 用请求号建状态记录
  • 成功后缓存/落库结果
  • 相同请求号再次到达,直接返回第一次结果

这比单纯“报重复请求”更友好,也更符合幂等语义。


一个更贴近生产的优化建议

上面的示例已经能跑,但如果你准备上生产,我建议至少再补三点:

优化 1:从请求头优先取幂等号

优先级可以这样设计:

  1. Idempotency-Key
  2. 业务单号
  3. 用户 + URI + 参数摘要

这样通用性更强。

优化 2:对参数做稳定化处理

比如:

  • Map 按 key 排序
  • 忽略空值字段
  • 排除时间戳、随机数

避免“同一个业务请求,不同 JSON 顺序算出不同 key”。

优化 3:按接口配置失败策略

例如给注解再加一个参数:

boolean deleteKeyOnException() default false;

这样你可以根据业务控制:

  • 失败后保留 key
  • 失败后删除 key 允许重试

方案适用边界

这套 Redis 防重方案非常实用,但不是万能的。

适合场景

  • 表单重复提交防护
  • 短时间重复点击
  • 创建类接口的快速幂等保护
  • 分布式环境下的轻量级防重

不适合单独使用的场景

  • 强一致支付核心链路
  • 必须返回首次处理结果的接口
  • 跨系统长事务幂等
  • 需要永久防重的业务

这些场景应结合:

  • 唯一业务单号
  • 数据库唯一索引
  • 幂等状态表
  • 消息去重机制
  • 状态机控制

总结

我们这篇文章落地了一套基于 Spring Boot + Redis 的接口幂等与重复提交防护方案,核心点可以浓缩成下面几条:

  1. 后端幂等不能只靠前端防抖
  2. Redis 防重要用原子 SET NX EX
  3. 幂等 key 设计决定了方案是否好用
  4. 短时防重和业务级幂等是两层能力
  5. 生产环境最好配合数据库唯一约束兜底
  6. 关键接口建议引入 Idempotency-Key 与结果复用机制

如果你现在要在项目里快速落地,我建议这么做:

  • 普通创建接口:先上本文这种 @Idempotent + Redis 方案
  • 订单/支付/券发放:再加业务请求号与数据库唯一索引
  • 超关键链路:引入幂等状态表,支持结果查询与重复返回

最后说一句比较实战的话:
幂等从来不是一个注解就能彻底解决的问题,它本质上是“接口层防重 + 业务层唯一性 + 数据层兜底”的组合拳。

如果你把这三层补齐,很多重复提交问题基本就稳了。


分享到:

上一篇
《Node.js 中基于 Worker Threads 与消息队列的高并发任务处理实战-73》
下一篇
《Spring Boot 中基于 JWT 与 Spring Security 的前后端分离认证授权实战指南-39》