# 幻嵐 Minecraft 伺服器架構，10 個 Paper 分流怎麼共用 Redis 與 MySQL

> 幻嵐是 Java 版 Minecraft 伺服器，一台 Velocity 後面掛 10 個 Paper 分流，分流之間不直接連線，共用狀態全部經過 Redis 和 MySQL。這篇寫每一層是什麼、怎麼接起來。

- 網址：https://www.weibuilds.com/blog/huanlan-minecraft-architecture/
- 作者：weiwei
- 日期：2026-10-27
- 主題：Minecraft 伺服器
- 標籤：velocity、paper、redis、mysql、架構

幻嵐是 Java 版 Minecraft 伺服器，一台 Velocity 代理後面掛 10 個 Paper 分流，分流之間不直接連線，共用狀態全部經過一套 Redis 和一台 MySQL。

這篇寫每一層是什麼、怎麼接起來。數字都是 2026 年 10 月的狀態。

## 架構全貌

<figure>
<svg class="diagram" viewBox="0 0 760 520" role="img" aria-label="兩台主機。玩家經上游轉發連到代理主機上的 Velocity，Velocity 用 modern forwarding 把玩家送進 MC 主機上的 10 個 Paper 分流。代理主機同時跑 Redis 和 MySQL，分流用 Jedis 與 JDBC 連過去。" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <marker id="arch-ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="var(--ink-2)"/></marker>
  </defs>
  <g font-size="13" font-family="inherit" fill="var(--ink)">
    <rect x="40" y="16" width="220" height="40" rx="20" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="150" y="41" text-anchor="middle">玩家（Java 版正版）</text>
    <line x1="150" y1="56" x2="150" y2="84" stroke="var(--ink-2)" stroke-width="1.6" marker-end="url(#arch-ar)"/>
    <rect x="40" y="86" width="220" height="48" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="150" y="106" text-anchor="middle">SRV → 上游轉發</text>
    <text x="150" y="124" text-anchor="middle" font-size="12" fill="var(--ink-2)">送 PROXY protocol 標頭</text>
    <line x1="150" y1="134" x2="150" y2="190" stroke="var(--ink-2)" stroke-width="1.6" marker-end="url(#arch-ar)"/>
    <rect x="10" y="160" width="740" height="130" rx="10" fill="none" stroke="var(--ink-2)" stroke-dasharray="5 4"/>
    <text x="24" y="180" font-size="12" fill="var(--ink-2)">代理主機</text>
    <rect x="40" y="192" width="220" height="80" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="150" y="220" text-anchor="middle" font-weight="600">Velocity</text>
    <text x="150" y="242" text-anchor="middle" font-size="12" fill="var(--ink-2)">驗正版、轉發玩家</text>
    <text x="150" y="260" text-anchor="middle" font-size="12" fill="var(--ink-2)">TAB、轉服外掛</text>
    <rect x="290" y="192" width="200" height="80" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="390" y="220" text-anchor="middle" font-weight="600">Redis</text>
    <text x="390" y="242" text-anchor="middle" font-size="12" fill="var(--ink-2)">快取、轉服 key</text>
    <text x="390" y="260" text-anchor="middle" font-size="12" fill="var(--ink-2)">pub/sub、佇列</text>
    <rect x="520" y="192" width="210" height="80" rx="6" fill="var(--paper-2)" stroke="var(--rule)"/>
    <text x="625" y="220" text-anchor="middle" font-weight="600">MySQL</text>
    <text x="625" y="242" text-anchor="middle" font-size="12" fill="var(--ink-2)">一個外掛</text>
    <text x="625" y="260" text-anchor="middle" font-size="12" fill="var(--ink-2)">一個資料庫</text>
    <line x1="150" y1="272" x2="150" y2="368" stroke="var(--ink-2)" stroke-width="1.6" marker-end="url(#arch-ar)"/>
    <text x="160" y="318" font-size="12" fill="var(--ink-2)">modern forwarding</text>
    <g stroke="var(--ink-2)" stroke-width="1.4" stroke-dasharray="4 3">
      <line x1="390" y1="370" x2="390" y2="274"/>
      <line x1="625" y1="370" x2="625" y2="274"/>
    </g>
    <text x="507" y="318" text-anchor="middle" font-size="11" fill="var(--ink-2)">Jedis／JDBC</text>
    <rect x="10" y="340" width="740" height="166" rx="10" fill="none" stroke="var(--ink-2)" stroke-dasharray="5 4"/>
    <text x="24" y="360" font-size="12" fill="var(--ink-2)">MC 主機</text>
    <text x="736" y="360" text-anchor="end" font-size="12" fill="var(--ink-2)">i9-13900K・96 GB・不對外開 port</text>
    <g font-size="13" fill="var(--paper)" text-anchor="middle">
      <rect x="30" y="372" width="132" height="36" rx="6" fill="var(--topic-minecraft)"/><text x="96" y="395">lobby_login</text>
      <rect x="172" y="372" width="132" height="36" rx="6" fill="var(--topic-minecraft)"/><text x="238" y="395">lobby</text>
      <rect x="314" y="372" width="132" height="36" rx="6" fill="var(--topic-minecraft)"/><text x="380" y="395">redstone</text>
      <rect x="456" y="372" width="132" height="36" rx="6" fill="var(--topic-minecraft)"/><text x="522" y="395">ads_01</text>
      <rect x="598" y="372" width="132" height="36" rx="6" fill="var(--topic-minecraft)"/><text x="664" y="395">ads_02</text>
      <rect x="30" y="418" width="132" height="36" rx="6" fill="var(--topic-minecraft)"/><text x="96" y="441">ads_03</text>
      <rect x="172" y="418" width="132" height="36" rx="6" fill="var(--topic-minecraft)"/><text x="238" y="441">stst01</text>
      <rect x="314" y="418" width="132" height="36" rx="6" fill="var(--topic-minecraft)"/><text x="380" y="441">stst02</text>
      <rect x="456" y="418" width="132" height="36" rx="6" fill="var(--topic-minecraft)"/><text x="522" y="441">stst03</text>
      <rect x="598" y="418" width="132" height="36" rx="6" fill="var(--topic-minecraft)"/><text x="664" y="441">rpg</text>
    </g>
    <text x="380" y="486" text-anchor="middle" font-size="12" fill="var(--ink-2)">一個分流一個 JVM、一個 tmux，10 台合計約 88 GB</text>
  </g>
