Skip to content

ORM 事务与一致性

ORM 框架能帮你减少数据访问代码,但不能替你决定“哪些数据库操作必须一起成功”。事务边界设计错了,轻则脏数据、重复数据,重则订单、库存、支付、消息状态互相矛盾。

这一章讲的是本地数据库事务在 ORM 场景下如何工作:Spring 事务代理如何开启事务,连接如何绑定到当前线程,MyBatis 如何复用同一个连接,JPA/Hibernate 如何在提交前 flush,为什么事务要放在 Service 层,以及线上事务不生效、锁等待、连接耗尽该怎么排查。

学习目标

学完本章要能说清楚:

  1. 事务为什么不能放 Controller,也不能放 Mapper/Repository。
  2. Spring @Transactional 的代理执行流程是什么。
  3. 数据库连接如何绑定到当前线程,MyBatis/JPA 如何参与同一个事务。
  4. REQUIREDREQUIRES_NEWNESTEDSUPPORTS 等传播行为怎么选。
  5. 默认回滚规则是什么,为什么捕获异常会导致不回滚。
  6. MyBatis 和 JPA 在事务提交时有什么差异。
  7. 为什么事务里不要放远程调用、MQ 发送、慢计算。
  8. 事务后发消息、本地消息表分别解决什么一致性问题。
  9. 批量导入为什么要拆事务,JPA 为什么还要 flush/clear。
  10. 线上事务不生效、锁等待、连接池耗尽、数据不一致怎么排查。

事务到底解决什么

事务保证的是一组数据库操作的 ACID。

特性含义例子
Atomicity 原子性要么都成功,要么都失败订单主表和明细必须一起成功
Consistency 一致性事务前后数据满足业务约束库存不能扣成负数
Isolation 隔离性并发事务之间互不乱读乱写两个人不能同时超卖同一库存
Durability 持久性提交后数据不能丢提交后的订单重启仍存在

ORM 只负责把对象操作转成 SQL。它不知道这些 SQL 在业务上是否必须一起成功。

例如创建订单:

mermaid
flowchart TD
    A["创建订单请求"] --> B["插入订单主表"]
    B --> C["插入订单明细"]
    C --> D["扣减库存"]
    D --> E["记录操作日志"]
    E --> F{"全部成功?"}
    F -->|"是"| G["提交事务"]
    F -->|"否"| H["回滚所有数据库变更"]

如果这几步不在同一个事务中:

  1. 订单主表成功、明细失败:出现空订单。
  2. 明细成功、库存失败:出现已下单但库存没扣。
  3. 库存成功、订单失败:库存被凭空扣掉。
  4. 业务日志成功、订单回滚:审计记录和真实数据矛盾。

事务边界为什么放 Service 层

Controller 负责接收请求,Mapper/Repository 负责数据访问,Service 负责业务编排。一个完整业务动作通常会调用多个 Mapper/Repository。

mermaid
flowchart TD
    A["Controller"] --> B["OrderService.createOrder 开启事务"]
    B --> C["OrderMapper.insert"]
    B --> D["OrderItemMapper.batchInsert"]
    B --> E["StockMapper.deduct"]
    B --> F["OrderLogMapper.insert"]
    C --> G["同一个数据库连接"]
    D --> G
    E --> G
    F --> G

事务放 Controller 的问题:

  1. Controller 可能包含参数解析、权限、远程调用、响应组装,事务范围过大。
  2. Web 层和数据库事务耦合,难维护。
  3. 异步、重试、内部调用时边界混乱。

事务放 Mapper/Repository 的问题:

  1. 单个 Mapper 只知道一条或一类 SQL,不知道完整业务动作。
  2. 多个 Mapper 无法自然组成一个原子事务。
  3. 每个 Mapper 自己提交会导致中间成功、中间失败。

结论:事务一般放在 Service 层,覆盖一个完整的本地数据库业务动作。

Spring 事务执行流程

@Transactional 通常通过 AOP 代理实现。

