Skip to content

JDBC 基础 ​

Java 初级 · 数据与持久化

JDBC(Java Database Connectivity)是 Java 访问关系型数据库的标准 API。表面看就是「连库、发 SQL、取结果」,但驱动是怎么被加载的、PreparedStatement 为什么能防注入、setAutoCommit(false) 后发生了什么——这些底层机制才是 JDBC 的精髓。本节把连接、语句执行、事务与底层原理一起讲清。

一、JDBC 四大核心对象 ​

  • DriverManager:管理数据库驱动、建立连接。
  • Connection:与数据库的一条会话连接,所有操作都基于它。
  • Statement / PreparedStatement:执行 SQL。后者预编译 + 参数化 + 防注入。
  • ResultSet:查询结果,逐行游标读取。
  • 事务:一组操作要么全成功、要么全回滚(setAutoCommit(false) + commit()/rollback())。

二、驱动是怎么被加载的:SPI 机制(原理) ​

早期 JDBC 需要写 Class.forName("com.mysql.cj.jdbc.Driver") 显式加载驱动类。现代(JDBC 4.0+,Java 6+)不需要这行代码了——因为驱动用 SPI(Service Provider Interface) 自动发现。

原理:MySQL 驱动 jar 里有个文件 META-INF/services/java.sql.Driver,内容写着实现类的全限定名:

text
com.mysql.cj.jdbc.Driver

JVM 启动后,DriverManager 初始化时用 ServiceLoader 扫描 classpath 下所有 jar 的这个文件,反射加载并注册驱动。所以你只需把 mysql-connector-j 依赖放进 classpath,DriverManager.getConnection(url,...) 就能自动找到对应驱动。

为什么曾经要 Class.forName?

老版本 Java 没有 SPI 自动发现,驱动类里有静态代码块执行 DriverManager.registerDriver(new Driver()),但静态块只有在「类被加载」时才跑,所以必须 Class.forName(...) 触发加载。现在 SPI 替你做了,那行代码可以删掉(保留也不报错,只是冗余)。

三、PreparedStatement:预编译 + 防注入(核心原理) ​

SQL 注入是怎么发生的 ​

若用字符串拼接拼 SQL:

java
// ❌ 危险!name 来自用户输入
String sql = "SELECT * FROM user WHERE name = '" + name + "'";
// 用户输入 name = "x' OR '1'='1"  →  实际 SQL:
// SELECT * FROM user WHERE name = 'x' OR '1'='1'
// 条件恒真,攻击者拿到所有用户数据!

拼接让用户的输入混入了 SQL 语法结构,改变了语义。

PreparedStatement 为什么能防住 ​

PreparedStatement 用 ? 占位符,SQL 模板先发给数据库预编译:

java
// ✅ 安全:参数与 SQL 分离
String sql = "SELECT id, name FROM user WHERE name = ? AND age > ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, name);   // 第 1 个 ? 绑定为「纯数据」
    ps.setInt(2, 18);
    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            int id = rs.getInt("id");
            String uname = rs.getString("name");
            System.out.println(id + " - " + uname);
        }
    }
}

关键点:? 处的东西永远是「数据值」,永远无法变成 SQL 关键字或语法。数据库把 SQL 模板先解析成执行计划,参数只是占位处的「填充值」,即使用户输入 ' OR '1'='1,也只会被当作一个奇怪的字符串去匹配 name 字段,无法改变 WHERE 的语义。这就是注入被根治的底层原因。

预编译还带来性能优势 ​

同一条 SQL 模板多次执行时,数据库复用已编译的执行计划(MySQL 服务端预编译 + 客户端缓存),省去重复解析。ORM 框架(MyBatis/Hibernate)底层也都用 PreparedStatement。

占位符不能用于表名/列名/ORDER BY 方向

? 只能绑定值(字符串、数字等)。"SELECT * FROM ?" 或 "ORDER BY ? DESC" 中表名/排序方向不是值,不能用占位符,会报错。这种动态表名需求需白名单校验后拼接(且绝不能拼接用户输入)。

四、ResultSet:游标式读取 ​

executeQuery 返回 ResultSet,初始游标在「第一行之前」,rs.next() 移到下一行并返回是否有数据;按列名或下标取字段。取完后务必关闭(try-with-resources 自动处理)。

