背景
Roblox 提供了一组 API 通过 DataStoreService 与数据存储进行交互。这些 API 的最常见用例是保存、加载和复制 玩家数据。也就是说,与玩家的进度、购买和其他会话特征关联的数据,这些数据会在不同的游戏会话之间持久化。
大多数 Roblox 游戏都使用这些 API 来实现某种形式的玩家数据系统。这些实现方式有所不同,但通常旨在解决相同的一系列问题。
常见问题
以下是一些玩家数据系统尝试解决的最常见问题:
内存访问: DataStoreService 请求会进行 Web 请求,这些请求是异步操作并受到速率限制。这适合于会话开始时的初始加载,但不适合在正常游戏过程中进行高频率的读和写操作。大多数开发者的玩家数据系统会将这些数据存储在 Roblox 服务器的内存中,将 DataStoreService 请求限制在以下场景中:
- 会话开始时的初始读取
- 会话结束时的最终写入
- 在一个时间间隔内进行周期性写入,以缓解最终写入失败的情况
- 在处理购买时进行写入,以确保数据被保存
高效存储: 将所有玩家的会话数据存储在一个表中可以让您原子性地更新多个值,并以更少的请求处理相同数量的数据。这还消除了值之间不同步的风险,并使回滚更容易处理。
一些开发者还实现了自定义序列化,以压缩大数据结构(通常用于保存游戏内用户生成的内容)。
复制: 客户端需要定期访问玩家数据(例如,更新 UI)。将玩家数据复制到客户端的通用方法可以让您在不需要为每个数据组件创建定制复制系统的情况下传输这些信息。开发者通常希望能够选择性地复制哪些数据,哪些不复制到客户端。
错误处理: 当无法访问数据存储时,大多数解决方案会实现重试机制和 fallback 到 “默认” 数据。特别注意确保 fallback 数据不会后续覆盖 “真实” 数据,并且适当地将其告知玩家。
重试: 当数据存储无法访问时,大多数解决方案会实现重试机制和 fallback 到默认数据。特别注意确保 fallback 数据不会后续覆盖 “真实” 数据,并适当地将情况通知玩家。
会话锁定: 如果一个玩家的数据在多个服务器上被加载并存储在内存中,则可能会出现一个服务器保存过时信息的问题。这可能导致数据丢失和常见的物品重复漏洞。
原子购买处理: 原子性地验证、奖励和记录购买,以防止物品丢失或多次奖励。
示例代码
Roblox 有参考代码以帮助您设计和构建玩家数据系统。本页的其余部分将探讨背景、实现细节和常见注意事项。
将模型导入 Studio 后,您应该会看到以下文件夹结构:

架构
此高级图示说明了示例中的关键系统以及它们如何与游戏中其余代码交互。

重试
背景
由于 DataStoreService 在内部进行 Web 请求,它的请求并不保证成功。当这种情况发生时,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,它提供保证按键顺序处理的可 yield 重试。
方法

DataStoreWrapper 提供了与 DataStore 方法相对应的方法:DataStore:GetAsync()、DataStore:SetAsync()、DataStore:UpdateAsync() 和 DataStore:RemoveAsync()。
当调用这些方法时:
将请求添加到队列。每个键都有自己的队列,请求按顺序和串行处理。请求线程在请求完成之前被挂起。
此功能基于 ThreadQueue 类,这是一个基于 coroutine 的任务调度器和速率限制器。它不会返回一个 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
背景
玩家数据存储在服务器的内存中,仅在必要时才从底层数据存储中读取和写入。您可以即时读取和更新内存中的玩家数据,而无需进行 Web 请求,从而避免超过 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 操作。例如,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 是一个单例,可以被在同一环境中运行的任何服务器代码所 require 和访问。
- 玩家数据被组织为一个键值字典。您可以使用 setValue、getValue、updateValue 和 removeValue 方法在服务器上操作这些值。所有这些方法都是同步操作而不挂起。
- hasLoaded 和 waitForDataLoadAsync 方法可确保数据在访问之前已加载。我们建议在加载屏幕中检测一次,以避免在与数据交互的过程中检查加载错误。
- 一个 hasErrored 方法可以查询玩家的初始加载是否失败,导致他们使用默认数据。在允许玩家进行任何购买之前检查此方法,因为在未成功加载时,购买无法保存到数据中。
- 每当玩家的数据发生更改时,playerDataUpdated 信号将以 player、key 和 value 的形式触发。各个系统可以对此进行订阅。
将更改复制到客户端
- 在 PlayerDataServer 中对玩家数据的任何更改都会复制到 PlayerDataClient,除非该键被标记为使用 setValueAsPrivate 的私有键。
- setValueAsPrivate 用于标记不应发送到客户端的键
- PlayerDataClient 包含一个获取键值的方法(get)和一个在更新时触发的信号(updated)。还包括一个 hasLoaded 方法和一个 loaded 信号,因此客户端可以等待数据加载和复制后再启动其系统。
- PlayerDataClient 是一个单例,可以被在同一环境中运行的任何客户端代码所 require 和访问。
将错误复制到客户端
- 保存或加载玩家数据时遇到的错误状态将复制到 PlayerDataClient。
- 使用 getLoadError 和 getSaveError 方法以及 loaded 和 saved 信号访问这些信息。
- 利用这些事件禁用客户端购买提示并实现警告对话框。此图显示了一个示例对话框:

保存玩家数据

当玩家离开游戏时,系统执行以下步骤:
- 检查是否可以安全地将玩家的数据写入数据存储。出现不安全的情况包括玩家数据加载失败或仍在加载过程中。
- 通过 SessionLockedDataStoreWrapper 发出请求,将当前内存数据值写入数据存储并在完成后移除会话锁定。
- 从服务器内存中清除玩家数据(以及其他变量,如元数据和错误状态)。
在一个周期性循环中,服务器将每个玩家的数据写入数据存储(前提是安全保存)。这种欢迎的冗余可以缓解服务器崩溃造成的数据丢失,并且还维持会话锁定是必要的。
收到关闭服务器的请求时,在 BindToClose 回调中发生以下情况:
- 进行请求以保存服务器中的每个玩家数据,按照玩家离开服务器时通常经历的过程。这些请求是并行进行的,因为 BindToClose 回调只有 30 秒的时间来完成。
- 为加快保存,清除每个键队列中底层 DataStoreWrapper 的所有其他请求(见 重试)。
- 在所有请求完成之前,回调不会返回。