问题描述
项目中,我们实现了一个定时清理过期数据的功能。该功能使用 @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 注解的方法)必须在事务环境中执行。
技术背景
-
Spring事务管理机制
- Spring的声明式事务管理需要通过
@Transactional注解来开启 @Scheduled方法默认由Spring的任务调度器直接调用,不会自动创建事务
- Spring的声明式事务管理需要通过
-
JPA批量操作要求
- JPA的
@Modifying操作(INSERT、UPDATE、DELETE)必须在事务中执行 - 这是JPA规范的要求,确保数据一致性
- JPA的
-
问题场景
@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:
- 使用小批量删除(如2000条/批)
- 批次间添加短暂休息(如50ms)
- 先查主键再删除,避免全表扫描
Q3: 如何处理删除过程中的异常?
A:
- 使用
REQUIRES_NEW确保批次独立 - 单批次失败时记录日志但继续处理
- 设置最大重试次数避免无限循环
Q4: 定时任务的事务回滚策略?
A:
@Transactional(rollbackFor = Exception.class)
public void cleanupExpiredData() {
// 遇到任何异常都回滚
// 但要注意:整个清理过程回滚可能影响性能
}
总结
这个问题的核心在于理解Spring的事务管理机制:
- 问题根源:
@Scheduled方法默认不在事务中运行 - 解决关键:添加
@Transactional注解 - 优化策略:使用合适的事务传播级别和异常处理
- 性能考虑:批量操作、索引优化、监控指标
通过这次问题的解决,我们不仅修复了定时清理功能,还建立了一套完整的大数据量清理方案,为后续类似问题提供了参考。
相关技术文档
标签:Spring, JPA, 事务管理, 定时任务, 批量删除