Spring Boot 接口幂等设计:幂等键、唯一索引与订单状态机
用户点击“提交订单”后迟迟没有看到响应,于是再次点击;网关认为第一次请求超时,自动重试;消息消费者处理完成后还没来得及提交位点,进程突然退出。三个场景背后是同一个问题:
调用方无法确定上一次请求是否成功,只能重试;服务端必须把重复到达当作正常情况处理。
幂等接口需要保证:对于同一个业务请求,多次执行与执行一次产生相同的业务效果。
本文示例基于 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);
}
}
}
这里没有使用“捕获唯一键异常后继续查询”的写法。某些持久化框架在发生约束异常后会将当前事务标记为回滚,随后继续执行业务很容易在提交时遇到 UnexpectedRollbackException。ON 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
);
}
}
}
这套实现把以下三次写入放在同一个本地事务中:
- 插入幂等记录;
- 创建订单;
- 保存成功响应。
因此不会出现“订单已经提交,但幂等记录仍是失败”的中间状态。如果创建订单时抛出运行时异常,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
推荐流程:
- 短事务写入
PROCESSING记录和业务任务; - 后台工作器执行外部调用;
- 成功后更新业务状态和幂等结果;
- 可重试失败由任务系统按退避策略重试;
- 客户端重复请求读取当前状态,而不是再次调用外部系统。
但拆分事务会引入“工作器崩溃后记录长期停留在 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
);
定时任务每次删除一小批,循环执行或等待下一周期。对于支付等高审计要求业务,幂等记录可以只删除响应快照而保留不可变审计字段。
十三、必须覆盖的并发测试
幂等功能只测一次正常请求远远不够。至少需要以下用例:
- 同一个键和请求体串行调用两次,只创建一条订单;
- 同一个键并发调用 20 次,只创建一条订单;
- 同一个键搭配不同请求体,第二次返回 409;
- 首次事务失败后,使用同一个键能够重试成功;
- Redis 不可用时,数据库唯一约束仍然生效;
- 响应回放的状态码和响应体与首次调用一致;
- 不同用户使用相同键时互不影响。
一个并发测试示例:
@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 启动与生产版本一致的数据库。
十四、生产检查清单
上线前逐项确认:
- 幂等键是否由高熵随机值生成,并限制长度与字符集;
- 唯一范围是否包含用户或租户与业务操作;
- 是否保存请求指纹并拒绝同键不同参数;
- 是否由数据库唯一约束承担最终并发裁决;
- 业务写入与幂等结果是否处于正确的事务边界;
- 响应回放是否保留原始状态码和必要响应头;
- 是否区分确定性失败与可重试失败;
- Redis 故障时是否仍保持正确性;
- PROCESSING 是否有超时接管或恢复机制;
- 是否有过期清理、容量监控和隐私数据治理;
- 日志是否记录幂等键、业务资源 ID 和 replay 标志;
- 是否用真实数据库完成并发冲突测试。
十五、总结
接口幂等的关键不是某个注解或一把分布式锁,而是一份完整协议和一个可靠的原子边界:
- 客户端用幂等键标识“同一次业务意图”;
- 服务端用请求指纹防止错误复用;
- 数据库唯一约束裁决并发请求;
- 同一事务保证业务结果与幂等记录一致;
- 状态机处理长流程、重试和故障恢复;
- Redis 提升性能,但不取代业务最终约束。
只要系统允许重试,重复请求就不是异常,而是必然会出现的正常输入。把它纳入接口契约,远比在事故发生后补一把锁更可靠。
参考资料
更多推荐
所有评论(0)