反模式与排查宝典:数据访问与事务深度篇

4 阅读1小时+

概述

通过前面 10 篇文章的深度剖析,从数据访问异常体系、JdbcTemplate 到 Spring 事务抽象、声明式事务、ORM 整合、OSIV、多数据源乃至分布式事务,我们已经建立了一个完整的 Spring 数据访问与事务知识图谱。本文将前面的正向设计知识转化为逆向排错能力,集中曝光那些反复出现、容易导致生产事故的事务与数据访问反模式,并提炼出一套以 SQL 日志、事务同步器日志、线程堆栈和 Arthas 动态追踪为核心的标准化诊断工具箱。

Spring 数据访问与事务管理是业务系统中最核心、最脆弱的环节之一。一个看似简单的 @Transactional 可能在幕后隐藏着自调用失效、传播行为错误、异常类型不匹配等多重陷阱;一个顺手开启的 open-in-view 可能在高并发下引发数据库连接池耗尽;而分布式事务的引入更是将数据一致性问题从单体放大了几个数量级。本文将 Spring 数据访问与事务开发中常见的 25+ 个反模式归纳为九大领域,每个反模式都结合前文讲过的核心链路进行深度剖析,并提炼出以 p6spyArthasTransactionManager 日志和 Actuator 端点为核心的通用排查方法,帮助开发者在面对复杂的数据与事务问题时快速定位根因。

核心要点

  • 反模式九大领域:异常处理、JdbcTemplate 资源管理、事务传播行为、声明式事务失效、JPA 与 Repository、MyBatis 整合、OSIV 与持久化上下文、多数据源、分布式事务。
  • 统一剖析结构:每个反模式都按照“错例→现象→排查→根因→修正→实践”的固定结构展开。
  • 诊断工具箱:结合 SQL 代理日志、TransactionSynchronizationManager 回调、jstack 线程分析、Arthas/BTrace 动态追踪、/actuator/metrics 等,并提供工具→反模式映射表
  • 根因溯源:所有反模式的根因都直接回溯到前文讲解过的核心源码机制,形成“正向学习→逆向排错”的完整闭环。

文章组织架构图

flowchart TD
    subgraph S1 ["全景与诊断"]
        n1["1. 反模式总览与分类"]
        n11["11. 诊断工具集、映射表与标准化排查流程"]
    end

    subgraph S2 ["九大领域"]
        n2["2. 数据访问异常处理反模式"]
        n3["3. JdbcTemplate 与资源管理反模式"]
        n4["4. 事务传播行为与抽象反模式"]
        n5["5. 声明式事务失效反模式"]
        n6["6. Spring Data JPA 与 Repository 反模式"]
        n7["7. MyBatis 整合反模式"]
        n8["8. 持久化上下文与 OSIV 反模式"]
        n9["9. 多数据源与读写分离反模式"]
        n10["10. 分布式事务反模式"]
    end

    n12["12. 面试高频专题"]

    n1 --> n2
    n2 --> n3
    n3 --> n4
    n4 --> n5
    n5 --> n6
    n6 --> n7
    n7 --> n8
    n8 --> n9
    n9 --> n10
    n10 --> n11
    n11 --> n12

    classDef topic fill:#f8f9fa,stroke:#333,stroke-width:2px,rx:5,color:#333;
    class n1,n2,n3,n4,n5,n6,n7,n8,n9,n10,n11,n12 topic;
    class S1,S2 topic;

架构图说明

  • 总览说明:全文 12 个模块从反模式分类全景出发,首先通过一张总览表(模块 1)让读者建立全局认知。随后深入九大领域(模块 2-10),逐一揭露 20+ 个典型错误。最后通过诊断工具集、映射表与决策树(模块 11)和面试专题(模块 12)赋能实战,形成从现象到根因再到解决方案的完整闭环。
  • 逐模块说明
    • 模块 1:提供所有反模式的风险等级与现象速查表,并配有全景分类图。
    • 模块 2-10:每个领域精选 2-3 个最具代表性的反模式,严格按“错例→现象→排查→根因→修正→实践”六步法进行深度剖析。
    • 模块 11:系统化汇总所有诊断工具,提供关键的工具→反模式映射表,并构建标准化排查决策树,是本文的实战中枢。
    • 模块 12:独立于正文的面试高频专题,覆盖核心排查思路与系统设计题,侧重数据访问特有的源码机制。
  • 关键结论掌握 Spring 数据访问与事务的底层调度机制,是高效排查事务回滚失败、连接泄漏、数据不一致等一切数据层问题的根本基础。

1. 反模式总览与分类

在深入剖析之前,我们先将所有即将讨论的反模式进行分类汇总,以便读者形成整体印象,并作为后续排查时的快速索引。

反模式名称领域风险等级可能现象
1. 异常粒度混淆异常处理业务逻辑误判,关键错误被吞没
2. SQLErrorCodes 缺失异常处理新数据库错误码无法识别,上层重试逻辑失效
3. RowMapper 内连接泄漏JdbcTemplate数据库连接池耗尽,应用无响应
4. queryForList 大结果集JdbcTemplateOutOfMemoryError,JVM 崩溃
5. REQUIRES_NEW 误用事务传播部分提交,数据逻辑不一致
6. NESTED 兼容性陷阱事务传播事务创建失败,业务中断
7. 多线程事务上下文丢失事务失效多线程任务未在预期事务中执行
8. @Async 与事务交织事务失效异步操作事务上下文丢失或行为异常
9. 非事务引擎静默失效事务失效声明式事务未生效,无任何异常
10. SimpleJpaRepository 冲突JPA事务行为不符合预期,更新操作失败
11. 分页查询缺少 countQueryJPA慢 SQL,数据库负载飙升
12. save 循环触发频繁 flushJPA性能低下,大量不必要的 SQL 交互
13. SqlSession 一级缓存混乱MyBatis读到脏数据,同一查询结果不一致
14. MapperScan 扫描过宽MyBatis启动变慢,可能注册错误的 Bean
15. OSIV N+1 风暴持久化上下文连接池耗尽,接口响应时间极长
16. 懒加载异常 LazyInitEx持久化上下文无 OSIV 时视图层访问延迟属性抛异常
17. 路由数据源事务内失效多数据源读写分离在事务中完全失效
18. @Async 从库查询误入主库多数据源主库压力增大,读写分离形同虚设
19. Seata 全局锁超时分布式事务业务大面积挂起,最终全部回滚
20. Seata undo_log 膨胀分布式事务磁盘空间耗尽,数据库性能下降
21. MQ 消费者未实现幂等分布式事务重复消费,导致业务数据错误

1.1 反模式全景分类图

flowchart LR
    A["数据访问与事务反模式"]

    A --> B["异常处理"]
    B --> B1["案例1: 异常粒度混淆"]
    B --> B2["案例2: SQLErrorCodes 缺失"]

    A --> C["JdbcTemplate"]
    C --> C1["案例3: RowMapper 连接泄漏"]
    C --> C2["案例4: queryForList OOM"]

    A --> D["事务传播与抽象"]
    D --> D1["案例5: REQUIRES_NEW 误用"]
    D --> D2["案例6: NESTED 兼容性陷阱"]

    A --> E["声明式事务"]
    E --> E1["案例7: 多线程上下文丢失"]
    E --> E2["案例8: @Async 与事务交织"]
    E --> E3["案例9: 非事务引擎失效"]

    A --> F["Spring Data JPA"]
    F --> F1["案例10: SimpleJpaRepository 冲突"]
    F --> F2["案例11: Page 缺少 countQuery"]
    F --> F3["案例12: save 循环与 clear"]

    A --> G["MyBatis 整合"]
    G --> G1["案例13: SqlSession 一级缓存混乱"]
    G --> G2["案例14: MapperScan 扫描与批处理"]

    A --> H["持久化上下文与 OSIV"]
    H --> H1["案例15: OSIV 引发 N+1 风暴"]
    H --> H2["案例16: 懒加载异常 LazyInitEx"]

    A --> I["多数据源与读写分离"]
    I --> I1["案例17: 路由数据源事务内失效"]
    I --> I2["案例18: @Async 从库误入主库"]

    A --> J["分布式事务"]
    J --> J1["案例19: Seata 全局锁超时"]
    J --> J2["案例20: Seata undo_log 膨胀"]
    J --> J3["案例21: MQ 消费者未实现幂等"]

    classDef root fill:#f8f9fa,stroke:#333,stroke-width:2px,rx:5,color:#333;
    classDef category fill:#e9ecef,stroke:#6c757d,stroke-width:2px,color:#333;
    classDef case fill:#ffffff,stroke:#adb5bd,stroke-width:1px,color:#333;
    class A root;
    class B,C,D,E,F,G,H,I,J category;
    class B1,B2,C1,C2,D1,D2,E1,E2,E3,F1,F2,F3,G1,G2,H1,H2,I1,I2,J1,J2,J3 case;

图表说明

  • 总览:本图以全景观视角展示了九大反模式领域及其下属的具体案例,清晰地呈现了 Spring 数据访问与事务管理中的风险地图。
  • 分类逻辑:从底层的异常处理、JdbcTemplate 资源管理,到中间层的事务抽象、声明式事务、ORM 整合,再到上层的 OSIV、多数据源和分布式事务,覆盖了从单体到分布式的完整链路。
  • 风险定位:每个节点都代表一个具体的反模式,读者可以根据实际遇到的现象,快速在此图中定位到可能的犯错领域,为后续的精准排查提供方向。
  • 系统性:此图将散落在各篇章的“注意事项”汇聚成一个完整的反模式知识网络,体现了本文“避坑与排错”的核心主线。

2. 数据访问异常处理反模式(案例 1-2)

异常的精确捕获与处理是构建健壮数据访问层的第一步。DataAccessException 作为 Spring 设计的免检异常体系(详见第 1 篇),其优势在于解耦,但其抽象层次也容易成为开发者“懒政”的温床。

案例 1:将 DuplicateKeyException 作为普通 DataAccessException 捕获

错误示例

// 错误示例:粗粒度捕获异常
public void registerUser(User user) {
    try {
        userRepository.save(user);
    } catch (DataAccessException e) { // 将所有数据访问异常都归为“保存失败”
        logger.error("用户数据保存失败: {}", e.getMessage());
        // 统一抛出业务异常,前端仅提示“操作失败”
        throw new BusinessException("操作失败,请重试");
    }
}

现象描述 用户在注册时,如果使用了已存在的用户名或邮箱(数据库有唯一约束),期望得到的是“用户名/邮箱已被占用”的明确提示,但实际看到的却是模糊的“操作失败”。业务日志中只记录了 DataAccessException,运维人员无法直接从中看出是“冲突”还是“连接超时”等其他数据库错误。

排查思路

  1. 日志检查:查看业务日志,发现大量“用户数据保存失败”,但无法区分具体错误类型。
  2. SQL 日志分析:开启 SQL 日志(如 logging.level.org.hibernate.SQL=DEBUG),发现事务回滚前的最后一条 SQL 是 INSERT 语句。手动执行该 SQL,得到 Duplicate entry 错误。
  3. 异常断点:在 catch (DataAccessException e) 上设置断点,运行时观察异常对象的运行时类型。

根因分析 在 Spring 的 DataAccessException 体系中,DataAccessException 是顶层抽象。其下细分了 TransientDataAccessExceptionNonTransientDataAccessException 等,而 DuplicateKeyException 正是 NonTransientDataAccessException 的子类,它明确指出了问题。save 操作底层(如 ORM 框架)会捕获 JDBC 驱动抛出的 SQLIntegrityConstraintViolationException,Spring 的 SQLExceptionTranslator(详见第 1 篇)会根据 SQLErrorCodes 将其准确地翻译为 DuplicateKeyException。在上面的错误示例中,我们用一个宽泛的 DataAccessException 捕获了它,这相当于无视了 Spring 异常体系提供的精确信息,导致“信息丢失”。

修正方案

// 正确示例:精细化解捕获异常
public void registerUser(User user) {
    try {
        userRepository.save(user);
    } catch (DuplicateKeyException e) { // 优先捕获更具体的异常
        logger.warn("用户注册唯一键冲突: {}", user.getUsername());
        throw new BusinessException("用户名或邮箱已被占用");
    } catch (DataAccessException e) { // 再捕获其他通用的数据访问异常
        logger.error("用户数据保存失败,未知数据库错误", e);
        throw new BusinessException("系统繁忙,请稍后重试");
    }
}

