数据库事务机制深入详解

Published on
4 29.3~37.7 min

本文由我和AI共同完成。

一、事务的本质与必要性

1.1 事务的基本概念

事务是数据库管理系统执行过程中的一个逻辑单位,由一系列操作组成,这些操作要么全部成功执行,要么全部不执行,不会出现中间状态。

1.2 为什么需要事务?

以银行转账为例:

-- 操作1:A账户扣款
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
​
-- 操作2:B账户收款  
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';

问题场景

  • 如果操作1成功,操作2失败 → A损失100元,系统不一致

  • 如果操作1失败,操作2成功 → B获得100元,系统不一致

  • 如果数据库在执行过程中崩溃 → 可能只执行了部分操作

事务的价值:将多个操作封装成一个原子操作,保证数据的一致性。

二、ACID特性的深度解析

2.1 原子性(Atomicity)的实现机制

核心:UNDO LOG(回滚日志)

实现流程

事务开始
    ↓
执行SQL1 → 将修改前的数据存入UNDO LOG
    ↓
执行SQL2 → 将修改前的数据存入UNDO LOG
    ↓
事务提交/回滚决策点
    ├── 提交成功 → 清除UNDO LOG
    └── 回滚 → 按逆序从UNDO LOG恢复数据

UNDO LOG关键特性

  • 存储在表空间中,与数据文件分离

  • 采用链式结构存储,支持多版本

  • 不仅用于回滚,还支持MVCC的快照读

2.2 持久性(Durability)的实现机制

核心:REDO LOG(重做日志) + WAL(Write-Ahead Logging)策略

WAL流程

修改数据页
    ↓
写入REDO LOG Buffer(内存)
    ↓
REDO LOG刷盘(fsync,确保持久化)
    ↓
修改Buffer Pool中的数据页(内存)
    ↓
Checkpoint时脏页刷盘(异步)

REDO LOG特点

  • 物理日志:记录"在某个数据页的某个偏移量做了什么修改"

  • 顺序写入:相比随机写数据文件,性能更高

  • 循环写入:文件大小固定,循环覆盖

崩溃恢复流程

  1. 找到最近一次Checkpoint位置

  2. 从Checkpoint开始重放REDO LOG

  3. 回滚所有未提交事务(使用UNDO LOG)

2.3 隔离性(Isolation)的完整实现体系

隔离性实现 = MVCC(多版本并发控制) 
            + 锁机制(Locking) 
            + UNDO LOG(版本链)

2.4 一致性(Consistency)的保障

一致性是事务的最终目标,由三方面保障:

  1. 数据库层面:ACID中的A、I、D共同保障

  2. 应用层面:业务逻辑的正确性

  3. 约束层面:主键、外键、唯一键、CHECK约束

三、并发事务问题深度分析

3.1 脏读(Dirty Read)

定义:事务A读取了事务B未提交的数据

示例

-- 事务A
BEGIN;
SELECT balance FROM accounts WHERE id = 'A'; -- 假设返回1000
​
-- 事务B
BEGIN;
UPDATE accounts SET balance = 2000 WHERE id = 'A'; -- 未提交
​
-- 事务A再次读取(读未提交隔离级别下)
SELECT balance FROM accounts WHERE id = 'A'; -- 返回2000(脏数据)

危害:如果事务B回滚,事务A基于错误数据做了决策

3.2 不可重复读(Non-Repeatable Read)

定义:同一事务内,多次读取同一数据,结果不一致

与脏读的区别

  • 脏读:读到了未提交的数据

  • 不可重复读:读到了已提交的数据,但两次读取之间被其他事务修改了

3.3 幻读(Phantom Read)

定义:同一事务内,两次范围查询返回的行数不同

详细示例

-- 事务A
BEGIN;
SELECT * FROM products WHERE price > 100; -- 返回3条记录
​
-- 事务B
BEGIN;
INSERT INTO products(name, price) VALUES ('New Product', 150);
COMMIT;
​
-- 事务A再次查询
SELECT * FROM products WHERE price > 100; -- 返回4条记录(出现幻行)

幻读与不可重复读的本质区别

  • 不可重复读:针对已存在行被修改

  • 幻读:新行被插入或已存在行被删除导致的行数变化

四、事务隔离级别实现原理

4.1 READ UNCOMMITTED(读未提交)

实现特点

  • 读操作:不加锁,直接读最新版本

  • 写操作:需要加锁,防止多个事务同时修改同一数据

性能与问题

  • 性能最高(几乎无锁竞争)

  • 存在脏读、不可重复读、幻读

4.2 READ COMMITTED(读已提交)

MVCC实现机制

每次SELECT操作时:
    1. 生成新的ReadView
    2. 读取每个数据行时:
        - 遍历版本链
        - 找到trx_id小于当前ReadView min_trx_id的版本
        - 或找到trx_id不在活跃事务列表中的已提交版本

示例时序

