实现玩家数据和购买系统

*此内容使用人工智能(Beta)翻译,可能包含错误。若要查看英文页面,请点按 此处

背景

Roblox 提供了一组 API 用于通过 DataStoreService 与数据存储交互。这些 API 最常见的使用案例是保存、加载和复制 玩家数据。也就是说,与玩家的进程、购买和其他在个别游戏会话之间持久保存的特征相关的数据。

Roblox 上的大多数体验都使用这些 API 来实现某种形式的玩家数据系统。这些实现方式有所不同,但通常旨在解决相同的一系列问题。

常见问题

以下是玩家数据系统尝试解决的一些最常见的问题:

  • 内存访问: DataStoreService 请求进行异步操作的网络请求,并受到速率限制。这对于会话开始时的初始加载是合适的,但不适合在正常游戏过程中进行高频率的读写操作。大多数开发者的玩家数据系统将这些数据保存在 Roblox 服务器的内存中,将 DataStoreService 请求限制在以下场景:

    • 会话开始时的初始读取
    • 会话结束时的最终写入
    • 定期写入,以缓解最终写入失败的情况
    • 在处理购买时确保数据被保存的写入
  • 有效存储: 将玩家的所有会话数据存储在单个表中,可以原子性地更新多个值,并以更少的请求处理相同数量的数据。它还消除了值之间失去同步的风险,并使回滚更容易推理。

    一些开发者还实现了自定义序列化,以压缩大型数据结构(通常用于保存游戏内用户生成的内容)。

  • 复制: 客户端需要定期访问玩家的数据(例如,为了更新 UI)。一种通用的方法将玩家数据复制到客户端,可以在不需要为每个数据组件创建定制复制系统的情况下传输这些信息。开发者通常希望选择性地决定哪些数据被复制到客户端。

  • 错误处理: 当无法访问 DataStores 时,大多数解决方案将实现重试机制和回退到“默认”数据的功能。需要特别注意,以确保回退数据不会后续覆盖“真实”数据,并适当地传达给玩家。

  • 重试: 当数据存储无法访问时,大多数解决方案实现了重试机制和回退到默认数据的功能。请特别注意确保回退数据不会后续覆盖“真实”数据,并适当地将情况传达给玩家。

  • 会话锁定: 如果单个玩家的数据在多个服务器上被加载并存储在内存中,则可能会出现一个服务器保存过时信息的问题。这可能导致数据丢失和常见项目重复漏洞。

  • 原子购买处理: 原子性地验证、授予和记录购买,以防止项目丢失或多次授予。

示例代码

Roblox 有参考代码来帮助您设计和构建玩家数据系统。本文的其余部分探讨了背景、实施细节和一般注意事项。


将模型导入 Studio 后,您应该会看到以下文件夹结构:

Explorer 窗口显示购买系统模型。

架构

此高层次的图示说明了示例中的关键系统以及它们如何与体验中的其他代码进行接口。

代码示例的架构图。

重试

Class: DataStoreWrapper

背景

由于 DataStoreService 在后台进行网络请求,它的请求并不能保证成功。当发生这种情况时,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 请求非常重要,因为它们与状态交互。考虑以下场景:

  1. 请求 A 希望设置键 K 的值为 1。
  2. 请求失败,因此计划在 2 秒后重试。
  3. 在重试发生之前,请求 B 将 K 的值设置为 2,但请求 A 的重试立即覆盖此值并将 K 设置为 1。

即使 UpdateAsync 基于键的最新版本操作,UpdateAsync 请求仍必须按顺序处理,以避免无效的瞬态状态(例如,购买在硬币增加之前扣除硬币,导致硬币为负)。

我们的玩家数据系统使用一个新类 DataStoreWrapper,提供确保按键顺序处理的协作重试。

方法

一个流程图,说明重试系统

DataStoreWrapper 提供与 DataStore 方法相对应的方法:DataStore:GetAsync()DataStore:SetAsync()DataStore:UpdateAsync()DataStore:RemoveAsync()

