从零搭建一个低成本 Fabric Minecraft 私人服务器

前言

Screen Shot 2026 07 10 072730 985

本文主要记录一次基于 Debian 服务器搭建 Minecraft Fabric 服务器会遇到哪些错误,包括服务器环境配置、Fabric 模组兼容处理、客户端连接、性能优化、崩溃排查。

服务器目标并不是大型多人商业服,而是提供 3~5 人的小型生存服务器环境,同时兼顾:

  • Fabric 轻量化模组支持
  • 较低服务器成本
  • 稳定运行
  • 支持客户端优化模组
  • 尽量减少朋友安装配置成本

整个过程遇到了 Fabric Loader 版本冲突、Java 版本问题、客户端模组同步、区块加载速度、Watchdog 崩溃等问题。

一、服务器基础环境

1.1 硬件配置

最终服务器配置:

项目 配置
CPU 2 核
内存 8GB
系统 Debian 12 Bookworm
Java Temurin JDK 21
Minecraft版本 1.20.1
服务端 Fabric Server
网络 6M 带宽
玩家数量 3~5人

最初服务器为:

  • 2核4GB内存
  • Java 17
  • Fabric 1.20.1

后续升级到 8GB 内存主要考虑:

  • 更多模组空间
  • 减少区块生成时内存压力
  • 提高多人同时在线稳定性

二、系统环境配置

2.1 Debian 12 选择原因

服务器系统选择 Debian 12,而不是 Ubuntu。
主要考虑:

  • 系统资源占用更低
  • 后台服务更少
  • 稳定性较高
  • 更适合作为长期运行服务器

基础环境:

Debian 12  (追求稳定)
Java 21
screen
Fabric Server

Screen Shot 2026 07 10 072905 597

别忘了开放端口


三、Fabric Server 安装过程

3.1 初始 Fabric Loader 问题

最初启动服务器出现:

Incompatible mods found!

错误:

Fabric API 0.92.8+1.20.1 requires Fabric Loader >=0.16.10

ModernFix requires Fabric Loader >=0.16.10

原因:

服务器使用:

fabric-loader-0.15.11

但是安装的优化模组要求:

Fabric Loader >=0.16.10

解决:

重新下载 Fabric Server:

wget -O fabric-server.jar \
"https://meta.fabricmc.net/v2/versions/loader/1.20.1/0.16.10/1.0.1/server/jar"

升级 Fabric Loader 到:

0.16.10

四、Java版本兼容问题

4.1 Java 17导致启动失败

升级 Fabric 后出现:

UnsupportedClassVersionError

class file version 65.0

错误含义:

class version 65 = Java 21
class version 61 = Java 17

说明:

部分组件已经使用 Java 21 编译,但是服务器仍然使用 Java 17。


4.2 安装 Java 21

Debian 软件源没有:

apt install openjdk-21-jdk

因此采用 Temurin JDK:

安装目录:

/opt/java/jdk-21.0.11+10

配置:

update-alternatives \
--install /usr/bin/java java \
/opt/java/jdk-21.0.11+10/bin/java 2100

确认:

java -version

服务器最终运行环境:

Java 21

五、服务器启动参数优化

最终启动:

java \
-Xms2800M \
-Xmx2800M \
-XX:+UseZGC \
-XX:+ZGenerational \
-XX:+AlwaysPreTouch \
-XX:+DisableExplicitGC \
-XX:+PerfDisableSharedMem \
-jar fabric-server.jar nogui

参数说明:

ZGC

用于降低 GC 停顿:

-XX:+UseZGC

适合:

  • 多模组
  • 长时间运行
  • 大地图

固定内存

 
-Xms2800M
-Xmx2800M
 

避免运行过程中动态扩容造成卡顿。


六、当前服务器安装模组

服务端模组

当前主要:

模组 作用
Fabric API Fabric基础接口
Lithium 游戏逻辑优化
ModernFix 内存优化、启动优化
FerriteCore 降低内存占用
Krypton 网络优化
Right Click Harvest 右键收割
EasyAuth 登录认证

七、客户端模组

客户端额外安装:

性能优化

模组 作用
Sodium 大幅提升FPS
Iris 光影支持
Indium Sodium兼容
Entity Culling 减少不可见实体渲染
Dynamic Lights 动态光源

超视距

尝试:

Distant Horizons

用于:

  • LOD远景渲染
  • 查看超远距离地形

但是发现:

服务器没有预生成地图时:

  • 远处显示空白
  • 出现断崖
  • 小地图区域黑色

原因:

Distant Horizons只能渲染客户端已有数据。

它不能凭空生成服务器没有发送过的区块。


八、区块加载问题分析

8.1 会看到断崖

Minecraft区块加载流程:

玩家移动
 ↓
客户端请求区块
 ↓
服务器生成/读取区块
 ↓
发送给客户端
 ↓
客户端显示

因此:

没有探索过的区域:

服务器不存在区块数据。

Distant Horizons无法显示真实地形。


8.2 6M带宽是否够?

对于3~5人:
6M带宽基本够。
区块加载慢主要不是带宽问题,而是:

  • CPU生成区块
  • 磁盘读取
  • Minecraft单线程限制

九、服务器崩溃排查

9.1 Watchdog问题

之前服务器出现崩溃。

主要原因:

Minecraft Watchdog检测到:

主线程长时间无法响应

触发:

强制关闭服务器

常见原因:

  • 大量新区块生成
  • 玩家快速探索
  • 模组占用主线程
  • GC停顿

十、服务器性能测试

使用:

/spark tps

测试。

最终结果:

TPS:

20.0 20.0 20.0 20.0 20.0

说明:

服务器保持满TPS。

Tick:

5.6 / 6.8 / 10.7 / 20.7 ms

Minecraft标准:

50ms = 极限

当前最高:

20.7ms

属于正常范围。


CPU:

system 7%
process 6%

说明:

CPU压力很低。


十一、当前服务器状态总结

当前环境:

Minecraft 1.20.1

Fabric Loader 0.16.10

Java 21

2核8GB

Fabric优化模组

Sodium客户端优化

Iris光影

Distant Horizons远景

性能:

TPS: 20

MSPT:
<30ms

CPU:
低占用

内存:
充足

当前瓶颈:
不是服务器性能。
主要问题:

  1. 世界没有提前生成
  2. Distant Horizons没有足够LOD数据
  3. Minecraft默认区块生成机制限制

十二、后续优化方向

12.1 使用 Chunky 预生成地图

推荐:

Chunky

提前生成:

例如:

出生点半径 512区块

流程:

无人在线
↓
服务器生成地图
↓
保存region文件
↓
玩家进入直接读取

可以明显改善:

  • 探索卡顿
  • 地形断裂
  • DH远景显示

12.2 添加玩法模组

由于客户端已经分发,不建议大量增加必须客户端安装的模组。

优先选择:

服务端可运行:

  • 经济系统
  • 任务系统
  • 管理工具
  • 世界优化

减少朋友维护成本。


总结

这次 Fabric Minecraft 服务器搭建过程主要解决了几个核心问题:

  1. Fabric Loader 与模组版本匹配
  2. Java 17 与 Java 21兼容问题
  3. 客户端服务端模组同步
  4. Watchdog崩溃排查
  5. 区块加载与远景显示问题
  6. 性能测试与优化

最终服务器已经达到稳定运行状态。

对于3~5人的生存服务器,目前配置已经足够,下一步优化重点应该从“提升硬件”转向:

  • 地图预生成
  • 模组选择
  • 世界管理
  • 长时间稳定运行优化

这类小型 Fabric 服务器的主要限制通常不是内存,而是 Minecraft 主线程处理能力和世界生成压力。

相关文章

用 Astro 做工业官网

