背景
Roblox 提供了一組 API,用於通過 DataStoreService 與數據存儲交互。這些 API 最常見的用例是保存、加載和復制 玩家數據,即與玩家的進度、購買和其他會話特徵相關的數據,這些數據在各個遊玩會話之間持久存在。
大多數 Roblox 遊戲使用這些 API 來實現某種形式的玩家數據系統。這些實現的方式各不相同,但一般都旨在解決相同的一組問題。
常見問題
以下是一些玩家數據系統試圖解決的最常見問題:
內存訪問: DataStoreService 請求進行網絡請求,這些請求以異步方式運行並受速率限制。這適用於會話開始時的初始加載,但不適合在正常遊玩過程中進行高頻讀寫操作。大多數開發者的玩家數據系統將這些數據存儲在 Roblox 服務器的內存中,將 DataStoreService 請求限制到以下場景:
- 會話開始時的初始讀取
- 會話結束時的最終寫入
- 在一定時間間隔內進行的定期寫入,以減輕最終寫入失敗的情況
- 確保在處理購買時數據被保存的寫入
高效存儲: 將玩家的所有會話數據存儲在一個表中可以讓你原子性地更新多個值,並在較少的請求中處理相同數量的數據。它還消除了值之間不同步的風險,並使回滾操作更容易處理。
一些開發者還實現了自定義序列化以壓縮大型數據結構(通常是為了保存遊戲內用戶生成的內容)。
復制: 客戶端需要定期訪問玩家的數據(例如,用於更新 UI)。一種通用的將玩家數據復制到客戶端的方法可以讓你傳輸此信息,而無需為數據的每個組件創建定制的復制系統。開發者通常希望對哪些數據被復制到客戶端和哪些不被復制具有選擇權。
錯誤處理: 當無法訪問數據存儲時,大多數解決方案都會實現重試機制和回退到“默認”數據。需要特別小心,以確保回退數據不會後來覆蓋“真實”數據,並妥善告知玩家這一情況。
重試: 當數據存儲無法訪問時,大多數解決方案會實現重試機制和回退到默認數據。需特別小心,確保回退數據不會後來覆蓋“真實”數據,並適當地告訴玩家情況。
會話鎖定: 如果單個玩家的數據在多個服務器上被加載並存儲在內存中,可能會出現一個服務器保存過時信息的問題。這可能導致數據丟失和常見的物品重復漏洞。
原子購買處理: 以原子方式驗證、授予和記錄購買,以防止物品遺失或被多次授予。
範例代碼
Roblox 提供了參考代碼,以協助你設計和構建玩家數據系統。本頁的其餘部分探討了背景、實施細節和一般注意事項。
將模型導入 Studio 後,你應該看到以下文件夾結構:

架構
此高級圖表說明了範例中的關鍵系統及其如何與遊戲中其他代碼接口。

重試
類別: DataStoreWrapper
背景
由於 DataStoreService 在內部發出網絡請求,因此其請求不保證成功。當這種情況發生時,DataStore 方法會引發錯誤,讓你進行處理。
一個常見的“陷阱”可能發生在你嘗試這樣處理數據存儲故障時:
local function retrySetAsync(dataStore, key, value)
for _ = 1, MAX_ATTEMPTS do
local success, result = pcall(dataStore.SetAsync, dataStore, key, value)
if success then
break
end
task.wait(TIME_BETWEEN_ATTEMPTS)
end
end
雖然這是一個完全有效的通用函數重試機制,但不適合 DataStoreService 請求,因為它不保證請求的發送順序。保留請求的順序對於 DataStoreService 請求至關重要,因為它們會影響狀態。考慮以下情況:
- 請求 A 被發出以將鍵 K 的值設置為 1。
- 該請求失敗,因此安排了一個 2 秒後再次重試的操作。
- 在重試之前,請求 B 將 K 的值設置為 2,但請求 A 的重試立即覆蓋了這個值並將 K 設置為 1。
即使 UpdateAsync 在鍵的最新版本上操作,UpdateAsync 請求仍然必須按照順序處理,以避免無效的瞬態狀態(例如,購買在硬幣增加之前減少硬幣,導致負硬幣)。
我們的玩家數據系統使用了一個新類 DataStoreWrapper,該類提供了保證按鍵順序處理的可讓重試。
方法

