You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

REST调用下Spring Data JPA实体部分加载缓存异常问题咨询

问题现象

在使用Spring Data JPA的Specification与Entity Graph实现实体部分加载时,出现不一致行为:

  • 通过REST端点调用方法时,返回的实体仅包含部分关联数据(被缓存)
  • 通过定时任务调用同一方法时,能正常加载完整关联数据

业务逻辑

需求是:加载所有包含班级1C的学校,提取ID后重新加载这些学校的完整数据。

核心代码与输出对比

关键代码片段

实体定义

@Entity
@NamedEntityGraphs(
    value = [
        NamedEntityGraph(
            name = "School.classes",
            attributeNodes = [NamedAttributeNode("classes")]
        )
    ]
)
class School {
    @Id
    @GeneratedValue
    var id: Long = 0

    @Column(length = 30, nullable = false)
    var name: String = ""

    @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = false, mappedBy = "school")
    var classes: MutableSet<SchoolClass> = mutableSetOf()

    override fun toString(): String {
        return "School(id=$id, name='$name', classes=${classes.joinToString { it.name }})"
    }
}

@Entity
class SchoolClass {
    @Id
    @GeneratedValue
    var id: Long = 0

    @Column(length = 30, nullable = false)
    var name: String = ""

    @ManyToOne(optional = false)
    var school: School? = null

    override fun toString(): String {
        return "SchoolClass(id=$id, name='$name', school=${school?.name})"
    }
}

Repository定义

interface SchoolRepository : JpaRepository<School, Long>, JpaSpecificationExecutor<School> {
    @EntityGraph("School.classes")
    override fun findAll(spec: Specification<School>?): List<School>

    @Query("select s from School s where s.id in :ids")
    @EntityGraph("School.classes")
    fun findAllByIdWithClasses(@Param("ids") ids: Set<Long>): List<School>
}

object SchoolSpecifications {
    fun className(className: String) = Specification<School> { root, _, criteriaBuilder ->
        val classes = root.join<School, SchoolClass>("classes")
        criteriaBuilder.equal(classes.get<String>("name"), className)
    }

    fun name(name: String) = Specification<School> { root, _, criteriaBuilder ->
        criteriaBuilder.equal(root.get<String>("name"), name)
    }
}

业务服务与控制器

@Service
class SchoolService(private val schoolRepository: SchoolRepository) {
    fun crazyStuff(): List<School> {
        val schoolIds = schoolRepository.findAll(SchoolSpecifications.className("1C"))
            .map { it.id }
            .toSet()
        return schoolRepository.findAllByIdWithClasses(schoolIds)
    }

    fun allFine(): List<School> {
        return schoolRepository.findAll(SchoolSpecifications.name("Goethe-School"))
    }
}

@RestController
class Controller(private val schoolService: SchoolService) {
    @GetMapping("/")
    fun query() = printSchool()

    @Scheduled(fixedDelay = 5000)
    fun scheduled() = printSchool()

    private fun printSchool() {
        println("=crazy stuff= ${schoolService.crazyStuff()}")
        println("=all fine= ${schoolService.allFine()}")
    }
}

输出对比

  • REST端点输出(异常):
=crazy stuff= [School(id=1, name='Goethe-School', classes=1C)]
=all fine= [School(id=1, name='Goethe-School', classes=1C)]
  • 定时任务输出(正常):
=crazy stuff= [School(id=1, name='Goethe-School', classes=1D, 1B, 1C, 1A)]
=all fine= [School(id=1, name='Goethe-School', classes=1A, 1C, 1B, 1D)]
原因分析

问题根源在于JPA一级缓存(EntityManager缓存)的作用:

  1. REST请求默认处于同一个事务上下文,共用同一个EntityManager:
    • 第一次调用findAll(SchoolSpecifications.className("1C"))时,通过join查询匹配到包含1C的School,此时返回的School实体的classes集合仅包含匹配的1C(join过滤了结果),这个不完整的实体被存入一级缓存。
    • 第二次调用findAllByIdWithClasses时,JPA直接从一级缓存中取出已存在的School实体,不会执行新查询加载完整的classes集合。
  2. 定时任务每次执行时,Spring会创建新的事务上下文和EntityManager,一级缓存为空:
    • 两次查询都执行完整SQL,第二次查询通过Entity Graph加载School的所有关联班级,因此返回完整数据。
解决方案

以下是几种可行的解决方法,按推荐程度排序:

1. 直接查询ID而非完整实体(最优)

修改Repository,添加只查询学校ID的方法,避免将不完整的School实体存入缓存:

interface SchoolRepository : JpaRepository<School, Long>, JpaSpecificationExecutor<School> {
    // ... 保留原有方法

    @Query("select distinct s.id from School s join s.classes c where c.name = :className")
    fun findSchoolIdsByClassName(@Param("className") className: String): Set<Long>
}

修改crazyStuff方法:

fun crazyStuff(): List<School> {
    val schoolIds = schoolRepository.findSchoolIdsByClassName("1C")
    return schoolRepository.findAllByIdWithClasses(schoolIds)
}

这种方式从根源上避免了不完整实体进入缓存,性能也更优(仅查询ID字段)。

2. 拆分事务,让两次查询处于不同EntityManager

将第一次查询封装为独立事务,使用REQUIRES_NEW传播级别,确保每次查询使用新的EntityManager:

@Service
class SchoolService(private val schoolRepository: SchoolRepository) {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun getSchoolIdsByClassName(className: String): Set<Long> {
        return schoolRepository.findAll(SchoolSpecifications.className(className))
            .map { it.id }
            .toSet()
    }

    fun crazyStuff(): List<School> {
        val schoolIds = getSchoolIdsByClassName("1C")
        return schoolRepository.findAllByIdWithClasses(schoolIds)
    }

    // ... 其他方法
}

第一次查询的EntityManager在事务结束后被销毁,缓存的不完整实体不会影响第二次查询。

3. 手动清除EntityManager缓存

在第一次查询后手动清除EntityManager的缓存,注意这会清空当前EntityManager的所有缓存数据,可能影响其他操作:

@Service
class SchoolService(
    private val schoolRepository: SchoolRepository,
    private val entityManager: EntityManager
) {

    fun crazyStuff(): List<School> {
        val schools = schoolRepository.findAll(SchoolSpecifications.className("1C"))
        val schoolIds = schools.map { it.id }.toSet()
        // 清除缓存,避免后续查询复用不完整实体
        entityManager.clear()
        return schoolRepository.findAllByIdWithClasses(schoolIds)
    }

    // ... 其他方法
}

内容的提问来源于stack exchange,提问作者Marco

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.19 05:45:41