最佳实践

  • 精细捕获:接收块中应优先捕获 DuplicateKeyExceptionDataIntegrityViolationExceptionDeadlockLoserDataAccessException 等具象异常,最后才兜底捕获 DataAccessException
  • 利用异常翻译:如果自行管理 JDBC 连接,应使用 SQLExceptionTranslator 将原生 SQLException 翻译成 Spring 的 DataAccessException 体系,而不是直接抛出或处理原生异常。

案例 2:未配置自定义 SQLErrorCodes,新数据库错误码被翻译为通用异常

错误示例 项目切换到了符合 SQL/MED 标准的新数据库厂商,但未在配置中提供自定义的 SQLErrorCodes。当此数据库抛出一个其特有的、用于标识“乐观锁更新失败”的特定错误码(例如 99001)时,系统期望能捕获到 Spring 的 OptimisticLockingFailureException 并触发重试逻辑。

现象描述 重试逻辑未生效,事务直接回滚。日志显示 Spring 框架将该错误码翻译为了一个通用的 DataIntegrityViolationExceptionUncategorizedDataAccessException,导致上层 AOP 切面根据异常类型匹配的“重试”逻辑无法命中。

排查思路

  1. 异常类型确认:在日志中搜索 DataAccessException,发现特定错误码 99001 被包装为 UncategorizedDataAccessException
  2. 源码溯源:进入 UncategorizedDataAccessException 的构造点,发现调用链来自 SQLExceptionSubclassTranslatorSQLErrorCodeSQLExceptionTranslator
  3. 配置检查:检查 spring-boot-autoconfigureJdbcTemplateAutoConfiguration 或相关配置,未发现针对该数据库厂商的自定义 SQLErrorCodes 配置。

根因分析 Spring 通过 SQLErrorCodeSQLExceptionTranslator 完成异常翻译的核心工作。它依赖 SQLErrorCodes 来查找某个数据库错误码对应的具体 DataAccessException 类型(详见第 1 篇)。 SQLErrorCodes 的加载机制如下:

  1. 尝试从 spring-database-idConnection.getMetaData().getDatabaseProductName() 获取 databaseProductName
  2. 从类路径下的 org/springframework/jdbc/support/sql-error-codes.xml 文件中查找与 databaseProductName 匹配的 SQLErrorCodes。 如果该文件没有为你的数据库产品名定义条目,或者该条目下没有包含错误码 99001 的映射,Spring 就会使用兜底的 SQLExceptionSubclassTranslator 或将其归类为 UncategorizedDataAccessException

修正方案 创建一个自定义的 SQLErrorCodes,并通过编程方式或覆盖 XML 配置的方式进行注册。

// 方案一:在配置类中编程式配置
@Configuration
public class CustomDataAccessConfig {

    @Bean
    public SQLErrorCodes customSqlErrorCodes(DataSource dataSource) {
        SQLErrorCodes errorCodes = new SQLErrorCodes();
        // 通过 DataSource 获取数据库产品名,或手动设置
        errorCodes.setDatabaseProductName("MyNewDB");
        errorCodes.setDataIntegrityViolationCodes(new String[]{"99001"}); // 映射到正确的异常子类
        return errorCodes;
    }

    // 需要将自定义的 SQLErrorCodes 注册到翻译器中,这通常通过覆盖 Spring Boot 的自动配置完成
    // 一种更简单的方式是继承并覆盖 JdbcTemplate 的默认翻译器
}
// 方案二:让 DBA/运维在数据库中执行,可能更简单
// 修改 Spring 能从 DataSource 元数据中匹配到的数据库产品名,
// 但强烈不建议在生产环境篡改数据库元数据。

最佳实践

  • 明确产品名:确保 Connection.getMetaData().getDatabaseProductName() 返回的名称与 sql-error-codes.xml 中定义的一致。可以通过配置数据源连接属性 spring.datasource.hikari.data-source-properties.databaseProductName=MySQL 等方式进行微调。
  • 自定义映射:对于新数据库或特化版本,始终应在项目中通过编程方式配置一个 SQLErrorCodes Bean,覆盖默认定义,确保业务逻辑依赖的特有错误码能被精确翻译。

3. JdbcTemplate 与资源管理反模式(案例 3-4)

JdbcTemplate 极大简化了 JDBC 操作,并内置了一套健壮的资源管理模式(详见第 2 篇)。然而,当开发者试图绕过它或在内部使用低级 API 时,问题便接踵而至。

案例 3:在 RowMapper 内部自行获取 JDBC 连接执行查询

错误示例

// 错误示例:在 RowMapper 内部再次获取连接执行查询
public List<UserOrder> getUserOrders(String userId) {
    String sql = "SELECT * FROM orders WHERE user_id = ?";
    return jdbcTemplate.query(sql, new Object[]{userId}, (rs, rowNum) -> {
        UserOrder order = new UserOrder();
        // ... 从 rs 映射基础信息 ...
        // 严重错误!在 RowMapper 中获取新的 JDBC 连接,从 DataSource 直接获取
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement("SELECT * FROM items WHERE order_id = ?")) {
            ps.setLong(1, order.getId());
            try (ResultSet itemRs = ps.executeQuery()) {
                while (itemRs.next()) {
                    // ... 映射订单项 ...
                }
            }
        }
        return order;
    });
}

现象描述 系统上线初期运行正常,但一段时间后(特别是高并发时),所有请求开始无限期 hang 住,直至超时。监控显示数据库连接池(如 HikariCP)活跃连接数持续攀升,最终达到最大值,并有大量线程处于 java.sql.DriverManager.getConnection 或连接池 getConnection 的非活动等待状态。

排查思路

  1. 监控指标:检查 Actuator /metrics 或数据库连接池内置监控,发现 hikaricp.connections.Active 持续处于高位,hikaricp.connections.Pending 数量激增。
  2. 线程堆栈:使用 jstack <pid> 抓取线程堆栈,发现大量业务线程阻塞在等待数据库连接,调用栈指向 DataSource.getConnection()
  3. 代码审查:审查所有调用 dataSource.getConnection() 的地方。RowMapper 中的二次获取连接是典型“代码异味”。
  4. 日志:查看应用启动及运行日志,没有发现显式的连接泄漏日志(因为连接并未泄露,而是被持有)。

根因分析 JdbcTemplate.query 在执行时会执行一套严密的资源获取-执行-释放流程(详见第 2 篇):

  1. DataSource 获取一个连接。
  2. 创建 PreparedStatement,执行查询得到 ResultSet
  3. 遍历 ResultSet,并对每一行调用 RowMapper.mapRow(rs, rowNum)
  4. 关闭 ResultSetStatement,将连接归还到连接池。

问题在于,在步骤 3 的 mapRow 方法内部,我们又调用了 dataSource.getConnection() 获取了第二个连接。这会导致:

  • 连接耗尽:遍历一个包含 100 条结果的主查询,如果在每行映射时都打开一个新连接,就会瞬间从连接池中取走 100 个连接。如果此时有多个线程并发执行,连接池很快就会耗尽。
  • 阻塞等待:主查询的连接在等待 RowMapper 完成所有映射并返回后才能被释放。而 RowMapper 内部又在等待获取新连接。当连接池无空闲时,就形成了死锁式的相互等待,导致所有线程阻塞。

修正方案 坚决杜绝此类 N+1 查询。应使用 JOIN 一次性查回,或在循环外批量查询。

// 正确方案 1:使用 JOIN 查询,一步到位
String joinSql = "SELECT o.*, i.* FROM orders o LEFT JOIN items i ON o.id = i.order_id WHERE o.user_id = ?";
// 使用 ResultSetExtractor 而非 RowMapper 来组装嵌套对象
public List<UserOrder> getUserOrdersWithJoin(String userId) {
    return jdbcTemplate.query(joinSql, new Object[]{userId}, rs -> {
        Map<Long, UserOrder> orderMap = new LinkedHashMap<>();
        while(rs.next()) {
            // ... 组装逻辑 ...
        }
        return new ArrayList<>(orderMap.values());
    });
}

// 正确方案 2:如果必须分开查询,在 query 外部先获取所有 IDs,再进行批量查询
public List<UserOrder> getUserOrdersWithBatch(String userId) {
    List<UserOrder> orders = jdbcTemplate.query("SELECT * FROM orders WHERE user_id = ?", 
        new BeanPropertyRowMapper<>(UserOrder.class), userId);
    if (!orders.isEmpty()) {
        List<Long> orderIds = orders.stream().map(UserOrder::getId).collect(Collectors.toList());
        List<Item> items = jdbcTemplate.query(
            "SELECT * FROM items WHERE order_id IN (?)", // 注意:jdbcTemplate 对 IN 支持不佳,此处仅为示例
            new BeanPropertyRowMapper<>(Item.class), orderIds.toArray()
        );
        // ... 组装逻辑 ...
    }
    return orders;
}

最佳实践

  • 禁止在 RowMapper 中获取连接:这是 JdbcTemplate 使用的“铁律”。RowMapper 应该是纯粹的无状态映射函数。
  • 优先使用 JOIN:能用 SQL 完成的,不要用 Java 代码迭代。
  • 流式处理:对于确实无法用一次 JOIN 解决的问题,使用 query 的重载,利用有状态的 ResultSetExtractor 来处理。

案例 4:使用 queryForList 加载大结果集导致 OutOfMemoryError

错误示例

// 错误示例:不加限制地使用 queryForList 加载全表数据
public List<Map<String, Object>> processAllTransactions() {
    // 假设 transaction 表有上千万行数据
    return jdbcTemplate.queryForList("SELECT * FROM transactions");
}

现象描述 一个批处理或报表导出任务启动后,JVM 迅速发生 Full GC,CPU 飙升,最终应用进程因 java.lang.OutOfMemoryError: Java heap space 而崩溃。

排查思路

  1. JVM 日志:查看 hs_err_pid<pid>.log 或 GC 日志,确认是堆内存溢出。
  2. 堆 Dump:在 OOM 前或崩溃后分析 Heap Dump 文件(如使用 Eclipse MAT)。可以看到巨大的 ArrayList 对象持有海量的 HashMap(每行数据为一个 Map),占据了绝大部分堆内存。
  3. 代码定位:通过线程栈或 Dump 中的引用链,可以轻松定位到调用 queryForList 的方法。

根因分析 JdbcTemplate.queryForList(String sql) 的源码实现是委托给 queryForList(sql, args, getSingleColumnRowMapper(...)) 的,它内部会调用 query 方法,并将 RowMapper 应用于 ResultSet 的每一行,最后将所有结果放入一个 List 中。 当结果集非常庞大(如上千万行)时,这个 List 将完全驻留在 JVM 堆内存中,导致内存溢出。尽管 JdbcTemplate 自身能很好地管理连接和语句,但它无法阻止我们构建一个超大的 List 并将其返回。

修正方案 采用流式处理,使用 RowCallbackHandlerResultSetExtractor 搭配具备可滚动游标的 PreparedStatement

// 方案一:使用 RowCallbackHandler,处理完毕后即丢弃
public void processAllTransactionsWithHandler() {
    jdbcTemplate.query("SELECT * FROM transactions", (RowCallbackHandler) rs -> {
        // 流式处理每一行,例如写入文件或逐条推送消息队列
        String txId = rs.getString("id");
        // ... 处理逻辑 ...
    });
}

// 方案二:使用底层 Statement 游标,确保逐条拉取(需要数据库驱动和数据库支持)
public void processAllTransactionsWithCursor(DataSource dataSource) {
    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement("SELECT * FROM transactions",
                 ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
        ps.setFetchSize(Integer.MIN_VALUE); // MySQL 特定方式,逐条拉取
        // ps.setFetchSize(100); // 其他数据库,如 PostgreSQL,设置批次大小
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                // 逐条处理...
            }
        }
    } catch (SQLException e) {
        // 使用 SQLExceptionTranslator 转换为 Spring 异常
    }
}

最佳实践

  • 大结果集必流式:任何可能返回海量数据的查询,都应使用 RowCallbackHandler 或在更底层设置 FetchSize 实现真正的流式或分批拉取,绝不可将全量数据加载到内存。
  • 分页是首选:对于 Web 应用的后端查询,分页是更好的选择,既能防止内存溢出,也能提供更好的用户体验。

