《Java 编程实战》3.1 JDBC 基础:Connection、PreparedStatement 与结果集

libraryd 从内存仓储走向 PostgreSQL,第一步是把 JDBC 的四个对象用对:DriverManager 与 DataSource 的取舍、PreparedStatement 的参数绑定、ResultSet 的列读取,以及生成键与资源关闭。本节用真实容器跑通图书和借阅记录的写入与查询。

本节目标:用 JDBC 直接连上 PostgreSQL,把一本书和一条借阅记录写进去再读出来,并看清 Connection、PreparedStatement、ResultSet 的生命周期。
适用版本:JDK 25、PostgreSQL 17、PostgreSQL JDBC 42.7.14。

承接的问题

第 2 章结束时,libraryd 已经有了五模块骨架、统一的编译告警和静态分析门禁,但仓储仍然是卷一留下的内存实现。进程一重启,所有图书和借阅记录都会消失。第 3 章要把它接到 PostgreSQL 上。

真正动手前,先明确这一节只解决最底层的问题:Java 代码怎样和数据库说话。上层的 BookRepository、LoanRepository 接口长什么样,SQL 怎样组织,错误码怎样翻译,分别留给 3.3 和后续章节。现在只回答四个具体问题:

  • 连接从哪里来,谁来关。
  • SQL 里的参数怎样绑定,为什么不能拼字符串。
  • 查询结果怎样从游标读成 Java 值。
  • 插入借阅记录后,数据库生成的主键怎样拿回来。

这四个问题如果靠猜,后面每加一个查询就会多一处资源泄漏或类型错误。先把它们跑通,再谈抽象。

核心机制

驱动、URL 与 Connection

JDBC 4 之后,驱动不需要再写 Class.forName("org.postgresql.Driver")。只要 postgresql 依赖在运行时 classpath 上,DriverManager 会通过 META-INF/services/java.sql.Driver 自动发现它。卷一的工程如果还留着那行 Class.forName,删掉不会影响功能,但保留也无害;新代码不必再写。

连接由 URL 描述。PostgreSQL 的形状是:

jdbc:postgresql://主机:端口/数据库名

本节的本机验证容器把端口映射到 55433,所以 URL 是 jdbc:postgresql://127.0.0.1:55433/libraryd。生产环境从 LIBRARYD_DB_URL 读取,默认值才是 jdbc:postgresql://localhost:5432/libraryd。

最小连接代码如下:

import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.DriverManager;

try (Connection connection = DriverManager.getConnection(
        "jdbc:postgresql://127.0.0.1:55433/libraryd",
        "libraryd",
        "libraryd")) {
    DatabaseMetaData meta = connection.getMetaData();
    System.out.println(meta.getDriverName() + " " + meta.getDriverVersion());
    System.out.println(meta.getDatabaseProductName() + " " + meta.getDatabaseProductVersion());
}

本机输出:

PostgreSQL JDBC Driver 42.7.14
PostgreSQL 17.11

Connection 是 AutoCloseable,try-with-resources 会在代码块结束时调用 close()。但这里的关闭是关闭一条物理连接:下一次调用 DriverManager.getConnection 会重新做 TCP 握手、认证和会话初始化。3.2 会用 HikariCP 把这一步换成从池里借还。

还要记住两点。第一,Connection 不是线程安全的,不要把它存成单例字段让多个请求共用。第二,Connection 的创建成本远高于一条普通对象,所以它的生命周期应该覆盖一组数据库操作,而不是整个应用。

PreparedStatement 与参数绑定

把值直接拼进 SQL 是最容易犯的错。先看正确写法:

import java.sql.PreparedStatement;
import java.time.LocalDate;

String sql = "insert into loans (isbn, member_id, borrowed_on, due_on)"
        + " values (?, ?, ?, ?)";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
    LocalDate today = LocalDate.of(2026, 10, 17);
    statement.setString(1, "9780132350884");
    statement.setString(2, "m-001");
    statement.setObject(3, today);
    statement.setObject(4, today.plusDays(14));
    int rows = statement.executeUpdate();
    System.out.println("rows=" + rows);
}

? 是占位符,序号从 1 开始。setObject(index, LocalDate) 会借助 JDBC 4.2 的映射把 LocalDate 写成 SQL date,不需要先转成 java.sql.Date。executeUpdate() 返回受影响的行数,插入、更新、删除都用它;查询用 executeQuery()。

拼字符串的危险可以用一个对照实验看清:

String input = "' or '1'='1";
String unsafeSql = "select count(*) from books where title = '" + input + "'";
try (Statement statement = connection.createStatement();
     ResultSet rs = statement.executeQuery(unsafeSql)) {
    rs.next();
    System.out.println("concatenated=" + rs.getLong(1));
}

传入的 input 把原本的等值条件改成了恒真条件,整张 books 表都被匹配。本机对照输出:

concatenated query count=1
prepared query count=0

