Skip to content

JDBC:Java 数据访问的底层抽象 ​

上一章我们理解了持久化的必要性——Java 对象必须跨越 JVM 生命周期存入数据库。但"存进去"和"取出来"具体怎么做?每种数据库(MySQL、Oracle、PostgreSQL)都有自己的通信协议和 C API,难道每换一种数据库就要重写一遍数据访问代码?本章要回答的核心问题是:JDBC 如何用一套统一的 Java 接口,屏蔽底层数据库的差异,让开发者用同一套代码操作所有关系数据库? 我们将从 JDBC 存在的历史原因出发,拆解它的核心接口与编程模型,分析它的性能瓶颈,并揭示它作为常见 ORM 框架底层基础的地位。

1. JDBC 为什么存在 ​

1.1 没有 JDBC 的年代 ​

想象一下 1990 年代中期的 Java 开发场景。你要连接 MySQL,需要用 MySQL 提供的 C 语言客户端库(libmysqlclient),通过 JNI 调用本地方法。你要换成 Oracle,又得换成 Oracle 的 OCI(Oracle Call Interface)库。每种数据库的 API 完全不同:函数名不同、参数不同、错误码不同、资源释放方式不同。

java
// 以下代码仅用于示意,并非对真实历史 API 的还原
// 假设的"无 JDBC"时代——连接 MySQL
native void mysql_connect(String host, int port, String user, String pwd);
native ResultSet mysql_query(String sql);
native void mysql_close();

// 连接 Oracle——完全不同的 API
native void oci_logon(String tnsName, String user, String pwd);
native void oci_execute(String sql);
native void oci_logoff();

这两段代码是为了突出接口不统一带来的迁移成本,并不代表当时所有 Java 应用都通过这类 API 访问数据库;早期历史情况比这里的简化示例更复杂。要表达的事实是:厂商专有 API 会让数据访问层难以跨数据库复用。迁移到另一数据库通常要同步调整驱动、SQL、类型映射和事务语义。

1.2 JDBC 的价值:统一抽象 ​

1997 年,Sun 公司在 JDK 1.1 中引入了 JDBC(Java Database Connectivity)规范。它的核心设计思想非常简单:

定义一套标准接口,让各数据库厂商提供自己的实现(驱动)。

java
// 无论底层是 MySQL、Oracle 还是 PostgreSQL,代码写法完全一样
Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
ps.setLong(1, 1001L);
ResultSet rs = ps.executeQuery();

这就是典型的面向接口编程:应用代码主要依赖 JDBC 标准接口(java.sql.*),具体实现由各厂商的 JDBC 驱动(Driver)提供。切换数据库通常要更换驱动和连接 URL,但还可能涉及 SQL 方言、类型映射、事务隔离、批量语义与错误码差异,不能假定业务代码完全不变。

维度没有 JDBC有了 JDBC
API 统一性每个数据库一套 API一套 java.sql.* 接口
换数据库成本重写数据访问层更换驱动和 URL,并验证 SQL 与行为差异
代码可移植性几乎为零高(SQL 方言除外)
驱动管理厂商各自为政标准化 Driver 接口

1.3 JDBC 是 ORM 的地基 ​

在后续章节中我们会学习 MyBatis、Hibernate/JPA 等 ORM 框架。但请记住一个关键事实:在传统的 Java 关系数据库访问中,MyBatis、Hibernate/JPA 等常见 ORM 框架通常通过 JDBC 与数据库通信;但 JDBC 并非唯一的 Java 数据访问方式(例如响应式访问可用 R2DBC),不能把"Java 数据访问"等同于 JDBC。

理解 JDBC,就是理解 Java 数据访问的"基岩层"。它不是最优雅的 API,但它是所有上层建筑的地基。

2. 核心接口 ​

JDBC 的 API 设计围绕四个核心接口展开,每个接口有明确的职责边界和生命周期。理解它们,就理解了 JDBC 的骨架。