DataStoreWrapper 提供了對應於 DataStore 方法的幾個方法:DataStore:GetAsync()、DataStore:SetAsync()、DataStore:UpdateAsync() 和 DataStore:RemoveAsync()。
當這些方法被調用時:
將請求添加到排隊中。每個鍵都有自己的隊列,請求按順序和串行處理。請求線程會在請求完成之前等待。
此功能基於 ThreadQueue 類,這是一個基於協程的任務調度器和速率限制器。ThreadQueue 不是返回一個 promise,而是使當前線程在操作完成之前暫停,並在失敗時引發錯誤。這更符合習慣的異步 Luau 模式。
如果請求失敗,則使用可配置的指數退避進行重試。這些重試是提交給 ThreadQueue 的回調的一部分,因此它們保證會在該鍵的下一個請求開始之前完成。
當請求完成後,請求方法會返回 success, result 模式。
DataStoreWrapper 還暴露了獲取給定鍵的隊列長度和清除過時請求的方法。後一個選項在伺服器關閉且沒有時間處理除最新請求之外的請求時特別有用。
注意事項
DataStoreWrapper 遵循的原則是,除了極端情況外,每個數據存儲請求都應允許完成(無論是成功還是失敗),即使更近期的請求使其變得多餘。當新請求發生時,在排隊中不會刪除過時的請求,而是允許其在新請求開始之前完成。這一原則根植於此模塊作為通用數據存儲實用工具的適用性,而不是專門針對玩家數據的工具,原因如下:
很難確定一組直觀的規則,何時請求可以安全地從隊列中刪除。考慮以下隊列:
Value=0, SetAsync(1), GetAsync(), SetAsync(2)
預期行為是,GetAsync() 會返回 1,但如果因為最近的請求多餘而刪除了 SetAsync() 請求,則會返回 0。
其邏輯進展是,當添加新寫入請求時,只修剪最近讀取請求之前的過時請求。UpdateAsync() 是最常見的操作(也是此系統唯一使用的操作),因此在這種設計中進行協調將會很困難,而不添加額外的複雜性。
DataStoreWrapper 可能要求你指定 UpdateAsync() 請求是否被允許讀取和/或寫入,但這對我們的玩家數據系統並不適用,因為由於會話鎖定機制,這無法提前確定(稍後會詳細介紹)。
一旦從隊列中刪除,則很難決定該如何處理它的直觀規則。當執行 DataStoreWrapper 請求時,當前線程會在請求完成之前暫停。如果我們從隊列中刪除了過時請求,則必須決定是返回 false, "Removed from queue" 還是永遠不返回並丟棄活躍線程。這兩種方法都各有其缺陷,並將額外的複雜性卸載給消費者。
最終,我們的看法是,處理每個請求的簡單方法更可取,並在處理像會話鎖定這樣複雜的問題時創造了一個更清晰的環境。唯一的例外是,在 DataModel:BindToClose() 期間,此時清空隊列成為保存所有用戶數據的必要條件,並且單個函數調用返回的值不再是持續關注的問題。為此,我們暴露了一個 skipAllQueuesToLastEnqueued 方法。有關更多上下文,請參見 玩家數據。
會話鎖定
類別: SessionLockedDataStoreWrapper
背景
玩家數據存儲在服務器內存中,僅在必要時從底層數據存儲中讀取和寫入。你可以立即讀取和更新內存中的玩家數據,而無需執行網絡請求,並避免超過 DataStoreService 的限制。
為了讓此模型按預期工作,至關重要的是不允許多於一個服務器同時從 DataStore 加載玩家的數據。
例如,如果服務器 A 加載了一個玩家的數據,則服務器 B 在服務器 A 在最終保存時釋放鎖之前無法加載該數據。如果沒有鎖定機制,服務器 B 可能在服務器 A 有機會保存最新版本的數據之前,從數據存儲加載過時的玩家數據。然後,如果服務器 A 在服務器 B 加載過時數據後保存其更新的數據,服務器 B 將在下一次保存中覆蓋該更新的數據。
雖然 Roblox 只允許一個客戶端同時連接到一個服務器,但你不能假設一個會話中的數據總是在下一個會話開始之前被保存。考慮一下玩家離開服務器 A 時可能發生的以下情況:
- 服務器 A 發出一個 DataStore 請求來保存玩家的數據,但請求失敗並需要多次重試才能成功完成。在重試期間,玩家加入了服務器 B。
- 服務器 A 對同一鍵進行了太多 UpdateAsync() 調用並受到限流。最終保存請求被放入隊列中。在請求還在隊列中時,玩家進入了服務器 B。
- 在服務器 A 上,與 PlayerRemoving 事件相關的某些代碼在玩家的數據保存之前被暫停。在此操作完成之前,玩家進入了服務器 B。
- 服務器 A 的性能已降到在玩家加入服務器 B 之前最終保存被延遲的地步。
這些情況應該是罕見的,但確實會發生,特別是在玩家快速斷開與一個服務器的連接並連接到另一個服務器的情況下(例如,當傳送時)。一些惡意用戶甚至可能試圖濫用此行為以在不持久化的情況下完成操作。這在允許玩家交易的遊戲中尤其可能造成影響,是物品重復利用漏洞的常見來源。
會話鎖定通過確保當服務器首次讀取玩家的 DataStore 鍵時,服務器將原子性地寫入鎖到該鍵的元數據中,從而解決了這一脆弱性。在同一次 UpdateAsync() 調用中進行。如果當任何其他服務器嘗試讀取或寫入該鍵時,此鎖值存在,則服務器不會繼續。
方法