时间轴:T1-----T2-----T3-----T4-----T5
事务A: 开始 -------- 查询1 -------- 查询2
事务B:       开始---更新---提交
结果:
- 查询1:读不到B的修改
- 查询2:读到B已提交的修改(不可重复读)

4.3 REPEATABLE READ(可重复读)

MVCC实现机制

第一次SELECT操作时:
    1. 生成ReadView并保存
后续所有SELECT操作:
    1. 复用同一个ReadView
    2. 因此看到的都是第一次查询时的数据快照

快照读示例

-- 事务A
START TRANSACTION; -- trx_id = 10
​
-- 第一次查询,创建ReadView
-- m_ids = [10, 20], min_trx_id=10, max_trx_id=30
SELECT * FROM users; -- 基于此快照
​
-- 事务B(trx_id=20)插入并提交新用户
​
-- 事务A第二次查询
SELECT * FROM users; -- 仍看到第一次的快照,避免幻读

4.4 SERIALIZABLE(可串行化)

实现机制

  • 所有SELECT自动转为SELECT ... LOCK IN SHARE MODE

  • 使用范围锁(Next-Key Locking)锁定整个查询范围

  • 完全串行化执行,性能最差但一致性最强

五、MVCC机制深度剖析

5.1 数据行的隐藏字段

InnoDB中每行数据实际包含:

  • DB_ROW_ID:行ID(6字节)

  • DB_TRX_ID:最近修改的事务ID(6字节)

  • DB_ROLL_PTR:回滚指针,指向UNDO LOG(7字节)

  • DELETE_BIT:删除标记(1字节)

5.2 ReadView的生成算法

ReadView四个关键字段

  1. creator_trx_id:创建此ReadView的事务ID

  2. m_ids[]:创建时活跃事务ID列表

  3. min_trx_id:m_ids中的最小值

  4. max_trx_id:下一个将分配的事务ID

可见性判断算法

对于数据行的每个版本(从最新到最旧遍历):
    if trx_id < min_trx_id:
        该版本可见(创建ReadView时已提交)
    else if trx_id >= max_trx_id:
        该版本不可见(创建ReadView后开始的事务)
    else if trx_id in m_ids:
        该版本不可见(创建ReadView时活跃未提交)
    else:
        该版本可见(创建ReadView时已提交)

5.3 版本链遍历示例

数据行X的版本链:
最新版本:trx_id=30, value=300, roll_ptr → 版本2
版本2:   trx_id=25, value=200, roll_ptr → 版本1  
版本1:   trx_id=15, value=100, roll_ptr → NULL
​
当前事务ReadView:
creator_trx_id=20, m_ids=[20,30], min_trx_id=20, max_trx_id=35
​
遍历过程:
1. 版本30: trx_id=30 in m_ids? → 是,不可见,继续
2. 版本25: 20≤25<35且25∉[20,30] → 可见,返回value=200

六、锁机制与MVCC的协同

6.1 InnoDB的锁类型

记录锁(Record Lock)

  • 锁定索引中的单条记录

  • 防止其他事务修改或删除该记录

间隙锁(Gap Lock)

  • 锁定索引记录之间的间隙

  • 防止其他事务在间隙中插入新记录

  • 只存在于RR隔离级别

临键锁(Next-Key Lock)

  • 记录锁 + 间隙锁的组合

  • 锁定记录及记录之前的间隙

  • 解决幻读问题的关键

6.2 当前读的加锁机制

当前读操作

  • SELECT ... FOR UPDATE

  • SELECT ... LOCK IN SHARE MODE

  • UPDATEDELETEINSERT

加锁流程示例

-- 表结构:id主键,age索引
-- 现有数据:id=1(age=10), id=3(age=20), id=5(age=30)
​
-- 事务A
BEGIN;
SELECT * FROM users WHERE age = 20 FOR UPDATE;
​
-- 加锁过程:
-- 1. 找到age=20的记录(id=3),加记录锁
-- 2. 锁定(10,20)的间隙
-- 3. 锁定(20,30)的间隙
-- 其他事务不能在(10,30)范围内插入age值

6.3 MVCC与锁的配合模式

场景分析:避免幻读的完整方案

-- 事务A(RR级别)
BEGIN;
​
-- 快照读:使用MVCC避免幻读
SELECT * FROM users WHERE age > 25; -- 返回id=5
​
-- 事务B插入age=28的新记录并提交
​
-- 事务A再次快照读
SELECT * FROM users WHERE age > 25; -- 仍只返回id=5(MVCC避免幻读)
​
-- 事务A想要插入age=28的记录
INSERT INTO users(age) VALUES(28); -- 当前读,需要加锁检查
​
-- 加锁过程:
-- 1. 当前读发现已存在age=28的记录(事务B插入)
-- 2. 等待事务B释放锁或报主键冲突

七、Next-Key Locking机制详解

7.1 锁定范围计算规则

索引结构示例

表t:id主键,a索引
数据:id=1(a=10), id=3(a=20), id=5(a=30), id=7(a=40)

查询与锁定

