JDBC以及事务
2026/9/7 21:28:28 网站建设 项目流程

一、引言:从数据库访问说起

在 Java 应用开发中,几乎每一套业务系统都需要与数据库打交道。无论是传统的关系型数据库 MySQL、Oracle、PostgreSQL,还是近年来流行的云原生数据库,Java 程序都需要一种统一、稳定的方式来执行 SQL、处理结果集、管理连接与事务。JDBC(Java Database Connectivity)正是 Java 官方提供的数据库访问标准接口。

JDBC 的全称是 Java Database Connectivity,它由 java.sql、javax.sql 等包中的一组接口和少量抽象类组成。通过 JDBC,开发人员可以用相同的 Java 代码访问不同类型的数据库,只要数据库厂商提供了对应的 JDBC 驱动即可。换句话说,JDBC 是一套「接口规范」,而各家数据库厂商发布的驱动包则是这套规范的具体「实现」。

理解 JDBC 的价值,可以从「解耦」两个维度来看。第一,它屏蔽了底层数据库的差异,让 Java 代码不再与某一种数据库的专有协议深度绑定;第二,它提供了统一的事务控制入口,使得连接管理、事务提交、回滚、隔离级别设置等操作能够以一致的方式完成。对于后续要深入学习的 ORM 框架(如 MyBatis、Hibernate、Spring JDBC),JDBC 都是他们最底层的技术支撑。

本文将围绕 JDBC 的核心 API、连接管理、Statement 家族、结果集处理、批处理以及最为关键的事务机制展开,力求把 JDBC 和事务讲透。读者在阅读时建议结合代码手动实践,尤其是事务隔离级别和传播行为部分,只有亲手执行才能真切感受到不同配置对并发场景的影响。

二、JDBC 架构与核心组成

JDBC 的整体架构可以概括为「应用程序 + JDBC API + 驱动管理器 + 数据库驱动 + 数据库」五层。Java 程序只面向 JDBC API 编程,而不直接依赖具体的驱动实现类,这是典型的面向接口设计。

2.1 架构分层

层级说明典型组件
应用层业务代码调用 JDBC 接口读写数据库Service、DAO
JDBC API 层Java 官方定义的接口与类Connection、Statement、ResultSet
驱动管理层负责注册驱动、建立连接DriverManager、DataSource
驱动实现层厂商提供的协议适配实现mysql-connector-j、ojdbc
数据库层真正存储与管理数据MySQL、Oracle、PostgreSQL

从调用角度看,应用代码拿到一个 java.sql.Connection 对象后,就能创建 Statement、PreparedStatement 执行 SQL,再通过 ResultSet 读取结果。至于底层的网络协议、数据包格式、游标行为,全部由驱动完成封装。这种分层让应用可以在非常小的改动成本下切换数据库。

2.2 核心接口

  • Driver:数据库驱动的顶层接口,每个驱动类都需要实现该接口,并完成驱动注册。
  • DriverManager:驱动管理器,负责遍历已注册驱动并创建数据库连接,是传统 JDBC 编程中最常用的入口。
  • Connection:表示一个数据库会话,负责创建执行对象、管理事务提交与回滚、设置隔离级别等。
  • Statement:基础 SQL 执行对象,适合执行无参数的静态 SQL。
  • PreparedStatement:预编译 SQL 执行对象,支持占位符参数,能防止 SQL 注入、提升执行效率。
  • CallableStatement:用于调用数据库存储过程或函数,支持输入、输出参数。
  • ResultSet:SQL 查询结果集,提供游标式遍历与按列读取的能力。
  • ResultSetMetaData:结果集元数据,可获取列数量、列名、列类型等信息。
  • DatabaseMetaData:数据库元数据,可查询数据库、表、索引等结构信息。
  • SQLException:JDBC 操作过程中抛出的主要受检异常,携带错误码与 SQL 状态。
  • DataSource:javax.sql 包中的连接获取接口,是连接池与 JNDI 场景下的标准入口。

在实际项目中,Connection 和 PreparedStatement 是出现频率最高的两个接口,几乎每一次数据库操作都绕不开它们。理解接口之间的关系,是打好 JDBC 基础的第一步。

三、环境准备与驱动加载

3.1 引入依赖

如果使用 Maven 项目,以 MySQL 为例,只需在 pom.xml 中加入驱动依赖即可。不同数据库对应不同的坐标,但接入方式完全一致。

<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> <scope>runtime</scope> </dependency>

对于 Oracle、PostgreSQL、SQL Server 等数据库,只需替换为对应厂商的驱动坐标即可,业务层代码几乎不用修改,这就是 JDBC 统一接口的价值。

3.2 加载驱动类