4. 事务传播行为与抽象反模式(案例 5-6)

传播行为定义了事务方法的边界和逻辑范围(详见第 3 篇)。对其理解不到位,轻则导致性能问题,重则造成数据不一致等严重生产事故。

案例 5:REQUIRES_NEW 误用,导致数据逻辑不一致

错误示例 一个创建订单的业务流程,业务逻辑要求:创建订单失败不应影响操作日志的记录。开发者错误地认为,只要记录日志的方法使用 REQUIRES_NEW 并在内部吞掉异常,就能达到“不管订单成败,日志都独立提交”的效果。

@Service
public class OrderService {

    @Autowired
    private LogService logService;

    @Autowired
    private OrderRepository orderRepository;

    // 外层事务
    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
        // 假设此步因库存不足校验失败,抛出异常
        this.checkInventory(order);
    }
}

@Service
public class LogService {
    // 内层事务,标记为 REQUIRES_NEW
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logOperation(String action) {
        // 记录业务操作...
    }
}

现象描述 假设 createOrder 方法在 save 订单后,在 checkInventory 阶段抛出了 RuntimeException。外层事务回滚,数据库中没有新订单记录,这符合预期。但内层 logService.logOperation 所记录的操作日志却成功提交,并持久化到了数据库中。这就导致了数据逻辑不一致:一条“成功创建订单”的操作日志,找不到对应的订单实体。

排查思路

  1. 业务数据核对:核对订单表和操作日志表,发现存在孤立日志。
  2. 事务日志:开启 Spring 事务 TRACE 级别日志,观察事务传播过程。会看到以下关键信息:
    • Creating new transaction with name [com.example.OrderService.createOrder]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
    • Suspending current transaction, creating new transaction with name [com.example.LogService.logOperation]
    • Initiating transaction commit (for logOperation)
    • Initiating transaction rollback (for createOrder)
  3. 代码审查:发现 logOperation 使用了 REQUIRES_NEW,并且在 createOrder 中调用它是非终态操作。

根因分析 REQUIRES_NEW 的行为在 AbstractPlatformTransactionManager.getTransaction 中定义(详见第 3 篇):

  1. 当在 createOrder 的当前事务中调用 logOperation 时,事务管理器会首先挂起当前事务。
  2. 然后为 logOperation 创建一个全新的物理事务
  3. logOperation 的执行和提交完全独立于外层被挂起的事务。
  4. logOperation 返回后,外层事务恢复。
  5. 外层事务最终回滚,但内层事务已经提交,且无法被撤销。

这使得 REQUIRES_NEW 成为了制造“孤儿”数据的完美风暴。它的行为是“完全独立”,而不是“最终依赖”。

修正方案 方案取决于真实业务需求。

  • 需求 1:日志记录也必须和外层事务保持一致(最典型)。
    @Transactional(propagation = Propagation.REQUIRED) // 或不写,默认就是 REQUIRED
    public void logOperation(String action) { ... }
    
  • 需求 2:确实需要独立的、不受外层影响的日志(如审计日志,失败也得记录)。 则必须在外层事务全部成功后再调用,或者采用更稳妥的方案:使用编程式事务对外发送一条消息,最终异步执行日志记录,或者在 catch 块中调用使用 REQUIRES_NEW 的方法。
    @Transactional
    public void createOrderCorrectly(Order order) {
        try {
            orderRepository.save(order);
            checkInventory(order);
            // 成功后再记录,此时即使 log 失败也会导致回滚(取决于需求)
            logService.logOperation("订单创建成功"); 
        } catch (Exception e) {
             // 失败时记录,此时用 REQUIRES_NEW 是合理的
             logService.logOperation("创建订单失败: " + e.getMessage(), true); // 另一个方法,使用 REQUIRES_NEW
             throw e; // 原异常需要继续抛出以触发回滚
        }
    }
    

最佳实践

  • REQUIRES_NEW 需谨慎:始终要问:“如果外层事务回滚了,我内层事务处理的数据最终被提交,这合理吗?” 如果不合理,就不要用。
  • 安全场景:适合使用 REQUIRES_NEW 的典型场景是审计日志写入自己的独立状态表(不依赖当前业务状态)向外发送业务补偿消息

案例 6:NESTED 传播行为与 savepoint 的数据库兼容性问题

错误示例 开发者在 ServiceA.methodA() 上使用 @Transactional(propagation = Propagation.NESTED),并且数据库使用的是 MySQL 5.5 版本,该版本下所有表均为 MyISAM 引擎,不支持 savepoint。

@Service
public class ServiceA {
    @Transactional(propagation = Propagation.NESTED)
    public void methodA() {
        // ... 
    }
}

现象描述 当 Spring 尝试为 REQUIRED 传播行为的事务内部调用方法 methodA 时,如果它是 NESTED 传播行为,Spring 会尝试创建一个 savepoint。由于底层数据库引擎不支持,Spring 将抛出 NestedTransactionNotSupportedException 异常,导致整个业务流程中断。

排查思路

  1. 异常日志:查看日志,发现清晰的异常栈 org.springframework.transaction.NestedTransactionNotSupportedException: JdbcTransactionObjectSupport does not support savepoints(具体异常类取决于事务所使用的资源管理器)。
  2. 数据库引擎检查:检查数据库中相关表的存储引擎,SHOW TABLE STATUS WHERE Name = 'your_table_name';。发现 Engine 字段为 MyISAM
  3. 版本与文档核对:对照数据库版本与官方文档,确认 MyISAM 引擎不支持事务和 savepoint。

根因分析 NESTED 的实现依赖于数据库 savepoint 机制。在 Spring 的 DataSourceTransactionManager 中,doBegin 只是开启一个事务,而 savepoint 的创建和管理发生在 AbstractPlatformTransactionManager.handleExistingTransactionTransactionStatus.createSavepoint 中。 当传播行为是 NESTED 时:

  1. 事务管理器不会创建新事务,而是判断当前是否存在事务。
  2. 如果已存在,它会调用 status.createSavepoint()
  3. JdbcTransactionObjectSupport 会通过 JDBC 的 Connection.setSavepoint() 来尝试创建 savepoint。
  4. 如果数据库或驱动不支持,此调用就会失败,Spring 将其包装为上述异常抛出。

修正方案 根本解决方案是使用支持事务和 savepoint 的数据库引擎。

-- 修正方案:将表引擎变更为支持事务的 InnoDB(MySQL 5.5+)
ALTER TABLE your_table_name ENGINE=InnoDB;

若项目仍必须使用 MyISAM 等非事务引擎,则须放弃 NESTED 传播行为,通过业务逻辑等方案来模拟“部分回滚”的效果,或者在更高的架构层面解决。

最佳实践

  • 事前检查:在采用 NESTED 前,必须确认数据库引擎支持事务(如 InnoDB, PostgreSQL, Oracle 等)且 JDBC 驱动版本正确。
  • 备选方案:如果数据库层面无法支持,应使用 REQUIRES_NEW 加上人工补偿逻辑(或分布式事务)来实现类似目的,但这已完全改变了事务范围,需重新评估业务一致性。

5. 声明式事务失效反模式(案例 7-9)

声明式事务 (@Transactional) 因其便捷性而广泛使用,但也因其基于 AOP 的实现而暗藏重重陷阱。第 4 篇已详细剖析了自调用、非 public 方法、异常类型不匹配等经典失效场景。下文将简要回顾并从排查视角提供诊断线索,重点补充几个新的、容易在生产中暴露的案例。

经典场景回顾与线上排查线索

将第 4 篇介绍的三个经典失效场景,从“线上排查”的视角进行重新审视。

1. 自调用(Self-invocation)

  • 排查线索
    • 代码审查:在 Service 中搜索 this.,检查调用的方法是否含有 @Transactional
    • 日志对比:开启 TRACE 事务日志,观察待排查方法被调用时,日志中是否出现 Getting transaction for [com.xxx.Service.method]。如果未出现,且通过外部接口直接调用此方法时会生成事务日志,则高度疑似自调用问题。
    • AOP 调试:在方法内通过 AopContext.currentProxy() 查看当前对象是否是一个代理。若抛出 IllegalStateException (由于未设置 exposeProxy=true),则为原始对象调用。

2. 非 public 方法(non-public method)

  • 排查线索
    • 快速扫描:使用 IDE 的 lint 工具或 FindBugs 规则扫描 @Transactional 注解是否位于 privateprotected 或包级别可见的方法上。
    • 日志:与自调用类似,方法被调用时,事务日志中没有任何事务获取信息。因为 CGlib 或 JDK 动态代理只会对 public 方法织入增强。

3. 异常类型不匹配(rollbackFor mismatch)

  • 排查线索
    • 事务日志金标准:这是最直接的排查方法。开启 logging.level.org.springframework.transaction.interceptor=TRACE。当方法内部抛出异常后,日志中会输出: Completing transaction for [com.xxx.Service.method] after exception: com.xxx.MyException Applying rules to determine whether transaction should rollback on com.xxx.MyException No relevant rollback rule found, so proceeding with commitWinning rollback rule is: ...。 如果日志显示 No relevant rollback rule found, so proceeding with commit,且该异常本应触发回滚,则可断定是 rollbackFor 配置缺失或不匹配。
    • 断点验证:在 RuleBasedTransactionAttribute.rollbackOn(Throwable ex) 方法处设置断点,观察传入的异常对象以及匹配规则判定的过程。

案例 7:多线程调用事务方法,事务上下文丢失

错误示例

@Service
public class BatchService {
    @Transactional
    public void batchProcess(List<Item> items) {
        // 主线程开启全局事务
        items.parallelStream().forEach(item -> {
            // 在多个子线程中处理
            innerService.processItem(item);
        });
    }
}

@Service
public class InnerService {
    @Transactional
    public void processItem(Item item) {
        // 希望各 item 处理能在自己的事务中?或共享主事务?但结果都不是
        itemRepository.updateStatus(item.getId(), "PROCESSED");
    }
}

现象描述 开发者本意可能是子任务共享外层事务,或是各自在独立事务中执行。但实际结果是:processItem 方法的 @Transactional 注解在子线程中完全失效,每个 updateStatus 调用都在自动提交模式下执行。如果 batchProcess 后续出现异常,主线程事务回滚,而子线程中已提交的 updateStatus 操作不会被回滚。

排查思路

  1. 事务日志:主线程日志清晰显示: Creating new transaction with name [com.xxx.BatchService.batchProcess]: ...。 但子线程中,processItem 被调用时,没有任何新事务创建或被加入的日志。对数据源的观察日志可能显示为“auto-commit”模式下的操作。
  2. 线程栈:使用 jstack 或其他工具观察,可以看到多个 ForkJoinPool 工作线程在执行业务逻辑,但它们都不在 Spring 的事务管理上下文内。
  3. TransactionSynchronizationManager 检查:可在 processItem 方法内部加一段调试代码来验证。

根因分析 Spring 的事务上下文(由 TransactionSynchronizationManager 管理)是存储在 ThreadLocal 中的(详见第 3 篇)。@Transactional 切面 (TransactionInterceptor) 在被调用时,会尝试从 ThreadLocal 中获取当前事务。

  • 当主线程进入 batchProcess 时,TransactionInterceptor 创建了一个事务,并将其资源(如数据库连接)绑定到了当前线程的 ThreadLocal
  • items.parallelStream().forEach(...) 启动多个子线程时,这些线程是全新的,它们有自己的 ThreadLocal 空间,里面没有任何事务上下文
  • 子线程调用 innerService.processItem(item),它的 @Transactional 注解会尝试获取事务。由于当前线程 ThreadLocal 内无事务,且无外层事务可加入,它会尝试创建一个全新的独立事务。但这要求 Spring 能够在新线程中管理完全独立的数据库连接和事务生命周期。由于主线程持有的是主连接,新线程必须获取新的连接。只要processItem的传播行为是默认的 REQUIRED,且线程池配置允许,它确实会创建新事务。但如果数据库配置或连接池导致无法获取新连接,或者因为其他原因,它就可能在非事务的自动提交模式下执行。 更常见的情况是,开发者使用了parallelStream这样的共享线程池,其中的线程可能已经绑定过其它连接或事务上下文,导致行为更加不可预测。核心问题在于 ThreadLocal 的线程私有性,使得主事务根本无法传播给子线程。

