# 接口幂等性设计,重复请求的终极解决方案

4 阅读3分钟

用户手抖点了两次提交,订单创建了两条。网络超时自动重试,库存扣了两次。这类问题,本质上都是同一个问题——接口不幂等。

什么是幂等

幂等(Idempotent)指:同一个请求执行一次和执行多次,结果是一样的。

操作是否幂等说明
GET查询是天然幂等
PUT更新(全量)是覆盖式更新
DELETE删除是删了再删还是删了
POST创建否会创建多条

注意:幂等强调的是结果一致,不是"不执行"。比如扣款接口,重复调用不能重复扣钱。

为什么会出现重复请求

1. 用户重复点击
2. 前端超时重试
3. 网关/Nginx重试
4. 消息队列重复投递
5. 服务端超时但实际已成功,客户端重试

第5种最阴险——服务端已经处理成功了,只是响应没发出去,客户端以为失败又发了一次。

方案一:唯一索引(最可靠)

数据库层面兜底,最靠谱。

CREATE TABLE t_order (
    id BIGINT PRIMARY KEY,
    order_no VARCHAR(64) NOT NULL,
    user_id BIGINT NOT NULL,
    amount DECIMAL(10,2),
    UNIQUE KEY uk_order_no (order_no)
);
public Result createOrder(CreateOrderRequest req) {
    try {
        orderMapper.insert(order);
        return Result.ok();
    } catch (DuplicateKeyException e) {
        // 订单号重复,说明是重复请求
        log.warn("重复创建订单: {}", req.getOrderNo());
        return Result.ok(orderMapper.selectByOrderNo(req.getOrderNo()));
    }
}

优点:绝对可靠,并发下也有效。缺点:需要业务上有唯一标识(订单号、流水号)。

方案二:Token机制

流程是"先申请token,再提交带token的请求"。

步骤1:前端请求获取token
GET /api/token → 返回 uuid,存入Redis

步骤2:前端提交时带上token
POST /api/order
Header: X-Idempotent-Token: uuid

步骤3:服务端校验并删除token
public Result createOrder(CreateOrderRequest req, String token) {
    // Redis的删除是原子的,只有第一次能删成功
    Boolean deleted = redisTemplate.delete("idempotent:" + token);

    if (Boolean.FALSE.equals(deleted)) {
        return Result.fail("请勿重复提交");
    }

    // 执行真正的业务
    return orderService.create(req);
}

这个方案适合前端可控的场景,比如表单提交。

方案三:Redis SETNX

用Redis的setIfAbsent做防重。

public Result submit(SubmitRequest req) {
    String key = "idem:" + req.getUserId() + ":" + req.getBizNo();
    String value = UUID.randomUUID().toString();

    // 10分钟内同一个业务号只能提交一次
    Boolean success = redisTemplate.opsForValue()
        .setIfAbsent(key, value, Duration.ofMinutes(10));

    if (Boolean.FALSE.equals(success)) {
        return Result.fail("请求处理中,请勿重复提交");
    }

    try {
        return doBusiness(req);
    } catch (Exception e) {
        // 业务失败,删除key允许重试
        redisTemplate.delete(key);
        throw e;
    }
}

注意:业务失败要删key,否则用户重试不了。

方案四:状态机

通过状态流转保证幂等,适合有明确生命周期的业务。

public void paySuccess(String orderNo) {
    // 只更新未支付的订单,已支付的不会受影响
    int affected = orderMapper.updateStatus(
        orderNo,
        OrderStatus.UNPAID,
        OrderStatus.PAID
    );

    if (affected == 0) {
        log.info("订单已支付或不存在,忽略本次通知: {}", orderNo);
        return;
    }

    // 只有真正更新成功才继续后续逻辑
    pointService.addPoints(orderNo);
}
UPDATE t_order
SET status = 'PAID'
WHERE order_no = #{orderNo} AND status = 'UNPAID'

这个写法天然幂等——状态已经是PAID了,再执行一次影响行数是0,后续逻辑不会重复触发。支付回调、消息消费这类场景强烈推荐。

方案对比

方案可靠性实现成本适用场景
唯一索引最高低有业务唯一标识
Token高中表单提交
Redis SETNX中低短时间防重
状态机高低状态流转

实战组合拳

生产环境通常是组合使用:

接入层:Nginx限流
    ↓
网关层:Token校验(可选)
    ↓
应用层:Redis SETNX快速拦截
    ↓
数据层:唯一索引兜底
    ↓
状态机:保证业务流转正确

每一层都有兜底,重复请求几乎不可能穿透到底层。

一句话总结:幂等不是选择题,是必修课。 尤其是支付、订单、库存这类核心链路,设计的时候就要想好。