滥用Lombok的@EqualsAndHashCode导致线上事故复盘
一、血的教训:那次生产事故,差点让我被祭天
去年双十一大促,电商团队线上系统频繁告警------订单去重逻辑失效,同一用户的库存被反复扣减,资金对账一塌糊涂。
连夜排查,根因竟是一行注解:@EqualsAndHashCode。
我们的OrderDTO用了@Data(包含@EqualsAndHashCode默认行为),里面有个updateTime字段。每次订单状态变更,updateTime都会更新。问题来了------
java
`@Data
public class OrderDTO {
private Long orderId;
private String userId;
private LocalDateTime updateTime; // ← 致命字段
}
`
同一个订单,更新前后updateTime不同 → hashCode()不同 → Redis缓存判定为不同key → 旧缓存清不掉,新缓存写不进 → 库存数据错乱。
更狠的还在后面。我们的User实体继承了BaseEntity:
java
`@Data
public class BaseEntity {
protected String code; // 业务编码
}
@Data
public class User extends BaseEntity {
private String name;
}
`
两个User对象,code不同("U001" vs "U002"),但name相同(都叫"张三")。equals()竟然返回true!因为@Data生成的equals()默认只比较当前类字段,完全无视父类。
这不是bug,这是Lombok默认行为 ------callSuper = false。
二、事故全景:三个坑,三次翻车
坑1:缓存keyhash漂移,数据对不上
| 时间线 | 现象 |
|---|---|
| 10:00 | 订单创建,OrderDTO(id=1, time=10:00) 存入Redis |
| 10:05 | 订单状态变更,updateTime刷新 |
| 10:05 | 查询时new了新对象OrderDTO(id=1, time=10:05) |
| 10:05 | hashCode()不同 → Redis判断为新key → 查不到旧缓存 |
| 10:05 | 重复扣库存,数据炸了 |
本质 :@EqualsAndHashCode默认把所有非静态字段纳入计算,updateTime这种易变字段就是定时炸弹。
坑2:继承体系equals语义崩坏
java
`Child c1 = new Child(); c1.setCode("A"); c1.setName("X");
Child c2 = new Child(); c2.setCode("B"); c2.setName("X");
System.out.println(c1.equals(c2)); // true!代码不同的两个人,竟然"相等"
`
业务语义上,code是用户唯一标识,name只是昵称。但Lombok默认生成的equals()只看name,导致两个完全不同的用户被判定为同一个人------权限越权、数据串户,全来了。
坑3:JPA双向关联导致StackOverflow
java
`@Entity
@EqualsAndHashCode // 默认行为
public class User {
@OneToMany(mappedBy = "user")
private List<Order> orders; // 双向关联
}
@Entity
public class Order {
@ManyToOne
private User user;
}
`
调用user.equals(anotherUser) → 比较orders → orders里每个Order比较user → 又比较orders......无限递归,栈溢出。
生产环境看到这个异常时,我整个人都不好了。
三、根因解剖:Lombok到底做了什么
@EqualsAndHashCode默认行为等价于:
java
`// 默认 callSuper = false, 所有非静态非transient字段参与
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof User)) return false;
User other = (User) o;
// 只比较当前类声明的字段,父类字段?不存在的
return Objects.equals(name, other.name)
&& Objects.equals(age, other.age);
}
`
三个致命默认:
| 默认配置 | 后果 |
|---|---|
callSuper = false |
继承体系中父类字段被无视,equals语义错误 |
| 所有非静态字段参与 | 易变字段(时间戳、临时状态)导致hash漂移 |
| 不排除关联字段 | JPA双向关联导致无限递归/栈溢出 |
四、修复方案:四种姿势,对症下药
✅ 姿势1:只用主键比较(实体类首选)
java
`@Data
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class User {
@TableId
@EqualsAndHashCode.Include
private Long id; // 只有id参与比较
private String name; // 不参与
private String email; // 不参与
private LocalDateTime updateTime; // 不参与
}
`
为什么这样做? 数据库记录的唯一标识是主键。即使其他字段全变了,id相同就是同一条记录。这符合实体类的语义,也彻底杜绝了hash漂移。
✅ 姿势2:排除捣乱字段
java
`@Data
@EqualsAndHashCode(exclude = {"updateTime", "createTime", "orders"})
public class Order {
private Long id;
private String status;
private LocalDateTime updateTime; // 排除
@OneToMany(mappedBy = "order")
private List<OrderItem> orders; // 排除!避免栈溢出
}
`
✅ 姿势3:继承场景必须callSuper = true
java
`@Data
@EqualsAndHashCode
public class BaseEntity {
protected String code;
}
@Data
@EqualsAndHashCode(callSuper = true) // ← 关键!
public class User extends BaseEntity {
private String name;
}
`
生成的equals逻辑:
java
`public boolean equals(Object o) {
if (!super.equals(o)) return false; // 先比较父类code
User other = (User) o;
return Objects.equals(name, other.name); // 再比较子类name
}
`
注意 :父类也必须正确实现equals/hashCode,否则super.equals(o)退化成Object.equals()(比较引用),callSuper=true形同虚设。
✅ 姿势4:JPA实体用业务键,别用全字段
java
`@Entity
@Table(name = "t_user")
@Data
@EqualsAndHashCode(of = {"username"}) // 业务唯一键
public class User {
@Id
private Long id;
private String username; // 业务唯一标识
private String password;
private String nickname;
}
`
五、血泪总结:Lombok不是银弹,是裹着糖衣的手雷
| 场景 | 推荐配置 | 禁忌 |
|---|---|---|
| 数据库实体 | onlyExplicitlyIncluded = true + @Include主键 |
❌ 默认全字段比较 |
| 继承体系 | callSuper = true(父子都加) |
❌ 默认忽略父类 |
| JPA实体 | exclude关联字段 + of业务键 |
❌ 包含@OneToMany/@ManyToOne |
| 缓存key/Map键 | 只用不可变唯一标识 | ❌ 包含时间戳等易变字段 |
| DTO/VO | 按需of或exclude |
❌ 无脑@Data |
最后一句话送给所有人:
Lombok生成的代码,你不看字节码就永远不知道它干了什么。线上每一次
equals的"理所当然",都可能是下一次事故的导火索。用注解之前,先反编译看看它到底生成了什么。
这篇复盘,希望你永远用不上。但如果用上了------至少别在同一个坑里摔两次。