一、为什么 2025 年了还值得聊 JDBC?

说实话,很多人一听 “JDBC”,心里第一反应是:

啊,不就是 getConnection() + prepareStatement() 那点老东西嘛, 现在项目里不都是 Spring Data JPA、MyBatis、MyBatis-Plus 吗?

乍一看没错,但只要你真正在生产项目里干过一段时间,就会发现:所有“高级”的持久层框架,最后都逃不过 JDBC 这道坎

几个你很可能真实遇到的场景:

  • 场景 1:线上连接池打满,服务雪崩

    • 监控上看到 HikariCP / Druid 的活跃连接数飙升、等待队列拉满;

    • 排查到处都是 getConnection(),却说不清:

      连接池到底怎么跟 JDBC 打交道?连接泄漏是卡在哪一层?

  • 场景 2:ORM 生成的 SQL 跑得飞慢

    • 你看到日志里 Hibernate/MyBatis 打出的 SQL;

    • 打开监控一看:某条查询耗时 2 秒;

    • 你很难判断:

      慢在数据库?慢在 JDBC?慢在 ORM 的对象映射和缓存?

  • 场景 3:面试被问懵

    • “JDBC 和 ORM 的关系是什么?”

    • DriverManager 具体做了什么?Driver 是怎么被发现和加载的?”

    • “连接池和 JDBC 之间是谁在管事务?”

这些问题,框架层都不会替你回答。 回答这些问题的“那一层”,就叫做:JDBC

更关键的是:

  • 你现在写的 MyBatis、JPA、Spring Data JPA、Spring Data JDBC…… 统统都是 站在 JDBC 的肩膀上

  • 如果你只会“调用 Repository / Mapper”,但脑子里没有一张清晰的:

    “应用 → ORM / MyBatis → JDBC → 驱动 → 数据库” 的调用图 那么一旦出了复杂问题,你的排查维度永远停留在“猜”。

所以,哪怕 2025 年了,JDBC 仍然是 Java 后端开发的“必修底层课”:

  • 它不时髦,但永远躺在调用栈最下面;

  • 它露出很少,但一出问题就是大问题;

  • 理解它,不是为了多写几行原生 JDBC,而是:

    让你在面对 ORM 和连接池时,真正知道自己在干什么。


二、JDBC 到底是什么?规范、驱动还是某个 jar 包?

很多同学对 JDBC 的印象是这样的:

  • “我在项目里加了 mysql-connector-j 这个 jar, 然后调用 DriverManager.getConnection, 能连上 MySQL,这就是 JDBC 吧?”

这其实只说对了一半。

为了不绕,我们先把 几个容易混淆的概念拆开

1. JDBC 不是“某一个驱动 jar”

JDBC 本身不等于

  • 不是 mysql-connector-j-x.x.x.jar

  • 不是 postgresql-x.x.x.jar

  • 更不是某家厂商的私有库。

这些 jar 是什么? 它们是:各个数据库厂商对 JDBC 规范的“实现”——也就是 JDBC 驱动(Driver)

2. JDBC 首先是一套“规范 + 接口”

在 JDK 里,你会看到这些熟悉的包:

java.sql.*
javax.sql.*   (在 Jakarta 之后是 jakarta.sql.*)

里面定义了一堆接口和类,比如:

  • java.sql.Driver

  • java.sql.DriverManager

  • java.sql.Connection

  • java.sql.Statement / PreparedStatement

  • java.sql.ResultSet

  • java.sql.SQLException

  • ……

它们一起构成了:

JDBC 规范:Java 世界访问关系型数据库的统一 API 和行为约定。

也就是说:

  • 对上:你写业务代码,只需要面向这些标准接口编程;

  • 对下:各家数据库厂商通过 实现这些接口 来接入 Java 生态。

3. 驱动(Driver)= JDBC 规范的厂商实现

比如 MySQL 驱动里就会有类似(类名略有不同,但本质一致):

public class com.mysql.cj.jdbc.Driver implements java.sql.Driver {
    // 实现 connect、acceptsURL 等方法
}

PostgreSQL 也会有:

public class org.postgresql.Driver implements java.sql.Driver {
    // 实现自己的连接逻辑
}

它们要做的事情可以用一句话概括:

“用 JDBC 规定好的接口形式,把 Java 世界的调用,翻译成各自数据库协议的一串字节,然后通过 Socket 发出去,接收结果,再翻译回来。”

所以整体关系可以画成这样:

你的业务代码
        ↓   (面向 JDBC API 编程)
  java.sql.*(JDBC 规范 / 接口)
        ↓   (由驱动实现)
  MySQL / PG / Oracle ... JDBC Driver
        ↓
    具体数据库

JDBC 本质上更像是《接口说明书 + 一套 Java API》 真正干活的是各家的 JDBC Driver 实现。


三、从一行 DriverManager.getConnection() 开始拆:DriverManager & Driver

