《Java 编程实战》5.3 并发控制:唯一索引、条件更新与幂等

两个借阅请求同时通过「没有活动借阅」检查时,应用层的先查再插并不安全。本节用两个 JDBC 连接真实复现部分唯一索引冲突、条件更新幂等归还与死锁 40P01,并给出 libraryd 借阅、归还和批量写的最终写法,说明为什么 synchronized 在多实例下无效。

本节目标:让「同一本书只能有一条活动借阅」和「重复归还不出错」由数据库约束与条件更新兜住,而不是靠应用层的一次查询。
适用版本:JDK 25(Temurin 25.0.5)、PostgreSQL 17、PostgreSQL JDBC 42.7.14。

承接的问题

5.1 节把借阅的三条 SQL 放进了同一个事务,5.2 节说明了并发事务之间能看到什么。但还有一个更直接的竞态没有解决,时间线示意如下:

请求 A:select ... where isbn = ? and returned_on is null  -> 0 行
请求 B:select ... where isbn = ? and returned_on is null  -> 0 行
请求 A:insert into loans (...)                            -> 成功
请求 B:insert into loans (...)                            -> 成功

两条活动借阅记录就这样出现了。问题不在于事务开没开,而在于「检查」和「写入」之间有一个时间窗。read committed 下每条语句取新快照,两个事务都可能在对方提交前通过检查。

归还也有类似问题。两个重试请求同时到达,如果代码是「先按 ISBN 查活动借阅,再按 id 更新」,第二次更新可能把 returned_on 改成另一个日期,甚至把已经算好的罚金覆盖掉。

这一节的原则是:把不变量写进数据库,把重复执行写成幂等。 实验沿用 5.1 节的 open() 与 libraryd-pg-55434 容器。

核心机制一:部分唯一索引是最后一道防线

V1__init.sql 里有一行不起眼但很关键的索引:

create unique index ux_loans_active_isbn on loans (isbn) where returned_on is null;

这是一个部分唯一索引(partial unique index)。它只约束 returned_on is null 的行,也就是活动借阅。效果是:

  • 同一本 ISBN 最多只能有一条活动借阅。
  • 已经归还的历史记录不参与唯一性判断,所以一本书归还后可以再次借出,历史上可以有多条 returned_on 非空的记录。

直接在 psql 里对已有活动借阅的 ISBN 再插一条:

insert into loans (isbn, member_id, borrowed_on, due_on)
values ('9787111544937', 'm-001', date '2026-10-18', date '2026-11-01');

本机输出:

ERROR:  duplicate key value violates unique constraint "ux_loans_active_isbn"
DETAIL:  Key (isbn)=(9787111544937) already exists.

这正是我们想要的行为。应用层不需要「先查再插」,可以直接插入,让索引裁决。冲突时 JDBC 抛出 SQLException,SQLState 是 23505,约束名在 ServerErrorMessage.getConstraint() 里。

3.3 的 SqlErrors.translate 已经按约束名把 23505 翻成 BOOK_ALREADY_BORROWED(HTTP 409),JdbcLoanRepository.insert 只需要在 catch 里调用它:

} catch (SQLException e) {
    throw SqlErrors.translate(e);
}

BOOK_ALREADY_BORROWED 比 500 更准确:请求本身没写错,是资源状态冲突。服务层不再重复判断 SQLState,翻译只发生在一个地方。

需要强调的是,索引不是「优化」,这里是约束。没有它,应用层任何先查再插的写法都有竞态;有了它,即使十个请求同时插入,也只有一个能成功。

核心机制二:两个连接同时借同一本书

用一个最小实验复现竞态。两个线程在同一时刻对同一个 ISBN 执行插入,各自使用独立连接:

static int borrow(String isbn, String member) throws SQLException {
    try (Connection c = open(); PreparedStatement ps = c.prepareStatement(
            "insert into loans (isbn, member_id, borrowed_on, due_on) " +
            "values (?, ?, date '2026-10-18', date '2026-11-01')")) {
        ps.setString(1, isbn);
        ps.setString(2, member);
        return ps.executeUpdate();
    }
}

