《Spring Boot 实战》5.3 幂等与并发控制

讲清 POST 为什么需要幂等键、幂等键的三种实现(唯一索引 / Redis 去重 / 状态机)各自的可靠性边界、乐观锁与 If-Match/ETag 的条件请求写法、并发写冲突用 409 还是 412 的取舍与重试语义,以及限流与防重各自解决什么问题。

本节目标:让写接口在超时重试、重复点击、并发提交下不产生重复数据——掌握幂等键的三种实现、条件请求与乐观锁的配合,并分清限流与防重各自解决什么。
适用版本: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 边界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计