跳至主要内容

ADR 002 遊戲伺服器是否應該知道交易伺服器的節點

· 閱讀時間約 6 分鐘
Senior Engineer
  • Status: 📝Proposed
  • Maker: GameServer , Transaction Server Developer
  • Refs: none

Context

若採取轉帳錢包,我們就必須負責維護用戶的餘額。本質上,我們也成為了一個平台。

為了設計抗併發系統,我認為可以使用 K:N:M 的概念來描述整個系統下的節點:

  • KK:Player(玩家連線數)
  • NN:Game Server(遊戲邏輯節點)
  • MM:Transaction Server(交易/帳務節點)

且預期是 KN>MK ≫ N > M

玩家數量(KK)可能來自我們平台或是外部平台:數量極大、行為不可控,系統上主要的交易來源。

如果交易服務 M={m1,m2,,mN}M = \{ m_1, m_2, \dots, m_N \} 是一群主機構成的叢集,則遊戲伺服器是否應該直接知道交易伺服器的位置,還是統一由 Loading Balancer 進行分發?

而遊戲節點(NN)比交易節點(MM)多的原因是因為:

  • 承接玩家連線
  • 維護遊戲狀態
  • 處理遊戲邏輯

先假定遊戲伺服器是個純函數1,則 Scale 會十分簡單,因為同樣的輸入,無論在什麼時間、哪一台機器上計算,結果都一樣。

傳統上,Game Server 困難在於它保存了玩家進度、當前局面與中間狀態。一旦節點掛掉,狀態就丟失,必須靠狀態同步或複寫等機制來維持連續性。Pure Function 把這些責任移除。Server 只負責計算,不擁有任何不可重建的資訊,因此節點可以隨時被替換。

若能做到,則一致性問題被自然消解,Scale 的目標變成「吞吐量足夠」,也就是每秒能算多少次:負載高時多加節點,負載低時縮減節點,系統行為保持不變。

但實務上,玩家餘額確實是狀態,且通常在整個系統裡都是不允許混亂。

如果遊戲邏輯只處理:在這個 seed 與下注條件下,結果是什麼?

  • 玩家現在有沒有錢
  • 這筆錢能不能扣
  • 這次扣款是不是重複請求
  • 這筆交易要不要入帳、可不可以回滾

這些就是交易語意,不是遊戲的語意。

Options

考慮到系統的併發數量,K:N:M 的設計中,我認為只要關注遊戲節點 N 的 Scale 行為,因為交易應該只是維護玩家的餘額,相對起來應該僅僅是記憶體操作。

主要的 Scale 行為發生在遊戲節點,交易節點也可能會 Scale,但是對於 N 的敏感性應該要小很多。

  • 每個玩家同一時間只允許一筆交易
  • 每筆交易只做餘額加減
  • 每次處理非常快

當玩家數量大幅增長時,要考慮的行為是:

  • 成千上萬個「彼此獨立、但同時到達」的交易流
  • 每個玩家的交易要序列處理
  • 不同玩家之間不需要序列化

我想導入的是Lock-Free 設計,將鎖的使用與系統間的狀態同步最小化。在過往 C++ 經驗設計,我傾向於使用 Bucket Sharding 機制:

HRW Hash

假定玩家透過 Loading Balancer 到任意的遊戲節點,會透過「某種算法」,確保玩家只會到同一個交易節點上。

玩家或錢包的集合:P={p1,p2,,pK}P = \{ p_1, p_2, \dots, p_K \}

交易節點的集合:M={m1,m2,,mN}M = \{ m_1, m_2, \dots, m_N \}

從玩家的屬性中可選出一個key:例如 UUID keyPkey \in P

為每一個交易節點計算權重: w(key,mi)=H(keymi)w(key, m_i) = H(key \,\|\, m_i)

存在一個 routing 函數,用以計算玩家的交易去哪個節點:

mi=assign(key)=argmaxmiMw(key,mi)m_i = \operatorname{assign}(key) = \arg\max_{m_i \in M} w(key, m_i)

同時考慮分配到 mim_i 的玩家交易集合 Bi={kPassign(key)=mi}B_i = \{ k \in P \mid \operatorname{assign}(key) = m_i \}

對同一個 Key,所有交易是存在交易順序的:

keyBi,  transactions(key) are totally ordered\forall key \in B_i,\; \text{transactions}(key) \text{ are totally ordered}

且不同的 Key(=不同玩家),所有的交易是可平行化的:

key1,key2Bi,  key1key2    transactions(k1)transactions(k2)\forall key_1, key_2 \in B_i,\; key_1 \neq key_2 \;\Rightarrow\; \text{transactions}(k_1) \parallel \text{transactions}(k_2)

這樣就可以做到「不使用鎖、減低主機之間狀態」同步,因為特定玩家提交的交易只會在特定的主機上。

考慮到節點變動時:M=M{mN+1}M' = M \cup \{ m_{N+1} \},交易節點的集合從 MM 變成 MM' 時,應該進可能保證遷移節點的玩家數量遠小於總玩家數量P|P|

{keyPassignM(key)assignM(key)}P\left| \{ key \in P \mid \operatorname{assign}_M(key) \neq \operatorname{assign}_{M'}(key) \} \right| \ll |P|

遊戲伺服器應該知道交易伺服器的節點

在這個方案中,Game Server(N)能取得 Transaction Server(M)的節點清單,並在本地執行 routing 決策。Bucket 的歸屬與節點選擇,都發生在遊戲層。

遊戲伺服器不應該知道交易伺服器的節點

在這個方案中,Game Server 只把交易請求送到一個固定入口,例如 Gateway、Router 或 Transaction Selector。

Comparison

面向Option 1Option 2:Gateway Routing
Routing 決策點遊戲層(N)交易層入口(Gateway / Frontend)
Game Server 是否知道 M 節點必須持有完整的節點清單不需要,只知道固定入口
HRW 執行位置每一台 Game Server集中在交易層
Bucket 歸屬決定時間請求送出前(N 已決定)請求進入交易層後
Bucket 語意可見性N 與 M 共同承擔僅 M 內部承擔
交易 Scale 影響範圍跨層事件:N 與 M 都受影響單層事件:僅 M 內部
Scale 時的需求所有 N 必須同步看到同一個 M只需 Gateway / M 看到一致 M
In-flight transaction 處理複雜,需定義切換語意相對單純,入口可序列化
送錯節點的風險存在,需補救策略幾乎不存在
Network hop少一跳多一跳
延遲特性最低延遲可預期但略高
Gateway 成為 SPOF是(需 HA)
遊戲的責任複雜度高(routing + discovery +一致性假設)低(只送請求)
交易的內部是否好重構低(需對外同步)高(可透明重組)
適合的系統文化去中心化、基礎設施成熟風險保守、邊界清楚
出事時影響半徑大(容易跨層)小(侷限於交易層)

SPOF = single point of failure, 單點故障

Decison

Consequences


Footnotes

  1. Pure Function