Skip to content

事务处理

  事务是数据库操作的最小逻辑单元——一组操作要么全部成功,要么全部失败。它是数据一致性的基石,也是理解分布式事务的前置知识。

一、什么是事务?

事务的概念:事务是一组共享数据上的操作序列,作为整体保持系统一致性;

例子:

银行转账

  张三给李四转 100 元:

  ① 张三账户余额 -100
  ② 李四账户余额 +100

  这两步必须同时成功或同时失败,不能出现:
    - 张三扣了钱,李四没收到(钱丢了)
    - 张三没扣钱,李四收到了(银行亏了)

1.1 MySQL 事务基础语法

sql
-- 显式事务(推荐方式)
START TRANSACTION;
-- 或
BEGIN;

-- 执行 SQL 操作
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;

-- 提交或回滚
COMMIT;    -- 确认,数据永久生效
ROLLBACK;  -- 撤销,回到事务开始前的状态
autocommit 模式:

  MySQL 默认 autocommit = ON,每条 SQL 语句自动作为一个事务提交

  -- 查看 autocommit 状态
  SELECT @@autocommit;  -- → 1(ON)

  -- 关闭自动提交(当前会话)
  SET autocommit = 0;

  -- 关闭后,每条 SQL 不会自动提交,必须手动 COMMIT 或 ROLLBACK
  UPDATE account SET balance = 1000 WHERE id = 1;
  -- 此时其他连接看不到修改,因为事务还没提交
  COMMIT;

  -- 显示开启事务(START TRANSACTION)会自动禁用 autocommit
  -- 事务结束后恢复 autocommit 设置
隐式提交:以下情况会自动提交当前事务(即使你没有写 COMMIT):

  ① 执行 DDL 语句(CREATE / ALTER / DROP / TRUNCATE TABLE)
  ② 隐式使用或修改 mysql 库中的表(如创建用户、授权)
  ③ 执行 LOCK TABLES / UNLOCK TABLES
  ④ 加载数据(LOAD DATA INFILE)
  ⑤ 开启一个新的事务(START TRANSACTION / BEGIN)会提交上一个
  ⑥ 从机执行 STOP SLAVE 等复制控制语句

  注意:在事务中穿插 DDL 语句要特别小心,它会隐式提交当前事务
sql
-- 事务的几种控制语句
START TRANSACTION;                     -- 开始事务
START TRANSACTION READ ONLY;           -- 只读事务(优化,不能执行写操作)
START TRANSACTION READ WRITE;          -- 读写事务(默认)

COMMIT;                                -- 提交
COMMIT AND CHAIN;                      -- 提交后立即开始一个新事务(省去 BEGIN)

ROLLBACK;                              -- 全部回滚
ROLLBACK AND CHAIN;                    -- 回滚后立即开始一个新事务

-- 事务特征设置(在 START TRANSACTION 时指定)
START TRANSACTION
    WITH CONSISTENT SNAPSHOT,          -- 开启时立即创建快照(等同 RR 行为)
    READ WRITE;

二、标准事务必须具备的四大核心特性(ACID 四大特性)

A(Atomicity,原子性):
  事务是一个不可分割的单位,要么全做,要么全不做
  实现:undo log,回滚时执行反向操作

C(Consistency,一致性):
  事务执行前后,数据库从一个一致状态到另一个一致状态
  例如:转账前后,总金额不变
  实现:由 AID 共同保证

I(Isolation,隔离性):
  多个事务并发执行时,互不干扰
  实现:锁 + MVCC

D(Durability,持久性):
  事务一旦提交,数据永久保存,即使系统崩溃也不丢失
  实现:redo log,崩溃恢复时重放
ACID 之间的依赖关系:

  A(原子性)──┐
  I(隔离性)──┼──→ C(一致性)←── D(持久性)

  一致性是目标,原子性、隔离性、持久性是手段

三、并发事务带来的问题

