用户点击“提交订单”后迟迟没有看到响应,于是再次点击;网关认为第一次请求超时,自动重试;消息消费者处理完成后还没来得及提交位点,进程突然退出。三个场景背后是同一个问题:

调用方无法确定上一次请求是否成功,只能重试;服务端必须把重复到达当作正常情况处理。

幂等接口需要保证:对于同一个业务请求,多次执行与执行一次产生相同的业务效果。

本文示例基于 Java 21 + Spring Boot 3.x + PostgreSQL


一、哪些接口天然幂等?

从 HTTP 语义看,读取资源的 GET、将资源设置为确定状态的 PUT、删除资源的 DELETE 通常可以设计为幂等;创建资源的 POST 往往不是。

例如下面两个操作完全不同:

PUT  /accounts/100/status  {"status":"FROZEN"}
POST /accounts/100/debit   {"amount":100}

把状态设置为 FROZEN 执行多次,最终状态仍然相同;扣款 100 元执行两次,会产生两次副作用。

不过,请求方法本身不能自动保证业务幂等。即使使用 PUT,如果实现中每次都追加一条流水或发送一次短信,系统仍然不幂等。


二、前端防抖、分布式锁和幂等不是一回事

1. 前端按钮置灰

它能改善用户体验,但挡不住:

  • 网关或客户端 SDK 自动重试;
  • 用户绕过页面直接调用接口;
  • 两台设备同时提交;
  • 消息队列重复投递。

2. 分布式锁

锁解决的是“同一时刻只能有一个执行者”,幂等解决的是“同一个请求执行多次仍只有一次业务效果”。锁释放后,重复请求仍可能再次执行。锁超时、续期失败和进程暂停还会引入新的边界问题。

3. Redis SET NX

SET key value NX EX 30 可以在短时间内挡住重复请求,但键过期后请求又能进入;如果业务已经成功、Redis 写入却丢失,也可能再次执行。因此 Redis 适合作为快速拦截层,不应单独承担资金、订单等关键业务的最终正确性。

真正可靠的兜底通常来自数据库唯一约束和业务状态约束。


三、设计一份明确的幂等协议

客户端为每次业务操作生成唯一键:

POST /api/orders HTTP/1.1
Idempotency-Key: 01JZ6Q4X7M9K8P2A6T3B5C1D0E
Content-Type: application/json

{
  "skuId": 10001,
  "quantity": 2
}

推荐使用 UUID、ULID 等高熵值。服务端不要让客户端用时间戳或商品编号充当幂等键,它们很容易碰撞,也无法区分两次真实购买。

一套完整协议至少需要定义以下规则:

场景 服务端行为
新键首次提交 执行业务并保存结果
相同键、相同请求 不重复执行,回放首次结果
相同键、不同请求 返回 409 Conflict
首次请求仍在处理 返回处理中,或等待短暂时间后回放
键已过期 按新请求处理,业务唯一约束继续兜底

幂等键不能只按键值全局唯一,否则不同用户可能互相冲突。合理作用域通常是:

用户/租户 + 业务操作 + Idempotency-Key

同时要保存请求指纹。否则调用方错误复用同一个键,却修改了商品或金额,服务端可能回放一个与本次请求完全无关的结果。


四、数据库表与唯一约束

幂等记录表:

CREATE TABLE idempotency_record (
    id               BIGSERIAL PRIMARY KEY,
    owner_id         BIGINT       NOT NULL,
    operation        VARCHAR(64)  NOT NULL,
    idempotency_key  VARCHAR(128) NOT NULL,
    request_hash     CHAR(64)     NOT NULL,
    status           VARCHAR(20)  NOT NULL,
    http_status      INTEGER,
    response_json    JSONB,
    resource_id      BIGINT,
    created_at       TIMESTAMPTZ  NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at       TIMESTAMPTZ  NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expires_at       TIMESTAMPTZ  NOT NULL,

    CONSTRAINT uk_idempotency_scope
        UNIQUE (owner_id, operation, idempotency_key),
    CONSTRAINT ck_idempotency_status
        CHECK (status IN ('PROCESSING', 'SUCCEEDED'))
);

CREATE INDEX idx_idempotency_expire
    ON idempotency_record (expires_at);

订单表还应有自己的业务唯一标识:

CREATE TABLE purchase_order (
    id               BIGSERIAL PRIMARY KEY,
    order_no         VARCHAR(40)  NOT NULL UNIQUE,
    owner_id         BIGINT       NOT NULL,
    sku_id           BIGINT       NOT NULL,
    quantity         INTEGER      NOT NULL,
    status           VARCHAR(20)  NOT NULL,
    created_at       TIMESTAMPTZ  NOT NULL DEFAULT CURRENT_TIMESTAMP
);

两层唯一性承担不同职责:

  • 幂等记录唯一约束识别“同一次 API 请求”;
  • 订单业务唯一约束保护“同一个业务实体”。

不要用“先查询是否存在,再插入”代替唯一约束。两个并发事务可能同时查询到不存在,然后都执行插入。正确方式是让数据库通过原子唯一约束裁决胜者。


五、请求对象与响应对象

public record CreateOrderRequest(
        @NotNull Long skuId,
        @Min(1) @Max(100) int quantity
) {
}

public record CreateOrderResponse(
        Long orderId,
        String orderNo,
        String status
) {
}

public record IdempotentResult<T>(
        T body,
        int httpStatus,
        boolean replayed
) {
    public static <T> IdempotentResult<T> created(T body) {
        return new IdempotentResult<>(body, 201, false);
    }

    public static <T> IdempotentResult<T> replay(
            T body,
            int httpStatus
    ) {
        return new IdempotentResult<>(body, httpStatus, true);
    }
}

控制器显式接收幂等键:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderApplicationService orderService;

    public OrderController(OrderApplicationService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<CreateOrderResponse> create(
            @RequestHeader("Idempotency-Key") String idempotencyKey,
            @AuthenticationPrincipal LoginUser loginUser,
            @Valid @RequestBody CreateOrderRequest request
    ) {
        IdempotentResult<CreateOrderResponse> result =
                orderService.create(
                        loginUser.userId(),
                        idempotencyKey,
                        request
                );

        return ResponseEntity.status(result.httpStatus())
                .header(
                        "Idempotent-Replayed",
                        Boolean.toString(result.replayed())
                )
                .body(result.body());
    }
}

建议限制幂等键长度,并校验允许字符,避免异常大请求头和日志注入:

private static final Pattern KEY_PATTERN =
        Pattern.compile("^[A-Za-z0-9_-]{16,128}$");

六、生成稳定的请求指纹

请求指纹必须满足:相同业务请求得到相同哈希,不同业务请求大概率得到不同哈希。

示例 DTO 字段简单,可以直接拼接规范化值:

@Component
public class RequestHasher {

    public String hash(CreateOrderRequest request) {
        String canonical = request.skuId()
                + "|" + request.quantity();

        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] bytes = digest.digest(
                    canonical.getBytes(StandardCharsets.UTF_8)
            );
            return HexFormat.of().formatHex(bytes);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 unavailable", e);
        }
    }
}

复杂 JSON 不能直接对原始字符串计算哈希,因为字段顺序、空白和数字格式变化都可能导致不同结果。生产系统应使用稳定的 canonical JSON 规则,明确是否包含:

  • 用户身份和租户;
  • URL 中的资源标识;
  • 影响业务结果的请求头;
  • 金额、币种、优惠券等关键字段。

密码、令牌等敏感字段既不应写入响应快照,也不应出现在可逆的日志中。


七、用 ON CONFLICT 原子争抢执行权

通过 PostgreSQL 的 ON CONFLICT DO NOTHING,只有一个并发请求能插入幂等记录:

@Repository
public class IdempotencyRepository {

    private final JdbcTemplate jdbcTemplate;
    private final ObjectMapper objectMapper;

    public IdempotencyRepository(
            JdbcTemplate jdbcTemplate,
            ObjectMapper objectMapper
    ) {
        this.jdbcTemplate = jdbcTemplate;
        this.objectMapper = objectMapper;
    }

    public boolean tryStart(
            long ownerId,
            String operation,
            String key,
            String requestHash,
            Instant expiresAt
    ) {
        int changed = jdbcTemplate.update("""
                INSERT INTO idempotency_record (
                    owner_id,
                    operation,
                    idempotency_key,
                    request_hash,
                    status,
                    expires_at
                ) VALUES (?, ?, ?, ?, 'PROCESSING', ?)
                ON CONFLICT (
                    owner_id,
                    operation,
                    idempotency_key
                ) DO NOTHING
                """,
                ownerId,
                operation,
                key,
                requestHash,
                Timestamp.from(expiresAt)
        );

        return changed == 1;
    }