聊 JDBC,如果只停留在「会写 CRUD」其实没什么感觉。 真正有意思的,是把这句经典代码拆开来看:

Connection conn = DriverManager.getConnection(
        "jdbc:mysql://localhost:3306/demo_db?useSSL=false&serverTimezone=UTC",
        "root",
        "password"
);

看起来只是:

“传一个 URL 和用户名密码,给我一个 Connection。”

实际上,这一行背后,至少发生了这么几件事:

  1. JVM 里已经有一堆 Driver 注册进来了(MySQL、PG、Oracle…)

  2. DriverManager 会在这堆 Driver 里“挑一个合适的”;

  3. 找到之后,会调用这个 Driver 的 connect() 方法;

  4. Driver 再真正去:

    • 解析 URL;

    • 建立 Socket;

    • 协议握手、认证;

    • 创建一个实现了 java.sql.Connection 的对象返回给你。

我们把这条链路按角色讲清楚。


3.1 DriverManager:JDBC 世界的“路由器”

DriverManager 不是驱动本身,它更像一个 “驱动管理中心 / 路由器”,核心职责有两件:

  1. 维护驱动列表

    • 内部有一个 registeredDrivers 的集合;

    • 存放所有已经注册的 java.sql.Driver 实例。

  2. 按 URL 选择合适的驱动并建立连接

    • 你调用 getConnection(url, info) 时:

      • 遍历所有已注册的 Driver;

      • 依次问它们:“你认这个 URL 吗?”;

      • 找到第一个说“我认”的那个,让它去 connect()

用一个简化版伪代码来感受一下 getConnection(url, info) 的流程(只保留核心逻辑):

public static Connection getConnection(String url, Properties info) throws SQLException {
    SQLException reason = null;

    // 遍历所有已注册的 Driver
    for (DriverInfo di : registeredDrivers) {
        Driver driver = di.driver;

        // 先问:你能不能处理这个 URL?
        if (driver.acceptsURL(url)) {
            try {
                // 能处理就尝试建立连接
                Connection conn = driver.connect(url, info);
                if (conn != null) {
                    return conn; // 成功则直接返回
                }
            } catch (SQLException ex) {
                reason = ex; // 记下异常,最后统一抛
            }
        }
    }

    // 没有任何 Driver 能处理这个 URL
    if (reason != null) throw reason;

    throw new SQLException("No suitable driver found for " + url);
}

注意几点:

  • DriverManager 不会自己去连数据库,它只是“调度者 + 分发器”;

  • 真正连接数据库的是每个具体 Driver 的 connect() 实现。


3.2 Driver:谁来负责“我认这个 URL,我去连”

java.sql.Driver 接口是 JDBC 规范要求各家驱动必须实现的核心接口。

它的职责本质上只有两件事:

  1. URL 过滤:我能不能处理这个 URL?

    boolean acceptsURL(String url) throws SQLException;
    

    比如 MySQL 驱动内部会判断:

    • URL 前缀是不是 jdbc:mysql:

    • 格式对不对,参数是否满足基本要求等。

  2. 建立连接:根据 URL 和参数创建 Connection

    Connection connect(String url, Properties info) throws SQLException;
    

    这里才是重头戏,一般会做:

    • 解析 URL(主机、端口、数据库名、连接参数等);

    • 建立底层 Socket 连接;

    • 按照各自的协议(MySQL 协议 / PG 协议……)完成握手和认证;

    • 创建一个自己实现的 Connection 实例返回。

同样,用一个简化伪代码感受一下:

@Override
public Connection connect(String url, Properties info) throws SQLException {
    // 1. 不认这个 URL,直接返回 null,让 DriverManager 去找下一个
    if (!acceptsURL(url)) {
        return null;
    }

    // 2. 解析 URL
    ParsedUrl parsed = parseUrl(url, info);

    // 3. 建立底层网络连接、完成认证
    Socket socket = openSocket(parsed.host, parsed.port);
    handshake(socket, info);

    // 4. 返回自己实现的 Connection
    return new MySqlConnectionImpl(socket, parsed, info);
}

你在业务代码里看到的:

Connection conn = DriverManager.getConnection(...);

拿到的是一个 java.sql.Connection 接口类型, 但实际上背后是类似 MySqlConnectionImpl 这种“驱动私有实现类”。


3.3 Driver 是怎么“注册”到 DriverManager 里的?

这一步很多人比较模糊,尤其是现在基本不用写 Class.forName 之后。

其实主要有两种机制:

① 早期写法:Class.forName() + 静态代码块注册

在旧时代(以及兼容一些老驱动时),我们会手写:

Class.forName("com.mysql.cj.jdbc.Driver");

这行代码的本质不是“new 一个 Driver”,而是:

  • 让 JVM 去加载 com.mysql.cj.jdbc.Driver 这个类;

  • 执行它的 静态代码块

很多驱动内部会这么写(伪代码):

public class Driver implements java.sql.Driver {