修正方案 方案取决于业务期望。

  • 期望 1:所有子任务共享一个事务,一起成功或失败。则必须使用单线程处理,或在主线程中收集所有操作,最后一块入库。
  • 期望 2:每个子任务都是一个独立的、互不影响的事务。则需要在子线程中明确地开启编程式事务,且不能依赖主线程的 @Transactional 传播。
    @Autowired
    private PlatformTransactionManager transactionManager;
    
    public void batchProcessCorrectly(List<Item> items) {
        // 移除外层 @Transactional
        items.parallelStream().forEach(item -> {
            // 使用编程式事务,确保每个子线程有独立完整的事务
            TransactionTemplate template = new TransactionTemplate(transactionManager);
            template.execute(status -> {
                innerService.processItem(item); // processItem 上不再需要 @Transactional
                return null;
            });
        });
    }
    

最佳实践

  • @Transactional 是线程绑定的:永远不要在会产生新线程的边界上幻想事务能跟着过去。CompletableFuture, parallelStream, @Async,都是如此。
  • 编程式事务:在多线程环境中进行数据库操作时,编程式事务是更安全、明确的选择。

案例 8:@Async 方法与 @Transactional 交织时事务上下文丢失

此案例与案例 7 同理,都是 ThreadLocal 隔离导致的。@Async 默认使用 SimpleAsyncTaskExecutor(不推荐生产环境,生产环境通常配置 ThreadPoolTaskExecutor),此执行器为每次任务调用创建新线程,事务上下文无法继承。

排查思路类似:结合事务日志和 jstack,可清晰看到异步方法在新线程中执行,且无事务信息。修正方案也是在异步方法内部,按需开启独立的编程式事务

// 错误示例:指望异步方法共享调用者事务
@Service
public class OrderService {
    @Transactional
    public void placeOrder(Order order) {
        orderRepository.save(order);
        emailService.sendConfirmationEmail(order); // 这是个异步方法
    }
}
@Service
public class EmailService {
    @Async
    @Transactional // 这个事务是无效的,或者是一个独立的新事务
    public void sendConfirmationEmail(Order order) { ... }
}

修正时,应在 sendConfirmationEmail 内自行管理事务,或使用传播行为 REQUIRES_NEW 使其意图明确。

案例 9:数据库引擎不支持事务(如 MyISAM),声明式事务静默失效

错误示例 数据库某个表意外创建为 MyISAM 引擎(可能由 DBA 从旧系统迁移而来),应用代码中对该表的操作仍使用了 @Transactional

现象描述 代码中对 MyISAM 表的更新操作立即生效,无论事务是否回滚,数据不会复原。没有任何异常抛出,Spring 事务管理“静默失效”,导致数据在回滚场景下不一致。

排查思路

  1. 引擎检查SHOW TABLE STATUS 查看表引擎。
  2. SQL 日志与事务日志对比:SQL 日志显示有 UPDATE/INSERT 语句执行。但事务日志显示 Initiating transaction rollback 之后,对应的数据行并没有被回滚。可以对比回滚前后的数据快照。
  3. 断点源码:在 DataSourceTransactionManager.doCommit/doRollback 处打条件断点,观察 Connection 实例,以及其 commit/rollback 方法是否被真实调用。对于 MyISAM 表,这些调用方法会执行,但数据库引擎会忽略它们,不会提示任何错误。

根因分析 Spring 的事务管理是依托于 JDBC Connection 和底层数据库引擎的。@Transactional 注解驱动 TransactionInterceptor,进而调用 DataSourceTransactionManager。经理层通过 Connection.setAutoCommit(false) 来开启事务,通过 Connection.commit()Connection.rollback() 来结束事务。对于 MyISAM 这类非事务型引擎,setAutoCommit(false) 同样会执行(可能无效果),而 commit()rollback() 不会报错,但也不会执行任何实际的数据回滚操作。整个过程的“正常”让你很难察觉它已失效。

修正方案

  • 根本措施:将表引擎变更为 InnoDB。
  • 防御性措施:在项目测试环节引入自动化校验,确保所有业务表引擎均为 InnoDB。

最佳实践

  • 数据库基线检查:将数据库引擎检查作为集成测试或环境健康检查的一环。
  • 编程式验证:如有怀疑,可编写一个小型测试,开启事务 → 修改某个 MyISAM 表 → 回滚 → 验证数据是否被回滚。

6. Spring Data JPA 与 Repository 反模式(案例 10-12)

Spring Data JPA 通过对 EntityManager 的高度封装(详见第 5、6 篇),极大提升了开发效率,但其内部的默认行为和 @Query 的复杂度也蕴藏着诸多陷阱。

案例 10:SimpleJpaRepository 的默认 @Transactional 与自定义更新方法冲突

错误示例

public interface UserRepository extends JpaRepository<User, Long> {
    // 自定义方法,意图更新用户最后登录时间,并发性高,不希望加入事务的读已提交语义
    @Modifying
    @Query("UPDATE User u SET u.lastLoginTime = :now WHERE u.id = :id")
    void updateLoginTime(@Param("id") Long id, @Param("now") LocalDateTime now);
}

// 在 Service 层调用时,外层可能没有任何事务
@Service
public class UserService {
    public void recordLogin(Long userId) {
        // 没有 @Transactional
        userRepository.updateLoginTime(userId, LocalDateTime.now());
    }
}

现象描述 调用 recordLogin 时,应用抛出异常:org.springframework.dao.InvalidDataAccessApiUsageException: Executing an update/delete query; nested exception is javax.persistence.TransactionRequiredException: Executing an update/delete query

排查思路

  1. 异常信息:异常信息 TransactionRequiredException 非常明确,直指需要一个事务。
  2. 代码审查:查看 recordLogin 方法,发现其和方法所属的类都没有 @Transactional 注解。但查看 UserRepository 源码,发现 JpaRepository 的默认实现 SimpleJpaRepository 中,save 等方法被标记为 @Transactional,但自定义的 @Modifying @Query 方法并不会自动获得事务

根因分析 SimpleJpaRepository 的设计细节(详见第 6 篇):

// Spring Data JPA 源码片段
@Repository
@Transactional(readOnly = true) // 类级别声明式事务,所有方法都继承,除非被覆盖
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {

    @Transactional // 写操作通常是这个方法
    public <S extends T> S save(S entity) { ... }

    // ... 其他方法
}
  • 类级别注解SimpleJpaRepository 被标记为 @Transactional(readOnly = true),表示其所有公有方法的默认事务是只读的。这是一个保险措施,确保无事务环境下的只读操作也能正常,并受益于 Hibernate 的只读优化。
  • 方法级别覆盖:对于 savedelete 等写方法,源码会在方法上添加 @Transactional,覆盖类级别的 readOnly=true。这是一个写操作保护
  • 自定义 Modifying Queries:我们自己定义的 userRepository.updateLoginTime(...) 方法,其代理调用链会最终绕过 SimpleJpaRepository 中的默认实现。Spring Data JPA 的代理机制会识别 @Modifying 注解,但它不会自动为这些自定义查询开启事务。执行 @Modifying 查询必须要求当前有事务存在,否则数据底层的 JPA 实现(如 Hibernate)就会抛出 TransactionRequiredException

修正方案 在调用 @Modifying 查询的方法上必须存在事务上下文。

// 修正:在调用该 Repository 方法的 Service 层方法上添加 @Transactional
@Service
public class UserService {
    @Transactional // 关键!必须提供事务上下文
    public void recordLogin(Long userId) {
        userRepository.updateLoginTime(userId, LocalDateTime.now());
    }
}

// 或者,在 Repository 接口的方法声明上直接添加 @Transactional(不推荐在接口上放太多事务细节)
public interface UserRepository extends JpaRepository<User, Long> {
    @Transactional // 也可以放在这里,但可能使 Repository 接口职责不单一
    @Modifying
    @Query("UPDATE User u SET u.lastLoginTime = :now WHERE u.id = :id")
    void updateLoginTime(...);
}

最佳实践

  • 写操作必须事务包裹:所有 @Modifying 查询必须在事务上下文中执行。推荐在 Service 层统一声明事务边界。
  • 理解继承与覆盖SimpleJpaRepository 的类级别 @Transactional(readOnly=true) 和方法的 @Transactional 是相互配合的,但其保护范围仅限于 SimpleJpaRepository 已实现的那些标准方法。

案例 11:分页查询时 Page 返回与 countQuery 分离导致性能低下

错误示例

public interface OrderRepository extends JpaRepository<Order, Long> {
    // 一个非典型的查询,比如关联了几个视图或使用了JOIN
    @Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
    Page<Order> findByStatusWithItems(@Param("status") String status, Pageable pageable);
}

现象描述 数据量小时,分页请求一切正常。当 Order 表数据量达到百万级后,每次分页请求都会导致数据库 CPU 飙升,响应时间极长(如十几秒)。通过 SQL 日志发现,不仅有一个带 LIMIT/OFFSET 的分页查询,还有一个无比缓慢且不带分页的 COUNT 查询

排查思路

  1. SQL 日志:开启 Hibernate show_sqlp6spy,捕获到两条 SQL。
    • SELECT o.*, i.* FROM orders o JOIN items i ON ... WHERE o.status = ? LIMIT ? OFFSET ? (很快)
    • SELECT COUNT(o) FROM orders o JOIN items i ON ... WHERE o.status = ? (非常慢)
  2. 慢查询日志:数据库的慢查询日志也捕获到了第二条 COUNT 语句。
  3. 性能分析:分析第二条查询的执行计划 (EXPLAIN),发现它执行了不必要的、代价高昂的 JOIN 操作,而 JOIN 操作对于纯粹计算 orders 的行数来说是不需要的。

根因分析 当 Spring Data JPA 遇到上述方法签名时,其代理实现逻辑是:

  1. 识别到返回 Page 对象。
  2. 生成一条数据查询:SELECT o FROM Order o JOIN FETCH o.items ...,并应用 Pageable 中的排序和分页参数。
  3. 自动生成一条 count 查询:默认情况下,Spring Data JPA 会基于你提供的 @Query 注解的值来自动派生出一个 count 查询。派生算法通常是将查询转换为 SELECT count(o) FROM ...,但保留了原查询中的 JOIN FETCH 和所有 JOIN 条件
  4. 如示例,生成的就是那条包含了 JOIN FETCHJOIN 的慢 COUNT 语句。这不仅可能产生错误的计数(因为 JOIN 可能导致重复),而且让一个只需统计 orders 数量的简单操作退化成了复杂的 JOIN 查询。

修正方案@Query 注解中提供 countQuery 属性,明确写出简单高效的计数统计查询。

// 修正:显式提供 countQuery,只统计主实体数量
public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query(value = "SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status",
           countQuery = "SELECT count(o) FROM Order o WHERE o.status = :status") // 关键补全
    Page<Order> findByStatusWithItems(@Param("status") String status, Pageable pageable);
}

最佳实践

  • 复杂查询必须提供 countQuery:任何包含 JOIN FETCH 或多个 JOIN 的复杂 @Query 并且返回 Page 时,都必须提供精确、简明的 countQuery
  • API 审计:代码审查时,对所有返回 Page@Query 方法进行重点审查,确保 count 逻辑最优。

案例 12:save 循环与 EntityManager.clear() 使用不当

错误示例 1:循环 save 触发频繁 flush

@Transactional
public void importOrders(List<OrderDto> dtos) {
    for (OrderDto dto : dtos) { // dtos 可能有上万条
        Order order = new Order();
        // ... 映射属性 ...
        orderRepository.save(order); // 每次 save 都可能触发一次 flush
    }
}

现象描述 批量导入功能非常缓慢。SQL 日志显示,对 orders 表每一条 INSERT 语句都是单独发送到数据库的,而不是批量执行。

排查思路

  1. SQL 日志:Hibernate show_sql 显示上万条 INSERT 语句,且中间没有批量提交的迹象。
  2. Hibernate 统计:开启 JPA statistics,发现 EntityManager.flush() 次数非常多。
  3. 事务行为:外层虽有 @Transactional,但 Hibernate 出于 ACID 保证,会在 save 时判断主键生成策略,如果是 IDENTITY 生成策略(MySQL 自增常用),则每次 save 都会强制 flush 以获取数据库生成的最新 ID。

