如何实现幂等 API?

201 阅读5分钟

在现代分布式系统中,幂等性是保证系统可靠性的重要特性。本文将详细介绍如何设计一个支持幂等操作的API,涵盖从客户端到服务端再到下游服务的完整流程。

幂等性基础概念

幂等性是指对同一个操作执行一次或多次所产生的影响是相同的。在API设计中,实现幂等性可以防止客户端因网络问题重试请求时导致重复操作,比如重复扣款、重复创建订单等问题。

客户端实现

客户端在发起状态变更类请求时,应当遵循以下规范:

  1. 为每个业务请求生成全局唯一的idempotency_key
  2. 通过请求头Idempotency-Key: <key_value>或请求体携带该标识
  3. 当请求失败或超时时,必须使用相同的idempotency_key进行重试

服务端实现

为简单服务添加幂等支持

对于仅需访问数据库而不需要访问其它服务的简单服务,可通过以下方案实现幂等。

在数据库里新增一个 idempotency_keys 表用于跟踪每个请求的执行情况。数据表设计:

CREATE TABLE idempotency_keys (
    idempotency_key VARCHAR(255) PRIMARY KEY,
    request_hash VARCHAR(255) NOT NULL COMMENT '请求参数哈希值',
    response_code INT COMMENT '缓存响应状态码',
    response_body TEXT COMMENT '缓存响应内容',
    version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
)

服务端具体实现:

public Response handleRequest(Request request) {
    // 1. 检查幂等键
    IdempotencyKey key = idempotencyRepository.findById(request.getIdempotencyKey());
    
    if (key != null) {
        // 2. 验证请求一致性
        if (!key.getRequestHash().equals(calculateRequestHash(request))) {
            throw new ValidationException("Request params are not consistent with stored idempotency key");
        }
        // 3. 返回缓存响应
        return new Response(key.getResponseCode(), key.getResponseBody());
    }
    
    // 4. 执行业务逻辑
    BusinessResult result = businessService.process(request);
    
    // 5. 保存幂等记录
    IdempotencyKey newKey = new IdempotencyKey(
        request.getIdempotencyKey(),
        calculateRequestHash(request),
        result.getStatusCode(),
        result.getBody()
    );
    
    // 6. 在事务中保存业务数据和幂等键
    transactionTemplate.execute(status -> {
        businessRepository.save(result.getData());
        idempotencyRepository.save(newKey);
        return null;
    });
    
    return new Response(result.getStatusCode(), result.getBody());
}

整个请求处理过程中只需要一次事务。而且修改幂等表和修改业务表必须在同一个事务里,这可以确保我们避免记录幂等令牌但无法修改业务表,或者修改业务表但无法记录幂等令牌的情况。

检查一下它如何应对各种情况:

场景处理机制
首次请求成功正常执行业务逻辑并持久化结果
重复请求直接返回缓存响应
并发重复请求通过乐观锁确保只有一个请求能成功提交

调用其它服务

当服务需要调用其他服务时,实现复杂度显著增加。首先其它服务也需要支持幂等。原因是如果本服务被重试,那么有可能需要重试对其它服务的调用。其次,要保证在多次调用其它服务的时候使用的是相同的 idempotency_key 和请求参数,因此需要在调用外部服务前先把请求和一些上下文持久化。

调用外部服务把整个执行分成了两个事务。第一个事务负责提交上下文到 idempotency_keys 表里,以及更新业务相关表。调用外部服务后,根据得到的返回再执行一些业务逻辑。第二个事务会更新业务相关表和把最终的 response 更新到 idempotency_keys 表里。因此我们需要在 idempotency_keys 里添加 recovery_point 和 recovery_data 两个字段。recovery_point 指示是执行到哪个阶段了,是事务一完成了还是整个请求都完成了。recovery_data 保存如果要恢复到调用外部服务前的一刻,需要恢复的上下文。

数据表扩展

ALTER TABLE idempotency_keys ADD COLUMN (
    recovery_point VARCHAR(32) COMMENT '执行进度标记',
    recovery_data JSON COMMENT '恢复执行所需数据',
);

服务端代码:

public Response handleComplexRequest(Request request) {
    // 1. 检查幂等键
    IdempotencyKey key = idempotencyRepository.findById(request.getIdempotencyKey());
    
    if (key == null) {
        key = new IdempotencyKey(
            request.getIdempotencyKey(),
            calculateRequestHash(request),
        );
        phase1(key, request, context);
    } else {
        // 2. 验证请求一致性
        if (!key.getRequestHash().equals(calculateRequestHash(request))) {
            throw new ValidationException("Request params are not consistent with stored idempotency key");
        }
        switch (key.getRecoveryPoint()) {
            case "AFTER_PHASE_1":
                // 从 recovery_data 恢复上下文
                ExecutionContext context = deserializeContext(key.getRecoveryData());
                phase2(key, request, context);
                break;
            case "COMPLETED":
                break;
            default:
                throw new IllegalStateException("Invalid recovery point");
        }
    }

    return new Response(key.getResponseCode(), key.getResponseBody());
}

private void phase1(IdempotencyKey key, Request request, ExecutionContext context) {
    // 执行第一阶段业务逻辑
    BusinessResult result = businessService.process(request);

    // 生成下游服务幂等键
    String downstreamIdempotencyKey = generateDownstreamIdempotencyKey();

    // 生成要调用下游的请求
    DownstreamRequest downstreamRequest = new DownstreamRequest(
        downstreamIdempotencyKey,
        // other params
    );

    ExecutionContext context = new ExecutionContext(downstreamRequest);
    
    // 更新幂等记录
    key.setRecoveryPoint("AFTER_PHASE_1");
    key.setRecoveryData(serializeContext(context));
    
    // 在事务中保存业务数据和幂等记录
    transactionTemplate.execute(status -> {
        businessRepository.save(result.getData());
        idempotencyRepository.save(key);
        return null;
    });

    phase2(key, request, context);
}

private void phase2(IdempotencyKey key, Request request, ExecutionContext context) {
    // 调用下游服务
    DownstreamResponse downstreamResponse = downstreamService.call(context.getDownstreamRequest());
    // 执行第二阶段的业务逻辑
    BusinessResult result = businessService.process(request, downstreamResponse);

    // 更新幂等记录
    key.setRecoveryPoint("COMPLETED");
    key.setRecoveryData(null);
    key.setResponseCode(result.getStatusCode());
    key.setResponseBody(result.getBody());

    // 在事务中保存业务数据和幂等记录
    transactionTemplate.execute(status -> {
        businessRepository.save(result.getData());
        idempotencyRepository.save(key);
        return null;
    })
}

关键点:

  1. 确保下游服务也支持幂等性
  2. 为每个下游调用生成唯一的 idempotency_key
  3. 外部服务调用把 request handling 区分成多个事务
  4. 每个事务负责提交业务表更新和更新 recovery_point 和 recovery_data
  5. recovery_point 表示上一次执行执行到哪个地点了。
  6. recovery_data 表示要恢复到 recovery_point 所指示的位置并继续执行时需要恢复的上下文。

全场景覆盖验证

故障场景系统行为
阶段一提交前崩溃无任何数据残留,等效于未收到请求
阶段一完成后崩溃重启后从recovery_data恢复上下文,继续执行阶段二
阶段二执行中崩溃重试时重新执行阶段二(依赖下游服务幂等性)
全流程完成后重试直接返回缓存响应

通过这套完整的幂等性设计方案,系统能够可靠地应对网络异常、服务重启等各种分布式场景,为业务提供强一致性的服务保障。

参考