    static {
        try {
            DriverManager.registerDriver(new Driver());
        } catch (SQLException e) {
            throw new RuntimeException("Can't register driver!", e);
        }
    }

    // 省略 connect / acceptsURL 实现...
}

也就是说:

你一 Class.forName(...), JVM 加载这个类 → 执行 static 块 → 调 DriverManager.registerDriver(...) → 这个 Driver 就出现在 registeredDrivers 列表里了。

② JDBC 4.0 之后:Service Provider(自动发现)

在 JDBC 4.0 之后,更现代的驱动(比如新版 MySQL/PostgreSQL) 一般不需要你手写 Class.forName 了,它们通过 SPI + ServiceLoader 自动注册。

驱动 jar 里有一个文件:

META-INF/services/java.sql.Driver

内容类似:

com.mysql.cj.jdbc.Driver
org.postgresql.Driver
...

JDK 在初始化 DriverManager 时,会通过 ServiceLoader.load(Driver.class) 扫描这些声明过的实现类,自动实例化并注册。

这就是为什么现在你只要:

  • 在依赖里加上 MySQL 驱动;

  • 写个正确的 jdbc:mysql://... URL;

不用写任何 Class.forNameDriverManager.getConnection 就能工作。


小结一下这一节的链路:

你的代码:
    DriverManager.getConnection(url, user, pass)
        ↓
DriverManager:
    遍历已注册的 Driver 列表
    → 调 driver.acceptsURL(url)
        ↓
匹配上的 Driver:
    调 connect(url, props)
    → 解析 URL、建连接、握手认证
    → new 一个具体 Connection 实现类
        ↓
返回给你:
    一个实现了 java.sql.Connection 的对象(接口视角)

从这一行 getConnection() 往下追, 你就会发现 JDBC 世界里几个最核心的角色:

  • DriverManager:驱动的“管理者”和“选择器”;

  • Driver:具体数据库的“接入实现”和“Connection 工厂”;

  • Connection:后面所有 SQL、事务、Statement、ResultSet 的起点。


四、Connection / Statement / ResultSet:JDBC 三件套的角色划分

前面我们说过:DriverManager.getConnection(...) 最终给你的,是一个实现了 java.sql.Connection 接口的对象。 从这一刻起,后面所有的数据库操作,基本都绕不开三件套:

Connection → Statement / PreparedStatement → ResultSet

很多初学者只知道“照着 Demo 写”,但脑子里没有清晰分工。 我们不妨先把它们当成一个小团队来看:

  • Connection:组长(会话 & 事务管理者)

  • Statement / PreparedStatement:执行 SQL 的“送信员”

  • ResultSet:数据库结果的“游标视图”

4.1 Connection:一次“会话” + 事务的承载者

Connection 的角色可以用一句话概括:

它代表了你和数据库之间的一次“会话”, 承载着整个会话期的配置和事务边界。

它负责的事情主要有:

  1. 管理事务

    conn.setAutoCommit(false); // 关闭自动提交,开始手动事务
    // ... 多条 SQL ...
    conn.commit();             // 提交事务
    conn.rollback();           // 回滚事务
    
    • 默认情况下,autoCommit = true,每条 DML(INSERT/UPDATE/DELETE)都是一个独立事务;

    • 当关闭自动提交后,多条语句就被“绑”在一个事务里,成功一起成功,失败一起失败。

  2. 保存会话级配置

    比如:

    conn.setReadOnly(true);
    conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
    
    • 隔离级别、只读模式、当前 schema/catalog 等, 其实都是挂在 Connection 上的“会话状态”。

  3. 创建 Statement / PreparedStatement

    • 执行 SQL 并不是 Connection 自己干的;

    • 它只负责创建执行 SQL 的“工具人”:

    Statement stmt = conn.createStatement();
    PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
    

    你可以把 Connection 理解成: “打开数据库的一扇门,这扇门背后站着一堆小弟(Statement)等你指挥。”


4.2 Statement / PreparedStatement:SQL 语句的“载体”

Statement 系列的职责只有一件事:

把你写的 SQL 发给数据库执行,并接收执行结果。

典型有三种常见用法:

  1. 执行无参数 SQL:Statement

    String sql = "CREATE TABLE users (id BIGINT PRIMARY KEY, name VARCHAR(50))";
    try (Statement stmt = conn.createStatement()) {
        stmt.executeUpdate(sql);
    }
    
    • 通常用于 DDL(建表、加索引)或简单、不带参数的语句;

    • 缺点是不能安全地拼接外部输入,否则容易 SQL 注入。

  2. 执行带参数 SQL:PreparedStatement

    String sql = "SELECT id, name, email FROM users WHERE id = ?";
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setLong(1, userId);                // 第 1 个 ? 绑定 userId
        try (ResultSet rs = ps.executeQuery()) {
            // 处理结果
        }
    }
    

    相比 Statement 的优势:

    • 使用 ? 占位符 + setXxx() 绑定参数,天然防止 SQL 注入

    • 在很多数据库中,预编译语句有缓存,重复执行时性能更好。

  3. 根据语句类型选择 executeUpdate / executeQuery / execute

    • executeUpdate():用于 DDL 和 DML 中的 INSERT/UPDATE/DELETE,返回影响行数;

    • executeQuery():用于 SELECT,返回 ResultSet

    • execute():通用型,返回布尔值表示有没有结果集(一般框架内部才用)。

