Java接口幂等性设计:防止重复请求的核心策略

15 次阅读

接口幂等性是 Java 后端开发中处理重复请求的重要设计手段,尤其适用于支付、订单创建、库存扣减、消息消费、数据提交等场景。当客户端因为网络抖动、超时重试、用户重复点击或网关重试而发送相同请求时,如果服务端没有幂等控制,就可能产生重复订单、重复扣款、库存多扣等严重问题。

理解接口幂等性,不能简单地认为“同一个请求执行多次,结果一样”就足够了。真正可靠的设计需要结合业务唯一标识、数据库约束、缓存、分布式锁、状态机以及请求重试机制,根据不同业务场景选择合适的方案。

一、什么是接口幂等性

幂等性指的是一个操作执行一次和执行多次,对系统最终状态产生的有效业务结果一致。

例如,查询用户信息本身通常具有幂等性:

http
GET /api/user/1001

无论连续请求多少次,只要用户数据没有发生其他变化,查询结果不会因为请求次数增加而产生额外业务数据。

但下面这种接口通常不是天然幂等的:

http
POST /api/order

客户端第一次请求创建订单,如果因为网络超时没有收到响应,客户端再次提交相同请求,服务端可能创建第二笔订单。

因此,接口幂等性的核心并不是阻止请求到达,而是确保同一个业务操作被重复执行时,不会产生额外的业务副作用。

二、为什么 Java 接口需要进行幂等设计

实际生产环境中,重复请求非常常见,并不一定意味着用户故意操作。

常见原因包括:

1. 用户重复点击

用户点击“提交订单”后页面响应较慢,可能连续点击多次。

如果后端每次都执行创建订单逻辑,就可能生成多笔订单。

2. 网络超时导致客户端重试

假设服务端已经成功创建订单,但响应数据在网络传输过程中丢失。

客户端看到超时后再次请求:

第一次请求 → 服务端创建订单成功 → 响应丢失
第二次请求 → 服务端再次创建订单

这属于典型的重复请求问题。

3. 网关或服务调用重试

微服务环境中,上游服务可能因为超时主动重试,下游接口如果没有幂等控制,同一业务操作可能被执行多次。

4. 消息重复消费

消息队列通常需要考虑至少一次投递语义,因此同一消息可能被消费者重复收到。

如果消费逻辑直接执行数据库更新,也可能产生重复业务结果。

5. 分布式系统中的异常

服务调用过程中可能出现:

客户端
  ↓
网关
  ↓
订单服务
  ↓
支付服务

任何一层出现超时,都可能触发重试。因此,幂等性往往需要贯穿整个调用链,而不是只依赖某一个接口。

三、接口幂等设计的核心思路

一个完整的幂等方案通常可以拆分为三个问题:

  1. 如何识别“这是同一个业务请求”?

  2. 如何记录这个请求已经执行过?

  3. 重复请求到达后应该返回什么结果?

其中第一点尤其重要。

最常见的方法是让客户端生成唯一的幂等请求号,例如:

Idempotency-Key: 202610061234567890

服务端根据这个 Key 判断请求是否已经处理。

也可以根据业务字段建立唯一业务标识,例如:

用户ID + 业务订单号

只要能够准确识别同一个业务操作,就可以作为幂等控制的基础。

四、方案一:数据库唯一约束

数据库唯一约束是最可靠、最基础的幂等方案之一。

例如订单表:

SQL
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    order_no VARCHAR(64) NOT NULL,
    user_id BIGINT NOT NULL,
    amount DECIMAL(18,2) NOT NULL,
    UNIQUE KEY uk_order_no (order_no)
);

Java 服务收到创建订单请求后,首先生成业务订单号:

Java
String orderNo = orderService.generateOrderNo();

随后执行插入:

Java
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setAmount(amount);

orderMapper.insert(order);

如果相同订单号再次插入,数据库会因为唯一索引而拒绝重复数据。

这种方案的优势非常明显:

  • 数据库层面保证唯一性

  • 不依赖 Redis

  • 服务重启不会丢失幂等记录

  • 实现简单

  • 适合订单、支付流水等核心业务