假设初始余额 = 1000 元

① 脏读(Dirty Read):
  事务 A 改了余额为 2000,还没提交
  事务 B 读到余额 = 2000
  事务 A 回滚,余额恢复为 1000
  → 事务 B 读到了不存在的 2000,脏数据

② 不可重复读(Non-Repeatable Read):
  事务 A 第一次读余额 = 1000
  事务 B 把余额改为 2000 并提交
  事务 A 第二次读余额 = 2000
  → 同一个事务内两次读结果不同

③ 幻读(Phantom Read):
  事务 A 查询"余额 > 500 的用户" → 10 条
  事务 B 插入了一条余额 = 800 的新用户并提交
  事务 A 再次查询"余额 > 500 的用户" → 11 条
  → 多出来一条"幽灵"数据
问题现象本质区别
脏读读到别人未提交的修改读到了"可能不存在"的数据
不可重复读同一行数据两次读结果不同别人 UPDATE 了同一行
幻读同样的条件两次查出的行数不同别人 INSERT/DELETE 了

四、四种隔离级别

隔离级别从低到高,性能从高到低:

  READ UNCOMMITTED(读未提交)  ← 最低,啥问题都不解决
  READ COMMITTED(读已提交)    ← 解决脏读
  REPEATABLE READ(可重复读)   ← 解决脏读 + 不可重复读(MySQL 默认)
  SERIALIZABLE(串行化)        ← 最高,全解决,但性能最差
隔离级别脏读不可重复读幻读实现方式
READ UNCOMMITTED无锁,直接读
READ COMMITTED每次读生成新 ReadView
REPEATABLE READ部分解决事务开始时生成 ReadView
SERIALIZABLE表锁 + 串行执行

4.1 Savepoint(保存点)

  Savepoint 允许在事务内部设置回滚标记点,部分回滚而不是全部撤销

sql
-- 基本语法
SAVEPOINT point_name;                    -- 创建保存点
ROLLBACK TO SAVEPOINT point_name;        -- 回滚到保存点(保存点之后的语句撤销,之前的保留)
RELEASE SAVEPOINT point_name;            -- 释放保存点(删除,不可再回滚到该点)
sql
-- 示例:转账业务中的部分回滚
START TRANSACTION;

-- ① 张三扣款
UPDATE account SET balance = balance - 100 WHERE id = 1;
SAVEPOINT after_deduct;  -- 扣款后打个标记

-- ② 李四加款
UPDATE account SET balance = balance + 100 WHERE id = 2;
-- 这里李四账户有问题,需要撤销加款,但张三的扣款保留
ROLLBACK TO SAVEPOINT after_deduct;  -- 回到扣款后,加款被撤销

-- ③ 尝试给王五加款
UPDATE account SET balance = balance + 100 WHERE id = 3;
COMMIT;  -- 最终:张三扣了 100,王五加了 100,李四不受影响
Savepoint 常见场景:

  场景 1:批量处理中逐条容错
    遍历 1000 条数据,每条处理前设 savepoint
    某条失败 → 回滚到 savepoint,继续处理下一条

  场景 2:分支逻辑回滚
    先执行 A 步骤,设置 savepoint
    尝试 B 步骤,失败 → 回滚到 savepoint,尝试 C 步骤

  场景 3:Spring NESTED 传播的底层实现
    @Transactional(propagation = Propagation.NESTED)
    内部调用的方法失败时,只回滚到 savepoint,不回滚整个事务
Spring 中使用 Savepoint:

  @Transactional
  public void processBatch() {
      // 主事务逻辑
      orderMapper.insert(order);  // ① 一定会保留

      try {
          // NESTED 传播:内部创建 savepoint
          nestedService.doSubTask();  // ② 失败不影响主事务
      } catch (Exception e) {
          log.warn("子任务失败,继续主流程", e);
      }

      paymentMapper.record(order);  // ③ 继续执行
  }

  @Service
  public class NestedService {
      @Transactional(propagation = Propagation.NESTED)
      public void doSubTask() {
          // 如果这里抛异常,只回滚到 savepoint
          // 主事务的 ① 和 ③ 不受影响
      }
  }