可以理解为:

Statement / PreparedStatement 就是 JDBC 世界里 “把 SQL 丢给数据库、拿回结果” 的那双手。


4.3 ResultSet:结果集的“游标式快照”

当你执行查询语句时:

ResultSet rs = ps.executeQuery();

拿到的 ResultSet 不是一个“List”,而是一个游标

  • 一开始“指针”在第一行之前;

  • 每次 next(),指针下移一行;

  • 通过 getXxx("columnName") 拿当前行的列值。

典型用法:

try (ResultSet rs = ps.executeQuery()) {
    while (rs.next()) {
        long id = rs.getLong("id");
        String name = rs.getString("name");
        String email = rs.getString("email");
        // 组装成对象或直接使用
    }
}

几个关键点:

  1. ResultSet 跟 Connection 生命周期强相关

    • ResultSet 依赖 StatementConnection

    • 一般关闭顺序:ResultSetStatementConnection

    • try-with-resources 可以自动帮你管理。

  2. 滚动/只进等高级用法

    • 默认情况下,是“只进、向前”的结果集;

    • 也可以创建可滚动、可更新的 ResultSet(用得少,这里不展开)。

你可以把 ResultSet 理解成:

“数据库在某个时间点返回的结果快照 + 一个可以在这份快照上来回移动的指针”。


4.4 小结:三件套的分工

用一句话总结三件套:

Connection:负责“开会”和“会后总结”(会话 + 事务 + 配置)
Statement/PreparedStatement:负责“把要说的话讲出去”(执行 SQL)
ResultSet:负责“把别人回复的内容记下来翻阅”(读取查询结果)

而你平时写的那些 ORM / MyBatis,本质上就是在背后帮你:

  • 自动创建和管理 Connection;

  • 自动构造各种 Statement / PreparedStatement;

  • 自动把 ResultSet 里的每一列映射到你的实体对象字段上。


五、DDL/DML 在 JDBC 中是怎么跑的?(最小 CRUD Demo)

理解了三件套,我们来看最常见的两类 SQL:

  • DDL(Data Definition Language):数据定义语言 → 改“结构”

    • CREATE TABLE / ALTER TABLE / DROP TABLE

  • DML(Data Manipulation Language):数据操作语言 → 改“数据”

    • INSERT / UPDATE / DELETE / SELECT

5.1 DDL:建一个 users 表

我们先用一段最简单的 JDBC 代码,创建一张 users 表:

String url = "jdbc:mysql://localhost:3306/demo_db?useSSL=false&serverTimezone=UTC";
String user = "root";
String password = "password";

String ddl = """
    CREATE TABLE IF NOT EXISTS users (
        id BIGINT PRIMARY KEY AUTO_INCREMENT,
        name VARCHAR(50) NOT NULL,
        email VARCHAR(100) NOT NULL UNIQUE,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
    """;

try (Connection conn = DriverManager.getConnection(url, user, password);
     Statement stmt = conn.createStatement()) {

    int result = stmt.executeUpdate(ddl);
    System.out.println("DDL 执行完成,返回值 = " + result); // 通常对 DDL 没什么意义

} catch (SQLException e) {
    e.printStackTrace();
}

要点:

  • DDL 通常使用 Statement 即可(SQL 一般比较固定);

  • 通过 executeUpdate 执行即可,返回值一般为 0,重点在“有没有异常”


5.2 DML:用 PreparedStatement 写一个最小 CRUD

假设我们的 users 表已经建好,接下来实现一个最小的:

  • 插入用户(INSERT)

  • 更新邮箱(UPDATE)

  • 按 id 查询(SELECT)

  • 删除用户(DELETE)

public class JdbcCrudDemo {

    private static final String URL = "jdbc:mysql://localhost:3306/demo_db?useSSL=false&serverTimezone=UTC";
    private static final String USER = "root";
    private static final String PASSWORD = "password";

