《Java 编程实战》5.1 JDBC 事务:提交、回滚与事务边界

libraryd 的一次借阅要写会员和借阅两条 SQL,JDBC 默认的自动提交会让失败只回滚一半。本节用真实 PostgreSQL 演示 autoCommit=false、commit、rollback 与保存点,把事务边界收在服务方法上,并解释 25P02 与连接归还问题。

本节目标:让 libraryd 的一次借阅在数据库里要么完整发生,要么完全不发生,并说清事务边界应该画在哪一层。
适用版本:JDK 25(Temurin 25.0.5)、PostgreSQL 17、PostgreSQL JDBC 42.7.14。

承接的问题

第 3 章把 BookRepository、MemberRepository、LoanRepository 的 JDBC 实现写完了,第 4 章又把 schema 和索引定稿。现在 libraryd 的借阅流程不再只是往一个 List 里加对象,它至少要碰两张表:

  1. 会员首次借阅时,往 members 插入一行。
  2. 往 loans 插入一条活动借阅,returned_on 为 null。
  3. 如果后续还要更新图书的 updated_at,那就是第三条语句。

问题在于 JDBC 的默认行为:Connection.getAutoCommit() 返回 true。这意味着每条 executeUpdate 结束就自动提交。第一条语句成功、第二条语句因为外键或唯一索引失败时,会员已经留在库里,借阅却没有发生。调用方看到的是一个异常,数据库里却是一半的状态。

更隐蔽的情况是:仓储方法各自从连接池取连接。MemberRepository.saveIfAbsent 用一个连接提交,LoanRepository.insert 用另一个连接提交,服务层即使想回滚也无从下手,因为它根本没有持有那条连接。

所以这一节要解决的不是「怎么调用 commit()」,而是两个更基础的问题:

  • 一条业务操作涉及多条 SQL 时,谁持有 Connection,谁决定提交或回滚?
  • 回滚之后,连接怎么还给池子,异常怎么向上传?

核心机制一:autoCommit=false 之后的三个动作

JDBC 的事务 API 很小:setAutoCommit(false)、commit()、rollback()。真正容易出错的是它们和连接生命周期的关系。

本节所有数据库实验都在同一个容器里跑,连接串固定为 127.0.0.1:55434:

docker run -d --rm --name libraryd-pg-55434 \
  -e POSTGRES_DB=libraryd -e POSTGRES_USER=libraryd -e POSTGRES_PASSWORD=libraryd \
  -p 127.0.0.1:55434:5432 postgres:17-alpine
static Connection open() throws SQLException {
    return DriverManager.getConnection(
            "jdbc:postgresql://127.0.0.1:55434/libraryd", "libraryd", "libraryd");
}

实验结束后执行 docker stop libraryd-pg-55434,容器用 --rm 启动,停止后会自动删除。

先看一个最小实验。两个连接,一个写、一个读,写连接关闭自动提交后插入一条借阅:

try (Connection writer = open(); Connection reader = open()) {
    writer.setAutoCommit(false);
    try (PreparedStatement ps = writer.prepareStatement(
            "insert into loans (isbn, member_id, borrowed_on, due_on) " +
            "values (?, ?, date '2026-10-18', date '2026-11-01')")) {
        ps.setString(1, "9787111213826");
        ps.setString(2, "m-001");
        ps.executeUpdate();
    }
    System.out.println("after insert, before commit, reader sees = "
            + activeLoans(reader, "9787111213826"));
    System.out.println("writer sees in its own tx        = "
            + activeLoans(writer, "9787111213826"));
    writer.commit();
    System.out.println("after commit, reader sees        = "
            + activeLoans(reader, "9787111213826"));

    writer.setAutoCommit(false);
    try (PreparedStatement ps = writer.prepareStatement(
            "update loans set returned_on = date '2026-10-18' " +
            "where isbn = ? and returned_on is null")) {
        ps.setString(1, "9787111213826");
        ps.executeUpdate();
    }
    writer.rollback();
    System.out.println("after rollback, active loans     = "
            + activeLoans(reader, "9787111213826"));
}