两条查询的差别不在数据库,而在 Java 侧:拼接后的 SQL 已经是另一条语句;PreparedStatement 先把 SQL 结构发给数据库,参数只作为值传入,引号不会变成语法。参数只能绑定值,不能绑定表名、列名或 order by 的方向,这些位置如果需要动态拼接,必须来自白名单而不是用户输入。

插入借阅记录时,主键由 bigserial 生成。JDBC 的标准做法是让驱动回传生成键:

try (PreparedStatement statement = connection.prepareStatement(
        "insert into loans (isbn, member_id, borrowed_on, due_on) values (?, ?, ?, ?)",
        Statement.RETURN_GENERATED_KEYS)) {
    statement.setString(1, "9780132350884");
    statement.setString(2, "m-001");
    statement.setObject(3, today);
    statement.setObject(4, today.plusDays(14));
    statement.executeUpdate();
    try (ResultSet keys = statement.getGeneratedKeys()) {
        while (keys.next()) {
            System.out.println("generated loan id=" + keys.getLong(1));
        }
    }
}

本机输出是 generated loan id=1。getGeneratedKeys() 返回的也是一张 ResultSet,通常只有一行一列,列名依驱动而定,所以这里用序号 1 读取,不依赖列标签。

PostgreSQL 还支持另一种写法:在 insert 末尾加 returning id,然后把它当查询执行。3.3 的仓储实现会采用 returning,因为它在同一条语句里可以返回整行,不必再查一次。

ResultSet 与行映射

ResultSet 是游标,初始位置在第一行之前。必须调用 next() 才能读到第一行;返回 false 表示没有更多行。读取列有两种方式:列序号或列标签。列标签可读性更好,但要注意 SQL 里有没有重名列。

try (PreparedStatement statement = connection.prepareStatement(
        "select id, isbn, member_id, borrowed_on, due_on, returned_on"
                + " from loans where isbn = ?")) {
    statement.setString(1, "9780132350884");
    try (ResultSet rs = statement.executeQuery()) {
        ResultSetMetaData md = rs.getMetaData();
        System.out.println("columns=" + md.getColumnCount());
        while (rs.next()) {
            long id = rs.getLong("id");
            String isbn = rs.getString("isbn");
            LocalDate borrowedOn = rs.getObject("borrowed_on", LocalDate.class);
            LocalDate returnedOn = rs.getObject("returned_on", LocalDate.class);
            System.out.println(id + " " + isbn + " " + borrowedOn + " -> " + returnedOn);
        }
    }
}

本机读到的列类型是:

columns=id:bigserial, isbn:varchar, member_id:varchar, borrowed_on:date, due_on:date, returned_on:date

getObject("returned_on", LocalDate.class) 在列为 NULL 时返回 null,这正是 Loan.returnedOn 需要的语义。相反,getLong 和 getInt 在列为 NULL 时返回 0,调用方无法区分「没有值」和「值就是 0」。对可空列,优先用 getObject;如果只能用基本类型读取器,读完后调用 wasNull() 判断。

资源关闭顺序由 try-with-resources 自动处理:后声明的 ResultSet 先关,然后 PreparedStatement,最后 Connection。不要只关 Connection 而把 ResultSet 留在外面,驱动可能延迟释放游标占用的资源。

在 libraryd 里落地

libraryd 的模块边界是:libraryd-domain 放 Book、Loan 和仓储端口,libraryd-persistence 放 JDBC 实现。3.1 先写出 JdbcBookRepository 里最基础的两个方法,完整实现留到 3.3。

package com.plume.library.persistence;

import com.plume.library.domain.Book;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;

public final class JdbcBookRepository {

    private final DataSource dataSource;

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

    public Optional<Book> findByIsbn(String isbn) {
        String sql = "select isbn, title, author, published_year from books where isbn = ?";
        try (Connection connection = dataSource.getConnection();
             PreparedStatement statement = connection.prepareStatement(sql)) {
            statement.setString(1, isbn);
            try (ResultSet rs = statement.executeQuery()) {
                return rs.next() ? Optional.of(map(rs)) : Optional.empty();
            }
        } catch (SQLException e) {
            throw new IllegalStateException("查询图书失败: " + isbn, e);
        }
    }

    public void insert(Book book) {
        String sql = "insert into books (isbn, title, author, published_year)"
                + " values (?, ?, ?, ?)";
        try (Connection connection = dataSource.getConnection();
             PreparedStatement statement = connection.prepareStatement(sql)) {
            statement.setString(1, book.isbn());
            statement.setString(2, book.title());
            statement.setString(3, book.author());
            statement.setInt(4, book.year());
            statement.executeUpdate();
        } catch (SQLException e) {
            throw new IllegalStateException("写入图书失败: " + book.isbn(), e);
        }
    }

    private static Book map(ResultSet rs) throws SQLException {
        return new Book(
                rs.getString("isbn"),
                rs.getString("title"),
                rs.getString("author"),
                rs.getInt("published_year"));
    }
}

