ElIauk

Spring Boot 接口幂等设计入门

为什么会重复 用户点了一次提交,前端因为超时又重试了一次;网关等不到响应,把请求转给了另一台实例;MQ 消费失败,消息被重新投递。这些场景在分布式系统里几乎必然发生,客户端无法确认“服务端到底处理了没有”,只能重试。重试带来的是至少一次投递,而不是恰好一次。 幂等要解决的就是:同一个业务请求到达多次

评论
Spring Boot 接口幂等设计入门

为什么会重复

用户点了一次提交,前端因为超时又重试了一次;网关等不到响应,把请求转给了另一台实例;MQ 消费失败,消息被重新投递。这些场景在分布式系统里几乎必然发生,客户端无法确认“服务端到底处理了没有”,只能重试。重试带来的是至少一次投递,而不是恰好一次。

幂等要解决的就是:同一个业务请求到达多次,最终效果和执行一次相同。注意是“效果相同”,不是“请求被拒绝”。重复请求可以返回第一次的结果,也可以返回“处理中”,但不能重复扣款、重复创建订单。

幂等的几个层次

手段 强度 适用场景 局限
数据库唯一索引 强 创建类操作,有天然业务唯一键 需要能定义唯一键
状态机条件更新 强 支付、发货等状态流转 只适用于有状态实体
幂等 Token 中 表单提交、下单入口 需要客户端配合
Redis SETNX 去重 中 短时间内的重复请求 过期后失效,需兜底
分布式锁 弱 串行化执行 只防并发,不防先后重复

实际项目通常组合使用:入口用 Token 或请求号挡住大部分重复,数据库唯一索引做最终兜底,状态机保证流转不会倒退。只靠 Redis 或只靠锁,都会在极端情况下漏掉。

代码:Token + 唯一索引

客户端每次提交生成一个 Idempotency-Key,服务端第一次见到它时占位,处理成功后把结果缓存起来,重复请求直接返回第一次的结果:

@PostMapping("/orders")
public Result<OrderVO> create(@RequestHeader("Idempotency-Key") String requestId,
                              @RequestBody @Valid CreateOrderRequest request) {
    String key = "idem:order:" + requestId;
    String cached = redis.opsForValue().get(key);
    if (cached != null) {
        return Result.ok(JsonUtils.parse(cached, OrderVO.class));
    }
    Boolean first = redis.opsForValue().setIfAbsent(key, "PROCESSING", Duration.ofMinutes(10));
    if (!Boolean.TRUE.equals(first)) {
        throw new BizException("请求正在处理中,请勿重复提交");
    }
    try {
        OrderVO order = orderService.create(requestId, request);
        redis.opsForValue().set(key, JsonUtils.toJson(order), Duration.ofHours(24));
        return Result.ok(order);
    } catch (RuntimeException e) {
        redis.delete(key);
        throw e;
    }
}

占位值用 PROCESSING 而不是直接写结果,是为了区分“正在处理”和“已经完成”。处理失败要删除 key,否则用户重试会被一直挡住;但如果是“数据库已提交、响应丢失”,删除 key 会导致下一次重复执行,所以还需要数据库唯一索引兜底:

CREATE TABLE orders (
    id BIGINT NOT NULL AUTO_INCREMENT,
    request_id VARCHAR(64) NOT NULL,
    user_id BIGINT NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    status VARCHAR(16) NOT NULL,
    created_at DATETIME NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uk_request_id (request_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
@Transactional(rollbackFor = Exception.class)
public OrderVO create(String requestId, CreateOrderRequest request) {
    Order order = new Order(requestId, request.userId(), request.amount());
    try {
        orderMapper.insert(order);
    } catch (DuplicateKeyException e) {
        Order existing = orderMapper.selectByRequestId(requestId);
        if (existing == null) {
            throw e;
        }
        return OrderVO.from(existing);
    }
    return OrderVO.from(order);
}

request_id 必须是全局唯一且不可猜测的,通常由客户端生成 UUID,或者由服务端在下单页初始化时下发。不要把用户 ID、时间戳拼一拼就用,重复概率和安全性都不可控。

状态机条件更新

支付回调、发货通知这类操作,用条件更新比查状态再更新更可靠:

UPDATE orders
SET status = 'PAID', paid_at = NOW()
WHERE id = #{orderId} AND status = 'UNPAID';

受影响行数为 1 表示本次真的完成了支付流转,为 0 表示已经支付过或状态不允许。业务代码判断影响行数,不要先 SELECT 再 UPDATE,那中间存在并发窗口。

常见坑

1. 用分布式锁代替幂等

锁只能保证同一时刻只有一个线程执行,第一个请求释放锁后,重复请求还是会进来执行第二次。锁解决的是并发,幂等解决的是重复,两者不能互相替代。

2. Token 用完没有删除或过期太长

Token 一次性使用,处理成功后就该失效;如果做成永久有效,用户第二天用同一个 key 提交会被误判为重复。TTL 要覆盖“客户端可能重试的时间窗”,通常几小时到一天。

3. 幂等键没有绑定请求内容

同一个 key 被用于不同的请求体,服务端会把第二次请求当成重复,直接返回第一次的结果。幂等键应该和用户、接口、请求参数绑定,或者对请求体做摘要,发现内容不一致时返回冲突而不是旧结果。

4. Redis 挂了就完全失效

只用 Redis 做幂等,Redis 故障时要么全部放行、要么全部拒绝。放行会重复,拒绝会丢单。关键业务必须有数据库唯一约束兜底,Redis 只是性能层。

5. 唯一索引和软删除冲突

订单软删除后,同一个 request_id 再次插入会撞唯一索引。要么唯一索引带上删除标记,要么用独立的幂等记录表,不要为了“能删”去掉唯一约束。

6. MQ 消费端没有幂等

生产者发送成功不代表消费只执行一次,网络抖动、消费者重启都会导致重复投递。消费端要用消息 ID 或业务唯一键做去重,不能假设 MQ 保证恰好一次。

排查步骤

出现重复订单时,先按业务唯一键查数据:

SELECT id, request_id, user_id, amount, status, created_at
FROM orders
WHERE request_id = '上送的单号';

再确认 Redis 里的幂等键状态和过期时间:

redis-cli GET idem:order:8f3c2a
redis-cli TTL idem:order:8f3c2a
redis-cli --scan --pattern 'idem:order:*' | head -n 20

然后看网关和客户端的重试配置:超时时间是不是短于接口实际耗时,重试次数是不是没有上限。最后检查数据库唯一索引是否真的存在:

SHOW INDEX FROM orders WHERE Key_name = 'uk_request_id';

如果索引存在但仍有重复,重点查是不是走了不同的库或分片,或者唯一键里带了时间戳、随机数导致每次都不一样。

小结

幂等不是加一个注解就能解决的,它是接口契约的一部分:客户端负责生成稳定的幂等键,服务端负责占位、去重、返回一致结果,数据库负责最终兜底。先想清楚“重复请求应该返回什么”,再选技术手段,顺序反了,代码只会越写越乱。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者