ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JDBC以及事务

JDBC以及事务 一、引言从数据库访问说起在 Java 应用开发中几乎每一套业务系统都需要与数据库打交道。无论是传统的关系型数据库 MySQL、Oracle、PostgreSQL还是近年来流行的云原生数据库Java 程序都需要一种统一、稳定的方式来执行 SQL、处理结果集、管理连接与事务。JDBCJava Database Connectivity正是 Java 官方提供的数据库访问标准接口。JDBC 的全称是 Java Database Connectivity它由 java.sql、javax.sql 等包中的一组接口和少量抽象类组成。通过 JDBC开发人员可以用相同的 Java 代码访问不同类型的数据库只要数据库厂商提供了对应的 JDBC 驱动即可。换句话说JDBC 是一套「接口规范」而各家数据库厂商发布的驱动包则是这套规范的具体「实现」。理解 JDBC 的价值可以从「解耦」两个维度来看。第一它屏蔽了底层数据库的差异让 Java 代码不再与某一种数据库的专有协议深度绑定第二它提供了统一的事务控制入口使得连接管理、事务提交、回滚、隔离级别设置等操作能够以一致的方式完成。对于后续要深入学习的 ORM 框架如 MyBatis、Hibernate、Spring JDBCJDBC 都是他们最底层的技术支撑。本文将围绕 JDBC 的核心 API、连接管理、Statement 家族、结果集处理、批处理以及最为关键的事务机制展开力求把 JDBC 和事务讲透。读者在阅读时建议结合代码手动实践尤其是事务隔离级别和传播行为部分只有亲手执行才能真切感受到不同配置对并发场景的影响。二、JDBC 架构与核心组成JDBC 的整体架构可以概括为「应用程序 JDBC API 驱动管理器 数据库驱动 数据库」五层。Java 程序只面向 JDBC API 编程而不直接依赖具体的驱动实现类这是典型的面向接口设计。2.1 架构分层层级说明典型组件应用层业务代码调用 JDBC 接口读写数据库Service、DAOJDBC 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用于调用数据库存储过程或函数支持输入、输出参数。ResultSetSQL 查询结果集提供游标式遍历与按列读取的能力。ResultSetMetaData结果集元数据可获取列数量、列名、列类型等信息。DatabaseMetaData数据库元数据可查询数据库、表、索引等结构信息。SQLExceptionJDBC 操作过程中抛出的主要受检异常携带错误码与 SQL 状态。DataSourcejavax.sql 包中的连接获取接口是连接池与 JNDI 场景下的标准入口。在实际项目中Connection 和 PreparedStatement 是出现频率最高的两个接口几乎每一次数据库操作都绕不开它们。理解接口之间的关系是打好 JDBC 基础的第一步。三、环境准备与驱动加载3.1 引入依赖如果使用 Maven 项目以 MySQL 为例只需在 pom.xml 中加入驱动依赖即可。不同数据库对应不同的坐标但接入方式完全一致。dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version scoperuntime/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?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/ShanghaiuseUnicode是否使用 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?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/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?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/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执行 DMLINSERT、UPDATE、DELETE或 DDLCREATE、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?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/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 11」时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?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/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 连接需加 rewriteBatchedStatementstrue 才能实现真正的批量优化。避免超大事务批量数据过大时建议拆分为多个事务防止长事务锁表。十、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?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/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();十一、事务四大特性 ACID11.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 失效同类内部方法自调用没有经过代理对象事务无法生效。方法不是 publicSpring 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较早的连接池功能稳定但性能一般。DBCPApache 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 和连接池在真实项目中应用。技术会不断演进数据库与框架也在持续迭代但对事务和一致性的理解是每一个后端开发者长期受益的底层能力。
返回列表