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

《Java Web 开发中基于 Spring Boot + Redis 实现接口幂等性的实战方案》

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

Java Web 开发中基于 Spring Boot + Redis 实现接口幂等性的实战方案

在 Java Web 项目里,“幂等性”几乎是一个迟早会碰到的话题。尤其是下单、支付、发券、创建工单这类写操作接口,只要前端重复点击、网络超时重试、消息重复投递,后台就可能出现“同一业务被执行多次”的问题。

我自己做业务系统时,最常见的翻车现场就是:

  • 用户点了两次“提交订单”,生成了两笔订单
  • 支付回调被重复通知,余额被加了两次
  • 前端因为超时自动重试,服务端又完整执行了一遍

这篇文章不讲太虚的概念,直接带你用 Spring Boot + Redis 实现一套可落地的接口幂等方案,并把常见坑、排查方法和性能安全建议一起说清楚。


背景与问题

先明确一个常见误区:

幂等性不是“接口只能调用一次”,而是“同样的请求调用多次,结果应当和调用一次一致”。

比如创建订单接口:

  • 用户第一次请求:成功创建订单
  • 用户第二次发起完全相同的请求:不应该再创建新订单,而应该返回第一次的处理结果,或者明确告诉客户端“请求已处理”

为什么会出现重复请求

常见来源有:

  1. 前端重复点击
  2. 网关/客户端超时重试
  3. 消息队列重复消费
  4. 第三方回调重复通知
  5. 分布式环境下并发请求同时落到不同实例

如果你只是做一个 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

核心原理

接口幂等,最常见的实现思路是:

  1. 客户端为每次业务请求携带一个 唯一幂等键,例如 Idempotency-Key
  2. 服务端先去 Redis 判断这个键是否存在
  3. 如果不存在,则原子写入 Redis,标记“处理中”
  4. 业务执行成功后,更新状态为“已完成”,并缓存处理结果
  5. 如果后续收到相同的幂等键:
    • 若状态是“处理中”,返回“请勿重复提交”
    • 若状态是“已完成”,直接返回之前结果

一句话理解

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. 幂等键从哪里来

推荐优先级:

  1. 前端生成唯一请求号
  2. 服务端基于业务字段生成
  3. 消息消费使用消息唯一 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:执行

会有一个问题:

  • 第一次请求成功了
  • 第二次请求来了
  • 你只能告诉它“重复提交”,但拿不到第一次结果

这种实现太粗糙,用户体验也差。建议至少存储 PROCESSINGSUCCESS 两种状态。


坑 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)
);

如果你的业务天然有唯一业务号,比如“支付流水号”“外部请求号”,更应该落到数据库唯一约束上。


常见排查思路

当你怀疑幂等失效时,可以按这个顺序查:

  1. 客户端是否真的复用了同一个 Idempotency-Key
  2. Redis 是否成功写入了 PROCESSING
  3. TTL 是否足够长
  4. 异常时是否清理 key
  5. 是否有多实例部署,但 Redis 配置不一致
  6. 业务层/数据库层是否还有其他重复写入入口

推荐观察日志字段

建议在日志里打印:

  • 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)

这很关键。如果你拆成:

  1. SETNX
  2. EXPIRE

中间一旦异常,就可能产生没有过期时间的死 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 接口幂等方案,核心思路并不复杂:

  1. 客户端携带唯一幂等键
  2. Redis 原子写入“处理中”状态
  3. 业务执行成功后写入“成功”状态和结果
  4. 相同 key 的重复请求直接返回历史结果或处理中提示

如果你只想记住最关键的几点,我建议记这 5 条:

  1. 幂等键必须稳定复用
  2. Redis 写入必须是原子操作
  3. 至少区分 PROCESSING 和 SUCCESS
  4. 异常时及时清理,成功时缓存结果
  5. 关键业务务必叠加数据库唯一约束兜底

最后给一个实战建议:

  • 普通表单重复提交:Redis 幂等就够用
  • 下单、支付、扣减库存:Redis 幂等 + DB 唯一约束
  • 回调、消息消费:优先使用业务唯一流水号做幂等

如果你的系统已经有分布式部署、前端重试、第三方回调这些场景,那么这套方案非常值得尽早接入。它不花哨,但非常实用,而且一旦缺失,线上问题往往来得很直接。


分享到:

上一篇
《从零搭建并二次开发开源权限管理系统:基于 Casbin 的中级实战指南》
下一篇
《Web3 中级实战:从零搭建基于智能合约的钱包登录与链上身份认证系统》