Press "Enter" to skip to content

Spring定时任务事务管理问题解决方案

问题描述

项目中,我们实现了一个定时清理过期数据的功能。该功能使用 @Scheduled 注解每小时执行一次,通过JPA进行批量删除操作。但在运行过程中遇到了以下错误:

2025-07-10 23:30:58.801  INFO 244 --- [scheduling-1] c.j.s.service.ExpiryDataCleanupService   : 开始清理过期数据
2025-07-10 23:30:58.815 ERROR 244 --- [scheduling-1] c.j.s.service.ExpiryDataCleanupService   : 分批删除过期数据失败

org.springframework.dao.InvalidDataAccessApiUsageException: Executing an update/delete query; nested exception is javax.persistence.TransactionRequiredException: Executing an update/delete query
	at org.springframework.orm.jpa.EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible(EntityManagerFactoryUtils.java:403)
	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:235)
	at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:551)
	at org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:61)
	at org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:242)
	at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:152)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:145)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215)
	at com.sun.proxy.$Proxy155.deleteByIds(Unknown Source)
	at com.jd.shorter.service.ExpiryDataCleanupService.deleteBatchExpiredData(ExpiryDataCleanupService.java:95)
	at com.jd.shorter.service.ExpiryDataCleanupService.cleanupExpiredData(ExpiryDataCleanupService.java:44)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.springframework.scheduling.support.ScheduledMethodRunnable.run(ScheduledMethodRunnable.java:84)
	at org.springframework.scheduling.support.DelegatingErrorHandlingRunnable.run(DelegatingErrorHandlingRunnable.java:54)
	at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
	at java.util.concurrent.FutureTask.runAndReset(FutureTask.java:308)
	at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.access$301(ScheduledThreadPoolExecutor.java:180)
	at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:294)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)
Caused by: javax.persistence.TransactionRequiredException: Executing an update/delete query
	at org.hibernate.internal.AbstractSharedSessionContract.checkTransactionNeededForUpdateOperation(AbstractSharedSessionContract.java:422)
	at org.hibernate.query.internal.AbstractProducedQuery.executeUpdate(AbstractProducedQuery.java:1679)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.springframework.orm.jpa.SharedEntityManagerCreator$DeferredQueryInvocationHandler.invoke(SharedEntityManagerCreator.java:406)
	at com.sun.proxy.$Proxy167.executeUpdate(Unknown Source)
	at org.springframework.data.jpa.repository.query.JpaQueryExecution$ModifyingExecution.doExecute(JpaQueryExecution.java:239)
	at org.springframework.data.jpa.repository.query.JpaQueryExecution.execute(JpaQueryExecution.java:88)
	at org.springframework.data.jpa.repository.query.AbstractJpaQuery.doExecute(AbstractJpaQuery.java:155)
	at org.springframework.data.jpa.repository.query.AbstractJpaQuery.execute(AbstractJpaQuery.java:143)
	at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:137)
	at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:121)
	at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:159)
	at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.invoke(QueryExecutorMethodInterceptor.java:138)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor.invoke(DefaultMethodInvokingMethodInterceptor.java:80)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123)
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:388)
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:137)
	... 22 common frames omitted

错误分析

根本原因

该错误的根本原因在于 Spring的定时任务(@Scheduled)默认不在事务中运行,而JPA的批量删除操作(特别是使用 @Modifying 注解的方法)必须在事务环境中执行。

技术背景

  1. Spring事务管理机制

    • Spring的声明式事务管理需要通过 @Transactional 注解来开启
    • @Scheduled 方法默认由Spring的任务调度器直接调用,不会自动创建事务
  2. JPA批量操作要求

    • JPA的 @Modifying 操作(INSERT、UPDATE、DELETE)必须在事务中执行
    • 这是JPA规范的要求,确保数据一致性
  3. 问题场景

    @Scheduled(fixedRate = 3600000)
    public void cleanupExpiredData() {
        // 这里没有事务环境
        deleteBatchExpiredData(); // 调用包含@Modifying操作的方法
    }
    

解决方案

方案一:在调度方法上添加事务注解(推荐)

最简单直接的解决方案是在 @Scheduled 方法上添加 @Transactional 注解:

@Scheduled(fixedRate = 3600000)
@Transactional  // 添加事务注解
public void cleanupExpiredData() {
    // 现在整个方法都在事务中运行
    // ... 清理逻辑
}

方案二:调整事务传播级别

为了更好地控制事务行为,可以为内部方法设置合适的事务传播级别:

@Transactional(propagation = Propagation.REQUIRES_NEW)
private int deleteBatchExpiredData(LocalDateTime currentTime, int batchSize) {
    // 使用REQUIRES_NEW确保每个批次都有独立的事务
    // 即使某批次失败,也不影响其他批次
}

方案三:完整的异常处理机制

@Scheduled(fixedRate = 3600000)
@Transactional
public void cleanupExpiredData() {
    try {
        // 清理逻辑
        while (iterations < maxIterations) {
            try {
                int deletedInThisBatch = deleteBatchExpiredData(currentTime, batchSize);
                // 处理成功的情况
            } catch (Exception e) {
                log.error("第 {} 批删除失败,跳过本批次继续处理", iterations, e);
                // 单次失败不影响整个清理过程
                continue;
            }
        }
    } catch (Exception e) {
        log.error("清理过期数据失败", e);
    }
}

完整代码实现

修复后的清理服务

@Service
@Slf4j
public class ExpiryDataCleanupService {
    
    @Autowired
    private ExpiryShortDataRepository expiryShortDataRepository;
    
    /**
     * 每小时执行一次清理过期数据(分批处理)
     */
    @Scheduled(fixedRate = 3600000) // 1小时 = 3600000毫秒
    @Transactional  // 关键修复:添加事务注解
    public void cleanupExpiredData() {
        try {
            LocalDateTime currentTime = LocalDateTime.now();
            log.info("开始清理过期数据");
            
            int batchSize = 2000;
            int maxIterations = 200;
            int totalDeleted = 0;
            int iterations = 0;
            
            while (iterations < maxIterations) {
                iterations++;
                
                try {
                    // 分批删除过期数据
                    int deletedInThisBatch = deleteBatchExpiredData(currentTime, batchSize);
                    totalDeleted += deletedInThisBatch;
                    
                    if (deletedInThisBatch == 0) {
                        break;
                    }
                    
                    Thread.sleep(50);
                    
                    if (iterations % 20 == 0) {
                        log.info("清理进度: 已执行 {} 批,累计删除 {} 条记录", iterations, totalDeleted);
                    }
                    
                } catch (Exception e) {
                    log.error("第 {} 批删除失败,跳过本批次继续处理", iterations, e);
                    continue;
                }
            }
            
            if (totalDeleted > 0) {
                log.info("过期数据清理完成: 删除了 {} 条记录,执行了 {} 批次", totalDeleted, iterations);
            }
            
        } catch (Exception e) {
            log.error("清理过期数据失败", e);
        }
    }
    
    /**
     * 分批删除过期数据(高效版本:先查ID,再删除)
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    private int deleteBatchExpiredData(LocalDateTime currentTime, int batchSize) {
        try {
            // 第一步:查询过期数据的ID(只查主键,很快)
            List<Long> expiredIds = expiryShortDataRepository.findExpiredDataIds(currentTime, batchSize);
            
            if (expiredIds.isEmpty()) {
                return 0;
            }
            
            // 第二步:按主键批量删除(最高效的删除方式)
            int deletedCount = expiryShortDataRepository.deleteByIds(expiredIds);
            
            if (log.isDebugEnabled()) {
                log.debug("本批次删除 {} 条过期数据,ID范围: {} - {}", 
                    deletedCount, expiredIds.get(0), expiredIds.get(expiredIds.size() - 1));
            }
            
            return deletedCount;
            
        } catch (Exception e) {
            log.error("分批删除过期数据失败,批次大小: {}", batchSize, e);
            throw new RuntimeException("删除过期数据失败", e);
        }
    }
}

Repository接口

@Repository
public interface ExpiryShortDataRepository extends JpaRepository<ExpiryShortData, Long> {
    
    /**
     * 高效的分批删除:先查询过期数据的ID,再按主键删除
     */
    @Query(value = "SELECT id FROM expiry_short_data WHERE expiry_time < :currentTime ORDER BY id LIMIT :batchSize", nativeQuery = true)
    List<Long> findExpiredDataIds(@Param("currentTime") LocalDateTime currentTime, @Param("batchSize") int batchSize);
    