2.1 DataSource——连接的工厂 ​

DataSource 是获取数据库连接的标准入口。它描述如何创建或借出 Connection,但池化、异步初始化、监控和 XA 等能力取决于具体实现。

java
// 方式一:DriverManager(早期方式,硬编码连接信息)
Connection conn = DriverManager.getConnection(
    "jdbc:mysql://localhost:3306/mydb", "root", "password"
);

// 方式二:DataSource(推荐方式,配置与代码分离)
DataSource ds = new MysqlDataSource();
ds.setUrl("jdbc:mysql://localhost:3306/mydb");
ds.setUser("root");
ds.setPassword("password");
Connection conn = ds.getConnection();

为什么推荐 DataSource?因为它把获取连接的方式与业务代码分离,便于通过配置或依赖注入替换具体实现;连接池、JNDI 查找与 XA 分布式事务等能力取决于具体实现,并非 DataSource 接口本身保证。在实际生产环境中,你几乎不会直接 new MysqlDataSource(),而是使用连接池框架(如 HikariCP)提供的 DataSource 实现。连接池的通用模型见下一章。

2.2 Connection——一次数据库会话 ​

Connection 表示一次 JDBC 数据库连接及其会话状态。应用通过它执行 SQL、管理事务和设置隔离级别。使用连接池时,getConnection() 返回的多是逻辑连接句柄,关闭后归还连接池、底层物理连接可能被复用;物理连接(常见实现即 TCP Socket)的建立与传输细节由驱动和数据库决定,语句缓存也不由 Connection 接口本身保证。

java
Connection conn = dataSource.getConnection();
try {
    conn.setAutoCommit(false);          // 开启手动事务
    // ... 执行多条 SQL ...
    conn.commit();                       // 提交事务
} catch (SQLException e) {
    conn.rollback();                     // 回滚事务
    throw e;
} finally {
    conn.close();                        // 归还连接(如果是连接池,close = 归还)
}

关键认知:Connection 通常是昂贵资源。 创建连接可能包含 TCP 握手、TLS 和数据库认证,耗时受网络与数据库配置影响。长生命周期服务通常使用连接池复用连接并控制连接数。

2.3 PreparedStatement——SQL 的执行者 ​

PreparedStatement 是可接受参数绑定、多次执行的 SQL 语句对象。JDBC API 将其描述为预编译语句;驱动是否在服务端真正预编译、何时编译,以及是否缓存执行计划由驱动与数据库实现决定。

java
// 创建 PreparedStatement;SQL 是否真正下发服务端预编译由驱动与数据库实现决定
PreparedStatement ps = conn.prepareStatement(
    "SELECT id, name, email FROM users WHERE age > ? AND city = ?"
);

// 绑定参数(? 占位符从 1 开始编号)
ps.setInt(1, 18);
ps.setString(2, "北京");

// 执行查询
ResultSet rs = ps.executeQuery();

使用 PreparedStatement 的主要原因是参数化查询能降低拼接 SQL 带来的注入风险。重复执行时是否减少解析或优化开销,则要结合驱动和数据库验证。

2.4 ResultSet——查询结果的游标 ​

ResultSet 是查询结果的迭代器。它维护一个游标,初始指向第一行之前,每次调用 next() 移动到下一行。

java
ResultSet rs = ps.executeQuery();
while (rs.next()) {
    Long id = rs.getLong("id");
    String name = rs.getString("name");
    String email = rs.getString("email");
    // 把每一行数据映射为 Java 对象
    User user = new User(id, name, email);
    users.add(user);
}

ResultSet 默认是只读、只向前的游标(TYPE_FORWARD_ONLY),这意味着你不能往回翻。这个设计是有意为之——只向前的游标性能最好,内存占用最小,适合绝大多数场景。

2.5 接口对比总览 ​