SessionLockedDataStoreWrapper 是對 DataStoreWrapper 類的元包裝器。DataStoreWrapper 提供排隊和重試功能,而 SessionLockedDataStoreWrapper 進一步補充了會話鎖定。
SessionLockedDataStoreWrapper 將每個 DataStore 請求—無論是 GetAsync、SetAsync 還是 UpdateAsync—通過 UpdateAsync。這是因為 UpdateAsync 允許對鍵的原子性讀寫。也可以根據轉換回調中讀取的值放棄寫入,返回 nil。
每個請求的轉換函數傳遞給 UpdateAsync,其執行以下操作:
驗證鍵是否可以安全訪問,如果不能則放棄操作。“安全訪問”意味著:
鍵的元數據對象不包含在鎖到期時間內最後更新的未識別的 LockId 值。這一點考慮到尊重另一個服務器放置的鎖和忽略過期的鎖。
如果此服務器之前在鍵的元數據中放置了自己的 LockId 值,那麼此值仍在鍵的元數據中。這考慮到了另一個服務器是否接管了此服務器的鎖(因過期或強制)並後來釋放它。可以說,即使 LockId 為 nil,另一個服務器仍可在你鎖定鍵的時間內替換和移除鎖。
UpdateAsync 根據 SessionLockedDataStoreWrapper 消費者請求的內容執行 DataStore 操作。例如,GetAsync() 轉譯為 function(value) return value end。
根據請求中傳遞的參數,UpdateAsync 鎖定或解鎖鍵:
如果需要鎖定鍵,UpdateAsync 將鍵的元數據中的 LockId 設置為 GUID。這個 GUID 儲存在服務器的內存中,以便在下次訪問鍵時進行驗證。如果服務器已經在此鍵上鎖定,則不進行更改。它還安排一個任務,如果未在鎖的過期時間內再次訪問該鍵,則會警告你。
如果需要解鎖鍵,UpdateAsync 將刪除鍵的元數據中的 LockId。
自定義重試處理程序被傳遞到底層的 DataStoreWrapper 中,因此如果在步驟 1 中因會話被鎖定而中止,則該操作將重新嘗試。
還將返回一條自定義錯誤消息,以允許玩家數據系統在會話鎖定時向客戶端報告替代錯誤。
注意事項
會話鎖定機制依賴於服務器在完成後總是釋放其對鍵的鎖。這應該始終通過在 PlayerRemoving 或 BindToClose() 中的最終寫入指令來完成。
然而,在某些情況下,解鎖可能會失敗。例如:
- 服務器崩潰,或 DataStoreService 在所有嘗試訪問該鍵的情況下都無法運行。
- 由於邏輯錯誤或類似的錯誤,未發出解鎖鍵的指令。
要維持對某個鍵的鎖定,必須定期訪問它,只要它已加載到內存中。這通常是大多數玩家數據系統的自動保存循環的一部分,但如果需要手動操作,此系統還暴露了一個 refreshLockAsync 方法。
如果在未更新鎖的情況下過期時間已超過,則任何服務器都可以自由接管鎖。如果另一個服務器接管了鎖,則當前服務器的讀取或寫入鍵的嘗試將失敗,除非它建立一個新的鎖定。
開發者產品處理
單例: ReceiptHandler
背景
ProcessReceipt 回調在確定何時最終完成購買方面扮演著至關重要的角色。ProcessReceipt 在非常特定的情況下被調用。其保證的集合見 MarketplaceService.ProcessReceipt。
儘管“處理”購買的定義在不同遊戲之間可能會有所不同,但我們使用以下標準。
購買尚未被處理過。
購買在當前會話中有所體現。
這需要在返回 PurchaseGranted 之前執行以下操作:
- 驗證 PurchaseId 之前未被記錄為已處理。
- 在玩家的內存數據中授予購買。
- 在玩家的內存數據中記錄 PurchaseId 為已處理。
- 將玩家的內存數據寫入 DataStore。
會話鎖定簡化了這一流程,因為你不再需要擔心以下情況:
- 當前服務器中內存中的玩家數據可能過時,需要從 DataStore 獲取最新值,然後核實 PurchaseId 歷史。
- 同一購買的回調在另一個服務器上運行,需要同時讀取和寫入 PurchaseId 歷史,並原子性地保存帶有購買反映的玩家數據以防止競爭條件。
會話鎖定保證,如果對玩家的 DataStore 寫入操作成功,則在此服務器中加載和保存數據之間,沒有其他服務器成功讀取或寫入過玩家的 DataStore。簡而言之,此服務器中的內存玩家數據是可用的最新版本。當然,還有一些注意事項,但不影響此行為。
方法
ReceiptProcessor 中的註解概述了該方法:
驗證玩家的數據當前已加載在此服務器上,並且加載過程中沒有出現錯誤。
因為這個系統使用會話鎖定,這一檢查同時驗證內存數據是最新版本。
如果玩家的數據尚未加載(這是在玩家加入遊戲時會出現的預期情況),則等待玩家的數據加載。系統還會監聽玩家在其數據加載之前離開遊戲的事件,以便在玩家重新加入時不會無限等待,阻塞此服務器上為該購買再次調用的回調。
在此服務器當地更新玩家數據以“授予”購買。
ReceiptProcessor 採用通用回調方法,並為每個 DeveloperProductId 指派不同的回調。
在此服務器當地更新玩家數據以儲存 PurchaseId。
提交請求以將內存數據保存到 DataStore,如果請求成功則返回 PurchaseGranted。如果不成功,則返回 NotProcessedYet。
如果此保存請求不成功,稍後保存玩家的內存會話數據仍然可能成功。在下一次 ProcessReceipt 調用中,步驟 2 會處理這種情況並返回 PurchaseGranted。
玩家數據
單例: PlayerData.Server、PlayerData.Client
背景
提供代碼以同步讀取和寫入玩家會話數據的接口的模塊在 Roblox 遊戲中很常見。本節涵蓋 PlayerData.Server 和 PlayerData.Client。
方法
PlayerData.Server 和 PlayerData.Client 處理以下內容:
- 將玩家的數據加載到內存中,包括處理加載失敗的情況
- 為服務器代碼提供查詢和更改玩家數據的接口
- 將玩家數據的更改復制到客戶端,以便客戶端代碼可以訪問
- 將加載和/或保存錯誤復制到客戶端,以便客戶端可以顯示錯誤對話框
- 定期保存玩家的數據,當玩家離開時,以及服務器關機時
加載玩家數據

