一、学习目标
- 深入理解数据库事务的 ACID 特性与 Spring 声明式事务原理。
- 掌握
@Transactional的常用配置:rollbackFor、propagation、isolation、timeout。 - 理解 7 种事务传播行为 的使用场景与常见坑。
- 掌握 4 种隔离级别 及其带来的脏读、不可重复读、幻读问题。
- 会用 乐观锁 与 悲观锁 处理并发更新。
- 理解 事务失效 的常见原因与排查方法。
- 能把第40天订单创建、第41天查询优化,延伸到 高并发写场景 的可靠实现。
二、为什么第42天要学事务与并发
第40天学会了用 @Transactional 保证"创建订单 + 写入明细"要么全成功要么全回滚。
第41天学会了复杂查询与索引优化。
但真实生产环境中还会遇到:
- 用户重复点击支付按钮,订单被扣款两次。
- 两个线程同时修改同一笔订单状态,后提交的覆盖先提交的。
- Service A 调用 Service B,事务边界不清晰导致部分回滚。
- 库存扣减时超卖。
@Transactional加了却不生效。- 长事务导致数据库连接被占满。
第42天的目标:从"会用事务"升级到"理解事务边界、并发问题与解决方案"。
三、事务 ACID 回顾
3.1 四个特性
| 特性 | 英文 | 含义 | 示例 |
|---|---|---|---|
| 原子性 | Atomicity | 一组操作要么全成功,要么全回滚 | 创建订单和明细同时成功或同时失败 |
| 一致性 | Consistency | 数据始终满足业务约束 | 订单金额等于明细金额之和 |
| 隔离性 | Isolation | 并发事务互不不当干扰 | 两个用户同时下单互不影响 |
| 持久性 | Durability | 提交后数据永久保存 | 提交后即使宕机数据不丢 |
3.2 Spring 事务与数据库事务的关系
Spring 的 @Transactional 是对 数据库事务 的封装:
- 方法开始前:获取连接,设置隔离级别,
BEGIN。 - 方法正常结束:
COMMIT。 - 方法抛出异常:
ROLLBACK(按配置决定哪些异常回滚)。
Spring 本身不实现事务,底层依赖 DataSource 和数据库引擎(如 InnoDB)。
四、@Transactional 基础配置
4.1 基本用法
java
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderCommandService {
@Transactional(rollbackFor = Exception.class)
public OrderDetailDTO create(CreateOrderRequest req) {
// 写 orders
// 写 order_items
return detail;
}
}
4.2 常用属性
| 属性 | 说明 | 默认值 |
|---|---|---|
| rollbackFor | 哪些异常触发回滚 | RuntimeException、Error |
| noRollbackFor | 哪些异常不回滚 | 无 |
| propagation | 传播行为 | REQUIRED |
| isolation | 隔离级别 | DEFAULT(跟随数据库) |
| timeout | 超时秒数,超时则回滚 | -1(不限制) |
| readOnly | 只读事务,优化查询 | false |
4.3 rollbackFor 为什么重要
默认只对 RuntimeException 和 Error 回滚。如果业务抛出 checked 异常:
java
public void pay(Long orderId) throws BusinessException {
// ...
throw new BusinessException("余额不足");
}
没有 rollbackFor = Exception.class 时,事务不会回滚。
推荐写法:
java
@Transactional(rollbackFor = Exception.class)
public void pay(Long orderId) throws BusinessException {
// ...
}
4.4 readOnly 只读事务
查询方法可加:
java
@Transactional(readOnly = true)
public PageResult<OrderSummaryDTO> findPage(int page, int size, String status) {
return orderQueryService.findPage(page, size, status);
}
好处:
- 提示数据库这是只读操作,部分驱动会做优化。
- 明确方法意图,避免误写。
- 某些场景下减少锁竞争。
注意:readOnly = true 不能阻止你写库,只是约定;真正写操作不要加 readOnly。
五、事务传播行为(Propagation)
5.1 七种传播行为一览
| 传播行为 | 说明 |
|---|---|
| REQUIRED | 有事务就加入,没有就新建(默认) |
| REQUIRES_NEW | 总是新建事务,挂起当前事务 |
| NESTED | 嵌套事务,外层回滚会回滚内层,内层回滚不影响外层 |
| SUPPORTS | 有事务就加入,没有就以非事务执行 |
| NOT_SUPPORTED | 以非事务执行,挂起当前事务 |
| MANDATORY | 必须在事务内,否则抛异常 |
| NEVER | 不能在事务内,否则抛异常 |
5.2 REQUIRED(最常用)
java
@Service
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderRequest req) {
orderRepository.save(order);
orderItemService.saveItems(order.getId(), req.getItems()); // 加入同一事务
}
}
@Service
public class OrderItemService {
@Transactional(rollbackFor = Exception.class)
public void saveItems(Long orderId, List<OrderItemRequest> items) {
for (OrderItemRequest item : items) {
orderItemMapper.insert(toEntity(orderId, item));
}
}
}
createOrder 和 saveItems 在同一个事务中。saveItems 抛异常,整个 createOrder 回滚。
5.3 REQUIRES_NEW(独立事务)
场景:写操作日志、审计记录,即使主业务回滚,日志也要保留。
java
@Service
public class AuditLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void log(String action, String detail) {
auditLogMapper.insert(new AuditLog(action, detail));
}
}
@Service
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderRequest req) {
try {
orderRepository.save(order);
orderItemService.saveItems(order.getId(), req.getItems());
} finally {
auditLogService.log("CREATE_ORDER", "orderId=" + order.getId());
}
}
}
auditLogService.log 在 新事务 中执行,主事务回滚不影响已提交的日志。
注意:REQUIRES_NEW 会挂起外层事务,外层回滚时内层已提交的数据不会回滚。
5.4 NESTED(嵌套事务)
内层用 Savepoint,内层回滚只回滚到 Savepoint,外层可继续或整体回滚。
java
@Transactional(propagation = Propagation.NESTED, rollbackFor = Exception.class)
public void saveOptionalItems(Long orderId, List<OrderItemRequest> items) {
// 可选明细,失败只回滚这部分
}
MySQL 需要 InnoDB 且 JDBC 支持 Savepoint。实际项目中 NESTED 用得较少,多数用 REQUIRES_NEW 或业务拆分。
5.5 传播行为选择建议
| 场景 | 推荐 |
|---|---|
| 普通业务调用 | REQUIRED |
| 审计日志、操作记录 | REQUIRES_NEW |
| 可选子步骤失败不影响主流程 | 考虑 NESTED 或拆成独立方法 + 捕获异常 |
| 纯查询 | SUPPORTS 或 readOnly + REQUIRED |
| 强制必须在事务内 | MANDATORY |
六、隔离级别(Isolation)
6.1 四种隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 几乎不用 |
| READ COMMITTED | 不会 | 可能 | 可能 | Oracle 默认 |
| REPEATABLE READ | 不会 | 不会 | 可能* | MySQL InnoDB 默认 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 性能最差 |
*MySQL InnoDB 在 REPEATABLE READ 下通过 MVCC 和间隙锁,在很大程度上避免了幻读。
6.2 三种并发问题
脏读:读到另一个事务未提交的数据。
sql
事务A: UPDATE orders SET status='PAID' WHERE id=1 (未提交)
事务B: SELECT status FROM orders WHERE id=1 --> 读到 PAID
事务A: ROLLBACK
事务B 读到的 PAID 是脏数据
不可重复读:同一事务内两次读同一行,结果不同。
sql
事务A: SELECT status FROM orders WHERE id=1 --> CREATED
事务B: UPDATE orders SET status='PAID' WHERE id=1; COMMIT;
事务A: SELECT status FROM orders WHERE id=1 --> PAID(与第一次不同)
幻读:同一事务内两次范围查询,行数不同。
sql
事务A: SELECT * FROM orders WHERE status='CREATED' --> 10 行
事务B: INSERT INTO orders (...) status='CREATED'; COMMIT;
事务A: SELECT * FROM orders WHERE status='CREATED' --> 11 行
6.3 Spring 中设置隔离级别
java
@Transactional(
isolation = Isolation.REPEATABLE_READ,
rollbackFor = Exception.class
)
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
// 扣款、加款
}
java
import org.springframework.transaction.annotation.Isolation;
// 读已提交,适合对一致性要求稍低、并发高的统计查询
@Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
public List<OrderStats> getStats() {
return orderReportMapper.selectDailyStats(start, end);
}
6.4 实际项目建议
- 默认使用数据库默认级别(MySQL 一般为 REPEATABLE READ)。
- 不要随意改成 SERIALIZABLE,性能差。
- 高并发写场景更多依赖 乐观锁、悲观锁、业务幂等,而不是单纯调隔离级别。
七、乐观锁
7.1 原理
在表中加 version 字段,更新时带版本条件:
sql
UPDATE orders
SET status = 'PAID', version = version + 1
WHERE id = 1 AND version = 2;
若 version 已被其他事务修改,affected rows = 0,更新失败,业务层重试或返回冲突。
7.2 MyBatis Plus 乐观锁
实体(第40天已介绍):
java
@Version
private Integer version;
配置插件:
java
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
Service:
java
@Transactional(rollbackFor = Exception.class)
public void payOrder(Long orderId) {
OrderEntity order = orderRepository.getById(orderId);
if (order == null) {
throw new IllegalArgumentException("订单不存在");
}
if (!"CREATED".equals(order.getStatus())) {
throw new IllegalStateException("订单状态不允许支付");
}
order.setStatus("PAID");
boolean ok = orderRepository.updateById(order);
if (!ok) {
throw new OptimisticLockException("订单已被修改,请刷新后重试");
}
}
7.3 乐观锁适用场景
- 读多写少。
- 冲突概率低。
- 可以接受"更新失败请重试"。
- 订单状态变更、库存扣减(冲突不特别激烈时)。
7.4 乐观锁重试示例
java
@Transactional(rollbackFor = Exception.class)
public void payOrderWithRetry(Long orderId) {
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
try {
payOrder(orderId);
return;
} catch (OptimisticLockException e) {
if (i == maxRetry - 1) {
throw e;
}
}
}
}
注意:重试要在事务外或每次重试新开事务,否则同一事务内 version 已变,重试无意义。更稳妥的是每次重试调用带 @Transactional(propagation = REQUIRES_NEW) 的方法。
八、悲观锁
8.1 原理
在查询时加锁,其他事务必须等待。
MySQL:
sql
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
在事务提交前,其他事务不能修改该行。
8.2 使用场景
- 冲突频繁。
- 必须保证强一致性。
- 库存扣减、账户余额变更。
8.3 MyBatis 中实现
java
@Select("SELECT * FROM orders WHERE id = #{id} FOR UPDATE")
OrderEntity selectByIdForUpdate(@Param("id") Long id);
java
@Transactional(rollbackFor = Exception.class)
public void payOrderPessimistic(Long orderId) {
OrderEntity order = orderMapper.selectByIdForUpdate(orderId);
if (order == null) {
throw new IllegalArgumentException("订单不存在");
}
if (!"CREATED".equals(order.getStatus())) {
throw new IllegalStateException("订单状态不允许支付");
}
order.setStatus("PAID");
orderMapper.updateById(order);
}
8.4 悲观锁注意点
- 必须在事务内使用,否则锁立即释放。
- 锁持有时间过长会导致阻塞,尽量缩短事务。
- 避免死锁:多表加锁时保持固定顺序。
- 高并发下可能成为瓶颈。
8.5 乐观锁 vs 悲观锁
| 对比项 | 乐观锁 | 悲观锁 |
|---|---|---|
| 实现 | version 字段 | SELECT FOR UPDATE |
| 冲突时 | 更新失败,业务重试 | 等待锁 |
| 适用 | 读多写少、冲突少 | 写多、冲突多 |
| 性能 | 无锁等待 | 可能阻塞 |
| 典型场景 | 订单状态更新 | 库存扣减、转账 |
九、库存扣减实战(防超卖)
9.1 错误做法
java
// 反例:先查后改,并发会超卖
ProductEntity product = productMapper.selectById(productId);
if (product.getStock() < quantity) {
throw new IllegalStateException("库存不足");
}
product.setStock(product.getStock() - quantity);
productMapper.updateById(product);
两个线程同时读到 stock=1,都扣减成功,实际卖了 2 件。
9.2 方案一:乐观锁
sql
UPDATE products
SET stock = stock - #{quantity}, version = version + 1
WHERE id = #{id} AND stock >= #{quantity} AND version = #{version}
java
@Transactional(rollbackFor = Exception.class)
public void deductStock(Long productId, int quantity) {
ProductEntity product = productMapper.selectById(productId);
if (product.getStock() < quantity) {
throw new IllegalStateException("库存不足");
}
product.setStock(product.getStock() - quantity);
boolean ok = productMapper.updateById(product);
if (!ok) {
throw new OptimisticLockException("库存更新冲突,请重试");
}
}
9.3 方案二:悲观锁
java
@Select("SELECT * FROM products WHERE id = #{id} FOR UPDATE")
ProductEntity selectByIdForUpdate(@Param("id") Long id);
java
@Transactional(rollbackFor = Exception.class)
public void deductStockPessimistic(Long productId, int quantity) {
ProductEntity product = productMapper.selectByIdForUpdate(productId);
if (product.getStock() < quantity) {
throw new IllegalStateException("库存不足");
}
product.setStock(product.getStock() - quantity);
productMapper.updateById(product);
}
9.4 方案三:原子 SQL(推荐简单场景)
java
@Update("UPDATE products SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity}")
int deductStock(@Param("id") Long id, @Param("quantity") int quantity);
java
@Transactional(rollbackFor = Exception.class)
public void deductStockAtomic(Long productId, int quantity) {
int rows = productMapper.deductStock(productId, quantity);
if (rows == 0) {
throw new IllegalStateException("库存不足或商品不存在");
}
}
一条 SQL 完成判断和扣减,无超卖,无需 version 字段。高并发库存扣减常用此方式。
十、事务失效的常见原因
10.1 方法不是 public
@Transactional 通过 AOP 代理实现,只有 public 方法会被代理。
java
@Transactional
void createOrder() { } // 失效
10.2 同类内部自调用
java
@Service
public class OrderService {
public void create(CreateOrderRequest req) {
this.saveOrder(req); // 直接调用,不走代理,事务失效
}
@Transactional(rollbackFor = Exception.class)
public void saveOrder(CreateOrderRequest req) {
// ...
}
}
解决方式:
- 把
saveOrder抽到另一个 Service,通过注入调用。 - 注入自身代理:
((OrderService) AopContext.currentProxy()).saveOrder(req)(需开启exposeProxy)。 - 用
ApplicationContext.getBean(OrderService.class).saveOrder(req)。
推荐方式 1,结构更清晰。
10.3 异常被吞掉
java
@Transactional(rollbackFor = Exception.class)
public void create(CreateOrderRequest req) {
try {
orderRepository.save(order);
throw new RuntimeException("模拟失败");
} catch (Exception e) {
log.error("error", e); // 捕获后不抛出,事务不会回滚
}
}
必须让异常传播到方法外,事务才会回滚。
10.4 抛出的异常类型不匹配
抛出 Exception 子类(checked 异常)且未配置 rollbackFor,不会回滚。
10.5 数据库引擎不支持事务
MyISAM 不支持事务,必须用 InnoDB。
10.6 未被 Spring 管理
类没有 @Service、@Component 等,或通过 new 创建,不会走代理。
10.7 排查清单
- 方法是否 public?
- 是否同类自调用?
- 异常是否被 catch 且未抛出?
- rollbackFor 是否包含业务异常?
- 表是否是 InnoDB?
- 类是否被 Spring 扫描?
十一、长事务与连接池
11.1 长事务的危害
- 占用数据库连接,连接池耗尽。
- 锁持有时间长,阻塞其他请求。
- 增加死锁概率。
11.2 避免长事务
java
// 反例:事务内调外部 HTTP、发邮件、复杂计算
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderRequest req) {
orderRepository.save(order);
httpClient.callPaymentApi(order); // 可能很慢
emailService.send(order); // 可能很慢
}
正确做法:事务内只做数据库操作,事务提交后再做外部调用。
java
@Transactional(rollbackFor = Exception.class)
public OrderEntity createOrder(CreateOrderRequest req) {
OrderEntity order = buildAndSave(req);
return order;
}
public void createOrderAndNotify(CreateOrderRequest req) {
OrderEntity order = createOrder(req);
paymentService.callAsync(order.getId());
emailService.sendAsync(order);
}
11.3 设置超时
java
@Transactional(rollbackFor = Exception.class, timeout = 10)
public void createOrder(CreateOrderRequest req) {
// 超过 10 秒自动回滚
}
十二、分布式场景下的局限
12.1 本地事务的边界
@Transactional 只能保证 单个数据库 内的一致性。
跨服务、跨库时:
- 订单服务写 order_db。
- 库存服务写 inventory_db。
- 支付服务调第三方支付。
无法用单个 @Transactional 保证全部成功或全部回滚。
12.2 常见方案(了解即可,后续阶段深入)
| 方案 | 说明 |
|---|---|
| 最终一致性 | 通过消息队列异步补偿 |
| TCC | Try-Confirm-Cancel 三阶段 |
| Saga | 长事务拆成多个本地事务 + 补偿 |
| Seata | 分布式事务框架 |
第42天先掌握 单库事务 + 乐观锁/悲观锁,分布式事务在微服务阶段再学。
12.3 幂等与事务配合
第38天的 Idempotency-Key 与事务结合:
java
@Transactional(rollbackFor = Exception.class)
public OrderDetailDTO create(CreateOrderRequest req, String idempotencyKey) {
if (idempotencyKey != null) {
String cached = idempotencyService.getCachedResponse(idempotencyKey);
if (cached != null) {
return objectMapper.readValue(cached, OrderDetailDTO.class);
}
if (!idempotencyService.tryBegin(idempotencyKey, hash(req))) {
throw new ConflictException("请求处理中,请稍后重试");
}
}
OrderDetailDTO result = doCreate(req);
if (idempotencyKey != null) {
idempotencyService.storeResponse(idempotencyKey, toJson(result));
}
return result;
}
幂等记录可与业务在同一事务,也可 REQUIRES_NEW 先占位再执行业务,按团队规范选择。
十三、统一异常与 HTTP 状态码
13.1 乐观锁冲突返回 409
java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OptimisticLockException.class)
public ProblemDetail handleOptimisticLock(OptimisticLockException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
pd.setTitle("并发更新冲突");
pd.setDetail(ex.getMessage());
return pd;
}
}
13.2 业务状态不允许返回 422
java
@ExceptionHandler(IllegalStateException.class)
public ProblemDetail handleIllegalState(IllegalStateException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
pd.setTitle("业务状态错误");
pd.setDetail(ex.getMessage());
return pd;
}
十四、实战任务
任务 1:传播行为实验
- 写
OrderService.createOrder调用OrderItemService.saveItems,两者都是REQUIRED,在saveItems中故意抛异常,观察订单和明细是否都未插入。 - 写
AuditLogService.log使用REQUIRES_NEW,主业务回滚后检查日志是否仍存在。
任务 2:乐观锁支付
- 为
orders表确保有version字段。 - 实现
payOrder,使用 MyBatis Plus 乐观锁。 - 写单元测试:两个线程同时支付同一订单,只有一个成功,另一个得到 409 或重试成功。
任务 3:库存原子扣减
- 建
products表:id, name, stock。 - 实现
deductStockAtomic,用UPDATE ... SET stock = stock - ? WHERE id = ? AND stock >= ?。 - 压测或模拟 10 个并发扣减,验证不会超卖。
任务 4:事务失效排查
- 故意写一个同类自调用的
@Transactional方法,验证事务不生效。 - 改为注入另一个 Service 调用,验证事务生效。
- 故意 catch 异常不抛出,验证不回滚。
任务 5:长事务改造
- 找一个在事务内调外部接口或发邮件的伪代码。
- 拆成:事务内只写库,事务外异步通知。
十五、自检清单
完成第42天后,你应该能回答:
- ACID 分别代表什么。
REQUIRED和REQUIRES_NEW的区别。- 脏读、不可重复读、幻读分别是什么。
- MySQL 默认隔离级别是什么。
- 乐观锁和悲观锁分别适合什么场景。
- 库存扣减为什么"先查后改"会超卖,如何用一条 SQL 避免。
@Transactional失效的至少 3 种原因。- 为什么事务内不宜调慢速外部接口。
- 本地事务和分布式事务的边界在哪里。
- 乐观锁冲突时应该返回什么 HTTP 状态码。
十六、学习总结
- ACID 是理解事务的基础,Spring 事务是对数据库事务的封装。
- 传播行为 :默认
REQUIRED;独立日志用REQUIRES_NEW。 - 隔离级别:MySQL 默认 REPEATABLE READ,多数场景无需修改。
- 乐观锁:version 字段 + 更新失败重试,适合读多写少。
- 悲观锁 :
SELECT FOR UPDATE,适合强一致、冲突多的写场景。 - 库存扣减 :优先用原子 SQL
WHERE stock >= ?,简单可靠。 - 事务失效:public、非自调用、异常要抛出、rollbackFor 要配对。
- 长事务:事务内只做数据库操作,外部调用放事务外。
- 分布式 :单库
@Transactional有边界,跨服务需最终一致性等方案。 - 第42天与第38天幂等、第40天订单事务、第41天查询形成完整写链路闭环。