接口类比职责生命周期实现方
DataSource工厂创建连接应用级(通常单例)数据库厂商 / 连接池框架
Connection一次会话维护事务,创建 Statement一次业务操作JDBC 驱动
PreparedStatement一条命令绑定参数,执行 SQL一次查询/更新JDBC 驱动
ResultSet一份报告迭代查询结果一次查询结果JDBC 驱动

生命周期关系如下:

txt
DataSource(应用级,长期存活)
  └─ 创建 → Connection(一次会话,用完关闭/归还)
       └─ 创建 → PreparedStatement(一次 SQL 执行)
            └─ 产生 → ResultSet(一次查询结果,遍历完关闭)

上面的树状图描述了一次查询的常见创建关系:DataSource 生产 Connection,Connection 生产 PreparedStatement,PreparedStatement 生产 ResultSet。这是一次查询的常见调用关系,不代表严格的一对一生命周期——一个 Connection 可创建多条语句,PreparedStatement 也可重复执行。

3. JDBC 编程模板 ​

让我们写一个完整的 JDBC 代码,从头到尾走一遍:查询指定年龄以上的用户列表。

3.1 完整示例 ​

java
public List<User> findUsersByAge(int minAge) throws SQLException {
    List<User> users = new ArrayList<>();
    
    // 1. 获取连接
    Connection conn = null;
    PreparedStatement ps = null;
    ResultSet rs = null;
    
    try {
        conn = dataSource.getConnection();
        
        // 2. 创建 PreparedStatement
        ps = conn.prepareStatement(
            "SELECT id, name, email, age FROM users WHERE age > ?"
        );
        ps.setInt(1, minAge);
        
        // 3. 执行查询
        rs = ps.executeQuery();
        
        // 4. 遍历结果集,映射为 Java 对象
        while (rs.next()) {
            User user = new User();
            user.setId(rs.getLong("id"));
            user.setName(rs.getString("name"));
            user.setEmail(rs.getString("email"));
            user.setAge(rs.getInt("age"));
            users.add(user);
        }
        
    } finally {
        // 5. 关闭资源(注意关闭顺序:ResultSet → PreparedStatement → Connection)
        if (rs != null) try { rs.close(); } catch (SQLException ignored) {}
        if (ps != null) try { ps.close(); } catch (SQLException ignored) {}
        if (conn != null) try { conn.close(); } catch (SQLException ignored) {}
    }
    
    return users;
}

3.2 模板代码的痛苦 ​

仔细审视这段代码,你会发现真正的业务逻辑只有 5 行(SQL 和结果映射),剩下的全是样板代码:

代码职责示例中的代码量属于什么
获取连接约 2 行基础设施(连接获取与失败处理)
创建语句并绑定参数约 4 行部分业务逻辑(SQL 是,绑定是样板)
执行查询约 1 行基础设施
遍历 ResultSet 并映射对象约 8 行重复映射(手动 getter → setter,可能含类型转换与空值处理)
关闭资源(try-catch-finally)约 4 行基础设施(还需处理关闭异常)
合计约 25 行真正表达业务意图的主要是那一行 SQL

这就是 JDBC 的核心痛点:样板代码太多,业务逻辑被淹没在基础设施代码中。 写一个查询尚且如此,一个真实业务方法可能涉及多次查询、更新、事务控制,重复的资源管理与映射代码会显著增加。

java
// Java 7 引入 try-with-resources 后,资源关闭稍微优雅了一些
try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setInt(1, minAge);
    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            // 映射逻辑
        }
    }
}

try-with-resources 解决了资源关闭的样板问题,但ResultSet 到 Java 对象的映射仍然是手动的、重复的、易错的。这正是 ORM 框架要解决的核心问题——第 3、4 章的主题。

4. PreparedStatement 与 SQL 注入 ​

PreparedStatement 不仅仅是一个"写法更优雅"的替代品。它解决了一个安全问题和一个性能问题。

4.1 SQL 注入:拼接字符串的代价 ​

