Spring Boot 接口幂等性完整实现指南

Spring Boot幂等性实战指南

【投稿赢 iPhone 17】「我的第一个开源项目」故事征集:用代码换C位出道! 10w+人浏览 1.6k人参与

适用对象:Java 后端开发工程师、架构师
目标:彻底理解并实现生产级接口幂等性,杜绝重复请求导致的数据错误
核心理念“同一个请求,多次执行,结果一致”


✅ 一、什么是接口幂等性?(Idempotency)

🔍 定义

接口幂等性(Idempotency)是指:客户端对同一接口发起多次相同请求,服务端的处理结果与仅执行一次完全一致,不会因重复调用产生副作用(如重复扣款、重复下单、重复发短信)

📌 通俗理解

  • 幂等操作:像“按一次开关灯”——无论按多少次,灯的状态只有“开”或“关”两种,不会越按越亮。
  • 非幂等操作:像“按一次加1按钮”——按10次,结果是+10,不是+1。

🧩 举例说明

场景是否幂等原因
GET /api/users/1001✅ 是查询操作,不修改数据,多次调用结果相同
POST /api/orders❌ 否每次调用都会创建新订单,重复调用 = 重复下单
PUT /api/users/1001✅ 是(若设计正确)修改用户信息,即使多次提交,最终状态一致
DELETE /api/users/1001✅ 是(软删除)删除一次后,再次删除仍为“已删除”状态
POST /api/payments❌ 否重复调用 = 重复支付,严重事故

⚠️ 重要结论
幂等性不是“默认具备”的能力,而是必须主动设计和实现的特性!


✅ 二、哪些接口需要实现幂等性?

必须实现幂等性的接口(写操作)

接口类型示例为什么需要幂等?
创建类(POST)/api/orders/api/payments/api/messages网络抖动、客户端重试、浏览器刷新导致重复创建
更新类(PUT)/api/users/{id}/api/products/{id}客户端误点多次,导致数据被多次覆盖或状态混乱
删除类(DELETE)/api/orders/{id}(物理删除)重复删除不应报错或产生副作用(推荐软删除)
转账/扣款类/api/wallet/withdraw/api/transfer重复调用 = 重复扣款,资金损失,法律风险
回调类(异步通知)支付宝/微信支付回调支付平台会多次重试回调,若未幂等,会重复发货/发券

💡 行业标准
所有非查询类接口(非 GET)都应默认视为“非幂等”,必须主动设计幂等机制!


✅ 三、哪些接口天然具有幂等性?

天然幂等的操作(无需额外设计)

接口类型说明为什么天然幂等
GET查询操作,如 /api/users/1001不修改任何状态,只读取数据,多次调用结果一致
HEAD获取响应头,不返回体同 GET,仅获取元信息
OPTIONS获取接口支持的方法仅返回元数据,无副作用
安全的 PUT用完整资源替换(全量更新)如 PUT /api/users/1001 传入完整 JSON,多次调用最终状态一致
安全的 DELETE软删除(逻辑删除)UPDATE users SET deleted=true WHERE id=1001,重复执行仍为 deleted=true

❌ 注意:不是所有 PUT 都幂等!

# ❌ 非幂等 PUT(部分更新)
PUT /api/users/1001
{ "age": 25 }  # 每次只传 age,其他字段被清空 → 多次调用数据丢失!

# ✅ 幂等 PUT(全量更新)
PUT /api/users/1001
{
  "id": 1001,
  "name": "张三",
  "email": "zhangsan@example.com",
  "age": 25,
  "phone": "13800138000"
}  # 无论调用多少次,最终都是这个完整状态

建议
生产环境中,优先使用全量更新(PUT),或使用 PATCH + 语义明确的更新指令(如 {"op": "add", "path": "/age", "value": 25})来保证幂等。


✅ 四、实现接口幂等性的主流方案对比