根因分析

  • IDENTITY 主键策略的坑:对于 @GeneratedValue(strategy = GenerationType.IDENTITY) 的主键,Hibernate 必须在 INSERT 执行后立即从数据库中获取生成的主键值,才能继续管理该实体。因此它无法对这类实体进行 JDBC 批量插入的优化。
  • save 调用与脏检查orderRepository.save(order) 会调用 entityManager.merge()persist(),对于新实体,这会使它成为脏检查候选。在每次 flush(包括自动 flush)前,Hibernate 都会自动进行一次脏检查,以确保所有变动同步到了数据库。

修正方案

  • 方案一:切换主键生成策略:将主键生成策略从 IDENTITY 改为 SEQUENCETABLE,这两种支持 JDBC 批量插入优化。
  • 方案二:使用 EntityManager 直接批量操作,并配合 clear
    @PersistenceContext
    private EntityManager entityManager;
    
    @Transactional
    public void importOrdersFast(List<OrderDto> dtos) {
        int batchSize = 50;
        int i = 0;
        for (OrderDto dto : dtos) {
            Order order = new Order();
            // ...映射...
            entityManager.persist(order);
            if (i % batchSize == 0 && i > 0) {
                entityManager.flush(); // 批量 flush
                entityManager.clear(); // 释放内存,防止一级缓存过大
            }
            i++;
        }
        entityManager.flush();
        entityManager.clear();
    }
    

补充案例:EntityManager.clear() 使用不当导致脏数据丢失

这是一个经常与上述批量操作相伴发生的错误。开发者知道需要 clear 来避免 OOM(持久化上下文过大),但在 clear 之后,又去修改了之前已被 clear 移除的实体,期待这些修改能持久化到数据库。

现象:只有最后一批 flush 的成功,中间批次的实体修改莫名其妙“丢失”了。 根因entityManager.clear() 将持久化上下文中的所有实体都变为了脱管状态。在这些脱管实体上的任何修改,都不会再被 Hibernate 跟踪,后续的 flush 也就不会将其同步到数据库。 修正:必须是 flush 之后立即 clear,且 clear 之后不能再依赖那些脱管对象。或者,将实体在 clear 之前重新获取(entityManager.merge())放入新的持久化上下文中。

7. MyBatis 整合反模式(案例 13-14)

MyBatis 作为半自动 ORM 框架,其与 Spring 的整合提供了 SqlSessionTemplate 等关键适配器(详见第 5 篇)。这些整合点若理解不深,容易引发数据一致性和性能问题。

案例 13:SqlSessionTemplate 与一级缓存混乱

错误示例 开发者在一个事务方法内,先通过 Mapper 查询了一个对象,然后执行了一次原生 JDBC 更新了该行数据,最后又通过同一个 Mapper 查询同一个 ID 的对象。

@Service
public class CacheDemoService {
    @Transactional
    public void demonstrateCacheIssue(Long id) {
        Order order1 = orderMapper.selectById(id); // 查询,放入一级缓存
        // ... 此处可能混入了原生JDBC操作,绕过了MyBatis缓存 ...
        jdbcTemplate.update("UPDATE orders SET status = 'SHIPPED' WHERE id = ?", id);
        Order order2 = orderMapper.selectById(id); 
        // 期待读到 'SHIPPED',但 order2.status 可能还是 'PENDING'
    }
}

现象描述 第二次查询 orderMapper.selectById 依然返回了与第一次查询结果相同的对象(order1 == order2 为 true),其状态是旧的,导致后续业务逻辑判断错误。

排查思路

  1. SQL 日志:MyBatis 日志显示第一条 SELECT 语句,第二条 SELECT 语句没有出现。
  2. 数据对比:观察数据库的该行数据,确认已被更新为 SHIPPED
  3. Debug 堆信息:断住 order2,查看其内存地址,与 order1 比较。

根因分析 SqlSessionTemplate 是 MyBatis 与 Spring 整合的核心,它通过 SqlSessionInterceptor 代理来将 SqlSession 绑定到 Spring 的事务同步管理器中。

  • 一级缓存的生命周期SqlSession 的一级缓存作用域是 SqlSession 本身。在 Spring 管理的事务中,同一个事务共享同一个 SqlSession。因此,如果在一个事务内进行多次查询,一级缓存就会生效。
  • 内部机制orderMapper.selectById(id) 会先检查它所属的 SqlSession 的一级缓存,如果缓存中有对应 ID 的实体,就直接返回,不会再向数据库发起查询。由于 jdbcTemplate.update 是直接通过 JDBC 访问数据库,完全绕过了 MyBatis 的 SqlSession,所以它无法知道缓存应该被清除,这就导致后续查询读到了“脏数据”。

修正方案

  • 方案一:避免混合访问:尽量避免在同一个事务中混用 MyBatis Mapper 和原生 JDBC 或其它在同一个事务中的 JPA 操作,除非你通过事件或钩子清除对方缓存。
  • 方案二:强制清除缓存:在原生 JDBC 的更新操作后,手动清除缓存。
    @Autowired
    private SqlSessionTemplate sqlSessionTemplate;
    // 在 jdbcTemplate.update 之后
    sqlSessionTemplate.clearCache(); // 这将清空一级缓存
    
    或者重新查询前,在 Mapper 的查询方法上配置 flushCache=true 或使用 @Options(flushCache=Options.FlushCachePolicy.TRUE) 来清空缓存后再查询。

最佳实践

  • 统一数据访问技术栈:在同一个切面或事务边界内,尽量只使用一种数据访问技术,避免缓存不一致。
  • 理解 SqlSession 生命周期:时刻牢记,事务内的 MyBatis 操作都运行在同一个 SqlSession 之上,一级缓存是其固有特性。

案例 14:@MapperScan 扫描过宽与批处理陷阱

错误示例 1:@MapperScanbasePackages 配置过宽

@SpringBootApplication
@MapperScan("com.example") // 错误:扫描了整个项目包
public class MyApp {
    public static void main(String[] args) {
        SpringApplication.run(MyApp.class, args);
    }
}

现象描述 应用启动奇慢,在 Bean 初始化阶段花费大量时间。日志中出现大量 MyBatis 相关的 debug 日志,显示它尝试去解析并创建许多根本不是 Mapper 的接口(如 Service、Repository 接口)的代理对象。

排查思路

  • 启动日志:查看启动日志,发现 SqlSessionFactory Bean 在注册 Mapper 时,遍历了大量的接口类名。
  • 配置审查:检查 @MapperScanbasePackages 属性值。

根因分析 MapperScannerRegistrar(对应 @MapperScan 注解的底层实现)会扫描指定包路径下及其子包下的所有接口,并为它们尝试创建 MapperFactoryBean。如果 basePackages 设得过宽,会将几百上千个无需成为 Mapper 的业务接口纳入 MyBatis 的管理逻辑,严重拖慢启动过程且产生无用的代理 Bean。

修正方案 精确指定 basePackages 或者使用 markerInterface/annotationClass 进行过滤。

// 修正方案:精确指定 Mapper 所在包
@MapperScan("com.example.project.data.mapper")
// 或使用注解过滤
@MapperScan(basePackages = "com.example", annotationClass = org.apache.ibatis.annotations.Mapper.class)

补充案例:ExecutorType.BATCH 批处理与 Spring 事务协作陷阱

开发者希望利用 MyBatis 的批处理模式进行高效的批量插入。

@Service
public class BatchService {
    @Autowired
    private SqlSessionTemplate sqlSessionTemplate;

    @Transactional
    public void batchInsert(List<User> users) {
        // 获取一个批处理模式的 SqlSession
        SqlSession sqlSession = sqlSessionTemplate.getSqlSessionFactory()
                .openSession(ExecutorType.BATCH);
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        for (User user : users) {
            mapper.insert(user); // 语句队列化,并不立即提交
        }
        // 问题点:这个 flush 会发生什么?
        sqlSession.flushStatements();
        sqlSession.close();
    }
}

现象sqlSession.flushStatements() 可能不产生任何效果,或者抛出了 TransactionSynchronizationManager 未绑定的异常。更糟的是,这些 INSERT 语句可能根本没有在当前的 Spring 事务上下文中执行。

根因SqlSessionTemplate 的设计目的是透明管理与 Spring 事务同步的 SqlSession。通过 openSession(ExecutorType.BATCH) 直接获取的是一个全新的、不受当前 Spring 事务管理的 SqlSession。它与当前的 Connection 和事务同步完全脱离,导致:

  1. 批处理操作不在当前事务中。
  2. 它的提交 (commit) 和关闭 (close) 由开发者负责,一旦操作不当会导致连接泄漏或事务管理混乱。
  3. SqlSessionTemplate 内部是通过 SqlSessionInterceptor 动态管理 SqlSession 的生命周期和事务同步的,绕过它直接获取 SqlSession 会丧失所有这些能力。

修正方案:利用 SqlSessionTemplateexecute 方法,以 Lambda 的形式安全地获得一个可以参与 Spring 事务管理的批处理 SqlSession

@Transactional
public void batchInsertCorrectly(List<User> users) {
    sqlSessionTemplate.execute(ExecutorType.BATCH, sqlSession -> {
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        for (User user : users) {
            mapper.insert(user);
        }
        // 无需手动 flush 和 commit,Spring 事务管理会处理这些
        // 但可能需要在事务提交前手动 flush,以确保 SQL 被执行
        sqlSession.flushStatements();
        return null;
    });
}

8. 持久化上下文与 OSIV 反模式(案例 15-16)

OSIV(Open Session/EntityManager in View)模式(详见第 7 篇)是一把双刃剑,它通过延长持久化上下文的生命周期至视图渲染层来解决懒加载问题,但如果不加控制,极易引发严重的性能和资源问题。

案例 15:OSIV 开启时,视图层触发 N+1 查询风暴

错误示例

# application.properties
spring.jpa.open-in-view=true # 默认就是 true

在 Controller 中,Service 返回了一个包含懒加载属性的 @Entity 列表。

// Controller
@GetMapping("/orders")
public String listOrders(Model model) {
    List<Order> orders = orderService.findAllOrders(); // 返回所有订单,items属性是懒加载的
    model.addAttribute("orders", orders);
    return "orders/list"; // 跳转到视图
}

Thymeleaf 模板 orders/list.html 中:

<table>
    <tr th:each="order : ${orders}">
        <td th:text="${order.id}"></td>
        <!-- 视图在渲染时,自动触发对 order.items 的延迟加载 -->
        <td th:each="item : ${order.items}" th:text="${item.name}"></td>
    </tr>
</table>

现象描述 当访问 /orders 页面时,响应时间非常长(数秒甚至数十秒)。数据库监控显示,连接池连接被瞬间占满,并长时间“不活动”(等待 I/O 完成)。SQL 日志(如 p6spy)揭示出可怕的 N+1 查询模式:

  1. 1次查询:SELECT * FROM orders
  2. N次查询:SELECT * FROM items WHERE order_id = ? (每行 order 一次)

排查思路

  1. SQL 日志(金标准):任何 SQL 监控工具(p6spy, Druid SQL 监控, 或供应商的慢查询日志)都是定位此问题的第一入口。会一眼看到重复的、模式化的查询。
  2. 线程堆栈:在请求处理缓慢时,抓取线程堆栈。大量 HTTP 工作线程会阻塞在org.hibernate.internal.SessionImpl.initializeCollection(...) 或类似的地方。
  3. 响应时间分析:对比 API 接口(返回 JSON,耗时正常)和页面接口(返回视图,耗时极长),可以初步推断是视图层导致了额外查询。
  4. OSIV 状态检查:通过 /actuator/configprops 或查看日志,确认 spring.jpa.open-in-view=true

