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

架構
此高級圖示說明了範例中的關鍵系統及其如何與遊戲中其餘代碼進行交互。

重試
Class: DataStoreWrapper
背景
由於 DataStoreService 在底層進行網絡請求,因此其請求不保證成功。當這種情況發生時,DataStore 方法會拋出錯誤,允許你處理它們。
一個常見的“陷阱”可能會發生,如果你嘗試這樣處理數據存儲故障:
local MAX_ATTEMPTS = 5
local BASE_DELAY = 2
local MAX_DELAY = 32
local function retrySetAsync(dataStore, key, value)
for attempt = 1, MAX_ATTEMPTS do
local success, result = pcall(dataStore.SetAsync, dataStore, key, value)
if success then
return result
end
if attempt < MAX_ATTEMPTS then
local backoff = math.min(MAX_DELAY, BASE_DELAY * (2 ^ (attempt - 1)))
local jitter = math.random() * backoff
task.wait(math.min(MAX_DELAY, backoff + jitter))
end
end
end使用指數退避和隨機抖動重試瞬時故障,以防止伺服器同時重試。限制延遲和嘗試次數。
即使有這種延遲模式,這種重試機制對於 DataStoreService 請求也不合適,因為它不保證請求的順序。保留請求的順序對於 DataStoreService 請求很重要,因為它們與狀態交互。考慮以下場景:
- 請求 A 被發出以將鍵 K 的值設置為 1。
- 請求失敗,因此計劃在退避延遲後重試。
- 在重試發生之前,請求 B 將 K 的值設置為 2,但請求 A 的重試立即覆蓋此值並將 K 設置為 1。
即使 UpdateAsync 在鍵的最新版本上運行,UpdateAsync 請求仍必須按順序處理,以避免無效的瞬時狀態(例如,購買在處理之前減少硬幣,導致負硬幣)。
我們的玩家數據系統使用一個新類 DataStoreWrapper,它提供保證按鍵順序處理的可讓步重試。
方法

DataStoreWrapper 提供與 DataStore 方法相對應的方法:DataStore:GetAsync()、DataStore:SetAsync()、DataStore:UpdateAsync() 和 DataStore:RemoveAsync()。
這些方法在被調用時:
將請求添加到隊列中。每個鍵都有自己的隊列,請求按順序和串行處理。請求線程在請求完成之前會讓出控制權。
此功能基於 ThreadQueue 類,這是一個基於協程的任務調度器和速率限制器。ThreadQueue 不返回承諾,而是讓當前線程直到操作完成後再返回,並在失敗時拋出錯誤。這與慣用的異步 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 方法。更多上下文,請參見 玩家數據。
會話鎖定
Class: 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 執行 DataStore 操作,這是 SessionLockedDataStoreWrapper 的消費者請求的。例如,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。
會話鎖定簡化了這一流程,因為你不再需要擔心以下場景:
- 當前伺服器中的內存玩家數據可能過時,要求你在驗證 PurchaseId 歷史之前從 DataStore 獲取最新值
- 相同購買的回調在另一伺服器中運行,要求你同時讀取和寫入 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 發出請求,將當前內存數據值寫入數據存儲,並在完成後移除會話鎖定。
- 從伺服器內存中清除玩家的數據(以及其他變量,如元數據和錯誤狀態)。
在定期循環中,伺服器將每個玩家的數據寫入數據存儲(前提是安全保存)。這種冗餘可以減輕伺服器崩潰時的數據丟失,並且還是維持會話鎖定所必需的。
該範例在 AUTO_SAVE_INTERVAL 秒(默認為 180)後啟動一個共享循環,然後並行保存每個已加載的玩家。該循環不會偏移伺服器或玩家,因此在相似時間啟動的伺服器可以一起刷新。
將每個玩家的第一次保存偏移一個隨機的持續時間在該間隔內,以便實時伺服器不會同時寫入:
local AUTO_SAVE_INTERVAL = 180local function startAutoSave(player)task.spawn(function()task.wait(math.random() * AUTO_SAVE_INTERVAL)while player.Parent doif canSave(player) thensavePlayerData(player)endtask.wait(AUTO_SAVE_INTERVAL)endend)end當收到關閉伺服器的請求時,在 BindToClose 回調中發生以下情況:
- 發出請求以保存伺服器中每個玩家的數據,遵循玩家離開伺服器時通常經過的過程。這些請求是並行發出的,因為 BindToClose 回調只有 30 秒的時間來完成。
- 為了加快保存,清除每個鍵的隊列中所有其他請求(請參見 重試)。
- 回調不會返回,直到所有請求完成。