    /**
     * 按主键批量删除(效率最高)
     */
    @Modifying
    @Query(value = "DELETE FROM expiry_short_data WHERE id IN :ids", nativeQuery = true)
    int deleteByIds(@Param("ids") List<Long> ids);
}

事务传播级别说明

传播级别 说明 适用场景
REQUIRED 如果存在事务则加入,否则创建新事务 默认级别,大多数场景
REQUIRES_NEW 总是创建新事务,挂起当前事务 需要独立事务的操作
MANDATORY 必须在现有事务中运行,否则抛异常 强制要求事务环境
NEVER 绝不能在事务中运行,否则抛异常 只读操作,不需要事务

在我们的场景中,选择 REQUIRES_NEW 的原因:

  • 每个批次都有独立的事务
  • 单个批次失败不影响其他批次
  • 便于错误恢复和重试

性能优化建议

1. 批量删除策略

// 优化前:直接删除(可能锁表)
@Modifying
@Query("DELETE FROM ExpiryShortData e WHERE e.expiryTime < :currentTime")
int deleteExpiredData(@Param("currentTime") LocalDateTime currentTime);

// 优化后:先查ID再删除(高效且安全)
@Query(value = "SELECT id FROM expiry_short_data WHERE expiry_time < :currentTime ORDER BY id LIMIT :batchSize", nativeQuery = true)
List<Long> findExpiredDataIds(@Param("currentTime") LocalDateTime currentTime, @Param("batchSize") int batchSize);

@Modifying
@Query(value = "DELETE FROM expiry_short_data WHERE id IN :ids", nativeQuery = true)
int deleteByIds(@Param("ids") List<Long> ids);

2. 数据库索引优化

-- 确保过期时间字段有索引
CREATE INDEX idx_expiry_time ON expiry_short_data(expiry_time);

-- 主键本身就是最高效的删除条件
-- 按主键删除 > 按索引删除 > 按普通字段删除

3. 监控和调优

// 添加监控指标
@Scheduled(fixedRate = 3600000)
@Transactional
public void cleanupExpiredData() {
    long startTime = System.currentTimeMillis();
    int totalDeleted = 0;
    
    try {
        // ... 清理逻辑
    } finally {
        long duration = System.currentTimeMillis() - startTime;
        log.info("清理任务完成: 删除{}条记录, 耗时{}ms", totalDeleted, duration);
    }
}

常见问题与解决方案

Q1: 为什么不直接使用 @Transactional(propagation = Propagation.MANDATORY)

A: MANDATORY 要求必须在现有事务中运行,而 @Scheduled 方法默认没有事务环境,会直接抛异常。

Q2: 大批量删除时如何避免锁表?

A:

  1. 使用小批量删除(如2000条/批)
  2. 批次间添加短暂休息(如50ms)
  3. 先查主键再删除,避免全表扫描

Q3: 如何处理删除过程中的异常?

A:

  1. 使用 REQUIRES_NEW 确保批次独立
  2. 单批次失败时记录日志但继续处理
  3. 设置最大重试次数避免无限循环

Q4: 定时任务的事务回滚策略?

A:

@Transactional(rollbackFor = Exception.class)
public void cleanupExpiredData() {
    // 遇到任何异常都回滚
    // 但要注意:整个清理过程回滚可能影响性能
}

总结

这个问题的核心在于理解Spring的事务管理机制:

  1. 问题根源@Scheduled 方法默认不在事务中运行
  2. 解决关键:添加 @Transactional 注解
  3. 优化策略:使用合适的事务传播级别和异常处理
  4. 性能考虑:批量操作、索引优化、监控指标

通过这次问题的解决,我们不仅修复了定时清理功能,还建立了一套完整的大数据量清理方案,为后续类似问题提供了参考。

相关技术文档


标签:Spring, JPA, 事务管理, 定时任务, 批量删除

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注