那个把静默丢失更新变成 409 的 JPA 字段:@Version 乐观锁

· 3 min read · engineering javaspringjpahibernateresttesting

两个人同时打开同一篇文章,两个人都改了,两个人都保存。其中一次 保存悄无声息地消失了——没有报错,没有警告,而且 API 对两次请求 都返回了 HTTP 200。

这就是丢失更新(lost update)问题,是读-改-写(read-modify-write) API 里最安静的数据丢失 bug。我在做 Spring Boot 作品集 demo 时撞上 了它——这是一个 Spring Boot 3.5.16(Java 21)的 REST API,编辑器 从浏览器页面创建和更新文章。修法最终落在三处:JPA 实体上的一个 @Version 字段、请求契约里的一个字段、以及 API 层把它映射成 HTTP 409 Conflict 的一个异常。这篇文章就沿着真实代码走一遍这条路。

问题:一条编辑消失了,却谁都怪不上

为什么两个人保存同一条记录时,其中一个人的修改会消失?

跟着时间线走。编辑器 A 和编辑器 B 都拉取了同一篇文章。A 先保存: 行被更新,API 返回 200。B 稍后保存:行被再次更新,这次覆盖了 A 的文字,API 又返回 200。每个请求都成功;每个响应都告诉它的调用 方「你的保存就是这条记录的当前状态」——对其中一个人来说这是假话。 A 的编辑就这么没了,两个客户端都没有任何办法知道。

失败是静默的,因为它就是读-改-写流程的正常行为,不是异常。这正是 它在真实系统里存活的理由,也是为什么在要展示工程判断力的东西里, 值得把它修得干净。

我先试的方案:不带版本号的读-改-写,以及它为什么失败

我对更新流程的第一版草图是最直白的那种:GET 记录、编辑、 PUT 回去,只带新的标题和正文,什么都不带。

GET  /api/posts/{id}    # 读取记录
PUT  /api/posts/{id}    # 写回新的标题和正文

服务端加载行、套用更新、返回 200。它无从知道在这一行数据上,客户端 在 GET 和 PUT 之间已经被别人动过——读取和写入是两个互不相干的 请求,契约里没有任何东西把它们连起来。最后写入的人赢,而两个写入 者都被告知成功。

在这里,200 比报错更糟。报错至少告诉输掉的那个客户端「出事了」, 给他们一个去查的理由。200 则告诉两个客户端「你们的保存就是记录 当前的状态」——对其中一个是撒谎。输掉的那次编辑现在哪儿都不存在: 屏幕上没有,数据库里也没有;而因为客户端相信自己保存成功了, 没有人会去找这份数据。

修法:一个 @Version 字段、一份契约、一个冲突

修法一共五小步,全部都在 demo 的源码里。

1. 实体随身携带一个版本列

@Entity
@Table(name = "posts", indexes = @Index(name = "idx_posts_title", columnList = "title"))
@EntityListeners(AuditingEntityListener.class)
public class Post {

    @Id
    @GeneratedValue
    @UuidGenerator
    private UUID id;

    @Column(nullable = false, length = 160)
    private String title;

    @Column(nullable = false, length = 10_000)
    private String body;

    @Version
    private long version;
    // ...
}

@Version 告诉 Hibernate 把 version 当作乐观锁。它生成的每一条 UPDATE 都变成有条件的:

UPDATE posts
SET title = ?, body = ?, version = version + 1, ...
WHERE id = ? AND version = ?

如果这行数据的版本号已经和语句构造时的不一致,受影响行数为零, Hibernate 就会拒绝这次 flush,而不是悄悄覆盖。实体从不用手自增 计数器——更新方法只碰 title 和 body;版本号跟着写入一起走。

2. 版本号进入 API 契约

锁如果客户端无法参与,就没有用,所以版本号在两个方向上都穿过 API。响应的 record 暴露它:

public record PostResponse(UUID id, long authorId, String title, String body,
                           long version, Instant createdAt, Instant updatedAt) {
}

更新请求则要求把它带回来:

public record UpdatePostRequest(
        @NotBlank @Size(max = 160) String title,
        @NotBlank @Size(max = 10_000) String body,
        @NotNull @Min(0) Long version) {
}

UpdatePostRequest 旁边的学习注记说得很直白: 「the expected version is part of the update contract, making optimistic locking visible to clients.」 (期望的版本号是更新契约的一部分,让乐观锁对客户端可见。)

3. 在拥有写事务的 service 里检查版本

@CachePut(cacheNames = "posts", key = "#postId")
@Transactional
public PostResponse updatePost(UUID postId, UpdatePostRequest request) {
    Post post = postRepository.findById(postId)
            .orElseThrow(() -> new PostNotFoundException(postId));
    if (post.getVersion() != request.version()) {
        throw new PostVersionConflictException(postId);
    }
    post.update(request.title().trim(), request.body().trim());
    return PostResponse.from(postRepository.saveAndFlush(post));
}

读取、比较、保存全部发生在一个 @Transactional 方法里——这正是让 检查诚实的那部分:版本号是和写入那一刻的行状态比较,而不是和 早前某个请求留下的快照比较。如果读取和写入分属不同事务,检查到的 就是这行数据已经离开的旧版本,整套机制就成了表演。PostService 的学习注记点名了这条规则:「service methods centralize transaction boundaries, cache coherence, and optimistic-locking rules.」(service 方法集中管理事务边界、缓存 一致性与乐观锁规则。)demo 还开着 spring.jpa.open-in-view: false,所以不会有残留的会话跨请求地 供给过期实体——行是在写入事务内部重新读取的。

显式的检查会确定性地挡掉过期请求。@Version 列在底层仍然有意义: 如果检查与 flush 之间恰好插进一次别的提交,那条有条件的 UPDATE 影响零行,Hibernate 照样拒绝这次写入。