在早期的 JDBC 编程中,通常需要通过 Class.forName 显式加载驱动类。这个动作会触发驱动类的静态代码块执行,从而把驱动实例注册到 DriverManager 中。

Class.forName("com.mysql.cj.jdbc.Driver");

从 JDBC 4.0 开始,引入了服务提供者机制(SPI)。只要驱动 jar 包位于 classpath 下,DriverManager 在初始化时会自动扫描 META-INF/services/java.sql.Driver 文件并完成注册。因此在现代 JDBC 版本中,上述显式加载语句可以省略,这也是为什么很多示例代码不再写 Class.forName 的原因。

3.3 连接参数说明

MySQL 8.x 的典型 JDBC URL 格式如下:

jdbc:mysql://localhost:3306/db_name?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
  • useUnicode:是否使用 Unicode 字符集。
  • characterEncoding:连接使用的字符编码,中文环境一般用 UTF-8。
  • useSSL:是否启用 SSL 连接,本地开发常关闭。
  • serverTimezone:时区设置,避免时区不一致导致的时间数据偏差。
  • allowPublicKeyRetrieval:在部分安全认证模式下需要开启。
  • rewriteBatchedStatements:开启后才能真正优化 MySQL 批量插入性能。

连接参数的细节很多,但在生产环境中尤其要关注字符编码、时区与 SSL 三个项,它们最常引发「乱码」「时间差 8 小时」「连接握手失败」等问题。

四、获取数据库连接

4.1 DriverManager 方式

最传统的方式是通过 DriverManager.getConnection 获取连接。它适合小型工具、教学示例和一次性脚本。

import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class JdbcConnectDemo { public static void main(String[] args) { String url = "jdbc:mysql://localhost:3306/jdbc_demo?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai"; String username = "root"; String password = "123456"; try (Connection connection = DriverManager.getConnection(url, username, password)) { System.out.println("连接成功,数据库产品:" + connection.getMetaData().getDatabaseProductName()); } catch (SQLException e) { System.out.println("连接失败,错误码:" + e.getErrorCode() + ",信息:" + e.getMessage()); } } }

注意这里使用了 try-with-resources 语法。Connection 实现了 AutoCloseable 接口,代码块结束时会自动调用 close 释放连接。虽然示例简单,但「用完即关」的思想必须从一开始就建立。

4.2 DataSource 方式

在企业级项目中,几乎不会直接使用 DriverManager,而是通过 DataSource 获取连接。DataSource 由连接池实现提供,它不仅仅是一个连接工厂,还负责连接复用、健康检查、最大连接数控制等能力。

