Skip to content

JDBC 与数据库连接 ​

S3-017必学进阶约 60 分钟JDK 21企业开发基础 · 数据持久化

1. 本节定位 ​

《SQL 基础》 到 《锁机制:行锁、表锁与乐观悲观锁》 这几节用 SQL 客户端直接跟数据库说话。本节换一种方式:

text
之前:你在客户端里手敲 SQL
       ↓
本节:Java 代码发 SQL、取结果、控事务

JDBC(Java Database Connectivity)是 Java 访问数据库的标准接口——它是后面 MyBatis(《MyBatis 数据持久化》)的地基,也是理解"框架到底替你做了什么"的前提。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 JDBC 的四个核心类型及各自职责;
  • 说出 Statement 与 PreparedStatement 的区别,并解释后者为什么能防注入;
  • 用 PreparedStatement 写参数化查询(含 IN、LIKE 的写法);
  • 用 setAutoCommit(false) + commit / rollback 控制事务边界;
  • 用 try-with-resources 保证连接与语句被关闭;
  • 说出"为什么每次请求都 new 一个 Connection 会出事";
  • 说出"手写 JDBC"的三处痛点(这也是 MyBatis 存在的理由)。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

没有 JDBC 之前:每种数据库都有自己的 API。换个数据库,业务代码要跟着重写。

JDBC 的解法:定一套接口,让数据库厂商提供实现(驱动)。

text
你的代码  →  java.sql.*(接口,JDK 自带)
                    ↑
              数据库驱动(厂商提供:mysql-connector-j / postgresql / h2)
                    ↓
                具体数据库

所以"换数据库"在代码层面可能只需要改两处:连接 URL 与驱动依赖 ✓

本节的实验就用这个特性:示例跑在 H2 的 MySQL 兼容模式上,换到真实 MySQL 只需改那两处。

为什么 JDBC 如此重要:所有 Java 的数据库框架都建在它之上——

框架与 JDBC 的关系
MyBatis用 JDBC 发 SQL、映射结果(《MyBatis 数据持久化》)
MyBatis-Plus在 MyBatis 之上再包一层(《MyBatis-Plus 通用 CRUD 与条件构造器》)
JPA / Hibernate把对象变更翻译成 JDBC 调用
Spring 的 JdbcTemplate把 JDBC 的样板代码包起来

读懂 JDBC,才能读懂它们"替你做了什么"。

5. 核心概念 ​

5.1 四个核心类型 ​

java
// ① DriverManager / DataSource:怎么连
Connection connection = DriverManager.getConnection(url, user, password);

// ② PreparedStatement:发什么 SQL
PreparedStatement statement = connection.prepareStatement(
        "SELECT name, price_in_cents FROM product WHERE price_in_cents BETWEEN ? AND ?");
statement.setInt(1, 10_000);
statement.setInt(2, 20_000);

// ③ ResultSet:怎么取结果
ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
    String name = resultSet.getString("name");
    int price = resultSet.getInt("price_in_cents");
}

// ④ 最后:都关掉
resultSet.close();  statement.close();  connection.close();
类型职责一句话
Connection一条数据库连接(含事务上下文)"跟数据库的那根线"
Statement / PreparedStatement执行 SQL"怎么发这条 SQL"
ResultSet结果集的游标"一行一行往下读"

三个 execute* 方法的区别(高频踩坑点):

方法用在返回
executeQuery(sql)SELECTResultSet
executeUpdate(sql)INSERT / UPDATE / DELETE受影响行数
execute(sql)任意(DDL 等)boolean(有没有结果集)

用错会抛:executeQuery 收到 UPDATE 会报 ResultSet is empty 或直接抛异常 ✗

5.2 Statement 与 PreparedStatement(本节重点) ​

Statement 的用法(危险):

java
String userInput = "' OR '1'='1";
String sql = "SELECT name FROM product WHERE name = '" + userInput + "'";   // 拼串
Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(sql);

拼出来的 SQL 是:

sql
SELECT name FROM product WHERE name = '' OR '1'='1'

OR '1'='1' 恒为真 → 整个 WHERE 条件失效 → 全表数据被拖出来 ✗

实测结果(本节代码的真实运行输出):

text
用户输入(模拟攻击者):' OR '1'='1

[危险] 拼串后的 SQL:
       SELECT name FROM product WHERE name = '' OR '1'='1'
       返回 3 行:[机械键盘, 人体工学椅, 降噪耳机]
       ← 攻击者拿到了全表数据

PreparedStatement 的用法(安全):

java
String sql = "SELECT name FROM product WHERE name = ?";      // 占位符,不是拼串
PreparedStatement statement = connection.prepareStatement(sql);
statement.setString(1, userInput);                           // 参数单独传进去
ResultSet resultSet = statement.executeQuery();

实测结果:

text
[安全] 参数化后执行:SELECT name FROM product WHERE name = ?
       返回 0 行:[]
       ← 整串被当成一个普通字符串去比较,注入失效

