JDBC 详解

欢迎来到第十一阶段——数据库与网络。前十个阶段我们的程序都在 JVM 里转悠——读写文件、操作内存。但真实世界的应用要和数据库打交道、要联网通信。这一阶段我们打开两扇门:数据库(JDBC、连接池)和网络(Socket、HttpClient)。第一扇门就是 JDBC——Java 访问数据库的”母语”。

JDBC(Java Database Connectivity)是 Java 访问关系型数据库的标准 API。无论是 MySQL、PostgreSQL、Oracle 还是 SQLite,只要厂商提供了 JDBC 驱动,Java 代码就能用同一套 API 操作——这就是”一套代码,多个数据库”的抽象。

一、JDBC 架构与驱动类型

JDBC 是个双层架构:

  • JDBC APIjava.sql 包)—— 应用面向它编程,与厂商无关。
  • JDBC Driver API —— 厂商实现它,把 JDBC 调用翻译成具体数据库的协议。

驱动分 4 种类型(了解即可,现代都用 Type 4):

类型原理现状
Type 1JDBC-ODBC 桥已淘汰(Java 8 移除)
Type 2部分 Java + 本地库少见
Type 3纯 Java,通过网络中间件少见
Type 4纯 Java,直连数据库主流(MySQL/PG 驱动都是)

Type 4 是纯 Java 实现,跨平台、无需本地库,所以现代 JDBC 驱动几乎都是 Type 4。

二、连接数据库

DriverManager.getConnection 是连接的入口:

// 加载驱动 (JDBC 4.0+ 自动加载, 可省略)
Class.forName("com.mysql.cj.jdbc.Driver");

// 三个参数: url, username, password
String url = "jdbc:mysql://localhost:3306/test?useSSL=false&serverTimezone=UTC";
try (Connection conn = DriverManager.getConnection(url, "root", "123456")) {
    System.out.println("连接成功: " + conn);
} catch (SQLException e) {
    e.printStackTrace();
}

URL 格式:jdbc:<子协议>://<host>:<port>/<数据库>?<参数>。不同数据库的子协议不同——MySQL 是 mysql、PostgreSQL 是 postgresql、Oracle 是 oracle:thin、SQLite 是 sqlite

JDBC 4.0(Java 6)起,驱动通过 META-INF/services/java.sql.Driver 自动发现,Class.forName 可以省略——只要驱动 jar 在 classpath 里。

三、Statement vs PreparedStatement

执行 SQL 有两种主要方式——StatementPreparedStatement

3.1 Statement:直接拼 SQL(有 SQL 注入风险)

Statement stmt = conn.createStatement();
String name = "张三";   // 假设来自用户输入
ResultSet rs = stmt.executeQuery("SELECT * FROM users WHERE name = '" + name + "'");

问题:如果用户输入 ' OR '1'='1,SQL 变成 SELECT * FROM users WHERE name = '' OR '1'='1'——SQL 注入!所有用户都查出来了。

3.2 PreparedStatement:预编译 + 参数化(防注入)

String sql = "SELECT * FROM users WHERE name = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, "张三");    // 索引从 1 开始!
ResultSet rs = ps.executeQuery();

? 是占位符,参数通过 setString/setInt/setObject 等填充。驱动会对参数做转义——单引号会被转义成 \',注入无效。永远用 PreparedStatement,不要用 Statement 拼用户输入!

ps.setString(1, "' OR '1'='1");   // 会被当成普通字符串, 不会注入

PreparedStatement 还有两个额外好处:

  1. 预编译——同一条 SQL 多次执行时,数据库只编译一次执行计划,性能高。
  2. 类型安全——setInt 传非数字会编译错,避免类型混乱。

四、ResultSet 与游标

ResultSet 是查询结果集——它是一个”游标”(cursor),初始指向第一行之前,next() 移动到下一行并返回是否有数据。

ResultSet rs = ps.executeQuery();
while (rs.next()) {           // 移动到下一行
    int id = rs.getInt("id");          // 按列名取
    String name = rs.getString(2);     // 按列索引取 (从 1 开始!)
    Date created = rs.getDate("created_at");
    System.out.println(id + ", " + name + ", " + created);
}