根因分析 OSIV 的核心实现是 OpenEntityManagerInViewInterceptor(详见第 7 篇)。此拦截器在请求到达 Controller 之前通过 preHandle 方法打开一个 EntityManager,并将其绑定到当前线程的全局 TransactionSynchronizationManager 上。但此时,它并不绑定一个数据库连接,也不开启事务,而是创建一个“长期存在”的 EntityManager。真正的数据库连接只有在需要时(如第一次查询)才从连接池获取。

  • orderService.findAllOrders() 执行时,拿到了连接,执行了 SQL,获取了 orders 列表。方法返回后,连接归还(不是关闭,因为 OSIV EntityManager 还活着)。
  • 当视图 Thymeleaf 开始渲染 order.items 时,通过 Hibernate 的懒加载代理,再次触发对集合的初始化。此时,OpenEntityManagerInViewInterceptor 为该操作再次从连接池借取连接,执行 SQL,完毕后归还。
  • 对列表中的每条 order,这个过程都会重复一次。这就产生了 N 条额外的 SQL,每次都伴随着从连接池获取/归还连接的开销。在高并发下,连接池成了严重的瓶颈,大部分线程阻塞在等待连接上,导致系统雪崩。

修正方案 存在几种思路,需根据场景选择。

  • 方案一(根治):关闭 OSIV。这是最推荐的方案。

    spring.jpa.open-in-view=false
    

    但这要求你在 Service 层就解决懒加载问题,即“在事务内完成所有懒加载属性的初始化”。

  • 方案二(在 Service 层用 JOIN FETCH 或 Entity Graph 解决)

    // Repository 中定义查询
    @Query("SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.items")
    List<Order> findAllOrdersWithItems();
    

    这会生成一条 JOIN 查询,一次性将所有需要的数据取出,避免了 N+1,而且即使 OSIV 关闭也不会有 LazyInitializationException

  • 方案三(OSIV 保留,添加批处理):在 Thymeleaf 或其他视图层,可考虑放入一个全局的批处理加载钩子,但那比较复杂且容易出错,远不如前两个方案简单直接。

最佳实践

  • 首选关闭 OSIV:这是避免本反模式各类变种的基石。
  • 在事务内完成数据组装:提供专门的 DTO 或使用 JOIN FETCH,确保从 Service 层返回的对象已经是“完整的”,不再需要懒加载。

案例 16:关闭 OSIV 后,视图层抛出 LazyInitializationException

错误示例 这是关闭 OSIV 后最常出现的“新手”错误。

# application.properties
spring.jpa.open-in-view=false

其它代码和模板与案例 15 完全相同。

现象描述 访问 /orders 页面时,浏览器直接显示 500 错误。后端日志抛出经典的 org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.example.Order.items, could not initialize proxy - no Session

排查思路

  • 异常栈一目了然LazyInitializationException 就是指明在事务之外访问了需要数据库交互的懒加载属性。
  • OSIV 配置确认:检查配置发现 spring.jpa.open-in-view=false

根因分析 关闭 OSIV 后,EntityManager 的生命周期被严格限制在 @Transactional 方法的范围内。当 Service 层方法结束,事务提交,EntityManager 就会 close。此时,从 Service 层返回的 Order 实体虽然已经脱离了对事务内连接的依赖,但它所包含的 items 属性只是一个未初始化的 Hibernate 代理集合 (PersistentBag)。在视图层开始渲染并首次访问 order.items 时,该代理尝试访问已关闭的数据库 Session,从而抛出了异常。

修正方案 同样的,方案依然是为视图提供完整的数据。

  • 使用 DTO:在 Service 层将 Entity 转换为设计好的 DTO。
  • 使用 JOIN FETCH:如上文所示。
  • 在事务内触发初始化:利用 Hibernate.initialize(order.getItems()) 在 Service 层 @Transactional 方法内部,人工触发懒加载集合的初始化,使其加载上数据,之后再返回这个对象时它就已经是填充的了。

9. 多数据源与读写分离反模式(案例 17-18)

通过 AbstractRoutingDataSource 实现的读写分离(详见第 8 篇)存在一个根本性的设计挑战:当读库切换遇到事务时,连接绑定与 Key 切换的时序问题。

案例 17:AbstractRoutingDataSource 在事务中切换失效

错误示例 典型的读写分离 AOP 切面会在 Service 方法执行前根据方法名或其他规则,将代表“读库”的 Key 放入 DataSourceContextHolder 这个 ThreadLocal 中。

@Service
public class OrderServiceRead {
    // 无事务
    public Order findOrderReadOnly(Long id) {
        // 某个 AOP 切面在切入时将 DataSourceContextHolder 设置为 READ
        return orderMapper.selectById(id);
    }

    @Transactional(readOnly = true) // 一个只读事务
    public Order findOrderWithReadOnlyTx(Long id) {
        // AOP 尝试先设置 key
        return orderMapper.selectById(id);
    }
}

现象描述

  • findOrderReadOnly:无事务时,AOP 正确设置了 Key 为 READ,路由到了从库,工作正常。
  • findOrderWithReadOnlyTx:存在 @Transactional 时,即使 AOP 成功地优先设置了 Key,方法内的查询依旧奇怪地落在了主库上。

排查思路

  1. SQL 日志分析:通过 p6spy 或日志增强,在 SQL 执行日志中输出当前的数据库 URL 或连接 hash,能直接看到使用的是主库还是从库。
  2. AOP 与事务 AOP 切面顺序:先检查 DataSource 切换 AOP 与事务切面的 @Order@Priority。诚然顺序很重要,但对 AbstractRoutingDataSource 而言,问题更深。
  3. 断点:断点调试 AbstractRoutingDataSource.determineCurrentLookupKey()DataSourceTransactionManager.doBegin()

根因分析 关键不在于切面顺序,而是 AbstractRoutingDataSource 确定目标 DataSource 的时机与事务获取连接时机之间存在割裂(详见第 8 篇)。

  1. 当你的 AOP 切面 setKey 后,TransactionInterceptor 开始工作,调用 PlatformTransactionManager.getTransaction()
  2. DataSourceTransactionManager.doBegin() 会调用 dataSource.getConnection()
  3. 现在,代理来到了 AbstractRoutingDataSourcegetConnection() 内部会立即调用**determineCurrentLookupKey(),从而根据键找到对应的物理 DataSource,并获取和绑定了连接**。
  4. 一旦这个连接(指向了主库)被开启并绑定到当前线程的 TransactionSynchronizationManager,那么在此事务结束前,所有后续通过 JdbcTemplate 或 ORM 框架获取的连接,都会是这个已经绑定好的、指向主库的连接
  5. 即使你在方法中途想修改 DataSourceContextHolder 的键,也于事无补了。因为事务管理器不会再为同一线程在一个活跃事务生命周期内,去另行调用 dataSource.getConnection() 切换数据库。事务的 ACID 特性要求它使用同一个物理连接。
// AbstractRoutingDataSource 源码片段
// Connection getConnection() 最终都会调用这里
protected DataSource determineTargetDataSource() {
    Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
    Object lookupKey = determineCurrentLookupKey(); // 关键!此刻确定目标
    DataSource dataSource = this.resolvedDataSources.get(lookupKey);
    if (dataSource == null) {
        throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
    }
    return dataSource;
}

修正方案

  • 方案一(调整架构):将读写分离的 AOP 决定权置于事务开启之前且确保原子性。对于需要读写分离的 Service 方法,禁止使用 @Transactional 开启大事务。或者主写从事务中分离,读操作永远在事务外。

  • 方案二(LazyConnectionDataSourceProxy 原理剖析与慎用)LazyConnectionDataSourceProxy 不立即获取连接,而是返回一个代理。真正的 getConnection() (以及 determineTargetDataSource()) 会延迟到第一个 SQL 执行时。这给“后置地”切换 Key 留出了一线生机。但这是有代价的

    1. 事务传播行为可能因不持有真正的连接而产生意料之外的影响。
    2. 它把物理连接的获取延迟到了 SQL 执行点,但 TransactionSynchronizationManager 的很多操作(如事务状态管理)依然绑定着这个代理连接。
    @Bean
    public DataSource dataSource() {
        return new LazyConnectionDataSourceProxy(abstractRoutingDataSource());
    }
    

最佳实践

  • 写请求和读请求强制分离:在设计层面,避免在一个显式的事务方法内,同时期望它做写操作然后又路由到从库做读。这样的设计违反了一致性常识:事务中的读取通常也需要是强一致的读。
  • 细粒度编程:在事务外部通过编程式方式使用从库执行查询,而将需要事务保证的读写操作全部指向主库。

案例 18:@Async 启动新线程查询从库,忘记设置 Key

错误示例

@Service
public class DashboardService {
    @Async
    public CompletableFuture<List<Order>> getRecentOrders() {
        // 幻想能继承主线程中 DataSourceContextHolder 的 READ Key
        return CompletableFuture.completedFuture(orderRepository.findTop10By());
    }
}

// 主线程调用
public void getDashboardData() {
    DataSourceContextHolder.set("READ"); // 设置从库Key
    dashboardService.getRecentOrders(); // 异步方法调用
    DataSourceContextHolder.clear();
}

现象描述 @Async 方法内的数据库查询却打到了主库上。主库压力因此意外增大。

排查思路:与案例 7 类似,ThreadLocal 不跨线程。通过 jstack 看到新线程执行此逻辑,且 SQL 日志指向主库。

根因分析DataSourceContextHolder 通常是基于 ThreadLocal 实现的,因此其保存的 Key 无法被 @Async 产生的新线程继承或访问到。新线程中的 DataSourceContextHolder.get() 会是空的,导致 AbstractRoutingDataSource 返回默认的数据源(主库)。

修正方案:在 @Async 方法内部的第一行显式设置 DataSourceContextHolder.set("READ")

10. 分布式事务反模式(案例 19-21)

引入 Seata 等分布式事务框架(详见第 9 篇)是为了解决跨服务数据一致性的问题,但同时也将错误的排查维度从单一应用扩展到了全局协调者。

案例 19:Seata AT 全局锁超时未正确处理

错误示例 在 Seata AT 模式中,两个服务同步地更新同一条记录的同一个字段。服务 A 开启全局事务,UPDATE 了该记录的 balance 字段,Seata 会在提交前获取此记录的全局锁。服务 B 也要在另一个全局事务中 UPDATE 同一条记录的 balance 字段。它尝试获取全局锁,但因为服务 A 的事务持有锁定且迟迟不提交(可能是业务逻辑耗时),导致服务 B 锁等待超时。

现象描述 服务 B 的接口调用失败,抛出 Seata 的 LockConflictExceptionGlobalLockNotTimeoutException,其发起的全局事务被回滚。而此时,服务 A 的事务可能最终成功提交,也可能因某种原因随后才回滚。短时间内大量类似冲突会导致业务大面积挂起并回滚。

排查思路

  1. Seata Server 日志:登录 Seata Server 端,查看事务协调者 (TC) 日志。会看到大量 lock conflictrollback branch transaction 事件。
  2. 业务日志:服务 A 和服务 B 的日志中都会有 Seata 相关的错误或警告。
  3. 数据库检查:在 Seata 的全局锁状态表(如 MySQL 中的 lock_table)中,可以看到被阻塞的锁记录。

根因分析 Seata AT 模式通过代理数据源,在 undo_log 等位置记录数据的前后镜像来实现补偿。为了隔离,在分支事务被提交给 TC 后,TC 会在第一阶段的提交阶段就去竞争它所修改的数据行的全局锁。该锁持续到整个全局事务结束(第二阶段提交)。 如果服务 A 长时间持有该行的锁,而服务 B 也需要该锁,但服务的竞争超时时间(client.rm.lock.retryTimeout 默认 30s)设置过短,或服务 A 持有时间过长,就会导致服务 B 快速失败。失败后,服务 B 的分支事务回滚,如果它是发起方则整个全局事务失败。

修正方案

  • 降低冲突概率:从业务层面避免对同一行记录的同时更新。例如在缓存中扣减,事后同步。
  • 合理调参:在 Seata 配置中增加 client.rm.lock.retryTimesclient.rm.lock.retryTimeout,给予更长的竞争时间。
  • 快速失败重试:上层业务逻辑捕获 LockConflictException,进行有限次数的重试。这实际上是将分布式事务转化为了补偿(重试)。

最佳实践

  • 避之为上:设计时就应该对高冲突资源进行分离。例如使用虚拟账户、队列、或最终一致性方案。
  • 监控与告警:对全局锁冲突的错误日志设置告警,第一时间发现业务热点。

案例 20:Seata undo_log 表无限膨胀

现象描述 Seata 运行一段时间后,数据库的 undo_log 表体积变得异常巨大,占用大量磁盘空间,并影响数据库备份和该表的读写性能。