为什么参数化能防注入——关键在于"SQL 结构先被固定下来":

text
拼串:      SQL 文本先拼好 → 再交给数据库解析
            ↑ 参数被当成 SQL 语法的一部分解析 ✗

参数化:    先把 SQL 骨架发给数据库编译(结构已定)→ 再把参数当"纯数据"传进去
            ↑ 参数永远只是"值",改不了结构 ✓

一句话:? 里的内容不参与语法解析——攻击者就算输入 SQL 关键字,也只是被当成字符串去比较 ✓

两个细节:

问题答案
LIKE 怎么写WHERE name LIKE CONCAT('%', ?, '%')(通配符在 SQL 里,参数还是 ?)
IN 怎么写占位符个数要动态拼:IN (?, ?, ?),然后逐个 setXxx ✅
不能写 IN (?) 然后传 "1,2,3" ✗

IN 的正确写法:

java
List<Long> ids = List.of(1L, 3L, 4L);
String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
String sql = "SELECT name FROM product WHERE id IN (" + placeholders + ")";
//                                            ↑ 拼的是"问号的个数",不是参数值 ✓

try (PreparedStatement statement = connection.prepareStatement(sql)) {
    for (int index = 0; index < ids.size(); index++) {
        statement.setLong(index + 1, ids.get(index));
    }
    ...
}

注意这里"也用了拼串"——但拼的是 ? 的个数,是程序算出来的整数,不含任何用户输入 ✓ 这才是安全的拼串。

5.3 事务控制 ​

默认情况:每条 SQL 执行完就自动提交(autocommit = true)✗

java
connection.setAutoCommit(false);      // 关掉自动提交 → 事务开始
try {
    updateBalance(connection, fromId, -amount);        // 扣款
    updateBalance(connection, toId, +amount);          // 入账
    connection.commit();                                // 两件事都成功才提交
} catch (SQLException exception) {
    connection.rollback();                              // 任何一步失败,整体回滚
    throw exception;
} finally {
    connection.setAutoCommit(true);                     // 恢复默认
}

实测结果(本节代码):

text
转账前:张三=100000 分,李四=50000 分

--- 场景 A:正常转账 300 元(张三 → 李四)---
提交后:张三=70000 分,李四=80000 分

--- 场景 B:扣款后抛异常(模拟业务校验失败),应整体回滚 ---
捕获异常:模拟业务校验失败(例如风控拦截)
回滚后:张三=70000 分,李四=80000 分

关键看场景 B 的余额:与场景 A 结束时完全相同 ✓

扣款那一步明明执行了(UPDATE ... -30000),但因为没有 commit,回滚后数据库里什么也没变 ✓

事务边界的本质是"承诺":在 commit 之前,你的所有修改都只是"草稿"。

三个容易搞混的点:

点说明
事务绑定在 Connection 上换一个 Connection 就是另一个事务(《Spring:AOP 与事务》 讲的"事务是线程绑定"的底层原因)
rollback 之后连接仍可用不是把连接废掉,只是撤销未提交的修改
DDL 会隐式提交多数数据库里 CREATE / ALTER 会触发隐式 commit(《索引》)

Spring 做了什么(《Spring:AOP 与事务》):用 @Transactional 把这段 setAutoCommit / commit / rollback 的样板代码包起来——但它底层就是本节写的这些东西 ✓

5.4 资源管理:try-with-resources ​

反面写法(现在几乎见不到,但要知道为什么被淘汰):

java
Connection connection = null;
PreparedStatement statement = null;
ResultSet resultSet = null;
try {
    connection = DriverManager.getConnection(url, user, password);
    statement = connection.prepareStatement(sql);
    resultSet = statement.executeQuery();
    // ...
} catch (SQLException exception) {
    // 处理
} finally {
    // 手写一堆 null 判断 + 嵌套 try-catch 才能关干净 ✗
    if (resultSet != null) { try { resultSet.close(); } catch (SQLException ignored) { } }
    if (statement != null) { try { statement.close(); } catch (SQLException ignored) { } }
    if (connection != null) { try { connection.close(); } catch (SQLException ignored) { } }
}

正确写法:

java
try (Connection connection = open();
     PreparedStatement statement = connection.prepareStatement(sql)) {

    statement.setInt(1, min);
    try (ResultSet resultSet = statement.executeQuery()) {
        while (resultSet.next()) { /* ... */ }
    }
}

try-with-resources 做对了两件事:

事价值
自动关闭(按声明的相反顺序)不会漏关
关闭时的异常不会吞掉业务异常手写 finally 时容易把原始异常覆盖掉 ✗

注意关闭顺序:ResultSet → Statement → Connection(后声明的先关)✓ 写成嵌套的 try 就自然满足了 ✓

5.5 连接池:为什么不能每次 new 一个 Connection ​

