实现玩家数据和购买系统

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

背景

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

大多数 Roblox 游戏使用这些 API 实现某种形式的玩家数据系统。这些实现的方式各不相同,但通常旨在解决相同的一系列问题。

常见问题

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

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

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

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

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

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

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

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

  • 原子购买处理: 原子性地验证、奖励和记录购买,以防止物品丢失或被多次奖励。

示例代码

Roblox 提供了参考代码,以帮助你设计和构建玩家数据系统。本页的其余部分将探讨背景、实现细节和一般注意事项。


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

显示购买系统模型的资源管理器窗口。

架构

此高层次图示说明了示例中的关键系统及其如何与游戏其余部分的代码进行交互。

代码示例的架构图。

重试

Class: 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 请求很重要,因为它们与状态交互。考虑以下场景:

  1. 请求 A 被发出以将键 K 的值设置为 1。
  2. 请求失败,因此计划在退避延迟后重试。
  3. 在重试发生之前,请求 B 将 K 的值设置为 2,但请求 A 的重试立即覆盖此值并将 K 设置为 1。

即使 UpdateAsync 在键的最新值上操作,UpdateAsync 请求仍必须按顺序处理,以避免无效的瞬态状态(例如,购买在处理之前减少了硬币,导致硬币为负)。

我们的玩家数据系统使用一个新类 DataStoreWrapper,它提供保证按键顺序处理的可让步重试。

方法

一个说明重试系统的过程图

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

这些方法在调用时:

  1. 将请求添加到队列中。每个键都有自己的队列,请求按顺序和串行处理。请求线程在请求完成之前会让步。

    此功能基于 ThreadQueue 类,这是一个基于协程的任务调度器和速率限制器。ThreadQueue 不返回承诺,而是让当前线程在操作完成之前让步,并在失败时抛出错误。这与习惯用法的异步 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, "Removed from queue" 还是不返回并丢弃活动线程。这两种方法都有其缺点,并将额外的复杂性转移给消费者。

最终,我们的观点是,简单的方法(处理每个请求)在这里是更可取的,并在处理复杂问题(如会话锁定)时创建了一个更清晰的环境。唯一的例外是在 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 请求——无论是 GetAsync、SetAsync 还是 UpdateAsync——都通过 UpdateAsync。这是因为 UpdateAsync 允许键原子性地被读取和写入。也可以根据读取的值放弃写入,通过在转换回调中返回 nil。

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

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

    • 键的元数据对象不包括在锁过期时间内最后更新的未识别的 LockId 值。这考虑到尊重由其他服务器放置的锁,并在锁过期时忽略该锁。

    • 如果该服务器之前在键的元数据中放置了自己的 LockId 值,则该值仍在键的元数据中。这考虑到另一台服务器接管了该服务器的锁(通过过期或强制)并随后释放了它。换句话说,即使 LockId 为 nil,另一台服务器仍然可以在你锁定键的时间内替换并移除锁。

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

  3. 根据请求中传递的参数,UpdateAsync 要么锁定键,要么解锁键:

    1. 如果要锁定键,UpdateAsync 将键的元数据中的 LockId 设置为一个 GUID。此 GUID 存储在服务器的内存中,以便在下次访问该键时进行验证。如果服务器已经对该键有锁,则不进行更改。它还安排一个任务,如果你在锁的过期时间内不再访问该键,则会警告你。

    2. 如果要解锁键,UpdateAsync 将键的元数据中的 LockId 移除。

一个自定义重试处理程序被传递到底层的 DataStoreWrapper,以便在步骤 1 中由于会话被锁定而中止时重试操作。

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

注意事项

会话锁定机制依赖于服务器在完成对键的操作后始终释放其锁。这应该始终通过在 PlayerRemoving 或 BindToClose() 中的最终写入指令来完成。

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

  • 服务器崩溃或 DataStoreService 在所有尝试访问该键时不可用。
  • 由于逻辑错误或类似的 bug,未发出解锁键的指令。

为了保持对键的锁定,必须在其加载到内存中时定期访问它。这通常是在大多数玩家数据系统中后台运行的自动保存循环的一部分,但该系统还公开了一个 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.Server, PlayerData.Client