java
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
    orderMapper.insert(order);
    orderItemMapper.batchInsert(items);
    stockMapper.deduct(command.getSkuId(), command.getCount());
}

执行链路:

mermaid
flowchart TD
    A["外部调用 Spring Bean 代理对象"] --> B["TransactionInterceptor 拦截"]
    B --> C["读取 @Transactional 配置"]
    C --> D["TransactionManager 获取连接并开启事务"]
    D --> E["连接绑定到当前线程"]
    E --> F["执行目标 Service 方法"]
    F --> G{"是否抛出需回滚异常?"}
    G -->|"否"| H["提交事务"]
    G -->|"是"| I["回滚事务"]
    H --> J["解绑连接并归还连接池"]
    I --> J

为什么同类自调用事务不生效?

java
@Service
public class OrderService {
    public void outer() {
        this.inner(); // 没经过 Spring 代理
    }

    @Transactional
    public void inner() {
        // 事务可能不生效
    }
}

this.inner() 是当前对象内部调用,没有经过代理对象,事务拦截器没有机会开启事务。

正确做法:

  1. 把事务方法放到另一个 Spring Bean。
  2. 让外部通过代理对象调用。
  3. 不要依赖内部私有方法开启新事务。

数据库连接如何绑定到当前线程

Spring 事务的关键不是注解本身,而是“同一个事务内的所有 SQL 使用同一个数据库连接”。

mermaid
flowchart TD
    A["事务开始"] --> B["DataSourceTransactionManager 获取 Connection"]
    B --> C["setAutoCommit(false)"]
    C --> D["Connection 绑定到 ThreadLocal"]
    D --> E["Mapper / Repository 执行 SQL"]
    E --> F["从当前线程拿同一个 Connection"]
    F --> G["commit 或 rollback"]
    G --> H["解绑并归还连接"]

如果不同 SQL 用的是不同连接,就无法由同一个本地事务统一提交/回滚。

这也是为什么:

  1. 多数据源事务要特别设计。
  2. 异步线程里访问数据库不会自动继承当前事务。
  3. 事务中开启新线程执行 SQL,通常不在原事务内。
  4. 连接池耗尽会让事务无法开始或执行缓慢。

MyBatis 如何参与事务

Spring + MyBatis 中,Mapper 通常通过 SqlSessionTemplate 执行。

mermaid
flowchart TD
    A["@Transactional 开启事务"] --> B["Connection 绑定当前线程"]
    B --> C["Service 调用 Mapper"]
    C --> D["SqlSessionTemplate 获取 SqlSession"]
    D --> E["从 Spring 事务上下文拿 Connection"]
    E --> F["MyBatis Executor 执行 SQL"]
    F --> G["事务结束由 Spring 统一提交或回滚"]

MyBatis 自己也能管理事务,但在 Spring 项目中一般交给 Spring。

错误示例:

java
SqlSession session = sqlSessionFactory.openSession(true);
session.insert("insertOrder", order);

如果手动开启自动提交,可能绕过 Spring 事务管理,导致外层回滚不了这条 SQL。商业项目里 Mapper 应该由 Spring 管理,不要在业务代码里随意手动创建 SqlSession

JPA/Hibernate 如何参与事务

JPA 的特点是事务提交前会 flush。

mermaid
flowchart TD
    A["@Transactional 开启事务"] --> B["EntityManager 绑定当前线程"]
    B --> C["Repository 查询 Entity"]
    C --> D["Entity 进入持久化上下文"]
    D --> E["业务代码修改托管实体"]
    E --> F["事务提交前 flush"]
    F --> G["脏检查生成 SQL"]
    G --> H["commit 或 rollback"]

MyBatis 是你调用 Mapper 时就明确执行 SQL。JPA/Hibernate 则可能先修改对象,等 flush 时统一生成 SQL。

这带来两个重要差异:

  1. JPA 事务内修改托管实体,不调用 save 也可能更新数据库。
  2. JPA 查询前可能自动 flush,所以“只是执行查询”前也可能先发 update。

