背景
Robloxは、DataStoreServiceを介してデータストアとインターフェースするためのAPIセットを提供しています。これらのAPIの最も一般的な使用ケースは、_プレイヤーデータ_の保存、読み込み、複製です。つまり、プレイヤーの進行状況、購入、その他のセッション特性に関連するデータであり、個々のプレイセッション間で持続します。
Robloxのほとんどのゲームは、これらのAPIを使用して何らかの形のプレイヤーデータシステムを実装しています。これらの実装はアプローチが異なりますが、一般的には同じ問題セットを解決しようとしています。
一般的な問題
以下は、プレイヤーデータシステムが解決しようとする最も一般的な問題のいくつかです。
メモリアクセス: DataStoreServiceのリクエストは、非同期で動作し、レート制限の対象となるウェブリクエストを行います。これはセッションの開始時の初期読み込みには適していますが、通常のゲームプレイ中の高頻度の読み書き操作には適していません。ほとんどの開発者のプレイヤーデータシステムは、このデータをRobloxサーバーのメモリ内に保存し、DataStoreServiceのリクエストを以下のシナリオに制限します:
- セッションの開始時の初期読み込み
- セッションの終了時の最終書き込み
- 最終書き込みが失敗するシナリオを軽減するための定期的な書き込み
- 購入処理中にデータが保存されることを保証するための書き込み
効率的なストレージ: プレイヤーのセッションデータを単一のテーブルに保存することで、複数の値を原子的に更新し、より少ないリクエストで同じ量のデータを処理できます。また、値間の非同期化のリスクを排除し、ロールバックをより簡単に考慮できます。
一部の開発者は、大きなデータ構造を圧縮するためにカスタムシリアル化を実装することもあります(通常はゲーム内のユーザー生成コンテンツを保存するため)。
複製: クライアントはプレイヤーのデータに定期的にアクセスする必要があります(たとえば、UIを更新するため)。プレイヤーデータをクライアントに複製するための一般的なアプローチにより、データの各コンポーネントに対して特注の複製システムを作成することなく、この情報を送信できます。開発者は、クライアントに複製されるものとされないものを選択するオプションを望むことがよくあります。
エラーハンドリング: DataStoresにアクセスできない場合、ほとんどのソリューションは再試行メカニズムと「デフォルト」データへのフォールバックを実装します。フォールバックデータが後で「実際の」データを上書きしないように特別な注意が必要であり、これがプレイヤーに適切に伝えられることが重要です。
再試行: データストアにアクセスできない場合、ほとんどのソリューションは再試行メカニズムとデフォルトデータへのフォールバックを実装します。フォールバックデータが後で「実際の」データを上書きしないように特別な注意を払い、状況をプレイヤーに適切に伝えます。
セッションロック: 単一のプレイヤーのデータが複数のサーバーで読み込まれ、メモリ内にある場合、1つのサーバーが古い情報を保存する問題が発生する可能性があります。これにより、データ損失や一般的なアイテム重複の抜け道が生じる可能性があります。
原子的な購入処理: アイテムが失われたり、複数回授与されたりしないように、購入を原子的に検証、授与、記録します。
サンプルコード
Robloxには、プレイヤーデータシステムの設計と構築を支援するための参照コードがあります。このページの残りの部分では、背景、実装の詳細、および一般的な注意事項を検討します。
モデルをStudioにインポートすると、次のフォルダ構造が表示されるはずです。

アーキテクチャ
この高レベルの図は、サンプル内の主要なシステムと、それらがゲームの残りのコードとどのようにインターフェースするかを示しています。

再試行
クラス: 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メソッドを公開しています。詳細については、プレイヤーデータを参照してください。
セッションロック
クラス: SessionLockedDataStoreWrapper
背景
プレイヤーデータはサーバーのメモリに保存され、必要に応じて基盤となるデータストアからのみ読み書きされます。ウェブリクエストを必要とせずにメモリ内のプレイヤーデータを即座に読み取り、更新でき、DataStoreServiceの制限を超えることを避けることができます。
このモデルが意図した通りに機能するためには、同時に1つのサーバーだけがプレイヤーのデータをDataStoreからメモリに読み込むことができることが不可欠です。
たとえば、サーバーAがプレイヤーのデータを読み込むと、サーバーBはサーバーAが最終保存中にロックを解除するまでそのデータを読み込むことができません。ロックメカニズムがない場合、サーバーBはサーバーAがメモリ内に持っている最新のバージョンを保存する機会がないまま、データストアから古いプレイヤーデータを読み込む可能性があります。その後、サーバーAが新しいデータを保存した場合、サーバーBは古いデータを次の保存時に上書きします。
Robloxは、クライアントが同時に1つのサーバーに接続できるように制限していますが、1つのセッションのデータが次のセッションが開始される前に常に保存されるとは限りません。プレイヤーがサーバーAを離れるときに発生する可能性のある次のシナリオを考えてみてください:
- サーバーAがプレイヤーのデータを保存するためにDataStoreリクエストを行いますが、リクエストが失敗し、成功するまでにいくつかの再試行が必要です。再試行期間中に、プレイヤーはサーバーBに参加します。
- サーバーAが同じキーに対してUpdateAsync()を多く呼び出しすぎてスロットリングされます。最終保存リクエストがキューに置かれます。リクエストがキューにある間に、プレイヤーはサーバーBに参加します。
- サーバーAで、PlayerRemovingイベントに接続されたコードが、プレイヤーのデータが保存される前に待機します。この操作が完了する前に、プレイヤーはサーバーBに参加します。
- サーバーAのパフォーマンスが低下し、最終保存がプレイヤーがサーバーBに参加した後まで遅延します。
これらのシナリオは稀であるべきですが、特にプレイヤーがサーバーAからサーバーBに迅速に接続し直す場合(たとえば、テレポート中)には発生する可能性があります。一部の悪意のあるユーザーは、この動作を悪用して、持続しないアクションを完了しようとするかもしれません。これは、プレイヤーがトレードを行うことを許可するゲームに特に影響を与え、アイテム重複の悪用の一般的な原因となります。
セッションロックは、この脆弱性に対処するために、プレイヤーのDataStoreキーが最初にサーバーによって読み込まれるときに、サーバーが同じUpdateAsync()呼び出し内でキーのメタデータにロックを書き込むことを保証します。このロック値が他のサーバーがキーを読み書きしようとする際に存在する場合、サーバーは処理を進めません。
アプローチ