    public static void main(String[] args) {
        try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD)) {
            conn.setAutoCommit(false); // 手动事务,后面一节会展开讲

            try {
                long userId = insertUser(conn, "Alice", "alice@example.com");
                System.out.println("插入用户成功,id = " + userId);

                updateUserEmail(conn, userId, "alice.new@example.com");
                System.out.println("更新邮箱成功");

                queryUser(conn, userId);

                deleteUser(conn, userId);
                System.out.println("删除用户成功");

                conn.commit(); // 所有操作成功,提交事务
            } catch (Exception e) {
                conn.rollback(); // 出现任何异常,回滚
                throw e;
            }

        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    // INSERT:插入一个用户,返回自增主键
    private static long insertUser(Connection conn, String name, String email) throws SQLException {
        String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
        try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, name);
            ps.setString(2, email);

            int affected = ps.executeUpdate();
            if (affected != 1) {
                throw new SQLException("插入用户失败,受影响行数 = " + affected);
            }

            // 取回数据库生成的自增主键
            try (ResultSet rs = ps.getGeneratedKeys()) {
                if (rs.next()) {
                    return rs.getLong(1);
                } else {
                    throw new SQLException("插入成功但未获取到自增主键");
                }
            }
        }
    }

    // UPDATE:更新用户邮箱
    private static void updateUserEmail(Connection conn, long userId, String newEmail) throws SQLException {
        String sql = "UPDATE users SET email = ? WHERE id = ?";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, newEmail);
            ps.setLong(2, userId);

            int affected = ps.executeUpdate();
            if (affected != 1) {
                throw new SQLException("更新邮箱失败,受影响行数 = " + affected);
            }
        }
    }

    // SELECT:按 id 查询用户
    private static void queryUser(Connection conn, long userId) throws SQLException {
        String sql = "SELECT id, name, email, created_at FROM users WHERE id = ?";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setLong(1, userId);

            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    long id = rs.getLong("id");
                    String name = rs.getString("name");
                    String email = rs.getString("email");
                    Timestamp createdAt = rs.getTimestamp("created_at");

                    System.out.printf("查询到用户:id=%d, name=%s, email=%s, created_at=%s%n",
                            id, name, email, createdAt);
                } else {
                    System.out.println("未找到 id=" + userId + " 的用户");
                }
            }
        }
    }

    // DELETE:删除用户
    private static void deleteUser(Connection conn, long userId) throws SQLException {
        String sql = "DELETE FROM users WHERE id = ?";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setLong(1, userId);

            int affected = ps.executeUpdate();
            if (affected != 1) {
                throw new SQLException("删除用户失败,受影响行数 = " + affected);
            }
        }
    }
}

通过这个 Demo,你可以清楚看到:

  • DDLStatement + executeUpdate

  • DML(INSERT/UPDATE/DELETE)PreparedStatement + executeUpdate

  • DML(SELECT)PreparedStatement + executeQuery + ResultSet;

在“裸 JDBC 时代”,你要为每张表、每个 SQL 反复写这些模板代码; ORM / MyBatis 做的事情,就包含了把这些重复劳动统一收走。


六、事务与自动提交:setAutoCommit(false) 背后发生了什么?

事务是 JDBC 里一个经常“被忽视,但一旦犯错就是大事故”的点。 我们从一个最常见的问题说起:

“为什么我的插入/更新明明没报错,但数据库里就是没看到数据?”

十有八九,是对 auto-commit 和手动事务 的理解不清。

6.1 autoCommit = true:每条语句都是一个“隐式事务”

JDBC 规范规定:

  • 默认情况下,一个新的 ConnectionautoCommit = true

  • 这意味着:

    每一条 DML 语句(INSERT/UPDATE/DELETE)都被当作一个独立事务, 执行成功后会自动 commit

简单理解就是:

try (Connection conn = getConnection()) {
    // autoCommit 默认是 true
    insertA(conn);  // 独立事务 1
    updateB(conn);  // 独立事务 2
    deleteC(conn);  // 独立事务 3
}

只要每条语句本身没抛异常,结果就立刻提交到数据库了。 你不会有“回滚整批操作”的机会。

这在简单场景下没问题,但在“要么都成功,要么都失败”的业务里就有风险了,例如:

  • 扣库存 + 记录流水

  • 下订单 + 写日志 + 更新账户余额

如果中途有一条 SQL 失败,你很可能得到“扣了库存但没写日志”“扣了钱但没下单”这种不一致状态。


6.2 关闭自动提交:一次真正意义上的“事务”

为了保证一组操作的原子性,我们一般会这么写:

try (Connection conn = getConnection()) {
    conn.setAutoCommit(false); // ① 关闭自动提交,开启手动事务

    try {
        // ② 一组要么都成功,要么都失败的操作
        insertA(conn);
        updateB(conn);
        deleteC(conn);

        conn.commit();         // ③ 全部成功,提交事务
    } catch (Exception e) {
        conn.rollback();       // ④ 任意一步失败,回滚事务
        throw e;
    }
}

这里有几个关键点:

  1. setAutoCommit(false) 通常等价于“告诉数据库:我要开始一个显式事务了”;

  2. commit() 之前,多个 DML 语句的效果都只对当前事务可见;

  3. 调用 rollback() 会让这些尚未提交的改动全部撤销;

  4. 事务结束后(无论 commit 还是 rollback),有的数据库会把连接恢复为自动提交,这取决于驱动 & 数据库实现, 所以很多框架会在连接池归还连接前显式恢复 autoCommit 状态,避免影响下一个租用者。