如果不了解这个过程,就会觉得 Hibernate “莫名其妙发 SQL”。

传播行为怎么理解

事务传播行为解决的是:一个事务方法调用另一个事务方法时,内层方法应该加入外层事务,还是开启新事务。

传播行为含义常见场景
REQUIRED有事务就加入,没有就新建默认,绝大多数业务
REQUIRES_NEW总是新建事务,挂起外层事务独立审计日志、失败也要记录
SUPPORTS有事务就加入,没有就非事务执行可有可无的只读查询
MANDATORY必须已有事务,没有就报错强制只能在业务事务中调用
NOT_SUPPORTED挂起事务,非事务执行不希望占用事务的慢查询
NEVER有事务就报错明确禁止事务的操作
NESTED嵌套事务,通常基于保存点局部失败回滚到 savepoint

REQUIRED

mermaid
flowchart TD
    A["outer REQUIRED"] --> B{"当前有事务?"}
    B -->|"有"| C["加入当前事务"]
    B -->|"无"| D["新建事务"]

最常用。创建订单、扣库存、写日志通常都应该在一个 REQUIRED 事务中。

REQUIRES_NEW

mermaid
flowchart TD
    A["外层事务"] --> B["调用内层 REQUIRES_NEW"]
    B --> C["挂起外层事务"]
    C --> D["开启新事务"]
    D --> E["内层提交或回滚"]
    E --> F["恢复外层事务"]

适合“外层失败也要保存”的独立记录,例如失败审计日志。但要小心:

  1. 会多占用一个数据库连接。
  2. 内层提交后,外层回滚不会撤销内层提交。
  3. 滥用会破坏整体一致性。

NESTED

NESTED 通常依赖数据库保存点。内层失败可以回滚到保存点,而不是回滚整个外层事务。

不是所有事务管理器和数据库场景都可靠支持,使用前要验证。

回滚规则

Spring 默认只对运行时异常和 Error 回滚。

java
@Transactional
public void create() throws IOException {
    orderMapper.insert(order);
    throw new IOException("文件失败");
}

默认情况下,受检异常 IOException 不一定触发回滚。

推荐:

java
@Transactional(rollbackFor = Exception.class)
public void create() {
    // ...
}

常见回滚失败原因:

问题为什么
异常被 catch 后没有抛出事务拦截器以为方法正常结束
抛的是受检异常默认不回滚
同类自调用没经过代理
方法不是 public代理可能无法拦截
数据源不一致事务管理器管不到另一个连接
手动提交连接绕过 Spring 事务

错误示例:

java
@Transactional(rollbackFor = Exception.class)
public void createOrder() {
    try {
        orderMapper.insert(order);
        stockMapper.deduct(skuId, count);
    } catch (Exception e) {
        log.error("create order failed", e);
        // 吞掉异常,外层会提交事务
    }
}

正确做法:

java
@Transactional(rollbackFor = Exception.class)
public void createOrder() {
    try {
        orderMapper.insert(order);
        stockMapper.deduct(skuId, count);
    } catch (Exception e) {
        log.error("create order failed", e);
        throw e;
    }
}

事务里为什么不要做远程调用

错误示例:

java
@Transactional(rollbackFor = Exception.class)
public void pay(String orderNo) {
    orderMapper.updatePaying(orderNo);
    paymentClient.pay(orderNo); // 慢远程调用
    orderMapper.updatePaid(orderNo);
}

问题:

  1. 远程调用期间数据库连接一直被占用。
  2. 事务锁一直持有,其他请求可能阻塞。
  3. 远程调用超时会导致事务时间不可控。
  4. 支付成功但本地回滚,会出现外部状态和本地状态不一致。

更合理的思路:

  1. 本地事务只更新本地状态和必要记录。
  2. 远程调用放事务外,或用状态机/异步任务推进。
  3. 外部系统结果通过回调、补偿任务、对账修正。