    public Optional<IdempotencyRecord> find(
            long ownerId,
            String operation,
            String key
    ) {
        List<IdempotencyRecord> rows = jdbcTemplate.query("""
                SELECT request_hash,
                       status,
                       http_status,
                       response_json,
                       resource_id
                  FROM idempotency_record
                 WHERE owner_id = ?
                   AND operation = ?
                   AND idempotency_key = ?
                """,
                idempotencyRowMapper(),
                ownerId,
                operation,
                key
        );
        return rows.stream().findFirst();
    }

    public void markSucceeded(
            long ownerId,
            String operation,
            String key,
            long resourceId,
            int httpStatus,
            Object response
    ) {
        try {
            String responseJson = objectMapper.writeValueAsString(response);
            jdbcTemplate.update("""
                    UPDATE idempotency_record
                       SET status = 'SUCCEEDED',
                           resource_id = ?,
                           http_status = ?,
                           response_json = CAST(? AS JSONB),
                           updated_at = CURRENT_TIMESTAMP
                     WHERE owner_id = ?
                       AND operation = ?
                       AND idempotency_key = ?
                       AND status = 'PROCESSING'
                    """,
                    resourceId,
                    httpStatus,
                    responseJson,
                    ownerId,
                    operation,
                    key
            );
        } catch (JsonProcessingException e) {
            throw new IllegalStateException("serialize response failed", e);
        }
    }
}

这里没有使用“捕获唯一键异常后继续查询”的写法。某些持久化框架在发生约束异常后会将当前事务标记为回滚,随后继续执行业务很容易在提交时遇到 UnexpectedRollbackExceptionON CONFLICT DO NOTHING 能把正常的并发竞争表达为返回值,而不是异常控制流。


八、在同一事务中写订单和幂等结果

核心服务如下:

@Service
public class OrderApplicationService {

    private static final String OPERATION = "CREATE_ORDER";

    private final IdempotencyRepository idempotencyRepository;
    private final OrderRepository orderRepository;
    private final RequestHasher requestHasher;
    private final ObjectMapper objectMapper;

    public OrderApplicationService(
            IdempotencyRepository idempotencyRepository,
            OrderRepository orderRepository,
            RequestHasher requestHasher,
            ObjectMapper objectMapper
    ) {
        this.idempotencyRepository = idempotencyRepository;
        this.orderRepository = orderRepository;
        this.requestHasher = requestHasher;
        this.objectMapper = objectMapper;
    }

    @Transactional
    public IdempotentResult<CreateOrderResponse> create(
            long ownerId,
            String key,
            CreateOrderRequest request
    ) {
        validateKey(key);
        String requestHash = requestHasher.hash(request);

        boolean owner = idempotencyRepository.tryStart(
                ownerId,
                OPERATION,
                key,
                requestHash,
                Instant.now().plus(Duration.ofHours(24))
        );

        if (!owner) {
            return replay(ownerId, key, requestHash);
        }

        PurchaseOrder order = orderRepository.save(
                PurchaseOrder.create(
                        nextOrderNo(),
                        ownerId,
                        request.skuId(),
                        request.quantity()
                )
        );

        CreateOrderResponse response = new CreateOrderResponse(
                order.getId(),
                order.getOrderNo(),
                order.getStatus().name()
        );

        idempotencyRepository.markSucceeded(
                ownerId,
                OPERATION,
                key,
                order.getId(),
                201,
                response
        );

        return IdempotentResult.created(response);
    }

