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

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

文章结构

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

  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 核心坑:锁释放与事务提交的顺序问题

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

锁在事务内部的问题
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

  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 和间隙锁解决了大部分幻读)。
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 的场景:

  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(默认)
java 复制代码
@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
java 复制代码
@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)问题
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 总结

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

  1. 事务栈是单向的,只有最外层有提交权
  2. 子事务无法越级控制外层事务
  3. 提交是显式的,只有当最外层方法执行完毕,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 事务上下文。

解决方案:

  1. 不在异步任务中调用事务方法(推荐)
java 复制代码
@Transactional
public void parent() {
    insert_A();
    insert_B(); // 直接在主线程执行
    insert_C();
    
    // 异步任务只做不需要事务的操作
    CompletableFuture.runAsync(() -> {
        sendEmail(); // 发邮件,不需要事务
    });
}
  1. 在异步任务中重新开启事务
java 复制代码
@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. 异常处理很关键。不同的传播行为下,异常的影响范围完全不同。

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

相关推荐
澪贰1 小时前
从数据中心到边缘:基于 openEuler 24.03 LTS SP2 的 K3s 轻量化云原生实战评测
后端
绝无仅有1 小时前
面试之高级实战:在大型项目中如何利用AOP、Redis及缓存设计
后端·面试·架构
爱找乐子的李寻欢1 小时前
谁懂啊!测试环境 RocketMQ 延迟消息崩了,罪魁祸首是个…
后端
milixiang1 小时前
项目部署时接口短暂访问异常问题修复:Nacos+Gateway活跃节点监听
后端·spring cloud
Stream1 小时前
加密与签名技术之密钥派生与密码学随机数
后端·算法
绝无仅有1 小时前
redis缓存功能结合实际项目面试之问题与解析
后端·面试·架构
Stream1 小时前
加密与签名技术之哈希算法
后端·算法
z***D6482 小时前
SpringBoot 新特性
java·spring boot·后端
IT_陈寒2 小时前
JavaScript 性能优化:7个 V8 引擎隐藏技巧让你的代码提速200%
前端·人工智能·后端