假设你要根据用户名查询用户信息,用字符串拼接的方式:

java
// 危险代码!
String name = request.getParameter("name");  // 用户输入
String sql = "SELECT * FROM users WHERE name = '" + name + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);

正常情况下,用户输入 张三,生成的 SQL 是:

sql
SELECT * FROM users WHERE name = '张三'

没问题。但如果攻击者输入的是:

txt
' OR '1'='1' --

生成的 SQL 变成:

sql
SELECT * FROM users WHERE name = '' OR '1'='1' --'

WHERE 条件永远为真,-- 注释掉了后面的代码。若应用把这样的查询用于登录校验,就可能绕过身份验证并读到远超预期的记录(甚至整张用户表)。 这就是 SQL 注入——一类常见的注入型安全漏洞,长期位列 OWASP Top 10,至今仍在真实世界中频繁被利用。

更危险的注入:

txt
'; DROP TABLE users; --

这会直接删除你的用户表(前提是驱动与配置允许一次执行多条语句)。

4.2 PreparedStatement 的参数化查询 ​

PreparedStatement 通过参数占位符 ? 把参数值作为绑定值传递,不参与 SQL 文本拼接:

java
// 安全代码
String name = request.getParameter("name");
PreparedStatement ps = conn.prepareStatement(
    "SELECT * FROM users WHERE name = ?"
);
ps.setString(1, name);  // 参数作为纯数据传递,不会被解析为 SQL
ResultSet rs = ps.executeQuery();

? 处的绑定值会被当作数据参数,而不会改变 SQL 文本结构。即使输入 ' OR '1'='1' --,它也只是参与条件比较的字符串。参数化查询降低注入风险,但仍应避免把值直接拼接到 SQL 中。

txt
┌──────────────────────────────────────────────────────┐
│              字符串拼接(危险)                          │
│                                                      │
│  SQL 语法 + 用户输入 → 混合成一条完整 SQL → 发给数据库    │
│  ↑ 数据和代码没有边界,注入就发生在这里                    │
└──────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────┐
│           PreparedStatement(安全)                     │
│                                                      │
│  SQL 模板(带 ?)→ 先发送给数据库预编译                   │
│  参数值 → 独立发送,只作为绑定值                          │
│  ↑ 数据和代码完全分离,注入无从发生                       │
└──────────────────────────────────────────────────────┘

参数化查询只能绑定 SQL 中允许使用参数的位置,通常是比较条件中的值,不能直接用来替代表名、列名或排序方向等 SQL 结构。例如 ORDER BY ? 中的 ? 是值参数,不会被理解成列标识符。若需动态选择排序字段,应将输入映射到应用预先定义的白名单再据此构造 SQL,不能把未经校验的用户输入直接拼接进 SQL。

4.3 预编译协议与性能边界 ​

除安全性外,PreparedStatement 在重复执行相同结构的语句时可能减少 SQL 解析或执行计划生成开销,但收益取决于驱动和数据库。下图以 MySQL 协议为例:

txt
JDBC 驱动                              数据库服务器
  │                                       │
  │── 1. COM_STMT_PREPARE ──────────────→ │  发送带 ? 的 SQL 模板
  │     "SELECT * FROM users WHERE id = ?"│
  │                                       │  → 解析语法
  │                                       │  → 生成执行计划
  │                                       │  → 缓存,返回 statement_id = 7
  │←─ 2. statement_id = 7  ────────────── │
  │                                       │
  │── 3. COM_STMT_EXECUTE ──────────────→ │  后续执行只需传 id + 参数
  │     statement_id=7, params=[1001]     │  → 直接拿缓存的执行计划运行
  │←─ 4. 结果集 ─────────────────────────  │
  │                                       │
  │── 5. COM_STMT_EXECUTE ──────────────→ │  换个参数再执行
  │     statement_id=7, params=[1002]     │  → 跳过解析和优化
  │←─ 6. 结果集 ────────────────────────── │