这些方法在调用时:

  1. 将请求添加到队列中。每个键都有自己的队列,请求按顺序处理且串行执行。请求线程在请求完成之前处于等待状态。

    此功能基于 ThreadQueue 类,这是一个基于协作的任务调度器和速率限制器。ThreadQueue 并不会返回 Promise,而是使当前线程在操作完成之前处于等待状态,如果操作失败则抛出错误。这与习惯的 Luau 异步模式更加一致。

  2. 如果请求失败,它将以可配置的指数退避机制进行重试。这些重试是提交给 ThreadQueue 的回调的一部分,因此它们在队列中开始下一个请求之前必须完成。

  3. 当请求完成时,请求方法将以 success, result 模式返回。

DataStoreWrapper 还暴露了获取特定键的队列长度和清理过时请求的方法。后者在服务器关闭且没有时间处理除最近请求以外的请求时特别有用。

注意事项

DataStoreWrapper 遵循一个原则,即在极端情况下外,应该允许每个数据存储请求完成(成功或失败),即使更近期的请求使其变得多余。当新请求发生时,过时请求不会从队列中移除,而是允许它们在新请求开始之前完成。这一理念根植于该模块作为通用数据存储工具的适用性,而不是特定于玩家数据工具,具体如下:

  1. 难以决定何时安全移除请求的直观规则。考虑以下队列:

    Value=0, SetAsync(1), GetAsync(), SetAsync(2)

    预期行为是 GetAsync() 将返回 1,但如果我们因最近请求而从队列中移除 SetAsync(),则它将返回 0

    合理的推理是,当添加新的写请求时,仅将过时的请求修剪到最近的读取请求为止。UpdateAsync(),是最常见的操作(也是该系统使用的唯一操作),可以进行读取和写入,因此在不增加额外复杂性的情况下,难以在此设计中调和。

    DataStoreWrapper 可能要求您指定是否允许 UpdateAsync() 请求进行读取和/或写入,但这对我们的玩家数据系统没有适用性,因为由于会话锁定机制(稍后将详细介绍),因此无法提前确定。

  2. 一旦从队列中移除,如何处理这一点的直观规则也难以确定。当 DataStoreWrapper 请求被发出时,当前线程将被挂起,直到请求完成。如果我们从队列中移除过时请求,则必须决定是返回 false, "从队列中移除",还是不返回并丢弃活动线程。这两种方法各有其缺点,并将额外的复杂性转嫁给使用者。

最终,我们的观点是,这里简单的方法(处理每个请求)是更可取的,并在处理复杂问题(如会话锁定)时创造了一个更清晰的导航环境。唯一的例外是在 DataModel:BindToClose() 期间,在此期间清除队列是必要的,以便及时保存所有用户的数据,而个别功能调用返回的值不再是一个持续的担忧。为此,我们暴露了 skipAllQueuesToLastEnqueued 方法。更多上下文,请参阅 玩家数据

会话锁定

Class: SessionLockedDataStoreWrapper

背景

玩家数据存储在服务器内存中,仅在必要时从底层数据存储读取和写入。您可以在内存中即时读取和更新玩家数据,而无需进行网络请求,从而避免超过 DataStoreService 的限制。

为了使此模型按预期工作,至关重要的是,任何时候都不能有超过一个服务器能够从 DataStore 加载玩家的数据。

例如,如果服务器 A 加载了玩家的数据,服务器 B 在服务器 A 释放其最终保存锁之前无法加载该数据。如果没有锁定机制,服务器 B 可能在服务器 A 有机会保存其在内存中的更改版本之前,从数据存储中加载过时的玩家数据。然后如果服务器 A 在服务器 B 加载过时数据后保存其更新的数据,服务器 B 会在下次保存时覆盖该更新数据。