我用 Astro 做了 LIANXIN 的工业机械官网,里面有产品目录、工厂展示,也接了 Directus 新闻和 AI 产品顾问。做这种网站,很多选择都落在同一个问题上:哪些内容应该直接送到用户面前,哪些功能等用到的时候再启动。 机器图片、参数表、介绍文字适合提前生成;新闻要能随时更新;轮播、咨询和视频又各有自己的交互。把这些边界拆清楚,后面的实现会顺很多。 官网现用的工厂主题海报。来源:LIANXIN 官网。 页面先到,交互按需启动 Astro 比较合我胃口的一点,是它允许我从 HTML 开始组织页面。产品介绍和图片这些内容先渲染出来,需要状态和事件的地方再接 React。装了 React 集成,也不用把整页都做成客户端应用。 本站导航用 client:load,页面加载时就激活;产品轮播用 client:visible,进入视口再加载和水合。两种方式都可以先输出 HTML,区别在于何时让组件在浏览器里运行。具体触发时机可以看 Astro 的客户端指令文档。 FAQ 更进一步,用了项目自己注册的 client:interaction。鼠标移入、触摸按下或键盘聚焦时开始加载。这里有个小坑:用户可能在加载结束前就点了按钮,所以指令会拦住这次点击,等组件就绪再补发。普通链接照常跳转。省 JavaScript 可以,别把第一次点击也省了。 新闻更新走另一条路。列表和详情按请求读取 Directus,首页的最新新闻则交给 server:defer。首页先返回主体和占位内容,新闻区块随后单独请求 HTML。这样 CMS 响应慢一些,也不会拖住整个首页。这部分需要服务端,我用 Node adapter 的 standalone 模式运行站点。 Astro 官方原理图,说明静态页面与动态区块如何分别取得内容。来源:Astro 5.0,&copy; Astro;此图并非本站部署架构图。 产品页和 AI 共用一份资料 产品有不同系列,每个型号又有图片、参数和应用说明。如果这些东西散在页面里,改一项参数就得挨个找。我把产品整理成 TypeScript 数据,再让页面读取它。型号详情用同一份模板,通过 getStaticPaths() 生成路由。 这是详情页里的路径生成部分,省略了导入和页面模板。 export function getStaticPaths() { return products.map((product) => ({ params: { category: product.category, slug: product.slug, }, props: { product, category: getCategory(product.category), }, })); } 参数表读这份数据,页面里的 Product 结构化数据也读它。AI 产品知识同样从这里生成,避免官网改了规格,顾问还在背旧版本。数据模型里保留型号、分类、图片和规格这些明确字段,页面只负责把它们展示出来。 AI 后端是独立的 Cloudflare Worker。它根据问题里的型号、别名和当前页面检索资料,最多选六份交给 DeepSeek,再检查回答附带的来源 ID 是否属于这批资料。浏览器拿到的是回答和对应的站内链接,模型密钥留在 Worker。 这种检索方式规模小、规则直接,适合目前这批产品。它能约束资料来源,不能保证模型每句话都对,具体选型和报价仍要人工确认。另外,共用源文件不等于自动同步上线。产品资料改动后,主站和顾问 Worker 都需要重新构建部署,这个步骤不能漏。 图片讲构图,视频讲时机 工业设备本来就长,外面再套几层大边框,能看清的机器只会越来越小。产品图这里用 object-fit: contain,保留完整外形;标题、卖点和按钮放到旁边,让图片有足够的展示面积。轮播控制留在左右两侧,翻图时不用往下找。 IBM 80E 的展示图,机身轮廓完整保留。来源:LIANXIN 对应型号页。 首页海报则准备了横、竖两版,用 <picture> 按 max-aspect-ratio: 1/1 切换。选图依据是视口形状,竖屏平板也能得到竖版构图。首张海报优先加载,后面的图延迟加载。文案目前也在海报里,换来的排版控制更直接,代价是改文字和做多语言都要重新出图。 视频的处理更干脆。工厂介绍先展示封面,视频地址放进 data-src,点击播放时才赋给 src。访问者只是看看页面,就不用顺手下载一段几分钟的视频。 播放函数中的关键动作如下,省略了加载提示、超时和错误处理。 if (!video.hasAttribute("src")) { video.src = video.dataset.src!; } void video.play(); 这一块用原生 <video> 和一个自定义元素管理播放状态,就够用了。网络慢时显示加载提示,失败后给出重试按钮和原视频链接;离开组件时中止事件监听并暂停视频。用户至少能知道点下去之后发生了什么,也有办法继续操作。

2026/9/7·技术分享
用 Astro 做工业官网

用 Android 做疲劳检测

