本节目标:让写接口在超时重试、重复点击、并发提交下不产生重复数据——掌握幂等键的三种实现、条件请求与乐观锁的配合,并分清限流与防重各自解决什么。
适用版本:Spring Boot 4.1.x(Java 21)
5.3 幂等与并发控制
前两节把「读」做干净了:资源建模清晰、列表分页可控。这一节处理「写」,而写接口在生产上最常出的事故不是逻辑错,是同一个请求被执行了两次。
场景很具体。会员在 App 上点「借书」,网络抖动导致客户端超时;客户端按重试策略又发了一次。如果服务端不设防,Loan 表里就出现两条记录——同一本书被同一个会员借了两次,库存对不上。这个问题的根源不在客户端,而在服务端把一个可能被重放的操作当成了天然唯一。
本节把「怎么让写操作在重放下保持正确」讲透,同时说清它和并发控制(同一资源被两个请求同时改)是两件不同的事。
5.3.1 为什么偏偏是 POST 需要幂等键
先看 HTTP 方法自带的幂等性:
| 方法 | 幂等 | 重放后果 |
|---|---|---|
| GET / PUT / DELETE | 是 | 重复执行结果相同,安全重试 |
| POST | 否 | 每次执行都产生新资源 |
PUT /books/42 重复发一百次,结果还是「42 号书被替换成这份内容」;DELETE /books/42 重复发,第一次删掉、后面几次返回 404 或 204,最终状态一致。只有 POST 会「每发一次多一条」。
所以幂等的重心落在 POST 上。业界通行做法是让客户端为每个「逻辑操作」生成一个唯一的 幂等键(Idempotency Key),随请求带上:
POST /loans
Idempotency-Key: 6f1c2a90-7d3b-4e11-9c5a-2b8f0e4d1a77
Content-Type: application/json
{ "bookId": 42, "memberId": 7 }
服务端记住这个键,第一次执行并把结果存下来,之后任何带同一个键的请求都直接返回第一次的结果,不再执行业务逻辑。客户端重试时用同一个键,就得到了「安全重试」的语义。
Idempotency-Key 目前是 IETF 的草案头(尚未成为正式 RFC),但已被 Stripe 等大量支付类 API 采用,实践上足够成熟。要不要引入它,判断标准是:这个 POST 操作如果被执行两次,会不会造成用户可感知的损害?
- 借书、扣款、下单、发消息 → 会(重复借阅、重复扣款)→ 需要。
- 写日志、埋点上报 → 不会(顶多多一条记录)→ 可以不引入,避免全站复杂度。
不要给每个 POST 都加幂等键。 那会引入一张幂等表、一次额外查询、一套清理机制,而收益在多数接口上为零。只在「重复执行有真实代价」的写接口上加。
5.3.2 三种实现:唯一索引 / Redis 去重 / 状态机
实现一:唯一索引兜底(最可靠)。
把幂等键作为一张表的主键或唯一索引,让数据库来保证「同一个键只能有一条记录」:
CREATE TABLE idempotency_record (
idempotency_key VARCHAR(64) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
response_body TEXT,
status VARCHAR(16) NOT NULL,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (idempotency_key)
);
@Service
class IdempotencyService {
private final IdempotencyRecordRepository repository;
IdempotencyService(IdempotencyRecordRepository repository) {
this.repository = repository;
}
Optional<String> findCachedResponse(String key, String requestHash) {
return repository.findById(key).map(record -> {
if (!record.getRequestHash().equals(requestHash)) {
// 同一个键配了不同的请求体,属于客户端误用
throw new IllegalStateException("Idempotency-Key reused with different payload");
}
return record.getResponseBody();
});
}
void remember(String key, String requestHash, String responseBody) {
try {
repository.save(new IdempotencyRecord(key, requestHash, responseBody, "DONE"));
} catch (DataIntegrityViolationException ignored) {
// 并发下另一个线程已抢先写入,忽略即可
}
}
}
这里的 request_hash 是一道重要的防线:同一个幂等键如果配了不同的请求体,说明客户端误用(键没有真正唯一),必须拒绝而不是静默返回旧结果,否则客户端以为改的是新内容,实际拿到的是旧响应。
唯一索引方案的优点是「不依赖外部组件、并发安全由数据库保证」,代价是每个写请求多一次插入。它是推荐默认方案,因为借阅服务本就有数据库,不引入新的故障点。
实现二:Redis 去重(快,但要处理失败窗口)。
用 SET key value NX EX ttl 做「第一次占用」:
Boolean firstTime = redis.opsForValue()
.setIfAbsent("idem:" + key, "PROCESSING", Duration.ofHours(24));
setIfAbsent 对应 Redis 的 SET NX EX,原子地完成「不存在才写入并设过期」。它比数据库快得多,适合高 QPS 场景。
但要清楚它的两个边界:其一,处理中途崩溃会留下一个永远 PROCESSING 的键,后续重试全被当成「进行中」而拒绝,需要额外的状态与超时回收逻辑;其二,它不是权威存储,Redis 主从切换或数据丢失后去重能力就没了,所以金额、库存这类不能出错的操作,仍然要用数据库唯一索引兜底。
本机没有可用的 Redis 实例,下面这段是示例输出,用于说明日志形态,不是实测结果:
2026-09-24T18:12:03.221+08:00 INFO 51234 --- [nio-8080-exec-3] c.e.loan.IdempotencyFilter : idempotency hit key=6f1c2a90 status=PROCESSING, returning 409 retry-later
实现三:状态机(业务自带幂等)。
有些操作的幂等性可以从领域模型里自然得到,不必额外加键。还书就是典型:
@Transactional
public LoanResponse returnLoan(Long loanId) {
Loan loan = loanRepository.findById(loanId)
.orElseThrow(() -> new LoanNotFoundException(loanId));
if (loan.getStatus() == LoanStatus.RETURNED) {
// 已经还过了,直接返回同一结果,不报错
return LoanResponse.from(loan);
}
loan.markReturned(Instant.now());
return LoanResponse.from(loanRepository.save(loan));
}
returnLoan 重复调用不会产生第二条归还记录,因为它只是把状态从 ACTIVE 推到 RETURNED,而 RETURNED 是个吸收态——再推一次不变。这类「状态迁移天然幂等」的操作,不需要幂等键,也不需要去重表。
| 方案 | 可靠性 | 成本 | 适用 |
|---|---|---|---|
| 唯一索引 | 高(数据库保证) | 一次插入 | 默认选择,尤其涉钱涉库存 |
| Redis 去重 | 中(有失败窗口、非持久权威) | 一次 SETNX | 高 QPS、可容忍极端情况 |
| 状态机 | 高(由领域约束保证) | 零额外存储 | 状态迁移类操作 |
三者可以叠加:状态机处理「本来就能幂等」的操作,唯一索引兜住其余写接口,Redis 只在压测证明数据库插入是瓶颈时才引入。先上唯一索引,别一开始就上 Redis——复杂度是渐进加的。
5.3.3 乐观锁与 If-Match:让「基于旧版本的更新」失败
幂等键解决的是「同一个请求被重放」,还有另一类问题:两个不同的请求并发修改同一资源,后写的覆盖先写的(丢失更新)。
比如会员在手机上改联系方式,同时在网页端也改。两个请求都读到版本 3,都基于版本 3 写回,最后一个提交的把前一个的修改覆盖了。
JPA 的 @Version 提供乐观锁:
@Entity
class Member {
@Id
private Long id;
@Version
private Long version;
private String email;
// ...
}
有了 @Version,更新时 Hibernate 会带上 WHERE version = ?,并在提交时把版本加一。如果两个事务基于同一个版本更新,第二个会因影响行数为 0 而抛 ObjectOptimisticLockingFailureException。乐观锁的关键是「不提前加锁」,冲突只在提交时暴露,适合读多写少、冲突概率低的场景。
把版本暴露到 HTTP 层,就得到标准的条件请求——用 ETag 下发版本、用 If-Match 校验:
@GetMapping("/books/{id}")
ResponseEntity<BookResponse> get(@PathVariable Long id, WebRequest request) {
Book book = bookService.get(id);
String etag = "\"" + book.getVersion() + "\"";
if (request.checkNotModified(etag)) {
return null; // 框架会写成 304
}
return ResponseEntity.ok().eTag(etag).body(BookResponse.from(book));
}
@PutMapping("/books/{id}")
ResponseEntity<BookResponse> update(@PathVariable Long id,
@RequestHeader(value = "If-Match", required = false) String ifMatch,
@RequestBody @Valid BookUpdateRequest body) {
Book book = bookService.get(id);
if (ifMatch == null || !ifMatch.equals("\"" + book.getVersion() + "\"")) {
throw new PreconditionFailedException("Stale version, refetch the resource first");
}
return ResponseEntity.ok(BookResponse.from(bookService.update(id, body)));
}
客户端流程变成:先 GET 拿到 ETag: "3",改完带 If-Match: "3" 提交;若期间别人改过,服务端返回 412,客户端重新拉取再改。这就是「乐观并发控制」在 HTTP 上的标准形态。
什么时候用乐观锁,什么时候用悲观锁?
| 场景 | 选择 | 理由 |
|---|---|---|
| 读多写少,冲突概率低 | 乐观锁(@Version) | 无锁开销,冲突时失败重试即可 |
| 写密集、冲突频繁 | 悲观锁(SELECT ... FOR UPDATE) | 乐观锁会大量失败重试,反而更慢 |
| 跨请求的编辑(表单) | 乐观锁 + If-Match | 用户思考期间不能持锁 |
| 秒杀式扣减库存 | 悲观锁或原子更新 | 冲突极密,且不容忍重试窗口 |
5.3.4 冲突响应:409 还是 412,以及重试语义
两个状态码容易混:
- 409 Conflict:请求与资源当前状态冲突,语义上「这个操作现在做不了」。例:把已借出的书再借一次、把已归还的借阅再归还(若状态机不允许)。它和「版本」无关。
- 412 Precondition Failed:请求带的前置条件(
If-Match/If-None-Match)不满足。它专门用于条件请求,是乐观锁失败的标准回答。
选错会让客户端无法区分「该重试」和「该放弃」:
| 状态码 | 含义 | 客户端该做什么 |
|---|---|---|
| 409 | 与当前状态冲突 | 通常不自动重试,提示用户或重新决策 |
| 412 | 版本已过期 | 可以自动重取资源、重新提交 |
| 428 | 要求条件请求(服务器要求带 If-Match) | 补上 If-Match 再发 |
| 429 | 限流 | 等 Retry-After 后重试 |
重试语义必须显式约定,否则客户端会盲目重试。 建议在错误响应的 ProblemDetail 里加一个机器可读的字段:
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
pd.setProperty("retryable", false);
pd.setProperty("conflictType", "BOOK_ALREADY_LOANED");
客户端读 retryable 决定是否重试,读 conflictType 决定给用户什么提示。把「能不能重试」写进响应体,比让客户端靠状态码猜要可靠得多。
对幂等键冲突(同一键的请求还在处理中)返回 409 是常见做法,但要让客户端知道「稍后原样重发即可」——这时 retryable=true 就派上用场。
5.3.5 限流与防重:两件不同的事
这两个词经常被混用,但解决的是完全不同的问题:
| 幂等 / 防重 | 限流 | |
|---|---|---|
| 解决的问题 | 同一请求被执行多次 | 单位时间请求过多 |
| 依据 | 幂等键(请求内容身份) | 频率(时间窗口计数) |
| 命中后 | 返回首次结果,不重复执行 | 返回 429,拒绝 |
| 典型实现 | 唯一索引 / 状态机 | 令牌桶 / 滑动窗口 |
一个具体的区分:会员连点 10 次「借书」按钮,10 次请求带的是同一个幂等键(同一次逻辑操作),幂等机制让它们只产生一条记录。而如果会员用脚本每秒发 100 个不同的借书请求(不同键),幂等机制毫无作用,能挡住它的是限流。
两者缺一不可,但不能互相替代。 只在网关做限流,挡不住「1 秒内两次正常点击」这种低速重复;只做幂等,挡不住高频攻击。生产上通常是:网关做粗粒度限流(按 IP / 用户),业务层做细粒度幂等(按操作身份)。
还要注意幂等键的清理。那张 idempotency_record 表不能无限增长,需要按时间清理过期键(比如保留 24 小时):
DELETE FROM idempotency_record WHERE created_at < NOW() - INTERVAL '24 hours';
保留窗口就是「允许客户端重试的最长时间」——太短,慢重试会重复执行;太长,表和存储涨得快。24 小时是常见起点,按业务的重试窗口调整。
5.3.6 常见坑
坑一:用请求体哈希当幂等键。 两个内容相同但确实是两次不同操作的请求(比如连下两单相同的商品),哈希相同会被误判为重复。幂等键必须由客户端为每次逻辑操作生成,不能用请求内容推导。
坑二:幂等记录和业务数据不在同一事务。 先写幂等记录再执行业务、或反过来,中间崩溃就会出现「记录了但没执行」或「执行了但没记录」。正确做法是在同一个本地事务里写业务数据与幂等记录,要么都成功要么都回滚。
坑三:把乐观锁异常直接抛给用户。 ObjectOptimisticLockingFailureException 是内部异常,应该转成 412 或 409 并附上 retryable,而不是让客户端看到一段堆栈。
坑四:ETag 用可变字段生成。 用 updatedAt 或对象哈希当 ETag,任何字段变化都会让 ETag 变,导致大量无谓的 412。用版本号这类单调递增、语义明确的字段更稳。
坑五:以为加了幂等键就万无一失。 幂等键依赖客户端在重试时用同一个键。如果客户端每次重试都生成新键,服务端照样重复执行。所以幂等是客户端与服务端的契约,文档里必须写清「重试请复用原键」。
小结
- 只有 POST 天然不幂等,所以幂等键主要加在「重复执行有真实代价」的写接口上。
- 三种实现各有边界:唯一索引最可靠(推荐默认),Redis 快但有失败窗口且非权威,状态机适用于状态迁移类操作,三者可叠加。
- 幂等键由客户端生成,服务端用请求哈希校验「同键不同体」的误用;业务数据与幂等记录必须在同一事务。
- 乐观锁用
@Version,配合ETag/If-Match落到 HTTP 层;冲突时 412 表示版本过期(可重试),409 表示状态冲突(通常不可重试)。 - 把「能否重试」写进
ProblemDetail的retryable字段,比让客户端猜状态码可靠。 - 限流与幂等是两件事:前者按频率拒绝,后者按操作身份去重,生产上要同时具备,并给幂等表设清理窗口。
到这里,接口的「形状」「读」「写」都定下来了。下一章回到实现层:6.1 会讲 Repository 与 DTO 的边界划分,把本节用到的幂等记录、分页信封这些结构落到代码组织上。
阅读导航:上一节:5.2 分页、过滤与排序 · 下一节:6.1 Repository 与 DTO 边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。