建一个数据库连接的成本很高(TCP 握手 + 认证 + 会话初始化)——几十毫秒量级。

如果每次请求都新建连接:

问题后果
延迟每次都要付建连成本
数据库压力连接数是数据库的稀缺资源(max_connections)
高并发下崩溃突发流量可能打满数据库的连接上限 ✗

连接池的做法:预先建一批连接,用完还回去:

text
应用启动 → 池子里建 10 个连接
请求进来 → 从池里借一个 → 用完归还(不关闭)
请求高峰 → 池子自动扩容(到上限为止)

主流的连接池:

池说明
HikariCPSpring Boot 默认(性能与稳定性最好)
Druid阿里出品,带监控面板
Tomcat JDBC Pool老项目常见

Spring Boot 自动做的事(《Spring Boot 自动配置与项目结构》):你只引了 spring-boot-starter-jdbc,它就把 HikariCP 配好了 ✓

所以"生产里为什么看不到 DriverManager.getConnection":

因为连接池接管了它——业务代码从池里拿连接,不是自己建。

本节的示例为什么可以直接 DriverManager:演示用,单线程、短生命周期 ✓ 真实项目里这样写会出事 ✗

5.6 批处理 ​

逐条执行:

java
for (Product product : products) {
    statement.setString(1, product.name());
    statement.executeUpdate();          // 每条一次往返
}

批处理:

java
for (Product product : products) {
    statement.setString(1, product.name());
    statement.addBatch();               // 先攒着
    if (index % 500 == 0) {
        statement.executeBatch();       // 攒够一批再发
    }
}
statement.executeBatch();

实测结果(插入 2000 行):

text
插入 2000 行 —— 逐条 executeUpdate:148 ms
插入 2000 行 —— addBatch/executeBatch:66 ms
总行数:4003

2.2 倍的差距 ✓

⚠️ 但这个差距在 H2 内存库上被严重低估了:

环境为什么差距更大
H2 内存库没有网络往返,"执行"本身就是纳秒级
真实 MySQL(跨网络)每条 SQL 都要一次网络往返(RTT),批处理把 N 次往返变成 1 次

在真实数据库上,批处理的差距通常是几十倍——这也是"为什么批量数据导入一定要用批处理"的原因 ✓

批处理的三个实践点:

点说明
分批提交(如每 500 条)一次攒几万条会占大量内存,且失败时回滚代价大
配合事务一批失败要能整体回滚
注意数据库的参数上限有些数据库对单条 SQL 的占位符个数有上限(如 65535)

5.7 JDBC 的"手工感":MyBatis 为什么存在 ​

回头看本节代码,一件事做了很多遍:

java
ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
    Product product = new Product();
    product.setId(resultSet.getLong("id"));
    product.setName(resultSet.getString("name"));
    product.setPriceInCents(resultSet.getInt("price_in_cents"));   // ← 列名到属性名
    product.setStock(resultSet.getInt("stock"));
    list.add(product);
}

这段代码的四个问题:

问题说明
重复每个查询都要手写一遍列到属性的映射
脆弱改一个列名要改所有用到它的地方;写错了编译期发现不了(字符串字面量)
啰嗦分页、动态条件、批量——每处都要自己拼
易错忘记关闭资源、事务边界写错、误用拼串

《MyBatis》 就是来解决这些——但它底层仍然是本节的东西:

MyBatis 的抽象对应的 JDBC
Mapper 接口方法PreparedStatement 的调用
XML 里的 #{}就是 setXxx 参数绑定 ✓
SqlSessionConnection 的包装
@Transactional / 事务管理器setAutoCommit(false) + commit / rollback

框架是"把重复劳动收拢",不是"变魔术"。 看懂 JDBC,MyBatis 的报错信息你才读得懂。

6. 递进示例 ​

6.1 最小示例:连上、查一行 ​

java
try (Connection connection = DriverManager.getConnection(URL, USER, PASSWORD);
     Statement statement = connection.createStatement();
     ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) FROM product")) {

    resultSet.next();
    System.out.println("总数:" + resultSet.getLong(1));
}

6.2 业务示例:参数化查询 + 事务 ​

见第 7 节。

6.3 反例:把用户输入拼进 SQL ​

java
// ✗ 只要有一处这样写,整个系统的安全边界就没了
String sql = "SELECT * FROM product WHERE name = '" + name + "'";

// ✗ LIKE 也有同样的问题
String sql = "SELECT * FROM product WHERE name LIKE '%" + keyword + "%'";

// ✗ 排序字段拼串(这个连参数化都救不了,只能白名单)
String sql = "SELECT * FROM product ORDER BY " + orderBy;

第三条要特别注意:排序字段、表名、列名无法参数化(因为 ? 只能代表"值")✗

处理方式:白名单校验(《MyBatis 数据持久化》 会演示):