排查思路

  • 表大小检查SELECT table_name, ROUND(((data_length + index_length) / 1024 / 1024), 2) AS 'Size(MB)' FROM information_schema.tables WHERE table_schema = 'your_db' AND table_name = 'undo_log';
  • 数据保留检查:查看 undo_log 表中 log_createdlog_status 字段,发现很多很久以前、状态为已完成(如 status=1 表示已回滚成功)的日志未被清理。

根因分析 undo_log 记录着一阶段过程中数据的 before_imageafter_image,用于二阶段的回滚或提交前镜像校验。正常情况下,全局事务完成后,对应的 undo_log 是“无用的”,需要被清理。Seata 提供了几种清理机制:file(默认,用于 Server 的存储模式)、db 自动清理(通过 TC 定时任务清理完成的事务日志)、或通过应用端定时任务清理。如果这些机制:

  1. 未正确配置。
  2. 资源配置错误(如 TC 无法连接该表)。
  3. 清理任务频率过低或者执行失败没有告警。 均会导致日志堆积。

修正方案

  • 开启应用端清理:使用 seata-spring-boot-starter 中的 seata.server.undo.log-save-days 或相关属性设置服务端清理,或在自己的 Server 端配置 db 清理策略。
  • 手动清理:编写一个小程序脚本,定期 DELETE FROM undo_log WHERE log_created < DATE_SUB(NOW(), INTERVAL 7 DAY)。注意,必须在确认全局事务均已结束的窗口进行。

最佳实践

  • 部署即清理配置:在 Seata 初上线时,就要配置好 undo_log 的保留和清理策略,这常常与日志和数据备份相关规范协同设定。

案例 21:基于 MQ 的最终一致性方案中,消费者未实现幂等

错误示例 用户支付成功后,支付服务发布一条“支付成功”事件到 RabbitMQ/Kafka。账户服务作为消费者,接收此事件并给用户账户增加余额(或积分)。

@RabbitListener(queues = "payment.success")
public void handlePaymentSuccess(PaymentEvent event) {
    // 错误实现:没有检查这个事件是否已处理过,直接扣款
    accountService.increaseBalance(event.getUserId(), event.getAmount());
}

现象描述 由于网络偶尔抖动,或 MQ 的“至少一次”投递保证,同一PaymentEvent 被投递了两次。消费者两次都处理成功,导致该用户的账户余额被增加了两次,造成了资损。

排查思路

  1. 对账:财务或者数据部门对账时,会发现少部分用户的入账总数和支付金额总数对不上。
  2. 日志分析:检查消费者日志。如果应用在关键节点(如消息接收、开始处理、结束处理)打印了详细日志,会发现同一个 event.id 有两次完整的处理成功日志。
  3. MQ 控制台:检查 MQ 控制台,可能发现有部分消息有 ACK 超时并被重新投递的记录。

根因分析 MQ 消息的投递语义(可靠投递)决定了绝大多数场景下,至少一次交付是必须保障的。消费者端的幂等性,是保证事务最终一致性的最后一道防线,其缺失将直接导致业务数据的重复变更。

修正方案 在消费者方法内部实现幂等逻辑。

@RabbitListener(queues = "payment.success")
public void handlePaymentSuccess(PaymentEvent event) {
    // 1. 利用数据库唯一约束
    // 在业务表(如 account_log)中,将 event.id 作为唯一键。
    try {
        // 2. 插入一条处理记录
        eventLogService.insertProcessedEvent(event.getId());
        // 3. 执行业务操作
        accountService.increaseBalance(event.getUserId(), event.getAmount());
    } catch (DuplicateKeyException e) {
        // 如果重复,说明已处理过,直接返回或ACK,避免重复执行
        log.warn("Ignoring duplicate event: {}", event.getId());
    }
}

最佳实践

  • 以消息ID/业务ID作为排他性杠杆:在任何消费者和状态变更中,都先基于业务全局 ID(如支付订单号)进行检查或尝试插入幂等表,然后再进行业务处理。
  • 本地事务:将业务操作和“插入处理记录”放在同一个本地事务中。如果插入幂等表采用独立数据库,则会是分布式事务问题。更推荐将事件 ID 和业务数据放在同一个数据库里进行事务保证。

11. 诊断工具集、映射表与标准化排查流程

总结以上反模式,我们发现大部分问题的最终定位都离不开一套系统化的诊断工具标准化的排查流程。下面将前述工具整合,并建立它们与问题模式的快速映射。

11.1 诊断工具集

  1. SQL 日志与代理工具

    • p6spy:强大的 JDBC 代理驱动,能记录下所有 SQL 语句及其执行时间,包括真实参数(而非占位符 ?)。
    • Hibernate show_sql / format_sql:快速启用,能看到生成的 SQL 骨架,但通常不含参数。
    • Mybatis Logging:在 logback 中为 Mapper 包开启 DEBUG,可输出 SQL 和参数(但参数绑定可能分多次输出)。
    • Druid/DBCP2/HikariCP 连接池监控:观察活跃/闲置/等待的连接数,是发现连接泄漏/耗尽的第一窗口。
  2. 事务与框架日志

    • logging.level.org.springframework.transaction=TRACE:金标准。详细记录事务的创建、传播、提交、回滚全过程。
    • logging.level.org.springframework.orm.jpa=TRACE / Hibernate日志:观察 JPA/Hibernate 的交互细节。
    • TransactionSynchronizationManager 辅助代码:在必要时,可通过 LoggingUtils.logTxStatus() 这样的工具类,在代码任意位置打印当前线程 tx 是否活跃、同步资源等。
  3. Actuator 监控端点

    • /beans:检查 DataSourcePlatformTransactionManagerEntityManagerFactory 的核心 Bean 是否正确注册。
    • /mappings:检查 RequestMapping,结合过滤查看哪些接口可能受影响。
    • /metrics:查看 hikaricp.connections 相关的 metrics,以及 jdbc.connections 的获取指标。
    • /health:查看数据库和数据源的连接健康状态。
  4. JVM 与线程分析工具

    • jstack <pid>:抓取线程快照,分析在哪里的哪个方法阻塞、等待,结合堆栈可以识别特定反模式(如 RowMapper 中获取连接)。
    • Arthas:线上诊断利器。
      • watch:观察某个方法的出参、入参、异常,可以实时查看 TransactionStatus 对象。
      • trace:追踪方法调用链,观察耗时与方法调用关系。
      • tt (TimeTunnel):记录方法调用,事后反复查看,甚至重放。

11.2 工具→反模式映射表

典型现象推荐排查工具关键日志搜索词或检查点
事务未回滚事务日志 (TRACE)Completing transaction ... after exception, No relevant rollback rule found
连接泄漏/耗尽jstack/metricsp6spygetConnection 调用栈,HikariCP Active/Pending
N+1 查询p6spy、Arthas trace重复的模式化 SQL
懒加载异常异常栈LazyInitializationException, no Session
读/写库未分离p6spy(加连接hash)、事务日志检查 TransactionSynchronization 绑定 Connection 的时机
分布式事务挂起Seata Server 日志、App 日志lock conflictLockConflictException
异常类型不匹配事务日志 (TRACE)No relevant rollback rule for ...
脏数据/缓存混乱SQL 日志 vs 业务日志相同查询无 SQL 产生,MyBatis Cache Hit

11.3 标准化排查决策树

这是一个从宏观问题现象逐步定位到具体反模式的诊断流程。

flowchart TD
    Start[遇到数据访问或事务问题] --> Q1{问题类型?}

    Q1 -- 性能问题 --> Q_Perf{具体慢在哪?}
    Q_Perf -- 单次查询慢 --> C1[检查SQL: p6spy/慢查询日志]
    C1 --> F1[案例11: 缺countQuery<br>案例15: OSIV N+1]

    Q_Perf -- 连接等待超时 --> C2[检查连接池: jstack, /metrics]
    C2 --> F2[案例3: RowMapper泄漏<br>案例15: OSIV连接池耗尽]

    Q_Perf -- 批量操作慢 --> C3[检查SQL批量: p6spy]
    C3 --> F3[案例14: 批处理未生效<br>案例12: 循环save频繁flush]

    Q1 -- 数据不一致 --> Q_Data{什么不一致?}
    Q_Data -- 部分回滚部分提交 --> C4[事务日志: TRACE]
    C4 --> F4[案例5: REQUIRES_NEW误用<br>案例7/8: 多线程/@Async丢失事务]

    Q_Data -- 读到的不是最新的 --> C5[检查缓存与隔离级别]
    C5 --> F5[案例13: MyBatis一级缓存<br>案例17: 事务内读主库]

    Q_Data -- 分布式事务挂起/膨胀 --> C6[Seata Server/undo_log]
    C6 --> F6[案例19: 全局锁冲突<br>案例20: undo_log膨胀]

    Q1 -- 异常/错误 --> Q_Ex{异常类型?}
    Q_Ex -- TransactionRequiredException --> F_Ex1[案例10: Modifying缺@Transactional]
    Q_Ex -- LazyInitializationException --> F_Ex2[案例16: 关闭OSIV后懒加载]
    Q_Ex -- NestedTransactionNotSupported --> F_Ex3[案例6: 数据库不支持savepoint]
    Q_Ex -- UncategorizedDataAccessException --> F_Ex4[案例2: SQLErrorCodes缺失]

决策树说明

  • 总览定位:该流程图将纷繁的现象概括为三大类问题:性能、数据一致性、异常。从这三大入口出发,我们可以通过一步步的检查点和排查工具,最终定位到本文讨论的具体反模式案例。
  • 路径解读
    • 性能问题分支:从发现“慢”开始,是单次 SQL 慢、连接等待慢,还是吞吐量上不去。这会分别导向 N+1 查询或无 countQuery(案例 11/15)、连接泄漏或耗尽(案例 3/15)、或批量操作低效(案例 14/12)等根本原因。
    • 数据不一致分支:从数据的具体表现(回滚不一致、读到旧数据、全局状态不正确)出发,导向 REQUIRES_NEW 误用(案例 5)、ThreadLocal 隔离失效(案例 7/8/17)、MyBatis 一级缓存(案例 13)等。
    • 异常/错误分支:直接根据异常类名来定位。TransactionRequiredException 是 JPA 自定义 update 忘加 @TransactionalLazyInitializationException 是 OSIV 关闭后的懒加载问题,NestedTransactionNotSupported 是数据库不支持 savepoint,UncategorizedDataAccessException 则指向 SQL 错误码未正确映射。这也体现了 Spring 异常体系设计的精妙:懂异常就能定位大半问题
  • 实践指南:此图可作为团队内线上应急响应的标准操作流程第一部分,帮助一线开发或 SRE 快速将问题归类,并交给相应领域的专家或基于后续章节的详细案例进行解决。

12. 面试高频专题