我做了一个 Android 疲劳提醒应用,用前置摄像头观察闭眼和张嘴,在手机上完成检测。模型接进来以后,真正需要想清楚的是后面的判断。眨一下眼就报警,提醒很快会变成噪声;摄像头看不清了还显示正常,又会让人误解系统的能力。 先把眼睛和嘴巴量出来 项目用的是 ML Kit Face Detection,模型随 APK 打包,安装后不用再等模型下载。它提供人脸轮廓、嘴部关键点和睁眼概率,疲劳规则由应用自己实现。这样检测和判定就能分开,调整持续时间不用动相机代码。 模型负责提取人脸信息,什么时候提醒,由后面的规则决定。插画来自 Google ML Kit 官方文档。 眼睛用 EAR,也就是眼睛的纵横比。从眼轮廓里选六个点,把两段上下眼睑距离相加,再除以两倍眼宽。计算式是 EAR =(上下一组眼睑距离 + 另一组眼睑距离)&divide;(2 &times; 眼宽)。闭眼时高度变小,EAR 通常也会下降。用比例可以减轻人脸远近带来的尺度变化,但侧脸、遮挡和关键点偏移仍然会影响结果。 Google ML Kit 的官方轮廓示例,点图可查看原图。眼睑和嘴唇的点要分清,本文的 MAR 量的是唇外沿。这是官方示例,不是本应用实测截图。图片来源。 嘴巴用 MAR。这个项目取上唇外上沿中心到下唇外下沿中心的距离,除以左右嘴角距离。公式看着简单,取点位置却不能含糊。量唇外沿和量嘴巴内部开口,算出来的数不会一样,网上找到一个阈值直接抄过来,很可能对不上。 眼部还保留了 ML Kit 的睁眼概率,与 EAR 做 OR 判断,任一路满足条件就进入对应计时。概率这一路要求左右眼的值都有效。这样能让两路信号各自参与,但也意味着单路误差就可能触发候选;它们来自同一个检测器,不能当成两套独立证据。嘴部单独判断,眼睛暂时不可用时,有效的 MAR 仍然可以参与检测。 一次越线,还不能算一个动作 单帧只知道这一刻眼睛有多窄、嘴张得有多大。要把眨眼、短暂张嘴和持续动作分开,还得加上时间。当前普通闭眼条件是 EAR &le; 0.22,或两眼概率都有效时任一眼低于 0.35,持续 800 ms 后进入 WARNING。更强的闭眼条件是 0 < EAR < 0.17,或两眼概率都低于 0.35,持续 400 ms 就进入 DANGER,且优先级更高。 哈欠候选用的是双阈值。MAR 超过 0.40 才开始计时,开始后只要不低于 0.35 就继续,累计达到 800 ms 才提醒。假设数值在 0.41、0.39、0.42 之间晃,单一阈值会让计时反复归零。嘴还张着,计时器已经替你闭上了。 代码里的关键分支只有几行。下面节选自判定逻辑,省略了无效值检查和后续计时。 val yawning = mar != null && if (highMarStartMs == null) { mar > config.marYawnEnterThreshold // 0.40 } else { mar >= config.marYawnExitThreshold // 0.35 } 时间取相机帧的时间戳,不取模型回调完成的时刻。否则推理这次慢一点、下次快一点,也会混进动作时长。相邻有效观测间隔超过 1.5 秒时,未完成的计时清零,从当前帧重新开始。中间没有画面,系统就没有依据把两次张嘴拼成一次连续动作。这个间隔也是工程取舍,不能证明采样之间发生了什么。 看不清,就别继续报正常 判定器除了 NORMAL、WARNING 和 DANGER,还留了 DEGRADED。没有人脸、眼嘴信号全部无效,或检测到黑暗画面、相机异常时,就清除未完成计时,显示当前无法可靠检测的原因。NORMAL 也只表示当前有效信号没有达到提醒条件,不能解释成驾驶员一定清醒。 相机侧通过 acquireLatestImage() 取最新分析帧,当前会话已有推理任务时,新帧直接释放,避免越算越落后。停止检测或切到后台时,会话编号递增;旧任务即使晚点返回,也不能覆盖新的状态。重新开始检测后,同样要从新画面重新计时。 声音和振动只由 DANGER 触发,WARNING 只更新界面。危险告警做了约两秒冷却,并用不重放历史事件的 SharedFlow 发送;界面只在前台恢复状态下接收,暂停时停止提示。否则回到应用突然补响一声,提醒的就成了过去的自己。 目前这套实现仍是固定阈值的研究原型,没有个人校准。持续张嘴也可能来自说话,睁眼概率还有正脸角度限制。当前手机端只在模拟器上验证了安装、相机链路和前后台切换,真人识别、覆盖层精度与声音振动还需要目标手机实测。接下来更值得做的是用正常说话、短张嘴、自然哈欠和闭眼录像,在静止环境里检验误报与漏报;这套原型还不能当作经过道路验证的驾驶安全系统。

2026/9/7·技术分享
用 Android 做疲劳检测

MediaPipe 手势识别的几个坑

这次用鲁班猫 3 做了个离线手势识别,系统是 Android 14。摄像头看到手就画出骨架,比个 OK,自动保存一张带骨架的截图。 模型直接用 Google 的 MediaPipe。它已经能找出手部的 21 个关键点,还带七种静态手势分类。自己要处理的是相机、画面坐标,以及识别结果怎么变成实际操作。 模型用现成的 Android 用 Kotlin 写,相机接 CameraX,识别接 MediaPipe Tasks。依赖用这几项。 def cameraxVersion = '1.4.2' implementation "androidx.camera:camera-core:$cameraxVersion" implementation "androidx.camera:camera-camera2:$cameraxVersion" implementation "androidx.camera:camera-lifecycle:$cameraxVersion" implementation 'com.google.mediapipe:tasks-vision:1.0.0' 把官方模型放进 app/src/main/assets,创建识别器时指定这个文件,运行模式设为 LIVE_STREAM,通过回调拿结果。官方 Android 示例已经有完整接法,照着看比从空白工程硬猜快。 模型跟着 APK 一起打包,推理在板子上完成,运行时只需要相机权限。 底图来自 MediaPipe 官方测试图片,这是本项目的板端测试输出。红点是关节点,蓝线标出拇指,其余连线用绿色。 相机别开两路 CameraX 常见的接法是 Preview 显示画面,ImageAnalysis 给模型送帧。但在我这块 RK3576 板子的固件上,两路一起开会卡住。 最后只保留一条 ImageAnalysis,预览和识别共用它。拿到 1920&times;1080 的图像后,处理行步幅,把最长边缩到 640 像素,再统一旋转和镜像,送进模型。 帧策略用 KEEP_ONLY_LATEST。处理不过来就丢旧帧,别让识别排队。当前代码还限制了至少 100 ms 才提交一帧,所以提交速率最高约每秒 10 帧,实际显示速度还得看推理回调。 ImageProxy 用完一定要 close,跳过帧和异常分支也一样。 缓冲区占着不放,相机后面就可能没帧了。 另外,板子上的 CAM0、Android 的 Camera ID 0 和 /dev/video0 要分别核对。这个项目同时检查物理 m00_ 模块、逻辑 ID 0 和当前固件的 FRONT 属性,条件不满足就停。这套映射只针对已经验证过的板子和固件。 骨架为什么会飘 这个问题得从坐标和时间两头查。 坐标上,模型给的是归一化关键点,预览用的却是 centerCrop。图片会先放大,再裁掉超出显示区域的部分。直接拿关键点乘屏幕宽高,当然对不上。 把关键点投到屏幕上,要和预览用同一个缩放比例和裁切偏移。下面的 x、y 是模型返回的归一化坐标,srcW、srcH 是模型输入图像的宽高。 scale = max(viewW / srcW, viewH / srcH) offsetX = (viewW - srcW * scale) / 2 offsetY = (viewH - srcH * scale) / 2 screenX = x * srcW * scale + offsetX screenY = y * srcH * scale + offsetY 这些除法要用浮点数。旋转和镜像也统一在推理前处理,别让预览和模型各算各的方向。 时间上,手已经移走了,上一帧的结果才回来。把旧骨架叠到新画面,多少有点灵魂出窍。 我的处理是从结果回调里取出对应的输入图片,和这次关键点一起更新到界面。画面和骨架必须属于同一帧。 代价是显示会等识别结果,预览流畅度也跟着受限。 还有一个容易漏的判断。分类结果是 None,只代表没认出支持的手势。只要关键点还在,骨架就继续画;没检测到手、结果过期或者切到后台时,再清掉。 OK 截图要防连拍 官方这七种分类没有 OK,我用现有关键点补了一层几何判断。拇指和食指指尖靠近,食指弯曲,另外三根手指伸直,才算匹配。 距离按掌部尺寸归一化,再结合关节角度判断。用固定像素距离的话,手往前伸一点,阈值就不合适了。这里用的是按输入宽高还原后的 2D 坐标,和画面里的骨架保持一致。 然后才是触发控制。视频每帧都可能认出 OK,你一直举着手,就一直存图,相册受不了这个。 判断 当前条件 确认手势 最近最多 5 帧中至少 4 帧强匹配,当前帧也满足,窗口持续至少 300 ms 触发后锁定 保持 OK 只拍一张 允许再拍 离开 OK 至少 600 ms,两次触发至少间隔 1.5 秒 触发条件严一点,保持条件宽一点,减少边界抖动。采样断开超过 400 ms,就清掉旧的观察窗口。这些是当前配置,实际触发时间还受帧率影响。 截图直接合成当前画面和同帧骨架,再用 MediaStore 存成 PNG。只画这两层,状态栏和提示框就不会混进去。编码和写盘放到独立线程,别堵着相机识别。 保存的是当前显示画面的合成图,输入已经缩小过,不能当成传感器原图。 这版配置的还是 Delegate.CPU。RK3576 有 NPU,但应用得接入 RKNN 才用得上。RKNN 的官方流程包括模型转换和运行时接入,前后处理也要重新对齐,光改一个配置没用。

2026/9/7·技术分享
MediaPipe 手势识别的几个坑
听歌