【Roblox】MemoryStore HashMapを利用したプレイヤーのデータのキャッシュ
はじめに
今回は、MemoryStoreのデータ構造の一つのHashMapをPlayerDataのキャッシュを例にして紹介します。
なお、MemoryStoreの概要については以下の記事をご覧ください。
Roblox バージョン: 0.661.0.6610708
MemoryStoreHashMapについて
MemoryStoreのデータ構造は、SortedMap、Queue、HashMapと3種類あります。
共通して、設定するデータには有効期限があり最大で45日です。
利用するにあたって各種制限などもありますが、これは概要編にて記載したので省きます。
詳しく知りたい方は、冒頭の「MemoryStoreの概要」のリンクからご覧いただけます。
このデータ構造の一つであるHashMapでは、MemoryStoreHashMapインスタンスを利用してキーを基準に一時的にアイテムを保持することができます。
SortedMapやQueueと違い、アイテムの順番は保持されません。
その分、単純なデータのキャッシュや大量のアイテム(1000以上)を保持し高速なアクセスが必要な場合などに役立ちます。
今回はこれを利用してプレイヤーのデータをMemoryStoreHashMapを通じてキャッシュする仕組みの一例を紹介します。
プレイヤーデータのキャッシュ
Robloxでは永続的に保存したいデータはDataStoreを用いて管理することが多いです。
ただ、高頻度なアクセスがある場合はパフォーマンスが落ちる場合があります。
この時管理するデータをユーザーごとにコピーしてキャッシュしておくことで、パフォーマンスの向上やリクエスト数の減少が見込めます。
※下記コードはキャッシュの仕組みにフォーカスするため、DataStoreの処理などは簡略化しています。
-- プレイヤーのデータの管理(キャッシュの仕組みにフォーカス)
local DataStoreService = game:GetService("DataStoreService")
local playerDataStore = DataStoreService:GetDataStore("PlayerData")
-- プレイヤーデータのキャッシュ(DataStore の読み書きは省略)
local playerDataCache: { [number]: any } = {}
-- 初回のプレイヤーデータ
local playerInitData = {
Level = 1,
Exp = 0,
Coins = 1000,
LastLogin = DateTime.now().UnixTimestamp
}
local PlayerDataManager = {}
-- プレイヤーのデータを取得
function PlayerDataManager:GetData(player: Player)
if not playerDataCache[player.UserId] then
local success, data = pcall(function()
return playerDataStore:GetAsync(tostring(player.UserId))
end)
if success and not data then
--初回
data = playerInitData
end
playerDataCache[player.UserId] = data
end
return playerDataCache[player.UserId]
end
-- データをDataStoreに書き込む
function PlayerDataManager:SetData(player: Player, data: any)
playerDataStore:SetAsync(tostring(player.UserId), data)
playerDataCache[player.UserId] = data
end
-- キャッシュを削除する
function PlayerDataManager:ReleaseCache(player: Player)
if playerDataCache[player.UserId] then
playerDataCache[player.UserId] = nil
end
end
-- 経験値を追加する
function PlayerDataManager:AddExp(player: Player, amount: number)
if not playerDataCache[player.UserId] then
return
end
-- 経験値を追加する
playerDataCache[player.UserId].Exp += amount
end
return PlayerDataManager
DataStoreからデータを取得した際にUserIdごとにデータをテーブルで保持します。
データの変更はキャッシュしてあるデータに対して行い、プレイヤーがゲームから退出する時や一定時間ごとにこのデータをDataStoreに書き込むという仕組みです。
DataStoreへのアクセス数が減る一方、エクスペリエンスにいるプレイヤーの数によってはキャッシュしているデータによってメモリが圧迫されパフォーマンスが落ちる場合があります。
これを解消するためにMemoryStoreのHashMapを利用します。
MemoryStoreはDataStoreと比べ高スループットで低レイテンシに動作するため一時的なデータの保存にむいています。
1分間におけるAPIへのリクエスト数の制限もDataStoreに比べ多いので一時的なデータのキャッシュに最適です。
MemoryStoreHashMapの作成
MemoryStoreでHashMapを利用するには、MemoryStoreService:GetHashMap() を実行しMemoryStoreHashMapを取得する必要があります。
local DataStoreService = game:GetService("DataStoreService")
+ local MemoryStoreService = game:GetService("MemoryStoreService")
local playerDataStore = DataStoreService:GetDataStore("PlayerData")
+ local memoryStoreHashMap = MemoryStoreService:GetHashMap("PlayerDataCache")
+ -- データひとつに対する有効期限(秒)
+ local EXPIRATION = 60 * 5
GetHashMap function | 名称 | 型 | 説明 |
---|---|---|---|
引数 | name | string | HashMapの名前。 |
戻り値 | MemoryStoreHashMap | MemoryStoreHashMap | 指定された名前のMemoryStoreHashMapインスタンス。 |
データの追加・変更
MemoryStoreのHashMapに値を追加するには、MemoryStoreHashMap:SetAsync() を利用します。
-- データをDataStoreに書き込む
function PlayerDataManager:SetData(player: Player, data: any)
playerDataStore:SetAsync(tostring(player.UserId), data)
- --playerDataCache[player.UserId] = data
+ local success, result = pcall(function()
+ return memoryStoreHashMap:SetAsync(tostring(player.UserId), data, EXPIRATION)
+ end)
+
+ if success and result then
+ print("Success SetAsync", player, data)
+ else
+ warn("Failed to SetAsync", player, result)
+ end
end
SetAsync function | 名称 | 型 | 説明 |
---|---|---|---|
引数 | key | string | HashMapに追加・変更する値のキー。 |
引数 | value | Variant | HashMapに追加・変更する値。 |
引数 | expiration | number | 追加・変更したアイテムの有効期限(秒単位)。有効期限が切れると、アイテムは自動的に削除される。最大有効期限は 45日間(3,888,000 秒)。 |
戻り値 | result | bool | 値の追加・変更が正しく行えたか。 |
expiration
として秒単位で有効期限を設定する必要があります。
有効期限を長くしてメモリサイズを抑えるか、有効期限を短くしてフルサイズに近いサイズを使用するか、ニーズによって決めましょう。
他サーバーとの競合を考慮したデータの設定・保存
MemoryStoreHashMap:SetAsync()
はキーに対してデータを上書きします。
同一のMemoryStoreHashMap
が他サーバーから呼ばれた際に、同じキーに対して同時に上書きが行われた時片方のデータが考慮されない場合があります。
これを解消すべく、DataStoreServiceと同様に MemoryStoreHashMap:UpdateAsync() が用意されています。
-- データをDataStoreにUpdateAsync()を利用して書き込む
function PlayerDataManager:UpdateData(player: Player, data: any)
playerDataStore:SetAsync(tostring(player.UserId), data)
--playerDataCache[player.UserId] = data
local success, newValue = pcall(function()
return memoryStoreHashMap:UpdateAsync(
tostring(player.UserId),
function(oldValue: any)
if oldValue == nil then
print("Set New Data")
end
return data
end,
EXPIRATION)
end)
if success and newValue then
print("Success UpdateAsync", player, newValue)
else
warn("Failed to UpdateAsync", player)
end
end
UpdateAsync function | 名称 | 型 | 説明 |
---|---|---|---|
引数 | key | string | 設定したいキー。 |
引数 | transformFunction | function | 変換関数。この関数は古い値を引数として受け取り、新しい値を返す。 |
引数 | expiration | number | 設定したアイテムの有効期限(秒単位)。有効期限が切れると、アイテムは自動的に削除される。最大有効期限は 45日間(3,888,000 秒)。 |
戻り値 | value | Variant | 変換関数によって返された、最新の値。 |
transformFunction
は引数として更新前の古い値を渡し、戻り値として設定した新しい値を返すようにしましょう。
この時、キーが読み取られた後に他サーバーなどから値が変更されるとUpdateAsync
はリトライを試みます。
このループは正しく値が設定されるか、変換関数内からnilを返すまで続きます。
このリトライはAPIのリクエスト制限数を消費します。
APIのリクエスト制限数やパフォーマンスなどが気になる場合は、ループ回数を計測し制限をかけるなどを行ってもよいかもしれません。
複数のサーバーから同一のキーを変更する可能性がある場合は、必ずUpdateAsync
を利用しましょう。
データの取得
MemoryStoreのHashMapからデータを取得するには、MemoryStoreHashMap:GetAsync() を利用します。
-- プレイヤーのデータを取得
function PlayerDataManager:GetData(player: Player)
if not playerDataCache[player.UserId] then
local success, data = pcall(function()
return playerDataStore:GetAsync(tostring(player.UserId))
end)
if success and not data then
--初回
data = playerInitData
end
- -- playerDataCache[player.UserId] = data
+ -- MemoryStoreにキャッシュを保存する
+ if data then
+ PlayerDataManager:UpdateData(player, data)
+ end
end
- -- return playerDataCache[player.UserId]
+ local success, cacheData = pcall(function()
+ return memoryStoreHashMap:GetAsync(tostring(player.UserId))
+ end)
+
+ if success and cacheData then
+ print("Success GetAsync", player, cacheData)
+ else
+ warn("Failed to GetAsync", player)
+ end
+
+ return cacheData
end
GetAsync function | 名称 | 型 | 説明 |
---|---|---|---|
引数 | key | string | 取得したい値のキー。 |
戻り値 | value | Variant | キーに対して保存された値を返す。キーに対して保存されたデータがない場合は、それぞれnilになる。 |
複数のキーと値のペアを取得
今回のキャッシュの例では使用しませんが、1つずつではなく複数のデータを取得したい場合は MemoryStoreHashMap:ListItemsAsync() を使用します。
-- Service
local MemoryStoreService = game:GetService("MemoryStoreService")
local sampleHashMap = MemoryStoreService:GetHashMap("SampleHashMap")
local success, pages = pcall(function()
return sampleHashMap:ListItemsAsync(100)
end)
if success then
while true do
-- HashMap内のデータを全表示
local entries = pages:GetCurrentPage()
for _, entry in ipairs(entries) do
print(entry.key, entry.value)
end
if pages.IsFinished then
break
else
pages:AdvanceToNextPageAsync()
end
end
end
ListItemsAsync function | 名称 | 型 | 説明 |
---|---|---|---|
引数 | count | number | 取得したいアイテムの最大数。有効な範囲は1~200。 |
戻り値 | MemoryStoreHashMapPages | MemoryStoreHashMapPages | 項目をMemoryStoreHashMapPagesインスタンスとして列挙するMemoryStoreHashMapPagesインスタンス。 |
取得できるアイテムの個数は1~200なので注意しましょう。
戻り値として取得したMemoryStoreHashMapPagesは、アイテムのキーと値のペアを含むインスタンスです。
パラメータやメソッドなどは基本的なPageインスタンスと同じになっています。
注意点としてAPIのリクエスト数は [スキャンされたパーティションの数] + [返されたアイテム数] 分消費します。
ListItemAsyncを使用する際は特にAPIのリクエスト数に注意しましょう。
データの削除
MemoryStoreHashMapからデータを削除するには、MemoryStoreHashMap:RemoveAsync() を使用します。
-- キャッシュを削除する
function PlayerDataManager:ReleaseCache(player: Player)
- --if playerDataCache[player.UserId] then
- -- playerDataCache[player.UserId] = nil
- --end
+ local success, errorMessage = pcall(function()
+ memoryStoreHashMap:RemoveAsync(tostring(player.UserId))
+ end)
+
+ if not success and errorMessage then
+ warn("Failed to RemoveAsync", player, errorMessage)
+ else
+ print("Success RemoveAsync", player)
+ end
end
RemoveAsync function | 名称 | 型 | 説明 |
---|---|---|---|
引数 | key | string | 削除したいキー。 |
戻り値 | - | - | なし。 |
データを設定する際に有効期限も設定していますが、それが経過する前に削除したい場合は利用しましょう。
エクスペリエンス全体で使用できるメモリ量が決まっているので無駄なデータを省くことで最適化できます。
MemoryStoreHashMap:ListItemsAsync()
と組み合わせることで全データの削除なども行えます。
キャッシュの変更
ここまでの内容をもとにキャッシュにあるデータの変更を行ってみます。
-- 経験値を追加する
function PlayerDataManager:AddExp(player: Player, amount: number)
- --if not playerDataCache[player.UserId] then
- -- return
- --end
- ---- 経験値を追加する
- --playerDataCache[player.UserId].Exp += amount
+ local cacheData = PlayerDataManager:GetData(player)
+
+ if cacheData then
+ -- 経験値を追加する
+ cacheData.Exp += amount
+ end
+
+ PlayerDataManager:UpdateData(player, cacheData)
end
キャッシュしてあるデータを取得し、データを変更して改めてキャッシュを更新します。
これにより不要なメモリがサーバー上に残らずキャッシュすることができます。
HashMapを用いたプレイヤーデータのキャッシュ 完成
これでプレイヤーデータをMemoryStoreのHashMapを用いてキャッシュするModuleが完成しました。
ただし、前述したとおりキャッシュの仕組みにフォーカスしたコードになっているので、DataStoreの処理をpcallで囲むであったり、失敗時のリトライ処理などは意図的に省いたコードになっています。
ご注意ください。
最終的なコードは下記になります。
最終的なコード
-- プレイヤーのデータの管理(キャッシュの仕組みにフォーカス)
local DataStoreService = game:GetService("DataStoreService")
local MemoryStoreService = game:GetService("MemoryStoreService")
local playerDataStore = DataStoreService:GetDataStore("PlayerData")
local memoryStoreHashMap = MemoryStoreService:GetHashMap("PlayerDataCache")
-- データひとつに対する有効期限(秒)
local EXPIRATION = 60 * 5
-- プレイヤーデータのキャッシュ(DataStore の読み書きは省略)
local playerDataCache: { [number]: any } = {}
-- 初回のプレイヤーデータ
local playerInitData = {
Level = 1,
Exp = 0,
Coins = 1000,
LastLogin = DateTime.now().UnixTimestamp
}
local PlayerDataManager = {}
-- データをDataStoreに書き込む
function PlayerDataManager:SetData(player: Player, data: any)
playerDataStore:SetAsync(tostring(player.UserId), data)
local success, result = pcall(function()
return memoryStoreHashMap:SetAsync(tostring(player.UserId), data, EXPIRATION)
end)
if success and result then
print("Success SetAsync", player, data)
else
warn("Failed to SetAsync", player, result)
end
end
-- データをDataStoreにUpdateAsync()を利用して書き込む
function PlayerDataManager:UpdateData(player: Player, data: any)
playerDataStore:SetAsync(tostring(player.UserId), data)
local success, newValue = pcall(function()
return memoryStoreHashMap:UpdateAsync(
tostring(player.UserId),
function(oldValue: any)
if oldValue == nil then
print("Set New Data")
end
return data
end,
EXPIRATION)
end)
if success and newValue then
print("Success UpdateAsync", player, newValue)
else
warn("Failed to UpdateAsync", player)
end
end
-- プレイヤーのデータを取得
function PlayerDataManager:GetData(player: Player)
if not playerDataCache[player.UserId] then
local success, data = pcall(function()
return playerDataStore:GetAsync(tostring(player.UserId))
end)
if success and not data then
--初回
data = playerInitData
end
-- MemoryStoreにキャッシュを保存する
if data then
PlayerDataManager:UpdateData(player, data)
end
end
local success, cacheData = pcall(function()
return memoryStoreHashMap:GetAsync(tostring(player.UserId))
end)
if success and cacheData then
print("Success GetAsync", player, cacheData)
else
warn("Failed to GetAsync", player)
end
return cacheData
end
-- キャッシュを削除する
function PlayerDataManager:ReleaseCache(player: Player)
local success, errorMessage = pcall(function()
memoryStoreHashMap:RemoveAsync(tostring(player.UserId))
end)
if not success and errorMessage then
warn("Failed to RemoveAsync", player, errorMessage)
else
print("Success RemoveAsync", player)
end
end
-- 経験値を追加する
function PlayerDataManager:AddExp(player: Player, amount: number)
local cacheData = PlayerDataManager:GetData(player)
if cacheData then
-- 経験値を追加する
cacheData.Exp += amount
end
PlayerDataManager:UpdateData(player, cacheData)
end
return PlayerDataManager
最適化
MemoryStoreのHashMapは設定するアイテムの個数に制限はありません。
HashMapでは自動的にストレージを細分化したパーティション毎にアイテムを分散して保存します。
この性質上、リクエストが集中した場合などにスロットリングが発生する場合があります。
テーブル型などでネストされた情報を扱う際は、フィールドごとに保存することがおすすめされています。
local MemoryStoreService = game:GetService("MemoryStoreService")
local memoryStoreHashMap = MemoryStoreService:GetHashMap("PlayerDataChace")
local data = {
Level = 1,
Experience = 0,
Coins = 1000,
LastLogin = DateTime.now().UnixTimestamp
}
game:GetService("Players").PlayerAdded:Connect(function(player: Player)
local expiration = 60
memoryStoreHashMap:SetAsync(player.UserId.."_Level", data.Level, expiration)
memoryStoreHashMap:SetAsync(player.UserId.."_Experience", data.Experience, expiration)
memoryStoreHashMap:SetAsync(player.UserId.."_Coins", data.Coins, expiration)
memoryStoreHashMap:SetAsync(player.UserId.."_LastLogin", data.LastLogin, expiration)
local level = memoryStoreHashMap:GetAsync(player.UserId.."_Level")
local experience = memoryStoreHashMap:GetAsync(player.UserId.."_Experience")
local coins = memoryStoreHashMap:GetAsync(player.UserId.."_Coins")
local lastLogin = memoryStoreHashMap:GetAsync(player.UserId.."_LastLogin")
print(level, experience, coins, lastLogin)
end)
上記のコードのようにフィールドごとにキーを変更することで、自動シャーディングの恩恵を受けスロットリングの可能性を軽減することができます。
メモリの使用状況やエラー状況を確認したい場合は、ダッシュボードから確認できます。
冒頭の「MemoryStoreの概要」のリンクからご覧いただけます。
まとめ
-
MemoryStoreのHashMapは単純なデータのキャッシュや大量のアイテム(1000以上)を保持し高速にアクセスが必要とする場合などに役立つ。
-
MemoryStoreでHashMapを使用するには、MemoryStore:GetHashMap() を実行しMemoryStoreHashMapを取得する必要がある。
-
MemoryStoreのHashMapに値を追加するには、MemoryStoreHashMap:SetAsync() を使用する。
-
MemoryStoreHashMap:UpdateAsync() は
MemoryStoreHashdMap:SetAsync()
に比べて、他サーバーとの競合を回避ししつつ値を変更・追加できる。
ただし、APIのリクエスト制限が肥大する可能性があるので注意が必要。 -
MemoryStoreのHashMapの値を取得するには、MemoryStoreHashMap:GetAsync() を使用する。
-
MemoryStoreのHashMapから複数のデータを取得したい場合は、MemoryStoreHashMap:ListItemsAsync() を使用しする。
-
MemoryStoreHashMap:ListItemsAsync()
は1~200個のアイテムを取得できるが、その分APIのリクエスト制限数を消費するので注意が必要。 -
MemoryStoreのHashMapからデータを削除するには、MemoryStoreHashMap:RemoveAsync() を使用します。
-
テーブル型などでネストされた情報を扱う際は、フィールドごとに保存する方がスロットリングの軽減につながる。
長くなりましたが以上になります。
プレイヤーデータのキャッシュについてはMemoryStoreを必ず使用したほうがいいわけではないのですが、パフォーマンスを改善したい場合には一つの有効な手段だと思います。
ぜひMemoryStoreのHashMapを使用してみてください!!!
参考

当社ではRobloxを活用したゲームの開発、 また企業の商品やサービスの認知度拡大に寄与する3Dワールドの制作など、 Robloxにおける様々な活用支援を行っております。 Robloxのコンテンツ開発をご検討されている企業様は、お気軽にご相談ください。 landho.co.jp/
Discussion