</svg>
<figcaption>MC 主機不開任何對外 port，玩家只能從 Velocity 進來。</figcaption>
</figure>

兩台實體機都在內網。代理主機跑 Velocity、Redis、MySQL。MC 主機只跑 10 個 Paper，不做 port forwarding。

## 入口：玩家怎麼連進來

玩家連的是網域，DNS 用 SRV 記錄指到上游的轉發服務，再由它連到 Velocity。轉發服務在連線開頭送 PROXY protocol 標頭，帶玩家真正的 IP，所以 Velocity 開了 `haproxy-protocol`。

Velocity 開 `online-mode` 驗正版。分流全部 `online-mode=false`，改用 modern forwarding：代理把玩家資料連同 HMAC 簽章送給分流，分流用共享密鑰驗證，驗不過就踢。就算有人在內網直連分流的 port 也進不去。

RCON 全關。對分流下指令用 `tmux send-keys` 送進主控台，用 `tmux capture-pane` 讀回來，走既有的 SSH。

Tab 列表、頁首頁尾、側邊記分板都畫在代理上的 TAB，分流只裝 TAB-Bridge 回報變數，玩家換分流時名單不用重畫。

## 10 個分流怎麼切

| 分流        | 角色                                              |
| ----------- | ------------------------------------------------- |
| lobby_login | 登入服。確認大廳活著、發材質包                    |
| lobby       | 主大廳，也是主分流                                |
| ads_01～03  | 生存                                              |
| stst01～03  | 副本，MythicMobs 設定最重，一台七百到近千個設定檔 |
| redstone    | 紅石，沒裝 MythicMobs                             |
| rpg         | 另一個 RPG 世界                                   |

切分的原則是把會互相拖累的負載拆開。副本分流的負擔是 MythicMobs 的怪物和技能，生存分流是玩家蓋的農場和漏斗，紅石分流整天在跑機關。放在同一個 JVM，一座農場就會讓打副本的人一起卡。

## 行程模型

開機分三層：

1. systemd 的 `minecraft-start.service`（`Type=oneshot`）跑一支啟動腳本。
2. 啟動腳本讀分流清單，每個分流開一個同名 tmux session 跑 `start.sh`。
3. `start.sh` 是 `while true` 迴圈，java 結束 5 秒後再開一次，所以主控台打 `stop` 就等於重啟。

不讓 systemd 直接管 java，是因為要能隨時 attach 進主控台。代價是 10 個 java 都在同一個 service 的 cgroup 裡，這個 service 被重啟，10 個分流會一起重開，自動更新的 needrestart 要把它排除。

每日重啟由 UltimateAutoRestart 觸發，錯開排程：大廳和紅石 02:00，生存三台從 04:00 起每 15 分鐘一台，副本三台從 05:00 起每 15 分鐘一台，rpg 05:45，登入服不重啟。一台開機要 200 到 245 秒，9 台同時開會互搶到每台 375 到 454 秒，所以一定錯開。

## 主機資源

MC 主機是 i9-13900K（32 執行緒）、96 GB 記憶體、NVMe。晚上尖峰時，10 個 JVM 合計約 88 GB RSS，平均一台約 8.8 GB，可用記憶體只剩幾 GB，CPU 還有約 80% 閒置。