12.1 面试题

  1. 如何系统地排查一个 @Transactional 没有回滚的问题?

    • 标准回答:分三步走。第一步,确认 AOP 代理是否生效,排查自调用及非 public 方法。第二步,查看事务日志(TRACE 级别),确认事务是否开启、有无 rollback 日志。第三步,检查异常类型,是否匹配 rollbackFor 规则。
    • 追问 1:如果用 TRACE 日志发现 No relevant rollback rule found,除了补 rollbackFor 还有什么方案?答:可以在抛出该异常的代码处,改为抛出继承自 RuntimeException 的异常,或者使用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
    • 追问 2:在大型项目中,如何在不修改所有 @Transactional 注解的情况下,全局性地将某个自定义业务异常都设为强制回滚?答:可以继承实现一个自定义的 TransactionAttributeSource,或者利用 RuleBasedTransactionAttribute 编程式创建。
    • 追问 3:自调用为什么 AOP 会失效?原理是什么?答:因为 this 引用的是目标对象,不是 Spring 生成的代理对象。调用 this.method() 直接绕过了代理,导致拦截器链(包括事务拦截器)不执行,因此事务逻辑不织入。
  2. REQUIRES_NEWNESTED 的区别及排查内部事务失效的方法。

    • 标准回答REQUIRES_NEW 总是开启一个新的独立物理事务,外层事务会被挂起。NESTED 使用同一个物理事务,但通过 savepoint 来嵌套,可以实现部分回滚。排查失效:对于 NESTED,首先检查数据库引擎是否支持;对于 REQUIRES_NEW,检查失效方法是否为内部调用。
    • 追问 1NESTED 嵌套事务提交后,外层事务回滚,嵌套事务的变更会保留吗?答:不会。NESTED 的提交实际上只是释放了 savepoint,真正的提交仍然依赖外层事务。外层事务回滚时,所有操作(包括被释放 savepoint 的部分)都会回滚。
    • 追问 2:在什么情况下你会选择 REQUIRES_NEW 而不是 NESTED?答:当事务边界需要严格物理隔离,且内层操作的完成不依赖于外层事务时(如审计日志的独立提交)。NESTED 用于需要在同一连接上“试验性”地执行操作并能回滚的场景。
    • 追问 3:如果一个方法被标记了 REQUIRES_NEW,但在同一个类中被调用,会生效吗? 答:不会,这仍然是一个自调用问题,AOP 失效,传播行为不会生效。
  3. 如何定位 OSIV 引起的 N+1 查询?

    • 标准回答:最有效的工具是 p6spy 或慢查询日志。会发现有一次对父表的查询,和 N 次模式一致的对子表的重复查询。另外,可以通过请求耗时和jstack线程堆栈,观察到大量线程阻塞在执行 Hibernate 的懒加载初始化。
    • 追问 1:除了关闭 OSIV,还有哪些根治方法?答:根本方法是在 Service 层(事务边界内)完成所有数据加载,不将未初始化的懒加载集合暴露给视图。使用 @EntityGraphJOIN FETCH 是标准的 JPQL/HQL 解决方案。
    • 追问 2JOIN FETCH 有什么副作用?答:对于多对多或集合关联,会产生笛卡尔积问题,需要搭配 DISTINCT 关键字;并且 JOIN FETCHPageable 一起使用时,会触发 Hibernate 的内存分页警告,需要配合 countQuery
    • 追问 3:OSIV 开启和关闭,EntityManager 的生命周期有何不同?答:开启时,EntityManager 在请求到达 Controller 前创建(OpenEntityManagerInViewInterceptor.preHandle),在请求结束时关闭(afterCompletion)。关闭后,其生命周期严格等于 @Transactional 修饰的方法边界,随事务开始而创建,随事务结束(commit/rollback)而关闭。
  4. AbstractRoutingDataSource 在事务中切换失效的根本原因是什么?

    • 标准回答:原因在于事务管理器中的DataSourceTransactionManager在事务开始时,会立即从AbstractRoutingDataSource借取一个Connection。借取过程中,determineCurrentLookupKey()被执行,并返回了目标数据源。此时该连接已被绑定到当前事务。在此之后,不论如何修改 ThreadLocal 中的 Key,只要事务还在,借出的连接都是最初绑定的那个。
    • 追问 1LazyConnectionDataSourceProxy 能根治这个问题吗?答:它是一种折中方案,通过延迟物理连接的获取,让开发者在执行 SQL 前有机会去修改 Key。但这要求开发者必须清晰地知道“第一个 SQL 被执行”的时机,并在此之前完成 Key 的设置。同时,它会改变事务与连接绑定的固有行为,可能引入新问题。
    • 追问 2:在主库开启事务后,若必须读取最新的从库数据来验证,该如何处理?答:建议在架构上闭环,即将异步的、非事务性的“验证性”查询抽离到另一个独立的服务或方法中,通过 CompletableFuture 异步执行,并明确设置它的数据源 Key。不要试图在同一个事务上下文内解决。
    • 追问 3determineCurrentLookupKey() 如何优雅地实现,以避免内存泄漏?答:必须使用 ThreadLocal 并搭配 TransmittableThreadLocal(如果涉及父子线程传递),或者在一个更高层抽象(如注解+AOP)中管理它的生命周期,并且在每个请求处理完毕后,确保执行 finally 块中的 clear() 操作。
  5. Seata AT 模式下,如何分析全局锁冲突?

    • 标准回答:首先,Seata Server 端会记录下 lock conflict 的日志,这是最直接的证据。其次,发生冲突的客户端会收到 LockConflictException,并自动触发重试。如果重试耗尽,事务发起者会感知到超时全局事务回滚。排查时,可查询数据库全局锁表,同时结合业务日志定位哪个资源在高并发下竞争最激烈。
    • 追问 1:全局锁和数据库行锁的区别?答:行锁是数据库为了本地 ACID 而加的资源锁。全局锁是 Seata 协调者 (TC) 管理的逻辑层面锁,用于在分布式场景下隔离不同微服务对同一行数据的修改,其生命周期横跨整个分布式全局事务,比本地行锁长得多。
    • 追问 2:Seata AT 模式的一阶段、二阶段是什么?答:一阶段,各分支事务做本地提交,同时记录 undo_log,提交前向 TC 注册分支事务并申请全局锁。二阶段,TC 根据各分支的汇报,通知所有分支进行真正的提交(异步删除 undo_log)或回滚(通过 undo_logbefore_image 进行补偿)。
    • 追问 3:如果 Seata 的应用在业务高峰期总是发生锁冲突,有何优化方向?答:业务上,这通常表示异构服务对共享数据的同步写入过多,可以尝试将竞争变为串行化处理,如将扣减等操作收入队列异步处理;技术上,可以缩小全局事务的粒度,减少独占共享资源的时间。
  6. 系统设计题:设计一个支持读写分离和分库分表的统一数据访问中间件。要求能够自动识别主从并处理事务中的连接一致性,并给出事务监控和诊断方案。

    • 标准回答:架构上,在 DAO 层之上、Service 下层,引入一个 “Data Access Router(数据访问路由器)”,核心由 AbstractRoutingDataSource 和分库分表中间件组成。

      • 读写分离与事务一致性处理:利用 AOP 切面和 ThreadLocal 在开启事务的方法入口进行 “决策”。但这会面临案例 17 的困境。解决思路是:事务方法内强制只走主库。对于一个被 @Transactional 标注的方法,我们的路由器应该始终报错或强制使用主库。对于读操作,通过在 @ReadOnlyTrans 这类自定义注解中切换从库,并在协议层通过 javax.sql.DataSource 代理池(如 LazyConnectionDataSourceProxy 的变体)来确保读操作永远不绑定物理事务。
      • 分库分表:集成 ShardingSphere 或自定义分片引擎。DataSource 代理需能解析 SQL,根据分片键 (sharding key) 和算法确定具体物理DataSource,再将结果归并。
      • 监控诊断:其核心是构建一个对开发者完全透明、可观测的诊断层。
        1. 可观测性:中间件应该为 JDBC 层嵌入 p6spy 般的日志记录器,不仅记录 SQL、耗时,还必须记录它所到达的最终物理数据库实例
        2. TraceID:将当前路由决策(主/从,分片 ID)和事务 ID(如有)注入 Slf4j MDC,使其与业务日志无缝融合。
        3. Actuator 端点:暴露一个 /health/db-route 的端点,可以展示所有物理数据源的健康状况、活跃连接等。
        4. 决策追踪 (Arthas 集成化):提供一个脚本,利用 Arthas 的 API,能实时观察特定 Mapper 方法的 determineCurrentLookupKey() 返回值及最终路由的 jdbcUrl
    • 追问 1:如何实现 @Transactional 方法内强行走主库的约束?答:可以利用 TransactionSynchronizationManager.isSynchronizationActive() 来判断是否在事务中。如果是,且当前 ThreadLocal 的 Key 标识为从库,可以设计为:直接抛出 UnsupportedOperationException,或自动忽略从库设置并 WARN 提示,将 Key 重置为 Master。

    • 追问 2:分片键如何传递,例如跨方法调用?答:与读写 Key 相同,可以放在 ThreadLocal。但跨线程(如 @Async)时同样会丢失。进阶方案是通过 RPC 框架的服务上下文(如 Dubbo 的 RpcContext、Spring Cloud 的 RequestContextHolder)或在 MQ 消息 Headers 中显式传递分片键。

    • 追问 3:如果某个从库挂了,这个中间件如何做故障转移?答:通过在 AbstractRoutingDataSourceresolvedDataSources Map 中,对从库集群的配置进行故障转移处理。可以利用连接池的 fail-fast 和后台重建机制,或集成一些动态发现组件,在 determineTargetDataSource 发现当前 Key 对应的 DS 不可用时,轮询尝试同组其它健康从库。

    • 加分回答:设计中可以融入 JMX 管理接口,使得在运行时能够动态改变某个实例、某个分片数据源的路由权重,或动态标记一个数据源为Down掉,用于实现优雅的灰度切换和压测隔离。

12.2 数据访问与事务反模式速查表

反模式名称领域现象根因关键词修正要点
1. 异常粒度混淆异常处理业务误判DataAccessException 体系,异常翻译精确捕获子类 (DuplicateKeyException 等)
2. SQLErrorCodes 缺失异常处理重试逻辑失效SQLErrorCodeSQLExceptionTranslator, 错误码为新数据库配置自定义 SQLErrorCodes
3. RowMapper 内连接泄漏JdbcTemplate连接池耗尽JdbcTemplate.query, 连接获取/释放绝对禁止在 RowMapper 内获取新连接
4. queryForList 大结果集JdbcTemplateOOMqueryForList 全部加载至 List使用 RowCallbackHandler 或底层游标流式处理
5. REQUIRES_NEW 误用事务传播孤儿数据,逻辑不一致AbstractPlatformTransactionManager,事务挂起/恢复审计/补偿使用,关键路径使用 REQUIRED
6. NESTED 兼容性陷阱事务传播抛出异常,中断业务Connection.setSavepoint,数据库引擎确保引擎为 InnoDB 等支持 savepoint
7. 多线程上下文丢失事务失效子线程不在事务中ThreadLocal,事务同步管理器拆分为各独立编程式事务
8. @Async 与事务交织事务失效异步操作上下文丢失@Async 产生新线程需在 @Async 方法内独立管理事务
9. 非事务引擎失效事务失效事务回滚,数据不变MyISAM 忽略 commit/rollback确保业务表使用 InnoDB 引擎
10. SimpleJpaRepo 冲突JPATransactionRequiredException@Modifying 自身不带 @Transactional必须在调用方或有事务的外层包裹
11. Page 缺 countQueryJPA慢 SQLJPA 自动派生的 countQuery 包含 JOIN复杂查询必须提供简明 countQuery
12. save/clear 不当JPA慢、脏数据丢失GenerationType.IDENTITY, 持久化上下文生命周期切换主键策略;在 flush 后立即 clear
13. SqlSession 缓存混乱MyBatis读到过期数据SqlSessionTemplate,一级缓存,混合访问统一数据访问技术或手动清空缓存
14. MapperScan/批处理MyBatis启动慢、数据未提交MapperScannerRegistrarExecutorType.BATCH 上下文精确扫描包;用 SqlSessionTemplate.execute 管理批处理
15. OSIV N+1 风暴持久化上下文连接池耗尽,响应慢OpenEntityManagerInViewInterceptor,懒加载代理关闭 OSIV 或使用 JOIN FETCH
16. 懒加载异常持久化上下文LazyInitializationExceptionSession/EntityManager 时触发代理使用 DTO、JOIN FETCH 或在事务内初始化
17. 路由数据源失效多数据源事务中读操作落在主库determineTargetDataSourcedoBegin 时绑定连接事务方法强制使用主库
18. @Async 误入主库多数据源从库查询打到主库ThreadLocal 不跨线程在新线程中显式设置 DataSource Key
19. Seata 全局锁超时分布式事务业务大面积挂起回滚Seata AT 全局锁,lock_table降低业务冲突;合理调整锁等待参数
20. Seata undo_log 膨胀分布式事务磁盘空间耗尽日志清理机制未配置或失效配置并启用 TC 或应用端的定期清理
21. MQ 消费未幂等分布式事务重复消费、错账至少一次投递,缺少消费端去重基于事件ID实现本地事务的幂等(去重表/唯一键)

延伸阅读

全文总结 至此,本文通过对 Spring 数据访问与事务管理领域中 9 大门类、超过 20 个生产线高发反模式的深度剖析,将前 10 篇文章构建的知识体系全面转化为了实战排查能力。从捕捉一个不起眼的异常类型,到理解 ThreadLocal 如何隔离多线程事务,再到处置分布式锁冲突,这套“避坑与排错”的方法论,旨在帮助读者在复杂的线上环境中,不再依赖于猜测,而是有逻辑、有工具、有底气地定位并解决根因。