数据库事务深入原理
事务是数据库操作的基本单位,保证一组操作要么全成功、要么全失败。本文档从本地事务到分布式事务,详解事务的原理与实践。
一、本地事务
1.1 ACID 四大特性
| 特性 | 说明 | 实现原理 |
|---|---|---|
| Atomic(原子性) | 事务是最小执行单位,不可分割 | Undo Log(回滚日志) |
| Consistency(一致性) | 事务执行前后,数据库状态一致 | 其他三个特性保证 |
| Isolation(隔离性) | 并发执行时,事务之间互不影响 | 锁 + MVCC |
| Durability(持久性) | 事务提交后,数据永久保存 | Redo Log(重做日志) |
1.2 事务隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED(未提交读) | ❌ | ❌ | ❌ |
| READ COMMITTED(已提交读) | ✅ | ❌ | ❌ |
| REPEATABLE READ(可重复读) | ✅ | ✅ | ❌(InnoDB 通过 Next-Key Lock 解决) |
| SERIALIZABLE(串行化) | ✅ | ✅ | ✅ |
MySQL 默认隔离级别:REPEATABLE READ
1.3 MVCC 原理(多版本并发控制)
快照读 vs 当前读
快照读:读取历史版本(不加锁)
SELECT * FROM users -- 快照读
当前读:读取最新数据(加锁)
SELECT * FROM users FOR UPDATE -- 当前读
INSERT / UPDATE / DELETE -- 当前读
MVCC 三要素
隐藏列:
- DB_TRX_ID:最近修改的事务ID
- DB_ROLL_PTR:指向 Undo Log 的指针
- DB_ROW_ID:行ID(无主键时生成)
ReadView(读取视图):
- m_ids:活跃事务ID列表
- min_trx_id:最小活跃事务ID
- max_trx_id:创建 ReadView 时最大事务ID
- creator_trx_id:当前事务ID
版本链:
每行数据通过 DB_ROLL_PTR 串起 Undo Log 版本链
新事务修改 → 生成新版本 → 指向前一个版本
MVCC 查询逻辑
事务A开启 → 生成 ReadView
↓
SELECT:读取数据时
↓
检查数据行的事务ID(DB_TRX_ID)
↓
DB_TRX_ID < min_trx_id?
是 → 数据在事务A开启前已提交 → 可读取 ✅
否 → 进入下一步
↓
DB_TRX_ID 在 m_ids 中?
是 → 数据是并发事务修改 → 沿着版本链找 ✅
否 → 数据是已提交事务修改 → 可读取 ✅
可见性判断
-- 事务A(隔离级别 RR)开启
BEGIN;
SELECT * FROM users WHERE id = 1;
-- 读取到的是事务A开启时的快照
-- 事务B在事务A开启后修改了 id=1 的数据
UPDATE users SET name = 'new' WHERE id = 1;
COMMIT;
-- 事务A再次查询
SELECT * FROM users WHERE id = 1;
-- 仍然读到旧值(因为 ReadView 未变)
-- 实现了可重复读
1.4 快照读与当前读
-- 快照读(MVCC,不加锁)
SELECT * FROM orders WHERE status = 'pending';
-- 当前读(加锁)
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- 写入操作(自动当前读)
INSERT INTO orders ...
UPDATE orders ...
DELETE FROM orders ...
1.5 Next-Key Lock(临键锁)解决幻读
InnoDB 在 RR 级别下,使用 Next-Key Lock 锁定索引范围,防止幻读:
-- 假设 orders 表有 id=1,3,5 三条记录
-- 事务A执行:
BEGIN;
SELECT * FROM orders WHERE id > 1 AND id < 5 FOR UPDATE;
-- 锁定:(1,3] 和 (3,5] 两个区间
-- 同时锁定了 id=3 这条记录
-- 事务B尝试插入 id=4 的记录 → 被阻塞
-- 事务B尝试更新 id=3 的记录 → 被阻塞
锁的分类:
| 锁类型 | 作用范围 | 说明 |
|---|---|---|
| Record Lock | 单行 | 只锁一行 |
| Gap Lock | 区间 | 锁两个值之间的间隙 |
| Next-Key Lock | 区间+行 | Record Lock + Gap Lock |
| 意向锁 | 表级 | 快速判断是否有行锁 |
二、分布式事务
2.1 CAP 定理
分布式系统最多只能同时满足两个特性:
CAP 三角:
Consistency(一致性)
/ \
/ \
/ \
/ P(分区容错) \
/ \
/__________________\
A(可用性)
| 特性 | 说明 |
|---|---|
| C(一致性) | 所有节点同一时刻看到相同数据 |
| A(可用性) | 每个请求都能在有限时间内得到响应 |
| P(分区容错) | 网络分区时,系统仍能运行 |
为什么只能同时满足两个?
网络分区不可避免 → 必须满足 P → 要么保证 C(等待同步完成,牺牲 A) → 要么保证 A(继续服务,牺牲 C)
2.2 BASE 理论
对 CAP 的补充,实际分布式系统的妥协方案:
BASE:
- Basically Available(基本可用):允许暂时不一致
- Soft state(软状态):数据可以处于中间状态
- Eventually consistent(最终一致):经过一段时间后达到一致
2.3 两阶段提交(2PC)
流程
协调者 参与者A 参与者B
| | |
|---- prepare ---- -------->| |
| | |
|<--- prepare OK ----------| |
| | |
|------------------- prepare ------------------------>|
| | |
|<------------------ prepare OK ----------------------|
| |
|========= commit ============================|
| |
|<-------------- commit OK ----------------------|
| |
问题
| 问题 | 说明 |
|---|---|
| 同步阻塞 | 参与者 prepare 后一直等待,直到 commit/rollback |
| 单点故障 | 协调者挂了,参与者一直阻塞 |
| 数据不一致 | 部分参与者收到 commit,部分没收到 |
2.4 三阶段提交(3PC)
阶段1:CanCommit(询问阶段)
协调者问参与者:你们能提交吗?
参与者检查自己状态,返回 Yes/No
阶段2:PreCommit(预提交)
协调者收到所有 Yes → 发送 PreCommit
协调者收到任何 No → 发送 Abort
阶段3:DoCommit(真正提交)
收到 PreCommit 的参与者执行提交
改进:
- 引入超时机制,避免无限阻塞
- CanCommit 阶段参与者可以自由放弃
仍然存在的问题:网络分区时,仍可能出现数据不一致。
2.5 TCC(Try-Confirm-Cancel)
Try:预留资源(冻结)
检查库存、预留座位、锁定账户
Confirm:确认执行
真正扣库存、出票、扣钱
Cancel:取消回滚
释放预留的资源
// 订单服务 TCC 示例
@LocalTCC
public interface OrderTccService {
@Try(course = "tryOrder")
void tryOrder(@BusinessContext Context context, Long userId, Long productId);
@Confirm(course = "confirmOrder")
void confirmOrder(@BusinessContext Context context, Long orderId);
@Cancel(course = "cancelOrder")
void cancelOrder(@BusinessContext Context context, Long orderId);
}
TCC 问题
| 问题 | 说明 |
|---|---|
| 空回滚 | Try 没执行,Cancel 却执行了 |
| 幂等性 | Confirm/Cancel 可能重复执行 |
| 悬挂 | Cancel 先于 Confirm 执行 |
2.6 Saga 模式
长事务拆分为多个子事务,每个子事务有对应的补偿操作:
订单创建 → 扣库存 → 支付 → 发货
↓ ↓ ↓ ↓
取消订单 回退库存 退款 撤回发货
适用于:
- 补偿事务可以逆向操作
- 长时间跨度的业务流程
2.7 Seata 分布式事务
阿里开源的分布式事务解决方案,支持 AT、TCC、Saga、XA 模式。
AT 模式(自动模式,最常用)
全局事务开启
↓
各分支事务执行,生成前后置快照(undo_log)
↓
提交成功后,异步删除 undo_log
↓
回滚时,根据 undo_log 生成反向 SQL 回滚
Seata AT 与 2PC 的区别
| 对比 | 2PC | Seata AT |
|---|---|---|
| 协调者 | 数据库自己 | Seata Server |
| 性能 | 差(同步阻塞) | 好(异步执行) |
| 侵入性 | 低(数据库支持) | 低(框架自动处理) |
| 复杂度 | 高 | 低 |
三、事务实践
3.1 本地事务最佳实践
@Transactional(rollbackFor = Exception.class) // 必须指定 rollbackFor
public void createOrder(Long userId, List<Long> productIds) {
// 1. 创建订单
Order order = new Order();
order.setUserId(userId);
orderMapper.insert(order);
// 2. 扣库存(如果抛出异常,自动回滚)
for (Long productId : productIds) {
productMapper.decreaseStock(productId, 1);
}
}
常见问题:
// 问题1:rollbackFor 不指定
// 默认只回滚 RuntimeException 和 Error
@Transactional // ❌ 受检异常不会回滚
public void createOrder() throws IOException {
// 这个不会回滚
}
// 解决:
@Transactional(rollbackFor = Exception.class) // ✅
// 问题2:自调用导致事务失效
@Service
public class OrderService {
public void createOrder() {
this.doCreate(); // ❌ this 调用不走代理,事务不生效
}
@Transactional
public void doCreate() { ... }
}
// 解决:注入自身,或通过 AopContext.currentProxy()
3.2 分布式事务实战
Seata AT 模式配置
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
seata:
tx-service-group: my_tx_group
registry:
type: nacos
nacos:
server-addr: localhost:8848
config:
type: nacos
// 使用 @GlobalTransactional 开启全局事务
@GlobalTransactional(rollbackFor = Exception.class)
public void createOrder(Long userId, List<Long> productIds) {
// Seata 自动管理分支事务
orderService.create(userId);
productService.decreaseStock(productIds);
paymentService.pay(userId, amount);
}
RocketMQ 事务消息(最终一致性)
发送方:
1. 发送半消息(Half Message)到 MQ → 消息对消费者不可见
2. 执行本地事务
3. 提交 or 回滚半消息
MQ:
4. 提交 → 消费者可见
5. 回滚 → 丢弃
补偿机制:
6. 未收到确认 → MQ 回查发送方本地事务状态
7. 发送方根据本地事务结果回复
四、事务与锁的关系
4.1 更新丢失(Lost Update)
两个事务同时读取并更新,后者覆盖了前者的更新:
T1:读取 x=100
T2:读取 x=100
T1:x=100+50=150,写回
T2:x=100+80=180,写回
结果:x=180(丢失了 T1 的更新)❌
解决:悲观锁(SELECT ... FOR UPDATE)或乐观锁(版本号)。
4.2 死锁
T1:持有 A 锁,等待 B 锁
T2:持有 B 锁,等待 A 锁
→ 死锁
MySQL 死锁处理:
- InnoDB 自动检测死锁,回滚代价最小的事务
- 业务层重试
-- 死锁日志查看
SHOW ENGINE INNODB STATUS;
4.3 乐观锁实现
-- 版本号方式
UPDATE orders
SET status = 'completed', version = version + 1
WHERE id = ? AND version = ?
-- 如果 version 不匹配,说明被其他事务更新了,返回失败
五、隔离级别与现象
| 现象 | 说明 |
|---|---|
| 脏读 | 读取到其他事务未提交的数据 |
| 不可重复读 | 同一事务两次读取同一行,结果不同(其他事务提交了) |
| 幻读 | 同一事务两次查询,结果集不同(其他事务插入了新行) |
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | ❌ | ❌ | ❌ |
| READ COMMITTED | ✅ | ❌ | ❌ |
| REPEATABLE READ | ✅ | ✅ | ✅(InnoDB 解决了) |
| SERIALIZABLE | ✅ | ✅ | ✅ |