在企业级系统开发中,"数据一致性" 是核心诉求 ------ 比如员工入职时,"新增员工记录" 和 "更新部门人数" 这两个操作必须同时成功或同时失败;再比如财务转账时,"扣款" 和 "收款" 也不能只执行其一。而保证这种 "要么全成、要么全败" 的核心技术,就是数据库事务(Transaction) 。本文将以 "部门 - 员工" 这一经典业务场景为核心,从事务的核心价值、ACID 特性、隔离级别,到 JDBC/MyBatis/Spring 中的实战落地,帮你彻底掌握事务的使用与优化。
一、事务的核心价值:为什么离不开事务?
先看一个典型的业务场景:
企业招聘新员工 "钱八" 入职研发部,需要执行两个操作:
- 向
emp表插入 "钱八" 的员工记录;- 向
dept表更新研发部的员工数量(emp_count字段),加 1。
如果没有事务控制,可能出现两种异常:
- 场景 1:插入员工记录成功,但更新部门人数失败 → 研发部人数少算 1 人,数据不一致;
- 场景 2:更新部门人数成功,但插入员工记录失败 → 研发部人数多算 1 人,数据错误。
而事务的核心作用,就是将一组操作封装为一个 "不可分割的执行单元" ,确保单元内的所有操作要么全部执行成功,要么全部回滚(撤销),从根本上避免数据不一致问题。
二、事务的核心特性:ACID 原则
事务的本质是遵循ACID 四大特性的一组数据库操作,这是事务的核心定义,我们结合 "部门 - 员工" 场景逐一拆解:
| 特性 | 英文全称 | 核心定义 | 部门 - 员工场景示例 |
|---|---|---|---|
| 原子性(A) | Atomicity | 事务中的操作要么全部执行成功,要么全部回滚,不存在 "部分成功" | 入职操作:插入员工 + 更新部门人数,要么都成,要么都回滚(比如员工姓名重复导致插入失败,部门人数也不更新) |
| 一致性(C) | Consistency | 事务执行前后,数据库的 "业务规则" 保持一致(如部门人数 = 该部门员工数) | 执行前研发部人数是 2,执行后必须是 3(成功)或仍为 2(失败),不会出现 "员工新增但人数不变" 的不一致 |
| 隔离性(I) | Isolation | 多个事务并发执行时,彼此隔离、互不干扰,避免并发导致的数据错误 | 管理员 A 新增员工、管理员 B 统计部门人数,两个事务并发执行时,B 不会读到 A "未提交的中间数据" |
| 持久性(D) | Durability | 事务提交后,修改会永久保存到数据库,即使数据库崩溃也不会丢失 | 入职操作提交后,"钱八" 的员工记录和研发部人数的修改会永久保存,重启数据库后数据仍有效 |
关键说明 :ACID 是事务的 "契约",其中原子性、一致性、持久性 由数据库的 "重做日志(Redo Log)" 和 "回滚日志(Undo Log)" 保证,隔离性由事务隔离级别和锁机制保证。
三、事务的隔离级别:解决并发事务冲突
在多用户并发操作数据库时,若没有隔离性控制,会出现脏读、不可重复读、幻读三类问题。MySQL 通过 "事务隔离级别" 来平衡 "隔离性" 和 "并发性能",共定义了 4 个隔离级别(从低到高):
1. 四类并发问题(先理解问题,再看解决方案)
| 问题类型 | 定义 | 部门 - 员工场景示例 |
|---|---|---|
| 脏读(Dirty Read) | 一个事务读取到另一个事务 "未提交" 的修改数据 | 事务 A 新增员工 "钱八" 但未提交,事务 B 查询研发部人数为 3;事务 A 回滚后,事务 B 读到的 "3" 就是 "脏数据" |
| 不可重复读(Non-repeatable Read) | 一个事务内多次读取同一数据,结果不一致(被其他事务 "修改并提交") | 事务 B 第一次查研发部人数为 2,事务 A 新增员工并提交,事务 B 再次查询人数为 3,两次结果不同 |
| 幻读(Phantom Read) | 一个事务内多次执行同一查询,结果集行数不一致(被其他事务 "插入 / 删除并提交") | 事务 B 查询 "薪资> 15000 的研发部员工" 有 2 人,事务 A 新增 1 名高薪员工并提交,事务 B 再次查询变为 3 人,如同 "出现幻觉" |
2. 四种隔离级别(解决并发问题的不同程度)
MySQL 默认隔离级别为REPEATABLE READ(可重复读),也是生产环境最常用的级别。
| 隔离级别 | 英文全称 | 解决的问题 | 允许的问题 | 性能 |
|---|---|---|---|---|
| 读未提交 | READ UNCOMMITTED | 无(最低隔离) | 脏读、不可重复读、幻读 | 最高 |
| 读已提交 | READ COMMITTED | 脏读 | 不可重复读、幻读 | 较高 |
| 可重复读(默认) | REPEATABLE READ | 脏读、不可重复读 | 幻读(MySQL 已通过 MVCC 解决) | 中等 |
| 串行化 | SERIALIZABLE | 所有问题(完全隔离) | 无(并发事务串行执行) | 最低 |
关键说明:
- MySQL 的
REPEATABLE READ通过 "多版本并发控制(MVCC)" 额外解决了幻读问题,是比标准定义更优的实现; - 隔离级别越高,并发性能越低,需根据业务场景选择(如财务系统用
SERIALIZABLE,普通业务用REPEATABLE READ)。
3. 查看 / 设置隔离级别
sql
-- 查看当前会话隔离级别
SELECT @@SESSION.tx_isolation;
-- 查看全局隔离级别
SELECT @@GLOBAL.tx_isolation;
-- 设置当前会话隔离级别为读已提交
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 设置全局隔离级别为可重复读(默认)
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
四、事务的基础操作语法(MySQL 实战)
1. 核心语法
MySQL 默认开启 "自动提交(AUTOCOMMIT)",即每条 SQL 语句都是一个独立事务。需手动关闭自动提交或显式开启事务。
sql
-- 1. 关闭自动提交(当前会话有效)
SET AUTOCOMMIT = 0;
-- 2. 开启事务(显式声明事务开始)
BEGIN; -- 或 START TRANSACTION;
-- 3. 执行事务内的操作(如插入员工+更新部门人数)
INSERT INTO `emp` (emp_name, emp_salary, hire_date, dept_id)
VALUES ('钱八', 14000.00, '2024-01-01', 1);
UPDATE `dept` SET emp_count = emp_count + 1 WHERE dept_id = 1;
-- 4. 提交事务(所有操作生效,持久化到数据库)
COMMIT;
-- 4. 回滚事务(撤销所有操作,回到事务开始前的状态)
-- ROLLBACK;
-- 5. 保存点(可选,回滚到指定位置,而非整个事务)
SAVEPOINT sp1; -- 设置保存点
ROLLBACK TO sp1; -- 回滚到保存点
2. 实战案例(部门 - 员工入职)
先给dept表新增emp_count字段(用于统计部门人数):
sql
ALTER TABLE `dept` ADD COLUMN `emp_count` INT DEFAULT 0 COMMENT '部门员工数';
-- 初始化现有部门人数
UPDATE `dept` SET emp_count = 2 WHERE dept_id = 1; -- 研发部2人
UPDATE `dept` SET emp_count = 1 WHERE dept_id = 2; -- 人事部1人
UPDATE `dept` SET emp_count = 1 WHERE dept_id = 3; -- 财务部1人
UPDATE `dept` SET emp_count = 0 WHERE dept_id = 4; -- 市场部0人
执行带事务的入职操作:
sql
-- 步骤1:开启事务
BEGIN;
-- 步骤2:执行操作1:插入新员工
INSERT INTO `emp` (emp_name, emp_salary, hire_date, dept_id, manager_id)
VALUES ('钱八', 14000.00, '2024-01-01', 1, 1);
-- 步骤3:执行操作2:更新研发部人数
UPDATE `dept` SET emp_count = emp_count + 1 WHERE dept_id = 1;
-- 步骤4:验证数据(事务未提交,仅当前会话可见)
SELECT * FROM `emp` WHERE emp_name = '钱八'; -- 能查到
SELECT emp_count FROM `dept` WHERE dept_id = 1; -- 3
-- 步骤5:提交事务(数据持久化)
COMMIT;
-- 若操作失败,执行回滚:ROLLBACK;
五、实战落地:不同开发场景下的事务使用
1. 原生 JDBC 中的事务控制
JDBC 默认也是自动提交事务,需手动关闭自动提交,通过Connection对象控制事务:
java
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
public class JdbcTransactionDemo {
// 数据库连接信息
private static final String URL = "jdbc:mysql://localhost:3306/test?serverTimezone=UTC&useSSL=false";
private static final String USER = "root";
private static final String PASSWORD = "123456";
public static void main(String[] args) {
Connection conn = null;
PreparedStatement pstmt1 = null;
PreparedStatement pstmt2 = null;
try {
// 1. 获取连接
conn = DriverManager.getConnection(URL, USER, PASSWORD);
// 2. 关闭自动提交(开启手动事务)
conn.setAutoCommit(false);
// 3. 操作1:插入新员工
String sql1 = "INSERT INTO emp (emp_name, emp_salary, hire_date, dept_id, manager_id) VALUES (?, ?, ?, ?, ?)";
pstmt1 = conn.prepareStatement(sql1);
pstmt1.setString(1, "钱八");
pstmt1.setBigDecimal(2, new java.math.BigDecimal("14000.00"));
pstmt1.setDate(3, java.sql.Date.valueOf("2024-01-01"));
pstmt1.setLong(4, 1L);
pstmt1.setLong(5, 1L);
pstmt1.executeUpdate();
// 4. 操作2:更新部门人数
String sql2 = "UPDATE dept SET emp_count = emp_count + 1 WHERE dept_id = ?";
pstmt2 = conn.prepareStatement(sql2);
pstmt2.setLong(1, 1L);
pstmt2.executeUpdate();
// 5. 提交事务(所有操作生效)
conn.commit();
System.out.println("入职操作成功,事务提交!");
} catch (SQLException e) {
e.printStackTrace();
// 6. 发生异常,回滚事务
try {
if (conn != null) {
conn.rollback();
System.out.println("操作失败,事务回滚!");
}
} catch (SQLException ex) {
ex.printStackTrace();
}
} finally {
// 7. 关闭资源
try {
if (pstmt1 != null) pstmt1.close();
if (pstmt2 != null) pstmt2.close();
if (conn != null) {
conn.setAutoCommit(true); // 恢复自动提交
conn.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
2. MyBatis 中的事务控制
MyBatis 的事务默认由SqlSession控制,默认关闭自动提交,需手动commit()或rollback():
(1)编写 Mapper 接口和 XML
java
// EmpMapper.java
public interface EmpMapper {
// 插入员工
int insertEmp(Emp emp);
}
// DeptMapper.java
public interface DeptMapper {
// 更新部门人数
int updateDeptCount(@Param("deptId") Long deptId, @Param("count") int count);
}
xml
<!-- EmpMapper.xml -->
<insert id="insertEmp">
INSERT INTO emp (emp_name, emp_salary, hire_date, dept_id, manager_id)
VALUES (#{empName}, #{empSalary}, #{hireDate}, #{deptId}, #{managerId})
</insert>
<!-- DeptMapper.xml -->
<update id="updateDeptCount">
UPDATE dept SET emp_count = emp_count + #{count} WHERE dept_id = #{deptId}
</update>
(2)MyBatis 事务实战
java
import org.apache.ibatis.session.SqlSession;
public class MyBatisTransactionDemo {
public static void main(String[] args) {
// 1. 获取SqlSession(默认autoCommit=false)
try (SqlSession session = MyBatisUtil.getSqlSession(false)) {
// 2. 获取Mapper
EmpMapper empMapper = session.getMapper(EmpMapper.class);
DeptMapper deptMapper = session.getMapper(DeptMapper.class);
// 3. 构建员工对象
Emp emp = new Emp();
emp.setEmpName("钱八");
emp.setEmpSalary(new java.math.BigDecimal("14000.00"));
emp.setHireDate(java.sql.Date.valueOf("2024-01-01"));
emp.setDeptId(1L);
emp.setManagerId(1L);
// 4. 执行操作
empMapper.insertEmp(emp);
deptMapper.updateDeptCount(1L, 1);
// 5. 提交事务
session.commit();
System.out.println("入职操作成功,事务提交!");
} catch (Exception e) {
e.printStackTrace();
// 6. 异常回滚(try-with-resources会自动关闭session,触发rollback)
System.out.println("操作失败,事务回滚!");
}
}
}
3. Spring 声明式事务(生产环境首选)
在 Spring/Spring Boot 项目中,推荐使用@Transactional注解实现 "声明式事务",无需手动控制commit/rollback,更简洁高效:
java
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class EmpDeptService {
// 注入Mapper
private final EmpMapper empMapper;
private final DeptMapper deptMapper;
public EmpDeptService(EmpMapper empMapper, DeptMapper deptMapper) {
this.empMapper = empMapper;
this.deptMapper = deptMapper;
}
// 声明事务:默认发生RuntimeException时回滚
@Transactional(rollbackFor = Exception.class) // 捕获所有异常回滚
public void addEmpWithDept(Emp emp) {
// 1. 插入员工
empMapper.insertEmp(emp);
// 2. 更新部门人数
deptMapper.updateDeptCount(emp.getDeptId(), 1);
// 模拟异常(测试回滚)
// int i = 1 / 0;
}
}
关键说明:
@Transactional默认只捕获RuntimeException,需通过rollbackFor = Exception.class指定捕获所有异常;- 可通过
isolation属性设置隔离级别(如isolation = Isolation.REPEATABLE_READ); - 可通过
propagation属性设置事务传播行为(如REQUIRED:默认,有事务则加入,无则新建)。
六、事务的常见问题与最佳实践
1. 常见问题排查
- 事务不回滚 :检查是否捕获了异常(未抛出异常则 Spring 不会回滚)、是否是非运行时异常(需指定
rollbackFor)、是否调用了非 public 方法(@Transactional仅对 public 方法生效); - 长事务导致性能问题:长事务会占用数据库连接、持有锁时间长,导致并发下降,需尽量缩小事务范围;
- 死锁 :多个事务互相等待对方持有的锁,可通过
SHOW ENGINE INNODB STATUS查看死锁日志,优化 SQL 执行顺序。
2. 核心最佳实践
(1)尽量缩小事务范围
只将 "必须原子执行" 的操作放入事务,避免无关操作(如日志打印、远程调用):
java
@Transactional
public void addEmpWithDept(Emp emp) {
// 事务内:核心操作(必须原子)
empMapper.insertEmp(emp);
deptMapper.updateDeptCount(emp.getDeptId(), 1);
}
// 事务外:无关操作
public void logEmpAdd(Emp emp) {
logger.info("新增员工:{}", emp.getEmpName());
}
(2)避免长事务
- 不在事务内执行耗时操作(如文件上传、第三方接口调用);
- 批量操作可拆分为多个小事务(如每 1000 条数据提交一次)。
(3)合理设置隔离级别
- 普通业务:使用 MySQL 默认的
REPEATABLE READ; - 高并发读业务:可降级为
READ COMMITTED(提升性能); - 财务 / 核心数据:使用
SERIALIZABLE(保证绝对一致性)。
(4)避免手动锁与事务冲突
尽量利用事务的隔离级别和 MVCC,而非手动加锁(如SELECT ... FOR UPDATE),手动锁易导致死锁。
(5)监控事务执行情况
生产环境需监控长事务、死锁、事务失败率,可通过 MySQL 的information_schema表查询:
sql
-- 查询当前运行的事务
SELECT * FROM information_schema.INNODB_TRX;
-- 查询死锁日志
SHOW ENGINE INNODB STATUS;
七、总结与拓展
本文以 "部门 - 员工" 场景为核心,讲解了事务的 ACID 特性、隔离级别、基础语法,以及 JDBC/MyBatis/Spring 中的实战落地,核心要点总结:
-
事务的核心目标:保证一组操作的原子性,避免数据不一致,遵循 ACID 四大特性;
-
隔离级别选择 :默认用
REPEATABLE READ,根据业务场景平衡 "隔离性" 和 "并发性能"; -
实战建议:
- 原生 JDBC/MyBatis:手动控制
commit/rollback,关闭自动提交; - Spring 项目:优先使用
@Transactional声明式事务,简化开发;
- 原生 JDBC/MyBatis:手动控制
-
性能优化:缩小事务范围、避免长事务、监控死锁和长事务。
拓展方向:
- 分布式事务:跨多个数据库 / 服务的事务,可使用 Seata、TCC、SAGA 等方案;
- 事务日志:了解 Redo Log/Undo Log 的工作原理,理解事务持久性和回滚的底层实现;
- 锁机制:深入学习 InnoDB 的行锁、表锁、间隙锁,理解隔离级别与锁的关系。
事务是数据库保证数据一致性的核心机制,也是企业级开发中必须掌握的技能。掌握事务的使用与优化,不仅能避免数据错误,还能提升系统的并发性能和稳定性。