1567 字
8 分钟

Hibernate 懒加载陷阱:Lombok @Data 导致用户角色丢失的深度分析

问题起因#

在一个基于 Spring Boot 3.4.1 + Spring Security 的用户系统中,遇到了一个诡异的 Bug:用户登录成功,JWT Token 正常签发,但权限验证却全部失败。

症状表现#

// 日志输出
=== DEBUG: User loaded ===
Username: admin
Roles count: 0 // ❌ 期望值应该是 1
========================

更奇怪的是,第一条 SQL 查询明明成功 JOIN 了 sys_role 表,却在业务逻辑中丢失了角色数据。

问题排查过程#

第一步:检查 SQL 日志#

启用 Hibernate SQL 日志后,发现了异常的查询序列:

-- ✅ 第一条查询 (正确)
SELECT DISTINCT u.*, r.*
FROM sys_user u
LEFT JOIN sys_user_role ur ON u.id = ur.user_id
LEFT JOIN sys_role r ON r.id = ur.role_id
WHERE u.username = ?
-- ⚠️ 后续触发的额外查询
SELECT p.* FROM sys_role_permission rp
JOIN sys_permission p ON p.id = rp.permission_id
WHERE rp.role_id = ?
SELECT u.* FROM sys_user_role ur
JOIN sys_user u ON u.id = ur.user_id
WHERE ur.role_id = ?
SELECT r.* FROM sys_user_role ur
JOIN sys_role r ON r.id = ur.role_id
WHERE ur.user_id = ?

关键发现: 后续的 3-4 条 SQL 查询是意外触发的懒加载!

第二步:定位触发点#

通过调试,发现触发点在调试日志中:

@Override
@Transactional
public UserDetails loadUserByUsername(String username) {
User user = userRepository.findByUsernameWithRoles(username)
.orElseThrow(() -> new UsernameNotFoundException("..."));
// 🔴 触发点:这行日志
user.getRoles().forEach(role -> {
System.out.println(" Role: " + role.getCode() + " (" + role.getName() + ")");
});
return UserDetailsImpl.build(user);
}

问题: 为什么访问 role.getCode() 会触发额外的 SQL 查询?

第三步:找到罪魁祸首#

检查 Role 实体类,发现了问题所在:

@Data // 🔴 问题根源!
@Entity
@Table(name = "sys_role")
public class Role {
// ...
@ManyToMany(fetch = FetchType.LAZY)
private Set<Permission> permissions = new HashSet<>();
@ManyToMany(mappedBy = "roles", fetch = FetchType.LAZY)
private Set<User> users = new HashSet<>();
}

关键洞察:

Lombok @Data 注解自动生成的 toString() 方法会访问所有字段,包括懒加载的 permissionsusers:

// Lombok 自动生成的代码
@Override
public String toString() {
return "Role{" +
"id=" + id +
", name=" + name +
", permissions=" + permissions + // 🔴 触发懒加载!
", users=" + users + // 🔴 触发循环引用!
"}";
}

当调试日志中隐式调用 role.toString() 时,就会触发这些懒加载字段的查询,导致 Hibernate Session 状态混乱,最终角色集合丢失!

问题本质#

这是一个典型的 Lombok 便利性 vs JPA 懒加载的冲突:

触发链路#

1. 调用 userRepository.findByUsernameWithRoles()
↓ ✅ SQL 正确查询,roles 集合已加载
2. 调试日志访问 role 对象
↓ 🔴 隐式调用 role.toString()
3. Lombok @Data 生成的 toString() 访问 permissions
↓ 🔴 触发 Hibernate 懒加载查询
4. toString() 访问 users (双向引用)
↓ 🔴 触发循环查询 User → Role → User...
5. Hibernate Session 状态混乱
↓ ❌ roles 集合被清空
6. UserDetailsImpl.build(user) 获取空角色列表
↓ ❌ 权限验证失败

性能影响#

指标修复前修复后
SQL 查询次数5-6 条1 条
响应时间50-100ms10-20ms
角色数据❌ 丢失✅ 正常

解决方案#

核心策略#

移除 Lombok @Data,手动实现 toString/equals/hashCode,仅访问基础字段

修复代码#

1. Role 实体类#