-- 查询1:等值查询
SELECT * FROM t WHERE a = 20 FOR UPDATE;
-- 锁定范围:(10,20] + (20,30)
-- 即:不允许插入a在(10,30)范围内的记录
​
-- 查询2:范围查询  
SELECT * FROM t WHERE a BETWEEN 15 AND 35 FOR UPDATE;
-- 锁定范围:(10,20] + (20,30] + (30,40]
-- 即:不允许插入a在(10,40)范围内的记录

7.2 锁降级机制

唯一索引等值查询的特殊优化

-- 假设id是唯一索引
SELECT * FROM t WHERE id = 5 FOR UPDATE;
-- 实际只加记录锁,不加间隙锁
-- 因为唯一索引可以保证不会有其他id=5的记录

八、实践中的事务设计建议

8.1 隔离级别选择策略

推荐方案

  1. 默认使用RR:MySQL默认,平衡性能与一致性

  2. 只读查询使用RC:若业务允许不可重复读,性能更优

  3. 财务系统使用Serializable:对一致性要求极高的场景

  4. 避免使用RU:除非完全了解风险

8.2 事务设计最佳实践

事务粒度的控制

// 反例:事务过大
@Transactional
public void processOrder(Order order) {
    // 1. 验证库存(IO操作)
    // 2. 复杂业务计算(CPU密集)
    // 3. 调用外部支付(网络IO)
    // 4. 更新库存(IO操作)
    // 问题:锁持有时间过长
}
​
// 正例:拆分事务
public void processOrderOptimized(Order order) {
    // 阶段1:验证并锁定库存(短事务)
    boolean locked = inventoryService.lockStock(order);
    
    // 阶段2:异步处理复杂逻辑
    businessService.asyncProcess(order);
    
    // 阶段3:支付(独立事务)
    paymentService.pay(order);
    
    // 阶段4:更新状态(短事务)
    orderService.completeOrder(order);
}

避免长事务的危害

  1. 锁竞争加剧,性能下降

  2. UNDO LOG积累,可能打满磁盘

  3. 主从延迟增加

  4. 连接占用时间过长

8.3 死锁预防与处理

常见的死锁场景

-- 事务A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
​
-- 事务B(并发执行)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
-- 可能产生死锁:A锁1等2,B锁2等1

预防策略

  1. 统一访问顺序:总是按id升序访问记录

  2. 减小事务粒度:尽快提交释放锁

  3. 使用乐观锁:版本号或时间戳机制

  4. 设置锁超时innodb_lock_wait_timeout

九、高级话题:分布式事务

9.1 XA事务(两阶段提交)

阶段一:准备阶段

协调者:向所有参与者发送prepare请求
参与者:执行事务,写redo/undo log,返回"YES/NO"

阶段二:提交/回滚阶段

协调者:
    if 所有参与者都返回YES:
        发送commit请求
    else:
        发送rollback请求
参与者:
    根据协调者指令执行最终操作

缺点

  • 同步阻塞:所有参与者需等待

  • 单点故障:协调者宕机导致阻塞

  • 数据不一致:网络分区问题

9.2 柔性事务模式

Saga模式

订单创建 → 扣减库存 → 扣减余额 → 生成物流单
    ↓          ↓          ↓           ↓
成功/失败   成功/失败   成功/失败    成功/失败
    ↓          ↓          ↓           ↓
正向流程     正向流程    正向流程     正向流程
    |          |          |           |
    └────── 补偿流程 ──────┘           |
            (反向操作)                |
                 └────── 补偿流程 ────┘

TCC模式

  • Try:资源预留(冻结库存、预扣金额)

  • Confirm:确认执行(实际扣减)

  • Cancel:取消预留(释放冻结)

十、监控与调优

10.1 关键监控指标

InnoDB状态监控

-- 查看锁信息
SHOW ENGINE INNODB STATUS;
​
-- 查看事务信息
SELECT * FROM information_schema.INNODB_TRX;
​
-- 查看锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
​
-- 查看当前锁
SELECT * FROM information_schema.INNODB_LOCKS;

性能关键指标

  1. 锁等待时间innodb_row_lock_time_avg

  2. 死锁频率innodb_deadlocks

  3. 事务提交率com_commit / com_rollback

  4. UNDO LOG大小:监控增长趋势

10.2 常见问题排查

问题1:事务超时

-- 设置会话级锁等待超时
SET SESSION innodb_lock_wait_timeout = 30;
​
-- 查询当前被阻塞的事务
SELECT 
    r.trx_id waiting_trx_id,
    r.trx_mysql_thread_id waiting_thread,
    r.trx_query waiting_query,
    b.trx_id blocking_trx_id,
    b.trx_mysql_thread_id blocking_thread,
    b.trx_query blocking_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;

问题2:长事务排查

-- 查找运行时间超过60秒的事务
SELECT 
    trx_id,
    trx_started,
    TIMEDIFF(NOW(), trx_started) duration,
    trx_state,
    trx_query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
ORDER BY trx_started;



0