背景

提供接口以便代码同步读取和写入玩家会话数据的模块在 Roblox 游戏中很常见。本节涵盖 PlayerData.Server 和 PlayerData.Client。

方法

PlayerData.Server 和 PlayerData.Client 处理以下内容:

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

加载玩家数据

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

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

    另一种选择是踢出玩家,但我们建议让玩家使用默认数据进行游戏,并清晰地传达发生了什么,而不是将他们从游戏中移除。

  2. 向 PlayerDataClient 发送初始有效负载,包含加载的数据和错误状态(如果有)。

  3. 任何使用 waitForDataLoadAsync 为玩家让步的线程都会恢复。

为服务器代码提供接口

  • PlayerDataServer 是一个单例,可以被任何在同一环境中运行的服务器代码所要求和访问。
  • 玩家数据组织成键值对字典。你可以使用 setValue、getValue、updateValue 和 removeValue 方法在服务器上操作这些值。这些方法都是同步操作,不会让步。
  • hasLoaded 和 waitForDataLoadAsync 方法可用于确保数据在访问之前已加载。我们建议在加载屏幕期间执行此操作一次,以避免在与客户端数据的每次交互之前检查加载错误。
  • hasErrored 方法可以查询玩家的初始加载是否失败,导致他们使用默认数据。在允许玩家进行任何购买之前检查此方法,因为没有成功加载就无法将购买保存到数据中。
  • 每当玩家的数据发生更改时,playerDataUpdated 信号会触发,携带 player、key 和 value。各个系统可以订阅此信号。

将更改复制到客户端

  • PlayerDataServer 中对玩家数据的任何更改都会复制到 PlayerDataClient,除非该键被标记为私有,使用 setValueAsPrivate。
    • setValueAsPrivate 用于标记不应发送到客户端的键。
  • PlayerDataClient 包含获取键值的方法(get)和更新时触发的信号(updated)。还包括 hasLoaded 方法和 loaded 信号,以便客户端可以等待数据加载并复制后再启动其系统。
  • PlayerDataClient 是一个单例,可以被任何在同一环境中运行的客户端代码所要求和访问。

将错误复制到客户端

  • 在保存或加载玩家数据时遇到的错误状态会复制到 PlayerDataClient。
  • 使用 getLoadError 和 getSaveError 方法访问此信息,以及 loaded 和 saved 信号。
  • 有两种类型的错误:DataStoreError(DataStoreService 请求失败)和 SessionLocked(见 会话锁定)。
  • 使用这些事件禁用客户端购买提示并实现警告对话框。此图显示了可能在玩家数据加载失败时显示的示例对话框:
显示玩家数据加载失败时可能显示的示例警告的截图

保存玩家数据

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

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

    示例在 AUTO_SAVE_INTERVAL 秒(默认 180 秒)后启动一个共享循环,然后并行保存每个加载的玩家。该循环不偏移服务器或玩家,因此在相似时间启动的服务器可以一起刷新。

    通过在间隔内的随机持续时间偏移每个玩家的第一次保存,以便实时服务器不会同时写入:

    local AUTO_SAVE_INTERVAL = 180
    local function startAutoSave(player)
    task.spawn(function()
    task.wait(math.random() * AUTO_SAVE_INTERVAL)
    while player.Parent do
    if canSave(player) then
    savePlayerData(player)
    end
    task.wait(AUTO_SAVE_INTERVAL)
    end
    end)
    end
  3. 当收到关闭服务器的请求时,在 BindToClose 回调中发生以下情况:

    1. 发出请求以保存服务器中每个玩家的数据,遵循玩家离开服务器时通常经过的过程。这些请求是并行发出的,因为 BindToClose 回调只有 30 秒的时间来完成。
    2. 为了加快保存,清除每个键队列中所有其他请求,以便在底层的 DataStoreWrapper 中(见 重试)。
    3. 回调在所有请求完成之前不会返回。
©2026 Roblox Corporation、Roblox、Roblox 标志及 Powering Imagination 是我们在美国及其他国家或地区的注册与未注册商标。