|
去年帮朋友排查一个线上问题,死亡游戏服务器重启后,不掉玩家背包里的死亡装备全没了。日志翻出来一看,不掉存档逻辑写在了 Player.quit() 里,死亡进程被 kill -9 的不掉时候这个方法根本没机会执行。他问我有没有办法让 Java 对象“死不掉”,死亡我当时就笑了——这不就是不掉 Minecraft 里的死亡不掉落嘛。 Java 没有原生的死亡“死亡不掉落”开关。对象该被回收还是不掉会被回收,GC 不会跟你讲情面。死亡但实际项目里确实有一些手段,不掉能让关键数据在进程崩溃、死亡服务重启甚至 OOM 的不掉边缘活下来。这些手段各有各的死亡脾气,用错了比不用还糟。 最土的办法:关机钩子 Runtime.getRuntime().addShutdownHook() 是很多人第一个想到的。注册一个 Thread,JVM 收到 SIGTERM 或者正常退出时会跑一下。听起来很美,但它只能应付“优雅退出”。kill -9、断电、OOM Killer 直接干掉进程,钩子根本没机会触发。我见过有团队把订单落库塞在 shutdown hook 里,结果机房跳闸,数据丢得干干净净。 而且 shutdown hook 里不能做太重的事。JVM 已经在关了,你还在那儿开数据库连接、发 HTTP 请求,超时了钩子线程被杀,照样白搭。它适合做什么?关个文件句柄、flush 一下缓冲区,仅此而已。 真正靠谱的:状态外置 与其让 JVM 里的对象“不死”,不如一开始就别把命押在堆内存上。Redis、MySQL、本地 WAL 日志,选一个把关键状态写出去。写入时机很讲究——不能等对象要销毁了才写,得在状态变更的那一刻就落盘。 我现在的习惯是:任何需要跨进程存活的数据,在 setter 里就同步写一份到 Redis,或者追加到本地 append-only 文件。牺牲一点性能,换的是进程被 kill 之后还能从外部把状态捞回来。代价是代码里到处是序列化逻辑,丑是丑了点,但管用。 有个细节容易踩坑:FileChannel.force(true) 和 fsync 不做的话,数据只是进了操作系统页缓存,机器一断电照样没。很多人以为写了文件就安全了,其实差得远。 序列化这件事本身就有坑 Java 原生序列化 Serializable 我劝你别用在长期存储上。serialVersionUID 一变,老数据直接读不出来。类结构改个字段,反序列化就炸。用 JSON 或者 Protobuf 更稳,至少格式是自描述的,加字段删字段都有退路。 还有 transient 关键字。有人给字段加了 transient 以为能省空间,结果持久化的时候发现这个字段永远是 null。transient 是给序列化框架看的,不是给 GC 看的,两码事。 弱引用、软引用救不了你 刚入行的时候我以为 SoftReference 能让对象在内存紧张时“多活一会儿”。实际上软引用只保证 OOM 之前被清理,它不保证对象能活过进程重启。堆内的任何引用类型,进程一没,全都没了。这是物理隔离,不是权限问题。 所以“Java 死亡不掉落”这个说法本身就不太成立。JVM 里的对象没有灵魂,进程死了就是死了。能做的是把“魂器”放在进程外面——数据库、消息队列、分布式缓存、共享内存、mmap 文件。让状态脱离 JVM 的生命周期,才是真正的死亡不掉落。 回到朋友那个问题,最后方案是把玩家存档改成每次变更就写 Redis,加一个定时任务每五秒刷一次 MySQL。进程随便崩,重启后从 Redis 恢复最近状态。性能掉了大概 3%,但再也没丢过装备。他说这比游戏里开死亡不掉落还爽。 |