【后端杂货铺】深入理解分布式事务与锁:从隔离级别到传播行为

深入理解分布式事务与锁:从隔离级别到传播行为

文章结构

本文分为七个部分,从实战问题出发,逐步深入理论:

  1. 事务与分布式锁的联合使用 — 常见场景和坑点
  2. 事务隔离级别深度解析 — 为什么不用 SERIALIZABLE
  3. Spring @Transactional 的默认配置 — 隔离级别和数据库的关系
  4. 事务传播行为与父子事务 — 7 种传播行为详解
  5. 子事务提交与父事务的关系 — 事务栈的真实原理
  6. 实战案例 — 3 个真实场景的代码
  7. 常见问题 FAQ — 最容易踩的坑

前言

在一个业务中, 对多库中的数据写入存在一致性问题, 涉及到 Milvus/MySQL 多个数据库之间记录的场景。 本文旨在深入探讨分布式事务与锁在实际业务中的应用和原理,帮助解决跨库数据一致性问题。

在高并发的分布式系统中,数据一致性问题总是绕不过去的。很多开发者会问:“为什么不直接用 SERIALIZABLE 隔离级别?” 或者 “子事务提交了,父事务会不会也提交?” 这些问题看似简单,但背后涉及的原理很多人都没搞清楚。

本文基于多年的实战经验,系统地讲解分布式事务和锁的核心概念。希望能帮你理解这些问题的本质,而不仅仅是知道怎么用。


一、事务与分布式锁的联合使用

1.1 为什么要联合使用?

在分布式系统中,单纯依靠数据库事务是不够的。分布式锁和事务的结合主要是为了解决三个问题:

第一,防止竞态条件。在应用层用锁挡住并发请求,避免多个线程同时进入临界区。

第二,保护数据库。分布式锁可以减少数据库的锁竞争,避免大量的行锁等待和死锁。

第三,处理复杂业务。当业务逻辑涉及外部 API 调用时,数据库事务无法保护这部分,需要在应用层用锁来保证幂等性。

1.2 常见应用场景

场景 A:防止重复提交(幂等性保证)

这是最常见的场景。用户点击两次按钮,或者网络重试导致同一个请求被处理两次。

处理流程是这样的:

  1. 先获取分布式锁(Key 通常是用户 ID + 业务 ID)
  2. 开启数据库事务
  3. 查询数据库,检查这个请求是否已经被处理过
  4. 如果没处理过,执行业务逻辑(插入或更新数据)
  5. 提交事务
  6. 释放锁

这个模式在支付、领取优惠券、创建订单等场景中很常见。

场景 B:库存扣减 / 防止超卖(秒杀场景)

高性能秒杀通常在 Redis 中直接扣减库存,但如果系统规模不大,或者对数据一致性要求很高,还是需要操作数据库。

问题在于,如果只用数据库事务,虽然逻辑上没问题,但在高并发下会导致大量的行锁等待、死锁甚至超时。数据库连接池很快就会被占满。

更好的方案是在应用层加分布式锁,把并发请求串行化。这样数据库的压力就会大大降低,同时保证了库存的准确性。

场景 C:分布式定时任务调度

在集群环境下,多个节点都会执行相同的定时任务代码。比如每天凌晨 2 点生成日结报表。

如果不加控制,这个任务会在所有节点上都执行一遍,导致数据重复或混乱。

解决方案是用分布式锁。任务触发时,所有节点都去抢这把锁,只有抢到的节点才执行任务,其他节点直接跳过。

场景 D:复杂的长业务流程

有时候业务逻辑很复杂,需要先调外部 API 查询状态,然后根据结果更新本地数据库。

这种情况下,分布式锁的作用是保证整个流程的原子性。从锁定业务实体开始,到调 API、更新数据库、最后释放锁,中间任何一个步骤出问题都不会有其他线程来干扰。

1.3 核心坑:锁释放与事务提交的顺序问题

这是联合使用中最容易犯的严重错误

锁在事务内部的问题
@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 读到的是旧数据
  • 后果: 超卖、重复数据
正确的写法(锁包裹事务)

必须保证:先提交事务,再释放锁

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)
SELECT * FROM table WHERE id = 1 FOR UPDATE;

优点: 如果并发量不大,直接利用数据库行锁最简单可靠

缺点: 数据库压力大,长事务会拖垮连接池

适用场景: 并发量低,逻辑简单