java
private static final Set<String> ALLOWED_ORDER_BY = Set.of("id", "price_in_cents", "stock");
if (!ALLOWED_ORDER_BY.contains(orderBy)) {
    throw new IllegalArgumentException("不允许的排序字段:" + orderBy);
}

7. 完整可运行代码 ​

文件:labs/data-access/src/main/java/cn/kb/learn/data/JdbcLesson.java

关于本节的数据库:示例跑在 H2 的 MySQL 兼容模式内存库上(连接串带 MODE=MySQL)。

为什么不用真实 MySQL:JDBC 是数据库无关的标准接口,用它学 JDBC 完全够;而且你不需要装数据库就能复现。

换到真实 MySQL 只需改两处:

  1. 连接串 → jdbc:mysql://localhost:3306/kb_learn
  2. pom 里的驱动 → com.mysql:mysql-connector-j

但要记住 H2 与真实 MySQL 的差异(T1-017 讲过):方言、约束行为、执行计划都可能不同——"H2 上过了"不等于"MySQL 上没问题" ✗

核心代码:

java
public class JdbcLesson {
    private static final String URL =
            "jdbc:h2:mem:kbjdbc;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1";
    private static final String USER = "sa";
    private static final String PASSWORD = "";

    public static void main(String[] args) throws SQLException {
        initSchema();
        insertProducts();
        queryAllProducts();
        findByPriceRange(10_000, 20_000);
        sqlInjectionDemo();          // 本节重点
        transferDemo();              // 事务
        batchInsertDemo();           // 批处理
    }
}

运行命令:

powershell
cd labs
./mvnw -pl data-access compile dependency:build-classpath "-Dmdep.outputFile=target/cp.txt"
java "-Dstdout.encoding=UTF-8" -cp "data-access/target/classes;$(Get-Content data-access/target/cp.txt)" cn.kb.learn.data.JdbcLesson

预期输出(真实运行结果):

text
建表完成:product、account(内存库,进程结束即消失)

===== 1. 插入与查询:Statement + ResultSet =====
插入 3 件商品
id   name         price(分)       stock
1    机械键盘         19900          12
2    人体工学椅        89900          5
3    降噪耳机         15900          30

===== 2. 参数化查询:PreparedStatement =====
价格在 10000 ~ 20000 分之间的商品:
  降噪耳机 —— 15900 分
  机械键盘 —— 19900 分

===== 3. SQL 注入:拼串 vs 参数化(本节重点)=====
用户输入(模拟攻击者):' OR '1'='1

[危险] 拼串后的 SQL:
       SELECT name FROM product WHERE name = '' OR '1'='1'
       返回 3 行:[机械键盘, 人体工学椅, 降噪耳机]
       ← 攻击者拿到了全表数据

[安全] 参数化后执行:SELECT name FROM product WHERE name = ?
       返回 0 行:[]
       ← 整串被当成一个普通字符串去比较,注入失效

===== 4. 事务:转账的提交与回滚 =====
转账前:张三=100000 分,李四=50000 分
--- 场景 A:正常转账 300 元(张三 → 李四)---
提交后:张三=70000 分,李四=80000 分
--- 场景 B:扣款后抛异常(模拟业务校验失败),应整体回滚 ---
捕获异常:模拟业务校验失败(例如风控拦截)
回滚后:张三=70000 分,李四=80000 分

===== 5. 批量插入:逐条 vs addBatch =====
插入 2000 行 —— 逐条 executeUpdate:148 ms
插入 2000 行 —— addBatch/executeBatch:66 ms
总行数:4003

三处需要盯住的输出:

输出说明了什么
危险:返回 3 行 vs 安全:返回 0 行同一个输入,两种写法的结果完全不同——这就是参数化的价值
场景 B 回滚后余额不变没 commit 的修改等于没发生
148 ms → 66 ms批处理的效果(在真实数据库上差距会大得多)

8. Debug 观察点 ​

  1. 在 sqlInjectionDemo 的 statement.executeQuery(badSql) 处断点:求值 badSql——你会亲眼看到 SQL 被拼成了什么样子;
  2. 在 connection.commit() 处断点:提交前在数据库里查一下余额(另开一个连接)——看不到未提交的修改(隔离性);
  3. 把 connection.rollback() 注释掉,重新运行——观察场景 B 的余额被扣掉了(说明"事务保护"不是自动的);
  4. 在 setAutoCommit(false) 处断点,观察 connection.getAutoCommit() 的值变化;
  5. 故意用 executeQuery 执行 UPDATE:观察抛出的异常,理解三个 execute* 方法的差异。

9. 常见错误 ​

错误 1:用字符串拼接构造 SQL ​

这是本节最重要的错误,见 5.2 节。

自查方法:搜代码里有没有 "SELECT ... '" + 这种模式 ✓

修复:一律用 ?;排序字段、表名用白名单。

错误 2:忘记关闭资源 ​

症状:跑一段时间后报 too many connections 或连接池耗尽 ✗

原因:连接没还回去。