即使 Roblox 仅允许客户端同时连接到一个服务器,您也不能假设一个会话的数据总是会在下一个会话开始之前保存。考虑玩家离开服务器 A 时可能发生的以下场景:

  1. 服务器 A 发出一个 DataStore 请求以保存他们的数据,但请求失败,且需要几次重试才能成功完成。在重试期间,玩家加入了服务器 B。
  2. 服务器 A 对同一键进行了过多的 UpdateAsync() 调用并被限制。最终保存请求被放入队列中。在请求排队期间,玩家加入了服务器 B。
  3. 在服务器 A 上,与 PlayerRemoving 事件相关的某些代码在玩家数据保存之前处于挂起状态。在该操作完成之前,玩家加入了服务器 B。
  4. 服务器 A 的性能下降到最终保存被延迟到玩家加入服务器 B 之后的程度。

这些场景应该是罕见的,但它们确实会发生,特别是在玩家快速离开一个服务器并连接到另一个服务器的情况下(例如,在传送时)。一些恶意用户甚至可能尝试利用这种行为来在不持久化的情况下完成操作。这在允许玩家进行交易的游戏中可能特别具有影响力,并且是常见的物品复制漏洞的来源。

会话锁定通过确保当玩家的 DataStore 密钥首次被服务器读取时,服务器在同一 UpdateAsync() 调用中原子性地将锁写入密钥的元数据。如果在任何其他服务器尝试读取或写入密钥时该锁值存在,则服务器不继续。

方法

一个流程图,说明会话锁定系统

SessionLockedDataStoreWrapper 是对 DataStoreWrapper 类的元包装器。DataStoreWrapper 提供排队和重试功能,而 SessionLockedDataStoreWrapper 则补充了会话锁定。

SessionLockedDataStoreWrapper 将每个 DataStore 请求——无论是 GetAsyncSetAsync 还是 UpdateAsync——通过 UpdateAsync 处理。这是因为 UpdateAsync 允许一个键被原子性地读取和写入。也可以在读取的值的基础上选择放弃写入,通过在转换回调中返回 nil

传递给 UpdateAsync 的转换函数为每个请求执行以下操作:

  1. 验证密钥是否可以安全访问,如果不安全则放弃该操作。“安全访问”意味着:

    • 密钥的元数据对象不包括在锁定过期时间内最后更新的不被识别的 LockId 值。这考虑到尊重由其他服务器设置的锁定以及在锁定过期时忽略该锁定。

    • 如果该服务器以前在密钥的元数据中放置了自己的 LockId 值,则该值仍在密钥的元数据中。这考虑到另一台服务器在过期或强制的情况下接管了该服务器的锁定并后来释放该锁定的情况。换言之,即使 LockIdnil,在您锁定密钥的期间,另一台服务器仍可能替换并移除锁定。

  2. UpdateAsync 执行用户对 SessionLockedDataStoreWrapper 请求的 DataStore 操作。例如,GetAsync() 转换为 function(value) return value end

  3. 根据请求中传入的参数,UpdateAsync 要么锁定,要么解锁该密钥:

    1. 如果密钥要被锁定,UpdateAsync 在密钥的元数据中设置 LockId 为一个 GUID。该 GUID 存储在服务器的内存中,以便在下次访问密钥时进行验证。如果服务器已经对此密钥进行锁定,则不作更改。它还调度一个任务以警告您,如果您在锁定过期时间内没有再次访问该密钥,则锁定可能失效。

    2. 如果密钥要被解锁,UpdateAsync 将删除密钥的元数据中的 LockId

一个自定义重试处理程序被传递到底层的 DataStoreWrapper,以便如果由于会话被锁定而中止操作,则会进行重试。

一个自定义错误消息也会返回给使用者,允许玩家数据系统在会话锁定的情况下向客户端报告替代的错误。

注意事项

会话锁定机制依赖于服务器在完成对密钥的使用后始终释放其锁定。这应该始终通过在 PlayerRemovingBindToClose() 中的最终写入过程中的解锁指令完成。

然而,在某些情况下,解锁可能会失败。例如:

  • 服务器崩溃或 DataStoreService 在所有访问该密钥的尝试中无效。
  • 由于逻辑错误或类似的错误,未发出解锁键的指令。