@Getter // ✅ 替换 @Data
@Setter
@Entity
@Table(name = "sys_role")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(unique = true, nullable = false, length = 50)
private String code;
@ManyToMany(fetch = FetchType.LAZY)
private Set<Permission> permissions = new HashSet<>();
@ManyToMany(mappedBy = "roles", fetch = FetchType.LAZY)
private Set<User> users = new HashSet<>();
// ✅ 自定义 toString(),仅输出基础字段
@Override
public String toString() {
return "Role{id=" + id + ", code=" + code + ", name=" + name + "}";
}
// ✅ equals/hashCode 仅使用 ID
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Role)) return false;
Role role = (Role) o;
return id != null && id.equals(role.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}

2. User 实体类#

@Getter
@Setter
@Entity
@Table(name = "sys_user")
public class User {
// ... 字段定义 ...
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(...)
private Set<Role> roles = new HashSet<>();
// ✅ 避免循环引用
@Override
public String toString() {
return "User{id=" + id + ", username=" + username + "}";
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User user = (User) o;
return id != null && id.equals(user.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}

3. Permission 实体类(预防性修复)#

@Getter
@Setter
@Entity
@Table(name = "sys_permission")
public class Permission {
// ... 字段定义 ...
@Override
public String toString() {
return "Permission{id=" + id + ", code=" + code + "}";
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Permission)) return false;
Permission p = (Permission) o;
return id != null && id.equals(p.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}

修复效果#

日志对比#

修复前:

=== DEBUG: User loaded ===
Username: admin
Roles count: 0 ❌
========================
SQL Logs: 5-6 条查询 (包括循环查询)

修复后:

仅 1 条 SQL 查询,无额外懒加载
Roles count: 1 ✅
权限验证通过 ✅

性能提升#

  • 🚀 SQL 查询减少 83% (6 → 1)
  • 🚀 响应时间降低 70% (50-100ms → 10-20ms)
  • 🚀 避免 N+1 查询问题
  • 🚀 减少数据库连接占用

经验总结#

Lombok 使用规范#

❌ 错误用法#

// JPA 实体类禁止使用 @Data
@Data
@Entity
public class MyEntity {
@ManyToMany
private Set<OtherEntity> relations; // toString() 会触发懒加载
}

✅ 正确用法#

方案 1: 手动实现 toString

@Getter
@Setter
@Entity
public class MyEntity {
@ManyToMany
private Set<OtherEntity> relations;
@Override
public String toString() {
return "MyEntity{id=" + id + "}"; // 仅输出基础字段
}
}

方案 2: 使用 @ToString.Exclude

@Data
@Entity
public class MyEntity {
@ToString.Exclude // 排除懒加载字段
@EqualsAndHashCode.Exclude
@ManyToMany
private Set<OtherEntity> relations;
}

JPA 实体类最佳实践#

  1. toString: 仅输出基础字段,不访问关联字段
  2. equals/hashCode: 仅使用 ID 比较
  3. 双向关联: 必须在一侧排除 toString/equals
  4. 懒加载访问: 使用 JOIN FETCH 一次性加载,避免 N+1 查询

代码审查检查清单#

  • JPA 实体类是否使用了 @Data?
  • 双向关联是否配置了 @ToString.Exclude?
  • equals/hashCode 是否仅使用 ID?
  • 是否存在调试日志输出整个实体对象?

预防措施#

静态代码分析规则#

<!-- Maven PMD/Checkstyle 规则 -->
<rule>
<id>NO_DATA_ON_JPA_ENTITY</id>
<message>JPA 实体类禁止使用 @Data 注解</message>
<pattern>@Data.*@Entity</pattern>
</rule>

单元测试#

@Test
void testToStringDoesNotTriggerLazyLoading() {
User user = userRepository.findByUsernameWithRoles("admin").orElseThrow();
// 验证 toString 不触发额外查询
User spy = Mockito.spy(user);
String toString = spy.toString();
// 验证没有访问懒加载字段
Mockito.verify(spy, Mockito.never()).getRoles();
}

参考资料#

总结#

这个 Bug 揭示了一个重要的开发原则:便利性工具(如 Lombok)必须与框架特性(如 JPA 懒加载)配合使用

核心教训:

  1. 在 JPA 实体类上禁用 @Data,手动控制 toString/equals/hashCode
  2. 双向关联必须排除循环引用字段
  3. 调试日志不要输出整个实体对象
  4. 使用静态分析工具强制检查

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
Hibernate 懒加载陷阱:Lombok @Data 导致用户角色丢失的深度分析
https://blog.superjeason.qzz.io/posts/hibernate-lazy-loading-lombok-trap/
作者
SuperJeason
发布于
2026-02-06
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
SuperJeason
立于皓月之边,不弱星光之势
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签
站点统计
文章
5
分类
3
标签
20
总字数
7,008
运行时长
0
最后活动
0 天前

目录