你平时用 MyBatis 或者 JPA 的时候,有没有想过这个问题:在 MyBatis 出现之前,前辈们是怎么操作数据库的?
如果你去翻一些老项目的代码,可能会看到这样的场景:一个查询方法写了五六十行,一半是 try-catch-finally,另一半是 rs.getString("xxx")。这就是原生 JDBC 的日常。
这一篇,我们回到"原始时代",亲手写一遍原生 JDBC,感受它的繁琐,理解它的痛点。因为只有知道了"从哪里来",才能真正理解"为什么要有这些框架"。
学习目标
- 回顾 JDBC 的核心 API(
DriverManager、Connection、Statement、ResultSet) - 理解原生 JDBC 的六大痛点(代码冗余、资源管理、SQL 硬编码、异常处理、性能问题、可维护性)
- 掌握
PreparedStatement相对于Statement的优势(预编译 + 防 SQL 注入) - 理解 "分层"思想 如何解决 JDBC 的痛点
正文
一、JDBC 六步法:数据库操作的"标准流程"
JDBC(Java Database Connectivity)是 Java 访问数据库的最底层规范。无论你用的是 MyBatis、Hibernate 还是 Spring JDBC,它们最终都要"翻译"成 JDBC 的 API 调用。
一次完整的 JDBC 操作,通常包含六个标准步骤:
| 步骤 | 做什么 | 核心 API |
|---|---|---|
| 1 | 注册/加载数据库驱动 | Class.forName()(JDBC 4.0+ 可省略) |
| 2 | 获取数据库连接 | DriverManager.getConnection() |
| 3 | 创建 Statement 对象 | Connection.createStatement() 或 prepareStatement() |
| 4 | 执行 SQL 语句 | executeQuery() / executeUpdate() |
| 5 | 处理结果集 | 遍历 ResultSet,取出数据 |
| 6 | 释放资源 | 关闭 ResultSet、Statement、Connection |
我们先用代码走一遍这个流程。
二、Statement vs PreparedStatement:从"拼接 SQL"到"占位符预编译"
在 JDBC 中,执行 SQL 语句有两种方式:Statement 和 PreparedStatement。
方式一:Statement(不推荐)
java
// 直接把用户输入拼到 SQL 里------危险!
String username = request.getParameter("username"); // 用户可能输入: ' OR '1'='1
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql);
方式二:PreparedStatement(推荐)
java
// 使用占位符 ?,SQL 结构和数据分离
String sql = "SELECT * FROM users WHERE username = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, username); // 参数绑定
ResultSet rs = pstmt.executeQuery();
两者的核心区别:
Statement 是直接把参数拼到 SQL 字符串里 再发给数据库执行。PreparedStatement 则是先把带占位符的 SQL 模板发给数据库进行预编译,数据库解析并缓存了 SQL 的语法结构,等真正执行时再把参数传进去。
这意味着什么?用户传入的参数不参与 SQL 语法树的构建,也就无法改变 SQL 的结构 。即使用户输入 ' OR '1'='1,它也会被当作普通字符串值处理,而不是 SQL 指令的一部分。
三、原生 JDBC 的六大痛点
我们用一段完整的原生 JDBC 查询代码,来感受一下"痛点"在哪里:
java
public User getUserById(int id) {
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
User user = null;
try {
// 1. 加载驱动(JDBC 4.0+ 可省略,但很多老代码仍然保留)
Class.forName("com.mysql.cj.jdbc.Driver");
// 2. 获取连接 ------ 硬编码:URL、用户名、密码都写死在代码里
conn = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/mydb?useSSL=false",
"root",
"123456"
);
// 3. 定义 SQL ------ 硬编码:SQL 写死在 Java 代码里
String sql = "SELECT id, username, email, create_time FROM users WHERE id = ?";
// 4. 创建 PreparedStatement
pstmt = conn.prepareStatement(sql);
pstmt.setInt(1, id);
// 5. 执行查询
rs = pstmt.executeQuery();
// 6. 处理结果集 ------ 硬编码:列名写死在代码里,手动封装成对象
if (rs.next()) {
user = new User();
user.setId(rs.getInt("id"));
user.setUsername(rs.getString("username"));
user.setEmail(rs.getString("email"));
user.setCreateTime(rs.getTimestamp("create_time"));
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (SQLException e) {
e.printStackTrace();
} finally {
// 7. 释放资源 ------ 繁琐!每个资源都要单独判断 null 然后关闭
if (rs != null) {
try { rs.close(); } catch (SQLException e) { e.printStackTrace(); }
}
if (pstmt != null) {
try { pstmt.close(); } catch (SQLException e) { e.printStackTrace(); }
}
if (conn != null) {
try { conn.close(); } catch (SQLException e) { e.printStackTrace(); }
}
}
return user;
}
这段代码不到 50 行,但问题太多了。我们来逐一拆解:
痛点一:代码冗余、重复劳动
每个数据库操作都要重复写:加载驱动→获取连接→创建 Statement→执行 SQL→处理结果→释放资源。如果一个项目有 50 张表,每张表 4 个 CRUD 方法,就要写 200 段几乎一样的模板代码。
痛点二:资源管理复杂、容易泄漏
Connection、PreparedStatement、ResultSet 都需要手动关闭。关闭顺序还有讲究 :先关 ResultSet,再关 Statement,最后关 Connection。而且每个 close() 都要单独 try-catch。开发中因为忘记关闭连接导致的连接泄漏,是线上最常见的故障之一。
痛点三:SQL 硬编码,维护困难
SQL 语句直接写在 Java 代码里。一旦 SQL 需要调整(比如加个字段、改个表名),就要修改 Java 代码、重新编译、重新部署。
痛点四:异常处理繁琐且不统一
每个 JDBC 操作都要处理 SQLException,但 SQLException 是一个**"胖异常"**------它包含了错误码、SQL 状态、还有链式异常。开发者很难统一处理,往往就是 e.printStackTrace() 了事。
痛点五:结果集映射手动化,容易出错
rs.getString("username") 这种代码,列名是字符串,没有编译期检查 。如果数据库字段改名了,代码不会报错,但运行时就取不到数据了。而且 50 个字段的表,你要写 50 行 setXxx()------手都酸了。
痛点六:性能问题
每次操作都要创建和销毁数据库连接。而创建一条数据库连接涉及到 TCP 三次握手、MySQL 协议握手、认证、会话初始化等一系列操作------耗时可以达到几十到几百毫秒。高并发下,频繁创建连接会严重拖垮性能。
四、第一次进化:封装工具类
既然"获取连接"和"释放资源"是重复的,那就把它们抽出来:
java
public class JDBCUtils {
private static final String URL = "jdbc:mysql://localhost:3306/mydb?useSSL=false";
private static final String USER = "root";
private static final String PASSWORD = "123456";
// 静态代码块:类加载时执行一次
static {
try {
Class.forName("com.mysql.cj.jdbc.Driver");
} catch (ClassNotFoundException e) {
throw new ExceptionInInitializerError("MySQL驱动加载失败");
}
}
// 获取连接
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USER, PASSWORD);
}
// 释放资源(重载多个版本)
public static void close(ResultSet rs, Statement stmt, Connection conn) {
closeResultSet(rs);
closeStatement(stmt);
closeConnection(conn);
}
public static void close(Statement stmt, Connection conn) {
closeStatement(stmt);
closeConnection(conn);
}
private static void closeResultSet(ResultSet rs) {
if (rs != null) {
try { rs.close(); } catch (SQLException e) { /* 日志记录 */ }
}
}
private static void closeStatement(Statement stmt) {
if (stmt != null) {
try { stmt.close(); } catch (SQLException e) { /* 日志记录 */ }
}
}
private static void closeConnection(Connection conn) {
if (conn != null) {
try { conn.close(); } catch (SQLException e) { /* 日志记录 */ }
}
}
}
有了工具类,查询方法可以简化成:
java
public User getUserById(int id) {
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
User user = null;
try {
conn = JDBCUtils.getConnection();
String sql = "SELECT id, username, email, create_time FROM users WHERE id = ?";
pstmt = conn.prepareStatement(sql);
pstmt.setInt(1, id);
rs = pstmt.executeQuery();
if (rs.next()) {
user = new User();
user.setId(rs.getInt("id"));
// ... 省略 setter
}
} catch (SQLException e) {
e.printStackTrace();
} finally {
JDBCUtils.close(rs, pstmt, conn);
}
return user;
}
代码缩短了,但本质问题没变:SQL 还是硬编码,结果集还是要手动映射。
五、第二次进化:DAO 模式
DAO(Data Access Object)模式 的核心思想是:把数据访问逻辑集中到一个独立的层中,业务层只和 DAO 层打交道,不直接接触 JDBC。
业务层(Service)
↓
DAO 层(数据访问对象)
↓
数据库
设计步骤:
- 定义 DAO 接口------声明数据访问方法
- 实现 DAO 接口------用 JDBC 实现具体逻辑
- 业务层通过接口调用,不依赖具体实现
java
// 1. DAO 接口
public interface UserDao {
User getById(int id);
List<User> findAll();
void save(User user);
void update(User user);
void deleteById(int id);
}
// 2. DAO 实现类
public class UserDaoImpl implements UserDao {
@Override
public User getById(int id) {
// 使用 JDBCUtils 完成查询
// ...
}
// 其他方法省略
}
// 3. 业务层使用
public class UserService {
private UserDao userDao = new UserDaoImpl();
public User findUser(int id) {
return userDao.getById(id);
}
}
DAO 模式的价值:
- 关注点分离:业务逻辑不关心数据是怎么存的
- 可替换性:将来换成 MyBatis,只需要换 DAO 的实现类
- 可测试性:可以轻松地用 Mock 对象替代真实 DAO
但 DAO 模式并没有解决 JDBC 本身的繁琐------只是把繁琐的代码挪了个地方而已。
代码示例
示例一:原生 JDBC 完整 CRUD(感受"繁琐")
我们创建一个用户表,然后用原生 JDBC 完成增删改查。完整代码运行在 Java 17 + MySQL 8.0 环境下。
java
package com.example.jdbc;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
/**
* 原生 JDBC 完成 CRUD 操作
* 感受:代码量大、重复、容易出错
*/
public class UserDaoRaw {
// 数据库连接信息(硬编码------痛点!)
private static final String URL = "jdbc:mysql://localhost:3306/testdb?useSSL=false&serverTimezone=Asia/Shanghai";
private static final String USER = "root";
private static final String PASSWORD = "123456";
/**
* 查询所有用户
*/
public List<User> findAll() {
List<User> userList = new ArrayList<>();
Connection conn = null;
Statement stmt = null;
ResultSet rs = null;
try {
// 1. 加载驱动(JDBC 4.0+ 可以省略,但为了兼容老版本保留)
Class.forName("com.mysql.cj.jdbc.Driver");
// 2. 获取连接
conn = DriverManager.getConnection(URL, USER, PASSWORD);
// 3. 创建 Statement ------ 注意:这里用 Statement 演示,实际应使用 PreparedStatement
stmt = conn.createStatement();
// 4. 执行查询
String sql = "SELECT id, username, email, age FROM users";
rs = stmt.executeQuery(sql);
// 5. 处理结果集 ------ 手动映射到 Java 对象
while (rs.next()) {
User user = new User();
user.setId(rs.getInt("id"));
user.setUsername(rs.getString("username"));
user.setEmail(rs.getString("email"));
user.setAge(rs.getInt("age"));
userList.add(user);
}
} catch (ClassNotFoundException e) {
System.err.println("MySQL驱动未找到: " + e.getMessage());
} catch (SQLException e) {
System.err.println("数据库操作异常: " + e.getMessage());
} finally {
// 6. 释放资源 ------ 繁琐且容易遗漏
if (rs != null) {
try { rs.close(); } catch (SQLException e) { /* 忽略 */ }
}
if (stmt != null) {
try { stmt.close(); } catch (SQLException e) { /* 忽略 */ }
}
if (conn != null) {
try { conn.close(); } catch (SQLException e) { /* 忽略 */ }
}
}
return userList;
}
/**
* 根据 ID 查询用户 ------ 使用 PreparedStatement
*/
public User getById(int id) {
User user = null;
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
Class.forName("com.mysql.cj.jdbc.Driver");
conn = DriverManager.getConnection(URL, USER, PASSWORD);
// 使用占位符 ?,防止 SQL 注入
String sql = "SELECT id, username, email, age FROM users WHERE id = ?";
pstmt = conn.prepareStatement(sql);
pstmt.setInt(1, id); // 参数绑定
rs = pstmt.executeQuery();
if (rs.next()) {
user = new User();
user.setId(rs.getInt("id"));
user.setUsername(rs.getString("username"));
user.setEmail(rs.getString("email"));
user.setAge(rs.getInt("age"));
}
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
} finally {
// 同样的资源释放代码 ------ 重复!
if (rs != null) { try { rs.close(); } catch (SQLException e) {} }
if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) {} }
if (conn != null) { try { conn.close(); } catch (SQLException e) {} }
}
return user;
}
/**
* 插入用户
*/
public void save(User user) {
Connection conn = null;
PreparedStatement pstmt = null;
try {
Class.forName("com.mysql.cj.jdbc.Driver");
conn = DriverManager.getConnection(URL, USER, PASSWORD);
String sql = "INSERT INTO users (username, email, age) VALUES (?, ?, ?)";
pstmt = conn.prepareStatement(sql);
pstmt.setString(1, user.getUsername());
pstmt.setString(2, user.getEmail());
pstmt.setInt(3, user.getAge());
int affected = pstmt.executeUpdate();
System.out.println("插入成功,影响行数: " + affected);
} catch (Exception e) {
e.printStackTrace();
} finally {
// 重复的资源释放代码...
if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) {} }
if (conn != null) { try { conn.close(); } catch (SQLException e) {} }
}
}
// 更新、删除方法省略(结构完全一样)
}
请你数一数 :这个类里写了多少遍 try-catch-finally?多少遍 rs.close()?这就是原生 JDBC 的"重量"。
示例二:SQL 注入攻击演示
这是每个开发者都应该亲手验证一次的实验。
java
package com.example.jdbc;
import java.sql.*;
/**
* 演示 SQL 注入攻击 ------ 使用 Statement 拼接 SQL 的危险性
* 注意:此代码仅用于演示,切勿在生产环境使用!
*/
public class SqlInjectionDemo {
private static final String URL = "jdbc:mysql://localhost:3306/testdb?useSSL=false";
private static final String USER = "root";
private static final String PASSWORD = "123456";
/**
* ❌ 不安全的登录方法 ------ 使用 Statement 拼接 SQL
* 攻击者输入: username = "任意值' OR '1'='1", password = "任意值"
* 生成的 SQL: SELECT * FROM users WHERE username = '任意值' OR '1'='1' AND password = '任意值'
* 因为 '1'='1' 恒为真,整个 WHERE 条件恒为真,返回所有用户!
*/
public static boolean loginUnsafe(String username, String password) {
Connection conn = null;
Statement stmt = null;
ResultSet rs = null;
try {
Class.forName("com.mysql.cj.jdbc.Driver");
conn = DriverManager.getConnection(URL, USER, PASSWORD);
stmt = conn.createStatement();
// 危险!直接把用户输入拼到 SQL 里
String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";
System.out.println("执行的 SQL: " + sql);
rs = stmt.executeQuery(sql);
return rs.next(); // 如果有结果,说明登录成功
} catch (Exception e) {
e.printStackTrace();
return false;
} finally {
// 省略资源释放
}
}
/**
* ✅ 安全的登录方法 ------ 使用 PreparedStatement
*/
public static boolean loginSafe(String username, String password) {
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
Class.forName("com.mysql.cj.jdbc.Driver");
conn = DriverManager.getConnection(URL, USER, PASSWORD);
String sql = "SELECT * FROM users WHERE username = ? AND password = ?";
pstmt = conn.prepareStatement(sql);
pstmt.setString(1, username);
pstmt.setString(2, password);
System.out.println("执行的 SQL 模板: " + sql);
rs = pstmt.executeQuery();
return rs.next();
} catch (Exception e) {
e.printStackTrace();
return false;
} finally {
// 省略资源释放
}
}
public static void main(String[] args) {
// 攻击者输入
String maliciousInput = "' OR '1'='1";
System.out.println("===== 不安全的登录 =====");
boolean result1 = loginUnsafe(maliciousInput, "任意密码");
System.out.println("登录结果: " + result1); // true ------ 攻击成功!
System.out.println("\n===== 安全的登录 =====");
boolean result2 = loginSafe(maliciousInput, "任意密码");
System.out.println("登录结果: " + result2); // false ------ 防御成功!
}
}
运行结果会告诉你:用 Statement 拼接 SQL,攻击者可以用 ' OR '1'='1 绕过任何登录验证 。而 PreparedStatement 通过预编译机制,将用户输入当作普通字符串值处理,从根本上杜绝了这个问题。
新手错误 vs 正确姿势
| 错误表象 | 根本原因 | 正确姿势 |
|---|---|---|
忘记关闭 Connection,导致数据库连接数飙升至上限,应用卡死 |
异常发生时 finally 块未正确执行,或未处理 close() 的异常 |
使用 try-with-resources (Java 7+),自动关闭实现 AutoCloseable 的资源 |
使用 Statement 拼接用户输入,被 SQL 注入攻击 |
未使用参数化查询,用户输入被当作 SQL 指令执行 | 始终使用 PreparedStatement,SQL 结构与数据分离 |
| 关闭资源的顺序错误(先关 Connection 再关 ResultSet)导致异常 | 不理解资源之间的依赖关系:ResultSet 依赖 Statement,Statement 依赖 Connection | 先关 ResultSet,再关 Statement,最后关 Connection(或直接用 try-with-resources 让 JVM 自动按正确顺序关闭) |
| 在循环中频繁获取和关闭 Connection | 未使用连接池,每次操作都新建连接 | 使用 HikariCP、Druid 等连接池管理连接 |
疑难深度追问
Q1:PreparedStatement 的预编译是在哪一层完成的?
分两个层面:客户端(JDBC 驱动)和数据库服务器。以 MySQL 为例:
- 客户端发送
PREPARE指令和 SQL 模板给 MySQL 服务器 - MySQL 服务器解析 SQL、生成执行计划并缓存
- 执行时,客户端发送
EXECUTE指令和参数值 - MySQL 服务器直接用缓存的执行计划 + 参数值执行
但并不是所有 JDBC 驱动都会把预编译发送到服务器端 。比如 MySQL 驱动默认只在服务端做"模拟预编译"(useServerPrepStmts=false),真正把预编译交给服务器需要配置 useServerPrepStmts=true。但无论哪种方式,参数化查询防止 SQL 注入的效果是一样的。
Q2:为什么说"用 JDBC 连接数据库"这句话其实省略了很多步骤?
因为 DriverManager.getConnection() 背后做了太多事情:
- 驱动发现 :通过 SPI 机制加载
java.sql.Driver实现类 - 协议握手:和数据库服务器建立 TCP 连接(三次握手)
- 认证:发送用户名和密码(MySQL 协议层的握手)
- 会话初始化:设置字符集、事务隔离级别、自动提交等
- 连接对象创建:封装底层的 Socket 和协议状态
这些步骤加起来,首次创建连接的耗时可能达到 100ms 以上。这也是为什么连接池如此重要------它把"创建连接"变成了"从池子里借一个现成的"。
Q3:既然 PreparedStatement 这么好,为什么还要保留 Statement?
PreparedStatement 并非万能:
- 动态表名/列名 :预编译时数据库需要校验表名和列名是否存在,所以它们不能被占位符替代 。比如
SELECT * FROM ? WHERE id = ?是非法的。 - DDL 语句 :
CREATE TABLE、ALTER TABLE等不支持参数化 - 一次性的 SQL:如果某条 SQL 只执行一次,预编译的开销(网络往返 + 服务端缓存)可能反而比直接执行更大
但在 90% 以上的场景 (带参数的 DML 查询和更新)中,PreparedStatement 都是更好的选择。
思考与延伸
-
数一数代码行数 :如果一张表有 20 个字段,用原生 JDBC 写一个
save()方法,要写多少行setXxx()?如果换成update()呢?这些重复劳动能否用反射 或内省自动化? -
动手改造 :把示例一中的
UserDaoRaw用 try-with-resources 重构一遍,对比 finally 方式的代码量变化。 -
思考题 :
PreparedStatement的setString(1, value)方法,如果value中包含 SQL 的关键字(比如DROP TABLE),会不会有问题?为什么? -
预告 :下一篇我们将解决"频繁创建连接"的问题------数据库连接池。你会发现,连接池不仅仅是"复用连接"这么简单。
参考与延伸阅读
- Oracle. JDBC 4.1 Specification --- try-with-resources. Oracle Java Documentation, 2022
- Oracle. Using Prepared Statements. Oracle Java Documentation
- 阿里云开发者社区. JDBC存在什么问题?MyBatis是如何解决的?. 2024-10
- 腾讯云. 预编译为什么可以防御 SQL 注入?. 2024-11
- 阿里云开发者社区. 详解预编译如何防止SQL注入的原理与代码实现. 2025-01
- Baeldung. JDBC PreparedStatement SQL Injection Example. Baeldung