修复:一律用 try-with-resources ✓

错误 3:executeQuery 与 executeUpdate 用错 ​

java
statement.executeQuery("UPDATE ...");       // ✗
statement.executeUpdate("SELECT ...");      // ✗

修复:SELECT 用 executeQuery,其余用 executeUpdate ✓

错误 4:事务没生效 ​

java
// ✗ 两个操作用了两个连接 → 两个独立事务
updateBalance(open(), fromId, -amount);
updateBalance(open(), toId, +amount);

症状:扣款成功、入账失败,钱少了 ✗

修复:两个操作必须共用同一个 Connection ✓(本节的 transfer 方法就是这么写的)

这也是 Spring 里"自调用导致事务失效"的底层原因(《Spring:AOP 与事务》 的经典坑)✓

错误 5:在循环里建连接 ​

java
for (Product product : products) {
    try (Connection connection = open()) { ... }     // ✗ 每次建连
}

修复:连接建一次,循环里复用;或者用连接池 ✓

错误 6:以为 IN (?) 能传多个值 ​

java
// ✗ 只会当成一个整体字符串,匹配不到
statement.setString(1, "1,2,3");

修复:按集合大小拼对应个数的 ? ✓(见 5.2)

10. 动手实验 ​

目标:把注入、事务、批处理三件事亲手验一遍。

步骤:

  1. 运行第 7 节的命令,核对三处关键输出;
  2. 改一个注入载荷:把 userInput 换成 "x' OR '1'='1' --"(注意末尾的注释符),重新运行——解释为什么结果一样;
  3. 验证 PreparedStatement 的"结构固定":把参数换成 "机械键盘"(正常值),观察能查到 1 行——说明它不是"永远查不到",而是"只按值比较";
  4. 注释掉 rollback:观察场景 B 的余额变化,记录回滚前后的差异;
  5. 改批量插入的批大小:把 500 改成 100、2000,各跑一次——观察耗时的变化趋势;
  6. 加一个 IN 查询:用 5.2 节的写法,查 id IN (1,3) 的商品,验证拼的是问号个数而不是值。

验收方式:能说出第 2 步为什么结果一样、第 4 步余额差多少。

扩展任务:把 transfer 方法改成"先查余额,不足则抛异常"——注意检查必须在同一个事务里(否则会出现"检查通过但扣款时余额已被别人改掉"的竞态,这正是 《事务与隔离级别》 讲的并发问题)。

11. 练习题 ​

基础题

  1. 判断:PreparedStatement 之所以能防注入,是因为它把参数里的引号转义了。(对 / 错)
  2. 填空:SELECT 用 ______ 方法执行,INSERT/UPDATE/DELETE 用 ______;事务的提交与回滚是 ______ 和 ______。
  3. 简答:为什么 ORDER BY 后面的字段不能用 ? 参数化?

代码阅读题

java
public void updatePrice(List<Long> ids, int newPrice) throws SQLException {
    StringBuilder sql = new StringBuilder("UPDATE product SET price_in_cents = ? WHERE id IN (");
    for (int i = 0; i < ids.size(); i++) {
        sql.append(i == 0 ? "?" : ", ?");
    }
    sql.append(")");

    try (Connection connection = open();
         PreparedStatement statement = connection.prepareStatement(sql.toString())) {
        statement.setInt(1, newPrice);
        for (int i = 0; i < ids.size(); i++) {
            statement.setLong(i + 2, ids.get(i));
        }
        statement.executeUpdate();
    }
}
  1. 这段代码安全吗?为什么?(注意它也用了拼串)
  2. 如果 ids 为空列表,会发生什么?如何修复?

编程题

  1. 实现一个"库存扣减"方法,要求满足:
    • 输入:商品 id、扣减数量;
    • 校验库存充足(不足则抛出自定义的 InsufficientStockException);
    • 保证并发下不会超卖(提示:UPDATE ... SET stock = stock - ? WHERE id = ? AND stock >= ?,看受影响行数);
    • 记录扣减前后的库存到一个 stock_log 表;
    • 两件事必须在同一个事务里;
    • 用 executeUpdate 的返回值判断是否成功;
    • 写完后回答:为什么不能"先 SELECT 查库存、再 UPDATE 扣减"?(提示:《事务与隔离级别》 的并发问题)

综合任务

  1. 写一个"简易的数据访问工具类",体会 JDBC 的痛点与抽象的价值:
    • 需求:实现三个方法——
      • List<Map<String, Object>> query(String sql, Object... params)
      • int update(String sql, Object... params)
      • <T> List<T> queryForList(String sql, Function<ResultSet, T> rowMapper, Object... params)
    • 要求:
      • 用 PreparedStatement,参数全部走占位符;
      • 用 try-with-resources 管理资源;
      • query 返回"列名 → 值"的 Map(注意用 ResultSetMetaData 取列名);
      • queryForList 接受一个映射函数,把"一行"翻译成一个对象(这就是 RowMapper 的雏形);
    • 用商品表跑通三个方法;
    • 最后回答:
      • 你写的这个工具类,和 MyBatis 的 RowMapper、Spring 的 JdbcTemplate 相比,缺了什么?
      • 列出至少三项"框架替你做的事"(提示:异常转换、资源管理、SQL 与代码的分离、动态 SQL、缓存……)。