这段代码在 libraryd-pg-55434 容器上跑出的本机输出是:

after insert, before commit, reader sees = 0
writer sees in its own tx        = 1
after commit, reader sees        = 1
after rollback, active loans     = 1

四个数字对应四件事:

  • 未提交的插入只对写连接自己可见,读连接看不到。
  • 写连接在自己的事务里能看到未提交的改动。
  • commit() 之后,读连接的下一条语句才看到新行。
  • rollback() 撤销了归还更新,所以活动借阅仍然是 1 条。

这里读连接用的是 PostgreSQL 默认的 read committed 隔离级别,每条语句取一次新快照。隔离级别的差异留到 5.2 节展开,本节只需要知道:提交是可见性的分界线。

还有一个常被忽略的点:try-with-resources 只负责关闭连接,不负责替你提交。JDBC 规范没有把「关闭时自动提交」当成可靠契约。正确写法是在 catch 里显式 rollback(),而不是指望 close() 帮你收尾。

核心机制二:保存点让一条坏语句不拖垮整批

有些场景不需要把整个事务全部回滚。比如批量导入图书,100 本书里第 37 本 ISBN 重复,合理的做法是跳过这一本、保留前 36 本,而不是把整批都扔掉。

保存点(savepoint)就是为此存在的。先 setSavepoint("name"),后续语句失败时 rollback(savepoint),事务回到这个点继续执行,而不是整体中止:

try (Connection c = open()) {
    c.setAutoCommit(false);
    insertLoan(c, "9787111213826", "m-001");
    Savepoint sp = c.setSavepoint("after_first_loan");
    try {
        insertLoan(c, "9787111213826", "m-002");
    } catch (SQLException e) {
        System.out.println("second insert failed: SQLState=" + e.getSQLState());
        System.out.println("message              : " + e.getMessage().split("\n")[0]);
        c.rollback(sp);
    }
    insertLoan(c, "9787115428028", "m-002");
    c.commit();
}

第二次插入命中了 5.3 节会详细讲的局部唯一索引 ux_loans_active_isbn。本机输出:

second insert failed: SQLState=23505
message              : ERROR: duplicate key value violates unique constraint "ux_loans_active_isbn"
after commit, transaction was not aborted
9787111213826  m-001  returned=null
9787115428028  m-002  returned=null

对比一下「不设保存点、捕获异常后继续执行」的本机结果:

without savepoint, first error: 23505
next statement in same tx      : 25P02 - ERROR: current transaction is aborted, commands ignored until end of transaction block

PostgreSQL 在一条语句报错后会把整个事务标记为 aborted。后续语句一律返回 25P02,直到你执行 ROLLBACK。很多「为什么后面所有 SQL 都失败」的工单,根因就是捕获了 SQLException 却没有回滚。

注意:保存点只解决「同一事务里部分语句失败」的问题,不解决并发。唯一索引冲突仍然是并发控制的最后一道防线,见 5.3 节。

在 libraryd 里落地:把事务边界放在服务方法

事务边界的选择比 API 细节重要。三种常见放法:

边界位置结果是否采用
每条 SQL 自动提交借阅可能只写一半否
每个 Repository 方法内部跨仓储操作无法共用事务否
服务方法定义一个事务体一次业务操作一个事务是

3.3 的仓储端口定义在 domain,方法不带 Connection,实现类自己从 DataSource 取连接。这套 API 适合单条 SQL,但没法让两个仓储方法共享同一个事务。第 5 章在 domain 增加一层事务端口,persistence 提供 JDBC 实现。服务层只描述「哪些操作要一起提交」,不 import 任何 java.sql 类型。

先定义 domain 侧的类型。Repositories 把一次事务里可用的仓储打包,TransactionWork 是事务体,TransactionRunner 是端口:

package com.plume.library.domain;

public record Repositories(
        BookRepository books,
        MemberRepository members,
        LoanRepository loans) {
}

@FunctionalInterface
public interface TransactionWork<T> {
    T run(Repositories repositories);
}

public interface TransactionRunner {
    <T> T inTransaction(TransactionWork<T> work);
    <T> T inTransaction(Isolation isolation, TransactionWork<T> work);
    <T> T inTransaction(Isolation isolation, boolean readOnly, TransactionWork<T> work);
}

Isolation 也是 domain 枚举,只有三个名字,不出现 JDBC 常量:

package com.plume.library.domain;

public enum Isolation {
    READ_COMMITTED,
    REPEATABLE_READ,
    SERIALIZABLE
}

persistence 的 JdbcTransactionRunner 实现这个端口。它打开连接、设置 autoCommit 和隔离级别,用同一个连接构造三个 JDBC 仓储,再交给事务体:

package com.plume.library.persistence;

import com.plume.library.domain.*;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

public final class JdbcTransactionRunner implements TransactionRunner {

    private final DataSource dataSource;

    public JdbcTransactionRunner(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public <T> T inTransaction(TransactionWork<T> work) {
        return inTransaction(Isolation.READ_COMMITTED, false, work);
    }

    @Override
    public <T> T inTransaction(Isolation isolation, TransactionWork<T> work) {
        return inTransaction(isolation, false, work);
    }

    @Override
    public <T> T inTransaction(Isolation isolation, boolean readOnly, TransactionWork<T> work) {
        try (Connection connection = dataSource.getConnection()) {
            boolean previousAutoCommit = connection.getAutoCommit();
            connection.setAutoCommit(false);
            connection.setTransactionIsolation(level(isolation));
            connection.setReadOnly(readOnly);
            try {
                Repositories repositories = new Repositories(
                        new JdbcBookRepository(connection),
                        new JdbcMemberRepository(connection),
                        new JdbcLoanRepository(connection));
                T result = work.run(repositories);
                connection.commit();
                return result;
            } catch (RuntimeException e) {
                connection.rollback();
                throw e;
            } finally {
                connection.setAutoCommit(previousAutoCommit);
            }
        } catch (SQLException e) {
            throw new LibraryException(ErrorCode.DB_UNAVAILABLE, "数据库操作失败", e);
        }
    }

    private static int level(Isolation isolation) {
        return switch (isolation) {
            case READ_COMMITTED -> Connection.TRANSACTION_READ_COMMITTED;
            case REPEATABLE_READ -> Connection.TRANSACTION_REPEATABLE_READ;
            case SERIALIZABLE -> Connection.TRANSACTION_SERIALIZABLE;
        };
    }
}

三个 Jdbc*Repository 在 3.3 的 DataSource 构造器之外,各加一个包内 Connection 构造器。构造器只保存引用,不关闭连接;连接的生命周期由 JdbcTransactionRunner 管理。domain 端口的方法签名不变,仓储实现仍然用 SqlErrors.translate 把 SQLException 转成 LibraryException。

5.2 节会在 inTransaction 外面加一层有限次重试,把这里的核心逻辑抽成 inTransactionOnce;本节先保持无重试,便于观察事务本身的行为。

服务层因此只依赖 TransactionRunner:

package com.plume.library.service;

import com.plume.library.domain.*;

import java.time.LocalDate;

public final class BorrowService {

    private final TransactionRunner transactions;

    public BorrowService(TransactionRunner transactions) {
        this.transactions = transactions;
    }

    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));
        });
    }
}

这里不再先查询「有没有活动借阅」。那个检查在并发下并不可靠,真正的约束交给数据库的唯一索引;插入冲突由 JdbcLoanRepository.insert 里的 SqlErrors.translate 映射成 BOOK_ALREADY_BORROWED,5.3 节展开。本节先把事务骨架立住:书存在检查、会员 saveIfAbsent、借阅插入,三条动作在同一个事务里。