    private IdempotentResult<CreateOrderResponse> replay(
            long ownerId,
            String key,
            String currentHash
    ) {
        IdempotencyRecord record = idempotencyRepository
                .find(ownerId, OPERATION, key)
                .orElseThrow(() -> new IllegalStateException(
                        "idempotency record not visible"
                ));

        if (!MessageDigest.isEqual(
                record.requestHash().getBytes(StandardCharsets.UTF_8),
                currentHash.getBytes(StandardCharsets.UTF_8)
        )) {
            throw new IdempotencyConflictException(
                    "Idempotency-Key was reused with a different request"
            );
        }

        if (!"SUCCEEDED".equals(record.status())) {
            throw new RequestProcessingException(
                    "the original request is still processing"
            );
        }

        try {
            CreateOrderResponse response = objectMapper.readValue(
                    record.responseJson(),
                    CreateOrderResponse.class
            );
            return IdempotentResult.replay(
                    response,
                    record.httpStatus()
            );
        } catch (JsonProcessingException e) {
            throw new IllegalStateException(
                    "deserialize stored response failed",
                    e
            );
        }
    }
}

这套实现把以下三次写入放在同一个本地事务中:

  1. 插入幂等记录;
  2. 创建订单;
  3. 保存成功响应。

因此不会出现“订单已经提交,但幂等记录仍是失败”的中间状态。如果创建订单时抛出运行时异常,Spring 默认会回滚整个事务,幂等记录也一起消失,调用方可以使用同一个键安全重试。

并发请求在唯一索引处竞争。第一个事务提交后,第二个请求无法插入,随后读取已完成记录并回放结果。


九、错误响应如何设计?

使用统一异常处理器:

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(IdempotencyConflictException.class)
    public ResponseEntity<ProblemDetail> handleConflict(
            IdempotencyConflictException ex
    ) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.CONFLICT,
                ex.getMessage()
        );
        problem.setTitle("Idempotency key conflict");
        return ResponseEntity.status(HttpStatus.CONFLICT).body(problem);
    }

    @ExceptionHandler(RequestProcessingException.class)
    public ResponseEntity<ProblemDetail> handleProcessing(
            RequestProcessingException ex
    ) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.CONFLICT,
                ex.getMessage()
        );
        problem.setTitle("Request is processing");
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .header("Retry-After", "1")
                .body(problem);
    }
}

相同键配合不同请求体必须明确返回冲突,不能静默回放旧结果。是否缓存业务失败响应则需要按错误类型决定:

  • 参数非法、库存不足等确定性失败,可以短期缓存并回放;
  • 网络抖动、数据库超时等临时失败,通常应允许重试;
  • 未知异常不应直接把堆栈和内部信息保存给客户端。

十、什么时候需要 PROCESSING 状态机?

上面的单事务方案适合耗时较短、主要修改同一个数据库的业务。如果流程需要调用支付、物流等外部系统,就不应该把数据库事务一直开着等待远程响应。

可以将状态扩展为:

PROCESSING → SUCCEEDED
     │
     ├────→ FAILED_RETRYABLE
     └────→ FAILED_FINAL

推荐流程:

  1. 短事务写入 PROCESSING 记录和业务任务;
  2. 后台工作器执行外部调用;
  3. 成功后更新业务状态和幂等结果;
  4. 可重试失败由任务系统按退避策略重试;
  5. 客户端重复请求读取当前状态,而不是再次调用外部系统。

但拆分事务会引入“工作器崩溃后记录长期停留在 PROCESSING”的问题,因此还必须保存租约到期时间、重试次数和最后心跳,并提供超时接管机制。

对“本地数据库写入 + 发布消息”的场景,可以使用 Outbox Pattern:业务数据和待发送事件在同一事务内落库,再由独立发布器投递消息。不要假设“先提交数据库,再发消息”能够天然保持一致。


十一、Redis 应该放在哪一层?

对于重复请求非常密集的接口,可以在数据库之前增加 Redis 快速拦截:

String redisKey = "idem:create-order:" + ownerId + ":" + key;
String token = UUID.randomUUID().toString();

Boolean acquired = redisTemplate.opsForValue().setIfAbsent(
        redisKey,
        token,
        Duration.ofSeconds(30)
);

对应的 Redis 命令语义是:

SET key token NX EX 30

如果需要主动释放,必须比较持有者 token 后再删除,避免请求 A 超时、锁过期、请求 B 获得锁后,请求 A 又误删 B 的锁:

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

不过,更稳妥的策略通常是让这个短期键自然过期,并始终保留数据库唯一约束作为最终防线。Redis 故障时系统可以退化为直接访问数据库,而不是失去幂等能力。

Redis 层适合:

  • 降低热点幂等键对数据库的重复查询;
  • 快速返回“处理中”;
  • 缓存已完成响应。