4.2 隔离级别的三种设置方式

  MySQL 提供了三种粒度的隔离级别设置,生效范围从大到小,优先级从低到高

三种设置级别:

  GLOBAL(全局级别)
    ├─ 生效范围:所有新建立的连接
    ├─ 已有连接:不受影响
    ├─ 重启后:失效(除非写入配置文件)
    └─ 优先级:最低(可被 SESSION 和 TRANSACTION 覆盖)

  SESSION(会话级别)
    ├─ 生效范围:当前连接中的所有后续事务
    ├─ 已有事务:不受影响
    ├─ 断开连接后:失效
    └─ 优先级:中等(可被 TRANSACTION 覆盖)

  TRANSACTION(事务级别)
    ├─ 生效范围:仅当前事务中的下一个语句
    ├─ 事务结束后:恢复为 SESSION 级别
    └─ 优先级:最高(覆盖 GLOBAL 和 SESSION)

① GLOBAL 级别

sql
-- 查看全局隔离级别
SELECT @@global.transaction_isolation;

-- 设置全局隔离级别(需要 SUPER 权限)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 验证
SELECT @@global.transaction_isolation;
-- → READ-COMMITTED

-- 已有的连接不会受影响,只有新连接才生效
-- 重启 MySQL 后失效,永久生效需要写入配置文件:
-- [mysqld]
-- transaction-isolation = READ-COMMITTED
GLOBAL 使用场景:

  场景:生产环境统一隔离级别
  例:公司规范要求所有服务使用 READ COMMITTED
  → SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
  → 所有新连接自动生效,已有连接下次重连后生效

② SESSION 级别

sql
-- 查看当前会话的隔离级别
SELECT @@transaction_isolation;
-- 或
SELECT @@session.transaction_isolation;

-- 设置当前会话的隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 这个连接中的所有后续事务都使用 REPEATABLE READ
-- 其他连接不受影响
-- 断开连接后恢复为全局默认级别
SESSION 使用场景:

  场景:临时调试或测试
  例:排查一个脏读问题,需要把当前会话降到 READ UNCOMMITTED
  → SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
  → 只在当前连接生效,不影响其他连接,关闭连接后自动恢复

③ TRANSACTION 级别

sql
-- 为下一个事务设置隔离级别(只影响下一个事务)
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- 开始事务
START TRANSACTION;
-- 或
BEGIN;

-- 这个事务使用 SERIALIZABLE 级别
-- 事务提交/回滚后,隔离级别恢复为 SESSION 级别
sql
-- 完整示例:混合使用不同级别
-- 当前会话默认是 REPEATABLE READ

SELECT @@transaction_isolation;  -- → REPEATABLE-READ

-- 事务 1:用 SERIALIZABLE
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT * FROM account WHERE id = 1;  -- 串行化读
COMMIT;

-- 事务 2:恢复为 REPEATABLE READ
START TRANSACTION;
SELECT * FROM account WHERE id = 1;  -- 可重复读
COMMIT;
TRANSACTION 使用场景:

  场景:特定业务需要更高隔离级别
  例:金融对账事务需要 SERIALIZABLE,但不想影响其他业务
  → SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  → START TRANSACTION;
  → 只影响这一个事务,结束后自动恢复

三种级别对比

维度GLOBALSESSIONTRANSACTION
语法SET GLOBAL TRANSACTION ...SET SESSION TRANSACTION ...SET TRANSACTION ...
生效范围所有新连接当前连接下一个事务
已有连接不影响不影响不影响
已有事务不影响不影响不影响
重启后失效失效失效
优先级最低中等最高
权限要求SUPER 或 SYSTEM_VARIABLES_ADMIN
典型场景全局规范调试排查特定业务隔离

