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

《Spring Boot 中基于 Redis + 注解实现接口幂等性的实战方案》

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

Spring Boot 中基于 Redis + 注解实现接口幂等性的实战方案

接口幂等性这个话题,很多人第一次接触时会觉得“是不是重复提交拦一下就行了”。但真到了线上,你会发现事情没这么简单:用户手抖连续点两次、前端超时重试、网关重放、消息补偿、支付回调重复通知……这些都可能让一个“应该只成功一次”的接口,被执行多次。

这篇文章我不讲太虚的概念,直接带你用 Spring Boot + Redis + 自定义注解 做一套实用方案。目标是:

  • 业务代码尽量少侵入
  • 对 Controller 方法做到开箱即用
  • 能应对高并发下的重复请求
  • 代码可运行、好排查、方便扩展

背景与问题

先看一个很常见的场景:下单接口。

用户点击“提交订单” -> 网络卡顿 -> 用户又点一次 -> 服务端创建两笔订单

或者:

客户端没收到响应 -> 自动重试 -> 服务端重复扣库存

如果接口没有幂等控制,后果通常是:

  • 重复下单
  • 重复扣款
  • 重复发券
  • 重复发送通知
  • 状态机推进错乱

什么叫接口幂等性?

简单说就是:

同一个请求,无论执行一次还是多次,最终结果应当一致。

注意,这里的“一致”不一定是每次都重新处理,而是后续重复请求不能把系统搞乱

常见实现方式

幂等性常见做法有几类:

  1. 数据库唯一索引
    • 适合强业务唯一性,例如订单号、流水号唯一
    • 简单可靠,但不够通用
  2. Token 机制
    • 请求前先拿 token,提交时消费 token
    • 适合防表单重复提交
  3. 状态机控制
    • 通过状态流转避免重复处理
    • 常见于支付、审核、工作流
  4. 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

核心原理

先把整体思路说清楚,再写代码会更顺。

方案思路

  1. 在接口方法上加一个自定义注解,比如 @Idempotent
  2. AOP 拦截这个注解
  3. 根据请求特征生成一个幂等 key
  4. 用 Redis 的 SETNX(即 setIfAbsent)尝试写入
  5. 如果写入成功,说明是第一次请求,允许执行业务
  6. 如果写入失败,说明短时间内已有相同请求,直接拦截

为什么 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
  • 序列化异常

原因

  • 参数里包含 HttpServletRequestHttpServletResponse、文件流
  • 参数对象有循环引用
  • 文件上传接口不适合直接序列化全部参数

建议

生成 key 时过滤掉这些类型,只保留业务参数。


安全/性能最佳实践

这部分是从“能跑”走向“能上线”。

1. 优先使用业务唯一号

如果你的接口本身天然有唯一请求号,比如:

  • requestId
  • bizNo
  • orderNo

那最好的办法是直接把它作为幂等核心键,而不是仅依赖参数摘要。

比如:

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. 易于扩展

后面你可以继续给注解加属性,比如:

  • keyStrategy
  • scene
  • requireUser
  • spEl

甚至支持:

@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 + 注解实现接口幂等性 的实战方案,核心链路是:

  1. 自定义 @Idempotent 注解
  2. 用 AOP 拦截目标接口
  3. 基于请求信息生成幂等 key
  4. 使用 Redis SETNX + 过期时间 做原子去重
  5. 重复请求直接拦截

这套方案特别适合:

  • 防止按钮重复点击
  • 前端短时间重复提交
  • 网关或客户端瞬时重试
  • 多实例部署下的统一幂等控制

但它也有边界:

  • 它更偏“短时防重”,不是所有业务场景下的“永久语义幂等”
  • 对支付、转账、回调类接口,最好结合业务唯一号、数据库唯一约束,甚至结果缓存一起做
  • Redis 只是第一道防线,不是最终一致性的唯一保障

如果你现在就要落地,我的建议很直接:

  • 普通提交类接口:先上本文这套注解 + Redis 方案
  • 关键资金类接口:必须叠加业务流水号 + DB 唯一约束
  • 要提升体验的场景:重复请求尽量返回历史结果,而不是简单报错

先把 80% 的重复请求问题解决掉,再逐步演进成更完整的幂等体系,这通常是成本和效果最平衡的做法。


分享到:

上一篇
《从 0 到可维护:基于开源项目的二次开发与本地部署实践指南》
下一篇
《安卓逆向实战:基于 Frida 与 JADX 的登录参数加密链路定位与复现》