本地事务解决不了跨系统原子一致性,跨系统要用分布式事务、事务消息、本地消息表、Saga、TCC、最大努力通知等方案。

事务后发送消息

不要在事务未提交时直接发 MQ。

java
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
    orderMapper.insert(order);
    mqClient.send("ORDER_CREATED", order.getOrderNo());
}

如果 MQ 发出后数据库回滚,下游会收到一个数据库里不存在的订单。

可以使用事务提交后回调:

java
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
    orderMapper.insert(order);

    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            mqClient.send("ORDER_CREATED", order.getOrderNo());
        }
    });
}

这能避免“回滚后发消息”,但不能保证“提交后消息一定发成功”。如果 JVM 在 commit 后、send 前宕机,消息仍会丢。

本地消息表

可靠性要求高时,用本地消息表。

mermaid
flowchart TD
    A["业务事务开始"] --> B["写订单表"]
    B --> C["写本地消息表 status=NEW"]
    C --> D["提交本地事务"]
    D --> E["后台任务扫描 NEW 消息"]
    E --> F["发送 MQ"]
    F --> G{"发送成功?"}
    G -->|"是"| H["更新消息 status=SENT"]
    G -->|"否"| I["保留 NEW 或 RETRY,等待重试"]

表设计示例:

sql
create table local_message (
    id bigint primary key auto_increment,
    biz_type varchar(64) not null,
    biz_id varchar(128) not null,
    topic varchar(128) not null,
    payload text not null,
    status varchar(32) not null,
    retry_count int not null default 0,
    next_retry_time datetime not null,
    create_time datetime not null,
    update_time datetime not null,
    unique key uk_biz_msg (biz_type, biz_id)
);

关键点:

  1. 业务数据和消息记录在同一个本地事务提交。
  2. 发送 MQ 由后台任务重试。
  3. 消费端必须幂等,因为消息可能重复发送。
  4. 长期失败要告警和人工处理。

批量导入事务怎么设计

错误做法:十万条数据放一个事务。

问题:

  1. 事务太大,undo/redo 压力大。
  2. 锁持有时间长。
  3. 连接长时间占用。
  4. 回滚成本巨大。
  5. JPA 持久化上下文可能堆积大量实体导致 OOM。

推荐拆批:

java
public void importAssets(List<AssetImportRow> rows) {
    int batchSize = 500;
    for (int i = 0; i < rows.size(); i += batchSize) {
        List<AssetImportRow> batch = rows.subList(i, Math.min(i + batchSize, rows.size()));
        assetImportTxService.importBatch(batch);
    }
}
java
@Service
public class AssetImportTxService {
    @Transactional(rollbackFor = Exception.class)
    public void importBatch(List<AssetImportRow> batch) {
        for (AssetImportRow row : batch) {
            assetMapper.insert(toAsset(row));
        }
    }
}

注意:拆到另一个 Bean,是为了让每个批次调用都经过事务代理。

JPA 批量导入还要考虑:

java
@PersistenceContext
private EntityManager entityManager;

@Transactional(rollbackFor = Exception.class)
public void importBatch(List<Asset> assets) {
    for (int i = 0; i < assets.size(); i++) {
        entityManager.persist(assets.get(i));
        if (i % 100 == 0) {
            entityManager.flush();
            entityManager.clear();
        }
    }
}

flush 把 SQL 发出去,clear 清理持久化上下文,避免托管实体越积越多。

MyBatis 和 JPA 的事务差异

对比MyBatisJPA/Hibernate
执行 SQL 时机调用 Mapper 时执行可能在 flush 时统一执行
修改数据方式明确调用 insert/update/delete修改托管实体字段即可
缓存核心SqlSession 一级缓存Persistence Context 一级缓存
常见坑手动 SqlSession、事务不生效懒加载、脏检查、merge、N+1
排查重点Mapper SQL、参数、执行计划SQL 日志、flush、实体状态、关联加载

它们共同点是:最终都依赖同一个 Spring 事务绑定的数据库连接来提交或回滚。