这里已经能看出数据库列名和领域字段的差异:Book.year 对应的列是 published_year。映射只发生在 map 一个地方,上层拿到的仍然是不可变 record,不需要知道 SQL 列叫什么。

第二个例子是借阅记录的插入。前面用 RETURN_GENERATED_KEYS 拿主键,仓储里用 PostgreSQL 的 returning 更直接:

String sql = "insert into loans (isbn, member_id, borrowed_on, due_on, returned_on, renew_count)"
        + " values (?, ?, ?, ?, ?, ?) returning id, isbn, member_id,"
        + " borrowed_on, due_on, returned_on, renew_count";
try (Connection connection = dataSource.getConnection();
     PreparedStatement statement = connection.prepareStatement(sql)) {
    statement.setString(1, loan.isbn());
    statement.setString(2, loan.memberId());
    statement.setObject(3, loan.borrowedOn());
    statement.setObject(4, loan.dueOn());
    statement.setObject(5, loan.returnedOn(), java.sql.Types.DATE);
    statement.setInt(6, loan.renewCount());
    try (ResultSet rs = statement.executeQuery()) {
        rs.next();
        return mapLoan(rs);
    }
}

setObject(5, null, Types.DATE) 处理尚未归还的记录:普通 setObject(index, null) 在部分驱动上会因为无法推断类型而报错,显式传 Types.DATE 更稳。returning 返回的是一张完整结果集,所以用 executeQuery() 而不是 executeUpdate()。

本机跑通后的关键输出:

find=Book[isbn=9780132350884, title=Clean Code, author=Robert C. Martin, year=2008]
inserted loan=Loan[id=1, isbn=9780132350884, memberId=m-001, borrowedOn=2026-10-17, dueOn=2026-10-31, returnedOn=null, renewCount=0]

连接 URL 不要写死在类里。DataSource 由 3.2 的 DataSourceFactory 创建,URL、用户名、密码和池大小来自 LIBRARYD_DB_* 与 LIBRARYD_POOL_SIZE。这样测试可以指向 55433 的容器,部署时可以指向真实数据库,代码本身不改。

常见错误与边界

「URL 写错一个字母。」 本机把 postgresql 写成 postgresq 时,DriverManager 找不到匹配的驱动,直接抛出:

java.sql.SQLException: No suitable driver found for jdbc:postgresq://127.0.0.1:55433/libraryd

这个异常不代表驱动没打进包,也可能是 URL 前缀不合法。先核对 jdbc:postgresql://,再检查运行时依赖里有没有 postgresql-42.7.14.jar。

「用字符串拼接 SQL。」 前面的对照已经说明,用户输入会改变 SQL 结构。即使输入来自内部系统,也不要开这个口子;一旦某个字段后来接了外部数据,漏洞就出现了。

「把 LocalDate 先转成 java.sql.Date 再 setDate。」 java.sql.Date 继承自 java.util.Date,内部带毫秒时间戳,跨时区时容易偏移一天。列是 date 就用 setObject(LocalDate),列是 timestamptz 才需要 Instant 或 OffsetDateTime。不要用 java.util.Date 同时表达两者。

「用 getLong 读可空列。」 returned_on 为 NULL 时 getLong 返回 0,后续把 0 当成有效日期会得到 1970-01-01 之类的错误结果。可空列用 getObject(..., LocalDate.class),或者读完后立刻检查 wasNull()。

「只关 Connection,不关 Statement 和 ResultSet。」 try-with-resources 应该按「Connection → PreparedStatement → ResultSet」的顺序逐层声明。只关最外层可能让驱动延迟释放游标;在连接池里,这种延迟会表现为池连接迟迟不归还。

小结

  • JDBC 4 会自动发现 classpath 上的 PostgreSQL 驱动,新代码不必写 Class.forName。
  • Connection 是物理连接,创建成本高且不是线程安全的;3.2 会用连接池管理它的生命周期。
  • PreparedStatement 用 ? 绑定值,既处理类型也隔离 SQL 结构,拼接字符串会把数据变成语法。
  • LocalDate 用 setObject 写入 date 列,可空列用 getObject(..., LocalDate.class) 读取,避免 getLong 的 0 与 NULL 混淆。
  • 插入后需要主键时,可以用 RETURN_GENERATED_KEYS,也可以在 PostgreSQL 里用 returning 直接返回整行。
  • 资源关闭按 Connection、PreparedStatement、ResultSet 逐层交给 try-with-resources,不要依赖垃圾回收。

底层 API 跑通后,下一个问题是连接从哪里来。每条请求都新建物理连接会让延迟和数据库连接数同时失控,3.2 用 HikariCP 把这件事收进一个有界池。

阅读导航:上一节:2.3 质量门禁:JaCoCo 覆盖率与 CI 流水线 · 下一节:3.2 HikariCP:连接池参数与泄漏排查 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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