实验验证

sql
-- 查看所有级别的隔离级别
SELECT @@global.transaction_isolation;     -- 全局
SELECT @@session.transaction_isolation;    -- 会话
SELECT @@transaction_isolation;            -- 当前生效(默认显示 session)
脏读实验(READ UNCOMMITTED):

  事务 A:                          事务 B:
  SET SESSION TRANSACTION
  ISOLATION LEVEL READ UNCOMMITTED;
  BEGIN;                            BEGIN;
  UPDATE account SET balance = 2000
  WHERE id = 1;
  (未提交)
                                     SELECT balance FROM account
                                     WHERE id = 1;
                                     → 读到 2000 ← 脏读!
  ROLLBACK;  ← 余额恢复为 1000
                                     COMMIT;
                                     → 事务 B 基于错误数据做了决策


不可重复读实验(READ COMMITTED):

  事务 A:                          事务 B:
  BEGIN;                            BEGIN;
  SELECT balance FROM account
  WHERE id = 1; → 1000
                                     UPDATE account SET balance = 2000
                                     WHERE id = 1;
                                     COMMIT;
  SELECT balance FROM account
  WHERE id = 1; → 2000 ← 不可重复读!
  COMMIT;

五、MVCC 原理

  MVCC(Multi-Version Concurrency Control,多版本并发控制)是 MySQL InnoDB 实现隔离性的核心机制。 它的核心思想:读不阻塞写,写不阻塞读,通过保存数据的多个版本来实现。

5.1 三个隐藏字段

InnoDB 每行数据都有三个隐藏字段:

  ┌──────────┬──────────┬──────────┬──────────────┬──────────────┐
  │  DB_TRX_ID  │ DB_ROLL_PTR │ DB_ROW_ID  │  col1 │  col2 │ ...  │
  └──────────┴──────────┴──────────┴──────────────┴──────────────┘
     ↑            ↑            ↑
     │            │            └── 行 ID(没有主键时自动生成)
     │            └── 回滚指针(指向 undo log 中的上一个版本)
     └── 事务 ID(最后一次修改这行的事务 ID)

5.2 ReadView(读视图)

ReadView 决定了当前事务能看到哪个版本的数据:

  活跃事务列表(m_ids):当前未提交的事务 ID 集合
  最小事务 ID(min_trx_id):m_ids 中的最小值
  最大事务 ID(max_trx_id):当前系统已分配的最大事务 ID + 1
  创建者 ID(creator_trx_id):当前事务自己的 ID

  版本可见性判断(对每个数据版本做判断):

  ① trx_id == creator_trx_id  → 可见(自己改的)
  ② trx_id < min_trx_id       → 可见(已提交的老数据)
  ③ trx_id >= max_trx_id      → 不可见(未来的事务)
  ④ trx_id 在 m_ids 中        → 不可见(未提交的事务)
  ⑤ trx_id 不在 m_ids 中      → 可见(已提交)

5.3 RC 与 RR 的区别

READ COMMITTED(每次读生成新 ReadView):

  事务 A:
    BEGIN;
    SELECT ① → 生成 ReadView 1 → 看到已提交的数据
    SELECT ② → 生成 ReadView 2 → 看到新提交的数据(可能不同)
    COMMIT;

  每次 SELECT 都生成新的 ReadView,所以能看到别人提交的修改
  → 这就是为什么 RC 下会有"不可重复读"


REPEATABLE READ(事务开始时生成 ReadView,整个事务不变):

  事务 A:
    BEGIN;
    SELECT ① → 生成 ReadView → 快照建立
    SELECT ② → 复用同一个 ReadView → 看到的数据和 ① 完全一样
    COMMIT;

  整个事务只用一个 ReadView,所以读到的数据始终一致
  → 这就是为什么 RR 下不会有"不可重复读"

5.4 RR 如何解决幻读