但它也存在一个问题:数据库唯一约束只能防止重复数据写入,无法自动解决“重复请求应该返回什么”的问题。

因此实际项目中通常需要捕获唯一键冲突,并查询原有业务记录返回。

例如:

Java
try {
    orderMapper.insert(order);
} catch (DuplicateKeyException e) {
    return orderMapper.selectByOrderNo(orderNo);
}

对于核心业务而言,“数据库唯一约束 + 正确处理重复异常”通常比单纯依赖分布式锁更加可靠。

五、方案二:Redis实现接口幂等

Redis具有高性能、低延迟的特点,非常适合处理短时间内的重复请求。

例如客户端携带:

Idempotency-Key: abc123

服务端可以执行:

Java
Boolean success = redisTemplate.opsForValue()
        .setIfAbsent("idempotent:abc123", "PROCESSING", Duration.ofMinutes(5));

setIfAbsent 的核心效果类似 Redis 的:

SET key value NX EX 300

如果返回 true,说明当前请求第一次进入,可以继续执行业务。

如果返回 false,说明这个 Key 已经存在,需要根据当前状态决定如何处理。

简单示例:

Java
public String createOrder(String requestId, OrderRequest request) {

    String key = "idempotent:" + requestId;

    Boolean firstRequest = redisTemplate.opsForValue()
            .setIfAbsent(key, "PROCESSING", Duration.ofMinutes(5));

    if (!Boolean.TRUE.equals(firstRequest)) {
        return "请求正在处理或已经处理";
    }

    try {
        return doCreateOrder(request);
    } catch (Exception e) {
        redisTemplate.delete(key);
        throw e;
    }
}

不过,这种写法还存在一个重要问题。

假设业务执行时间超过 Redis Key 的过期时间:

0秒       创建幂等Key
5分钟     Key过期
5分10秒   第二个请求进入
5分20秒   第一个请求仍在执行

此时第二个请求可能重新获得执行资格。

所以 Redis 幂等设计不能简单理解为“加一个 Key 就结束”,需要根据业务执行时间、状态管理和异常恢复策略进行设计。

六、Redis幂等最好采用状态记录

比起简单保存:

requestId → SUCCESS

更完善的方式是保存请求状态:

requestId → PROCESSING
requestId → SUCCESS
requestId → FAILED

例如:

Java
public class IdempotentRecord {

    private String requestId;

    private String status;

    private String result;

    private LocalDateTime createTime;
}

第一次请求:

requestId = abc123
status = PROCESSING

业务执行成功:

requestId = abc123
status = SUCCESS
result = {"orderId":10001}

重复请求到达后,如果发现:

SUCCESS

就可以直接返回第一次请求的结果,而不是再次执行创建逻辑。

这种模式尤其适合支付、订单、任务提交等需要保证响应一致性的业务。

七、方案三:数据库幂等记录表

对于重要业务,可以建立专门的幂等记录表。

例如:

SQL
CREATE TABLE idempotent_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    request_id VARCHAR(64) NOT NULL,
    status VARCHAR(20) NOT NULL,
    result TEXT,
    create_time DATETIME NOT NULL,
    update_time DATETIME NOT NULL,
    UNIQUE KEY uk_request_id (request_id)
);

第一次请求:

INSERT request_id = abc123

如果插入成功,说明当前请求拥有执行资格。

如果插入失败并触发唯一键冲突,说明请求已经处理过,可以查询已有记录。

这种方案的最大优势是幂等记录和业务数据都可以使用数据库事务管理。

例如:

Java
@Transactional
public OrderResult createOrder(String requestId, OrderRequest request) {

    IdempotentRecord record = new IdempotentRecord();
    record.setRequestId(requestId);
    record.setStatus("PROCESSING");

    try {
        idempotentMapper.insert(record);
    } catch (DuplicateKeyException e) {
        return getExistingResult(requestId);
    }

    Order order = createOrderInternal(request);

    record.setStatus("SUCCESS");
    record.setResult(order.getId().toString());
    idempotentMapper.update(record);

    return new OrderResult(order.getId());
}