本机输出:

[borrow-A] inserted rows = 1
[borrow-B] SQLState=23505 constraint=ux_loans_active_isbn msg=ERROR: duplicate key value violates unique constraint "ux_loans_active_isbn"

一个成功,一个失败。失败者拿到的是约束冲突,不是「查询发现已借出」。这两种错误对调用方看起来相似,但可靠性完全不同:前者由数据库在写入瞬间判定,后者依赖查询和插入之间的时间窗。

在 libraryd 的 BorrowService 里,流程因此简化成「检查书存在、saveIfAbsent 会员、直接插入」:

public Loan borrow(String isbn, String memberId, LocalDate today) {
    return transactions.inTransaction(Isolation.READ_COMMITTED, repositories -> {
        repositories.books().findByIsbn(isbn).orElseThrow(() -> new LibraryException(
                ErrorCode.BOOK_NOT_FOUND, "图书不存在: " + isbn));
        repositories.members().saveIfAbsent(new Member(memberId, memberId));
        return repositories.loans().insert(Loan.open(isbn, memberId, today));
    });
}

BOOK_ALREADY_BORROWED 是运行时异常,会触发 JdbcTransactionRunner 的 rollback(),所以会员的 saveIfAbsent 也不会留下。异常继续向上传到 web 层,由 ErrorCode.httpStatus() 决定 409。

核心机制三:条件更新让归还幂等

归还的关键不是「找到借阅再更新」,而是「只更新仍然活动的借阅」。把条件写进 where:

update loans
set returned_on = ?,
    fine_cents = greatest(0, ?::date - due_on) * ?
where isbn = ? and returned_on is null
returning id, isbn, member_id, borrowed_on, due_on, returned_on, renew_count, fine_cents;

3.3 的 markReturned(long id, LocalDate, long fineCents) 已经带了 where returned_on is null,但它要求调用方先查到借阅 id 和 due_on。本节把查询、罚金计算和条件更新合并成一条 SQL,少一次往返,也少一个「查到旧 due_on 再更新」的窗口。

这条 SQL 有三个特点:

  • returned_on is null 是条件的一部分。只有活动借阅会被更新。
  • 罚金在 SQL 里用 greatest(0, 归还日 - 到期日) * 每日罚金 算,避免「先查 due_on、再在 Java 里算、再更新」的第二次竞态。
  • returning 一次返回更新后的整行和 fine_cents,不需要再查一次。

连续执行两次,第二次的 UPDATE 行数是 0,本机输出:

 id |     isbn      | member_id | returned_on 
----+---------------+-----------+-------------
  3 | 9787111544937 | m-001     | 2026-10-19
(1 row)

UPDATE 1
 id | isbn | member_id | returned_on 
----+------+-----------+-------------
(0 rows)

UPDATE 0

这就是幂等的来源:第一次更新 1 行,第二次更新 0 行,数据库状态不再变化。应用层拿到 0 行时,不应该当成「没找到」直接报错,而要先区分两种情况:

  • 这本书确实有过借阅,且已经归还:返回已有的归还结果,重复请求得到同样的回执。
  • 这本书从来没有借阅记录:返回 LOAN_NOT_FOUND。

ClosedLoan 放在 domain,因为 LoanRepository 是 domain 端口,不能返回 persistence 的类型:

package com.plume.library.domain;

public record ClosedLoan(Loan loan, long fineCents) {
}

LoanRepository 增加两个方法,参数不再出现 Connection:

Optional<ClosedLoan> closeActiveByIsbn(String isbn, LocalDate today, long finePerDay);

Optional<ClosedLoan> findLatestByIsbn(String isbn);

