ddl-transaction-pitfall
🤖 Assistant
问题复盘与排查思路总结
问题现象
在使用 MyBatis-Plus DDL-Auto 插件进行数据库 schema 自动初始化时,出现了 ddl_history 表被成功创建,但其中没有任何脚本执行记录的异常情况。这意味着初始化流程被部分执行,但记录历史的 INSERT 操作最终失败。
第一阶段:初步诊断与首次尝试
- 问题表现:
ddl_history表为空。 - 核心猜想:
CREATE TABLE操作通常会隐式提交(Implicit Commit),而INSERT操作则需要显式提交。如果二者在一个事务中,而该事务最后没有被commit而是被rollback,就会导致表已创建但数据插入失败的现象。 - 日志分析 (初步):在最初的日志中,我们发现了关键线索:
com.zaxxer.hikari.pool.ProxyConnection: ... Executed rollback on connection ... due to dirty commit state on close().
这条日志是“确凿证据”,证实了 HikariCP 连接池在回收连接时,发现了一个未提交的事务(即“脏连接”),并为了安全起见自动执行了回滚。
4. 定位原因:MyBatis-Plus 的 DdlApplicationRunner 在执行过程中,通过 runner.setAutoCommit(false) 开启了手动事务模式。但由于它只是一个普通的 ApplicationRunner,在其 run 方法执行完毕后,并没有任何机制来显式 commit 这个事务。
5. 首次解决方案尝试:
- 思路:利用 Spring 强大的声明式事务管理。创建一个新的、带有
@Transactional注解的ApplicationRunner(我们称之为TransactionalDdlRunner),在这个 Runner 内部去调用原始的DdlApplicationRunner。 - 预期:Spring AOP 会为
TransactionalDdlRunner.run()方法创建一个事务。当方法正常结束时,Spring 会自动提交该事务,从而持久化INSERT操作。
第二阶段:深入分析与最终解决方案
- 新问题出现:应用首次解决方案后,
ddl_history表依然为空。问题并未解决。 - 日志分析 (深度):仔细审查新生成的日志,发现了更深层次的问题:DDL 逻辑被执行了两次。
- 第一次执行(无事务):日志显示,一个
DdlApplicationRunner在没有任何事务包裹的情况下被执行了。这次执行创建了表,但INSERT被回滚。 - 第二次执行(有事务):紧接着,我们创建的
TransactionalDdlRunner被执行,并正确地开启了事务。但在其内部调用 DDL 逻辑时,MyBatis-Plus 检测到ddl_history表已存在,且可能内部有机制防止重复执行脚本,因此跳过了所有 SQL 执行,包括INSERT。最终,一个空的事务被提交。
- 核心猜想:为什么会执行两次?
- 通过分析 Spring Boot 的启动流程,我们意识到
ApplicationRunner是一个特殊的接口。Spring 容器在启动阶段会自动扫描并执行所有被注册为 Bean 且实现了此接口的类。 - 在我们之前的配置中,存在两个这样的 Bean:
DdlApplicationRunner:由DdlExecutionConfig类通过@Bean方法创建。TransactionalDdlRunner:通过@Component注解被扫描和注册。
- 这就导致 Spring Boot 按照一定的(不确定的)顺序,依次执行了这两个 Runner,从而引发了上述的“二次执行”问题。
- 最终解决方案:整合与单一化
- 思路:必须确保 DDL 的配置和执行逻辑在一个统一的、具备事务能力的单元中完成,并且这个单元作为唯一的
ApplicationRunnerBean 被 Spring 管理。 - 实施步骤:
- 废弃分离的配置:删除原有的
DdlExecutionConfig和TransactionalDdlRunner。 - 创建统一的初始化器:新建一个
DdlInitializer类。 - 角色合一:让
DdlInitializer同时承担多个角色:
- 它是一个
@ConfigurationBean,能被 Spring 扫描到。 - 它实现了
ApplicationRunner接口,所以 Spring 会在启动时调用它的run方法。 - 它的
run方法被@Transactional注解,确保了执行逻辑的原子性。
- 控制权反转:在
DdlInitializer的run方法内部,@Autowired注入所有IDdl的实现。关键在于,将DdlApplicationRunner作为一个局部变量在方法内new出来,而不是作为 Spring Bean。这样它就只是一个普通的执行工具,Spring 不会再自动调用它的run方法。 - 完整配置:在这个事务方法内,完成对
DdlApplicationRunner实例的所有配置(如setAutoCommit, 错误处理器等),然后调用其run方法。 - 结果:现在整个 DDL 初始化流程只被调用一次,并且这次调用从始至终都处于一个被 Spring 正确管理的事务中。当
DdlInitializer.run()方法成功执行完毕后,事务被提交,INSERT语句被成功持久化到数据库。
经验总结 (Key Takeaways)
- 警惕框架的自动化机制:Spring Boot 的
ApplicationRunner自动执行机制非常方便,但也可能导致意想不到的重复执行,尤其是在通过@Bean和@Component等多种方式注册了实现了相同接口的Bean时。 - 日志是最终的真相来源:当行为与预期不符时,详细的 DEBUG 级别日志是揭示问题的最可靠工具。通过日志,我们能清晰地看到方法的调用顺序、事务的创建与提交/回滚、以及框架的内部决策过程。
- 事务边界至关重要:对于包含多个数据库写操作的业务逻辑,必须确保它们被包裹在同一个事务边界内。Spring 的
@Transactional是实现这一点的首选方式,但要确保它被正确地应用在执行入口上。 - 区分“Bean”与“普通对象”:在 Spring 环境中,要清晰地认识到一个类是被当作 Spring Bean 管理,还是仅仅作为一个普通的 Java 对象被创建和使用。这个区别决定了它是否会参与到 Spring 的生命周期管理(如自动执行
ApplicationRunner)中。 - 优雅的解决方案往往是整合与简化:当遇到由多个组件交互引起的复杂问题时,通常最佳的解决方案是重新设计,将分散的职责整合到一个更内聚、更清晰的单一组件中,而不是在现有复杂结构上“打补丁”。
- 原始修改文件 DdlExecutionConfig.java TransactionalDdlRunner.java