为了维护对密钥的锁定,您必须在内存中定期访问它,直到它被加载。这通常是在大多数玩家数据系统的后台自动保存循环中完成的,但如果您需要手动执行此操作,本系统还暴露了 refreshLockAsync 方法。

如果锁定过期时间超过而未更新锁定,则任何服务器均可接管该锁定。如果另一台服务器接管了锁定,则当前服务器读取或写入该密钥的尝试将失败,除非建立新的锁定。

开发者产品处理

单例: ReceiptHandler

背景

ProcessReceipt 回调执行确定何时最终确认购买的关键任务。ProcessReceipt 在非常特定的场景下被调用。有关其保证的集合,请参见 MarketplaceService.ProcessReceipt

尽管“处理”购买的定义在不同体验中可能有所不同,但我们使用以下标准:

  1. 购买尚未被处理。

  2. 购买在当前会话中已反映。

  3. 购买已保存到 DataStore

    每次购买,即使是一次性可消耗品,都应反映在 DataStore 中,以便用户的购买历史记载在其会话数据中。

这需要在返回 PurchaseGranted 之前执行以下操作:

  1. 验证 PurchaseId 是否尚未记录为已处理。
  2. 在玩家的内存玩家数据中授予购买。
  3. 在玩家的内存玩家数据中记录 PurchaseId 为已处理。
  4. 将玩家的内存玩家数据写入 DataStore

会话锁定简化了此流程,因为您不再需要担心以下场景:

  • 当前服务器中的内存玩家数据可能过时,要求您在验证 PurchaseId 历史之前从 DataStore 获取最新值
  • 相同购买的回调在另一台服务器上运行,需要您在读取和写入 PurchaseId 历史并原子性地保存更新的玩家数据与购买之间进行操作,以防止竞争条件

会话锁定确保,如果成功写入玩家的 DataStore,则在此服务器中加载和保存的数据之间没有其他服务器成功读取或写入玩家的 DataStore。简而言之,此服务器中的内存玩家数据是可用的最新版本。还有一些注意事项,但它们不会影响此行为。

方法

ReceiptProcessor 中的注释概述了该方法:

  1. 验证玩家的数据当前是否被加载在该服务器上,并且加载没有任何错误。

    由于此系统使用会话锁定,这一检查还验证了内存数据是最新版本。

    如果玩家的数据尚未加载(这是玩家加入游戏时的预期情况),请等待玩家的数据加载。系统还会监听玩家在其数据加载之前离开游戏,因为如果玩家重新加入,则不应无限期挂起,并阻止该回调在当前服务器上再次被调用。

  2. 验证 PurchaseId 在玩家数据中尚未记录为已处理。

    由于会话锁定,系统内存中 PurchaseIds 数组是最新版本。如果 PurchaseId 被记录为已处理并且反映在已加载或保存到 DataStore 的值中,则返回 PurchaseGranted。如果它被记录为已处理,但 反映在 DataStore 中,则返回 NotProcessedYet

  3. 在此服务器中更新玩家数据以“授予”购买。

    ReceiptProcessor 采用通用回调方法,并为每个 DeveloperProductId 分配不同的回调。

  4. 在此服务器中更新玩家数据以存储 PurchaseId

  5. 提交请求将内存数据保存到 DataStore,如果请求成功则返回 PurchaseGranted。如果不成功,返回 NotProcessedYet

    如果此保存请求不成功,稍后的请求以保存玩家的内存会话数据仍可能成功。在下一个 ProcessReceipt 调用时,第 2 步处理此情况并返回 PurchaseGranted

玩家数据

单例: PlayerData.ServerPlayerData.Client

背景

提供游戏代码与玩家会话数据同步读取和写入的接口的模块在 Roblox 体验中很常见。本节介绍 PlayerData.ServerPlayerData.Client

方法

