第04篇 · JDBC手写到进化:数据库操作的“原始时代”

你平时用 MyBatis 或者 JPA 的时候,有没有想过这个问题:在 MyBatis 出现之前,前辈们是怎么操作数据库的?

如果你去翻一些老项目的代码,可能会看到这样的场景:一个查询方法写了五六十行,一半是 try-catch-finally,另一半是 rs.getString("xxx")。这就是原生 JDBC 的日常。

这一篇,我们回到"原始时代",亲手写一遍原生 JDBC,感受它的繁琐,理解它的痛点。因为只有知道了"从哪里来",才能真正理解"为什么要有这些框架"。

学习目标

  • 回顾 JDBC 的核心 API(DriverManagerConnectionStatementResultSet
  • 理解原生 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 释放资源 关闭 ResultSetStatementConnection

我们先用代码走一遍这个流程。

二、Statement vs PreparedStatement:从"拼接 SQL"到"占位符预编译"

在 JDBC 中,执行 SQL 语句有两种方式:StatementPreparedStatement

方式一: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 段几乎一样的模板代码。

痛点二:资源管理复杂、容易泄漏

ConnectionPreparedStatementResultSet 都需要手动关闭。关闭顺序还有讲究 :先关 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 层(数据访问对象)
    ↓
数据库

设计步骤

  1. 定义 DAO 接口------声明数据访问方法
  2. 实现 DAO 接口------用 JDBC 实现具体逻辑
  3. 业务层通过接口调用,不依赖具体实现
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 为例:

  1. 客户端发送 PREPARE 指令和 SQL 模板给 MySQL 服务器
  2. MySQL 服务器解析 SQL、生成执行计划并缓存
  3. 执行时,客户端发送 EXECUTE 指令和参数值
  4. MySQL 服务器直接用缓存的执行计划 + 参数值执行

并不是所有 JDBC 驱动都会把预编译发送到服务器端 。比如 MySQL 驱动默认只在服务端做"模拟预编译"(useServerPrepStmts=false),真正把预编译交给服务器需要配置 useServerPrepStmts=true。但无论哪种方式,参数化查询防止 SQL 注入的效果是一样的

Q2:为什么说"用 JDBC 连接数据库"这句话其实省略了很多步骤?

因为 DriverManager.getConnection() 背后做了太多事情:

  1. 驱动发现 :通过 SPI 机制加载 java.sql.Driver 实现类
  2. 协议握手:和数据库服务器建立 TCP 连接(三次握手)
  3. 认证:发送用户名和密码(MySQL 协议层的握手)
  4. 会话初始化:设置字符集、事务隔离级别、自动提交等
  5. 连接对象创建:封装底层的 Socket 和协议状态

这些步骤加起来,首次创建连接的耗时可能达到 100ms 以上。这也是为什么连接池如此重要------它把"创建连接"变成了"从池子里借一个现成的"。

Q3:既然 PreparedStatement 这么好,为什么还要保留 Statement

PreparedStatement 并非万能:

  • 动态表名/列名 :预编译时数据库需要校验表名和列名是否存在,所以它们不能被占位符替代 。比如 SELECT * FROM ? WHERE id = ? 是非法的。
  • DDL 语句CREATE TABLEALTER TABLE 等不支持参数化
  • 一次性的 SQL:如果某条 SQL 只执行一次,预编译的开销(网络往返 + 服务端缓存)可能反而比直接执行更大

但在 90% 以上的场景 (带参数的 DML 查询和更新)中,PreparedStatement 都是更好的选择。

思考与延伸

  1. 数一数代码行数 :如果一张表有 20 个字段,用原生 JDBC 写一个 save() 方法,要写多少行 setXxx()?如果换成 update() 呢?这些重复劳动能否用反射内省自动化?

  2. 动手改造 :把示例一中的 UserDaoRawtry-with-resources 重构一遍,对比 finally 方式的代码量变化。

  3. 思考题PreparedStatementsetString(1, value) 方法,如果 value 中包含 SQL 的关键字(比如 DROP TABLE),会不会有问题?为什么?

  4. 预告 :下一篇我们将解决"频繁创建连接"的问题------数据库连接池。你会发现,连接池不仅仅是"复用连接"这么简单。

参考与延伸阅读

  • 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