背景
一套 Minecraft 1.19.4 Forge 服务端运行在 Linux 主机上,并通过 systemd 管理。服务端使用多模组环境,世界存档体量约数 GB,包含主世界、下界、末地以及额外维度。此前服务端在运行过程中出现异常,停止服务时 systemd 报告 timeout,并最终对 Java 进程发送强制终止信号。
最初看起来像是服务器性能不足、保存世界过慢或 systemd 停止超时太短。但经过只读检查后,真正的主线问题被定位为:服务端实际 JVM heap 只有 1GB,Forge 多模组环境运行一段时间后发生 Java heap OOM,随后在保存世界时又撞上了 systemd 默认 90 秒停止超时。
初始现象
服务端停止时出现过如下现象:
Stopping minecraftd.service...
State 'final-sigterm' timed out. Killing.
Killing process java with signal SIGKILL.
Failed with result 'timeout'.
Unit process java remains running after unit stopped.
这类日志容易让人第一时间怀疑是 systemd 停止流程本身有问题,或者服务器保存世界太慢。但如果只盯着停止阶段,会忽略更早发生的根因。
进一步检查崩溃报告和 latest.log 后,可以看到关键异常是:
java.lang.OutOfMemoryError: Java heap space
同时崩溃报告中的 JVM Flags 显示:
-Xms256M -Xmx1024M
这说明服务端最大堆内存只有 1GB。对于 Minecraft 1.19.4 Forge、多模组、多个维度和数 GB 世界存档来说,1GB heap 明显偏小。服务端先因为堆内存不足进入异常状态,然后停服保存世界时又被 systemd 默认 90 秒限制打断,最终形成 timeout 和 SIGKILL。
只读检查结论
服务端目录中存在 run.sh、user_jvm_args.txt、server.properties、config、mods、世界目录、日志和崩溃报告。检查后发现:
run.sh 实际写死了 -Xms256M -Xmx1024M
user_jvm_args.txt 中虽然有关于 4GB 的注释,但没有实际生效参数
崩溃报告明确显示 Java heap space OOM
旧崩溃报告中也出现过类似 1GB heap OOM
systemd service 没有显式 TimeoutStopSec,实际使用默认 90 秒
这说明问题不是某一次偶发,而是服务端长期以不合理的 JVM heap 运行。
与此同时,其他配置并没有发现必须立刻修改的严重问题。例如:
view-distance=10
simulation-distance=10
sync-chunk-writes=true
entity-broadcast-range-percentage=100
spawn-protection=0
RCON 关闭
这些配置中确实有可以后续优化的项目,但都不是这次崩溃的第一根因。服务端当前最需要修复的是 JVM heap 和 systemd 停止超时。
第一轮修复目标
第一轮修复遵循最小变更原则,只解决明确问题,不同时改动多个变量。
修复目标包括:
1. 让服务端实际使用 -Xms2G -Xmx4G
2. 让 run.sh 读取 user_jvm_args.txt
3. 使用 exec java 让 Java 成为 systemd 跟踪的主进程
4. 将 systemd 停止超时从默认 90 秒提高到 300 秒
5. 不修改 server.properties、mods、world、日志和模组配置
没有在第一轮修改视距、模拟距离、sync-chunk-writes 或世界生成类模组配置。原因是当前已经有明确的 OOM 根因,必须先修复内存问题,再通过实际运行观察是否还存在 tick 压力或保存压力。
JVM 参数修复
原始启动脚本中,Java 启动行类似:
java -Xms256M -Xmx1024M @libraries/.../unix_args.txt --nogui
这会使服务端最大堆固定在 1GB。修复后改为:
exec java @user_jvm_args.txt @libraries/.../unix_args.txt --nogui
同时在 user_jvm_args.txt 中放入实际生效参数:
-Xms2G
-Xmx4G
这样做有两个好处。
第一,JVM 内存参数从脚本硬编码中移出,后续维护更加清晰。修改内存不需要再改启动脚本,只需要调整 user_jvm_args.txt。
第二,exec java 可以让 Java 进程替代 shell 进程,成为 systemd 直接跟踪的主进程。这样 systemd 在启动、停止和状态检查时看到的就是实际 Minecraft Java 进程,而不是外层脚本。
修复后,进程状态显示:
Main PID: java
CGroup: java @user_jvm_args.txt @libraries/.../unix_args.txt --nogui
这说明 systemd 已经正确跟踪 Java 主进程。
systemd 停止超时修复
原本 systemd service 没有显式设置 TimeoutStopSec,因此使用默认 90 秒。对于 Forge 模组服和数 GB 世界来说,停服时保存所有维度和区块可能需要更长时间。尤其在服务端刚经历 OOM 或负载异常后,保存过程更可能变慢。
因此在 service 的 [Service] 段加入:
TimeoutStopSec=300s
这并不是性能优化,而是停服安全性优化。它的作用是给 Minecraft 更多时间完成保存世界、卸载维度和退出流程,避免 systemd 过早发送 SIGKILL。
修改后通过 systemd 验证,停止超时已经变为:
TimeoutStopUSec=5min
启动验证
修复后手动启动服务,服务成功进入运行状态:
Active: active (running)
Main PID: java
Memory: 约 3.5G
Done (13.147s)
启动日志显示服务端正常完成启动,没有再次出现:
OutOfMemoryError
Java heap space
Crash
Can't keep up
server overloaded
进程参数确认已经不再使用旧的 1GB 配置:
-Xms2G
-Xmx4G
旧的:
-Xms256M
-Xmx1024M
已经不再残留。
服务端启动后内存使用达到约 3.5GB,这恰好说明原来的 1GB heap 明显不足。Forge 多模组环境在启动和加载世界后使用数 GB 内存是正常现象,只要没有继续逼近上限并触发 OOM,就不应立刻把它视为异常。
关于 datafixerupper / serialization 堆栈
首次启动过程中曾在 journalctl 中看到 datafixerupper / Mojang serialization 相关堆栈,且服务长时间没有继续输出。由于当时只看到堆栈中后段,无法直接判断具体根因。随后服务被强制重启。
重启后服务很快完成启动,并且没有再次出现该堆栈。结合后续日志看,这更像是旧启动过程中的一次性解析、数据修复或兼容路径问题,而不是持续性故障。由于没有复现,也没有阻止服务最终正常启动,因此暂时不作为主线问题处理。
这种情况下不应立即修改存档、模组或数据包。更稳妥的处理方式是观察后续启动是否复现。如果再次出现,再回溯完整堆栈第一行,定位具体 mod、registry、datapack、dimension、biome 或 world data。
停服验证与 143 退出码
服务正常启动后,执行 systemd 停止命令。停服日志中出现:
All chunks are saved
All dimensions are saved
Stopped minecraftd.service
这说明 Minecraft 已经完成各维度区块保存,停服流程本身是正常的。
不过 systemd 最初将 Java 进程返回的 143 判定为失败:
Main process exited, code=exited, status=143
Failed with result 'exit-code'
143 的含义是:
143 = 128 + 15
15 = SIGTERM
也就是说,systemd 停止服务时向 Java 发送 SIGTERM,Java 收到信号后完成保存并退出,最终返回 143。这个场景下 143 并不代表崩溃,而是正常停服流程中的信号退出码。
为了让 systemd 正确识别这种退出为正常状态,在 service 的 [Service] 段增加:
SuccessExitStatus=143
同时保留原有的:
Restart=always
TimeoutStopSec=300s
ExecStart=...
WorkingDirectory=...
User=...
执行 systemctl daemon-reload 后,systemd 已经识别:
SuccessExitStatus=143
Result=success
这样以后通过 systemctl stop 正常停服,即使 Java 返回 143,systemd 也不会误判为 failed。
为什么没有继续优化 view-distance 和 simulation-distance
服务端 view-distance=10 和 simulation-distance=10 对多模组服务端确实会带来负载。但本次没有立即修改它们,原因是优化应避免同时改变过多变量。
当前已知根因是 JVM heap 过小。修复后服务端已经能够正常启动、正常加载世界、正常停止保存。如果此时继续下调视距和模拟距离,就很难判断稳定性改善到底来自内存修复,还是来自区块压力降低。
更合理的后续策略是:
如果 4GB heap 后稳定运行,则保持 view-distance=10 和 simulation-distance=10
如果后续出现持续 Can't keep up 或区块加载压力,再考虑降到 8 / 5 或 8 / 6
如果保存世界仍然过慢,再评估 sync-chunk-writes,但不优先修改
当前最终状态
本次服务端修复完成后,关键状态如下:
run.sh 不再硬编码 1GB heap
user_jvm_args.txt 实际生效为 -Xms2G -Xmx4G
Java 成为 systemd 直接跟踪的 MainPID
TimeoutStopSec=300s
SuccessExitStatus=143
服务可以正常启动并出现 Done
停服可以完成 All chunks are saved / All dimensions are saved
没有新的 OOM、Crash、Can't keep up
这说明服务端第一轮优化已经闭环。
后续观察重点
后续不应继续盲目修改配置,而应实际运行观察:
是否再次出现 OutOfMemoryError / Java heap space
是否出现持续 Can't keep up / server overloaded
保存世界是否仍然明显缓慢
停服是否保持 inactive / success,而不是 failed
内存是否长期稳定在 4GB 堆范围内
是否有新的 crash-report
如果后续服务端长时间运行稳定,就没有必要继续降低配置。如果仍有 tick 压力,再按优先级考虑服务端第二轮优化,例如降低 view-distance 和 simulation-distance。
总结
这次服务端问题表面上是 systemd 停止超时,实际根因是 Forge 多模组服务端长期以 1GB heap 运行,最终触发 Java heap OOM。OOM 之后保存世界变慢,又被 systemd 默认 90 秒停止超时放大成 SIGKILL 风险。
正确的修复顺序不是先盲目删模组、降视距或改世界文件,而是先修复明确的 JVM heap 配置,再给 systemd 足够的停服等待时间,最后处理 Java 143 正常停服返回码的识别问题。
最终结果表明,最小变更策略是有效的:服务端恢复正常启动和停服,核心配置更加清晰,后续排查也更容易。