第一次执行时,SQL 模板(带 ?)通过 COM_STMT_PREPARE 命令发给数据库,数据库完成三件事:

  1. 解析 SQL:检查语法、验证表和列的存在
  2. 优化执行计划:选择最优的索引和查询策略
  3. 缓存执行计划:返回一个 statement_id,后续通过这个 ID 引用

后续用不同参数执行同一条 SQL 时,驱动只发 COM_STMT_EXECUTE(statement_id + 参数值),数据库可复用之前缓存的执行计划、跳过部分解析与优化。对于高频、参数化的查询,在驱动与数据库支持且正确配置的前提下,这类复用可能带来可观收益;实际收益取决于驱动实现、数据库版本与工作负载,不能假定每次执行都跳过所有解析。

需要注意的是,JDBC 驱动可以有两种方式实现 PreparedStatement:

方式工作机制优点缺点
服务端预编译将带 ? 的 SQL 发给数据库预编译,得到 statement_id,后续靠 id 执行执行计划可跨请求复用,性能最优多一次网络往返(PREPARE + EXECUTE)
客户端模拟驱动本地把参数值拼成完整 SQL,用普通 Statement 协议发给数据库无额外 PREPARE 往返每次都需要数据库重新解析和优化

MySQL Connector/J 默认使用客户端模拟模式——useServerPrepStmts 默认为 false。这是出于兼容性考虑:某些早期 MySQL 版本的服务端预编译有 bug,而且一次 PREPARE + 一次 EXECUTE 对单次执行的查询反而是负优化。只有明确设置 useServerPrepStmts=true&cachePrepStmts=true 时才启用服务端预编译,此时才能获得执行计划缓存的收益。

java
// 同一条 SQL 模板,执行 1000 次,只解析和优化一次
PreparedStatement ps = conn.prepareStatement(
    "SELECT * FROM users WHERE id = ?"
);
for (long id : userIds) {
    ps.setLong(1, id);
    ResultSet rs = ps.executeQuery();
    // 处理结果...
    rs.close();
}

一句话总结:PreparedStatement 的两个价值——安全靠参数化、性能靠复用执行计划(视驱动与数据库而定)。 在业务代码中应默认优先使用 PreparedStatement;对于完全静态、无外部输入的 SQL,Statement 仍可使用,但不应为拼接参数而退回字符串构造 SQL 的方式。

5. 用 Connection 管理事务 ​

JDBC 的事务边界属于 Connection,不属于某一条 Statement。同一个连接上的多条语句参与同一个事务;调用 commit() 会提交、调用 rollback() 会撤销当前事务尚未提交的修改。若关闭连接时仍有活动事务,结果取决于实现,因此应在显式结束事务后再关闭连接,不能把 close() 当作提交或回滚的替代操作。

5.1 从自动提交切换到显式事务 ​

JDBC 默认通常处于自动提交模式:每条 SQL 执行成功后立即提交。需要让多条语句共享一个原子结果时,要显式关闭自动提交:

java
try (Connection connection = dataSource.getConnection()) {
    boolean originalAutoCommit = connection.getAutoCommit();
    try {
        connection.setAutoCommit(false);
        try (PreparedStatement debit = connection.prepareStatement(DEBIT_SQL);
             PreparedStatement credit = connection.prepareStatement(CREDIT_SQL)) {
            debit.setBigDecimal(1, amount);
            debit.executeUpdate();
            credit.setBigDecimal(1, amount);
            credit.executeUpdate();
        }
        connection.commit();
    } catch (SQLException ex) {
        try {
            connection.rollback();
        } catch (SQLException rollbackFailure) {
            ex.addSuppressed(rollbackFailure);
        }
        throw ex;
    } finally {
        connection.setAutoCommit(originalAutoCommit);
    }
}