对应的 SQL 也很直接。会员 upsert 用 on conflict do nothing,让首次借阅建档、后续借阅不报错:

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

借阅插入依赖外键 isbn references books (isbn) 和 member_id references members (id)。如果前面任何一步失败,JdbcTransactionRunner 会回滚,会员和借阅都不会留下。

结构演进:第 7 章会改什么

本节为了把事务边界单独讲清楚,用 BorrowService 承载借阅用例,事务端口叫 TransactionRunner,事务体拿到一个 Repositories 包。这套结构适合解释「一次业务操作一个事务」,但它不是 libraryd 最终的类名。

到第 7 章接 HTTP 时,libraryd 会把借阅、归还、续借、查询合并进 LibraryService 门面,端口实现落到 ThreadLocalTransactionManager:连接绑定到当前线程,仓储通过 JdbcSupport 复用同一条连接。仓储的构造器也随之改成 (DataSource, ThreadLocalTransactionManager),不再需要本节新增的包内 Connection 构造器。职责划分没有变——服务层决定哪些操作要一起提交,persistence 管理连接,领域层不出现 java.sql——只是最终实现少了一层 Repositories 参数。后文统一使用 LibraryService 和 ThreadLocalTransactionManager;读到这里时,把 BorrowService 当作 LibraryService 的借阅切片即可。

常见错误与边界

错误一:在仓储方法里 commit()。 仓储不知道调用方的业务意图。如果 MemberRepository.saveIfAbsent 自己提交,服务层后面再失败也无法撤销会员。仓储的职责边界是「在给定连接上执行 SQL」,提交权属于服务方法。

错误二:捕获 SQLException 后继续用同一条连接。 前面已经看到 25P02。如果确实要跳过某条语句,用保存点;如果整批都失败,直接回滚并把异常往上抛。

错误三:在事务里做 HTTP 调用或长时间计算。 PostgreSQL 的行锁会一直持有到事务结束。一个慢的外部请求会让别的借阅排队,甚至拖到锁等待超时。事务里只放数据库操作,外部调用放到事务外,或者先落本地状态再异步处理。

边界:连接池归还。 try-with-resources 关闭的是逻辑连接,HikariCP 会把它还给池。若在 autoCommit=false 状态下直接关闭,不同池实现的处理并不完全一致。显式 commit() 或 rollback(),再恢复 autoCommit,才能让下一位借用者拿到干净连接。

边界:只读事务。 查询多条相互关联的数据时,也可以开事务,但应设置只读并尽快结束。libraryd 的报表查询会在 5.2 节用 repeatable read 包起来,让多条查询看到同一份快照。

小结

  • JDBC 默认 autoCommit=true,每条语句独立提交;跨多条 SQL 的业务操作必须显式开事务。
  • 未提交的写入只对当前事务可见,commit() 是可见性的分界线,rollback() 撤销整个事务。
  • 保存点用于「部分回滚」,失败后可以继续;没有保存点时,PostgreSQL 会把事务标记为 aborted,后续语句返回 25P02。
  • 事务边界应放在服务方法,事务内的仓储实例共享同一条 Connection,只执行 SQL,不决定提交。
  • 本节用 JdbcTransactionRunner 统一处理提交、回滚、autoCommit 恢复和基础设施异常映射;第 7 章起由 ThreadLocalTransactionManager 承担同一职责。
  • 事务要短,不要在持锁期间做外部调用。

事务解决了「一组语句要么全成、要么全败」,但没有回答「两个事务同时读写同一行会看到什么」。下一节进入隔离级别与 MVCC,看看 PostgreSQL 在 read committed 和 repeatable read 下的真实行为。

阅读导航:上一节:4.3 分页、批量写与 N+1 · 下一节:5.2 隔离级别与 MVCC:PostgreSQL 的真实行为 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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