PlayerData.ServerPlayerData.Client 处理以下内容:

  1. 将玩家的数据加载到内存中,包括处理加载失败的情况
  2. 为服务器代码提供查询和更改玩家数据的接口
  3. 将玩家数据的变化复制到客户端,以便客户端代码可以访问
  4. 将加载和/或保存错误复制到客户端,以便它可以显示错误对话框
  5. 定期保存玩家的数据,在玩家离开时,以及当服务器关闭时

加载玩家数据

一个流程图,说明加载系统
  1. SessionLockedDataStoreWrapper 向数据存储发出一个 getAsync 请求。

    如果此请求失败,则使用默认数据,并且该配置文件标记为“错误”,以确保后续不会被写入到数据存储中。

    另一种选择是将玩家踢出,但我们建议让玩家使用默认数据并清晰地传达所发生的事情,而不是将他们移除体验。

  2. 将初始有效载荷发送到 PlayerDataClient,其中包含加载的数据和错误状态(如果有)。

  3. 使用 waitForDataLoadAsync 被挂起的任何线程将恢复。

为服务器代码提供接口

  • PlayerDataServer 是一个单例,可以被任何在同一环境中运行的服务器代码所引用和访问。
  • 玩家数据被组织成键值对字典。您可以在服务器上使用 setValuegetValueupdateValueremoveValue 方法操作这些值。这些方法都是同步操作,无需挂起。
  • 提供 hasLoadedwaitForDataLoadAsync 方法以确保数据在您访问之前已加载。我们建议在加载屏幕中进行一次此操作,然后再启动其他系统,以避免每次与客户端数据交互之前检查加载错误。
  • hasErrored 方法可以查询玩家的初始加载是否失败,导致其使用默认数据。在允许玩家进行任何购买之前检查此方法,因为没有成功加载,购买无法保存到数据中。
  • 每当玩家的数据被更改时,都会触发 playerDataUpdated 信号,携带 playerkeyvalue。各个系统可以对此进行订阅。

将更改复制到客户端

  • PlayerDataServer 中对玩家数据的任何更改都复制到 PlayerDataClient,除非该键使用 setValueAsPrivate 标记为私有
    • setValueAsPrivate 用于表示不应发送到客户端的键
  • PlayerDataClient 包含一个获取键值的方法(获取)和一个在更新时触发的信号(更新)。还包括 hasLoaded 方法和 loaded 信号,以便客户端可以在加载和复制数据之前等待
  • PlayerDataClient 是一个单例,可以被任何环境内的客户端代码引用和访问

将错误复制到客户端

  • 保存或加载玩家数据时遇到的错误状态将被复制到 PlayerDataClient
  • 使用 getLoadErrorgetSaveError 方法以及 loadedsaved 信号访问此信息。
  • 错误分为两类:DataStoreErrorDataStoreService 请求失败)和 SessionLocked(见 会话锁定)。
  • 使用这些事件禁用客户端购买提示并实现警告对话框。此图显示了一个示例对话框:
一 Screenshot 的示例警告,显示当玩家数据加载失败时可能显示的内容。

保存玩家数据

一个流程图,说明保存系统
  1. 当玩家离开游戏时,系统执行以下步骤:

    1. 检查是否可以安全地将玩家的数据写入数据存储。可能不安全的场景包括玩家的数据加载失败或仍在加载中。
    2. 通过 SessionLockedDataStoreWrapper 发出请求,将当前内存数据值写入数据存储,并在完成后移除会话锁定。
    3. 从服务器内存中清除玩家的数据(以及其他变量,如元数据和错误状态)。
  2. 在周期性循环中,服务器将每个玩家的数据写入数据存储(前提是可以安全保存)。这种欢迎的冗余可以减少服务器崩溃时的损失,并且也是维护会话锁定所必要。

  3. 当接收到关闭服务器的请求时,将在 BindToClose 回调中发生以下情况:

    1. 发出请求以保存服务器中的每个玩家数据,遵循玩家离开服务器时通常执行的过程。这些请求并行发出,因为 BindToClose 回调仅有 30 秒的时间完成。
    2. 为了加速保存,清除每个键队列中的所有其他请求(见 重试)。
    3. 在所有请求完成之前,回调不会返回。