实现类只从事务构造器取 Connection,连接由 JdbcTransactionRunner 管理;3.3 的 DataSource 构造器保留给单条 SQL 的只读方法。两条路径不会混用:事务构造器下 dataSource 为 null,只有 closeActiveByIsbn、findLatestByIsbn 这类事务内方法会被调用。

@Override
public Optional<ClosedLoan> closeActiveByIsbn(String isbn, LocalDate today, long finePerDay) {
    String sql = """
            update loans
            set returned_on = ?,
                fine_cents = greatest(0, ?::date - due_on) * ?
            where isbn = ? and returned_on is null
            returning id, isbn, member_id, borrowed_on, due_on,
                      returned_on, renew_count, fine_cents
            """;
    try (PreparedStatement ps = connection.prepareStatement(sql)) {
        ps.setObject(1, today);
        ps.setObject(2, today);
        ps.setLong(3, finePerDay);
        ps.setString(4, isbn);
        try (ResultSet rs = ps.executeQuery()) {
            if (!rs.next()) {
                return Optional.empty();
            }
            return Optional.of(new ClosedLoan(readLoan(rs), rs.getLong("fine_cents")));
        }
    } catch (SQLException e) {
        throw SqlErrors.translate(e);
    }
}

setObject(1, today) 让 PostgreSQL JDBC 把 LocalDate 绑定成 date;?::date 是为了让第二个参数参与日期减法,得到整数天数。FinePolicy.dailyFineCents() 返回 50,单位是分。

服务层在更新 0 行时补一次查询,把「已归还」和「从未借阅」分开:

public ClosedLoan returnBook(String isbn, LocalDate today) {
    return transactions.inTransaction(Isolation.READ_COMMITTED, repositories -> {
        Optional<ClosedLoan> closed = repositories.loans()
                .closeActiveByIsbn(isbn, today, FinePolicy.dailyFineCents());
        if (closed.isPresent()) {
            return closed.get();
        }
        return repositories.loans().findLatestByIsbn(isbn)
                .filter(result -> !result.loan().active())
                .orElseThrow(() -> new LibraryException(
                        ErrorCode.LOAN_NOT_FOUND, "没有找到在借记录: " + isbn));
    });
}

findLatestByIsbn 同样返回 ClosedLoan,内部用 order by id desc limit 1 取这本书最近一条记录,并带上 fine_cents。如果它是已归还状态,就直接返回已有的罚金;如果仍然是活动状态(例如另一个事务刚插入但还没提交),read committed 的这次查询看不到它,会落到 LOAN_NOT_FOUND,客户端重试即可。

边界:如果两个归还请求几乎同时到达,后到的 update 会等待前一个事务提交,然后重新判断 where 条件,得到 0 行。它不会覆盖第一次的 returned_on 和 fine_cents。

核心机制四:死锁与固定加锁顺序

条件更新解决了单行幂等,但多行操作仍可能死锁。两个事务以相反顺序锁两行时,PostgreSQL 的死锁检测器会 abort 其中一个,SQLState 是 40P01。

实验用两个线程,各自先锁一行,再锁另一行,顺序相反,本机输出:

[tx-1] SQLState=40P01 msg=ERROR: deadlock detected
[tx-2] committed

本机这次是 tx-1 被选为牺牲者,tx-2 正常提交。错误原文:

ERROR: deadlock detected

死锁不是靠「避免同时执行」解决的,而是靠固定加锁顺序减少发生概率,并在发生后重试。libraryd 里可能一次锁多行的场景是批量续借,规则是:先按主键升序查出 id,再按同一顺序更新。

public int renewAll(List<Long> loanIds, LocalDate newDueOn) {
    List<Long> ordered = loanIds.stream().distinct().sorted().toList();
    return transactions.inTransaction(Isolation.READ_COMMITTED, repositories -> {
        int updated = 0;
        for (long id : ordered) {
            if (repositories.loans().renew(id, newDueOn)) {
                updated++;
            }
        }
        return updated;
    });
}