SessionLockedDataStoreWrapper 向數據存儲發出 getAsync 請求。
如果該請求失敗,則使用默認數據,並將資料標記為“錯誤”,以確保不會在稍後寫入數據存儲。
另一個選項是將玩家踢出,但我們建議允許玩家使用默認數據並清楚告知發生了什麼,而不是將其從遊戲中移除。
初始有效負載發送到 PlayerDataClient,包含加載的數據和錯誤狀態(如果有)。
任何使用 waitForDataLoadAsync 需等待的線程將被恢復。
為服務器代碼提供接口
- PlayerDataServer 是一個單例,任何在同一環境中運行的服務器代碼都可以依賴和訪問。
- 玩家數據被組織為一個鍵值對字典。你可以使用 setValue、getValue、updateValue 和 removeValue 方法在服務器上操作這些值。這些方法都是同步操作,無需讓出。
- hasLoaded 和 waitForDataLoadAsync 方法可確保在訪問數據之前已加載。建議在加載屏幕期間進行一次檢查,然後再啟動其他系統,以避免在每次與客戶端數據交互前都進行加載錯誤的檢查。
- 可以使用 hasErrored 方法查詢玩家是否因初始加載失敗而使用默認數據。在允許玩家進行購買之前檢查此方法,因為在沒有成功加載的情況下無法將購買保存到數據中。
- 每當玩家的數據被更改時,playerDataUpdated 信號會發射,包含 player、key 和 value。單獨的系統可以訂閱此信號。
將變更復制到客戶端
- 在 PlayerDataServer 中對玩家數據的任何更改都會復制到 PlayerDataClient,除非該鍵使用 setValueAsPrivate 標記為私有
- setValueAsPrivate 用於標記不應發送到客戶端的鍵
- PlayerDataClient 包含獲取鍵的值(get)的方法,以及在其被更新時觸發的信號(updated)。還包含 hasLoaded 方法和 loaded 信號,以便客戶端可以在資料加載並復制之前等待以便啟動其系統
- PlayerDataClient 是一個單例,可以依賴和訪問在同一環境中運行的任何客戶端代碼
將錯誤復制到客戶端
- 在保存或加載玩家數據時遇到的錯誤狀態會被復制到 PlayerDataClient 中。
- 使用 getLoadError 和 getSaveError 方法訪問這些信息,還有 loaded 和 saved 信號。
- 使用這些事件禁用客戶端購買提示,並實現警告對話框。這張圖片顯示了一個可能的警告對話框的示例:

保存玩家數據

當玩家離開遊戲時,系統會執行以下步驟:
- 檢查是否可以安全地將玩家的數據寫入數據存儲。在玩家數據未加載或仍在加載的情況下,這通常會被認為不安全。
- 通過 SessionLockedDataStoreWrapper 提交請求,以將當前內存中的數據值寫入數據存儲,並在完成後刪除會話鎖。
- 清除服務器內存中的玩家數據(以及其他變量,例如元數據和錯誤狀態)。
在定期循環中,服務器將每位玩家的數據寫入數據存儲(前提是安全保存)。這種冗餘可以減輕服務器崩潰時的數據丟失,並對保持會話鎖也是必要的。
當收到關閉服務器的請求時,在 BindToClose 回調中將發生以下情況:
- 對服務器中的每位玩家數據發出保存請求,遵循玩家離開服務器時通常進行的流程。這些請求將並行發出,因為 BindToClose 回調僅有 30 秒的時間完成。
- 為加快保存,將清除每鍵隊列中的所有其他請求,從底層的 DataStoreWrapper 中刪除(見 重試)。
- 此回調不會返回,直到所有請求完成。