在前面的文章中,我们一直在 MySQL 命令行或图形化工具中直接编写 SQL。然而,实际的应用系统中,数据库总是躲在服务端程序的背后------用户点击按钮,后端代码去执行 SQL,再把结果返回给前端。对于 Java 开发者来说,连接和操作数据库的标准方式就是 JDBC(Java Database Connectivity)。
本文将系统讲解 JDBC 编程的核心知识,内容包括:
- JDBC 的定位与原理
- 从加载驱动到获取连接的完整步骤
Statement与PreparedStatement的使用与对比(重点:防 SQL 注入)- 执行 DML(增删改)与 DQL(查询)操作
- 处理
ResultSet结果集 - 在 JDBC 中控制事务
- 实战:用 Java 实现一个带事务的"图书借阅"功能
读完本文后,你将能够独立编写 Java 代码来操作 MySQL 数据库,并知道如何写出安全、高效的数据访问层。
1. 什么是 JDBC?
JDBC 是 Java 定义的一套接口 (java.sql 和 javax.sql 包),它规定了 Java 程序应该如何与数据库通信。数据库厂商(如 Oracle、MySQL)则提供这些接口的实现类,也就是所谓的"数据库驱动"。
这种设计的最大好处是:我们的 Java 代码只需要面向 JDBC 接口编程,切换数据库时只需更换驱动 jar 包和连接 URL,业务代码几乎无需改动。
2. 环境准备:添加 MySQL 驱动
我们使用 Maven 来管理依赖。在 pom.xml 中加入:
xml
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.0.33</version>
</dependency>
如果不用 Maven,也可以从 MySQL Connector/J 下载页 手动下载 jar 并加入 classpath。
注意 :驱动类名从 8.0 起由 com.mysql.jdbc.Driver 改为 com.mysql.cj.jdbc.Driver,不过由于 SPI 自动注册机制,我们通常不需要手动执行 Class.forName() 了。
3. JDBC 编程六步走
无论多复杂的数据库操作,JDBC 编程的核心流程都可以归纳为以下六个步骤:
- 加载驱动(可选,现代 JDBC 自动完成)
- 获取连接 (
DriverManager.getConnection()) - 创建 Statement / PreparedStatement 对象
- 执行 SQL (
executeUpdate()或executeQuery()) - 处理结果集 (
ResultSet) - 释放资源 (
Connection、Statement、ResultSet------ 顺序关闭)
我们先从一个最简单的查询示例开始,再逐步深入。
3.1 获取数据库连接
java
String url = "jdbc:mysql://localhost:3306/library_db?useSSL=false&serverTimezone=UTC&characterEncoding=utf8";
String user = "root";
String password = "your_password";
Connection conn = DriverManager.getConnection(url, user, password);
连接 URL 参数说明:
useSSL=false:开发环境可关闭 SSL,生产环境务必配置证书。serverTimezone=UTC:指定时区,防止时间字段偏移。MySQL 8.0 驱动要求明确设置。characterEncoding=utf8:告诉驱动使用 UTF-8 通信。
3.2 一个完整的查询示例
java
String sql = "SELECT id, name, email FROM readers";
// 推荐使用 try-with-resources 自动释放资源
try (Connection conn = DriverManager.getConnection(url, user, password);
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
while (rs.next()) {
int id = rs.getInt("id");
String name = rs.getString("name");
String email = rs.getString("email");
System.out.printf("%d: %s (%s)%n", id, name, email);
}
} catch (SQLException e) {
e.printStackTrace();
}
try-with-resources语法能确保Connection、Statement、ResultSet在代码块结束后自动关闭,省去繁琐的finally手动关闭。
4. Statement 与 SQL 注入风险
上面的例子使用了 Statement,它把 SQL 字符串原封不动地发送给数据库。如果 SQL 中拼接了用户输入,就会产生致命的安全漏洞------SQL 注入。
4.1 注入演示
假设登录逻辑这样写:
java
String username = request.getParameter("username");
String password = request.getParameter("password");
String sql = "SELECT * FROM users WHERE username='" + username + "' AND password='" + password + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
如果用户输入:
- username =
' OR '1'='1 - password =
' OR '1'='1
拼接后的 SQL 变为:
sql
SELECT * FROM users WHERE username='' OR '1'='1' AND password='' OR '1'='1'
条件 '1'='1' 恒成立,结果返回所有用户,登录被绕过。
4.2 防范之道:PreparedStatement
PreparedStatement 使用占位符 ? 来替代直接拼接字符串,数据库会对 SQL 模板进行预编译,参数只是作为数据填充,永远不会被当作 SQL 代码执行。
改用 PreparedStatement 的登录查询:
java
String sql = "SELECT * FROM users WHERE username=? AND password=?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, username);
ps.setString(2, password);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
// 登录成功
}
}
}
此时即使用户输入 ' OR '1'='1,也会被当作普通的字符串值,不再构成注入威胁。永远不要用 Statement 拼接用户输入,始终使用 PreparedStatement。
额外优点:
- 预编译:同一模板 SQL 多次执行时性能更好(MySQL 8.0 默认开启服务端预编译)。
- 代码可读性高:不再需要手动拼单引号和转义。
5. 执行增删改操作(DML)
PreparedStatement 的 executeUpdate() 方法用于执行 INSERT、UPDATE、DELETE 语句,返回受影响的行数。
5.1 插入数据
java
String insertSql = "INSERT INTO readers (name, email, phone) VALUES (?, ?, ?)";
try (Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement ps = conn.prepareStatement(insertSql)) {
ps.setString(1, "新读者");
ps.setString(2, "new@example.com");
ps.setString(3, "13800000000");
int rows = ps.executeUpdate();
System.out.println("插入了 " + rows + " 行");
}
5.2 更新与删除
java
// 更新
String updateSql = "UPDATE readers SET email=? WHERE id=?";
try (PreparedStatement ps = conn.prepareStatement(updateSql)) {
ps.setString(1, "updated@example.com");
ps.setInt(2, 1);
int rows = ps.executeUpdate();
System.out.println("更新了 " + rows + " 行");
}
// 删除
String deleteSql = "DELETE FROM readers WHERE id=?";
try (PreparedStatement ps = conn.prepareStatement(deleteSql)) {
ps.setInt(1, 10);
int rows = ps.executeUpdate();
System.out.println("删除了 " + rows + " 行");
}
6. 查询与处理 ResultSet
executeQuery() 返回 ResultSet 对象,它代表一个二维表,内部有一个游标 初始指向第一行之前。调用 next() 方法可逐行向后移动,并读取各列数据。
常用取值方法(根据列类型选择):
getInt(columnIndex)或getInt("columnName")getString(...)getDate(...)、getTimestamp(...)getDouble(...)等
示例:查询指定读者的借阅记录并打印
java
String querySql = "SELECT b.title, br.borrow_date, br.due_date, br.return_date " +
"FROM borrow_records br " +
"JOIN books b ON br.book_id = b.id " +
"WHERE br.reader_id = ?";
try (Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement ps = conn.prepareStatement(querySql)) {
ps.setInt(1, 1); // 读者ID=1
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
String title = rs.getString("title");
Date borrowDate = rs.getDate("borrow_date");
Date dueDate = rs.getDate("due_date");
Date returnDate = rs.getDate("return_date");
System.out.printf("书名:%s,借出:%s,应还:%s,归还:%s%n",
title, borrowDate, dueDate, returnDate != null ? returnDate : "未还");
}
}
}
注意 :getDate() 返回 java.sql.Date,只包含日期部分。如果要获取精确时间,可以用 getTimestamp()。
7. JDBC 中的事务控制
默认情况下,Connection 处于自动提交模式,即每执行一条 SQL 就立即提交。对于需要原子性的多个操作,我们必须关闭自动提交,手动控制事务边界。
关键方法:
conn.setAutoCommit(false);------ 关闭自动提交,开启事务conn.commit();------ 提交事务conn.rollback();------ 回滚事务(通常在 catch 块中)
示例:银行转账(模拟)
java
Connection conn = null;
try {
conn = DriverManager.getConnection(url, user, password);
conn.setAutoCommit(false); // 开启事务
String debitSql = "UPDATE accounts SET balance = balance - ? WHERE id = ?";
String creditSql = "UPDATE accounts SET balance = balance + ? WHERE id = ?";
try (PreparedStatement debitPs = conn.prepareStatement(debitSql);
PreparedStatement creditPs = conn.prepareStatement(creditSql)) {
debitPs.setDouble(1, 100.0);
debitPs.setInt(2, 1);
debitPs.executeUpdate();
creditPs.setDouble(1, 100.0);
creditPs.setInt(2, 2);
creditPs.executeUpdate();
conn.commit(); // 全部成功,提交
System.out.println("转账成功");
} catch (SQLException e) {
conn.rollback(); // 任何一步失败,回滚所有
System.out.println("转账失败,已回滚");
e.printStackTrace();
}
} finally {
if (conn != null) {
conn.setAutoCommit(true); // 恢复默认
conn.close();
}
}
强烈建议:始终在
finally中将autoCommit重置为true,否则归还到连接池后可能引发奇怪的行为。
8. 实战:Java 实现图书借阅功能
现在,让我们把事务和 PreparedStatement 运用到图书管理系统中。借阅一本书需要两个动作:
- 向
borrow_records表插入一条借阅记录。 - 将对应图书的
stock减 1。
这两个操作必须在一个事务中完成,否则可能出现"记录了借阅但库存没减"或"库存减了但没记录"的数据不一致。
8.1 数据库表准备
假设你的 library_db 数据库中已存在以下表(参考第一阶段实战):
books(id, title, author, stock, ...)borrow_records(id, reader_id, book_id, borrow_date, due_date, return_date)
如果还没有,请先使用之前的建表语句创建。
8.2 Java 代码实现
java
import java.sql.*;
import java.time.LocalDate;
public class LibraryService {
private static final String URL = "jdbc:mysql://localhost:3306/library_db?useSSL=false&serverTimezone=UTC&characterEncoding=utf8";
private static final String USER = "root";
private static final String PASSWORD = "your_password";
/**
* 借阅图书
*
* @param readerId 读者ID
* @param bookId 图书ID
* @param borrowDuration 借阅天数(用于计算应还日期)
* @return 是否借阅成功
*/
public boolean borrowBook(int readerId, int bookId, int borrowDuration) {
String checkStockSql = "SELECT stock FROM books WHERE id = ?";
String insertBorrowSql = "INSERT INTO borrow_records (reader_id, book_id, borrow_date, due_date) " +
"VALUES (?, ?, CURRENT_DATE, DATE_ADD(CURRENT_DATE, INTERVAL ? DAY))";
String updateStockSql = "UPDATE books SET stock = stock - 1 WHERE id = ? AND stock > 0";
Connection conn = null;
try {
conn = DriverManager.getConnection(URL, USER, PASSWORD);
conn.setAutoCommit(false); // 开启事务
// 1. 检查库存
int stock;
try (PreparedStatement checkPs = conn.prepareStatement(checkStockSql)) {
checkPs.setInt(1, bookId);
try (ResultSet rs = checkPs.executeQuery()) {
if (!rs.next()) {
throw new RuntimeException("图书不存在");
}
stock = rs.getInt("stock");
}
}
if (stock <= 0) {
throw new RuntimeException("库存不足,无法借阅");
}
// 2. 插入借阅记录
try (PreparedStatement insertPs = conn.prepareStatement(insertBorrowSql)) {
insertPs.setInt(1, readerId);
insertPs.setInt(2, bookId);
insertPs.setInt(3, borrowDuration);
int rows = insertPs.executeUpdate();
if (rows != 1) {
throw new RuntimeException("插入借阅记录失败");
}
}
// 3. 更新库存
try (PreparedStatement updatePs = conn.prepareStatement(updateStockSql)) {
updatePs.setInt(1, bookId);
int rows = updatePs.executeUpdate();
if (rows != 1) {
throw new RuntimeException("更新库存失败,可能库存已为0");
}
}
conn.commit(); // 全部成功,提交事务
System.out.println("借阅成功!读者" + readerId + " 借了图书" + bookId);
return true;
} catch (Exception e) {
if (conn != null) {
try {
conn.rollback(); // 出现异常,回滚
System.out.println("借阅失败,事务已回滚:" + e.getMessage());
} catch (SQLException ex) {
ex.printStackTrace();
}
}
return false;
} finally {
if (conn != null) {
try {
conn.setAutoCommit(true); // 恢复自动提交
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
LibraryService service = new LibraryService();
// 测试:读者ID=1 借阅图书ID=3,借阅期14天
boolean success = service.borrowBook(1, 3, 14);
System.out.println("借阅结果:" + (success ? "成功" : "失败"));
}
}
关键点解读:
- 使用
stock > 0作为更新条件,并检查受影响行数,防止并发超借(初版依然存在并发问题,后续将用悲观锁/乐观锁优化,这里先掌握基本事务)。 - 库存检查和更新放同一事务中,保证原子性。
- 所有资源都用
try-with-resources或finally确保释放。 - 异常时
rollback,成功后commit。
9. 小结
本文从零搭建了 Java 连接 MySQL 的完整知识体系:
- JDBC 六步走:加载驱动 → 获取连接 → 创建 Statement/PreparedStatement → 执行 SQL → 处理结果集 → 释放资源。
- 安全性 :绝不要用
Statement拼接用户输入,使用PreparedStatement能防止 SQL 注入,并提升性能。 - DML 与查询 :
executeUpdate()处理增删改,executeQuery()返回ResultSet。 - 事务控制 :
setAutoCommit(false)开始事务,commit()提交,rollback()回滚。务必在连接归还前恢复自动提交。 - 实战:结合图书管理系统,实现了一个带事务控制的借阅功能,涵盖库存检查、记录插入、库存更新三个步骤。
JDBC 是 Java 数据库开发的基石,无论后续使用的框架是 MyBatis 还是 JPA,底层都是通过 JDBC 与数据库通信。深入理解它,能帮助你写出更高效、更安全的持久层代码。
思考题:
- 上面的借阅方法在并发情况下可能出现什么问题?(提示:两个线程同时借走最后一本书)
PreparedStatement的预编译在 MySQL 8.0 中默认是服务端还是客户端?如何配置?- 如果用
try-with-resources管理Connection,还需要手动rollback吗?为什么?