关闭自动提交前先记录原值,并在归还连接前恢复;try-with-resources 保证连接最终关闭。连接池中的 close() 通常表示归还连接;如果事务尚未提交或回滚,事务状态可能继续污染下一个借用者,因此不能把关闭连接当作提交事务。恢复自动提交状态本身也可能失败,生产代码还要保留或记录这个次级失败,避免它覆盖原始事务异常。

还要注意:若连接处于手动提交模式,调用 setAutoCommit(true) 会提交当前活动事务;上面示例假定本方法独占该连接且连接初始处于自动提交模式。连接池借用场景的状态恢复应单独处理,不应直接套用此模板。事务示例还应检查 executeUpdate() 的返回值:数据库事务只保证一组操作的原子性,不保证这些操作符合业务规则;若关键更新实际影响 0 行,应显式抛错,而不是在没抛异常的情况下继续后续步骤。

5.2 回滚、隔离级别与失败边界 ​

rollback() 撤销当前事务中尚未提交的修改。事务中任何一步失败,都应进入回滚路径;只有全部业务步骤成功后才调用 commit()。

JDBC 通过 setTransactionIsolation() 设置事务隔离级别,常见取值包括 TRANSACTION_READ_UNCOMMITTED、TRANSACTION_READ_COMMITTED、TRANSACTION_REPEATABLE_READ 和 TRANSACTION_SERIALIZABLE。驱动可以通过 supportsTransactionIsolationLevel() 声明是否支持某个级别,但“设置成功”不等于数据库具有与该名称完全相同的并发语义。具体异常、锁和快照行为由数据库决定:

事务隔离级别不是越高越安全。更高隔离级别通常增加锁竞争或写冲突,应从真实并发异常出发选择,并用并发测试验证。

5.3 使用 Savepoint 划分局部回滚 ​

驱动支持时,可以创建 Savepoint,把一组语句标记为一个检查点:

java
connection.setAutoCommit(false);
Savepoint beforeOptionalStep = connection.setSavepoint("beforeOptionalStep");
try {
    executeRequiredSteps(connection);
    executeOptionalStep(connection);
    connection.commit();
} catch (SQLException ex) {
    connection.rollback(beforeOptionalStep);
    try {
        executeFallback(connection);
        connection.commit();
    } catch (SQLException fallbackFailure) {
        connection.rollback();
        throw fallbackFailure;
    }
}

回滚到 Savepoint 只撤销检查点之后的修改,不会提交之前已经执行的修改。releaseSavepoint() 可以释放不再需要的检查点;驱动不支持 Savepoint 时会抛出 SQLFeatureNotSupportedException,数据库还可能因 DDL 或事务实现限制拒绝保存点。不要默认所有数据库和驱动都提供相同能力。

回滚到保存点本身也可能失败;备用逻辑失败时仍应回滚整个事务,并保留原始业务异常与回滚异常,不能让后者无条件覆盖前者。在手动提交模式下,事务中的数据库修改要通过 commit() 提交;回滚到保存点只撤销该保存点之后的相关修改,不会提交整个事务。如果后续操作失败,应根据业务要求决定是否回滚整个事务。

5.4 选择 JDBC 事务还是框架事务 ​

直接使用 JDBC API 适合需要精确控制连接、Savepoint 或批量操作的底层代码。业务方法通常使用 Spring 声明式事务管理边界、传播和回滚规则,但最终仍会落到当前 Connection 的事务状态。

两种方式不要在同一个业务边界中混用:Spring 已经取得连接并开启事务时,再手动切换 autoCommit 或调用 commit(),可能提前结束框架管理的事务。分布式事务和 XA 不属于本页范围,应根据一致性需求单独评估。

6. 下一步 ​

前面的内容覆盖了 JDBC 的接口、参数化查询、结果处理和事务边界。接下来从请求延迟和资源占用出发,分析连接创建、逐条执行与连接池参数之间的取舍。

下一页: JDBC 性能瓶颈与连接池

Released under the MIT License.