乐观锁 (Version 字段)
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 CommittedOracle、PostgreSQL、SQL Server
Repeatable ReadMySQL (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):

if (lock.tryLock(timeout)) {
    // 获取到锁,执行业务
} else {
    // 立刻返回给用户"系统繁忙,请稍后再试"
    // 不消耗数据库连接
}

Serial 事务:

请求一旦打到数据库,就会 hang 住(阻塞)等待锁释放
直到超时或获得锁
这会迅速耗尽 Web 服务器的线程池和数据库连接池
导致整个服务不可用(雪崩效应)

2.3 什么时候可以用 SERIALIZABLE?

只有在以下极其特殊的场景下,才考虑使用 SERIALIZABLE

  1. 并发极低: 例如仅供内部运营人员使用的后台管理系统,几分钟才有一个请求
  2. 数据绝对核心且逻辑简单: 比如银行核心账务的某些极小范围的转账表,且不能容忍任何幻读
  3. 没有外部依赖: 业务逻辑纯粹是数据库内部的计算

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 和间隙锁解决了大部分幻读)。
OracleREAD COMMITTED (读已提交)只能读到别的事务已提交的数据。
PostgreSQLREAD COMMITTED (读已提交)同上。
SQL ServerREAD COMMITTED (读已提交)同上。

3.2 为什么要注意这一点?

因为不同的默认级别会导致代码在不同数据库上表现不一致。

场景: 你在一个事务里先读取数据 A,处理一会,再次读取数据 A。

  • 如果用 MySQL: 两次读取结果通常是一样的(因为是 Repeatable Read)
  • 如果用 Oracle: 第二次读取可能会变(如果期间有别的事务修改并提交了 A),这叫"不可重复读"

3.3 如何修改隔离级别?

如果你需要强制指定(比如为了统一行为或性能优化),可以在注解中显式设置:

// 强制使用读已提交(互联网大厂常用配置,并发度更高)
@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(默认)

@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,最终整体回滚。这包括父方法之前执行的所有操作。

@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

@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 最大的优势。

@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 机制,允许部分回滚:

@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 的场景:

  1. 记录操作日志(即使主业务失败,日志也要保存)
  2. 发送异步通知(失败不影响主流程)
  3. 统计数据(独立的数据收集)
  4. 需要完全独立的事务隔离

NESTED 的场景:

  • 需要部分回滚,但又不想创建完全独立的事务(性能更好)
  • 确保数据库支持 Savepoint

五、子事务提交与父事务的关系

5.1 核心结论

子事务提交时,永远不会触发父事务的提交。这是事务管理的基本原则,无论使用哪种传播行为都遵循这个规则。

5.2 为什么不会?

原理:事务栈的单向流动

Spring 事务管理用一个栈来管理事务的生命周期。每个线程有自己的事务栈(通过 ThreadLocal),线程之间的事务完全隔离。

当你调用一个事务方法时,Spring 会把这个事务压入栈。当方法返回时,Spring 会把这个事务弹出栈。

执行流程:

parent() 开始
  栈:[TX1]  ← 父事务入栈

  调用 child()
    栈:[TX1, TX2]  ← 子事务入栈(栈顶)

  child() 返回
    栈:[TX1]  ← 子事务出栈

parent() 返回
  栈:[]  ← 父事务出栈

关键点: 栈顶是当前活跃的事务。只有栈顶的事务才能直接控制数据库的提交/回滚。

当子事务执行完毕被弹出栈后,父事务又回到栈顶,成为当前活跃的事务。这就是为什么子事务无法触发父事务的提交——子事务没有栈顶的权力。

5.3 具体场景分析

场景 1:PROPAGATION_REQUIRED(默认)
@Transactional
public void parent() {
    insert_A();
    child(); // 子事务
    insert_C();
    // 父事务在这里才真正提交
}

@Transactional(propagation = Propagation.REQUIRED)
public void child() {
    insert_B();
    // 这里的 commit() 只是虚拟提交,不会真的提交到数据库
}

执行流程:

  1. 父事务开始
  2. insert_A()
  3. child() 方法调用
  4. insert_B()
  5. child() 方法内的虚拟 commit() —— Spring 检测到当前还在父事务内,所以这个 commit() 被忽略
  6. B 的数据还在内存中,没有写入数据库
  7. child() 方法返回
  8. insert_C()
  9. 父事务真正 commit() —— A、B、C 一起写入数据库