注意 JDBC 的索引从 1 开始(不是 0)——这是历史遗留,许多 JDBC API 都是 1-based。

默认 ResultSet 只能 next() 向前滚动、只读。要可滚动可更新:

Statement stmt = conn.createStatement(
    ResultSet.TYPE_SCROLL_INSENSITIVE,   // 可滚动
    ResultSet.CONCUR_UPDATABLE           // 可更新
);
// 之后 rs.previous()/rs.first()/rs.absolute(3) 等都能用

实际开发很少用可滚动结果集——大数据量时占用内存,一般用 LIMIT/OFFSET 分页。

五、批处理

要执行大量 INSERT/UPDATE,一条条发太慢——addBatch + executeBatch 一次性发给数据库:

conn.setAutoCommit(false);
PreparedStatement ps = conn.prepareStatement("INSERT INTO logs(msg) VALUES(?)");
for (int i = 0; i < 1000; i++) {
    ps.setString(1, "log " + i);
    ps.addBatch();          // 加入批
    if (i % 100 == 0) {
        ps.executeBatch();   // 每 100 条执行一次
        ps.clearBatch();
    }
}
ps.executeBatch();           // 剩余的
conn.commit();

批处理能把 1000 次网络往返压缩到 10 次,性能提升 10 倍以上。

六、获取自增主键

INSERT 后经常需要拿到数据库生成的自增主键:

String sql = "INSERT INTO users(name) VALUES(?)";
PreparedStatement ps = conn.prepareStatement(
    sql,
    Statement.RETURN_GENERATED_KEYS     // 声明要返回主键
);
ps.setString(1, "张三");
ps.executeUpdate();

ResultSet keys = ps.getGeneratedKeys();
if (keys.next()) {
    long id = keys.getLong(1);   // 自增主键
    System.out.println("新用户 id: " + id);
}

七、事务管理

JDBC 默认自动提交——每条 SQL 执行完立即 commit。要事务控制,先关掉自动提交:

Connection conn = DriverManager.getConnection(url, user, pwd);
conn.setAutoCommit(false);   // 关闭自动提交

try {
    // 转账: A 减 100, B 加 100
    ps1.executeUpdate("UPDATE account SET balance = balance - 100 WHERE id = 1");
    ps2.executeUpdate("UPDATE account SET balance = balance + 100 WHERE id = 2");
    conn.commit();            // 都成功 -> 提交
} catch (SQLException e) {
    conn.rollback();          // 任一失败 -> 回滚
    throw e;
}

7.1 Savepoint:部分回滚

事务里有时只想回滚一部分,不影响前面已成功的操作——Savepoint 派上用场:

conn.setAutoCommit(false);
try {
    ps1.executeUpdate("INSERT ...");   // 操作 1

    Savepoint sp = conn.setSavepoint("before_risky");   // 设置保存点
    try {
        ps2.executeUpdate("INSERT ...");   // 操作 2 (可能失败)
    } catch (SQLException e) {
        conn.rollback(sp);    // 只回滚到 sp, 操作 1 保留
    }

    conn.commit();            // 操作 1 + 操作 2(若成功) 一起提交
} catch (SQLException e) {
    conn.rollback();
}

八、实战:通用 DAO 封装

由于 Piston 在线环境没有真实数据库驱动,我用模拟的 JDBC API 演示完整流程——所有接口签名和真 JDBC 一致,只是底层用内存 Map 模拟数据库。你换上真实 JDBC 驱动,代码逻辑完全一样。

Java · 在线运行

下面是真实 JDBC 的完整代码模板(在本地有 MySQL 时可直接运行):

import java.sql.*;