结合前面的 CRUD Demo,你再看这段逻辑就很清晰了: 那段代码里把“插入 → 更新 → 查询 → 删除”放在一个手动事务里, 要么四个操作都完成,要么一个都不算数。


6.3 事务隔离级别:JDBC 只是“翻译官”

除了“有事务 / 没事务”,还有一个经常被提到的概念:隔离级别

在 JDBC 里,你可以通过 Connection 设置隔离级别:

conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);

常见的几种:

  • TRANSACTION_READ_UNCOMMITTED

  • TRANSACTION_READ_COMMITTED

  • TRANSACTION_REPEATABLE_READ

  • TRANSACTION_SERIALIZABLE

JDBC 做的事情其实很简单:

它只是提供了一组常量 + 一个方法, 把你在 Java 代码里的设置,翻译成各自数据库的命令。

真正决定“会不会脏读 / 不可重复读 / 幻读”的,是数据库本身

  • MySQL InnoDB 在 REPEATABLE_READ 下如何处理;

  • PostgreSQL 在 READ_COMMITTED 下是什么行为;

这些都不是 JDBC 定义的,而是各家数据库的实现。

JDBC 只负责这件事:

你说:我要 READ_COMMITTED
JDBC:好的,我翻译成一条数据库能听懂的命令发过去
数据库:收到,我按自己的规则执行

6.4 为什么事务这层理解清楚很重要?

因为一旦你上了:

  • Spring 事务(@Transactional

  • 连接池(HikariCP / Druid)

  • ORM / MyBatis

你会遇到各种迷惑行为:

  • 明明加了 @Transactional,为什么好像没回滚?

  • 为什么一个服务里某个方法改了 autoCommit,会影响到完全不相干的地方?

  • 连接池里的“归还连接”到底会不会 rollback / 重置事务状态?

要搞清楚这些问题,绕不开 JDBC 这一层:

  • Connection 是谁创建的?谁在池子里维护?

  • setAutoCommit(false) 是在什么时机被调用的?

  • commit() / rollback() 是谁来触发的?

理解 JDBC 层的事务行为之后,你再看 Spring 事务传播机制、再看 ORM 的 Session / EntityManager,就不会只停留在“背注解”的层面了。


七、连接池与 JDBC:为什么不能每次都 new 一个 Connection?

如果你只在本地写过 Demo,很容易有一种错觉:

try (Connection conn = DriverManager.getConnection(...)) {
    // do something
}

跑得也挺快啊?为啥生产上大家都要搞什么 HikariCP、Druid、C3P0?

关键在于:**getConnection() 背后可不是“new 一个 Java 对象”这么简单。**

7.1 一次真正的数据库连接成本有多高?

当你调用:

DriverManager.getConnection(url, user, password)

驱动实际要做的事情大致包括:

  1. 解析 URL:主机、端口、数据库名、连接参数;

  2. 建立网络连接:TCP 三次握手;

  3. 协议握手:MySQL/PG/Oracle 各自的一套 binary 协议交互;

  4. 认证:用户名/密码、权限检查、可能还有 SSL、加密协商;

  5. 初始化会话状态:字符集、时区、事务隔离级别、某些 session 变量等。

这些都是真金白银的网络 IO + CPU 开销。

本地 Demo 只有你一个人在玩,感觉不明显; 一旦到了线上高并发场景:

  • 每个请求都 new Connection

  • 每条 SQL 都走一遍 TCP + 握手 + 认证;

很快你就会遇到:

  • 数据库连接数飙升到上限;

  • 数据库 CPU 飙高;

  • 应用端请求大量阻塞在“获取连接”这一步。

7.2 连接池解决的核心问题

连接池(Connection Pool)背后的核心思想很简单:

把“昂贵的连接创建”摊薄掉,把“有限的数据库连接资源”管理起来。

具体来说,它做了几件事:

  1. 预先创建一批连接

    • 比如启动时先创建 10 个连接;

    • 后续根据负载动态扩容到最多 50 个。

  2. 复用连接

    • 请求 A:向池子借一个 Connection,用完 close() → 实际是“归还到池子”;

    • 请求 B:再来借 → 得到的是同一个底层物理连接。

  3. 限制最大并发连接数

    • 池子里最多就 50 个连接;

    • 超过的请求要么阻塞等待,要么直接抛异常;

    • 起到了“防止把数据库撑爆”的阀门作用。

  4. 做健康检查和泄漏检测

    • 空闲太久的连接会被回收;

    • 检测“借出去很久没还”的连接,帮助排查连接泄漏问题;

    • 对“坏掉的连接”(网络中断、数据库重启)做自动剔除和重建。

7.3 在 JDBC 视角下,连接池长什么样?

从代码层面看,连接池基本就是:实现了 javax.sql.DataSource 的一个类

比如在 HikariCP 里,你会这样用:

HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:mysql://localhost:3306/demo_db");
ds.setUsername("root");
ds.setPassword("password");
ds.setMaximumPoolSize(20);

try (Connection conn = ds.getConnection()) {
    // do something...
}

注意两点:

  1. 你拿到的还是一个 java.sql.Connection 对象;

  2. 但这个 Connection 的 close() 被池子“代理”了:

    • 看起来你关掉了连接;

    • 实际上只是把连接“放回池子”,以便下一个请求复用。

从调用链上看,变成了:

你的代码:
    DataSource.getConnection()
        ↓
连接池:
    从池子里借出一个空闲的 Connection(或者新建一个)
        ↓
底层:
    真正的 JDBC Connection(由 DriverManager/Driver 创建)

所以你可以这么记:

DriverManager + Driver 负责“怎么连上数据库”; 连接池负责“连上之后,这些连接怎么高效、安全地被复用”。


八、JDBC vs ORM / MyBatis:为什么上层还要套那么多框架?

这时候你可能会问:

我已经知道怎么用 JDBC 操作数据库了, 为啥项目里大家还要用 Hibernate / JPA / MyBatis / MyBatis-Plus 这一堆东西?

简单对比一下,同样一个“按 id 查询 User”的逻辑。

8.1 纯 JDBC 写法:一切从零开始

public User findById(Connection conn, long id) throws SQLException {
    String sql = "SELECT id, name, email, created_at FROM users WHERE id = ?";

    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setLong(1, id);
        try (ResultSet rs = ps.executeQuery()) {
            if (!rs.next()) {
                return null;
            }

            User user = new User();
            user.setId(rs.getLong("id"));
            user.setName(rs.getString("name"));
            user.setEmail(rs.getString("email"));
            user.setCreatedAt(rs.getTimestamp("created_at").toInstant());
            return user;
        }
    }
}