所以,子事务的 commit() 无法触发父事务的 commit()。

场景 2:PROPAGATION_REQUIRES_NEW
@Transactional
public void parent() {
    insert_A();
    child(); // 子事务
    insert_C();
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void child() {
    insert_B();
    // 这里的 commit() 是真实提交
}

执行流程:

  1. 父事务开始
  2. insert_A()
  3. child() 方法调用
  4. 暂停父事务,创建新事务
  5. insert_B()
  6. child() 方法内的真实 commit() —— B 被写入数据库
  7. 恢复父事务
  8. insert_C()
  9. 父事务真正 commit() —— A、C 写入数据库

即使子事务真的提交了,也不会影响父事务。这是两个独立的事务。

5.4 常见的误解

有时候你会看到"子事务提交了父事务"的现象,但通常是误解:

误解 1:自调用(Self-Invocation)问题
@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:异常处理不当
@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 总结

子事务提交时,永远不会触发父事务的提交。原因很简单:

  1. 事务栈是单向的,只有最外层有提交权
  2. 子事务无法越级控制外层事务
  3. 提交是显式的,只有当最外层方法执行完毕,Spring 才会调用真实的 commit()

如果你看到了"子事务提交导致父事务提交"的现象,那一定是其他原因——比如自调用、异常处理不当,或者根本没有形成真正的父子事务关系。


六、实战案例

6.1 案例 1:电商订单系统(防止重复提交)

这是最常见的场景。用户可能点击两次"提交订单"按钮,或者网络重试导致请求被处理两次。

关键是要在锁内部执行事务,而不是反过来:

@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:库存扣减(防止超卖)

秒杀场景下,如果不加控制,多个请求会同时读取库存、同时扣减,导致超卖。分布式锁可以把并发请求串行化:

@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 的典型应用场景:

@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 方法时,这个异步任务运行在不同的线程上,无法访问主线程的事务上下文。

@Transactional
public void parent() {
    insert_A();
    
    // ❌ 错误:异步任务中的事务不生效
    CompletableFuture.runAsync(() -> {
        child(); // 这个 child() 没有事务!
    });
    
    insert_C();
}

@Transactional
public void child() {
    insert_B(); // 这个操作没有事务保护
}

为什么无效? 因为异步任务运行在线程池的线程上,这个线程没有主线程的 ThreadLocal 事务上下文。

解决方案:

  1. 不在异步任务中调用事务方法(推荐)
@Transactional
public void parent() {
    insert_A();
    insert_B(); // 直接在主线程执行
    insert_C();
    
    // 异步任务只做不需要事务的操作
    CompletableFuture.runAsync(() -> {
        sendEmail(); // 发邮件,不需要事务
    });
}
  1. 在异步任务中重新开启事务
@Transactional
public void parent() {
    insert_A();
    
    CompletableFuture.runAsync(() -> {
        asyncChild(); // 这个方法有自己的事务
    });
    
    insert_C();
}

@Transactional // 异步任务中的事务
public void asyncChild() {
    insert_B();
}
  1. 使用事务传播机制(如果异步框架支持)
    某些异步框架(如 Spring 的 @Async)可以传递事务上下文,但需要特殊配置。

记住: 事务和线程是绑定的。不同线程的事务完全隔离,无法共享。


总结

分布式锁 + 事务 是高并发系统的标配。但最关键的是要理解它们的工作原理,而不是盲目使用。

核心要点:

  1. 锁和事务的顺序很重要:必须是锁包裹事务,而不是反过来。

  2. SERIALIZABLE 隔离级别虽然最安全,但性能代价太大,互联网系统基本不用。

  3. Spring 的默认隔离级别取决于数据库,MySQL 是 REPEATABLE READ,Oracle 是 READ COMMITTED。

  4. 事务传播行为决定了子方法是加入父事务还是创建新事务。REQUIRED 是默认的,REQUIRES_NEW 用于需要独立事务的场景。

  5. 子事务永远不会触发父事务的提交。这是事务栈的基本设计,不是 bug。

  6. 根据并发量选择方案:低并发用悲观锁,中等并发用乐观锁,高并发用分布式锁。

  7. 异常处理很关键。不同的传播行为下,异常的影响范围完全不同。

最后,记住一个原则:在应用层用锁挡住并发,在数据库层用事务保证一致性。这样才能既保证性能,又保证数据安全。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值