
本文详解因并发执行导致的重复删除异常问题,重点分析 EmptyResultDataAccessException 的根本原因,并提供线程安全、事务可控的批量删除解决方案。
本文详解因并发执行导致的重复删除异常问题,重点分析 `emptyresultdataaccessexception` 的根本原因,并提供线程安全、事务可控的批量删除解决方案。
在 Spring Boot 应用中,使用 @Async + @Transactional 批量删除实体(如用户行为日志)是一种常见需求。但若未充分考虑并发与事务边界,极易触发 org.springframework.dao.EmptyResultDataAccessException: No class com.db.model.UserActivity entity with id XXX exists! 异常——表面看是“删了一个不存在的 ID”,实则暴露了典型的竞态条件(Race Condition)问题。
? 问题根源:并发读-删不一致
您提供的代码中,deleteUserActivities() 方法存在两个关键隐患:
- 查询与删除分离:先通过 findCreatedBefore(...) 获取待删 ID 列表,再逐个调用 deleteById(id);
- 无并发防护:当调度器(如 @Scheduled)高频触发或上一次执行耗时较长时,多个异步线程可能同时执行该方法,并各自查到同一组 ID。第一个线程成功删除后,第二个线程尝试删除相同 ID 即抛出 EmptyResultDataAccessException。
⚠️ 注意:deleteById() 在 JPA 中默认执行 SELECT + DELETE(取决于实现),且 @Transactional 作用于方法级别,无法跨线程互斥。@Async 更会将每次调用分发至独立线程池线程,彻底失去单线程串行保障。
✅ 推荐解决方案:原子化 + 幂等性设计
方案一:使用 JPQL 批量删除(推荐 ✅)
绕过实体加载,直接执行 SQL 级别删除,天然避免竞态,性能更高:
@Repository
public interface UserActivityRepository extends JpaRepository<UserActivity, Long> {
@Modifying
@Query("DELETE FROM UserActivity u WHERE u.createdAt < :createdBefore")
int deleteByCreatedAtBefore(@Param("createdBefore") LocalDateTime createdBefore);
// 或使用 Spring Data JPA 内置方法(需字段名匹配)
// long deleteByCreatedAtBefore(LocalDateTime createdBefore);
}调用方式(无需循环,无异常风险):
@Async
@Transactional
public void deleteUserActivities(LocalDateTime createdBefore) {
int deletedCount = userActivityRepository.deleteByCreatedAtBefore(createdBefore);
log.info("Deleted {} UserActivity records before {}", deletedCount, createdBefore);
}✅ 优势:
- 原子执行,无中间状态;
- 避免 N+1 查询,性能提升显著;
- 天然幂等(重复执行结果一致);
- 无需手动捕获 EmptyResultDataAccessException。
方案二:加分布式锁(适用于复杂业务逻辑)
若删除前需校验、触发事件或关联清理,可在方法入口加锁:
@Async
public void deleteUserActivities(LocalDateTime createdBefore) {
String lockKey = "user_activity_cleanup:" + createdBefore.toLocalDate();
boolean locked = redisLock.tryLock(lockKey, 30, TimeUnit.SECONDS);
if (!locked) {
log.warn("Skip execution: lock not acquired for {}", lockKey);
return;
}
try {
// 此处执行您的原逻辑(含循环删除)
for (Long uaId : userActivityRepository.findCreatedBefore(createdBefore)) {
userActivityRepository.deleteById(uaId); // 仍建议改用批量删除
}
} finally {
redisLock.unlock(lockKey);
}
}? 提示:优先选择方案一;仅当业务强依赖单实体生命周期钩子(如 @PreRemove)时,才考虑方案二,并务必配合 try-catch + 日志降级。
? 关键注意事项总结
- ❌ 避免在 @Async 方法中使用“查 ID → 循环删”模式,尤其在定时任务场景;
- ✅ 批量操作优先使用 @Modifying + @Query 或 JpaRepository#deleteAllInBatch(...);
- ⏱️ 若必须分批处理(如防止长事务),应使用 OFFSET/LIMIT 分页查询 + IN (...) 批删,并确保分页键(如 id)单调递增;
- ? 测试时模拟高并发调用(如 JMeter 启动 5+ 线程),验证删除幂等性;
- ? 监控 deletedCount 返回值,及时发现数据不一致或锁竞争问题。
通过将“逻辑删除”下沉至数据库层,不仅能根治并发异常,更能显著提升系统吞吐量与稳定性——这才是面向生产环境的正确实践。










