滥用Lombok的@EqualsAndHashCode导致线上事故复盘

3 阅读4分钟

滥用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:05hashCode()不同 → 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) → 比较ordersorders里每个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按需ofexclude❌ 无脑@Data

最后一句话送给所有人

Lombok生成的代码,你不看字节码就永远不知道它干了什么。线上每一次equals的"理所当然",都可能是下一次事故的导火索。用注解之前,先反编译看看它到底生成了什么。

这篇复盘,希望你永远用不上。但如果用上了——至少别在同一个坑里摔两次。