java
while (rs.next()) {                 // 游标下移
    int id = rs.getInt("id");       // 按列名
    String name = rs.getString(2);  // 也可按下标(从 1 开始)
}

五、事务:要么全成,要么全回 ​

默认 Connection 是**自动提交(auto-commit)**的:每条 SQL 立即提交。转账这类「多步操作」必须包成事务:

java
try (Connection conn = DriverManager.getConnection(url, user, pwd)) {
    conn.setAutoCommit(false);            // 关闭自动提交,开启事务
    try (PreparedStatement ps1 = conn.prepareStatement(
            "UPDATE account SET balance = balance - ? WHERE id = ?")) {
        ps1.setBigDecimal(1, new BigDecimal("100"));
        ps1.setInt(2, 1);                 // A 扣款
        ps1.executeUpdate();
    }
    try (PreparedStatement ps2 = conn.prepareStatement(
            "UPDATE account SET balance = balance + ? WHERE id = ?")) {
        ps2.setBigDecimal(1, new BigDecimal("100"));
        ps2.setInt(2, 2);                 // B 加款
        ps2.executeUpdate();
    }
    conn.commit();                        // 两步都成功 → 提交
} catch (SQLException e) {
    conn.rollback();                      // 任一步失败 → 回滚,数据回到事务前状态
    e.printStackTrace();
}

异常分支里要 rollback

上面的 try-with-resources 在连接关闭时会自动回滚未提交的事务(若 autoCommit=false 且未 commit,关闭连接即回滚),但显式 rollback() 更稳妥、语义更清晰。生产环境用连接池(如 HikariCP),连接归还池前务必确保事务已结束,否则可能「脏连接」被别的请求复用。

六、常见误区与注意点 ​

WARNING

  • SQL 拼接导致注入:"... WHERE name='" + name + "'" 易被恶意构造,必须用 ? 占位 + setXxx 绑定。
  • 连接/语句未关闭:占用数据库连接直到耗尽,务必 try-with-resources(它同时关闭 Connection/Statement/ResultSet)。
  • 忘记提交事务:setAutoCommit(false) 后不 commit,数据不会落库。
  • 驱动未引入:缺 MySQL 驱动依赖抛 No suitable driver,需在 Maven 加连接器(SPI 才能发现)。
  • 在循环里反复 prepareStatement:同模板应复用 PreparedStatement,或靠批处理 addBatch() 提升性能。

七、最佳实践 ​

  1. 用连接池(HikariCP/Druid)管理连接,避免频繁建立/关闭 TCP 连接的开销。
  2. 所有 SQL 用 PreparedStatement + 占位符,杜绝注入、复用执行计划。
  3. 多步写操作包事务(setAutoCommit(false) + commit/rollback),异常必回滚。
  4. 资源一律 try-with-resources,确保连接、语句、结果集按逆序关闭。
  5. 读多写少或批量插入用 addBatch()/executeBatch(),减少网络往返。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. 用 JDBC 连接本地 MySQL,查询一张表并把结果打印到控制台(先建库建表插数据)。
  2. 写一个方法,根据 id 删除一条记录,使用 PreparedStatement 占位;再故意把 id 拼进字符串,体会注入风险并对比两种写法。
  3. 模拟「转账」:从 A 扣款、给 B 加款,放进同一事务;在中间抛异常,观察 rollback 是否让两边余额都不变。
  4. 思考:为什么现代 JDBC 不需要 Class.forName("com.mysql.cj.jdbc.Driver") 也能连上数据库?(提示:META-INF/services/java.sql.Driver 与 ServiceLoader)

本节小结

JDBC 用 Connection/PreparedStatement/ResultSet 操作数据库。驱动加载靠 SPI:驱动 jar 在 META-INF/services/java.sql.Driver 声明实现类,DriverManager 通过 ServiceLoader 自动发现并注册,故现代 JDBC 无需 Class.forName。PreparedStatement 先发 SQL 模板给数据库预编译、再用 setXxx 把参数绑定为「纯数据值」,参数永远无法变成 SQL 语法,从根上杜绝 SQL 注入,且可复用执行计划。多步操作包事务(setAutoCommit(false) + commit/rollback),异常必须回滚。资源用 try-with-resources 关闭,生产用连接池管理。