深入理解分布式事务与锁:从隔离级别到传播行为
文章结构
本文分为七个部分,从实战问题出发,逐步深入理论:
- 事务与分布式锁的联合使用 --- 常见场景和坑点
- 事务隔离级别深度解析 --- 为什么不用 SERIALIZABLE
- Spring @Transactional 的默认配置 --- 隔离级别和数据库的关系
- 事务传播行为与父子事务 --- 7 种传播行为详解
- 子事务提交与父事务的关系 --- 事务栈的真实原理
- 实战案例 --- 3 个真实场景的代码
- 常见问题 FAQ --- 最容易踩的坑
前言
在一个业务中, 对多库中的数据写入存在一致性问题, 涉及到 Milvus/MySQL 多个数据库之间记录的场景。 本文旨在深入探讨分布式事务与锁在实际业务中的应用和原理,帮助解决跨库数据一致性问题。
在高并发的分布式系统中,数据一致性问题总是绕不过去的。很多开发者会问:"为什么不直接用 SERIALIZABLE 隔离级别?" 或者 "子事务提交了,父事务会不会也提交?" 这些问题看似简单,但背后涉及的原理很多人都没搞清楚。
本文基于多年的实战经验,系统地讲解分布式事务和锁的核心概念。希望能帮你理解这些问题的本质,而不仅仅是知道怎么用。
一、事务与分布式锁的联合使用
1.1 为什么要联合使用?
在分布式系统中,单纯依靠数据库事务是不够的。分布式锁和事务的结合主要是为了解决三个问题:
第一,防止竞态条件。在应用层用锁挡住并发请求,避免多个线程同时进入临界区。
第二,保护数据库。分布式锁可以减少数据库的锁竞争,避免大量的行锁等待和死锁。
第三,处理复杂业务。当业务逻辑涉及外部 API 调用时,数据库事务无法保护这部分,需要在应用层用锁来保证幂等性。
1.2 常见应用场景
场景 A:防止重复提交(幂等性保证)
这是最常见的场景。用户点击两次按钮,或者网络重试导致同一个请求被处理两次。
处理流程是这样的:
- 先获取分布式锁(Key 通常是用户 ID + 业务 ID)
- 开启数据库事务
- 查询数据库,检查这个请求是否已经被处理过
- 如果没处理过,执行业务逻辑(插入或更新数据)
- 提交事务
- 释放锁
这个模式在支付、领取优惠券、创建订单等场景中很常见。
场景 B:库存扣减 / 防止超卖(秒杀场景)
高性能秒杀通常在 Redis 中直接扣减库存,但如果系统规模不大,或者对数据一致性要求很高,还是需要操作数据库。
问题在于,如果只用数据库事务,虽然逻辑上没问题,但在高并发下会导致大量的行锁等待、死锁甚至超时。数据库连接池很快就会被占满。
更好的方案是在应用层加分布式锁,把并发请求串行化。这样数据库的压力就会大大降低,同时保证了库存的准确性。
场景 C:分布式定时任务调度
在集群环境下,多个节点都会执行相同的定时任务代码。比如每天凌晨 2 点生成日结报表。
如果不加控制,这个任务会在所有节点上都执行一遍,导致数据重复或混乱。
解决方案是用分布式锁。任务触发时,所有节点都去抢这把锁,只有抢到的节点才执行任务,其他节点直接跳过。
场景 D:复杂的长业务流程
有时候业务逻辑很复杂,需要先调外部 API 查询状态,然后根据结果更新本地数据库。
这种情况下,分布式锁的作用是保证整个流程的原子性。从锁定业务实体开始,到调 API、更新数据库、最后释放锁,中间任何一个步骤出问题都不会有其他线程来干扰。
1.3 核心坑:锁释放与事务提交的顺序问题
这是联合使用中最容易犯的严重错误。
锁在事务内部的问题
java
@Transactional // 事务切面在外层
public void doBusiness() {
lock.lock(); // 1. 加锁
try {
// 2. 读写数据库
// 3. 业务逻辑
} finally {
lock.unlock(); // 4. 释放锁
}
} // 5. 事务提交 (Spring AOP 在方法结束时提交)
问题分析:
- 线程 A 在第 4 步释放了锁,但事务在第 5 步才提交
- 在第 4 步和第 5 步之间的微小时间窗口内,线程 B 获取了锁,读取数据
- 由于线程 A 的事务还没提交(根据数据库隔离级别,通常是 Read Committed),线程 B 读到的是旧数据
- 后果: 超卖、重复数据
正确的写法(锁包裹事务)
必须保证:先提交事务,再释放锁。
java
public void handleRequest() {
lock.lock(); // 1. 加锁
try {
// 调用独立的事务方法(注意 Spring 的 self-invocation 问题)
service.doBusinessInTransaction();
// 2. 事务方法执行完毕并已提交
} finally {
lock.unlock(); // 3. 释放锁
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void doBusinessInTransaction() {
// 读写数据库
}
时间线:
1. 加锁
2. 开启事务
3. 读写数据库
4. 提交事务(数据已写入数据库)
5. 释放锁
6. 其他线程才能获取锁
1.4 替代方案对比
在决定联合使用前,先思考是否可以用更简单的方案:
悲观锁 (Select ... For Update)
sql
SELECT * FROM table WHERE id = 1 FOR UPDATE;
优点: 如果并发量不大,直接利用数据库行锁最简单可靠
缺点: 数据库压力大,长事务会拖垮连接池
适用场景: 并发量低,逻辑简单
乐观锁 (Version 字段)
sql
UPDATE table SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = old_version;
优点: 无需引入 Redis/Zookeeper 分布式锁,性能好
缺点: 竞争激烈时失败率高,需要重试机制
适用场景: 中等并发,能接受失败重试
分布式锁 + 事务
优点: 在应用层挡住流量,保护数据库,能处理复杂业务逻辑
缺点: 引入外部依赖(Redis/Zookeeper),代码复杂度高,需要处理锁超时、释放等问题
适用场景: 高并发,业务逻辑复杂
二、事务隔离级别深度解析
2.1 四个隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 并发度 | 数据库默认 |
|---|---|---|---|---|---|
| Read Uncommitted | ✅ | ✅ | ✅ | 最高 | 无 |
| Read Committed | ❌ | ✅ | ✅ | 高 | Oracle、PostgreSQL、SQL Server |
| Repeatable Read | ❌ | ❌ | ✅ | 中 | MySQL (InnoDB) |
| Serializable | ❌ | ❌ | ❌ | 最低 | 无 |
2.2 直接使用 SERIALIZABLE 的陷阱
很多开发者会问:"为什么不直接用 SERIALIZABLE 隔离级别?" 理论上可行,但在实际的高并发工程实践中,极少直接使用,原因如下:
陷阱 1:性能崩塌(并发度极低)
原理: SERIALIZABLE 是数据库隔离级别的最高级。在 MySQL InnoDB 中,它会隐式地将所有普通 SELECT 转化为 SELECT ... FOR SHARE(共享锁),并极其激进地使用**间隙锁(Gap Locks)和临键锁(Next-Key Locks)**来防止幻读。
后果: 它会将并行的事务强制变成串行执行。原本 1000 QPS 的系统,开启后可能瞬间跌到 50 QPS。数据库连接池会被大量阻塞的线程占满,导致系统假死。
陷阱 2:死锁地狱 (Deadlock)
现象: 在 SERIALIZABLE 级别下,死锁的概率呈指数级上升。
场景:
事务 A 读取了范围 X,持有了共享锁
事务 B 也读取了范围 X,持有了共享锁
事务 A 想要更新 X 中的某条数据,需要升级为排他锁,但被 B 的共享锁卡住
事务 B 也想更新,也被 A 卡住
→ 死锁
结果: 数据库频繁报错回滚,业务端会收到大量的 Deadlock found when trying to get lock 异常,导致用户体验极差,需要编写大量的重试逻辑。
陷阱 3:无法解决"分布式"逻辑问题
数据库事务只能保证当前数据库实例内的一致性。分布式锁通常用于解决比单纯"写库"更宽泛的业务逻辑。
场景: 你的业务逻辑是:锁 -> 调第三方支付接口 -> 更新本地库 -> 发短信
问题: 如果只用数据库事务(哪怕是 Serial),你无法阻止两个线程同时去调用"第三方支付接口"。因为调用外部接口通常是在 DB 事务提交之前或之外进行的(如果在事务内调 HTTP 会导致长事务,是大忌)。
分布式锁的作用: 它可以在应用层入口就挡住并发,保证连 HTTP 请求都不会重复发出。
陷阱 4:"快速失败" vs "阻塞等待"
分布式锁(Redis):
java
if (lock.tryLock(timeout)) {
// 获取到锁,执行业务
} else {
// 立刻返回给用户"系统繁忙,请稍后再试"
// 不消耗数据库连接
}
Serial 事务:
请求一旦打到数据库,就会 hang 住(阻塞)等待锁释放
直到超时或获得锁
这会迅速耗尽 Web 服务器的线程池和数据库连接池
导致整个服务不可用(雪崩效应)
2.3 什么时候可以用 SERIALIZABLE?
只有在以下极其特殊 的场景下,才考虑使用 SERIALIZABLE:
- 并发极低: 例如仅供内部运营人员使用的后台管理系统,几分钟才有一个请求
- 数据绝对核心且逻辑简单: 比如银行核心账务的某些极小范围的转账表,且不能容忍任何幻读
- 没有外部依赖: 业务逻辑纯粹是数据库内部的计算
2.4 幻读详解
SERIALIZABLE 最主要的存在意义就是消灭幻读。
什么是幻读?
事务 A 查询:"给我所有工资大于 1万 的员工列表"
结果:查出来 10 个人
事务 B 插入:"新增一个员工,工资 2万"
事务 B 提交
事务 A 再查一次同样的条件
结果:看到 11 个人(或者在执行 update 时突然发现多了一行)
这就是幻读。
Serializable 怎么解决?
当事务 A 查询"工资 > 1万"时,Serializable 不仅仅锁住这 10 行数据,它还会锁住**"工资大于 1万 的所有空间(间隙)"**。此时事务 B 想插入数据,会被直接阻塞,直到 A 提交。
三、Spring @Transactional 的默认配置
3.1 默认隔离级别
Spring 中 @Transactional 的默认隔离级别是 Isolation.DEFAULT。
这是一个特殊的中间值(对应整数 -1),它的含义是:直接使用底层数据库配置的默认隔离级别。Spring 自己不强行指定,而是"入乡随俗"。
因此,实际生效的级别取决于你使用的数据库:
| 数据库类型 | 默认隔离级别 | 说明 |
|---|---|---|
| MySQL (InnoDB) | REPEATABLE READ (可重复读) | 可能会有幻读(但 InnoDB 通过 MVCC 和间隙锁解决了大部分幻读)。 |
| Oracle | READ COMMITTED (读已提交) | 只能读到别的事务已提交的数据。 |
| PostgreSQL | READ COMMITTED (读已提交) | 同上。 |
| SQL Server | READ COMMITTED (读已提交) | 同上。 |
3.2 为什么要注意这一点?
因为不同的默认级别会导致代码在不同数据库上表现不一致。
场景: 你在一个事务里先读取数据 A,处理一会,再次读取数据 A。
- 如果用 MySQL: 两次读取结果通常是一样的(因为是 Repeatable Read)
- 如果用 Oracle: 第二次读取可能会变(如果期间有别的事务修改并提交了 A),这叫"不可重复读"
3.3 如何修改隔离级别?
如果你需要强制指定(比如为了统一行为或性能优化),可以在注解中显式设置:
java
// 强制使用读已提交(互联网大厂常用配置,并发度更高)
@Transactional(isolation = Isolation.READ_COMMITTED)
public void doSomething() {
// ...
}
// 强制使用可重复读
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void doSomething() {
// ...
}
// 强制使用串行化(不推荐)
@Transactional(isolation = Isolation.SERIALIZABLE)
public void doSomething() {
// ...
}
四、事务传播行为与父子事务
4.1 什么是事务传播行为?
当一个事务方法调用另一个事务方法时,Spring 需要做个决定:子方法是加入父事务,还是创建新事务?这就是传播行为(Propagation)。
这个决定很重要,因为它直接影响异常处理和数据一致性。
4.2 最常见的场景:PROPAGATION_REQUIRED(默认)
java
@Transactional(propagation = Propagation.REQUIRED)
public void parentMethod() {
// 父事务开始
insert_A();
childMethod(); // 调用子方法
insert_C();
// 父事务提交
}
@Transactional(propagation = Propagation.REQUIRED)
public void childMethod() {
// 子方法
insert_B();
}
结果: 子事务不会真正提交。
原理很简单: 当父方法开启事务后,子方法检测到已经有事务了,就直接加入这个事务,而不是创建新事务。子方法执行完后,Spring 会调用 commit(),但这只是个虚拟提交------实际上就是减少事务计数器。只有当最外层的父事务执行完毕,才会真正提交到数据库。
最关键的一点: 如果子方法抛出异常,整个父事务都会被标记为 rollback-only,最终整体回滚。这包括父方法之前执行的所有操作。
java
@Transactional
public void parent() {
insert_A(); // 插入 A
try {
child(); // 子方法抛异常
} catch (Exception e) {
// 捕获异常
}
}
@Transactional
public void child() {
insert_B(); // 插入 B
throw new RuntimeException("出错了"); // 异常
}
// 结果:A 和 B 都被回滚了!即使你捕获了异常。
// 这是很多人踩过的坑。
4.3 创建新事务:PROPAGATION_REQUIRES_NEW
java
@Transactional(propagation = Propagation.REQUIRED)
public void parentMethod() {
insert_A();
childMethod(); // 调用子方法
insert_C();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void childMethod() {
insert_B();
}
结果: 子事务会立即提交。
原理: 父方法有事务,但子方法指定了 REQUIRES_NEW,所以 Spring 会暂停父事务,创建一个全新的独立事务来执行子方法。子方法执行完毕后,新事务立刻提交到数据库。然后父事务恢复,继续执行。
时间线:
1. 父事务开始
2. insert_A()
3. [暂停父事务]
4. 子事务开始
5. insert_B()
6. 子事务提交 --> (B 已经写入数据库)
7. [恢复父事务]
8. insert_C()
9. 父事务提交 --> (A 和 C 写入数据库)
关键点: 如果子方法抛异常,只有子事务回滚,父事务不受影响。这是 REQUIRES_NEW 最大的优势。
java
@Transactional
public void parent() {
insert_A(); // 提交
try {
child(); // 子方法抛异常
} catch (Exception e) {
// 捕获异常
}
insert_C(); // 提交
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void child() {
insert_B(); // 独立提交,回滚(只有 B 回滚)
throw new RuntimeException("出错了");
}
// 结果:A 和 C 被提交,B 被回滚。
// 这就是 REQUIRES_NEW 的价值。
4.4 其他传播行为
| 传播行为 | 说明 | 子事务提交时机 | 异常影响 |
|---|---|---|---|
REQUIRED (默认) |
加入父事务 | 不真正提交,等父事务 | 子异常导致整体回滚 |
REQUIRES_NEW |
创建新事务 | 立即提交 | 子异常不影响父事务 |
NESTED |
创建嵌套事务(Savepoint) | 不真正提交,等父事务 | 可部分回滚 |
SUPPORTS |
有事务就加入,没有就不用 | 取决于是否有父事务 | 取决于是否有父事务 |
NOT_SUPPORTED |
暂停事务,无事务执行 | 无事务 | 无事务 |
NEVER |
必须无事务,否则报错 | 无事务 | 无事务 |
MANDATORY |
必须有事务,否则报错 | 取决于父事务 | 取决于父事务 |
4.5 NESTED 的特殊性
NESTED 使用数据库的 Savepoint 机制,允许部分回滚:
java
@Transactional
public void parent() {
insert_A();
try {
child(); // 子方法
} catch (Exception e) {
// 捕获异常,只有子方法的操作被回滚
}
insert_C(); // 继续执行
}
@Transactional(propagation = Propagation.NESTED)
public void child() {
insert_B();
throw new RuntimeException("出错了");
}
// 结果:A 和 C 被提交,B 被回滚。
// 与 REQUIRES_NEW 的区别:NESTED 使用 Savepoint,性能更好
注意: 不是所有数据库都支持 Savepoint(例如 MySQL 的 InnoDB 支持,但某些其他数据库不支持)。
4.6 实战建议
默认用 REQUIRED:
- 大多数场景下,你希望子方法和父方法共享同一个事务,任何地方出错都全部回滚
用 REQUIRES_NEW 的场景:
- 记录操作日志(即使主业务失败,日志也要保存)
- 发送异步通知(失败不影响主流程)
- 统计数据(独立的数据收集)
- 需要完全独立的事务隔离
用 NESTED 的场景:
- 需要部分回滚,但又不想创建完全独立的事务(性能更好)
- 确保数据库支持 Savepoint
五、子事务提交与父事务的关系
5.1 核心结论
子事务提交时,永远不会触发父事务的提交。这是事务管理的基本原则,无论使用哪种传播行为都遵循这个规则。
5.2 为什么不会?
原理:事务栈的单向流动
Spring 事务管理用一个栈来管理事务的生命周期。每个线程有自己的事务栈(通过 ThreadLocal),线程之间的事务完全隔离。
当你调用一个事务方法时,Spring 会把这个事务压入栈。当方法返回时,Spring 会把这个事务弹出栈。
执行流程:
parent() 开始
栈:[TX1] ← 父事务入栈
调用 child()
栈:[TX1, TX2] ← 子事务入栈(栈顶)
child() 返回
栈:[TX1] ← 子事务出栈
parent() 返回
栈:[] ← 父事务出栈
关键点: 栈顶是当前活跃的事务。只有栈顶的事务才能直接控制数据库的提交/回滚。
当子事务执行完毕被弹出栈后,父事务又回到栈顶,成为当前活跃的事务。这就是为什么子事务无法触发父事务的提交------子事务没有栈顶的权力。
5.3 具体场景分析
场景 1:PROPAGATION_REQUIRED(默认)
java
@Transactional
public void parent() {
insert_A();
child(); // 子事务
insert_C();
// 父事务在这里才真正提交
}
@Transactional(propagation = Propagation.REQUIRED)
public void child() {
insert_B();
// 这里的 commit() 只是虚拟提交,不会真的提交到数据库
}
执行流程:
- 父事务开始
- insert_A()
- child() 方法调用
- insert_B()
- child() 方法内的虚拟 commit() ------ Spring 检测到当前还在父事务内,所以这个 commit() 被忽略
- B 的数据还在内存中,没有写入数据库
- child() 方法返回
- insert_C()
- 父事务真正 commit() ------ A、B、C 一起写入数据库
所以,子事务的 commit() 无法触发父事务的 commit()。
场景 2:PROPAGATION_REQUIRES_NEW
java
@Transactional
public void parent() {
insert_A();
child(); // 子事务
insert_C();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void child() {
insert_B();
// 这里的 commit() 是真实提交
}
执行流程:
- 父事务开始
- insert_A()
- child() 方法调用
- 暂停父事务,创建新事务
- insert_B()
- child() 方法内的真实 commit() ------ B 被写入数据库
- 恢复父事务
- insert_C()
- 父事务真正 commit() ------ A、C 写入数据库
即使子事务真的提交了,也不会影响父事务。这是两个独立的事务。
5.4 常见的误解
有时候你会看到"子事务提交了父事务"的现象,但通常是误解:
误解 1:自调用(Self-Invocation)问题
java
@Transactional
public void parent() {
insert_A();
this.child(); // ⚠️ 直接调用,不经过 Spring AOP
insert_C();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void child() {
insert_B();
}
问题在于 this.child() 绕过了 Spring 的 AOP 代理,@Transactional 注解根本不生效。子方法会在父事务的上下文中执行,表现得像 REQUIRED。
这不是"子事务提交了父事务",而是根本没有独立的子事务。
误解 2:异常处理不当
java
@Transactional
public void parent() {
insert_A();
try {
child();
} catch (Exception e) {
// 捕获异常,继续执行
}
insert_C();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void child() {
insert_B();
throw new RuntimeException("出错");
}
现象是 A、C 被提交,B 被回滚。但这不是"子事务提交了父事务",而是:
- 子事务回滚了自己(B 没有提交)
- 父事务因为异常被捕获,所以继续执行并最终提交(A、C 被提交)
- 这是两个独立事务的独立行为,不是因果关系
5.5 总结
子事务提交时,永远不会触发父事务的提交。原因很简单:
- 事务栈是单向的,只有最外层有提交权
- 子事务无法越级控制外层事务
- 提交是显式的,只有当最外层方法执行完毕,Spring 才会调用真实的 commit()
如果你看到了"子事务提交导致父事务提交"的现象,那一定是其他原因------比如自调用、异常处理不当,或者根本没有形成真正的父子事务关系。
六、实战案例
6.1 案例 1:电商订单系统(防止重复提交)
这是最常见的场景。用户可能点击两次"提交订单"按钮,或者网络重试导致请求被处理两次。
关键是要在锁内部执行事务,而不是反过来:
java
@Service
public class OrderService {
@Autowired
private RedisLockService lockService;
@Autowired
private OrderRepository orderRepository;
public OrderResult createOrder(CreateOrderRequest request) {
String lockKey = "order:" + request.getUserId() + ":" + request.getOrderNo();
// 先获取锁
if (!lockService.tryLock(lockKey, 30)) {
throw new BusinessException("系统繁忙,请稍后再试");
}
try {
// 在锁内部调用事务方法
return doCreateOrderInTransaction(request);
} finally {
lockService.unlock(lockKey);
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
private OrderResult doCreateOrderInTransaction(CreateOrderRequest request) {
// 检查订单是否已存在(幂等性检查)
Order existing = orderRepository.findByOrderNo(request.getOrderNo());
if (existing != null) {
return new OrderResult(existing.getId(), "订单已存在");
}
// 创建订单
Order order = new Order();
order.setOrderNo(request.getOrderNo());
order.setUserId(request.getUserId());
order.setAmount(request.getAmount());
orderRepository.save(order);
return new OrderResult(order.getId(), "创建成功");
}
}
注意:锁的 Key 要包含用户 ID 和订单号,这样才能保证同一个用户的同一个订单不会被重复处理。
6.2 案例 2:库存扣减(防止超卖)
秒杀场景下,如果不加控制,多个请求会同时读取库存、同时扣减,导致超卖。分布式锁可以把并发请求串行化:
java
@Service
public class InventoryService {
@Autowired
private RedisLockService lockService;
@Autowired
private InventoryRepository inventoryRepository;
public void deductStock(String skuId, int quantity) {
String lockKey = "inventory:" + skuId;
// 获取锁,如果获取不到立刻返回
if (!lockService.tryLock(lockKey, 10)) {
throw new BusinessException("库存操作繁忙,请稍后重试");
}
try {
deductStockInTransaction(skuId, quantity);
} finally {
lockService.unlock(lockKey);
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
private void deductStockInTransaction(String skuId, int quantity) {
Inventory inventory = inventoryRepository.findBySkuId(skuId);
if (inventory.getStock() < quantity) {
throw new BusinessException("库存不足");
}
// 扣减库存
inventory.setStock(inventory.getStock() - quantity);
inventoryRepository.save(inventory);
}
}
这样做的好处是:同一时间只有一个请求能读取和修改库存,完全避免了超卖问题。
6.3 案例 3:日志记录(REQUIRES_NEW 的应用)
有时候你需要记录操作日志,即使主业务失败了,日志也要保存。这就是 REQUIRES_NEW 的典型应用场景:
java
@Service
public class BusinessService {
@Autowired
private AuditLogService auditLogService;
@Transactional
public void processOrder(Order order) {
// 主业务逻辑
order.setStatus("PROCESSING");
orderRepository.save(order);
// 记录审计日志(独立事务,主业务失败也要保存)
auditLogService.logOrderProcessing(order);
// 其他业务逻辑...
// 如果这里抛异常,主业务会回滚,但日志已经被保存了
}
}
@Service
public class AuditLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logOrderProcessing(Order order) {
AuditLog log = new AuditLog();
log.setOrderId(order.getId());
log.setAction("PROCESSING");
log.setTimestamp(System.currentTimeMillis());
auditLogRepository.save(log);
// 这个事务立刻提交,不受主业务影响
}
}
这样做的好处是:即使订单处理失败,审计日志也被记录下来了。这对于追踪系统行为很重要。
七、常见问题 FAQ
Q1:为什么我的子事务异常被捕获后,父事务还是回滚了?
A: 这是 PROPAGATION_REQUIRED 的特性。子事务和父事务共享同一个事务,子方法抛出异常后,即使你捕获了异常,事务也会被标记为 rollback-only。最终整个事务都会回滚。
解决方案: 如果子方法失败不应该影响父事务,改用 PROPAGATION_REQUIRES_NEW。或者在子方法内部处理异常,不让异常抛出。
Q2:我用了 PROPAGATION_REQUIRES_NEW,为什么子事务提交后,父事务还是回滚了?
A: 这说明父事务本身出错了。REQUIRES_NEW 只能保证子事务的独立性,无法保护父事务。如果父事务有异常或被标记为 rollback-only,它仍然会回滚。
Q3:分布式锁和数据库锁有什么区别?
A: 数据库锁由数据库管理,粒度细(行锁),但占用数据库资源。分布式锁由应用层管理(通常基于 Redis),粒度可控,不占用数据库资源,但需要额外的依赖。
怎么选择:
- 并发低、逻辑简单 → 用数据库锁(悲观锁)
- 并发中等、能接受失败重试 → 用乐观锁
- 并发高、业务复杂 → 用分布式锁
Q4:我应该在锁内部开启事务,还是在事务内部加锁?
A: 必须在锁内部开启事务。顺序是:加锁 → 开启事务 → 执行业务 → 提交事务 → 释放锁。
如果反过来,会出现竞态条件。这是最常见的错误。
Q5:NESTED 和 REQUIRES_NEW 有什么区别?
A: REQUIRES_NEW 是完全独立的新事务,子事务失败不影响父事务,但性能开销大。NESTED 使用数据库的 Savepoint 机制,子事务失败可以部分回滚,性能更好,但需要数据库支持。
Q6:避免在异步任务中使用 @Transactional
事务是线程本地的(通过 ThreadLocal 存储)。当你在异步任务中调用一个 @Transactional 方法时,这个异步任务运行在不同的线程上,无法访问主线程的事务上下文。
java
@Transactional
public void parent() {
insert_A();
// ❌ 错误:异步任务中的事务不生效
CompletableFuture.runAsync(() -> {
child(); // 这个 child() 没有事务!
});
insert_C();
}
@Transactional
public void child() {
insert_B(); // 这个操作没有事务保护
}
为什么无效? 因为异步任务运行在线程池的线程上,这个线程没有主线程的 ThreadLocal 事务上下文。
解决方案:
- 不在异步任务中调用事务方法(推荐)
java
@Transactional
public void parent() {
insert_A();
insert_B(); // 直接在主线程执行
insert_C();
// 异步任务只做不需要事务的操作
CompletableFuture.runAsync(() -> {
sendEmail(); // 发邮件,不需要事务
});
}
- 在异步任务中重新开启事务
java
@Transactional
public void parent() {
insert_A();
CompletableFuture.runAsync(() -> {
asyncChild(); // 这个方法有自己的事务
});
insert_C();
}
@Transactional // 异步任务中的事务
public void asyncChild() {
insert_B();
}
- 使用事务传播机制 (如果异步框架支持)
某些异步框架(如 Spring 的 @Async)可以传递事务上下文,但需要特殊配置。
记住: 事务和线程是绑定的。不同线程的事务完全隔离,无法共享。
总结
分布式锁 + 事务 是高并发系统的标配。但最关键的是要理解它们的工作原理,而不是盲目使用。
核心要点:
-
锁和事务的顺序很重要:必须是锁包裹事务,而不是反过来。
-
SERIALIZABLE 隔离级别虽然最安全,但性能代价太大,互联网系统基本不用。
-
Spring 的默认隔离级别取决于数据库,MySQL 是 REPEATABLE READ,Oracle 是 READ COMMITTED。
-
事务传播行为决定了子方法是加入父事务还是创建新事务。REQUIRED 是默认的,REQUIRES_NEW 用于需要独立事务的场景。
-
子事务永远不会触发父事务的提交。这是事务栈的基本设计,不是 bug。
-
根据并发量选择方案:低并发用悲观锁,中等并发用乐观锁,高并发用分布式锁。
-
异常处理很关键。不同的传播行为下,异常的影响范围完全不同。
最后,记住一个原则:在应用层用锁挡住并发,在数据库层用事务保证一致性。这样才能既保证性能,又保证数据安全。