方案实现方式优点缺点适用场景推荐度
1. 数据库唯一索引在表中加 idempotency_key 字段,设为 UNIQUE数据强一致,无需中间件性能差,高并发下锁竞争严重,扩展性差小流量系统、简单场景⭐⭐
2. 状态字段判断业务表加 status 字段,如 processed=true,先查后改实现简单并发下存在“检查-更新”竞态条件(Race Condition),不安全不推荐用于金融、支付
3. Token 机制(一次性)服务端生成 token,客户端携带,使用后失效可防重放服务端需存储 token,内存/DB 压力大,不支持分布式登录、验证码等场景⭐⭐
4. Redis 缓存 + 客户端 Key客户端生成 UUID 作为幂等键,服务端用 Redis 缓存结果✅ 高性能、分布式、可追溯、支持重试依赖 Redis,需配置 TTL推荐用于所有写接口⭐⭐⭐⭐⭐
5. MQ 消费幂等消息队列消费端做幂等校验(如 Kafka 消费)解耦、异步需在消费者层实现,复杂度高消息驱动系统⭐⭐⭐⭐

🚫 不推荐方案:前端防重复点击、按钮禁用、setTimeout 防抖 —— 这些完全无法拦截网络层重试、API 被恶意调用!


✅ 五、推荐方案:Idempotency Key + Redis 缓存(业界标准)

为什么推荐?

  • 客户端可控:由前端/客户端生成 UUID,服务端不生成,避免单点
  • 高性能:Redis 内存读写,QPS > 10万+
  • 分布式支持:Redis 集群可横向扩展
  • 可追溯:日志中可查 key,便于审计
  • 支持重试:HTTP 重试自动幂等,用户体验好
  • 无锁竞争:Redis 是单线程,原子操作

业界实践

  • 支付宝:支付请求必须携带 out_biz_no(幂等键)
  • 微信支付out_trade_no 为幂等键
  • AWS S3x-amz-idempotency-token
  • Google CloudX-Idempotency-Key

✅ 六、完整实现方案(含详细中文注释示例)

本方案包含:

  • 客户端生成幂等键
  • 服务端拦截器自动校验
  • Redis 缓存响应结果
  • AOP 切面统一处理
  • 数据库唯一索引兜底(双重保障)

📁 1. 客户端:生成并携带幂等键(前端/调用方)

前端示例(JavaScript)

// 生成 UUID(客户端)
function generateIdempotencyKey() {
  return 'uuid-' + Math.random().toString(36).substr(2, 9);
}

// 发起支付请求(携带幂等键)
async function pay(amount) {
  const idempotencyKey = generateIdempotencyKey();
  localStorage.setItem('lastIdempotencyKey', idempotencyKey); // 保存用于重试

  const response = await fetch('/api/v1/payments', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey, // ✅ 关键:通过 Header 传递幂等键
    },
    body: JSON.stringify({
      userId: 1001,
      amount: amount,
      orderId: 'ORD-20240520-001'
    })
  });

  return response.json();
}

// 用户点击“支付”按钮,若失败自动重试(带相同 key)
document.getElementById('payBtn').addEventListener('click', async () => {
  try {
    const result = await pay(99.99);
    console.log('支付成功', result);
  } catch (err) {
    // 网络错误,自动重试(使用相同幂等键)
    setTimeout(() => {
      console.log('网络异常,自动重试...');
      pay(99.99); // 重试,但 key 相同 → 服务端自动幂等
    }, 2000);
  }
});

客户端最佳实践

  • 使用 UUID v4(随机唯一)
  • 重试时必须复用原始 key,不能重新生成
  • 将 key 存入 localStoragesessionStorage,防止页面刷新丢失

📁 2. 服务端:定义幂等性注解(@Idempotent)

package com.example.demo.annotation;

import java.lang.annotation.*;

/**
 * 幂等性控制注解
 * 标记在 Controller 方法上,自动启用幂等校验
 * 所有写操作(POST/PUT/DELETE)都应使用此注解
 *
 * @author Java后端开发工程师
 */
@Target(ElementType.METHOD)      // 只能用于方法
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,供 AOP 使用
@Documented
public @interface Idempotent {

    /**
     * 幂等键在请求中的字段名
     * 默认从 HTTP Header 中获取:Idempotency-Key
     * 也可配置为 "X-Idempotency-Key"、"Idempotency-Key" 等
     */
    String keyName() default "Idempotency-Key";

    /**
     * 幂等键来源:Header 或 Body
     * 推荐使用 HEADER,因为 Body 可能被读取一次后失效(InputStream 只能读一次)
     */
    IdempotencySource source() default IdempotencySource.HEADER;