public class JdbcDemo {
    public static void main(String[] args) throws SQLException {
        String url = "jdbc:mysql://localhost:3306/test?useSSL=false&serverTimezone=UTC";
        try (Connection conn = DriverManager.getConnection(url, "root", "123456")) {
            // 建表
            try (Statement st = conn.createStatement()) {
                st.execute("CREATE TABLE IF NOT EXISTS users ("
                    + "id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50), age INT)");
            }

            // 插入 (PreparedStatement + 获取自增主键)
            try (PreparedStatement ps = conn.prepareStatement(
                    "INSERT INTO users(name, age) VALUES(?, ?)",
                    Statement.RETURN_GENERATED_KEYS)) {
                ps.setString(1, "张三");
                ps.setInt(2, 20);
                ps.executeUpdate();
                try (ResultSet keys = ps.getGeneratedKeys()) {
                    if (keys.next()) System.out.println("新 id: " + keys.getLong(1));
                }
            }

            // 查询
            try (PreparedStatement ps = conn.prepareStatement(
                    "SELECT * FROM users WHERE age > ?")) {
                ps.setInt(1, 18);
                try (ResultSet rs = ps.executeQuery()) {
                    while (rs.next()) {
                        System.out.println(rs.getInt("id") + " "
                            + rs.getString("name") + " " + rs.getInt("age"));
                    }
                }
            }

            // 事务
            conn.setAutoCommit(false);
            try (PreparedStatement ps = conn.prepareStatement(
                    "UPDATE users SET age = age + ? WHERE id = ?")) {
                ps.setInt(1, 1); ps.setInt(2, 1); ps.executeUpdate();
                ps.setInt(1, -1); ps.setInt(2, 2); ps.executeUpdate();
                conn.commit();
            } catch (SQLException e) {
                conn.rollback();
                throw e;
            }
        }
    }
}

观察重点

  • PreparedStatement? 占位 + setXxx 填参——防 SQL 注入的核心。
  • JDBC 索引从 1 开始——setString(1, ...) 不是 0,这是历史遗留。
  • 事务三步走——setAutoCommit(false) → 业务 → commit/rollback
  • try-with-resources 自动关连接——Connection/Statement/ResultSet 都要关,否则泄漏。
  • Savepoint 回滚到指定点——不影响保存点之前的操作。

九、JDBC 资源管理与陷阱

JDBC 资源(Connection/Statement/ResultSet)都是”重型”对象,必须及时关闭,否则泄漏。最佳实践:

// 推荐: try-with-resources 自动关闭
try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql)) {
    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) { ... }
    }
}

常见陷阱

  1. Connection 不关——数据库连接数有限(MySQL 默认 151),泄漏多了拒绝服务。
  2. ResultSet 不关——游标占用数据库资源,长期不关会撑爆。
  3. 在循环里创建 Statement——每次循环 new 一个,资源爆炸。
  4. SELECT *——查不需要的列浪费带宽,明确列出列名。
  5. 事务跨方法——setAutoCommit(false) 后跨方法调用,容易忘记 commit。

十、本章小结

概念核心要点
DriverManager.getConnection获取连接
Statement vs PreparedStatement后者防 SQL 注入
? 占位符setString/setInt 填参,索引从 1
ResultSet游标式遍历,next() 移动
批处理addBatch/executeBatch
自增主键RETURN_GENERATED_KEYS + getGeneratedKeys
事务setAutoCommit(false) + commit/rollback
Savepoint部分回滚
资源管理try-with-resources 自动关

记忆口诀

  • 永远用 PreparedStatement——别用 Statement 拼用户输入。
  • JDBC 索引从 1 开始——历史遗留,别按 0 算。
  • 事务三件套——关自动提交、commit、rollback。
  • 资源用 try-with-resources——Connection/Statement/ResultSet 都要关。
  • 批处理先 add 再 execute——大量插入性能 10 倍。

结语:从 JDBC 到连接池

这一章我们用模拟环境演示了 JDBC 的全流程——连接、CRUD、批处理、事务、Savepoint。真实项目里基本不直接写 JDBC——会用 MyBatis/JPA 等框架封装。但理解 JDBC 是理解这些框架的前提——MyBatis 的 #{} 就是 PreparedStatement?@Transactional 就是 setAutoCommit(false)

下一章我们看一个现实问题——每次请求都 getConnection 太慢怎么办?答案是连接池