MVCC 的快照读(Snapshot Read)可以避免幻读:

  SELECT * FROM users WHERE balance > 500;
  → 使用 ReadView,看不到其他事务插入的新行

但当前读(Current Read)仍然可能幻读:

  SELECT * FROM users WHERE balance > 500 FOR UPDATE;
  → 加锁读取最新数据,能读到其他事务插入的行

MySQL RR 下的"间隙锁"解决了当前读的幻读:

  SELECT * FROM users WHERE balance > 500 FOR UPDATE;
  → 不仅锁住存在的行,还锁住行之间的间隙
  → 其他事务无法在间隙中插入新行
  → 幻读被彻底解决

六、Spring 事务管理

6.1 @Transactional 核心机制

@Transactional 工作原理(AOP 代理):

  调用 service.method()


  ┌─────────────────────────────────────┐
  │ 代理对象(CGLIB 动态代理)            │
  │                                      │
  │  TransactionInterceptor              │
  │    ↓                                 │
  │  ① 开启事务(从连接池获取连接,关闭自动提交)│
  │  ② 执行目标方法                        │
  │  ③ 提交事务 / 回滚事务                  │
  │  ④ 归还连接                           │
  └─────────────────────────────────────┘

6.2 核心属性

java
@Service
public class OrderService {

    @Transactional(
        propagation = Propagation.REQUIRED,      // 传播机制
        isolation = Isolation.REPEATABLE_READ,   // 隔离级别
        timeout = 30,                            // 超时时间(秒)
        readOnly = false,                        // 是否只读
        rollbackFor = Exception.class,           // 哪些异常回滚
        noRollbackFor = IllegalArgumentException.class  // 哪些异常不回滚
    )
    public void createOrder(Order order) {
        orderMapper.insert(order);
        inventoryMapper.deduct(order.getProductId(), order.getQuantity());
    }
}

6.3 七种传播机制

传播机制 = 当前方法被另一个事务方法调用时,事务如何处理

  REQUIRED(默认):
    有事务 → 加入;没事务 → 新建
    ████████████████████  ← 最常用,适合大多数场景

  REQUIRES_NEW:
    不管有没有事务,都新建一个独立事务,挂起当前事务
    ████████  ░░░░░░░░░░  ← 子事务独立提交/回滚,不影响父事务

  SUPPORTS:
    有事务 → 加入;没事务 → 非事务运行
    ████████████████████  或  ─ ─ ─ ─ ─ ─ ─ ─

  NOT_SUPPORTED:
    不管有没有事务,都非事务运行,挂起当前事务
    ─ ─ ─ ─ ─ ─ ─ ─ ─ ─

  MANDATORY:
    必须有事务,没有就抛异常
    ████████████████████

  NEVER:
    必须没有事务,有就抛异常
    ─ ─ ─ ─ ─ ─ ─ ─ ─ ─

  NESTED:
    有事务 → 创建嵌套事务(savepoint);没事务 → 和 REQUIRED 一样
    ████████████████████
        └─ savepoint
java
// REQUIRES_NEW 典型场景:记录日志,日志失败不影响主业务
@Transactional
public void createOrder(Order order) {
    orderMapper.insert(order);   // 主业务

    try {
        logService.recordLog(order);  // 日志记录
    } catch (Exception e) {
        // 日志失败不影响订单创建
    }
}

@Service
public class LogService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordLog(Order order) {
        logMapper.insert(order);  // 独立事务,提交/回滚不影响外部
    }
}

6.4 事务失效的 8 个场景

java
// ① 非 public 方法
@Transactional
void createOrder() { }  // ❌ AOP 代理只能拦截 public 方法

// ② 同类内部调用(this 调用)
public void outerMethod() {
    this.innerMethod();  // ❌ 绕过了代理,事务不生效
}
@Transactional
public void innerMethod() { }