瓶頸在記憶體。要多開一台分流，得先關掉或合併一台。

## 資料層：MySQL 和 Redis 各放什麼

MySQL 和 Redis 都在代理主機上，各一台。

**MySQL** 是持久資料。慣例是一個外掛開一個資料庫，現在有二十幾個。要搬走或拆掉一個外掛，只動它自己的庫。

**Redis** 放分流之間要共用、要快的東西：

| 用途     | 內容                                                                   |
| -------- | ---------------------------------------------------------------------- |
| 餘額快取 | 14 種貨幣，加扣點先寫 Redis 標成 dirty，主分流每 60 秒批次寫回 MySQL   |
| 換分流   | `hu_redirect:<UUID>` → 目標分流，10 秒過期                             |
| 在線名單 | 玩家名 → UUID 的 Hash，加每人目前在哪台，代理每分鐘整批刷新            |
| 跨服訊息 | pub/sub 頻道：跨服 TPA、全服事件廣播                                   |
| 佇列     | 要送到 Discord 的通知，由主分流消費                                    |
| 玩家狀態 | 材質包接受與否（7 天過期），各分流照這個決定送圖示版還是純文字版的訊息 |

餘額的加扣點用 `WATCH`／`MULTI` 做樂觀鎖。這裡一定要原子，兩個分流同時對同一人加點，「讀出來、算完、整筆寫回」會互相蓋掉。

## 分流之間怎麼溝通

分流之間不直接連線。跨服的事寫進 Redis 或 MySQL，由需要的那一方去拿。現在並存三種做法：

| 做法                          | 用在哪                 | 特性                                                       |
| ----------------------------- | ---------------------- | ---------------------------------------------------------- |
| 寫一個有 TTL 的 key，對方輪詢 | 換分流                 | 對方晚一點讀也拿得到，沒人處理的 key 自己過期              |
| Redis pub/sub                 | 跨服 TPA、全服事件廣播 | 即時，但即發即忘，訂閱者重啟期間的訊息就沒了               |
| 資料庫版本號，各分流輪詢      | 副本模板更新           | 版本號是持久的，分流重啟後自己追上；寫資料和加版本號同交易 |

換分流的流程：分流寫 `hu_redirect:<UUID>`，代理上的轉服外掛每秒把所有在線玩家的 key 用一次 `MGET` 讀回來，有值就送過去，再整批刪掉。延遲最多一秒。

跨服 TPA 兩種都用到：請求和同意走 pub/sub，每台都收得到，目標玩家在哪台就由那台處理；同意後寫一個 30 秒的 key 記傳送目的地，再走上面的換分流；玩家進目標分流時讀這個 key，傳送到對方身邊。

副本模板改完要讓 10 台都重讀。這裡刻意不用 pub/sub：寫模板時在同一個 MySQL 交易裡把版本號加一，各分流每 5 秒讀一次版本號，變了就重讀。

## 主分流

有些事全服只能做一次：全服事件的結算、餘額寫回資料庫、送 Discord 通知、每週輪替。這些都只在大廳跑。每個外掛啟動時比對設定檔裡的 `MainServer` 和自己的 server-name，決定要不要啟動這些排程。

其他分流判定事件任務達成時 publish 一則訊息，大廳收到先檢查事件是不是已經結束，所以重複收到也只結算一次。全服共用的等級每台都會加經驗，升級用 Lua 比對再寫，保證同一級只升一次。

## 登入服

登入服是 Velocity 後面的第一站，只裝一支守門外掛：

- **探大廳**。每 5 秒對大廳做一次 Server List Ping，連續 3 次失敗就判定停機，擋住進大廳的指令並顯示維護中。只看 port 不夠，Paper 開 port 之後到 `Done` 還要約 40 秒，要真的拿到狀態 JSON 才算開好。
- **發材質包**。CI 把材質包傳到物件儲存，更新一份帶檔名和 sha1 的 `latest.json`。登入服每 5 分鐘讀一次，在登入的 configuration 階段發給玩家。`server.properties` 的 `resource-pack` 留空。

## 背包同步和物品定義

背包跨分流用 HuskSync 同步。HuskSync 搬的是玩家身上的物品，MythicMobs 物品的定義卻寫在各分流自己的設定檔。物品只定義在一台，玩家帶到別台就失效。

所以共用的物品和物品技能只寫在大廳，再用 rsync 同步到其他 9 台。分流自己只放怪物、怪物技能、掉落表、生怪點。

## 還沒解決的

- 單一主機。10 個分流都在同一台，主機出事就是全服。
- 大廳是單點。結算、寫回、通知都在大廳，大廳重啟的那幾分鐘，別台發出的訊息沒人收。
- 記憶體快滿。下一步要嘛合併分流，要嘛加一台主機把分流拆過去。