线上排查流程

事务不生效

mermaid
flowchart TD
    A["发现事务没有回滚"] --> B{"方法是否经过 Spring 代理?"}
    B -->|"否"| C["检查同类自调用、private/final、手动 new"]
    B -->|"是"| D{"异常是否抛出到代理层?"}
    D -->|"否"| E["检查 catch 吞异常"]
    D -->|"是"| F{"异常类型是否触发回滚?"}
    F -->|"否"| G["配置 rollbackFor"]
    F -->|"是"| H{"是否同一个数据源/连接?"}
    H -->|"否"| I["检查多数据源、手动连接、手动提交"]
    H -->|"是"| J["检查事务传播行为和外层事务"]

锁等待严重

排查:

  1. 找到慢事务和持锁 SQL。
  2. 看事务里是否有远程调用、sleep、批量过大。
  3. 检查更新条件是否命中索引。
  4. 检查是否大范围更新或删除。
  5. 缩短事务范围,拆批,优化索引。

连接池耗尽

排查:

  1. 事务是否执行太久。
  2. 是否有外部接口调用被包进事务。
  3. 是否批量任务并发过高。
  4. 是否 REQUIRES_NEW 嵌套导致一个线程占多个连接。
  5. 是否连接泄漏或手动连接未关闭。

数据库回滚了但 MQ 发出去了

排查:

  1. 是否在事务内直接发送 MQ。
  2. 是否用了 afterCommit。
  3. 是否需要本地消息表保证最终发送。
  4. 消费端是否幂等。
  5. 是否有补偿和对账任务。

面试标准回答

事务为什么放 Service 层

text
Service 层代表一个完整业务动作,通常会调用多个 Mapper 或 Repository。事务放 Service 层可以保证这一组数据库操作共用同一个连接并一起提交或回滚。Controller 层事务范围太大,Mapper 层只能覆盖单条数据访问,都不适合表达业务原子性。

Spring 事务为什么会失效

text
Spring 声明式事务通常基于代理。事务失效常见原因是同类自调用没有经过代理、方法不是 public、异常被 catch 吞掉、抛出受检异常但未配置 rollbackFor、多数据源事务管理器不一致、手动创建连接或 SqlSession 绕过 Spring 管理。

MyBatis 和 JPA 在事务里有什么区别

text
MyBatis 通常在调用 Mapper 方法时明确执行 SQL;JPA/Hibernate 会管理托管实体状态,事务提交前 flush 时通过脏检查生成 SQL。两者最终都依赖 Spring 事务绑定到当前线程的数据库连接来统一提交或回滚。

事务里为什么不要发 MQ 或调远程接口

text
远程调用会拉长事务时间,占用连接和锁,失败语义也不受本地数据库事务控制。事务内直接发 MQ 还可能出现消息发出后数据库回滚。一般使用事务提交后回调或本地消息表,可靠性更高的场景要配合幂等、重试、补偿和对账。

本地消息表解决什么问题

text
本地消息表把业务数据和待发送消息记录放在同一个本地事务中提交,保证业务成功时一定留下消息发送任务。后台任务扫描消息表发送 MQ,失败可重试。它解决的是本地事务和消息发送之间的最终一致性问题,但消费端仍必须幂等。

关联知识点

  1. MyBatis 核心全过程原理
  2. Hibernate/JPA 核心全过程原理
  3. Spring 事务
  4. MySQL redo log 与 binlog
  5. 分布式事务
  6. Kafka 可靠性幂等与事务
  7. 可靠消息最终一致性

本章小结

ORM 事务的本质不是注解,而是业务边界、数据库连接、代理拦截、提交回滚规则共同作用。Service 层开启事务后,Spring 把连接绑定到当前线程,MyBatis/JPA 在这个连接上执行 SQL,方法成功提交,异常按规则回滚。所有事务问题都可以沿着这条线排查:有没有经过代理、有没有同一个连接、异常有没有抛出、传播行为是否符合预期、事务范围是否过大。