优惠券金额半夜自己变了
4 月 27 号凌晨一点,我被电话叫醒。客服说有用户投诉:领的 20 元券,下单时变成 5 元了。我爬起来查数据库,发现那批用户券的金额字段确实从 20 变成了 5,而且更新时间不是操作时间。
翻代码的时候找到了这段:
// 从配置里取出默认的券模板
CouponTemplate defaultTpl = couponConfig.getDefaultTemplate();
// 给每个用户生成一张券
for (Long userId : userIds) {
CouponTemplate tpl = defaultTpl;
tpl.setAmount(calcDiscount(userId)); // 按用户等级算不同金额
couponService.grant(userId, tpl);
}
看出问题了吗?循环里 CouponTemplate tpl = defaultTpl; 并没有创建新对象,它只是多了一个指向同一块内存的引用。所以 20 个用户循环下来,改的全是同一个对象,最后一次算出来的 5 元覆盖了前面所有人的值。
更糟糕的是 defaultTpl 本身来自一个全局配置缓存,所以这次操作还把配置缓存给污染了,后续所有新用户领到的券都成了 5 元。
Java 里没有"把对象传进去"这回事
这个问题我入职第一个月就被师傅提醒过,但真到自己写代码还是忘了。Java 的参数传递和赋值,对于对象类型传递的是引用的副本:
void change(User u) {
u.setName("张三"); // 生效,因为改的是引用指向的那个对象
u = new User("李四"); // 不生效,只是把这个局部引用指向了新对象
}
很多人把上面第二种情况说成"Java 对对象是引用传递",其实不准确。Java 只有值传递,只不过对象变量里存的那个"值"是一个内存地址。所以修改对象内部的字段会影响到调用方,重新赋值引用不会。
数组也一样,甚至更隐蔽:
int[] a = {1, 2, 3};
int[] b = a;
b[0] = 99;
System.out.println(a[0]); // 99
System.out.println(a == b); // true
clone() 的坑比想象中多
知道问题之后,第一反应是用 clone:
CouponTemplate tpl = (CouponTemplate) defaultTpl.clone();
写了之后编译直接报错:java.lang.CloneNotSupportedException。因为 Object.clone() 要求类实现 Cloneable 接口,否则抛异常。这是个设计得相当糟糕的接口——它里面一个方法都没有,纯粹是个"许可证"标记。
public class CouponTemplate implements Cloneable {
private BigDecimal amount;
private List<String> applicableShops;
@Override
protected CouponTemplate clone() throws CloneNotSupportedException {
return (CouponTemplate) super.clone();
}
}
好,现在不报错了。但我测了一下,发现浅拷贝:Object.clone() 只把字段值原样复制一遍。基本类型和 String(不可变)没问题,但 applicableShops 这个 List 复制的是引用,新旧对象的 List 还是同一个。
CouponTemplate t1 = defaultTpl.clone();
t1.getApplicableShops().add("shop-999");
System.out.println(defaultTpl.getApplicableShops()); // [shop-999] 被污染了!
要实现真正的深拷贝,得在 clone 方法里手动处理每个引用字段:
@Override
protected CouponTemplate clone() throws CloneNotSupportedException {
CouponTemplate copy = (CouponTemplate) super.clone();
if (this.applicableShops != null) {
copy.applicableShops = new ArrayList<>(this.applicableShops);
}
return copy;
}
而且如果 List 里的元素本身也是对象,还得继续拷下去,一层套一层。字段一多就漏,漏了就是线上事故。我大概能理解为啥《Effective Java》里说"clone 方法是 Java 的一个败笔,能不用就不用"。
序列化拷贝:通用但慢
后来我试了序列化方案,把对象写成字节流再读回来,天然就是深拷贝:
@SuppressWarnings("unchecked")
public static <T extends Serializable> T deepCopy(T obj) {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos)) {
oos.writeObject(obj);
oos.flush();
try (ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis)) {
return (T) ois.readObject();
}
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException("深拷贝失败", e);
}
}
好处是通用,不用给每个类写拷贝逻辑,嵌套多深都行。代价有三个:
- 慢。 我测了一下,拷贝一个 10 字段的对象,序列化方案耗时 186 微秒,手写 clone 只要 3 微秒,差了 60 倍。循环 1 万次就是 1.8 秒,不能接受。
- 所有涉及的类都必须实现 Serializable,包括嵌套的、泛型参数里的。漏一个就
java.io.NotSerializableException。我们项目里有个第三方 jar 的 DTO 没实现,直接卡死在这。 - transient 字段会丢失。 被 transient 修饰的字段不参与序列化,拷出来的对象里它是 null 或默认值。
我最终的选择
这个项目里我做了三层处理:
第一,能用不可变对象就不用拷贝。 那次事故的根子是 CouponTemplate 是可变的(有 setter)。我把它改成不可变——去掉所有 setter,字段 final,要改就 new 一个新的:
public class CouponTemplate {
private final BigDecimal amount;
private final List<String> applicableShops;
private CouponTemplate(BigDecimal amount, List<String> shops) {
this.amount = amount;
this.applicableShops = Collections.unmodifiableList(new ArrayList<>(shops));
}
public CouponTemplate withAmount(BigDecimal newAmount) {
return new CouponTemplate(newAmount, this.applicableShops);
}
}
对象不可变之后,随便传引用都不会出事,这个问题从根上消失了。
第二,集合拷贝用构造方法。 new ArrayList<>(source)、new HashMap<>(source) 就够用,注意这也是浅拷贝,元素还是共享的,但对我们够用了。
第三,真的需要深拷贝时用工具类。 我们项目里有 Apache Commons Lang 3.7,它的 SerializationUtils.clone() 就是封装好的序列化方案,省得自己写。另外 Jackson 也能做:
ObjectMapper mapper = new ObjectMapper();
CouponTemplate copy = mapper.readValue(
mapper.writeValueAsString(original), CouponTemplate.class);
这个不需要实现 Serializable,只要有无参构造和 getter/setter 就行,对第三方 DTO 特别友好。实测耗时 240 微秒,比 Java 原生序列化还慢一点,但这种场景本来也不在循环里。
留个问题
关于《深拷贝与浅拷贝:一次对象被意外修改引发的生产事故》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。