SessionLockedDataStoreWrapperは、DataStoreWrapperクラスのメタラッパーです。DataStoreWrapperはキューイングと再試行機能を提供し、SessionLockedDataStoreWrapperはセッションロックを補完します。
SessionLockedDataStoreWrapperは、すべてのDataStoreリクエストをUpdateAsyncを通じて処理します。これは、UpdateAsyncがキーを原子的に読み書きできるためです。また、読み取った値に基づいて書き込みを放棄することも可能です。
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に書き込みます。
セッションロックにより、このフローが簡素化されます。次のシナリオを心配する必要がなくなります:
- 現在のサーバーのメモリ内プレイヤーデータが古くなる可能性があり、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メソッドが利用可能です。これを、他のシステムが開始される前の読み込み画面中に1回行うことをお勧めします。これにより、クライアントのデータとのすべての相互作用の前に読み込みエラーを確認する必要がなくなります。
- プレイヤーの初期読み込みが失敗した場合、デフォルトデータを使用することを引き起こすhasErroredメソッドを照会できます。このメソッドを確認して、プレイヤーが購入を行うことを許可する前に、成功した読み込みがないとデータに保存できないことを確認します。
- プレイヤーデータが変更されるたびに、playerDataUpdatedシグナルがplayer、key、valueとともに発火します。個々のシステムはこれにサブスクライブできます。
クライアントへの変更の複製
- PlayerDataServerのプレイヤーデータの変更は、setValueAsPrivateを使用してそのキーがプライベートとしてマークされていない限り、PlayerDataClientに複製されます。
- setValueAsPrivateは、クライアントに送信されるべきでないキーを示すために使用されます。
- PlayerDataClientには、キーの値を取得するメソッド(get)と、更新されたときに発火するシグナル(updated)が含まれています。データが読み込まれるのを待つためのhasLoadedメソッドとloadedシグナルも含まれており、クライアントはデータが読み込まれ、複製されるのを待ってからシステムを開始できます。
- PlayerDataClientは、同じ環境で実行されている任意のクライアントコードから要求され、アクセスできるシングルトンです。
クライアントへのエラーの複製
- プレイヤーデータの保存または読み込み中に発生したエラーステータスは、PlayerDataClientに複製されます。
- getLoadErrorおよびgetSaveErrorメソッドを使用してこの情報にアクセスし、loadedおよびsavedシグナルとともに使用します。
- これらのイベントを使用して、クライアントの購入プロンプトを無効にし、警告ダイアログを実装します。この画像は、プレイヤーデータの読み込みに失敗したときに表示される可能性のある警告の例を示しています:

プレイヤーデータの保存

プレイヤーがゲームを離れると、システムは次の手順を実行します:
- プレイヤーのデータをデータストアに書き込むのが安全かどうかを確認します。安全でないシナリオには、プレイヤーのデータが読み込まれなかったり、まだ読み込み中であったりすることが含まれます。
- SessionLockedDataStoreWrapperを介して、現在のメモリ内データ値をデータストアに書き込み、完了したらセッションロックを解除するリクエストを行います。
- サーバーメモリからプレイヤーのデータ(およびメタデータやエラーステータスなどの他の変数)をクリアします。
定期的なループで、サーバーは各プレイヤーのデータをデータストアに書き込みます(保存が安全である場合)。この歓迎すべき冗長性は、サーバークラッシュ時の損失を軽減し、セッションロックを維持するためにも必要です。
サンプルは、AUTO_SAVE_INTERVAL秒(デフォルトで180秒)後に1つの共有ループを開始し、その後、すべての読み込まれたプレイヤーを並行して保存します。このループはサーバーやプレイヤーをオフセットしないため、同時に開始するサーバーは一緒にフラッシュできます。
各プレイヤーの最初の保存を、間隔内のランダムな期間でオフセットして、ライブサーバーが同時に書き込まれないようにします:
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コールバックは完了するまで返されません。
- 保存を迅速化するために、各キーのキュー内のすべての他のリクエストが基盤となるDataStoreWrapperからクリアされます(再試行を参照)。
- コールバックは、すべてのリクエストが完了するまで返されません。