import javax.sql.DataSource; import java.sql.Connection; public class DataSourceDemo { private final DataSource dataSource; public DataSourceDemo(DataSource dataSource) { this.dataSource = dataSource; } public void executeQuery() throws Exception { try (Connection connection = dataSource.getConnection()) { // 执行业务 SQL } } }

使用 DataSource 的好处非常明显:业务代码只需要依赖 javax.sql.DataSource 接口,连接池的具体实现(HikariCP、Druid、C3P0)可以在配置层面替换,业务层零改动。这也是 Spring Boot 项目默认采用的方式。

4.3 驱动管理器查找机制

当调用 DriverManager.getConnection 时,它会遍历所有已注册的 Driver,依次调用每个驱动的 connect 方法。只有 URL 协议匹配的驱动才会尝试建立连接,其余驱动直接返回 null。最终第一个成功建立连接的驱动会被采用。这个机制解释了为什么 URL 前缀必须写对,例如 MySQL 是 jdbc:mysql://,Oracle 是 jdbc:oracle:thin:@。

五、Statement 详解

5.1 Statement 的基本使用

Statement 是最基础的 SQL 执行对象,适合执行结构固定、不含用户输入参数的 SQL。它的创建和使用流程如下:

import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.Statement; public class StatementDemo { public static void main(String[] args) throws Exception { String url = "jdbc:mysql://localhost:3306/jdbc_demo?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai"; try (Connection connection = DriverManager.getConnection(url, "root", "123456"); Statement statement = connection.createStatement()) { String createSql = "CREATE TABLE IF NOT EXISTS t_user (" + "id BIGINT PRIMARY KEY AUTO_INCREMENT," + "username VARCHAR(50) NOT NULL," + "age INT," + "balance DECIMAL(10,2)" + ")"; statement.executeUpdate(createSql); String insertSql = "INSERT INTO t_user(username, age, balance) VALUES ('zhangsan', 25, 1000.00)"; int rows = statement.executeUpdate(insertSql); System.out.println("影响行数:" + rows); String querySql = "SELECT id, username, age, balance FROM t_user"; try (ResultSet resultSet = statement.executeQuery(querySql)) { while (resultSet.next()) { long id = resultSet.getLong("id"); String username = resultSet.getString("username"); int age = resultSet.getInt("age"); System.out.println("id=" + id + ", username=" + username + ", age=" + age); } } } } }

5.2 execute 系列方法

  • executeQuery:执行查询语句,返回 ResultSet。
  • executeUpdate:执行 DML(INSERT、UPDATE、DELETE)或 DDL(CREATE、DROP 等),返回受影响行数,对于 DDL 通常返回 0。
  • execute:通用执行方法,可执行任意 SQL,通过返回值判断第一个结果是 ResultSet 还是更新计数。

execute 方法使用频率较低,主要用于不确定 SQL 类型的通用框架场景。在这种场景下,需要结合 getResultSet 和 getUpdateCount 逐步读取混合结果。

5.3 Statement 的局限

Statement 最大的问题在于字符串拼接 SQL。一旦 SQL 中包含用户输入,就容易产生 SQL 注入漏洞;同时每次执行都会重新解析和编译 SQL,数据库无法复用执行计划。正是这两个缺陷,催生了 PreparedStatement 的广泛使用。

六、PreparedStatement 详解

6.1 为什么需要预编译

PreparedStatement 在创建时就把 SQL 结构发送给数据库进行解析和预编译,后续执行时只需要传递参数值,数据库可以复用已经生成的执行计划。对于循环批量插入、高频重复查询等场景,性能提升非常明显。

更关键的是,PreparedStatement 使用占位符绑定参数,参数值不会被当作 SQL 语法的一部分解析,从根本上杜绝了绝大多数的 SQL 注入攻击。

6.2 基本用法

import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; public class PreparedStatementDemo { public static void main(String[] args) throws Exception { String url = "jdbc:mysql://localhost:3306/jdbc_demo?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai"; try (Connection connection = DriverManager.getConnection(url, "root", "123456")) { String sql = "SELECT id, username, age FROM t_user WHERE age > ? AND username LIKE ?"; try (PreparedStatement statement = connection.prepareStatement(sql)) { statement.setInt(1, 20); statement.setString(2, "%zhang%"); try (ResultSet resultSet = statement.executeQuery()) { while (resultSet.next()) { System.out.println("id=" + resultSet.getLong("id") + ", username=" + resultSet.getString("username")); } } } } } }

6.3 参数设置方法

PreparedStatement 提供了一系列 setXxx 方法,参数索引从 1 开始。常见的包括 setInt、setLong、setString、setDouble、setBigDecimal、setDate、setTimestamp、setObject 等。其中 setObject 最为灵活,可以在类型不确定时使用。

PreparedStatement statement = connection.prepareStatement("INSERT INTO t_user(username, age, balance) VALUES (?, ?, ?)"); statement.setString(1, "lisi"); statement.setInt(2, 30); statement.setBigDecimal(3, new java.math.BigDecimal("2000.50")); int rows = statement.executeUpdate();

6.4 获取自增主键

插入数据后,经常需要立即拿到数据库生成的自增主键。此时可以在创建 PreparedStatement 时传入 RETURN_GENERATED_KEYS 标志,再从 getGeneratedKeys 中读取。

String sql = "INSERT INTO t_user(username, age, balance) VALUES (?, ?, ?)"; try (PreparedStatement statement = connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS)) { statement.setString(1, "wangwu"); statement.setInt(2, 28); statement.setBigDecimal(3, new java.math.BigDecimal("1500.00")); statement.executeUpdate(); try (ResultSet generatedKeys = statement.getGeneratedKeys()) { if (generatedKeys.next()) { long generatedId = generatedKeys.getLong(1); System.out.println("新插入记录的主键:" + generatedId); } } }

6.5 SQL 注入演示与防御

假设使用 Statement 拼接登录 SQL,当用户输入密码为「' OR '1'='1」时,SQL 条件会恒成立,从而绕过密码校验。而使用 PreparedStatement 后,该恶意字符串只会作为普通参数值处理,不会改变 SQL 结构。通过对比实验,可以直观理解预编译参数绑定在安全层面的意义。

需要注意的是,PreparedStatement 并不能防御所有注入场景,例如动态拼接表名、列名、ORDER BY 字段时,占位符无法用于这些位置,此时必须对标识符做白名单校验。正确理解「参数化」的适用边界,才能写出真正安全的代码。

七、CallableStatement 与存储过程

7.1 什么是存储过程

存储过程是存储在数据库端、可重复调用的一段 SQL 逻辑,通常包含参数、变量、条件分支和循环。将复杂数据处理逻辑下沉到数据库端,可以减少网络往返、提升执行效率,但也会增加数据库负担和迁移难度,需要根据业务权衡。

7.2 创建存储过程

下面是一个 MySQL 存储过程示例,它根据用户 ID 查询姓名并输出年龄。

DELIMITER // CREATE PROCEDURE get_user(IN user_id BIGINT, OUT user_name VARCHAR(50), OUT user_age INT) BEGIN SELECT username, age INTO user_name, user_age FROM t_user WHERE id = user_id; END // DELIMITER ;

7.3 调用存储过程

import java.sql.Connection; import java.sql.DriverManager; import java.sql.CallableStatement; import java.sql.Types; public class CallableStatementDemo { public static void main(String[] args) throws Exception { String url = "jdbc:mysql://localhost:3306/jdbc_demo?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai"; try (Connection connection = DriverManager.getConnection(url, "root", "123456")) { String callSql = "{call get_user(?, ?, ?)}"; try (CallableStatement statement = connection.prepareCall(callSql)) { statement.setLong(1, 1L); statement.registerOutParameter(2, Types.VARCHAR); statement.registerOutParameter(3, Types.INTEGER); statement.execute(); String username = statement.getString(2); int age = statement.getInt(3); System.out.println("username=" + username + ", age=" + age); } } } }

CallableStatement 同时支持 IN、OUT、INOUT 三种参数模式。输出参数在执行前必须先调用 registerOutParameter 注册类型,执行结束后通过对应的 getXxx 方法读取结果。

八、ResultSet 结果集处理

8.1 结果集遍历

ResultSet 内部维护一个游标,初始时位于第一行之前,每调用一次 next 方法游标下移一行。当 next 返回 false 时表示已经遍历完所有行。读取列值时,可以通过列索引(从 1 开始)或列名获取,推荐使用列名以提高可读性。

try (ResultSet resultSet = statement.executeQuery("SELECT id, username, age FROM t_user")) { while (resultSet.next()) { long id = resultSet.getLong("id"); String username = resultSet.getString("username"); int age = resultSet.getInt("age"); System.out.println(id + " / " + username + " / " + age); } }

8.2 游标类型与并发模式

创建 Statement 时可以指定结果集的游标类型与并发模式,影响遍历方向和更新能力。

Statement statement = connection.createStatement( ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_UPDATABLE);
  • TYPE_FORWARD_ONLY:只能向前遍历,默认值,性能最好。
  • TYPE_SCROLL_INSENSITIVE:可前后滚动,对结果集底层数据修改不敏感。
  • TYPE_SCROLL_SENSITIVE:可前后滚动,对底层数据修改敏感,实现依赖数据库。
  • CONCUR_READ_ONLY:只读结果集,默认值。
  • CONCUR_UPDATABLE:可通过结果集直接更新数据库记录。

绝大多数业务场景使用默认的只读向前游标即可。可更新结果集虽然提供了便利,但容易隐藏 SQL 逻辑,不利于维护,实际项目中较少使用。

8.3 ResultSetMetaData

当需要动态处理查询结果、不知道具体列结构时,可以通过 ResultSetMetaData 获取列元信息。

import java.sql.ResultSet; import java.sql.ResultSetMetaData; public class MetaDataDemo { public static void printResultSet(ResultSet resultSet) throws Exception { ResultSetMetaData metaData = resultSet.getMetaData(); int columnCount = metaData.getColumnCount(); for (int i = 1; i <= columnCount; i++) { System.out.print(metaData.getColumnLabel(i) + "\t"); } System.out.println(); while (resultSet.next()) { for (int i = 1; i <= columnCount; i++) { System.out.print(resultSet.getObject(i) + "\t"); } System.out.println(); } } }

这类能力广泛用于通用查询工具、报表导出框架和数据库可视化组件中,体现了 JDBC 元数据接口的灵活性。

九、JDBC 批处理

9.1 为什么需要批处理

循环逐条插入数据时,每条 SQL 都需要一次完整的网络往返与数据库执行,性能低下。批处理通过把多条 SQL 打包发送,大幅减少网络交互次数,显著提升批量写入吞吐量。

9.2 Statement 批处理

try (Statement statement = connection.createStatement()) { for (int i = 0; i < 1000; i++) { String sql = "INSERT INTO t_user(username, age, balance) VALUES ('user" + i + "', " + (20 + i % 30) + ", 100.00)"; statement.addBatch(sql); if (i % 200 == 0) { statement.executeBatch(); statement.clearBatch(); } } statement.executeBatch(); }

9.3 PreparedStatement 批处理

PreparedStatement 的批处理配合预编译,性能更优。各参数的每次 set 操作之后调用 addBatch,最后一次性 executeBatch 提交。

String sql = "INSERT INTO t_user(username, age, balance) VALUES (?, ?, ?)"; try (PreparedStatement statement = connection.prepareStatement(sql)) { for (int i = 0; i < 1000; i++) { statement.setString(1, "batch_user_" + i); statement.setInt(2, 20 + i % 30); statement.setBigDecimal(3, new java.math.BigDecimal("100.00")); statement.addBatch(); if (i % 200 == 0) { statement.executeBatch(); statement.clearBatch(); } } statement.executeBatch(); }

executeBatch 返回一个 int 数组,表示每条批量语句各自影响的行数。需要注意的是,如果某一条语句失败,行为与具体数据库及其配置有关,有的会整批回滚,有的则已执行部分生效,实际使用时要结合业务幂等性设计。

9.4 注意事项

  • 分批提交:不要一次性堆积过多 SQL,建议每几百条执行一次,避免内存峰值过高。
  • 关闭自动提交:批处理前先关闭自动提交,全部成功后统一 commit,失败时 rollback。
  • MySQL 特殊参数:MySQL 连接需加 rewriteBatchedStatements=true 才能实现真正的批量优化。
  • 避免超大事务:批量数据过大时建议拆分为多个事务,防止长事务锁表。

十、JDBC 事务概述

10.1 什么是事务

事务是数据库操作的最小执行单元,它把一组 SQL 操作集合成一个不可分割的整体:要么全部成功,要么全部失败回滚。典型的转账场景中,「A 账户扣款」和「B 账户加款」两条 SQL 必须同时成功或同时失败,不能出现只扣不加的中间状态。

10.2 自动提交

JDBC 连接默认处于自动提交模式,即每一条 SQL 执行后立即提交。在需要多步操作保持原子性的场景中,必须先关闭自动提交,手动控制事务边界。

import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; public class TransactionDemo { public static void main(String[] args) throws Exception { String url = "jdbc:mysql://localhost:3306/jdbc_demo?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai"; try (Connection connection = DriverManager.getConnection(url, "root", "123456")) { connection.setAutoCommit(false); try { String deductSql = "UPDATE t_account SET balance = balance - ? WHERE account_no = ?"; try (PreparedStatement deduct = connection.prepareStatement(deductSql)) { deduct.setBigDecimal(1, new java.math.BigDecimal("100.00")); deduct.setString(2, "A001"); deduct.executeUpdate(); } String addSql = "UPDATE t_account SET balance = balance + ? WHERE account_no = ?"; try (PreparedStatement add = connection.prepareStatement(addSql)) { add.setBigDecimal(1, new java.math.BigDecimal("100.00")); add.setString(2, "B001"); add.executeUpdate(); } connection.commit(); System.out.println("转账成功,事务已提交"); } catch (Exception e) { connection.rollback(); System.out.println("转账失败,事务已回滚:" + e.getMessage()); } } } }

10.3 事务边界管理

手动管理事务时,要严格保证 commit 和 rollback 的调用时机。任何异常都应及时回滚;如果提交前连接被关闭,数据库通常会执行回滚。连接池场景下还必须保证「一次获取连接 = 一次完整事务」,不能在事务中途把连接归还连接池,否则会造成事务泄漏。

可以使用保存点(Savepoint)实现部分回滚,即事务失败时只回滚到某个保存点,而不是回滚整个事务。保存点适合分段处理的长事务,但使用不当会增加复杂度。

connection.setAutoCommit(false); Savepoint savepoint = connection.setSavepoint("before_step2"); try { // 执行步骤二 } catch (Exception e) { connection.rollback(savepoint); } connection.commit();

十一、事务四大特性 ACID

11.1 原子性(Atomicity)

原子性指事务中的操作要么全部执行成功,要么全部不执行。数据库通过 undo log(回滚日志)实现原子性:当事务执行过程中修改数据时,会先在 undo log 中记录旧值,如果事务回滚,就依据 undo log 把数据恢复到事务开始前的状态。原子性是转账等关键业务正确性的基础保障。

11.2 一致性(Consistency)

一致性指事务执行前后,数据库必须从一个一致状态转换到另一个一致状态,所有约束、触发器、级联规则都保持有效。一致性通常由其他三个特性共同保证,同时也依赖业务逻辑本身的正确性。例如转账总额守恒,就是在应用层对一致性提出的业务约束。

11.3 隔离性(Isolation)

隔离性指多个事务并发执行时相互独立,一个事务的执行不被其他事务干扰。隔离性由锁机制、MVCC(多版本并发控制)等实现。由于完全隔离会严重损失并发性能,数据库提供了多个隔离级别,允许应用在一致性与并发性能之间权衡。

11.4 持久性(Durability)

持久性指事务一旦提交,其对数据的修改就是永久的,即使数据库宕机也不会丢失。持久性由 redo log(重做日志)实现:事务提交前,修改记录会先写入 redo log 并落盘,数据库重启后可依据 redo log 重放,恢复已提交的数据。

ACID 是一套理论框架,不同数据库在具体实现上有差异,但核心目标一致:让开发人员可以把事务当作逻辑上的安全单元,而不必担心底层故障。

十二、并发事务带来的问题

12.1 脏读(Dirty Read)

一个事务读取到了另一个事务尚未提交的数据。如果另一个事务随后回滚,第一个事务读取到的就是「脏数据」。脏读是最不能被容忍的问题之一,因为应用可能基于并不存在的数据做出决策。

12.2 不可重复读(Non-repeatable Read)

同一个事务内,两次读取同一条记录得到的结果不一致。典型场景是事务 A 读取某记录后,事务 B 修改了该记录并提交,事务 A 再次读取时看到的是修改后的值。不可重复读针对的是同一行数据的值变化。

12.3 幻读(Phantom Read)

同一个事务内,两次执行相同范围查询,结果集的行数不一致。典型场景是事务 A 查询某范围内数据后,事务 B 插入了符合该范围的新记录并提交,事务 A 再次查询时发现了「新出现」的行。幻读针对的是结果集行数的变化。

12.4 三者关系

脏读、不可重复读、幻读的严重程度依次降低,但解决它们的代价依次升高。理解这些并发问题,是理解隔离级别设计动机的前提。

十三、事务隔离级别

13.1 四种隔离级别

隔离级别脏读不可重复读幻读并发性能
READ UNCOMMITTED(读未提交)可能可能可能最高
READ COMMITTED(读已提交)不会可能可能较高
REPEATABLE READ(可重复读)不会不会可能中等
SERIALIZABLE(串行化)不会不会不会最低

13.2 JDBC 设置隔离级别

connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); int level = connection.getTransactionIsolation(); System.out.println("当前隔离级别:" + level);

Connection 接口提供了四个常量:TRANSACTION_READ_UNCOMMITTED、TRANSACTION_READ_COMMITTED、TRANSACTION_REPEATABLE_READ、TRANSACTION_SERIALIZABLE。设置时机应在事务开始之前,否则不同数据库的表现可能不一致。

13.3 数据库默认隔离级别

  • MySQL InnoDB:默认 REPEATABLE READ,并借助 next-key lock 在一定程度上抑制幻读。
  • Oracle:只支持 READ COMMITTED 和 SERIALIZABLE,默认 READ COMMITTED。
  • PostgreSQL:默认 READ COMMITTED。
  • SQL Server:默认 READ COMMITTED。

在实际项目中,READ COMMITTED 是互联网应用最常见的选择,它在并发性能与一致性之间取得了较好平衡。对于有强一致性要求的场景,可结合行锁、间隙锁或数据库的特定机制做加固。

13.4 隔离级别实验

理解隔离级别的最好方式是动手实验。可以打开两个数据库连接或两个事务线程,按照「事务 A 读、事务 B 写、事务 A 再读」的流程观察不同隔离级别下的结果变化。实验时需要注意,MySQL 的可重复读和读已提交行为差异非常明显,亲自运行一遍会比只看理论记得更牢。

十四、事务隔离的实现机制

14.1 锁机制

数据库通过锁来控制并发访问。按锁的粒度分为表锁、行锁、页锁;按锁的性质分为共享锁(S 锁)和排他锁(X 锁)。共享锁允许多个事务同时读,排他锁只允许一个事务写。读写操作之间通过锁的兼容性矩阵协调。

高隔离级别往往意味着更严格、更持久的加锁,从而降低并发吞吐。例如 SERIALIZABLE 级别下,读写操作互相阻塞,几乎退化为串行执行,因此实际生产环境很少使用。

14.2 MVCC 多版本并发控制

MVCC 通过保存数据的历史版本实现「读不阻塞写、写不阻塞读」。每个事务在启动时获取一个版本快照,读取操作看到的是快照中的旧版本,而写操作创建新版本。这种方式兼顾了并发性能与读一致性。

MySQL InnoDB 的 MVCC 依赖隐藏的版本号字段和 undo log 构建旧版本数据。在 REPEATABLE READ 级别下,同一事务内始终读取同一快照,从而避免不可重复读;在 READ COMMITTED 级别下,每次读取都会获取最新快照,因此仍可能发生不可重复读。

14.3 当前读与快照读

在 MySQL InnoDB 中,普通 SELECT 默认是快照读,读取 MVCC 历史版本;而 SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE 属于当前读,读取最新版本并加锁。理解快照读与当前读的区别,才能解释很多「看似违反直觉」的并发现象。

14.4 锁与 MVCC 的结合

现代数据库通常不是纯粹使用锁或纯 MVCC,而是两者结合:普通读走 MVCC 快照,避免加锁;写操作和显式加锁读走锁机制。这种组合策略在保证一致性的同时,最大限度提升了读并发能力。

十五、事务传播行为

15.1 什么是传播行为

JDBC 层面的事务只有 commit 和 rollback,传播行为是更上层框架(如 Spring)引入的概念。它解决的问题是:当一个事务方法调用另一个事务方法时,被调用方是加入调用方已有的事务,还是新开一个事务,还是不使用事务。

15.2 典型传播行为

  • REQUIRED:如果当前有事务则加入,没有则新建。最常用,Spring 默认值。
  • REQUIRES_NEW:无论当前是否有事务,都新建一个独立事务,外层事务挂起。
  • SUPPORTS:如果有事务则加入,没有则非事务执行。
  • NOT_SUPPORTED:以非事务方式执行,如果有事务则挂起。
  • MANDATORY:必须在已有事务中执行,否则抛出异常。
  • NEVER:必须以非事务方式执行,否则抛出异常。
  • NESTED:嵌套事务,使用保存点实现部分回滚。

15.3 REQUIRED 与 REQUIRES_NEW 的差异

REQUIRED 让两个方法共享同一个事务,任何一处回滚会导致整体回滚。REQUIRES_NEW 让被调用方法拥有独立事务,其失败不会自动回滚外层事务。典型的日志记录场景中,业务操作使用主事务,日志写入使用 REQUIRES_NEW,这样即使日志写入失败也不影响主流程提交。

import org.springframework.transaction.annotation.Transactional; public class OrderService { @Transactional public void createOrder() { // 主流程:创建订单、扣库存 } } public class AuditService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void recordAuditLog() { // 独立事务:记录审计日志 } }

传播行为虽然定义在框架层,但其底层依然通过 JDBC 的 Connection 来落地。理解传播行为本质上是理解「同一个 Connection 还是多个 Connection」以及「savepoint 如何回退」的问题。

十六、Spring 事务管理

16.1 声明式事务

Spring 提供了声明式事务管理,开发人员只需在方法上添加 @Transactional 注解,框架自动完成事务开启、提交、回滚、连接获取与释放。它基于 AOP 代理实现,底层仍然调用 PlatformTransactionManager。

import org.springframework.transaction.annotation.Transactional; public class TransferService { @Transactional(rollbackFor = Exception.class) public void transfer(String fromAccount, String toAccount, BigDecimal amount) { accountMapper.deduct(fromAccount, amount); accountMapper.add(toAccount, amount); } }

16.2 回滚规则

Spring 事务默认只在抛出 RuntimeException 或 Error 时回滚,受检异常(Checked Exception)默认不回滚。实际项目中,通常将 rollbackFor 设置为 Exception.class,保证所有异常都触发回滚,避免数据不一致。

16.3 失效场景

声明式事务依赖代理机制,以下情况可能导致 @Transactional 失效:

  • 同类内部方法自调用,没有经过代理对象,事务无法生效。
  • 方法不是 public,Spring AOP 代理无法拦截。
  • 异常被内部捕获且未向上抛出,框架感知不到异常。
  • 所在的类没有被 Spring 容器管理。
  • 数据库引擎不支持事务(如 MySQL 的 MyISAM)。

16.4 编程式事务

除了注解方式,Spring 还提供了 TransactionTemplate,用于需要精细控制事务边界的场景。

import org.springframework.transaction.support.TransactionTemplate; public class TemplateTransferService { private final TransactionTemplate transactionTemplate; public TemplateTransferService(TransactionTemplate transactionTemplate) { this.transactionTemplate = transactionTemplate; } public void transfer(String fromAccount, String toAccount, BigDecimal amount) { transactionTemplate.execute(status -&gt; { // 执行扣款与加款 return null; }); } }

编程式事务的优势在于事务范围清晰可见,适合事务逻辑较复杂、不便于用注解统一管理的场景。但代码侵入性更强,主流项目仍以声明式事务为主。

十七、数据库连接池

17.1 为什么需要连接池

数据库连接的建立需要经过 TCP 握手、认证、资源分配等过程,开销较大。如果每次操作都新建连接、用完即销毁,频繁的建立与释放会严重拖慢系统。连接池通过预创建连接并复用,把连接视为可循环使用的资源,大幅降低连接管理成本。

17.2 常用连接池

  • HikariCP:性能优异、配置简单,是 Spring Boot 默认连接池。
  • Druid:阿里巴巴开源,功能丰富,提供监控、SQL 防火墙、多数据源支持。
  • C3P0:较早的连接池,功能稳定但性能一般。
  • DBCP:Apache Commons 系列,使用广泛。

17.3 核心参数

参数含义
maximumPoolSize连接池最大连接数,过大会压垮数据库,过小会导致排队。
minimumIdle最小空闲连接数,保持连接池基本规模。
connectionTimeout获取连接的最长等待时间,超时则抛异常。
idleTimeout空闲连接超过该时间会被回收。
maxLifetime连接最大存活时间,需小于数据库超时设置。

17.4 连接池与事务的关系

连接池的底层仍是 JDBC Connection。Spring 事务管理器从连接池获取一个 Connection,绑定到当前线程,事务内所有操作复用同一个 Connection;事务结束后归还连接。理解这一点,有助于排查「事务中切换线程导致连接错乱」等疑难问题。

需要注意,连接池中的连接是物理连接的封装。归还连接时,连接池会重置连接状态,包括清理事务状态、重置隔离级别和自动提交模式,以保证下一次获取时连接处于干净状态。

十八、JDBC 最佳实践

18.1 资源释放

Connection、Statement、ResultSet 都属于需要显式释放的资源,必须保证在 finally 或 try-with-resources 中关闭。关闭顺序上,先关 ResultSet,再关 Statement,最后归还 Connection。如果使用连接池,不关闭 Statement 和 ResultSet 可能导致游标泄漏。

try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement(sql); ResultSet resultSet = statement.executeQuery()) { while (resultSet.next()) { // 处理结果 } }

18.2 统一异常处理

SQLException 提供错误码、SQL 状态和异常链信息。业务代码不应把原始 SQLException 直接暴露给前端,而应转换为友好的业务异常。可以通过 SQLState 或供应商错误码区分约束冲突、死锁、连接超时等不同失败场景,从而采取差异化处理策略。

18.3 SQL 与连接参数规范

  • 所有带用户输入的 SQL 一律使用 PreparedStatement,杜绝拼接。
  • 避免 SELECT *,明确列出需要的列,减少网络传输和内存占用。
  • 大批量写入使用批处理,并控制每批条数和事务大小。
  • 查询时合理使用分页,避免一次性加载海量行导致内存溢出。
  • 连接参数统一配置字符编码、时区、超时与 SSL 策略。

18.4 事务使用规范

  • 事务范围尽量小,避免长事务占用锁和连接。
  • 不要在事务内执行远程调用、发消息等耗时操作。
  • 根据业务选择隔离级别,默认优先 READ COMMITTED。
  • 明确回滚规则,重要业务设置 rollbackFor = Exception.class。
  • 幂等与重试结合,应对偶发的死锁或连接超时。

十九、常见问题与性能优化

19.1 中文乱码

中文乱码多由字符集不一致引起。排查方向包括:数据库字符集、表字符集、连接参数 characterEncoding、JVM 默认编码、应用与数据库服务器时区。统一使用 UTF-8 并在 JDBC URL 中显式指定编码,能从源头规避大部分问题。

19.2 时间偏差

读取时间字段出现 8 小时偏差,通常是时区不一致导致。解决方式是在 JDBC URL 中指定 serverTimezone,并统一应用、数据库、JVM 的时区设置。对于跨时区的国际化系统,建议统一使用 UTC 存储。

19.3 死锁

两个事务互相等待对方持有的锁时发生死锁。数据库一般会自动检测并回滚其中一个事务。应用侧应遵循一致的加锁顺序,缩短事务时间,并在捕获死锁异常后重试。

19.4 连接泄漏

连接池连接数被逐渐耗尽,常见原因是获取连接后未及时归还。排查时可通过连接池监控查看活跃连接数变化,定位未关闭资源的代码路径。

19.5 SQL 性能排查

通过开启 SQL 日志、慢查询日志和数据库执行计划分析,找到耗时 SQL。优化的常规手段包括:添加合适索引、避免全表扫描、减少返回列、改写低效连接查询、使用批处理减少交互次数。数据库层面的优化往往比应用代码微调收益更大。

二十、总结

JDBC 是 Java 生态中一切数据库访问的基石。从 DriverManager 到 DataSource,从 Statement 到 PreparedStatement,从逐条执行到批处理,JDBC 以一套简洁的接口抽象了形态各异的数据库。掌握 JDBC,不只是会写增删改查,更重要的是理解连接的生命周期、预编译的价值、资源释放的纪律以及异常处理的方式。

事务则是数据库编程中最需要审慎对待的部分。ACID 定义了事务的理想特性,隔离级别与传播行为则提供了在一致性与并发性能之间做取舍的工具。脏读、不可重复读、幻读这些并发问题的产生与治理,背后是锁机制与 MVCC 的科学设计。无论是直接使用 JDBC 手动管理事务,还是通过 Spring 声明式事务简化开发,最终都要回到「数据一致性」这个业务核心上来。

学习 JDBC 与事务,建议遵循「接口认知、动手实验、框架应用、性能调优」的路径。先把核心 API 跑通,再用多线程实验观察隔离级别的真实效果,最后结合 Spring 和连接池在真实项目中应用。技术会不断演进,数据库与框架也在持续迭代,但对事务和一致性的理解,是每一个后端开发者长期受益的底层能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询