    /**
     * 幂等键缓存的过期时间(单位:秒)
     * 支付类建议 2 小时(7200s),订单类建议 24 小时(86400s)
     * 避免无限期缓存,防止内存爆炸
     */
    long ttlSeconds() default 7200; // 默认 2 小时
}

/**
 * 幂等键来源枚举
 * 说明:Header 更安全、更标准,Body 需特殊处理(见后文)
 */
enum IdempotencySource {
    HEADER, // 从 HTTP Header 获取(推荐)
    BODY    // 从 JSON 请求体获取(需封装 RequestWrapper,复杂,仅特殊场景用)
}

📁 3. 服务端:编写幂等性 AOP 切面(核心)

✅ 使用 Spring AOP 拦截所有标记了 @Idempotent 的方法,实现自动幂等控制

package com.example.demo.aspect;

import com.example.demo.annotation.Idempotent;
import com.example.demo.annotation.IdempotencySource;
import com.example.demo.response.ApiResponse;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

/**
 * 幂等性切面(AOP)
 * 核心逻辑:拦截带 @Idempotent 注解的方法,自动校验幂等键
 * 如果 Redis 中已存在该 key 的响应,则直接返回缓存结果,不再执行业务
 * 如果不存在,则执行业务,成功后将结果缓存到 Redis
 *
 * @author Java后端开发工程师
 */
@Aspect
@Component
public class IdempotencyAspect {

    private static final Logger log = LoggerFactory.getLogger(IdempotencyAspect.class);

    @Autowired
    private StringRedisTemplate redisTemplate; // Redis 操作模板

    @Autowired
    private ObjectMapper objectMapper; // 用于将响应对象序列化为 JSON 字符串

    /**
     * 切点:所有被 @Idempotent 注解标注的方法
     * 表达式说明:
     *   @annotation(com.example.demo.annotation.Idempotent):匹配带有该注解的方法
     */
    @Around("@annotation(idempotent)")
    public Object checkIdempotency(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {

        // 1. 获取当前 HTTP 请求对象(Spring 的 RequestContextHolder)
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
        HttpServletRequest request = attributes.getRequest();

        // 2. 从注解中获取配置参数
        String keyName = idempotent.keyName(); // 默认是 "Idempotency-Key"
        IdempotencySource source = idempotent.source();
        long ttlSeconds = idempotent.ttlSeconds(); // 缓存过期时间

        // 3. 根据来源(HEADER/BODY)获取幂等键
        String idempotencyKey = null;

        if (source == IdempotencySource.HEADER) {
            // ✅ 推荐方式:从 HTTP Header 中获取
            idempotencyKey = request.getHeader(keyName);
        } else if (source == IdempotencySource.BODY) {
            // ⚠️ 不推荐:需封装 RequestWrapper 读取 Body,此处简化处理
            log.warn("【幂等性】Body 方式暂未实现,建议使用 HEADER。当前请求方法:{}", joinPoint.getSignature().getName());
            idempotencyKey = request.getHeader(keyName); // 降级使用 Header
        }

        // 4. 如果未提供幂等键,放行(但建议强制要求)
        if (idempotencyKey == null || idempotencyKey.trim().isEmpty()) {
            log.warn("⚠️ 请求未携带幂等键({}),接口 {} 将直接执行,可能不幂等",
                    keyName, joinPoint.getSignature().toShortString());
            return joinPoint.proceed(); // 继续执行业务
        }

        // 5. 构造 Redis 缓存键:格式:idempotency:{key}
        String cacheKey = "idempotency:" + idempotencyKey;

        // 6. 尝试从 Redis 中读取缓存的响应结果
        String cachedResponseJson = redisTemplate.opsForValue().get(cacheKey);

        if (cachedResponseJson != null) {
            // ✅ 幂等命中:该请求已处理过,直接返回缓存结果
            log.info("✅ 幂等键 [{}] 已存在,直接返回缓存结果,避免重复处理", idempotencyKey);

            // 将 JSON 字符串反序列化为 ApiResponse 对象并返回
            // 注意:这里假设返回的是 ApiResponse<T> 类型
            try {
                ApiResponse<?> response = objectMapper.readValue(cachedResponseJson, ApiResponse.class);
                return response;
            } catch (Exception e) {
                log.error("❌ 反序列化缓存响应失败,key={}", cacheKey, e);
                // 缓存损坏,继续执行业务(兜底)
                return joinPoint.proceed();
            }
        }

        // 7. 未命中缓存:执行原始业务逻辑
        log.info("🚀 幂等键 [{}] 未缓存,开始执行业务逻辑...", idempotencyKey);
        Object result = joinPoint.proceed(); // 执行 Controller 方法

        // 8. 业务执行成功后,将响应结果缓存到 Redis(TTL)
        // 注意:仅当业务成功时才缓存,失败不缓存(下次还可重试)
        if (result instanceof ApiResponse) {
            try {
                // 将 ApiResponse 对象转为 JSON 字符串
                String resultJson = objectMapper.writeValueAsString(result);

                // 缓存到 Redis,TTL 为注解指定时间(如 7200 秒)
                redisTemplate.opsForValue().set(cacheKey, resultJson, ttlSeconds, TimeUnit.SECONDS);

                log.info("💾 幂等键 [{}] 处理成功,响应已缓存,TTL={}s", idempotencyKey, ttlSeconds);

            } catch (Exception e) {
                log.error("❌ 缓存幂等响应失败,key={}", cacheKey, e);
                // 缓存失败不影响业务,但失去幂等保障 → 生产环境建议报警
            }
        }

        // 9. 返回业务处理结果
        return result;
    }
}

关键设计说明