但需要特别注意事务边界。

如果幂等记录、订单数据和状态更新没有处于合理的事务控制范围内,仍然可能出现状态不一致。

八、方案四:分布式锁

Redis、ZooKeeper、数据库等都可以实现分布式锁。

例如使用 Redis 锁:

lock:order:user:1001

Java 中可以使用成熟的分布式锁组件,例如 Redisson:

Java
RLock lock = redissonClient.getLock("lock:order:" + userId);

boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);

if (!locked) {
    throw new RuntimeException("请求处理中");
}

try {
    return createOrder(request);
} finally {
    lock.unlock();
}

分布式锁能够让同一时间只有一个请求执行关键业务。

但是需要明确:

分布式锁不等于幂等性。

锁解决的是:

同一时刻谁可以执行

幂等解决的是:

同一个业务请求执行多次后是否产生重复结果

例如第一个请求执行成功并释放锁,第二个完全相同的请求随后仍然可以获得锁。

因此:

分布式锁 + 幂等记录

往往比单独使用分布式锁更加合理。

九、方案五:Token机制防止重复提交

Web系统中经常使用 Token 防止表单重复提交。

用户打开页面时,服务端生成一个 Token:

token = 7d9f8a...

客户端提交表单时携带:

JSON
{
    "token": "7d9f8a...",
    "productId": 1001,
    "quantity": 2
}

服务端通过 Redis 判断 Token 是否已经消费。

例如:

Java
String key = "submit:token:" + token;

Boolean success = redisTemplate.delete(key) > 0;

if (!Boolean.TRUE.equals(success)) {
    throw new RuntimeException("请勿重复提交");
}

第一次请求删除成功,继续执行。

第二次请求由于 Token 已经不存在,因此拒绝处理。

这种方案非常适合:

  • 表单提交

  • 创建订单

  • 发布内容

  • 用户操作提交

  • 管理后台数据保存

但如果客户端在第一次请求执行成功后没有收到响应,Token 已经被消费,第二次请求可能只能得到“重复提交”,而无法拿到第一次操作的结果。

因此,对于要求返回原始结果的场景,更适合使用“幂等 Key + 结果缓存”。

十、方案六:利用业务状态机实现幂等

很多业务天然具有状态流转,例如订单:

待支付 → 已支付 → 已发货 → 已完成

支付接口收到请求时,可以判断当前订单状态:

Java
Order order = orderMapper.selectByOrderNo(orderNo);

if (order.getStatus() == OrderStatus.PAID) {
    return paymentResult(order);
}

只有订单处于:

WAIT_PAY

状态时才允许执行支付逻辑。

数据库更新可以进一步增加状态条件:

SQL
UPDATE orders
SET status = 'PAID'
WHERE order_no = ?
  AND status = 'WAIT_PAY';

然后判断受影响的行数:

Java
int rows = orderMapper.markPaid(orderNo);

if (rows == 0) {
    // 订单可能已经支付
}

这种设计非常重要,因为它把幂等性直接融入业务状态机。

相比单纯依赖缓存,数据库状态通常更加持久可靠。

十一、支付接口为什么特别需要幂等

支付是接口幂等设计最典型的场景之一。

假设用户支付100元:

第一次支付请求
    ↓
支付成功
    ↓
客户端超时
    ↓
用户重新点击支付
    ↓
第二次支付请求

如果服务端只是简单执行:

Java
paymentService.pay(orderId, amount);

就可能造成重复扣款。

正确设计应该使用支付流水号或者商户订单号作为唯一业务标识。

例如:

merchantOrderNo = ORDER202610060001

数据库建立唯一索引:

SQL
UNIQUE KEY uk_merchant_order_no (merchant_order_no)

同时根据支付状态判断:

未支付 → 执行支付
支付中 → 查询支付结果
已支付 → 返回已有支付结果
支付失败 → 根据业务规则决定是否允许重试