你自己处理了:

  • SQL 字符串;

  • 参数绑定;

  • ResultSet 解析;

  • 字段 → 对象属性映射。

这在一个小 Demo 里没问题; 但换成一个有几十张表、几百个 CRUD 场景的项目,你会发现:

  • 大量重复代码;

  • 一堆 copy-paste;

  • 一改字段就要到处找 SQL、改映射。

8.2 ORM / SQL Mapper 做的事

① ORM(比如 JPA / Hibernate)

你只需要定义实体类:

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    private String email;

    // getter / setter ...
}

然后就可以:

User user = entityManager.find(User.class, 1L);

ORM 会帮你:

  • 生成合适的 SQL;

  • 用 JDBC 执行;

  • ResultSet 映射回 User 对象;

  • 管理实体的生命周期(一级缓存、脏检查、懒加载等)。

② SQL Mapper(比如 MyBatis)

你定义 Mapper 接口 + XML/注解 SQL:

public interface UserMapper {
    User selectById(@Param("id") Long id);
}
<select id="selectById" parameterType="long" resultType="User">
    SELECT id, name, email, created_at
    FROM users
    WHERE id = #{id}
</select>

然后:

User user = userMapper.selectById(1L);

MyBatis 会帮你:

  • 处理参数绑定;

  • 执行 SQL;

  • ResultSet 按映射规则转换成 User 对象。

可以看到:

ORM / MyBatis 的核心价值就是: 把“操作数据库”从“面向 SQL 字符串 + ResultSet” 提升到“面向对象 / 接口”的抽象层。

8.3 那 JDBC 和它们的关系是什么?

一句话:

JDBC 是地基,ORM / MyBatis 是盖在地基上的楼。

  • ORM / MyBatis 内部还是用 JDBC

    • 拿连接(通常是从连接池 DataSource 里拿);

    • 创建 PreparedStatement

    • 执行 SQL;

    • 解析 ResultSet

  • 只是这些细节被封装掉了,你在业务层更关注:

    • 对象;

    • 仓储接口(Repository/Mapper);

    • 事务边界。

这也是为什么:

  • 当 ORM 生成的 SQL 很慢时,你依然要看 JDBC 层的执行日志;

  • 当连接池出现问题时,你需要知道“ORM 是如何获取和释放 Connection 的”。

理解 JDBC,是理解这些高层框架“底座行为”的前提。


九、JDBC 实战中的常见坑 + 最佳实践

讲了这么多原理,落地到实际项目中,JDBC 有几类经典“坑点”,非常值得单独列一节当作 checklist。

9.1 忘记关闭资源 / 关闭顺序不当

经典写法(反例):

Connection conn = DriverManager.getConnection(...);
PreparedStatement ps = conn.prepareStatement(...);
ResultSet rs = ps.executeQuery();
// 忘记 close,或者只关了 rs 没关 ps/conn

后果:

  • 连接池里的连接数越用越多;

  • 最终“看起来没多少请求”,但数据库连接数却打满。

最佳实践:

  • 一律使用 try-with-resources:

    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement(sql);
         ResultSet rs = ps.executeQuery()) {
    
        while (rs.next()) {
            // ...
        }
    }
    
  • 连接池场景下,close() 不是真的断开连接,而是“归还到池子”,不用心疼。

9.2 字符串拼 SQL,导致 SQL 注入