// 解决:注入自己
@Autowired
private OrderService self;
public void outerMethod() {
    self.innerMethod();  // ✅ 通过代理调用
}

// ③ 异常被吞掉
@Transactional
public void createOrder() {
    try {
        orderMapper.insert(order);
        int i = 1 / 0;  // 抛出异常
    } catch (Exception e) {
        log.error("error", e);  // ❌ 异常被吞了,事务不回滚!
    }
}

// ④ 抛出的不是 RuntimeException
@Transactional
public void createOrder() throws Exception {
    throw new Exception("...");  // ❌ 默认只回滚 RuntimeException
}
// 解决:@Transactional(rollbackFor = Exception.class)

// ⑤ 数据库引擎不支持事务(MyISAM)
// ⑥ 多线程调用(子线程不在同一事务中)
// ⑦ 方法用 final 修饰(CGLIB 不能代理 final 方法)
// ⑧ @Transactional 放在了抽象类上(Spring 不代理抽象类)

6.5 事务最佳实践

java
// ✅ 1. 放在 Service 层,不要放在 Controller
@Service
public class OrderService {

    @Transactional
    public void createOrder(Order order) {
        // 业务逻辑集中在这里
    }
}

// ✅ 2. 明确指定 rollbackFor
@Transactional(rollbackFor = Exception.class)
public void createOrder(Order order) { }

// ✅ 3. 事务方法尽量短,只包裹必要的数据库操作
@Transactional
public void createOrder(Order order) {
    orderMapper.insert(order);     // 数据库操作
    // 不要把下面的放进来:
    // sendEmail(order);           // 外部 API 调用
    // generatePDF(order);         // 文件操作
    // Thread.sleep(5000);         // 阻塞操作
}

// ✅ 4. 使用编程式事务(事务模板),更灵活
@Autowired
private TransactionTemplate transactionTemplate;

public void createOrder(Order order) {
    transactionTemplate.execute(status -> {
        orderMapper.insert(order);
        inventoryMapper.deduct(order.getProductId(), order.getQuantity());
        return null;
    });
}

七、分布式事务入口

单机事务 → 分布式事务的演进:

  单机事务(本章):
    一个数据库 → @Transactional 搞定
    保证:ACID(原子性、一致性、隔离性、持久性)

  分布式事务(下一章):
    多个数据库、多个服务 → 需要协调多个参与者
    代价:放弃强一致性,追求最终一致性(BASE 理论)

  详见 [分布式事务与实现](../../microservices-architecture/09-微服务解决方案/分布式事务与实现)

八、面试要点

Q1:MySQL 默认隔离级别是什么?为什么?

  REPEATABLE READ(可重复读)。原因:1) 避免不可重复读;2) 通过间隙锁解决幻读;3) 比 SERIALIZABLE 性能好很多。Oracle 默认是 READ COMMITTED。

Q2:MVCC 的实现原理?

  核心是隐藏字段(DB_TRX_ID + DB_ROLL_PTR)+ undo log + ReadView。通过 ReadView 的可见性判断,让不同事务看到不同版本的数据,实现读不阻塞写、写不阻塞读。

Q3:@Transactional 失效的原因有哪些?

  非 public 方法、同类 this 调用绕过了代理、异常被 catch 吞掉、抛出 checked 异常未指定 rollbackFor、数据库引擎不支持事务、final 方法不能被 CGLIB 代理。

Q4:REQUIRED 和 REQUIRES_NEW 的区别?

  REQUIRED 加入已有事务,一起提交回滚;REQUIRES_NEW 挂起当前事务,创建独立事务,提交回滚互不影响。典型场景:日志记录失败不影响主业务。

Q5:RR 隔离级别下,幻读是怎么解决的?

  快照读(普通 SELECT)通过 MVCC 的 ReadView 避免幻读。当前读(SELECT FOR UPDATE)通过间隙锁(Gap Lock)锁住行之间的间隙,阻止其他事务插入。