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,内容写着实现类的全限定名:
com.mysql.cj.jdbc.DriverJVM 启动后,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:
// ❌ 危险!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 模板先发给数据库预编译:
// ✅ 安全:参数与 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 自动处理)。
while (rs.next()) { // 游标下移
int id = rs.getInt("id"); // 按列名
String name = rs.getString(2); // 也可按下标(从 1 开始)
}五、事务:要么全成,要么全回
默认 Connection 是**自动提交(auto-commit)**的:每条 SQL 立即提交。转账这类「多步操作」必须包成事务:
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()提升性能。
七、最佳实践
- 用连接池(HikariCP/Druid)管理连接,避免频繁建立/关闭 TCP 连接的开销。
- 所有 SQL 用
PreparedStatement+ 占位符,杜绝注入、复用执行计划。 - 多步写操作包事务(
setAutoCommit(false)+commit/rollback),异常必回滚。 - 资源一律 try-with-resources,确保连接、语句、结果集按逆序关闭。
- 读多写少或批量插入用
addBatch()/executeBatch(),减少网络往返。
📚 官方文档与参考资料
- JDBC 教程(Oracle):https://docs.oracle.com/javase/tutorial/jdbc/
- Java SE 21 文档:https://docs.oracle.com/en/java/javase/21/
- MySQL 8.0 参考手册:https://dev.mysql.com/doc/refman/8.0/en/
- OpenJDK 项目:https://openjdk.org/
✏️ 练习与自测
- 用 JDBC 连接本地 MySQL,查询一张表并把结果打印到控制台(先建库建表插数据)。
- 写一个方法,根据
id删除一条记录,使用PreparedStatement占位;再故意把id拼进字符串,体会注入风险并对比两种写法。 - 模拟「转账」:从 A 扣款、给 B 加款,放进同一事务;在中间抛异常,观察
rollback是否让两边余额都不变。 - 思考:为什么现代 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 关闭,生产用连接池管理。