renew 本身是条件更新:未归还、未超过续借次数、未逾期才更新。所有事务都按升序拿锁,就不会出现 A 等 B、B 等 A 的环。即便如此,网络分区、重试和不同代码路径仍可能制造死锁,所以 JdbcTransactionRunner 对 40P01 保留重试(5.2 节的 isRetryable)。

在 libraryd 里落地

把三块拼起来,libraryd 的并发控制可以写成三条规则:

场景机制失败时的错误
借阅直接插入,依赖 ux_loans_active_isbn23505 → BOOK_ALREADY_BORROWED
归还update ... where returned_on is null0 行 → 查最近记录,区分已归还与不存在
批量写按主键升序加锁40P01 → 整个事务重试

借阅不需要 select ... for update。for update 解决的是「读到旧值再基于它写」的问题;借阅的写入不依赖读到的旧值,唯一索引已经足够。多一条 for update 只会让并发变差。

归还也不需要先 select。条件更新的 where 就是判断,returning 就是结果。先查再更新反而把一次原子操作拆成了两次。

会员 saveIfAbsent 继续用 on conflict (id) do nothing:

insert into members (id, name)
values (?, ?)
on conflict (id) do nothing;

它和借阅插入在同一个事务里。如果借阅因唯一索引失败,整个事务回滚,会员也不会留下;如果会员已存在,do nothing 不报错,借阅继续。

常见错误与边界

错误一:捕获 23505 后继续用同一个事务。 和 5.1 节的 25P02 一样,语句报错后事务已经 aborted。正确做法是让 LibraryException 穿出去触发回滚,或者用保存点回滚到插入之前。libraryd 选前者:借阅失败就整单失败,没有需要保留的前置写入。

错误二:只加应用层锁,不加数据库约束。 synchronized 只在单个 JVM 内有效。多实例部署、滚动发布、另一个后台任务直连数据库时,应用锁形同虚设。数据库约束是唯一跨进程生效的防线。

错误三:归还时按 id 更新但不带 returned_on is null。 第二次请求会把归还日期改成新的日期,罚金也可能被重算。历史记录被覆盖后,对账就失去了依据。条件必须写进 where,而不是写在 Java 的 if 里。

错误四:把死锁当成不可恢复错误直接返回 500。 死锁是并发系统的正常现象,abort 掉一个事务是为了让另一个继续。应用应该有限次重试,并在重试耗尽后返回 503 或 409,而不是把堆栈暴露给客户端。

边界:唯一索引与软删除。 如果将来给 loans 加 deleted_at,部分唯一索引的条件要同步改成 where returned_on is null and deleted_at is null,否则「已删除但未归还」的记录仍会占用名额。

边界:重试与副作用。 重试整个事务会重新执行事务内的所有代码。事务里不要发消息、不要调用外部支付接口;这些动作应该放在提交之后,或者用 outbox 表把副作用收敛到一次。

小结

  • 应用层的「先查再插」有竞态,跨进程可靠的约束只能放在数据库。
  • ux_loans_active_isbn 是部分唯一索引,只约束未归还的借阅,冲突时 SQLState 为 23505。
  • 借阅直接插入,23505 在 SqlErrors.translate 里映射成 BOOK_ALREADY_BORROWED,不需要预先查询。
  • 归还用 update ... where returned_on is null returning ...,第一次更新 1 行,重复请求更新 0 行。
  • 罚金在 SQL 里按 greatest(0, 归还日 - 到期日) * 50 计算,避免先查后算的第二次竞态。
  • 多行写入按主键升序加锁,死锁 40P01 通过整事务重试处理。

数据库层的并发控制到这里收口。下一章换一个方向:服务已经能正确处理借阅和归还,接下来要把领域对象变成 HTTP 上的 JSON,先看 Jackson 的绑定、命名和日期处理。

阅读导航:上一节:5.2 隔离级别与 MVCC:PostgreSQL 的真实行为 · 下一节:6.1 Jackson 基础:绑定、命名与日期 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发