Redis 层不适合单独保证:

  • 订单只创建一次;
  • 账户只扣款一次;
  • 消息只产生一次业务效果。

十二、定时清理与数据保留

幂等记录不能无限增长。清理策略必须同时考虑:

  • 客户端最长重试窗口;
  • 网关和消息队列可能的延迟;
  • 业务审计周期;
  • 响应快照中的隐私数据。

可以分批删除过期记录,避免一次大事务产生锁和 WAL 压力:

DELETE FROM idempotency_record
 WHERE id IN (
     SELECT id
       FROM idempotency_record
      WHERE expires_at < CURRENT_TIMESTAMP
      ORDER BY expires_at
      LIMIT 1000
 );

定时任务每次删除一小批,循环执行或等待下一周期。对于支付等高审计要求业务,幂等记录可以只删除响应快照而保留不可变审计字段。


十三、必须覆盖的并发测试

幂等功能只测一次正常请求远远不够。至少需要以下用例:

  1. 同一个键和请求体串行调用两次,只创建一条订单;
  2. 同一个键并发调用 20 次,只创建一条订单;
  3. 同一个键搭配不同请求体,第二次返回 409;
  4. 首次事务失败后,使用同一个键能够重试成功;
  5. Redis 不可用时,数据库唯一约束仍然生效;
  6. 响应回放的状态码和响应体与首次调用一致;
  7. 不同用户使用相同键时互不影响。

一个并发测试示例:

@Test
void concurrent_requests_should_create_only_one_order()
        throws Exception {
    int concurrency = 20;
    ExecutorService pool = Executors.newFixedThreadPool(concurrency);
    CountDownLatch ready = new CountDownLatch(concurrency);
    CountDownLatch start = new CountDownLatch(1);

    List<Future<IdempotentResult<CreateOrderResponse>>> futures =
            IntStream.range(0, concurrency)
                    .mapToObj(i -> pool.submit(() -> {
                        ready.countDown();
                        start.await();
                        return service.create(
                                100L,
                                "01JZ6Q4X7M9K8P2A6T3B5C1D0E",
                                new CreateOrderRequest(10001L, 2)
                        );
                    }))
                    .toList();

    ready.await();
    start.countDown();

    List<CreateOrderResponse> responses = new ArrayList<>();
    for (Future<IdempotentResult<CreateOrderResponse>> future : futures) {
        responses.add(future.get().body());
    }

    assertThat(responses)
            .extracting(CreateOrderResponse::orderId)
            .containsOnly(responses.getFirst().orderId());

    assertThat(orderRepository.countByOwnerId(100L)).isEqualTo(1);
    pool.shutdownNow();
}

这类测试需要连接真实 PostgreSQL,H2 等内存数据库的锁和冲突行为可能与生产数据库不同。可以使用 Testcontainers 启动与生产版本一致的数据库。


十四、生产检查清单

上线前逐项确认:

  1. 幂等键是否由高熵随机值生成,并限制长度与字符集;
  2. 唯一范围是否包含用户或租户与业务操作;
  3. 是否保存请求指纹并拒绝同键不同参数;
  4. 是否由数据库唯一约束承担最终并发裁决;
  5. 业务写入与幂等结果是否处于正确的事务边界;
  6. 响应回放是否保留原始状态码和必要响应头;
  7. 是否区分确定性失败与可重试失败;
  8. Redis 故障时是否仍保持正确性;
  9. PROCESSING 是否有超时接管或恢复机制;
  10. 是否有过期清理、容量监控和隐私数据治理;
  11. 日志是否记录幂等键、业务资源 ID 和 replay 标志;
  12. 是否用真实数据库完成并发冲突测试。

十五、总结

接口幂等的关键不是某个注解或一把分布式锁,而是一份完整协议和一个可靠的原子边界:

  • 客户端用幂等键标识“同一次业务意图”;
  • 服务端用请求指纹防止错误复用;
  • 数据库唯一约束裁决并发请求;
  • 同一事务保证业务结果与幂等记录一致;
  • 状态机处理长流程、重试和故障恢复;
  • Redis 提升性能,但不取代业务最终约束。

只要系统允许重试,重复请求就不是异常,而是必然会出现的正常输入。把它纳入接口契约,远比在事故发生后补一把锁更可靠。


参考资料

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