Spring @Transactional配合@Scheduled、Stream使用时事务不回滚如何解决?
Spring项目@Transactional注解配合@Scheduled/Stream操作不生效问题排查与解决
问题背景
在Spring项目中使用@Transactional注解时,发现该注解配合@Scheduled定时任务、Stream流操作使用时无法触发事务回滚,业务方法抛出异常后,已执行的持久化操作数据仍然被写入数据库。
相关代码
定时任务调度类
@Service public class DocumentServiceScheduler { private final Path storageArchitasPath; @Autowired private DocumentService documentService; public DocumentServiceScheduler(final DocumentConfiguration documentConfiguration) { this.storageArchitasPath = documentConfiguration.storageArchitasPath(); } @Scheduled(cron = "${document.scan.timer}") public void scanDocumentsTask() { this.scanDocuments(); } public void scanDocuments() { final List<DocumentProcessDetail> report = new ArrayList<>(); FileCoreUtils.loadPathFilesInDirectory(this.storageArchitasPath).forEach(fileName -> report.add(this.documentService.processDocument(fileName))); if (!report.isEmpty()) { final DataSource excelReport = FileCoreUtils.writeReportAsExcel(report); this.documentService.sendReportByEmail(excelReport); } } }
业务实现类
@Service public class DocumentServiceImpl implements DocumentService { private static Logger logger = LoggerFactory.getLogger(DocumentServiceImpl.class); private static String DOCUMENT_SEPARATOR = "#"; private final Path storageArchitasPath; private final Path storageSystemPath; private final DocumentRepository documentRepository; private final DocumentTypeRepository documentTypeRepository; public DocumentServiceImpl(final DocumentRepository documentRepository, final DocumentTypeRepository documentTypeRepository, final DocumentConfiguration documentConfiguration) { this.documentRepository = documentRepository; this.documentTypeRepository = documentTypeRepository; this.storageArchitasPath = documentConfiguration.storageArchitasPath(); this.storageSystemPath = documentConfiguration.storageSystemPath(); } @Override @Transactional public DocumentProcessDetail processDocument(final Path fileName) { final Date uploadDate = new Date(); try { final String[] splitFileName = fileName.getFileName().toString().split(DOCUMENT_SEPARATOR); String brokerId = splitFileName[0].trim(); final StringBuilder error = this.validateDocumentFormat(splitFileName); if (error.length() != 0) { FileUtils.forceDelete(this.storageArchitasPath.resolve(fileName).toFile()); return new DocumentProcessDetail(fileName.toString(), uploadDate, "Failure", error.toString()); } if (splitFileName.length == 2) { if (this.deleteDocument(splitFileName)) { return new DocumentProcessDetail(fileName.toString(), uploadDate, "Success", null); } FileUtils.forceDelete(this.storageArchitasPath.resolve(fileName).toFile()); error.append("Couldn't delete file, not found in database"); return new DocumentProcessDetail(fileName.toString(), uploadDate, "Failure", error.toString()); } final String type = splitFileName[1].toUpperCase().trim(); final String language = splitFileName[2].toUpperCase().trim(); final String filename = splitFileName[3].trim(); final String uuid = UUID.randomUUID().toString(); final String newFileName = uuid + "." + FilenameUtils.getExtension(fileName.toString()); final Path destination = this.generateStorageSystemPath(brokerId, newFileName); brokerId = brokerId.equalsIgnoreCase("all") ? null : brokerId; final Optional<DocumentTypeDB> documentTypeDB = this.documentTypeRepository.findById(DocumentType.valueOf(type.toUpperCase())); final DocumentDB document = new DocumentDB(filename, brokerId, documentTypeDB.get(), uploadDate, DocumentLanguage.valueOf(language), uuid); this.documentRepository.save(document); FileCoreUtils.moveFile(this.storageArchitasPath.resolve(fileName), destination); throw new Exception(); //return new DocumentProcessDetail(uuid, fileName.toString(), uploadDate, "Success", null); } catch (final Exception e) { try { FileUtils.forceDelete(this.storageArchitasPath.resolve(fileName).toFile()); } catch (final IOException e1) { logger.error("An unexpected error while deleting file: " + e.getStackTrace()); } //TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); throw new RuntimeException(); //return new DocumentProcessDetail(fileName.toString(), uploadDate, "failure", "An unexpected error occurred: " + e.getStackTrace()); } } }
Repository接口
@Repository public interface DocumentRepository extends JpaRepository<DocumentDB, Long>, DocumentRepositoryCustom { DocumentDB findByDocumentReference(String reference); }
环境说明
- 数据库:MySQL 5.7
- 持久层框架:Spring Data JPA
问题表现
processDocument方法抛出RuntimeException后,documentRepository.save操作的数据依然被持久化到数据库- 手动调用
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()强制回滚被忽略 - 新增
@EnableTransactionManagement注解无效果
启动自动配置评估日志如下:
========================== CONDITION EVALUATION DELTA ========================== Positive matches: ----------------- None Negative matches: ----------------- TransactionAutoConfiguration.EnableTransactionManagementConfiguration: Did not match: - @ConditionalOnMissingBean (types: org.springframework.transaction.annotation.AbstractTransactionManagementConfiguration; SearchStrategy: all) found beans of type 'org.springframework.transaction.annotation.AbstractTransactionManagementConfiguration' org.springframework.transaction.annotation.ProxyTransactionManagementConfiguration (OnBeanCondition) TransactionAutoConfiguration.EnableTransactionManagementConfiguration.CglibAutoProxyConfiguration: Did not match: - Ancestor org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration$EnableTransactionManagementConfiguration did not match (ConditionEvaluationReport.AncestorsMatchedCondition) Matched: - @ConditionalOnProperty (spring.aop.proxy-target-class=true) matched (OnPropertyCondition) Exclusions: ----------- None Unconditional classes: ---------------------- None
补充测试验证
测试方法
@Transactional public void testDoc() { final DocumentDB document = new DocumentDB("test", this.brokerId, new DocumentTypeDB(DocumentType.valueOf("TAX_FORMS")), new Date(), DocumentLanguage.valueOf("FRENCH"), "000"); System.out.println("documentRepository.save called... start"); this.documentRepository.save(document); System.out.println("documentRepository.save called... end"); }
事务执行日志
2021-08-18 15:32:26.486 INFO 7976 --- [ main] o.s.t.c.transaction.TransactionContext : Began transaction (1) for test context [DefaultTestContext@7be58f16 testClass = DocumentServiceImplTest, testInstance = be.architas.broker.portal.core.service.DocumentServiceImplTest@553f1d75, testMethod = testDoc@DocumentServiceImplTest, testException = [null], mergedContextConfiguration = [MergedContextConfiguration@242aa8d9 testClass = DocumentServiceImplTest, locations = '{}', classes = '{class be.architas.broker.portal.core.BrokerPortalImplTestConfig}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@1e4d3ce5, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@379614be, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@14fa86ae, org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@398dada8, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@4066c471, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@65466a6a], contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.event.ApplicationEventsTestExecutionListener.recordApplicationEvents' -> false]]; transaction manager [org.springframework.orm.jpa.JpaTransactionManager@4ebd0b52]; rollback [true] documentRepository.save called... start 2021-08-18 15:32:26.651 TRACE 7976 --- [ main] o.s.t.i.TransactionInterceptor : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save] Hibernate: select documentty_.ID, documentty_.IS_OFFICIAL as is_offic2_1_, documentty_.RETENTION as retentio3_1_ from DOCUMENT_TYPE documentty_ where documentty_.ID=? Hibernate: insert into DOCUMENT (BROKER_ID, DOCUMENT_REFERENCE, LANGUAGE, NAME, TYPE_ID, UPLOAD_DATE) values (?, ?, ?, ?, ?, ?) 2021-08-18 15:32:26.757 TRACE 7976 --- [ main] o.s.t.i.TransactionInterceptor : Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save] documentRepository.save called... end 2021-08-18 15:32:26.771 INFO 7976 --- [ main] o.s.t.c.transaction.TransactionContext : Rolled back transaction for test: [DefaultTestContext@7be58f16 testClass = DocumentServiceImplTest, testInstance = be.architas.broker.portal.core.service.DocumentServiceImplTest@553f1d75, testMethod = testDoc@DocumentServiceImplTest, testException = [null], mergedContextConfiguration = [MergedContextConfiguration@242aa8d9 testClass = DocumentServiceImplTest, locations = '{}', classes = '{class be.architas.broker.portal.core.BrokerPortalImplTestConfig}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@1e4d3ce5, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@379614be, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@14fa86ae, org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@398dada8, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@4066c471, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@65466a6a], contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.event.ApplicationEventsTestExecutionListener.recordApplicationEvents' -> false]]
测试日志显示外层事务已经标记回滚,但Repository的save方法似乎拥有独立事务,执行完成后直接提交。
问题根源与解决方案
核心问题排查
- MySQL表引擎不支持事务
MySQL 5.7默认创建表使用MyISAM引擎,该引擎不支持事务操作,即使上层代码配置了事务也无法回滚。
- 验证方法:执行SQL
SHOW CREATE TABLE DOCUMENT;查看表引擎 - 解决方法:执行SQL
ALTER TABLE DOCUMENT ENGINE = InnoDB;将表引擎修改为支持事务的InnoDB,所有涉及事务操作的表都需要同步修改。
- Repository方法配置了独立事务传播级别
如果DocumentRepository或父接口JpaRepository上配置了@Transactional(propagation = Propagation.REQUIRES_NEW),会导致每次调用save方法时开启独立的新事务,外层事务回滚无法影响到已经提交的独立事务。
- 解决方法:删除Repository层的独立事务配置,保持默认的
Propagation.REQUIRED传播级别,让save方法加入外层业务方法的同一个事务。
其他辅助检查
- 确认
@Transactional注解的包是org.springframework.transaction.annotation.Transactional,不是javax.persistence包下的同名注解。 - 多数据源场景下,需要给
@Transactional显式指定对应数据源的事务管理器:@Transactional(transactionManager = "jpaTransactionManager")。 - 如果需要回滚所有异常类型,可给注解添加属性
rollbackFor = Exception.class。
内容的提问来源于stack exchange,提问作者bindCake
相关产品推荐
相关产品推荐