分级提示

提示 1(方向):第 1 题的关键是"参数是作为 SQL 语法解析的,还是作为数据传入的"。

提示 2(关键点):第 5 题里如果 ids 为空,拼出来的 SQL 是 ... IN ()——这是语法错误,要提前返回或特殊处理。

提示 3(接近答案):第 6 题的"先查后改"问题是典型的"检查-使用"竞态(TOCTOU):两次操作之间,别人可能已经改了数据。

12. 参考答案 ​

基础题
  1. 错。转义引号是"治标",参数化是"治本"。
方式做法为什么不够/够
转义引号把 ' 变成 ''依赖"转义规则写对了"——规则很复杂(编码、宽字符、数据库方言差异),总有漏网之鱼 ✗
参数化先把 SQL 结构固定,参数只作为值传入参数根本不参与语法解析,无论内容是什么都改不了结构 ✓

更根本的一点:PreparedStatement 的 ? 背后就是 JDBC 驱动调用的"参数绑定"接口——不是字符串处理 ✓ 数据库收到的是"SQL 骨架 + 参数列表"两份东西。

顺带:PreparedStatement 还有性能收益——同一条 SQL 多次执行时,数据库可以复用已编译的执行计划 ✓(这也是它名字里 "Prepared" 的由来)

  1. executeQuery;executeUpdate;commit();rollback()。

补充:execute() 用于"不确定类型"的场景(如 DDL),返回 boolean 表示"有没有结果集" ✓

  1. 因为 ? 只能代表"值",不能代表"标识符"。

原因:SQL 的语法结构必须在使用前确定——表名、列名、排序字段决定了"怎么查",而"值"只决定"查什么" ✓

sql
SELECT * FROM ?              -- ✗ 语法错误:? 不能是表名
SELECT * FROM product ORDER BY ?   -- ✗ 被当成"按一个常量排序",等于没排

所以:这类"结构可变"的需求只能靠拼串,而拼串必须配白名单 ✓

java
private static final Set<String> ALLOWED = Set.of("id", "price_in_cents");
if (!ALLOWED.contains(orderBy)) throw new IllegalArgumentException(...);
代码阅读题(第 4、5 题)

第 4 题:安全。

关键区分:它拼的是"问号的个数",不是"参数值" ✓

java
sql.append(i == 0 ? "?" : ", ?");        // ← 拼进去的是固定字符串 "?",不是用户数据
拼串的内容安全吗为什么
问号的个数(由 ids.size() 决定)✅ 安全内容是 ? 这种固定字符,不含用户输入
参数值(用户传进来的字符串)❌ 危险会被当成 SQL 语法解析
列名/排序字段(用户传进来的)❌ 危险同上(除非过白名单)

一句话:"拼串"本身不是罪,拼"不可信内容"才是 ✓

这也是 MyBatis 里 foreach 的实现方式——《MyBatis 数据持久化》 的 XML 里:

xml
<foreach collection="ids" item="item" open="(" separator="," close=")">
    #{item}
</foreach>

它最终生成的就是 ( ? , ? , ? ) ✓ 与我们手写的完全一样。

第 5 题:会抛语法错误。

拼出来的 SQL:

sql
UPDATE product SET price_in_cents = ? WHERE id IN ()     -- ✗ 语法错误

修复:

java
if (ids == null || ids.isEmpty()) {
    return;                      // 没有 id 可改,直接返回
}

这引出一个通用原则:

动态拼 SQL 时,必须处理"集合为空"的边界情况。

三种处理方式:

方式说明
提前返回最简单(本次的场景适用)
补一个恒假条件WHERE 1 = 0(保证语法正确且不误伤数据)
用 MyBatis 的 <foreach>框架已经处理了这个边界——它会在集合为空时生成 WHERE id IN () 吗?

关于第三种,需要纠正一个常见误解:MyBatis 的 <foreach> 遇到空集合时,仍然会拼出 IN () 这种非法语法 ✗ 所以还是要在 XML 里用 <if> 包一层:

xml
<if test="ids != null and ids.size() > 0">
    AND id IN
    <foreach collection="ids" item="item" open="(" separator="," close=")">
        #{item}
    </foreach>
</if>

这正好呼应了 5.7 节的结论:框架把重复劳动收拢了,但边界情况仍要你自己想清楚 ✓

编程题(第 6 题)
java
package cn.kb.learn.data;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;

public class StockService {
    private static final String URL =
            "jdbc:h2:mem:kbjdbc;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1";