  • 缓存的是整个响应对象(如 ApiResponse<Payment>),而非只缓存“成功”标志
  • 缓存内容包含状态码、消息、数据,客户端可直接使用
  • 失败不缓存:避免错误结果被重复返回(如“余额不足”)
  • TTL 设置合理:2小时足够覆盖重试窗口,避免内存泄漏

📁 4. 服务端:统一响应结构(ApiResponse)

所有接口返回统一格式,便于前端解析和缓存

package com.example.demo.response;

import lombok.Data;

import java.time.LocalDateTime;

/**
 * 统一响应封装类(所有接口返回都使用此结构)
 * 优点:前端无需判断不同结构,统一处理 success / code / message / data
 *
 * @author Java后端开发工程师
 */
@Data
public class ApiResponse<T> {

    /**
     * 响应码:200 表示成功,其他为错误码(建议使用业务码,如 4001 用户未登录)
     * 200: 成功
     * 400: 请求参数错误
     * 401: 未授权
     * 404: 资源不存在
     * 500: 服务器内部错误
     * 自定义业务码:如 1001 - 用户已存在
     */
    private Integer code;

    /**
     * 响应信息:对用户友好的提示语
     */
    private String message;

    /**
     * 响应数据:泛型,可为任意类型(List、Object、null)
     */
    private T data;

    /**
     * 请求时间戳,便于链路追踪和日志分析
     */
    private LocalDateTime timestamp;

    /**
     * 构造成功响应(带数据)
     */
    public static <T> ApiResponse<T> success(T data) {
        ApiResponse<T> response = new ApiResponse<>();
        response.code = 200;
        response.message = "操作成功";
        response.data = data;
        response.timestamp = LocalDateTime.now();
        return response;
    }

    /**
     * 构造成功响应(无数据)
     */
    public static <T> ApiResponse<T> success() {
        return success(null);
    }

    /**
     * 构造错误响应(带错误码和信息)
     */
    public static <T> ApiResponse<T> error(Integer code, String message) {
        ApiResponse<T> response = new ApiResponse<>();
        response.code = code;
        response.message = message;
        response.timestamp = LocalDateTime.now();
        return response;
    }

    /**
     * 构造系统异常响应(500)
     */
    public static <T> ApiResponse<T> systemError(String message) {
        return error(500, "系统繁忙,请稍后再试:" + message);
    }
}

📁 5. 服务端:业务层(PaymentService)—— 无需关心幂等

业务逻辑只关注“做什么”,幂等性由切面统一处理

package com.example.demo.service;

import com.example.demo.dto.PaymentCreateRequest;
import com.example.demo.entity.Payment;
import com.example.demo.repository.PaymentRepository;
import com.example.demo.response.ApiResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;

/**
 * 支付服务层
 * 此处无需任何幂等逻辑!
 * 幂等性由 @Idempotent 注解 + AOP 拦截器自动处理
 *
 * @author Java后端开发工程师
 */
@Service
public class PaymentService {

