Java学习第42天 - Spring 事务传播、隔离级别、锁机制与并发一致性

一、学习目标

  • 深入理解数据库事务的 ACID 特性与 Spring 声明式事务原理。
  • 掌握 @Transactional 的常用配置:rollbackForpropagationisolationtimeout
  • 理解 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 为什么重要

默认只对 RuntimeExceptionError 回滚。如果业务抛出 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));
        }
    }
}

createOrdersaveItems 在同一个事务中。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) {
        // ...
    }
}

解决方式:

  1. saveOrder 抽到另一个 Service,通过注入调用。
  2. 注入自身代理:((OrderService) AopContext.currentProxy()).saveOrder(req)(需开启 exposeProxy)。
  3. 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:传播行为实验

  1. OrderService.createOrder 调用 OrderItemService.saveItems,两者都是 REQUIRED,在 saveItems 中故意抛异常,观察订单和明细是否都未插入。
  2. AuditLogService.log 使用 REQUIRES_NEW,主业务回滚后检查日志是否仍存在。

任务 2:乐观锁支付

  1. orders 表确保有 version 字段。
  2. 实现 payOrder,使用 MyBatis Plus 乐观锁。
  3. 写单元测试:两个线程同时支付同一订单,只有一个成功,另一个得到 409 或重试成功。

任务 3:库存原子扣减

  1. products 表:id, name, stock
  2. 实现 deductStockAtomic,用 UPDATE ... SET stock = stock - ? WHERE id = ? AND stock >= ?
  3. 压测或模拟 10 个并发扣减,验证不会超卖。

任务 4:事务失效排查

  1. 故意写一个同类自调用的 @Transactional 方法,验证事务不生效。
  2. 改为注入另一个 Service 调用,验证事务生效。
  3. 故意 catch 异常不抛出,验证不回滚。

任务 5:长事务改造

  1. 找一个在事务内调外部接口或发邮件的伪代码。
  2. 拆成:事务内只写库,事务外异步通知。

十五、自检清单

完成第42天后,你应该能回答:

  • ACID 分别代表什么。
  • REQUIREDREQUIRES_NEW 的区别。
  • 脏读、不可重复读、幻读分别是什么。
  • MySQL 默认隔离级别是什么。
  • 乐观锁和悲观锁分别适合什么场景。
  • 库存扣减为什么"先查后改"会超卖,如何用一条 SQL 避免。
  • @Transactional 失效的至少 3 种原因。
  • 为什么事务内不宜调慢速外部接口。
  • 本地事务和分布式事务的边界在哪里。
  • 乐观锁冲突时应该返回什么 HTTP 状态码。

十六、学习总结

  1. ACID 是理解事务的基础,Spring 事务是对数据库事务的封装。
  2. 传播行为 :默认 REQUIRED;独立日志用 REQUIRES_NEW
  3. 隔离级别:MySQL 默认 REPEATABLE READ,多数场景无需修改。
  4. 乐观锁:version 字段 + 更新失败重试,适合读多写少。
  5. 悲观锁SELECT FOR UPDATE,适合强一致、冲突多的写场景。
  6. 库存扣减 :优先用原子 SQL WHERE stock >= ?,简单可靠。
  7. 事务失效:public、非自调用、异常要抛出、rollbackFor 要配对。
  8. 长事务:事务内只做数据库操作,外部调用放事务外。
  9. 分布式 :单库 @Transactional 有边界,跨服务需最终一致性等方案。
  10. 第42天与第38天幂等、第40天订单事务、第41天查询形成完整写链路闭环。
相关推荐
道友可好1 小时前
让 AI 自己验收,等于让学生自己批卷
前端·人工智能·后端
鱼人2 小时前
响应式三巨头:rem / vw / em 深度对比,移动端到底该选谁?
后端
小强19882 小时前
Grid 网格布局实战:快速实现复杂网页排版
后端
胡志辉2 小时前
深入浅出 call、apply、bind
前端·javascript·后端
长大19882 小时前
Flex 布局完整教程:告别浮动,拥抱万能弹性布局
后端
iccb10132 小时前
5年,一个程序员是如何把私有化在线客服系统做到第一名的
前端·后端·github
Rust研习社2 小时前
这 8 个 Rust 学习资源值得每个新手收藏起来
后端·rust·编程语言
Nturmoils2 小时前
ksql 里这些命令不用加分号,但日常查库少不了
后端
Dilee3 小时前
Spring AI 接 RAG 最小 Demo:DeepSeek、Ollama、SimpleVectorStore 一次跑通
后端