← 返回 Java 后端知识路线
阶段 03数据库与持久化

JDBC 与 PreparedStatement:一行 SQL 如何安全到达数据库

用 JDBC 完成文章查询和参数绑定,追踪 Connection、PreparedStatement、ResultSet 与领域对象的转换。

第 18 / 35 篇
JDBCPreparedStatementResultSetSQL 注入

先看这一课值不值得学

学完后,你手里多了哪些代码积木

把 Java 参数安全绑定到 SQL,再把结果逐列还原。

本课正式新增

语法 / API / 命令你必须会到什么程度
DriverManager / Connection建立数据库连接
PreparedStatement预编译 SQL 并绑定参数
executeUpdate / executeQuery执行写操作或查询
ResultSet逐行读取查询结果

本课只借用,先别硬背

  • 连接池第 19 课,事务边界第 20 课

学完必须能独立写

  • 安全新增并查询文章
  • 解释 SQL 注入为何不能靠字符串拼接解决
本课目录
  1. 1. 现实问题:拼 SQL 让安全和类型都失去边界
  2. 2. 最小可运行示例:绑定参数、映射结果
  3. 3. 调用链与对象变化
  4. 4. 为什么这样设计
  5. 5. 项目落点:Repository 只做数据边界
  6. 6. 易错排查
  7. 7. 一页复习

1. 现实问题:拼 SQL 让安全和类型都失去边界

把用户输入直接拼进 SQL,输入中的引号可能改变语句结构,形成 SQL 注入;即使没有恶意,日期、字符串转义和 null 也会让代码变得脆弱。JDBC 的核心学习价值,是把连接、参数、执行、结果和关闭一段段看清楚,之后使用 MyBatis 或 Repository 仍然能定位问题。

我们写一个按状态查询文章的 DAO。示例中使用 PreparedStatement 占位符,明确查询列,并把每一行映射为不可变记录。连接字符串、账号和密码只作环境配置,不写进源码。

2. 最小可运行示例:绑定参数、映射结果

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;

public class ArticleJdbcDemo {
    record Article(long id, String title) {}

    static List<Article> findPublished(Connection connection) throws Exception {
        String sql = "SELECT id, title FROM article WHERE status = ? ORDER BY id DESC LIMIT ?";
        List<Article> result = new ArrayList<>();
        try (PreparedStatement statement = connection.prepareStatement(sql)) {
            statement.setString(1, "PUBLISHED");
            statement.setInt(2, 20);
            try (ResultSet rows = statement.executeQuery()) {
                while (rows.next()) {
                    result.add(new Article(rows.getLong("id"), rows.getString("title")));
                }
            }
        }
        return List.copyOf(result);
    }

    public static void main(String[] args) throws Exception {
        try (Connection connection = DriverManager.getConnection(
                System.getenv("DB_URL"), System.getenv("DB_USER"), System.getenv("DB_PASSWORD"))) {
            findPublished(connection).forEach(System.out::println);
        }
    }
}

运行前需要把 MySQL JDBC 驱动放进构建依赖,且环境变量指向可测试数据库;本文不在课程仓库创建完整 demo 工程。setStringsetInt 让驱动知道参数类型,不能用字符串拼接替代。

3. 调用链与对象变化

DriverManager 根据 URL 选择已注册的 JDBC Driver,创建 Connection。prepareStatement 把 SQL 模板交给驱动,参数槽最初为空;set 方法把 Java 值绑定到槽位,驱动在执行时编码成数据库协议。数据库返回结果,ResultSet 维护当前行游标,next() 移动游标并告诉程序是否还有行。

每次 getLonggetString 都把当前行列值转成 Java 类型,DAO 创建一个 Article 记录放入 List。关闭 ResultSet、Statement 和 Connection 释放数据库端游标、语句和连接。List.copyOf 最后创建不可变结果,调用方不能反过来改变 DAO 的中间列表。

4. 为什么这样设计

PreparedStatement 的第一价值是参数与 SQL 结构分离,减少注入风险;第二价值是驱动可以复用解析和类型绑定路径。它不是权限和业务校验的替代品,仍要限制查询范围、列名和排序白名单。动态列名、表名不能用 ? 绑定,需要在受控枚举映射后拼入。

JDBC 资源顺序是 Connection → Statement → ResultSet,关闭顺序相反。DAO 不应把 Connection 作为全局单例长期持有,也不应在循环里每行重新建立连接。连接池会在更高层复用连接,但借用者仍必须关闭逻辑连接。

5. 项目落点:Repository 只做数据边界

博客系统可以有 ArticleRepository.findPublished(int limit)findById(long id),Service 负责发布规则,DAO 负责 SQL 和映射。查询失败抛带 SQL 场景的基础异常,但不要把密码、完整 SQL 参数或敏感内容打进日志。数据库字段可空时使用 wasNull() 或明确的包装类型映射。

练习:实现 insertDraft,使用 RETURN_GENERATED_KEYS 取回 id;再增加一个 title 查询,测试引号、中文、空字符串和超长输入。用数据库日志确认最终发送的 SQL 结构没有把用户文本拼进去。

6. 易错排查

  • 参数下标从 1 开始:setString(0, ...) 会失败,逐个核对 SQL 占位符。
  • 连接忘记关闭:短测正常,长期运行耗尽数据库连接;统一 try-with-resources。
  • getString 返回 null:数据库列可空,先决定 DTO 是 nullable 还是业务默认值。
  • 把 LIMIT 当字符串拼接:数值也应该绑定或经过范围校验,动态排序只能走白名单。