    @Autowired
    private PaymentRepository paymentRepository;

    /**
     * 创建支付订单(核心业务逻辑)
     * 注意:这里不处理幂等!
     * 即使被调用 10 次,也会创建 10 条记录(但 AOP 会阻止!)
     */
    public ApiResponse<Object> createPayment(PaymentCreateRequest request) {
        // 1. 参数校验(业务层面)
        if (request.getAmount() <= 0) {
            return ApiResponse.error(400, "支付金额必须大于0");
        }

        // 2. 创建支付记录
        Payment payment = new Payment();
        payment.setUserId(request.getUserId());
        payment.setAmount(request.getAmount());
        payment.setOrderId(request.getOrderId());
        payment.setStatus("SUCCESS");
        payment.setCreatedAt(LocalDateTime.now());

        // 3. 保存到数据库(此时可能因唯一索引冲突而失败,作为兜底)
        payment = paymentRepository.save(payment);

        // 4. 返回成功响应(会被 AOP 缓存)
        return ApiResponse.success(payment);
    }
}

📁 6. 数据实体类(Payment)

package com.example.demo.entity;

import lombok.Data;

import javax.persistence.*;
import java.time.LocalDateTime;

/**
 * 支付实体类
 * ✅ 关键:增加 idempotency_key 字段,作为数据库兜底
 * 即使 Redis 挂了,数据库唯一索引也能防止重复插入
 *
 * @author Java后端开发工程师
 */
@Entity
@Table(name = "payments")
@Data
public class Payment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long userId;          // 用户ID
    private Double amount;        // 支付金额
    private String orderId;       // 业务订单号
    private String status;        // 状态:SUCCESS / FAILED
    private String idempotencyKey; // ✅ 幂等键(数据库唯一索引兜底)

    private LocalDateTime createdAt;

    // 无参构造函数(JPA 要求)
    public Payment() {}

    // 有参构造函数(方便使用)
    public Payment(Long userId, Double amount, String orderId, String idempotencyKey) {
        this.userId = userId;
        this.amount = amount;
        this.orderId = orderId;
        this.idempotencyKey = idempotencyKey;
        this.status = "PENDING";
    }
}

📁 7. 数据访问层(Repository)—— 数据库唯一索引兜底

package com.example.demo.repository;

import com.example.demo.entity.Payment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

/**
 * 支付仓库
 * ✅ 在数据库层面设置唯一索引,防止 Redis 失效时的重复插入
 *
 * @author Java后端开发工程师
 */
@Repository
public interface PaymentRepository extends JpaRepository<Payment, Long> {

    // ✅ 数据库兜底:根据幂等键查询是否存在
    Payment findByIdempotencyKey(String idempotencyKey);

    // ✅ 可选:按订单号查询(业务常用)
    Payment findByOrderId(String orderId);
}

数据库建表语句(MySQL)