反例:

String sql = "SELECT * FROM users WHERE name = '" + name + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);

如果 name 来自用户输入,你可能被直接注入成:

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

最佳实践:永远使用 PreparedStatement + ? 占位符。

String sql = "SELECT * FROM users WHERE name = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, name);
    try (ResultSet rs = ps.executeQuery()) {
        // ...
    }
}

9.3 不合理的事务边界

反例 1:该用事务的地方没用事务

// 默认 autoCommit = true
insertOrder(conn);
deductStock(conn);
insertLog(conn);
// 中间任何一步失败,之前的操作都无法回滚

反例 2:事务范围过大、长事务占用连接

conn.setAutoCommit(false);
// 这里做了一堆远程调用、文件 IO、复杂计算
// 很久之后才 commit()
// 这一整个时间段里,数据库连接被占着不放

最佳实践:

  • 有明确原子性要求的一组操作,要显式事务:

    • setAutoCommit(false) → try … commit → catch … rollback;

  • 控制事务粒度:

    • 事务中避免长时间阻塞(远程调用/复杂计算);

    • 保证“打开事务 → 执行 SQL → 立刻提交/回滚”的节奏。

9.4 没有设置超时导致请求挂死

很多代码默认不设置任何超时:

  • SQL 卡在锁等待 / 慢查询;

  • 线程一直等,连接一直占着;

  • 连接池很快被占满,引发级联雪崩。

最佳实践:

  • JDBC 层合理设置查询超时:

    ps.setQueryTimeout(30); // 秒
    
  • 数据库层设置语句超时 / 锁等待超时;

  • 业务层(比如 Spring)设置事务超时。

9.5 批量操作没用 batch,导致性能惨烈

反例:循环里一条条 insert:

for (User u : users) {
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setString(1, u.getName());
        ps.executeUpdate();
    }
}

最佳实践:使用 batch:

String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    int batchSize = 0;

    for (User u : users) {
        ps.setString(1, u.getName());
        ps.setString(2, u.getEmail());
        ps.addBatch();

        if (++batchSize % 1000 == 0) {
            ps.executeBatch(); // 每 1000 条 flush 一次
            batchSize = 0;
        }
    }

    if (batchSize > 0) {
        ps.executeBatch();
    }
}

十、总结:一张图串起 JDBC、连接池、ORM、Spring

最后,用一张逻辑图把本文所有角色串起来:

         (Controller / Service)
               你的业务代码
                       │
                       ▼
         (Repository / Mapper / DAO 层)
       ┌─────────────────────────────────┐
       │        ORM / MyBatis / JPA      │
       │  - 实体映射 / SQL 生成或管理      │
       │  - 一级缓存 / 懒加载 / 级联等     │
       └─────────────────────────────────┘
                       │
                       ▼
       ┌─────────────────────────────────┐
       │           JDBC API              │
       │  Connection / Statement /       │
       │  PreparedStatement / ResultSet  │
       └─────────────────────────────────┘
                       │
                       ▼
       ┌─────────────────────────────────┐
       │           连接池 DataSource      │
       │  - 连接复用 / 最大连接数控制      │
       │  - 泄露检测 / 健康检查            │
       └─────────────────────────────────┘
                       │
                       ▼
       ┌─────────────────────────────────┐
       │   JDBC Driver (厂商实现)       │
       │  - DriverManager 注册 / 发现     │
       │  - connect() / 协议握手 / 认证   │
       └─────────────────────────────────┘
                       │
                       ▼
                 具体数据库
        (MySQL / PostgreSQL / Oracle ...)

再加上 Spring 事务 放的位置:

  • Spring 通过 @Transactional 拦截你的 Service 方法;

  • 在方法开始之前:

    • 从连接池拿一个 Connection;

    • 设置 autoCommit = false

  • 在方法正常返回时:

    • 调用 commit()

  • 在抛异常时:

    • 调用 rollback()

  • 最后把 Connection 交还给连接池。

所以,Spring 事务、本地 JDBC 事务、本地 Connection, 本质上都在操作同一层:JDBC Connection 的事务状态。


把这几层关系想清楚之后,JDBC 就不再只是“复制粘贴 Demo 里的三行代码”,而是:

  • 一个清晰可见的“最底层规范”;

  • 一个你在排查性能问题、连接池问题、事务问题时,必须理解的“地基”。

以后再看到:

  • DriverManager.getConnection(...)

  • HikariCP 里那一排连接池配置;

  • Hibernate/MyBatis 打出来的 SQL 日志;

你脑子里就不只是“能跑就行”,而是:

“哦,这是在 JDBC 这一层怎么走的; 再往下是驱动;再往上是 ORM; 事务和连接池又是怎么跨这几层配合的。”

Logo

葡萄城是专业的软件开发技术和低代码平台提供商,聚焦软件开发技术,以“赋能开发者”为使命,致力于通过表格控件、低代码和BI等各类软件开发工具和服务

更多推荐