4. 异常处理器把它映射成 409

异常本身带着客户端能直接照做的信息:

public class PostVersionConflictException extends RuntimeException {

    public PostVersionConflictException(UUID postId) {
        super("Post %s has changed; fetch it again before retrying".formatted(postId));
    }
}

错误边界把它转成正确的 HTTP 应答:

@ExceptionHandler(PostVersionConflictException.class)
ProblemDetail handleConflict(PostVersionConflictException exception) {
    return problem(HttpStatus.CONFLICT, "POST_VERSION_CONFLICT", exception.getMessage());
}

HttpStatus.CONFLICT 就是 HTTP 409,响应是一份 problem document——一个 type URI、一个稳定的机器码 (POST_VERSION_CONFLICT)和一段人类可读的说明。demo 在配置里 开启了框架的 problem-details 支持 (spring.mvc.problemdetails.enabled: true),所以校验错误和所有 其他错误都共用同一种响应形状。

5. 整条链路,从头到尾

过期的 PUT /api/posts/{id}
  -> PostService.updatePost 在事务内部重新读取这一行
  -> post.getVersion() != request.version()
  -> PostVersionConflictException: "fetch it again before retrying"
  -> ApiExceptionHandler.handleConflict
  -> HTTP 409, ProblemDetail with code POST_VERSION_CONFLICT
  -> 客户端知道自己的前提过期了

一个 @Version 字段、请求契约里的一个 version、一个异常、一个 handler 方法。Controller 什么都没改——updatePost 看起来还是普通 的 PUT。

为什么答案是冲突,而不是重试

当两个编辑器产出同一份记录的两种不同版本时,服务端无法知道哪一方 的意图才是被需要的。从各自作者的角度看,两次保存都正当;重放那条 过期的写入,只是重放同一次数据丢失。

诚实的结论就是 HTTP 对 409 的定义:请求与资源的当前状态冲突, 并且客户端被明确告知如何解决——「fetch it again before retrying」 (重新取一次再试)。输掉的客户端重新读取记录,看到另一位作者的 修改,由人来决定保留什么。静默覆盖变成了一处看得见的决策点——这 正是这件事的全部意义。

这个 demo 的其余部分证明了什么

版本字段只是一个小系统里的一层,而其余部分都能用同样的方式核实 ——下面每一行都是仓库里的一个类或配置文件,不是一句口号:

层做了什么在哪
缓存单篇文章读取使用有界 Caffeine 缓存(上限 500,TTL 10 分钟,都来自配置);读取用 @Cacheable、更新用 @CachePut、删除用 @CacheEvict;GET /api/showcase/cache 暴露请求数、命中数、未命中数和命中率CacheConfig、CacheProperties、ShowcaseMetricsController
安全无状态 HTTP Basic + BCrypt;页面与读 API 公开,所有写操作需要 EDITOR 角色;本地默认是专用的非机密 demo-editor/changeit 账户,可用环境变量覆盖SecurityConfig、application.yml
错误请求 record 在边界处校验;一个 @RestControllerAdvice 返回一致的 problem document 和机器码——POST_NOT_FOUND(404)、POST_VERSION_CONFLICT(409)、带字段错误表的 VALIDATION_FAILED(400)CreatePostRequest、UpdatePostRequest、ApiExceptionHandler
配置本地 profile 使用内存 H2 数据库(PostgreSQL 模式),ddl-auto: update 并预置示例文章;prod 选择 PostgreSQL,所有凭据来自 APP_* 环境变量,ddl-auto: validateapplication.yml、application-prod.yml
测试一个 @SpringBootTest 套件断言:渲染的 Thymeleaf 页面、未认证 401 对编辑器 201、VALIDATION_FAILED 问题响应、公开搜索,以及重复读取后的缓存命中DemoApplicationIntegrationTest

如果做真东西,我会怎么改

一旦这不再是本地 demo,有两件事立刻要变。项目 README 里两件都写了, 值得作为我自己的判断再重复一遍。

第一,schema 管理。我会在任何时候把 ddl-auto 设为 validate 之前, 先加 Flyway 或 Liquibase 迁移。demo 默认 profile 用的是 update, 在临时数据库上很方便,养成习惯就很危险——生产 schema 应该是 有版本管理的代码,而不是启动时的副作用。

第二,密钥。本地编辑器账户是刻意的非机密 demo 账户,带默认凭据。 做真东西的话,我会把凭据放进托管的密钥存储(managed secret store), 拿不到凭据就拒绝启动,而不是回退到默认值。prod profile 已经做到 所有凭据来自环境变量、不提交任何密钥——这正是我想要的形态, 只是不要那些回退值。

结果

过期的更新现在返回 HTTP 409,机器码 POST_VERSION_CONFLICT,而不是 一个静静丢掉另一位作者修改的 200。丢失不再可能悄悄发生:失败是 响亮的、可操作的——「fetch it again before retrying」——并且集成 测试套件端到端地断言了访问与校验行为。

整个修法就是一个字段上的一个注解,加上让版本号成为契约一部分的 管道代码。如果一条记录可能同时被两个人读取,这个字段就是「编辑 丢失」与「双方都看得见的冲突」之间的分界线。

我平时就做 Java/Spring 后端和全栈 Web 应用——从浏览器页面到数据库 的 REST API:JPA、乐观锁、缓存、校验和一整套集成测试。如果你的表单 或字段里也有「编辑悄悄消失」的问题,跟我说说: WhatsApp · [email protected] · hoelee.com。

Lee Teong Hoe

Full-stack developer & DevOps engineer. I build web apps, self-host infrastructure, and automate things — this blog is my living portfolio.