CREATE TABLE payments (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT NOT NULL,
  amount DECIMAL(10,2) NOT NULL,
  order_id VARCHAR(64) NOT NULL,
  status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
  idempotency_key VARCHAR(64) UNIQUE NOT NULL, -- ✅ 关键:唯一索引
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

📁 8. 控制器层(Controller)—— 使用注解启用幂等

package com.example.demo.controller;

import com.example.demo.annotation.Idempotent;
import com.example.demo.dto.PaymentCreateRequest;
import com.example.demo.response.ApiResponse;
import com.example.demo.service.PaymentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/payments")
public class PaymentController {

    @Autowired
    private PaymentService paymentService;

    /**
     * 创建支付订单(幂等接口)
     * 客户端必须在 Header 中携带:Idempotency-Key: uuid-xxxx
     *
     * @Idempotent 注解说明:
     *   keyName = "Idempotency-Key":从 Header 获取
     *   ttlSeconds = 7200:缓存2小时
     *
     * 无论客户端重试多少次,只要 key 相同,服务端只处理一次!
     *
     * ✅ 示例请求:
     * POST /api/v1/payments
     * Header: Idempotency-Key: 8a7e3f1b-9d2c-4e1f-a0b5-6d4c7f8e9a0b
     * Body: { "userId": 1001, "amount": 99.99, "orderId": "ORD-20240520-001" }
     */
    @PostMapping
    @Idempotent(keyName = "Idempotency-Key", ttlSeconds = 7200) // ✅ 启用幂等
    public ApiResponse<Object> createPayment(@RequestBody PaymentCreateRequest request) {
        return paymentService.createPayment(request);
    }
}

📁 9. 配置文件(application.yml)—— Redis 配置

spring:
  redis:
    host: localhost
    port: 6379
    password: # 如果有密码
    timeout: 5000ms
    lettuce:
      pool:
        max-active: 20   # 最大连接数
        max-wait: -1ms   # 等待连接最大时间
        max-idle: 10     # 最大空闲连接
        min-idle: 5      # 最小空闲连接

# 开启 AOP(默认开启)
spring.aop.auto=true

✅ 七、幂等性流程图(客户端 → 服务端)

graph TD
    A[客户端发起请求] --> B{是否携带 Idempotency-Key?}
    B -- 否 --> C[执行业务逻辑,可能重复]
    B -- 是 --> D[查询 Redis: idempotency:{key}]
    D -- 存在 --> E[返回缓存结果,结束]
    D -- 不存在 --> F[执行业务逻辑]
    F --> G{业务是否成功?}
    G -- 是 --> H[将响应结果写入 Redis,TTL=2h]
    G -- 否 --> I[返回错误,不缓存]
    H --> J[返回响应]
    I --> J

✅ 八、测试用例(MockMvc)—— 验证幂等性

package com.example.demo.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
public class PaymentControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    public void testIdempotentRequest() throws Exception {
        String requestBody = """
            {
              "userId": 1001,
              "amount": 99.99,
              "orderId": "ORD-2024-001"
            }
            """;

        String key = "test-key-001";

        // 第一次请求:创建支付
        mockMvc.perform(post("/api/v1/payments")
                .header("Idempotency-Key", key)
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
                .andExpect(status().isOk());

        // 第二次请求:使用相同 key,应返回缓存结果,不创建新记录
        mockMvc.perform(post("/api/v1/payments")
                .header("Idempotency-Key", key)
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
                .andExpect(status().isOk())
                .andExpect(result -> {
                    // 可验证响应体与第一次相同
                    // 如:两次响应的 data.id 应该一致(实际是同一个支付记录)
                });
    }
}

测试结果

  • 数据库中只插入 1 条记录
  • Redis 中缓存 1 个响应
  • 两次请求都返回 200,且内容一致

✅ 九、生产环境最佳实践总结

项目建议
幂等键生成客户端生成 UUID v4,保存在本地(localStorage)
幂等键传输使用 HTTP Header:Idempotency-Key(标准)
缓存存储Redis,Key 格式:idempotency:{key},TTL 2~24 小时
数据库兜底在核心表加 UNIQUE(idempotency_key) 字段
异常处理缓存失败不阻塞业务,但记录日志并告警
监控监控 Redis 中 idempotency:* key 数量,异常增长报警
日志记录每个请求的 idempotency_key,便于排查
前端网络错误时自动重试,不重新生成 key
文档API 文档中注明“此接口幂等,需携带 Idempotency-Key”

✅ 十、总结:一句话记住幂等性实现

“客户端生成唯一幂等键,服务端用 Redis 缓存响应,数据库加唯一索引兜底”


✅ 附录:常见问题解答(FAQ)

Q1:幂等键能重复使用吗?

✅ 可以,但仅限于相同业务场景。如订单支付成功后,若用户退款,应使用新的幂等键,避免混淆。

Q2:如果 Redis 挂了怎么办?

✅ 数据库唯一索引兜底,保证不重复插入。但响应缓存失效,可能重新执行一次(损失性能,不损失数据)。

Q3:PUT 和 PATCH 哪个更适合幂等?

PUT(全量更新) 更适合幂等。PATCH(部分更新)易因字段遗漏导致状态不一致。

Q4:能否用数据库事务代替幂等?

❌ 不能。事务只能保证原子性,不能防止重复请求。幂等是业务层概念,事务是数据层概念。


🎯 结语

幂等性不是“可有可无”的优化,而是金融级系统、支付系统、订单系统的“生命线”
一个支付接口重复扣款,可能引发客户投诉、法律诉讼、品牌危机。
用好 Idempotency Key + Redis + 数据库唯一索引,你将构建出真正可靠的后端系统。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙茶清欢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值