这比简单的“重复请求直接返回错误”更加完善。

十二、Java接口幂等设计的推荐组合

实际生产项目中,不建议所有接口都采用同一种方案。

可以根据业务类型选择。

场景推荐方案
普通查询接口天然幂等,无需额外处理
用户表单提交Token + Redis
创建订单业务唯一号 + 数据库唯一约束
支付接口商户订单号 + 状态机 + 唯一约束
库存扣减数据库条件更新 + 业务流水
消息消费消费记录 + 唯一消息ID
高并发提交Redis + 数据库兜底
长时间任务请求ID + 状态表
分布式并发控制分布式锁 + 幂等记录

核心业务通常应该采用“多层防护”,而不是把所有安全性押在 Redis 或锁上。

十三、Spring Boot中的幂等拦截设计

如果项目中有大量需要幂等处理的接口,可以通过注解统一实现。

定义:

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

    int expire() default 300;
}

Controller:

Java
@PostMapping("/order")
@Idempotent(expire = 300)
public OrderResult createOrder(@RequestBody OrderRequest request) {
    return orderService.createOrder(request);
}

然后通过 Spring AOP 拦截:

Java
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint point,
                     Idempotent idempotent) {

    String requestId = getRequestId();

    String key = "idempotent:" + requestId;

    Boolean first = redisTemplate.opsForValue()
            .setIfAbsent(
                    key,
                    "PROCESSING",
                    Duration.ofSeconds(idempotent.expire())
            );

    if (!Boolean.TRUE.equals(first)) {
        throw new RuntimeException("请求重复");
    }

    return point.proceed();
}

这种方式可以减少重复代码,让幂等逻辑集中管理。

不过,AOP 更适合解决通用的请求拦截问题,真正的业务唯一性仍然应该由业务层和数据库共同保证。

十四、幂等Key应该如何设计

幂等 Key 设计得好不好,会直接影响方案可靠性。

一种常见形式是客户端生成随机请求ID:

550e8400-e29b-41d4-a716-446655440000

服务端直接使用:

idempotent:{requestId}

另一种方式是根据业务信息生成:

idempotent:{userId}:{businessType}:{businessNo}

例如:

idempotent:10001:CREATE_ORDER:ORDER202610060001

对于核心业务,推荐优先使用具有业务意义的唯一流水号。

因为业务唯一号可以同时用于:

  • 接口幂等

  • 数据库唯一约束

  • 日志追踪

  • 链路排查

  • 消息去重

  • 对账

十五、不能简单使用请求参数Hash作为幂等Key

有些开发者会把整个请求参数计算 MD5:

MD5(JSON(request))

然后把结果作为幂等 Key。

这种方法并不适用于所有业务。

例如:

JSON
{
    "productId": 1001,
    "quantity": 1
}

用户连续提交两次完全相同的购买请求。

参数相同并不一定代表业务上是同一次操作。用户可能确实想购买两次。

因此,判断幂等性的关键不是:

请求参数是否相同

而是:

是否属于同一个业务操作

这也是业务幂等设计中最容易被忽略的一点。

十六、幂等接口需要处理哪些异常情况

设计接口幂等机制时,至少应该考虑以下几种状态。

第一次请求执行成功

requestId不存在
↓
创建幂等记录
↓
执行业务
↓
SUCCESS

重复请求:

requestId存在
↓
SUCCESS
↓
返回第一次执行结果

第一次请求正在执行

requestId存在
status=PROCESSING

第二次请求可以:

直接返回处理中

也可以:

短暂等待

具体取决于业务场景。

第一次请求执行失败

这里不能简单地认为失败后一定允许立即重试。

需要区分:

业务失败
系统异常
网络超时
数据库异常
第三方接口未知状态

尤其是第三方支付接口,如果本地服务收到网络异常,并不能确定第三方支付到底有没有成功。

这种情况下贸然删除幂等记录并重新执行,可能导致重复支付。

因此,未知状态通常应该进入:

PROCESSING

或者:

UNKNOWN

然后通过查询、补偿、对账等方式最终确定结果。

十七、幂等与事务不能混为一谈

事务主要解决:

一组数据库操作的原子性和一致性

幂等解决:

同一个业务请求重复执行的问题

例如:

Java
@Transactional
public void createOrder() {
    insertOrder();
    insertOrderItem();
}

即使这个方法具有事务,如果用户提交两次,仍然可能创建两笔订单。

因此:

事务 ≠ 幂等

正确的做法是将两者结合:

幂等控制
   ↓
确定当前请求是否可以执行
   ↓
事务执行业务操作
   ↓
提交业务结果

十八、接口幂等设计中的常见误区

误区一:只依赖前端按钮禁用

前端可以:

JavaScript
button.disabled = true;

但这只能减少用户重复点击。

它无法阻止:

  • HTTP重试

  • 网关重试

  • 服务间重试

  • 恶意请求

  • 消息重复消费

所以前端防重复提交只能作为辅助措施。

误区二:只使用分布式锁

锁只能控制并发执行,无法永久记录某个业务操作是否已经完成。

误区三:只使用Redis,不考虑持久化数据

Redis适合作为高性能幂等控制层,但对于订单、支付等核心业务,最终仍然应该由数据库唯一约束或业务状态进行兜底。

误区四:重复请求直接返回错误

例如:

请求重复,请稍后再试

对于简单操作可以接受,但如果第一次请求实际上已经成功,用户再次请求时最好返回第一次请求产生的业务结果。

误区五:幂等Key没有过期策略

如果所有幂等记录永久保存,数据库或 Redis 最终都会积累大量无效数据。

因此应该根据业务生命周期设计合理的保留时间。

十九、推荐的生产级幂等架构

对于订单、支付等核心接口,可以采用这样的结构:

客户端
   │
   │ Idempotency-Key
   ↓
API Gateway
   │
   ↓
幂等拦截层
   │
   ├── Redis快速判断
   │
   └── 请求状态查询
   │
   ↓
业务服务
   │
   ├── 业务状态校验
   ├── 数据库唯一约束
   ├── 事务处理
   └── 业务结果持久化
   │
   ↓
返回结果

如果 Redis 出现异常,核心业务不能因此完全失去数据一致性保障。

最终仍然应该依赖:

数据库唯一约束
+
业务状态机
+
事务

作为最后一道防线。

二十、如何选择合适的Java接口幂等方案

可以按照以下思路进行判断。

如果接口本身是查询操作,通常不需要额外设计幂等机制。

如果是普通表单提交,可以使用:

Token + Redis

如果是创建订单,建议:

业务唯一号 + 数据库唯一索引

如果是支付业务,建议:

支付流水号 + 状态机 + 数据库唯一约束 + 结果查询

如果是高并发接口,可以考虑:

Redis + 分布式锁 + 数据库兜底

如果是消息消费,则应该使用:

消息唯一ID + 消费记录 + 唯一索引

如果是长时间异步任务,可以设计:

请求ID + 任务状态表

核心原则不是追求最复杂的架构,而是让幂等机制与业务语义匹配。

二十一、总结

Java接口幂等性设计的本质,是让系统能够正确面对重复请求,而不是简单地拒绝第二次请求。

可靠的幂等方案通常包含几个关键组成部分:

唯一业务标识
      ↓
幂等状态记录
      ↓
业务状态判断
      ↓
数据库唯一约束
      ↓
事务保证一致性
      ↓
重复请求返回已有结果

Redis适合承担高性能的快速拦截,分布式锁适合控制并发,数据库唯一索引适合提供最终一致性保障,业务状态机则负责判断当前操作是否允许继续执行。

对于真正重要的订单、支付、库存和资金类接口,最佳实践不是依赖某一种技术,而是建立“缓存快速拦截、业务状态控制、数据库最终兜底”的多层幂等体系。这样即使遇到网络超时、客户端重试、服务重启、消息重复投递等异常情况,也能够最大程度避免重复执行造成的数据问题。