在现代分布式系统中,幂等性是保证系统可靠性的重要特性。本文将详细介绍如何设计一个支持幂等操作的API,涵盖从客户端到服务端再到下游服务的完整流程。
幂等性基础概念
幂等性是指对同一个操作执行一次或多次所产生的影响是相同的。在API设计中,实现幂等性可以防止客户端因网络问题重试请求时导致重复操作,比如重复扣款、重复创建订单等问题。
客户端实现
客户端在发起状态变更类请求时,应当遵循以下规范:
- 为每个业务请求生成全局唯一的
idempotency_key - 通过请求头
Idempotency-Key: <key_value>或请求体携带该标识 - 当请求失败或超时时,必须使用相同的
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;
})
}
关键点:
- 确保下游服务也支持幂等性
- 为每个下游调用生成唯一的 idempotency_key
- 外部服务调用把 request handling 区分成多个事务
- 每个事务负责提交业务表更新和更新 recovery_point 和 recovery_data
- recovery_point 表示上一次执行执行到哪个地点了。
- recovery_data 表示要恢复到 recovery_point 所指示的位置并继续执行时需要恢复的上下文。
全场景覆盖验证
| 故障场景 | 系统行为 |
|---|---|
| 阶段一提交前崩溃 | 无任何数据残留,等效于未收到请求 |
| 阶段一完成后崩溃 | 重启后从recovery_data恢复上下文,继续执行阶段二 |
| 阶段二执行中崩溃 | 重试时重新执行阶段二(依赖下游服务幂等性) |
| 全流程完成后重试 | 直接返回缓存响应 |
通过这套完整的幂等性设计方案,系统能够可靠地应对网络异常、服务重启等各种分布式场景,为业务提供强一致性的服务保障。
参考
- Making retries safe with idempotent APIs
- Using Atomic Transactions to Power an Idempotent API
- Implementing Stripe-like Idempotency Keys in Postgres
- Avoiding Double Payments in a Distributed Payments System
- Designing for Correctness in a Distributed Payment System: Part 1
- Designing for Correctness in a Distributed Payment System: Part 2