主题
界面
主题
《SQL 基础》 到 《锁机制:行锁、表锁与乐观悲观锁》 这几节用 SQL 客户端直接跟数据库说话。本节换一种方式:
之前:你在客户端里手敲 SQL
↓
本节:Java 代码发 SQL、取结果、控事务JDBC(Java Database Connectivity)是 Java 访问数据库的标准接口——它是后面 MyBatis(《MyBatis 数据持久化》)的地基,也是理解"框架到底替你做了什么"的前提。
完成本节后,你应该能够:
Statement 与 PreparedStatement 的区别,并解释后者为什么能防注入;PreparedStatement 写参数化查询(含 IN、LIKE 的写法);setAutoCommit(false) + commit / rollback 控制事务边界;没有 JDBC 之前:每种数据库都有自己的 API。换个数据库,业务代码要跟着重写。
JDBC 的解法:定一套接口,让数据库厂商提供实现(驱动)。
你的代码 → 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,才能读懂它们"替你做了什么"。
// ① 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) | SELECT | ResultSet |
executeUpdate(sql) | INSERT / UPDATE / DELETE | 受影响行数 |
execute(sql) | 任意(DDL 等) | boolean(有没有结果集) |
用错会抛:executeQuery 收到 UPDATE 会报 ResultSet is empty 或直接抛异常 ✗
Statement 与 PreparedStatement(本节重点) Statement 的用法(危险):
String userInput = "' OR '1'='1";
String sql = "SELECT name FROM product WHERE name = '" + userInput + "'"; // 拼串
Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(sql);拼出来的 SQL 是:
SELECT name FROM product WHERE name = '' OR '1'='1'OR '1'='1' 恒为真 → 整个 WHERE 条件失效 → 全表数据被拖出来 ✗
实测结果(本节代码的真实运行输出):
用户输入(模拟攻击者):' OR '1'='1
[危险] 拼串后的 SQL:
SELECT name FROM product WHERE name = '' OR '1'='1'
返回 3 行:[机械键盘, 人体工学椅, 降噪耳机]
← 攻击者拿到了全表数据PreparedStatement 的用法(安全):
String sql = "SELECT name FROM product WHERE name = ?"; // 占位符,不是拼串
PreparedStatement statement = connection.prepareStatement(sql);
statement.setString(1, userInput); // 参数单独传进去
ResultSet resultSet = statement.executeQuery();实测结果:
[安全] 参数化后执行:SELECT name FROM product WHERE name = ?
返回 0 行:[]
← 整串被当成一个普通字符串去比较,注入失效为什么参数化能防注入——关键在于"SQL 结构先被固定下来":
拼串: SQL 文本先拼好 → 再交给数据库解析
↑ 参数被当成 SQL 语法的一部分解析 ✗
参数化: 先把 SQL 骨架发给数据库编译(结构已定)→ 再把参数当"纯数据"传进去
↑ 参数永远只是"值",改不了结构 ✓一句话:? 里的内容不参与语法解析——攻击者就算输入 SQL 关键字,也只是被当成字符串去比较 ✓
两个细节:
| 问题 | 答案 |
|---|---|
LIKE 怎么写 | WHERE name LIKE CONCAT('%', ?, '%')(通配符在 SQL 里,参数还是 ?) |
IN 怎么写 | 占位符个数要动态拼:IN (?, ?, ?),然后逐个 setXxx ✅不能写 IN (?) 然后传 "1,2,3" ✗ |
IN 的正确写法:
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));
}
...
}注意这里"也用了拼串"——但拼的是 ? 的个数,是程序算出来的整数,不含任何用户输入 ✓ 这才是安全的拼串。
默认情况:每条 SQL 执行完就自动提交(autocommit = true)✗
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); // 恢复默认
}实测结果(本节代码):
转账前:张三=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 的样板代码包起来——但它底层就是本节写的这些东西 ✓
反面写法(现在几乎见不到,但要知道为什么被淘汰):
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) { } }
}正确写法:
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 就自然满足了 ✓
建一个数据库连接的成本很高(TCP 握手 + 认证 + 会话初始化)——几十毫秒量级。
如果每次请求都新建连接:
| 问题 | 后果 |
|---|---|
| 延迟 | 每次都要付建连成本 |
| 数据库压力 | 连接数是数据库的稀缺资源(max_connections) |
| 高并发下崩溃 | 突发流量可能打满数据库的连接上限 ✗ |
连接池的做法:预先建一批连接,用完还回去:
应用启动 → 池子里建 10 个连接
请求进来 → 从池里借一个 → 用完归还(不关闭)
请求高峰 → 池子自动扩容(到上限为止)主流的连接池:
| 池 | 说明 |
|---|---|
| HikariCP | Spring Boot 默认(性能与稳定性最好) |
| Druid | 阿里出品,带监控面板 |
| Tomcat JDBC Pool | 老项目常见 |
Spring Boot 自动做的事(《Spring Boot 自动配置与项目结构》):你只引了 spring-boot-starter-jdbc,它就把 HikariCP 配好了 ✓
所以"生产里为什么看不到 DriverManager.getConnection":
因为连接池接管了它——业务代码从池里拿连接,不是自己建。
本节的示例为什么可以直接 DriverManager:演示用,单线程、短生命周期 ✓ 真实项目里这样写会出事 ✗
逐条执行:
for (Product product : products) {
statement.setString(1, product.name());
statement.executeUpdate(); // 每条一次往返
}批处理:
for (Product product : products) {
statement.setString(1, product.name());
statement.addBatch(); // 先攒着
if (index % 500 == 0) {
statement.executeBatch(); // 攒够一批再发
}
}
statement.executeBatch();实测结果(插入 2000 行):
插入 2000 行 —— 逐条 executeUpdate:148 ms
插入 2000 行 —— addBatch/executeBatch:66 ms
总行数:40032.2 倍的差距 ✓
⚠️ 但这个差距在 H2 内存库上被严重低估了:
| 环境 | 为什么差距更大 |
|---|---|
| H2 内存库 | 没有网络往返,"执行"本身就是纳秒级 |
| 真实 MySQL(跨网络) | 每条 SQL 都要一次网络往返(RTT),批处理把 N 次往返变成 1 次 |
在真实数据库上,批处理的差距通常是几十倍——这也是"为什么批量数据导入一定要用批处理"的原因 ✓
批处理的三个实践点:
| 点 | 说明 |
|---|---|
| 分批提交(如每 500 条) | 一次攒几万条会占大量内存,且失败时回滚代价大 |
| 配合事务 | 一批失败要能整体回滚 |
| 注意数据库的参数上限 | 有些数据库对单条 SQL 的占位符个数有上限(如 65535) |
回头看本节代码,一件事做了很多遍:
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 参数绑定 ✓ |
SqlSession | Connection 的包装 |
@Transactional / 事务管理器 | setAutoCommit(false) + commit / rollback |
框架是"把重复劳动收拢",不是"变魔术"。 看懂 JDBC,MyBatis 的报错信息你才读得懂。
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));
}见第 7 节。
// ✗ 只要有一处这样写,整个系统的安全边界就没了
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 数据持久化》 会演示):
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);
}文件:labs/data-access/src/main/java/cn/kb/learn/data/JdbcLesson.java
关于本节的数据库:示例跑在 H2 的 MySQL 兼容模式内存库上(连接串带
MODE=MySQL)。为什么不用真实 MySQL:JDBC 是数据库无关的标准接口,用它学 JDBC 完全够;而且你不需要装数据库就能复现。
换到真实 MySQL 只需改两处:
- 连接串 →
jdbc:mysql://localhost:3306/kb_learn- pom 里的驱动 →
com.mysql:mysql-connector-j但要记住 H2 与真实 MySQL 的差异(T1-017 讲过):方言、约束行为、执行计划都可能不同——"H2 上过了"不等于"MySQL 上没问题" ✗
核心代码:
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(); // 批处理
}
}运行命令:
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预期输出(真实运行结果):
建表完成: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 | 批处理的效果(在真实数据库上差距会大得多) |
sqlInjectionDemo 的 statement.executeQuery(badSql) 处断点:求值 badSql——你会亲眼看到 SQL 被拼成了什么样子;connection.commit() 处断点:提交前在数据库里查一下余额(另开一个连接)——看不到未提交的修改(隔离性);connection.rollback() 注释掉,重新运行——观察场景 B 的余额被扣掉了(说明"事务保护"不是自动的);setAutoCommit(false) 处断点,观察 connection.getAutoCommit() 的值变化;executeQuery 执行 UPDATE:观察抛出的异常,理解三个 execute* 方法的差异。这是本节最重要的错误,见 5.2 节。
自查方法:搜代码里有没有 "SELECT ... '" + 这种模式 ✓
修复:一律用 ?;排序字段、表名用白名单。
症状:跑一段时间后报 too many connections 或连接池耗尽 ✗
原因:连接没还回去。
修复:一律用 try-with-resources ✓
executeQuery 与 executeUpdate 用错 statement.executeQuery("UPDATE ..."); // ✗
statement.executeUpdate("SELECT ..."); // ✗修复:SELECT 用 executeQuery,其余用 executeUpdate ✓
// ✗ 两个操作用了两个连接 → 两个独立事务
updateBalance(open(), fromId, -amount);
updateBalance(open(), toId, +amount);症状:扣款成功、入账失败,钱少了 ✗
修复:两个操作必须共用同一个 Connection ✓(本节的 transfer 方法就是这么写的)
这也是 Spring 里"自调用导致事务失效"的底层原因(《Spring:AOP 与事务》 的经典坑)✓
for (Product product : products) {
try (Connection connection = open()) { ... } // ✗ 每次建连
}修复:连接建一次,循环里复用;或者用连接池 ✓
IN (?) 能传多个值 // ✗ 只会当成一个整体字符串,匹配不到
statement.setString(1, "1,2,3");修复:按集合大小拼对应个数的 ? ✓(见 5.2)
目标:把注入、事务、批处理三件事亲手验一遍。
步骤:
userInput 换成 "x' OR '1'='1' --"(注意末尾的注释符),重新运行——解释为什么结果一样;PreparedStatement 的"结构固定":把参数换成 "机械键盘"(正常值),观察能查到 1 行——说明它不是"永远查不到",而是"只按值比较";IN 查询:用 5.2 节的写法,查 id IN (1,3) 的商品,验证拼的是问号个数而不是值。验收方式:能说出第 2 步为什么结果一样、第 4 步余额差多少。
扩展任务:把 transfer 方法改成"先查余额,不足则抛异常"——注意检查必须在同一个事务里(否则会出现"检查通过但扣款时余额已被别人改掉"的竞态,这正是 《事务与隔离级别》 讲的并发问题)。
基础题
PreparedStatement 之所以能防注入,是因为它把参数里的引号转义了。(对 / 错)SELECT 用 ______ 方法执行,INSERT/UPDATE/DELETE 用 ______;事务的提交与回滚是 ______ 和 ______。ORDER BY 后面的字段不能用 ? 参数化?代码阅读题
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();
}
}ids 为空列表,会发生什么?如何修复?编程题
InsufficientStockException);UPDATE ... SET stock = stock - ? WHERE id = ? AND stock >= ?,看受影响行数);stock_log 表;executeUpdate 的返回值判断是否成功;综合任务
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,参数全部走占位符;query 返回"列名 → 值"的 Map(注意用 ResultSetMetaData 取列名);queryForList 接受一个映射函数,把"一行"翻译成一个对象(这就是 RowMapper 的雏形);RowMapper、Spring 的 JdbcTemplate 相比,缺了什么?提示 1(方向):第 1 题的关键是"参数是作为 SQL 语法解析的,还是作为数据传入的"。
提示 2(关键点):第 5 题里如果 ids 为空,拼出来的 SQL 是 ... IN ()——这是语法错误,要提前返回或特殊处理。
提示 3(接近答案):第 6 题的"先查后改"问题是典型的"检查-使用"竞态(TOCTOU):两次操作之间,别人可能已经改了数据。
| 方式 | 做法 | 为什么不够/够 |
|---|---|---|
| 转义引号 | 把 ' 变成 '' | 依赖"转义规则写对了"——规则很复杂(编码、宽字符、数据库方言差异),总有漏网之鱼 ✗ |
| 参数化 | 先把 SQL 结构固定,参数只作为值传入 | 参数根本不参与语法解析,无论内容是什么都改不了结构 ✓ |
更根本的一点:PreparedStatement 的 ? 背后就是 JDBC 驱动调用的"参数绑定"接口——不是字符串处理 ✓ 数据库收到的是"SQL 骨架 + 参数列表"两份东西。
顺带:PreparedStatement 还有性能收益——同一条 SQL 多次执行时,数据库可以复用已编译的执行计划 ✓(这也是它名字里 "Prepared" 的由来)
executeQuery;executeUpdate;commit();rollback()。补充:execute() 用于"不确定类型"的场景(如 DDL),返回 boolean 表示"有没有结果集" ✓
? 只能代表"值",不能代表"标识符"。原因:SQL 的语法结构必须在使用前确定——表名、列名、排序字段决定了"怎么查",而"值"只决定"查什么" ✓
SELECT * FROM ? -- ✗ 语法错误:? 不能是表名
SELECT * FROM product ORDER BY ? -- ✗ 被当成"按一个常量排序",等于没排所以:这类"结构可变"的需求只能靠拼串,而拼串必须配白名单 ✓
private static final Set<String> ALLOWED = Set.of("id", "price_in_cents");
if (!ALLOWED.contains(orderBy)) throw new IllegalArgumentException(...);第 4 题:安全。
关键区分:它拼的是"问号的个数",不是"参数值" ✓
sql.append(i == 0 ? "?" : ", ?"); // ← 拼进去的是固定字符串 "?",不是用户数据| 拼串的内容 | 安全吗 | 为什么 |
|---|---|---|
问号的个数(由 ids.size() 决定) | ✅ 安全 | 内容是 ? 这种固定字符,不含用户输入 |
| 参数值(用户传进来的字符串) | ❌ 危险 | 会被当成 SQL 语法解析 |
| 列名/排序字段(用户传进来的) | ❌ 危险 | 同上(除非过白名单) |
一句话:"拼串"本身不是罪,拼"不可信内容"才是 ✓
这也是 MyBatis 里 foreach 的实现方式——《MyBatis 数据持久化》 的 XML 里:
<foreach collection="ids" item="item" open="(" separator="," close=")">
#{item}
</foreach>它最终生成的就是 ( ? , ? , ? ) ✓ 与我们手写的完全一样。
第 5 题:会抛语法错误。
拼出来的 SQL:
UPDATE product SET price_in_cents = ? WHERE id IN () -- ✗ 语法错误修复:
if (ids == null || ids.isEmpty()) {
return; // 没有 id 可改,直接返回
}这引出一个通用原则:
动态拼 SQL 时,必须处理"集合为空"的边界情况。
三种处理方式:
| 方式 | 说明 |
|---|---|
| 提前返回 | 最简单(本次的场景适用) |
| 补一个恒假条件 | WHERE 1 = 0(保证语法正确且不误伤数据) |
用 MyBatis 的 <foreach> | 框架已经处理了这个边界——它会在集合为空时生成 WHERE id IN () 吗? |
关于第三种,需要纠正一个常见误解:MyBatis 的 <foreach> 遇到空集合时,仍然会拼出 IN () 这种非法语法 ✗ 所以还是要在 XML 里用 <if> 包一层:
<if test="ids != null and ids.size() > 0">
AND id IN
<foreach collection="ids" item="item" open="(" separator="," close=")">
#{item}
</foreach>
</if>这正好呼应了 5.7 节的结论:框架把重复劳动收拢了,但边界情况仍要你自己想清楚 ✓
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);
}
}
}
}建表语句:
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 扣减":
时刻 ① A 查库存 → 读到 10
时刻 ② B 查库存 → 也读到 10
时刻 ③ A 扣 8 → 库存变成 2 ✓
时刻 ④ B 扣 8 → 库存变成 -6 ✗ 超卖了!这是典型的"检查-使用"竞态(TOCTOU:Time-of-check to time-of-use) ✗
| 方案 | 做法 | 是否安全 |
|---|---|---|
| 先查后改 | SELECT 判断 → UPDATE | ❌ 两个操作之间有窗口 |
| 合并成一条 UPDATE | WHERE stock >= ? + 看受影响行数 | ✅ 判断与修改是原子的 |
| 悲观锁 | SELECT ... FOR UPDATE 锁住行 | ✅(但会阻塞,《数据库范式与表关联设计》 讲过) |
| 乐观锁 | 版本号字段 + WHERE version = ? | ✅(冲突时重试) |
本项目用的第二种——它最简洁,且把"判断"交给了数据库:
UPDATE product SET stock = stock - 8 WHERE id = 1 AND stock >= 8affected == 0 就是"库存不足"的信号 ✓ 不需要提前查、也不会有竞态 ✓
一个关键认知:
"检查 + 修改"要么放进同一个原子操作(一条 SQL、锁、事务 + 约束),要么接受它不可靠。
应用层的
if判断无论写得多仔细,都挡不住并发 ✓ —— 这与 《参数校验与全局异常处理》 里"业务校验不能替代数据库约束"是同一个道理。
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]); // 参数一律走占位符
}
}
}跑通三个方法:
// ① 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 层级 |
| 3 | SQL 与代码分离 | SQL 散落在 Java 字符串里,编译期查不出错 | MyBatis 把 SQL 放进 XML/注解,还能被工具校验 |
| 4 | 结果映射 | 每张表都要手写 rowMapper | 按列名自动映射到属性(含驼峰转换) |
| 5 | 动态 SQL | 条件一变就要拼串(还容易出错) | <if> / <foreach> / <where> |
| 6 | 事务管理 | 每个方法自己管 commit / rollback | 声明式 @Transactional(《Spring:AOP 与事务》) |
| 7 | IN 的占位符 | 要自己拼问号个数 | <foreach> 自动处理 |
| 8 | 分页 | 要自己写 LIMIT ? OFFSET ? 与总数查询 | MyBatis-Plus 的 selectPage(《MyBatis-Plus 通用 CRUD 与条件构造器》) |
| 9 | 缓存 / 批处理 / 类型处理器 / 枚举映射 …… | 细节极多 | 框架逐个补齐 |
三项最核心的(如果只记三个):连接池、结果映射、SQL 与代码分离 ✓
末尾的认知:
框架的价值不是"让简单的事更简单",而是"让重复的事只做一次"。
注意第 3 项:把 SQL 从 Java 字符串里搬到 XML,最大的收益不是"好看",而是"可被工具与测试检查" ✓ 这是 《MyBatis 数据持久化》 的起点。
execute* 方法的区别;PreparedStatement 为什么能防注入(参数不参与语法解析);IN 与 LIKE;?",会用白名单处理;setAutoCommit(false) + commit / rollback 控制事务,并知道两个操作必须共用同一个 Connection;addBatch / executeBatch,并知道它在真实数据库上收益更大;《MyBatis 数据持久化》:把本节那些重复的样板代码,交给一个框架。
暂无