    /** 库存不足:业务异常,调用方据此返回 409 */
    public static class InsufficientStockException extends RuntimeException {
        public InsufficientStockException(String message) {
            super(message);
        }
    }

    /**
     * 扣减库存,并把变更写入日志表。
     *
     * <p>关键设计:把"校验"和"扣减"合并成一条 UPDATE,
     * 用 WHERE stock >= ? 让数据库在一条语句里完成"判断 + 修改"。</p>
     */
    public void deduct(long productId, int quantity, String reason) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("扣减数量必须大于 0");
        }
        try (Connection connection = DriverManager.getConnection(URL, "sa", "")) {
            connection.setAutoCommit(false);                 // 事务开始:扣减与写日志要么都成功,要么都不做
            try {
                int before = currentStock(connection, productId);

                // 一条 SQL 同时完成"判断"与"扣减":受影响 0 行就说明库存不够(或商品不存在)
                int affected;
                try (PreparedStatement statement = connection.prepareStatement(
                        "UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?")) {
                    statement.setInt(1, quantity);
                    statement.setLong(2, productId);
                    statement.setInt(3, quantity);
                    affected = statement.executeUpdate();
                }

                if (affected == 0) {
                    throw new InsufficientStockException(
                            "库存不足:商品 " + productId + " 需要 " + quantity + " 件,当前 " + before + " 件");
                }

                try (PreparedStatement statement = connection.prepareStatement(
                        "INSERT INTO stock_log (product_id, change_qty, stock_after, reason) VALUES (?, ?, ?, ?)")) {
                    statement.setLong(1, productId);
                    statement.setInt(2, -quantity);
                    statement.setInt(3, before - quantity);
                    statement.setString(4, reason);
                    statement.executeUpdate();
                }

                connection.commit();
            } catch (RuntimeException | SQLException exception) {
                connection.rollback();                       // 任一步失败:整体回滚
                throw exception instanceof RuntimeException runtime
                        ? runtime
                        : new IllegalStateException("扣减库存失败", exception);
            } finally {
                connection.setAutoCommit(true);
            }
        } catch (SQLException exception) {
            throw new IllegalStateException("数据库访问失败", exception);
        }
    }

    private int currentStock(Connection connection, long productId) throws SQLException {
        try (PreparedStatement statement =
                     connection.prepareStatement("SELECT stock FROM product WHERE id = ?")) {
            statement.setLong(1, productId);
            try (var resultSet = statement.executeQuery()) {
                if (!resultSet.next()) {
                    throw new IllegalArgumentException("商品不存在:" + productId);
                }
                return resultSet.getInt(1);
            }
        }
    }
}

建表语句:

sql
CREATE TABLE stock_log (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  product_id BIGINT NOT NULL,
  change_qty INT NOT NULL,
  stock_after INT NOT NULL,
  reason VARCHAR(64),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

为什么不能"先 SELECT 查库存、再 UPDATE 扣减":

text
时刻 ①  A 查库存 → 读到 10
时刻 ②  B 查库存 → 也读到 10
时刻 ③  A 扣 8 → 库存变成 2  ✓
时刻 ④  B 扣 8 → 库存变成 -6  ✗ 超卖了!

这是典型的"检查-使用"竞态(TOCTOU:Time-of-check to time-of-use) ✗

方案做法是否安全
先查后改SELECT 判断 → UPDATE❌ 两个操作之间有窗口
合并成一条 UPDATEWHERE stock >= ? + 看受影响行数✅ 判断与修改是原子的
悲观锁SELECT ... FOR UPDATE 锁住行✅(但会阻塞,《数据库范式与表关联设计》 讲过)
乐观锁版本号字段 + WHERE version = ?✅(冲突时重试)

本项目用的第二种——它最简洁,且把"判断"交给了数据库:

sql
UPDATE product SET stock = stock - 8 WHERE id = 1 AND stock >= 8

affected == 0 就是"库存不足"的信号 ✓ 不需要提前查、也不会有竞态 ✓

一个关键认知:

"检查 + 修改"要么放进同一个原子操作(一条 SQL、锁、事务 + 约束),要么接受它不可靠。

应用层的 if 判断无论写得多仔细,都挡不住并发 ✓ —— 这与 《参数校验与全局异常处理》 里"业务校验不能替代数据库约束"是同一个道理。

综合任务(第 7 题)
java
package cn.kb.learn.data;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

/**
 * 简易数据访问工具——用来体会"框架替你做了什么"。
 */
public final class SimpleJdbcHelper {
    private static final String URL =
            "jdbc:h2:mem:kbjdbc;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1";

    /** 查询:列名 → 值 */
    public static List<Map<String, Object>> query(String sql, Object... params) {
        return queryForList(sql, resultSet -> {
            try {
                ResultSetMetaData meta = resultSet.getMetaData();
                Map<String, Object> row = new LinkedHashMap<>();
                for (int index = 1; index <= meta.getColumnCount(); index++) {
                    row.put(meta.getColumnLabel(index), resultSet.getObject(index));
                }
                return row;
            } catch (SQLException exception) {
                throw new IllegalStateException("读取列失败", exception);
            }
        }, params);
    }

    /** 更新:返回受影响行数 */
    public static int update(String sql, Object... params) {
        try (Connection connection = DriverManager.getConnection(URL, "sa", "");
             PreparedStatement statement = connection.prepareStatement(sql)) {
            bind(statement, params);
            return statement.executeUpdate();
        } catch (SQLException exception) {
            throw new IllegalStateException("执行失败:" + sql, exception);
        }
    }

    /** 泛型查询:把"一行"翻译成 T —— 这就是 RowMapper 的雏形 */
    public static <T> List<T> queryForList(String sql, Function<ResultSet, T> rowMapper, Object... params) {
        try (Connection connection = DriverManager.getConnection(URL, "sa", "");
             PreparedStatement statement = connection.prepareStatement(sql)) {
            bind(statement, params);
            try (ResultSet resultSet = statement.executeQuery()) {
                List<T> rows = new ArrayList<>();
                while (resultSet.next()) {
                    rows.add(rowMapper.apply(resultSet));
                }
                return rows;
            }
        } catch (SQLException exception) {
            throw new IllegalStateException("查询失败:" + sql, exception);
        }
    }

    private static void bind(PreparedStatement statement, Object... params) throws SQLException {
        for (int index = 0; index < params.length; index++) {
            statement.setObject(index + 1, params[index]);      // 参数一律走占位符
        }
    }
}

跑通三个方法:

java
// ① query
SimpleJdbcHelper.query("SELECT name, price_in_cents FROM product WHERE price_in_cents > ?", 20_000)
        .forEach(System.out::println);

// ② update
int affected = SimpleJdbcHelper.update(
        "UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?", 1, 1L, 1);

// ③ queryForList(映射成对象)
List<Product> products = SimpleJdbcHelper.queryForList(
        "SELECT id, name, price_in_cents, stock FROM product",
        resultSet -> {
            Product product = new Product();
            product.setId(resultSet.getLong("id"));
            product.setName(resultSet.getString("name"));
            product.setPriceInCents(resultSet.getInt("price_in_cents"));
            product.setStock(resultSet.getInt("stock"));
            return product;
        });

缺了什么(与 MyBatis / JdbcTemplate 对比):

#缺的能力说明框架怎么做的
1连接池每次都新建连接(几十毫秒)✗Spring Boot 自动配 HikariCP
2异常转换抛的是 SQLException(检查型),业务代码被迫处理Spring 转成非检查型的 DataAccessException 层级
3SQL 与代码分离SQL 散落在 Java 字符串里,编译期查不出错MyBatis 把 SQL 放进 XML/注解,还能被工具校验
4结果映射每张表都要手写 rowMapper按列名自动映射到属性(含驼峰转换)
5动态 SQL条件一变就要拼串(还容易出错)<if> / <foreach> / <where>
6事务管理每个方法自己管 commit / rollback声明式 @Transactional(《Spring:AOP 与事务》)
7IN 的占位符要自己拼问号个数<foreach> 自动处理
8分页要自己写 LIMIT ? OFFSET ? 与总数查询MyBatis-Plus 的 selectPage(《MyBatis-Plus 通用 CRUD 与条件构造器》)
9缓存 / 批处理 / 类型处理器 / 枚举映射 ……细节极多框架逐个补齐

三项最核心的(如果只记三个):连接池、结果映射、SQL 与代码分离 ✓

末尾的认知:

框架的价值不是"让简单的事更简单",而是"让重复的事只做一次"。

注意第 3 项:把 SQL 从 Java 字符串里搬到 XML,最大的收益不是"好看",而是"可被工具与测试检查" ✓ 这是 《MyBatis 数据持久化》 的起点。

13. 掌握标准 ​

  • [ ] 能说出 JDBC 的四个核心类型与三个 execute* 方法的区别;
  • [ ] 能解释 PreparedStatement 为什么能防注入(参数不参与语法解析);
  • [ ] 会用参数化写 IN 与 LIKE;
  • [ ] 知道"排序字段/表名不能用 ?",会用白名单处理;
  • [ ] 能用 setAutoCommit(false) + commit / rollback 控制事务,并知道两个操作必须共用同一个 Connection;
  • [ ] 一律使用 try-with-resources;
  • [ ] 知道为什么需要连接池,知道 Spring Boot 默认给的是 HikariCP;
  • [ ] 会用 addBatch / executeBatch,并知道它在真实数据库上收益更大;
  • [ ] 能列出"手写 JDBC"的三处痛点。

14. 扩展资源 ​

15. 下一步学习 ​

《MyBatis 数据持久化》:把本节那些重复的样板代码,交给一个框架。

前置知识

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引