PAYATHON 2026

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 方法都会按照自己的事务边界运行,更接近真实请求:

  1. 注册请求执行并提交创建事务;
  2. 登录请求开启新的事务和持久化上下文;
  3. 查询从数据库重新构造 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()。

备注:内容仅供参考。