本节目标:让「同一本书只能有一条活动借阅」和「重复归还不出错」由数据库约束与条件更新兜住,而不是靠应用层的一次查询。
适用版本: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_isbn | 23505 → BOOK_ALREADY_BORROWED |
| 归还 | update ... where returned_on is null | 0 行 → 查最近记录,区分已归还与不存在 |
| 批量写 | 按主键升序加锁 | 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 基础:绑定、命名与日期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。