7. 一页复习

JDBC 链是驱动建连接、预编译 SQL、绑定参数、执行、移动 ResultSet、映射对象、关闭资源。PreparedStatement 分离结构与值,但不能替代校验和权限。DAO 只负责数据边界,Service 负责业务规则,调用链清楚后换 ORM 也不会失去判断力。

JDBC 的资源状态可以细化为:Connection 尚未借出、已建立但 autoCommit 状态未知、PreparedStatement 已绑定部分参数、ResultSet 位于当前行或已结束、资源已关闭。DAO 只有在成功关闭 ResultSet 和 Statement 后才应返回结果,Connection 的关闭要回到连接池边界。若参数为 null,使用 setNull 和明确 SQL 类型,不能把 Java null 拼成字符串 "null"

输入到输出的对象变化是:HTTP/query 的文本先由上层变成 limit、status 等类型,PreparedStatement 保存模板和参数,数据库执行后 ResultSet 暴露列值,Article 记录复制出所需字段,List.copyOf 固化快照。映射层应明确时间、金额、可空列和枚举 code 的转换,不能让驱动默认转换隐藏语义。

项目文件可以在 persistence/jdbc/ArticleJdbcRepository 中保存 SQL 和 row mapper,在 application/article 中定义 Repository 接口。测试用假的 DataSource 验证参数绑定,用 Testcontainers 验证真实 SQL 和唯一键;两种测试目的不同。日志只记录 statement 名、耗时、受影响行数和参数类别,不打印密码或完整正文。

排错时 SQL 注入先检查是否存在字符串拼接,参数错位检查占位符从 1 开始和绑定顺序,结果少一行检查 while (rows.next()) 与过滤条件,连接泄漏检查异常路径和池等待。插入成功但 id 为 0,要查生成键选项、列类型和读取方式;查询中文乱码要同时看数据库连接字符集和 Java 解码。

练习是把 findPublished 改成带 cursor 的分页,要求按时间和 id 稳定翻页;写出第一页/第二页的输入输出表,测试同一时间多个 id、空结果、非法 limit、包含引号的关键词和数据库断连。最后用 SQL 客户端复核同一参数,确认问题发生在 Java 绑定还是数据库执行。

JDBC 示例里的每个变量都对应一段外部状态:Connection 表示数据库会话,PreparedStatement 表示 SQL 结构和参数槽,ResultSet 表示服务器返回的游标,Article 是应用内的值快照。关闭 ResultSet 后不能再读取当前行,关闭 Statement 后不能再次执行,关闭 Connection 后不能继续查询;资源状态不是编译器自动替你检查的。

参数绑定要按业务类型选择 setter:状态是受控字符串,limit 是经过范围校验的整数,时间要使用带时区或明确本地语义的类型,金额要与 DECIMAL 精度一致,空值要用 setNull 指定 SQL 类型。动态排序列不能靠 ? 绑定,应将请求中的 newest/oldest 映射为两段固定 SQL,结构和用户文本保持分离。

结果映射时,列名是数据库协议,字段名是 Java 对象属性,二者不一致就需要别名或显式 RowMapper。可空列不能直接假设 getLong 的 0 就是真实值,要结合 wasNull;枚举 code 不认识时应该返回映射错误;文章正文很大时要评估 getString 带来的内存峰值,不能把所有行无界收集进 List。

项目文件可把 SQL 常量和 ArticleRowMapper 放在 persistence,Repository 接口放在 application,事务组合放在 Service。DAO 单测可以用记录参数的 fake Connection,集成测用真实 MySQL 检查 SQL、索引和约束。生产日志使用 statement 名和参数类型,不记录密码、token、完整正文和带个人信息的关键词。

常见故障的原因不同:SQL 注入来自拼接结构,参数错位来自占位符顺序,连接耗尽来自异常路径没有关闭,结果少行来自 cursor 没有 next 到末尾,乱码来自连接/表/客户端字符集不一致。遇到“查询没有报错但数据不对”,先在客户端执行同一 SQL,再比较绑定值、事务提交和映射字段。

练习是实现 cursor 分页和插入草稿:输入包括 status、afterTime、afterId、limit,输出包括 rows 和 nextCursor;测试相同时间多个 id、空页、非法 limit、引号关键词、唯一冲突和断连。把每次测试的 SQL 模板、影响行数和最终数据库状态保留,形成后续 MyBatis 映射的基准。

验证清单:用同一个查询分别传入普通关键词、包含引号的关键词、空状态、非法 limit 和未知排序 key,记录 SQL 模板、参数类型/顺序、受影响行数和结果 id;SQL 文本不应随关键词改变,排序只能从白名单映射。故意让 ResultSet 在读取后关闭、让异常发生在 execute 后和让事务中途断开,确认 try-with-resources 依次归还 ResultSet、Statement、Connection,并把失败状态传给上层。复盘时把数据库行、Article 快照和返回 DTO 分开,避免把可变游标当领域对象长期保存。

进阶附录:批处理与生成键

批量插入可以使用 addBatchexecuteBatch,但要设置合理批次、处理部分失败和事务边界。生成键的类型和数据库配置相关,读取后不要假设一定是 int。高并发服务应使用连接池和指标观察 active、idle、等待时间,而不是在 DAO 内自行创建线程。

本课按「JDBC 4.3、PreparedStatement 与关系数据访问」的学习范围组织,正文与示例均为本站原创整理。