LEFT JOIN FETCH 在 @Transactional 测试中返回空 PersistentBag
结论
问题通常出在同一持久化上下文中的实体状态没有同步,LEFT JOIN FETCH 本身一般没有失效。
测试类上的 @Transactional 可能让两次 MockMvc 请求加入同一个测试事务,共用同一个 EntityManager。创建用户时,User.profiles 已作为空的 PersistentBag 进入持久化上下文。之后保存 UserProfile 时,代码只设置了关联的拥有方 UserProfile.user,没有把该对象加入 User.profiles。
后续查询通过 SQL 找到了 users_profiles 记录,但 Hibernate 会根据实体标识返回持久化上下文中已有的 User 实例。集合一旦初始化,fetch join 不保证覆盖其中的现有内容,所以最终拿到的还是原来那个空 PersistentBag。
如果换到全新的持久化上下文后,尚未初始化的集合仍无法装载 SQL 返回的关联记录,就需要检查 Hibernate、实体映射或查询本身。
为什么数据库有记录,集合仍然为空
外键 user_id 位于 users_profiles 表中,因此 UserProfile.user 是关联的拥有方:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
User.profiles 则是反向映射:
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<UserProfile> profiles = new ArrayList<>();
下面的代码只建立了子实体到父实体的关系:
profile.setUser(savedUser);
userProfileRepository.save(profile);
它不会自动执行:
savedUser.getProfiles().add(profile);
此时,数据库和 Java 对象中的状态并不一致:
- 数据库中的
users_profiles.user_id已正确指向用户; - Java 对象中的
savedUser.getProfiles()仍然为空。
userProfileRepository.count() 通常会触发自动 flush。日志因此可以证明数据库中已有记录,但无法说明当前 User 对象里的集合也完成了同步。
推荐修复:同时维护双向关联
可以在实体中提供关联维护方法,并让所有创建逻辑通过这些方法建立关系:
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(
mappedBy = "user",
cascade = CascadeType.ALL,
orphanRemoval = true
)
private List<UserProfile> profiles = new ArrayList<>();
public void addProfile(UserProfile profile) {
profiles.add(profile);
profile.setUser(this);
}
public void removeProfile(UserProfile profile) {
profiles.remove(profile);
profile.setUser(null);
}
}
创建用户时,先构建完整的对象关系,再保存父实体:
@Transactional
public UserResponse createUser(SignupRequest request) {
User user = new User();
user.setEmail(request.getEmail());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setPhone(request.getPhone());
user.setEnabled(true);
user.setCreatedAt(LocalDateTime.now());
UserProfile profile = new UserProfile();
profile.setRole(request.getRole());
profile.setNickname(request.getRoleProfile().getNickname());
profile.setBio(request.getRoleProfile().getBio());
profile.setEnabled(true);
user.addProfile(profile);
User savedUser = userRepository.save(user);
// 组装响应
}
如果不配置级联,也可以分别保存,但仍需同时维护关联的两端:
User savedUser = userRepository.save(user);
UserProfile profile = new UserProfile();
profile.setRole(request.getRole());
profile.setNickname(request.getRoleProfile().getNickname());
profile.setBio(request.getRoleProfile().getBio());
profile.setEnabled(true);
savedUser.getProfiles().add(profile);
profile.setUser(savedUser);
userProfileRepository.save(profile);
Controller 集成测试建议移除 @Transactional
使用 MockMvc 连续调用 HTTP 接口时,通常可以移除测试类上的 @Transactional:
@SpringBootTest
@ActiveProfiles("test")
class UserControllerIntegrationTest {
// ...
}
这样,每个 Service 方法都会按照自己的事务边界运行,更接近真实请求:
- 注册请求执行并提交创建事务;
- 登录请求开启新的事务和持久化上下文;
- 查询从数据库重新构造
User及其关联。
如果移除测试事务后问题消失,基本可以确定问题与共享持久化上下文有关。不过,这种做法只是避开了测试中的旧对象状态,业务代码仍需正确维护双向关联。
测试数据可以通过独立数据库、@Sql 清理脚本或其他明确的清理方式处理,无需依赖测试事务自动回滚。
必须保留测试事务时
如果测试事务必须保留,可以在第一次操作后 flush 并清空持久化上下文:
@Autowired
private EntityManager entityManager;
@Test
@Transactional
void login_ShouldAuthenticateUser_WhenValidCredentials() throws Exception {
mockMvc.perform(post("/v0/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(signupRequest)))
.andExpect(status().isCreated());
entityManager.flush();
entityManager.clear();
mockMvc.perform(post("/v0/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(loginRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.user.roles[0].role_name").exists());
}
flush() 会把待处理的 SQL 写入数据库,clear() 则会移除一级缓存中的实体。后续查询需要重新加载 User,fetch join 也就能根据查询结果组装集合。
这种方式可以用来验证数据库读取结果,但不能替代双向关联维护。
LEFT JOIN FETCH 的行为边界
假设 Repository 查询如下:
@Query("""
select distinct u
from User u
left join fetch u.profiles
where u.email = :email
""")
Optional<User> findByEmailWithProfiles(@Param("email") String email);
当 User 和 profiles 尚未加载时,fetch join 应该在本次查询中加载集合。不过,它不是强制刷新指令,无法保证覆盖持久化上下文中已有且已经初始化的集合。
distinct 能处理一对多连接造成的父实体重复,却无法修复过期的集合状态。关闭二级缓存也解决不了一级缓存问题,因为两者属于不同的机制。
如果执行 entityManager.clear() 后问题依旧,可以继续检查以下内容:
- SQL 结果中的父实体主键是否与
users_profiles.user_id一致; - Repository 查询是否确实包含
left join fetch u.profiles; - 是否存在过滤条件、软删除或 Hibernate Filter;
- 集合是否在查询完成前被其他代码初始化;
- 能否通过最小化测试稳定复现问题。
在全新的持久化上下文中,如果未初始化的集合仍然无法接收 fetch join 返回的关联行,才有充分理由继续从 Hibernate、实体映射或查询实现入手,并考虑提交最小复现。
额外注意
双向实体不建议直接使用 Lombok @Data。它生成的 equals()、hashCode() 和 toString() 可能遍历关联,引发递归、意外懒加载或实体身份判断异常。通常更稳妥的做法是使用 @Getter、@Setter,并基于稳定身份显式实现 equals() 和 hashCode()。
备注:内容仅供参考。
