🗺️

【Roblox】MemoryStore HashMapを利用したプレイヤーのデータのキャッシュ

に公開

はじめに

今回は、MemoryStoreのデータ構造の一つのHashMapをPlayerDataのキャッシュを例にして紹介します。

なお、MemoryStoreの概要については以下の記事をご覧ください。
https://zenn.dev/landho_roblox/articles/4d3fcbc648beb1

Roblox バージョン: 0.661.0.6610708

MemoryStoreHashMapについて

MemoryStoreのデータ構造は、SortedMap、Queue、HashMapと3種類あります。
共通して、設定するデータには有効期限があり最大で45日です。

利用するにあたって各種制限などもありますが、これは概要編にて記載したので省きます。
詳しく知りたい方は、冒頭の「MemoryStoreの概要」のリンクからご覧いただけます。

このデータ構造の一つであるHashMapでは、MemoryStoreHashMapインスタンスを利用してキーを基準に一時的にアイテムを保持することができます。

SortedMapやQueueと違い、アイテムの順番は保持されません。
その分、単純なデータのキャッシュや大量のアイテム(1000以上)を保持し高速なアクセスが必要な場合などに役立ちます。

今回はこれを利用してプレイヤーのデータをMemoryStoreHashMapを通じてキャッシュする仕組みの一例を紹介します。

プレイヤーデータのキャッシュ

Robloxでは永続的に保存したいデータはDataStoreを用いて管理することが多いです。
ただ、高頻度なアクセスがある場合はパフォーマンスが落ちる場合があります。
この時管理するデータをユーザーごとにコピーしてキャッシュしておくことで、パフォーマンスの向上やリクエスト数の減少が見込めます。

※下記コードはキャッシュの仕組みにフォーカスするため、DataStoreの処理などは簡略化しています。

PlayerDataManager
-- プレイヤーのデータの管理(キャッシュの仕組みにフォーカス)

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を取得する必要があります。

PlayerDataManager
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() を利用します。

PlayerDataManager
 -- データを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() が用意されています。

PlayerDataManager
-- データを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() を利用します。

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
		
+		-- 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() を使用します。

Sample
-- 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() を使用します。

PlayerDataManager
 -- キャッシュを削除する
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()と組み合わせることで全データの削除なども行えます。

キャッシュの変更

ここまでの内容をもとにキャッシュにあるデータの変更を行ってみます。

PlayerDataManager
 -- 経験値を追加する
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で囲むであったり、失敗時のリトライ処理などは意図的に省いたコードになっています。
ご注意ください。
最終的なコードは下記になります。

最終的なコード
PlayerDataManager
-- プレイヤーのデータの管理(キャッシュの仕組みにフォーカス)

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では自動的にストレージを細分化したパーティション毎にアイテムを分散して保存します。
この性質上、リクエストが集中した場合などにスロットリングが発生する場合があります。

テーブル型などでネストされた情報を扱う際は、フィールドごとに保存することがおすすめされています。

Sample
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を使用してみてください!!!

参考

https://create.roblox.com/docs/cloud-services/memory-stores/hash-map
https://create.roblox.com/docs/reference/engine/classes/MemoryStoreService
https://create.roblox.com/docs/reference/engine/classes/MemoryStoreHashMap
https://create.roblox.com/docs/reference/engine/classes/MemoryStoreHashMapPages

ランド・ホー Roblox開発チーム

Discussion