roblox-data

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese
<!-- Source: brockmartin/roblox-game-skill (MIT) -->
<!-- Source: brockmartin/roblox-game-skill (MIT) -->

Roblox Data Persistence Reference

Roblox 数据持久化参考文档

1. Overview

1. 概述

Data persistence in Roblox means saving player progress so it survives across sessions. Every time a player joins, the server loads their data from the cloud; every time they leave (or periodically), it saves back.
When data flows:
Player Joins  -->  Server loads from DataStore  -->  Populate in-game objects
Player Plays  -->  Data lives in server memory   -->  Auto-save on interval
Player Leaves -->  Server saves to DataStore     -->  Data persists for next session
Data architecture decisions:
ApproachBest ForComplexity
Raw DataStoreServiceSimple games, prototypesLow
ProfileStoreProduction games (USE THIS)Medium
Custom wrapperSpecific advanced requirementsHigh
Use ProfileStore for any game that will ship. Raw DataStore examples in sections 2-3 exist to explain the underlying system. Do NOT implement manual auto-save, session locking, BindToClose handlers, or retry logic - ProfileStore handles all of this automatically. Section 4 is the production pattern.
Prerequisite: Enable API Services in Roblox Studio under Game Settings > Security > Enable Studio Access to API Services. Without this, DataStore calls will fail in Studio testing.

Roblox中的数据持久化指的是保存玩家进度,使其在不同会话间得以保留。玩家每次加入游戏时,服务器会从云端加载他们的数据;每次离开(或定期)时,数据会重新保存回云端。
数据流转流程:
Player Joins  -->  Server loads from DataStore  -->  Populate in-game objects
Player Plays  -->  Data lives in server memory   -->  Auto-save on interval
Player Leaves -->  Server saves to DataStore     -->  Data persists for next session
数据架构方案选择:
方案适用场景复杂度
原生DataStoreService简单游戏、原型开发
ProfileStore正式上线游戏(推荐使用)
自定义封装特定高级需求
所有要上线的游戏都请使用ProfileStore。第2-3节中的原生DataStore示例仅用于解释底层系统。请勿手动实现自动保存、会话锁定、BindToClose处理器或重试逻辑——ProfileStore会自动处理所有这些内容。第4节是生产环境的标准实现方案。
前置条件:在Roblox Studio的游戏设置 > 安全 > 启用Studio对API服务的访问中开启API服务。否则,在Studio测试时DataStore调用会失败。

Quick Reference

快速参考

Load Full Reference below only when you need specific implementation examples or migration patterns.
Key rules:
  • ALWAYS use ProfileStore for player data. Never raw DataStoreService for mutable player state.
  • Session locking prevents data corruption from multi-server joins. ProfileStore handles this.
  • BindToClose is MANDATORY. Flush all pending saves on server shutdown.
  • Schema: use a default template table. New fields get default values automatically.
  • Access pattern:
    profile.Data.fieldName
    . Mutate directly, ProfileStore auto-saves.
  • Release profile on PlayerRemoving:
    profile:Release()
  • OrderedDataStore for leaderboards only (separate from player data).
  • Data migration: version field in schema, migrate on load if version < current.
  • Never store Instances or functions in DataStores. Serialize to primitives.
  • Cross-server: MessagingService for real-time, GlobalDataStore for shared state.

仅当需要特定实现示例或迁移方案时,才查看下方完整参考内容。
核心规则:
  • 始终使用ProfileStore处理玩家数据。绝不要使用原生DataStoreService处理可变玩家状态。
  • 会话锁定可防止多服务器加入导致的数据损坏。ProfileStore会自动处理这一点。
  • BindToClose是必须的。服务器关闭时需刷新所有待保存数据。
  • 数据结构:使用默认模板表。新增字段会自动获取默认值。
  • 访问方式:
    profile.Data.fieldName
    。直接修改数据,ProfileStore会自动保存。
  • 玩家离开时释放档案:
    profile:Release()
  • 仅将OrderedDataStore用于排行榜(与玩家数据分开)。
  • 数据迁移:在数据结构中加入版本字段,加载时若版本低于当前版本则执行迁移。
  • 不要在DataStores中存储实例或函数。需序列化为基本类型。
  • 跨服务器通信:使用MessagingService实现实时通信,使用GlobalDataStore处理共享状态。

Full Reference

完整参考

2. Raw DataStore API (Reference Only)

2. 原生DataStore API(仅作参考)

For production games, skip to section 4 (ProfileStore). This section exists so you understand what's underneath. Do NOT implement manual auto-save, session locking, BindToClose handlers, or retry logic - ProfileStore handles all of this.
对于正式上线游戏,请直接跳至第4节(ProfileStore)。本节仅用于帮助你理解底层原理。请勿手动实现自动保存、会话锁定、BindToClose处理器或重试逻辑——ProfileStore会自动处理所有这些内容。

Core Methods

核心方法

MethodPurposeNotes
GetDataStore(name)
Get/create a named DataStoreReturns DataStore object
GetAsync(key)
Read a valueReturns nil if key doesn't exist
SetAsync(key, value)
Write a value (overwrites)No conflict protection
UpdateAsync(key, callback)
Atomic read-modify-writePreferred for saves
RemoveAsync(key)
Delete a keyReturns the old value
UpdateAsync
is preferred over
SetAsync
because it is atomic - reads current value, transforms it, writes back in one operation.
方法用途说明
GetDataStore(name)
获取/创建指定名称的DataStore返回DataStore对象
GetAsync(key)
读取值若键不存在则返回nil
SetAsync(key, value)
写入值(会覆盖原有内容)无冲突保护
UpdateAsync(key, callback)
原子化的读取-修改-写入操作推荐用于保存操作
RemoveAsync(key)
删除键返回旧值
UpdateAsync
SetAsync
更推荐,因为它是原子化操作——在一个操作中完成读取当前值、转换值、写回的流程。

Leaderstats (Display Pattern)

排行榜(显示方案)

Leaderstats are IntValue/StringValue children of a Folder named "leaderstats" parented to the Player. Roblox automatically displays these on the in-game leaderboard.
luau
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
leaderstats.Parent = player

local cash = Instance.new("IntValue")
cash.Name = "Cash"
cash.Value = profile.Data.Cash  -- populated from ProfileStore
cash.Parent = leaderstats
Leaderstats are display-only. The authoritative data lives in ProfileStore's
profile.Data
. Sync leaderstats back to profile on save.

Leaderstats是名为“leaderstats”的Folder的子对象,类型为IntValue/StringValue,父对象为Player。Roblox会自动在游戏内排行榜中显示这些数据。
luau
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
leaderstats.Parent = player

local cash = Instance.new("IntValue")
cash.Name = "Cash"
cash.Value = profile.Data.Cash  -- populated from ProfileStore
cash.Parent = leaderstats
Leaderstats仅用于显示。权威数据存储在ProfileStore的
profile.Data
中。保存时需将Leaderstats的数据同步回档案。

4. ProfileStore

4. ProfileStore

ProfileStore is the community-standard library for production-grade data persistence. It solves critical problems that raw DataStore usage does not handle.
ProfileStore是生产级数据持久化的社区标准库。它解决了原生DataStore使用时无法处理的关键问题。

Why Use It

为什么使用它

FeatureRaw DataStoreProfileStore
Session lockingManual (hard)Automatic
Auto-saveManualBuilt-in
Schema migrationManualSupported
Data corruption protectionNoneBuilt-in
Retry logicManualBuilt-in
BindToClose handlingManualAutomatic
特性原生DataStoreProfileStore
会话锁定需手动实现(难度大)自动处理
自动保存需手动实现内置功能
数据结构迁移需手动实现支持
数据损坏防护内置功能
重试逻辑需手动实现内置功能
BindToClose处理需手动实现自动处理

Installation

安装方式

With Wally (recommended):
toml
undefined
推荐使用Wally:
toml
undefined

wally.toml

wally.toml

[dependencies] ProfileStore = "madstudioroblox/profileservice@1.4.0"

Run `wally install`, then require from the Packages folder.

**Manual:** Download from GitHub and place the ProfileStore ModuleScript into ServerScriptService or ReplicatedStorage.
[dependencies] ProfileStore = "madstudioroblox/profileservice@1.4.0"

运行`wally install`,然后从Packages文件夹中引入。

**手动安装:**从GitHub下载ProfileStore ModuleScript,放入ServerScriptService或ReplicatedStorage中。

Complete ProfileStore Setup

完整ProfileStore配置

luau
-- ServerScript in ServerScriptService
local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")

local ProfileStore = require(ServerScriptService.Packages.ProfileStore)
-- Adjust the require path based on where you installed it

-- Define the profile template (default data for new players)
local PROFILE_TEMPLATE = {
    DataVersion = 1,
    Cash = 0,
    Level = 1,
    Experience = 0,
    Inventory = {},
    Settings = {
        MusicVolume = 0.5,
        SFXVolume = 0.8,
    },
    Statistics = {
        TotalPlayTime = 0,
        GamesPlayed = 0,
    },
}

-- Create the store (wraps a DataStore with session locking)
local PlayerStore = ProfileStore.New("PlayerProfiles_v1", PROFILE_TEMPLATE)

-- Active profiles cache
local Profiles: { [Player]: typeof(PlayerStore:LoadProfileAsync("")) } = {}

local function onProfileLoaded(player: Player, profile)
    -- Session lock: if the profile was stolen by another server, release and kick
    profile:AddUserId(player.UserId) -- GDPR compliance
    profile:Reconcile() -- Fills in missing fields from PROFILE_TEMPLATE

    profile:ListenToRelease(function()
        Profiles[player] = nil
        player:Kick("Your data was loaded on another server. Please rejoin.")
    end)

    -- Check if player is still in game (they may have left during async load)
    if not player:IsDescendantOf(Players) then
        profile:Release()
        return
    end

    -- Store and set up the player
    Profiles[player] = profile

    -- Example: set up leaderstats from profile data
    local leaderstats = Instance.new("Folder")
    leaderstats.Name = "leaderstats"
    leaderstats.Parent = player

    local cash = Instance.new("IntValue")
    cash.Name = "Cash"
    cash.Value = profile.Data.Cash
    cash.Parent = leaderstats

    local level = Instance.new("IntValue")
    level.Name = "Level"
    level.Value = profile.Data.Level
    level.Parent = leaderstats
end

Players.PlayerAdded:Connect(function(player: Player)
    local profile = PlayerStore:LoadProfileAsync(
        `Player_{player.UserId}`,
        "ForceLoad" -- Wait until the session lock is acquired
    )

    if profile == nil then
        player:Kick("Unable to load your data. Please rejoin.")
        return
    end

    onProfileLoaded(player, profile)
end)

Players.PlayerRemoving:Connect(function(player: Player)
    local profile = Profiles[player]
    if profile then
        -- Sync leaderstats back to profile before release
        local leaderstats = player:FindFirstChild("leaderstats")
        if leaderstats then
            profile.Data.Cash = leaderstats.Cash.Value
            profile.Data.Level = leaderstats.Level.Value
        end

        profile:Release()
    end
end)

-- Helper to get a player's profile from other scripts
-- Export this via a ModuleScript in production
local function getProfile(player: Player)
    return Profiles[player]
end
luau
-- ServerScript in ServerScriptService
local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")

local ProfileStore = require(ServerScriptService.Packages.ProfileStore)
-- 根据安装路径调整引入路径

-- 定义档案模板(新玩家的默认数据)
local PROFILE_TEMPLATE = {
    DataVersion = 1,
    Cash = 0,
    Level = 1,
    Experience = 0,
    Inventory = {},
    Settings = {
        MusicVolume = 0.5,
        SFXVolume = 0.8,
    },
    Statistics = {
        TotalPlayTime = 0,
        GamesPlayed = 0,
    },
}

-- 创建存储(包装DataStore并添加会话锁定功能)
local PlayerStore = ProfileStore.New("PlayerProfiles_v1", PROFILE_TEMPLATE)

-- 活跃档案缓存
local Profiles: { [Player]: typeof(PlayerStore:LoadProfileAsync("")) } = {}

local function onProfileLoaded(player: Player, profile)
    -- 会话锁定:如果档案被另一服务器抢占,释放并踢出玩家
    profile:AddUserId(player.UserId) -- 符合GDPR合规要求
    profile:Reconcile() -- 从PROFILE_TEMPLATE填充缺失字段

    profile:ListenToRelease(function()
        Profiles[player] = nil
        player:Kick("你的数据已在另一服务器加载,请重新加入。")
    end)

    -- 检查玩家是否仍在游戏中(加载异步操作期间玩家可能已离开)
    if not player:IsDescendantOf(Players) then
        profile:Release()
        return
    end

    -- 存储档案并设置玩家数据
    Profiles[player] = profile

    -- 示例:从档案数据设置排行榜
    local leaderstats = Instance.new("Folder")
    leaderstats.Name = "leaderstats"
    leaderstats.Parent = player

    local cash = Instance.new("IntValue")
    cash.Name = "Cash"
    cash.Value = profile.Data.Cash
    cash.Parent = leaderstats

    local level = Instance.new("IntValue")
    level.Name = "Level"
    level.Value = profile.Data.Level
    level.Parent = leaderstats
end

Players.PlayerAdded:Connect(function(player: Player)
    local profile = PlayerStore:LoadProfileAsync(
        `Player_{player.UserId}`,
        "ForceLoad" -- 等待获取会话锁定
    )

    if profile == nil then
        player:Kick("无法加载你的数据,请重新加入。")
        return
    end

    onProfileLoaded(player, profile)
end)

Players.PlayerRemoving:Connect(function(player: Player)
    local profile = Profiles[player]
    if profile then
        -- 释放前将排行榜数据同步回档案
        local leaderstats = player:FindFirstChild("leaderstats")
        if leaderstats then
            profile.Data.Cash = leaderstats.Cash.Value
            profile.Data.Level = leaderstats.Level.Value
        end

        profile:Release()
    end
end)

-- 辅助函数:从其他脚本获取玩家档案
-- 生产环境中可通过ModuleScript导出此函数
local function getProfile(player: Player)
    return Profiles[player]
end

Accessing Profile Data From Other Scripts

从其他脚本访问档案数据

luau
-- In another ServerScript or ModuleScript
local function addCash(player: Player, amount: number)
    local profile = getProfile(player)
    if not profile then
        return
    end

    profile.Data.Cash += amount

    -- Also update leaderstats if visible
    local leaderstats = player:FindFirstChild("leaderstats")
    if leaderstats and leaderstats:FindFirstChild("Cash") then
        leaderstats.Cash.Value = profile.Data.Cash
    end
end

luau
-- 在其他ServerScript或ModuleScript中
local function addCash(player: Player, amount: number)
    local profile = getProfile(player)
    if not profile then
        return
    end

    profile.Data.Cash += amount

    -- 如果排行榜可见,同时更新其数据
    local leaderstats = player:FindFirstChild("leaderstats")
    if leaderstats and leaderstats:FindFirstChild("Cash") then
        leaderstats.Cash.Value = profile.Data.Cash
    end
end

5. Session Locking Explained

5. 会话锁定详解

The Problem

问题背景

Without session locking, data corruption can occur during server hops:
Timeline:
  t=0   Player is on Server A, data loaded
  t=1   Player teleports to Server B
  t=2   Server B starts loading player data from DataStore
  t=3   Server A fires PlayerRemoving, starts saving data
  t=4   Server B finishes loading (gets STALE data)
  t=5   Server A finishes saving (writes LATEST data)
  t=6   Server B eventually saves its stale copy, OVERWRITING the latest data

Result: Player loses progress from Server A session
如果没有会话锁定,服务器切换期间可能会发生数据损坏:
时间线:
  t=0   玩家在服务器A,数据已加载
  t=1   玩家传送至服务器B
  t=2   服务器B开始从DataStore加载玩家数据
  t=3   服务器A触发PlayerRemoving事件,开始保存数据
  t=4   服务器B完成加载(获取到过期数据)
  t=5   服务器A完成保存(写入最新数据)
  t=6   服务器B最终保存其过期数据,覆盖了最新数据

结果:玩家丢失了在服务器A会话中的进度

The Solution

解决方案

Session locking ensures that only one server can own a player's data at a time:
Timeline with Session Locking:
  t=0   Server A loads profile, acquires session lock
  t=1   Player teleports to Server B
  t=2   Server B tries to load -- sees lock owned by Server A, WAITS
  t=3   Server A fires PlayerRemoving, saves data, RELEASES lock
  t=4   Server B detects lock released, acquires lock, loads LATEST data

Result: No data loss
会话锁定确保同一时间只有一台服务器能拥有玩家的数据:
启用会话锁定后的时间线:
  t=0   服务器A加载档案,获取会话锁定
  t=1   玩家传送至服务器B
  t=2   服务器B尝试加载——发现锁定被服务器A持有,等待
  t=3   服务器A触发PlayerRemoving事件,保存数据,释放锁定
  t=4   服务器B检测到锁定已释放,获取锁定,加载最新数据

结果:无数据丢失

How ProfileStore Implements It

ProfileStore的实现方式

  1. When
    LoadProfileAsync
    is called, ProfileStore writes a session lock tag (server JobId) to the DataStore entry.
  2. If another server already holds the lock, ProfileStore either waits ("ForceLoad") or gives up ("Steal").
  3. The locking server periodically refreshes the lock via auto-save.
  4. On
    profile:Release()
    , the lock is cleared and the data is saved.
  5. If a server crashes without releasing, the lock expires after a timeout (~30 minutes by default), and another server can then claim it.
You do NOT need to implement session locking manually. ProfileStore handles all of this. This is the primary reason to use it over raw DataStoreService.

  1. 调用
    LoadProfileAsync
    时,ProfileStore会向DataStore条目写入会话锁定标签(服务器JobId)。
  2. 如果另一服务器已持有锁定,ProfileStore会选择等待("ForceLoad"模式)或放弃("Steal"模式)。
  3. 持有锁定的服务器会通过自动保存定期刷新锁定。
  4. 调用
    profile:Release()
    时,锁定会被清除并保存数据。
  5. 如果服务器崩溃未释放锁定,锁定会在超时后过期(默认约30分钟),之后其他服务器可获取锁定。
你无需手动实现会话锁定。ProfileStore会自动处理所有相关逻辑。这是使用它而非原生DataStoreService的主要原因。

6. Data Schema Design

6. 数据结构设计

Flat vs. Nested Structure

扁平结构 vs 嵌套结构

Flat (simple games):
luau
local PROFILE_TEMPLATE = {
    Cash = 0,
    Level = 1,
    Wins = 0,
    Losses = 0,
}
Nested (complex games):
luau
local PROFILE_TEMPLATE = {
    DataVersion = 1,
    Currency = {
        Cash = 0,
        Gems = 0,
        Tickets = 0,
    },
    Progression = {
        Level = 1,
        Experience = 0,
        Prestige = 0,
    },
    Inventory = {
        Swords = {},    -- Array of item IDs or item tables
        Armor = {},
        Consumables = {},
    },
    Quests = {
        Active = {},
        Completed = {},
    },
    Settings = {
        MusicVolume = 0.5,
        SFXVolume = 0.8,
        ShowTutorial = true,
    },
}
扁平结构(适用于简单游戏):
luau
local PROFILE_TEMPLATE = {
    Cash = 0,
    Level = 1,
    Wins = 0,
    Losses = 0,
}
嵌套结构(适用于复杂游戏):
luau
local PROFILE_TEMPLATE = {
    DataVersion = 1,
    Currency = {
        Cash = 0,
        Gems = 0,
        Tickets = 0,
    },
    Progression = {
        Level = 1,
        Experience = 0,
        Prestige = 0,
    },
    Inventory = {
        Swords = {},    -- 物品ID或物品表的数组
        Armor = {},
        Consumables = {},
    },
    Quests = {
        Active = {},
        Completed = {},
    },
    Settings = {
        MusicVolume = 0.5,
        SFXVolume = 0.8,
        ShowTutorial = true,
    },
}

Versioning Your Schema

数据结构版本控制

Always include a
DataVersion
field. This lets you detect and migrate old data formats.
luau
local PROFILE_TEMPLATE = {
    DataVersion = 3,   -- Increment when schema changes
    -- ... fields ...
}
始终包含
DataVersion
字段。这能让你检测并迁移旧数据格式。
luau
local PROFILE_TEMPLATE = {
    DataVersion = 3,   -- 数据结构变更时递增版本号
    -- ... 其他字段 ...
}

Default Values for New Fields

新增字段的默认值

When you add new fields, existing players won't have them. ProfileStore's
Reconcile()
handles this automatically
- it fills in any missing fields from your PROFILE_TEMPLATE. Call it after loading:
luau
profile:Reconcile() -- Fills missing fields from template
No manual merge code needed when using ProfileStore.
当你添加新字段时,现有玩家的数据中不会有这些字段。ProfileStore的
Reconcile()
方法会自动处理这一点
——它会从PROFILE_TEMPLATE中填充所有缺失字段。加载档案后调用此方法:
luau
profile:Reconcile() -- 从模板填充缺失字段
使用ProfileStore时无需编写手动合并代码。

Type Safety Tips

类型安全建议

  • Use consistent types per field. If
    Cash
    is a number, never save it as a string.
  • Arrays should contain uniform types (
    { number }
    , not mixed).
  • Avoid storing
    nil
    explicitly -- DataStore omits nil keys, which can cause confusion. Use sentinel values (e.g.,
    0
    ,
    ""
    ,
    false
    ) instead.
  • Remember: DataStore serializes to JSON internally. Only JSON-compatible types work:
    number
    ,
    string
    ,
    boolean
    ,
    table
    (arrays and dictionaries). No Instances, Vector3s, CFrames, or other Roblox types directly.

  • 每个字段使用一致的类型。如果
    Cash
    是数字类型,绝不要将其保存为字符串。
  • 数组应包含统一类型(如
    { number }
    ,不要混合类型)。
  • 避免显式存储
    nil
    ——DataStore会忽略nil键,可能导致混淆。使用标记值(如
    0
    ""
    false
    )替代。
  • 注意:DataStore内部会序列化为JSON。仅支持JSON兼容类型:
    number
    string
    boolean
    table
    (数组和字典)。不能直接存储Instances、Vector3s、CFrames或其他Roblox类型。

7. Data Migration

7. 数据迁移

When your data schema changes, you need to migrate existing player data to the new format.
当数据结构变更时,你需要将现有玩家的数据迁移至新格式。

Migration Strategy

迁移策略

  1. Check
    DataVersion
    when data is loaded.
  2. Apply migration functions sequentially (v1 -> v2, v2 -> v3, etc.).
  3. Update
    DataVersion
    to current.
  1. 加载数据时检查
    DataVersion
  2. 按顺序应用迁移函数(v1 -> v2,v2 -> v3,依此类推)。
  3. DataVersion
    更新为当前版本。

Complete Migration Example

完整迁移示例

luau
-- DataMigrations module
local DataMigrations = {}

-- Each migration transforms data from version N to version N+1
local migrations: { [number]: (data: { [string]: any }) -> { [string]: any } } = {}

-- v1 -> v2: Split "Money" into "Cash" and "Gems"
migrations[1] = function(data)
    if data.Money then
        data.Cash = data.Money
        data.Gems = 0
        data.Money = nil
    end
    return data
end

-- v2 -> v3: Move settings out of flat structure into nested table
migrations[2] = function(data)
    data.Settings = {
        MusicVolume = data.MusicVolume or 0.5,
        SFXVolume = data.SFXVolume or 0.8,
    }
    data.MusicVolume = nil
    data.SFXVolume = nil
    return data
end

-- v3 -> v4: Add Quests system and rename "Wins" to "Statistics.Wins"
migrations[3] = function(data)
    data.Quests = {
        Active = {},
        Completed = {},
    }
    data.Statistics = data.Statistics or {}
    data.Statistics.Wins = data.Wins or 0
    data.Wins = nil
    return data
end

local CURRENT_VERSION = 4

function DataMigrations.migrate(data: { [string]: any }): { [string]: any }
    local version = data.DataVersion or 1

    if version > CURRENT_VERSION then
        warn(`[Migration] Data version {version} is newer than code version {CURRENT_VERSION}`)
        return data
    end

    while version < CURRENT_VERSION do
        local migrator = migrations[version]
        if migrator then
            data = migrator(data)
            print(`[Migration] Migrated data from v{version} to v{version + 1}`)
        end
        version += 1
    end

    data.DataVersion = CURRENT_VERSION
    return data
end

return DataMigrations
luau
-- DataMigrations模块
local DataMigrations = {}

-- 每个迁移函数将数据从版本N转换为版本N+1
local migrations: { [number]: (data: { [string]: any }) -> { [string]: any } } = {}

-- v1 -> v2:将"Money"拆分为"Cash"和"Gems"
migrations[1] = function(data)
    if data.Money then
        data.Cash = data.Money
        data.Gems = 0
        data.Money = nil
    end
    return data
end

-- v2 -> v3:将设置从扁平结构移至嵌套表
migrations[2] = function(data)
    data.Settings = {
        MusicVolume = data.MusicVolume or 0.5,
        SFXVolume = data.SFXVolume or 0.8,
    }
    data.MusicVolume = nil
    data.SFXVolume = nil
    return data
end

-- v3 -> v4:添加任务系统并将"Wins"重命名为"Statistics.Wins"
migrations[3] = function(data)
    data.Quests = {
        Active = {},
        Completed = {},
    }
    data.Statistics = data.Statistics or {}
    data.Statistics.Wins = data.Wins or 0
    data.Wins = nil
    return data
end

local CURRENT_VERSION = 4

function DataMigrations.migrate(data: { [string]: any }): { [string]: any }
    local version = data.DataVersion or 1

    if version > CURRENT_VERSION then
        warn(`[Migration] 数据版本{version}高于代码版本{CURRENT_VERSION}`)
        return data
    end

    while version < CURRENT_VERSION do
        local migrator = migrations[version]
        if migrator then
            data = migrator(data)
            print(`[Migration] 将数据从v{version}迁移至v{version + 1}`)
        end
        version += 1
    end

    data.DataVersion = CURRENT_VERSION
    return data
end

return DataMigrations

Using Migrations With ProfileStore

在ProfileStore中使用迁移

luau
-- After loading the profile, before using the data:
local profile = PlayerStore:LoadProfileAsync(`Player_{player.UserId}`, "ForceLoad")
if profile then
    profile.Data = DataMigrations.migrate(profile.Data)
    profile:Reconcile() -- Fill in any remaining missing defaults
end

luau
-- 加载档案后,使用数据前:
local profile = PlayerStore:LoadProfileAsync(`Player_{player.UserId}`, "ForceLoad")
if profile then
    profile.Data = DataMigrations.migrate(profile.Data)
    profile:Reconcile() -- 填充所有剩余缺失的默认值
end

8. OrderedDataStore

8. OrderedDataStore

OrderedDataStore
is a special DataStore type that stores integer values and supports sorted queries. It is the standard way to build global leaderboards.
OrderedDataStore
是一种特殊的DataStore类型,用于存储整数值并支持排序查询。它是构建全局排行榜的标准方式。

Complete Leaderboard Implementation

完整排行榜实现

luau
-- ServerScript in ServerScriptService
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")

local cashLeaderboard = DataStoreService:GetOrderedDataStore("CashLeaderboard")

local LEADERBOARD_SIZE = 100
local UPDATE_INTERVAL = 120  -- seconds

-- Update a player's score in the leaderboard
local function updateLeaderboardScore(userId: number, score: number)
    local success, err = pcall(function()
        cashLeaderboard:SetAsync(tostring(userId), score)
    end)

    if not success then
        warn(`[Leaderboard] Failed to update score for {userId}: {err}`)
    end
end

-- Fetch the top N entries from the leaderboard
local function getTopPlayers(count: number): { { UserId: number, Score: number, Rank: number } }
    local results = {}

    local success, pages = pcall(function()
        return cashLeaderboard:GetSortedAsync(
            false,   -- isAscending: false = highest first
            count    -- pageSize
        )
    end)

    if not success then
        warn(`[Leaderboard] Failed to fetch leaderboard: {pages}`)
        return results
    end

    local currentPage = pages:GetCurrentPage()
    local rank = 0

    for _, entry in currentPage do
        rank += 1
        table.insert(results, {
            UserId = tonumber(entry.key),
            Score = entry.value,
            Rank = rank,
        })
    end

    return results
end

-- Populate a SurfaceGui or Billboard leaderboard (example with a Frame)
local function displayLeaderboard(surfaceGui: SurfaceGui, entries: { { UserId: number, Score: number, Rank: number } })
    local container = surfaceGui:FindFirstChild("Container")
    if not container then
        return
    end

    -- Clear old entries
    for _, child in container:GetChildren() do
        if child:IsA("Frame") then
            child:Destroy()
        end
    end

    for _, entry in entries do
        -- Get player name (works for offline players too)
        local success, name = pcall(function()
            return Players:GetNameFromUserIdAsync(entry.UserId)
        end)

        if success then
            local row = Instance.new("Frame")
            row.Name = `Rank_{entry.Rank}`
            row.Size = UDim2.new(1, 0, 0, 30)
            row.LayoutOrder = entry.Rank
            row.Parent = container

            local rankLabel = Instance.new("TextLabel")
            rankLabel.Text = `#{entry.Rank}`
            rankLabel.Size = UDim2.new(0.15, 0, 1, 0)
            rankLabel.Parent = row

            local nameLabel = Instance.new("TextLabel")
            nameLabel.Text = name
            nameLabel.Size = UDim2.new(0.55, 0, 1, 0)
            nameLabel.Position = UDim2.new(0.15, 0, 0, 0)
            nameLabel.Parent = row

            local scoreLabel = Instance.new("TextLabel")
            scoreLabel.Text = tostring(entry.Score)
            scoreLabel.Size = UDim2.new(0.3, 0, 1, 0)
            scoreLabel.Position = UDim2.new(0.7, 0, 0, 0)
            scoreLabel.Parent = row
        end
    end
end

-- Periodic leaderboard update loop
task.spawn(function()
    while true do
        -- Update scores for all online players
        for _, player in Players:GetPlayers() do
            local leaderstats = player:FindFirstChild("leaderstats")
            if leaderstats and leaderstats:FindFirstChild("Cash") then
                task.spawn(updateLeaderboardScore, player.UserId, leaderstats.Cash.Value)
            end
        end

        -- Fetch and display updated leaderboard
        task.wait(5) -- Brief delay for scores to propagate
        local topPlayers = getTopPlayers(LEADERBOARD_SIZE)

        task.wait(UPDATE_INTERVAL)
    end
end)
Important: OrderedDataStore only supports integer values. If you need decimal scores, multiply by a factor (e.g., store
score * 100
).

luau
-- ServerScript in ServerScriptService
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")

local cashLeaderboard = DataStoreService:GetOrderedDataStore("CashLeaderboard")

local LEADERBOARD_SIZE = 100
local UPDATE_INTERVAL = 120  -- 秒

-- 更新玩家在排行榜中的分数
local function updateLeaderboardScore(userId: number, score: number)
    local success, err = pcall(function()
        cashLeaderboard:SetAsync(tostring(userId), score)
    end)

    if not success then
        warn(`[Leaderboard] 更新玩家{userId}分数失败: {err}`)
    end
end

-- 从排行榜获取前N名玩家
local function getTopPlayers(count: number): { { UserId: number, Score: number, Rank: number } }
    local results = {}

    local success, pages = pcall(function()
        return cashLeaderboard:GetSortedAsync(
            false,   -- isAscending: false = 分数从高到低排序
            count    -- 每页数量
        )
    end)

    if not success then
        warn(`[Leaderboard] 获取排行榜失败: {pages}`)
        return results
    end

    local currentPage = pages:GetCurrentPage()
    local rank = 0

    for _, entry in currentPage do
        rank += 1
        table.insert(results, {
            UserId = tonumber(entry.key),
            Score = entry.value,
            Rank = rank,
        })
    end

    return results
end

-- 在SurfaceGui或Billboard中显示排行榜(以Frame为例)
local function displayLeaderboard(surfaceGui: SurfaceGui, entries: { { UserId: number, Score: number, Rank: number } })
    local container = surfaceGui:FindFirstChild("Container")
    if not container then
        return
    end

    -- 清除旧条目
    for _, child in container:GetChildren() do
        if child:IsA("Frame") then
            child:Destroy()
        end
    end

    for _, entry in entries do
        -- 获取玩家名称(离线玩家也适用)
        local success, name = pcall(function()
            return Players:GetNameFromUserIdAsync(entry.UserId)
        end)

        if success then
            local row = Instance.new("Frame")
            row.Name = `Rank_{entry.Rank}`
            row.Size = UDim2.new(1, 0, 0, 30)
            row.LayoutOrder = entry.Rank
            row.Parent = container

            local rankLabel = Instance.new("TextLabel")
            rankLabel.Text = `#{entry.Rank}`
            rankLabel.Size = UDim2.new(0.15, 0, 1, 0)
            rankLabel.Parent = row

            local nameLabel = Instance.new("TextLabel")
            nameLabel.Text = name
            nameLabel.Size = UDim2.new(0.55, 0, 1, 0)
            nameLabel.Position = UDim2.new(0.15, 0, 0, 0)
            nameLabel.Parent = row

            local scoreLabel = Instance.new("TextLabel")
            scoreLabel.Text = tostring(entry.Score)
            scoreLabel.Size = UDim2.new(0.3, 0, 1, 0)
            scoreLabel.Position = UDim2.new(0.7, 0, 0, 0)
            scoreLabel.Parent = row
        end
    end
end

-- 定期更新排行榜的循环
task.spawn(function()
    while true do
        -- 更新所有在线玩家的分数
        for _, player in Players:GetPlayers() do
            local leaderstats = player:FindFirstChild("leaderstats")
            if leaderstats and leaderstats:FindFirstChild("Cash") then
                task.spawn(updateLeaderboardScore, player.UserId, leaderstats.Cash.Value)
            end
        end

        -- 短暂延迟等待分数同步
        task.wait(5)
        local topPlayers = getTopPlayers(LEADERBOARD_SIZE)

        task.wait(UPDATE_INTERVAL)
    end
end)
重要提示:OrderedDataStore仅支持整数值。如果需要小数分数,可乘以一个系数(例如,存储
score * 100
)。

9. Cross-Server Data

9. 跨服务器数据

MessagingService (Real-Time Pub/Sub)

MessagingService(实时发布/订阅)

For real-time communication between servers (announcements, events, cross-server trading).
luau
local MessagingService = game:GetService("MessagingService")

-- Subscribe to a topic
local connection = MessagingService:SubscribeAsync("GlobalAnnouncement", function(message)
    local data = message.Data -- The payload
    local sent = message.Sent -- Timestamp when sent (Unix time)

    -- Broadcast to all players on this server
    for _, player in Players:GetPlayers() do
        -- Show announcement UI, etc.
    end
end)

-- Publish to a topic (reaches all servers)
local success, err = pcall(function()
    MessagingService:PublishAsync("GlobalAnnouncement", {
        Text = "Double XP weekend starts now!",
        Duration = 3600,
    })
end)
MessagingService limits:
  • Message size: 1 KB max
  • Messages per server: 150 + 60 * playerCount per minute
  • Subscriptions per server: 5 + 2 * playerCount
  • Messages are NOT persisted -- only online servers receive them.
用于服务器间的实时通信(公告、事件、跨服务器交易等)。
luau
local MessagingService = game:GetService("MessagingService")

-- 订阅主题
local connection = MessagingService:SubscribeAsync("GlobalAnnouncement", function(message)
    local data = message.Data -- 负载数据
    local sent = message.Sent -- 发送时间戳(Unix时间)

    -- 广播给当前服务器的所有玩家
    for _, player in Players:GetPlayers() do
        -- 显示公告UI等
    end
end)

-- 发布到主题(所有服务器都会收到)
local success, err = pcall(function()
    MessagingService:PublishAsync("GlobalAnnouncement", {
        Text = "双倍经验周末现在开始!",
        Duration = 3600,
    })
end)
MessagingService限制:
  • 消息大小:最大1 KB
  • 每服务器消息数量:每分钟150 + 60 * 玩家数量
  • 每服务器订阅数量:5 + 2 * 玩家数量
  • 消息不持久化——仅在线服务器会收到。

GlobalDataStore for Shared State

用于共享状态的GlobalDataStore

For persistent cross-server state (global counters, server-wide events):
luau
local globalStore = DataStoreService:GetDataStore("GlobalState")

-- Atomically increment a global counter
local function incrementGlobalCounter(key: string, amount: number): number?
    local success, newValue = pcall(function()
        return globalStore:UpdateAsync(key, function(old)
            return (old or 0) + amount
        end)
    end)

    if success then
        return newValue
    end
    return nil
end

-- Example: Track total enemies defeated across all servers
local totalDefeated = incrementGlobalCounter("TotalEnemiesDefeated", 1)

用于持久化跨服务器状态(全局计数器、服务器级事件等):
luau
local globalStore = DataStoreService:GetDataStore("GlobalState")

-- 原子化递增全局计数器
local function incrementGlobalCounter(key: string, amount: number): number?
    local success, newValue = pcall(function()
        return globalStore:UpdateAsync(key, function(old)
            return (old or 0) + amount
        end)
    end)

    if success then
        return newValue
    end
    return nil
end

-- 示例:跟踪所有服务器击败的敌人总数
local totalDefeated = incrementGlobalCounter("TotalEnemiesDefeated", 1)

10. Best Practices

10. 最佳实践

If using ProfileStore (recommended), sections 10.1 through 10.4 are handled automatically. You only need to worry about these if you're building on raw DataStoreService. The patterns below are shown for understanding and for the rare case where raw DataStore is appropriate.
如果使用ProfileStore(推荐),第10.1至10.4节的内容会自动处理。仅当基于原生DataStoreService开发时才需要关注这些内容。以下模式仅用于理解原理,以及极少数适合使用原生DataStore的场景。

10.1 Auto-Save Interval (ProfileStore: automatic)

10.1 自动保存间隔(ProfileStore:自动处理)

ProfileStore handles auto-save internally. If using raw DataStore, save every 5 minutes:
luau
local AUTO_SAVE_INTERVAL = 300

task.spawn(function()
    while true do
        task.wait(AUTO_SAVE_INTERVAL)
        for player, _data in playerDataCache do
            task.spawn(savePlayerData, player)
        end
    end
end)
ProfileStore内部会处理自动保存。如果使用原生DataStore,建议每5分钟保存一次:
luau
local AUTO_SAVE_INTERVAL = 300

task.spawn(function()
    while true do
        task.wait(AUTO_SAVE_INTERVAL)
        for player, _data in playerDataCache do
            task.spawn(savePlayerData, player)
        end
    end
end)

10.2 Save on PlayerRemoving (ProfileStore: automatic via Release)

10.2 玩家离开时保存(ProfileStore:通过Release自动处理)

ProfileStore saves and releases the session lock when
profile:Release()
is called. If using raw DataStore:
luau
Players.PlayerRemoving:Connect(function(player: Player)
    savePlayerData(player)
    playerDataCache[player] = nil
end)
调用
profile:Release()
时,ProfileStore会保存数据并释放会话锁定。如果使用原生DataStore:
luau
Players.PlayerRemoving:Connect(function(player: Player)
    savePlayerData(player)
    playerDataCache[player] = nil
end)

10.3 BindToClose Handler (ProfileStore: automatic)

10.3 BindToClose处理器(ProfileStore:自动处理)

ProfileStore handles shutdown saves automatically. If using raw DataStore,
game:BindToClose
fires when the server shuts down. You have 30 seconds to save all data before the server terminates. Use
task.spawn
for parallel saves.
luau
-- Only needed with raw DataStore
game:BindToClose(function()
    if game:GetService("RunService"):IsStudio() then
        task.wait(1)
        return
    end

    local finished = Instance.new("BindableEvent")
    local allPlayers = Players:GetPlayers()
    local remaining = #allPlayers

    if remaining == 0 then return end

    for _, player in allPlayers do
        task.spawn(function()
            savePlayerData(player)
            remaining -= 1
            if remaining <= 0 then finished:Fire() end
        end)
    end

    task.delay(25, function() finished:Fire() end)
    finished.Event:Wait()
    finished:Destroy()
end)
ProfileStore会自动处理服务器关闭时的保存操作。如果使用原生DataStore,
game:BindToClose
会在服务器关闭时触发。你有30秒时间完成所有数据保存,之后服务器会强制终止。使用
task.spawn
实现并行保存。
luau
-- 仅原生DataStore需要
game:BindToClose(function()
    if game:GetService("RunService"):IsStudio() then
        task.wait(1)
        return
    end

    local finished = Instance.new("BindableEvent")
    local allPlayers = Players:GetPlayers()
    local remaining = #allPlayers

    if remaining == 0 then return end

    for _, player in allPlayers do
        task.spawn(function()
            savePlayerData(player)
            remaining -= 1
            if remaining <= 0 then finished:Fire() end
        end)
    end

    task.delay(25, function() finished:Fire() end)
    finished.Event:Wait()
    finished:Destroy()
end)

10.4 Retry Failed Saves (ProfileStore: built-in)

10.4 失败保存的重试逻辑(ProfileStore:内置)

ProfileStore has built-in retry with exponential backoff. If using raw DataStore:
luau
local MAX_RETRIES = 3
local RETRY_DELAY = 2

local function saveWithRetry(player: Player): boolean
    for attempt = 1, MAX_RETRIES do
        local success = savePlayerData(player)
        if success then return true end

        if attempt < MAX_RETRIES then
            warn(`[DataStore] Retry {attempt}/{MAX_RETRIES} for {player.Name}`)
            task.wait(RETRY_DELAY * attempt)
        end
    end

    warn(`[DataStore] All retries failed for {player.Name}`)
    return false
end
ProfileStore内置了带指数退避的重试逻辑。如果使用原生DataStore:
luau
local MAX_RETRIES = 3
local RETRY_DELAY = 2

local function saveWithRetry(player: Player): boolean
    for attempt = 1, MAX_RETRIES do
        local success = savePlayerData(player)
        if success then return true end

        if attempt < MAX_RETRIES then
            warn(`[DataStore] 为玩家{player.Name}进行第{attempt}/{MAX_RETRIES}次重试`)
            task.wait(RETRY_DELAY * attempt)
        end
    end

    warn(`[DataStore] 玩家{player.Name}的所有重试均失败`)
    return false
end

10.5 Validate Data Before Saving (always relevant)

10.5 保存前验证数据(始终适用)

This applies regardless of whether you use ProfileStore or raw DataStore. Validate before writing:
luau
local function validateData(data: { [string]: any }): boolean
    if typeof(data) ~= "table" then return false end
    if typeof(data.Cash) ~= "number" or data.Cash < 0 then return false end
    if typeof(data.Level) ~= "number" or data.Level < 1 then return false end
    return true
end

无论使用ProfileStore还是原生DataStore,都需要在写入前验证数据:
luau
local function validateData(data: { [string]: any }): boolean
    if typeof(data) ~= "table" then return false end
    if typeof(data.Cash) ~= "number" or data.Cash < 0 then return false end
    if typeof(data.Level) ~= "number" or data.Level < 1 then return false end
    return true
end

11. Anti-Patterns

11. 反模式

Saving Too Frequently

过于频繁地保存

Wrong:
luau
-- DO NOT DO THIS: saving on every coin pickup
coinTouched:Connect(function(player)
    player.Data.Cash += 1
dataStore:SetAsync(`Player_{player.UserId}`, player.Data) -- Rate limit hit
end)
Right: Modify in-memory data immediately, rely on periodic auto-save.
DataStore rate limits:
60 + numPlayers * 10
requests per minute per DataStore. With 50 players, that is 560 requests/min total -- or about 11 per player per minute. Saving once per 5 minutes uses only 0.2 per player per minute.
错误示例:
luau
-- 请勿这样做:每次拾取硬币都保存
coinTouched:Connect(function(player)
    player.Data.Cash += 1
dataStore:SetAsync(`Player_{player.UserId}`, player.Data) -- 会触发速率限制
end)
**正确做法:**立即修改内存中的数据,依赖定期自动保存。
**DataStore速率限制:**每个DataStore每分钟允许
60 + numPlayers * 10
次请求。如果有50名玩家,总共有560次请求/分钟——约每位玩家每分钟11次请求。每5分钟保存一次仅占每位玩家每分钟0.2次请求。

Not Handling DataStore Errors

未处理DataStore错误

Wrong:
luau
-- DO NOT DO THIS: unprotected call
local data = dataStore:GetAsync(key) -- Will error and break the script
Right:
luau
local success, data = pcall(function()
    return dataStore:GetAsync(key)
end)

if not success then
    warn("DataStore error:", data)
    -- Handle gracefully
end
错误示例:
luau
-- 请勿这样做:未受保护的调用
local data = dataStore:GetAsync(key) -- 会报错并中断脚本
正确做法:
luau
local success, data = pcall(function()
    return dataStore:GetAsync(key)
end)

if not success then
    warn("DataStore错误:", data)
    -- 优雅处理错误
end

Storing Instance References

存储实例引用

Wrong:
luau
-- DO NOT DO THIS: Instances are not serializable
data.Weapon = workspace.Sword -- Will fail or produce garbage
data.Character = player.Character -- Same problem
Right: Store serializable identifiers.
luau
data.WeaponId = "IronSword"
data.EquippedSlots = { "Helmet_01", "Armor_03" }
错误示例:
luau
-- 请勿这样做:实例无法序列化
data.Weapon = workspace.Sword -- 会失败或产生无效数据
data.Character = player.Character -- 同样问题
**正确做法:**存储可序列化的标识符。
luau
data.WeaponId = "IronSword"
data.EquippedSlots = { "Helmet_01", "Armor_03" }

Exceeding Key Size Limits

超过键大小限制

LimitValue
Key name length50 characters
Value size per key4,194,304 bytes (4 MB)
DataStore name length50 characters
If you're approaching 4 MB, split data across multiple keys:
luau
-- Split by category
local coreStore = DataStoreService:GetDataStore("PlayerCore")
local inventoryStore = DataStoreService:GetDataStore("PlayerInventory")
local questStore = DataStoreService:GetDataStore("PlayerQuests")

限制数值
键名称长度50字符
每个键的值大小4,194,304字节(4 MB)
DataStore名称长度50字符
如果数据大小接近4 MB,可将数据拆分到多个键中:
luau
-- 按类别拆分
local coreStore = DataStoreService:GetDataStore("PlayerCore")
local inventoryStore = DataStoreService:GetDataStore("PlayerInventory")
local questStore = DataStoreService:GetDataStore("PlayerQuests")

12. Sharp Edges

12. 注意事项

Rate Limits

速率限制

DataStore requests are throttled per-server, not per-player:
OperationBudget per Minute
GetAsync60 + numPlayers * 10
SetAsync / UpdateAsync60 + numPlayers * 10
GetSortedAsync5 + numPlayers * 2
SetAsync on OrderedDataStore5 + numPlayers * 2
Exceeding these results in requests being queued or erroring. Plan save intervals accordingly.
DataStore请求是按服务器而非按玩家进行限流的:
操作每分钟请求限额
GetAsync60 + numPlayers * 10
SetAsync / UpdateAsync60 + numPlayers * 10
GetSortedAsync5 + numPlayers * 2
OrderedDataStore上的SetAsync5 + numPlayers * 2
超过限额会导致请求排队或报错。请合理规划保存间隔。

Eventual Consistency

最终一致性

DataStore reads are eventually consistent. After a
SetAsync
, a
GetAsync
from another server may briefly return stale data.
UpdateAsync
on the same key is atomic within a single call, but across keys or across servers there is no transaction guarantee.
DataStore读取是最终一致的。调用
SetAsync
后,另一服务器的
GetAsync
可能会短暂返回过期数据。同一键的
UpdateAsync
在单次调用中是原子化的,但跨键或跨服务器没有事务保证。

BindToClose 30-Second Timeout

BindToClose的30秒超时

When a Roblox server shuts down,
BindToClose
callbacks are given at most 30 seconds to finish. After that, the server process is killed regardless. If you have many players, you MUST save in parallel using
task.spawn
, not sequentially.
luau
-- BAD: Sequential saves with 50 players could take > 30 seconds
for _, player in Players:GetPlayers() do
    savePlayerData(player) -- Each call might take 0.5-2 seconds
end

-- GOOD: Parallel saves complete in the time of the slowest single save
for _, player in Players:GetPlayers() do
    task.spawn(savePlayerData, player)
end
task.wait(25) -- Wait with buffer
当Roblox服务器关闭时,
BindToClose
回调最多有30秒时间完成。超时后,服务器进程会被强制终止。如果玩家数量较多,必须使用
task.spawn
并行保存,而非顺序保存。
luau
-- 错误:顺序保存50名玩家可能耗时超过30秒
for _, player in Players:GetPlayers() do
    savePlayerData(player) -- 每次调用可能耗时0.5-2秒
end

-- 正确:并行保存耗时等于最慢的单次保存时间
for _, player in Players:GetPlayers() do
    task.spawn(savePlayerData, player)
end
task.wait(25) -- 留缓冲时间等待

Data Loss from Race Conditions Without Session Locking

无会话锁定时竞态条件导致的数据丢失

Without session locking (i.e., using raw DataStore), the following scenario causes data loss:
  1. Player leaves Server A.
    PlayerRemoving
    fires, save begins.
  2. Player joins Server B before Server A's save completes.
  3. Server B loads stale data (Server A hasn't finished writing yet).
  4. Server A finishes saving. Server B later saves its stale copy, overwriting Server A's save.
This is why you use ProfileStore. It handles session locking automatically. If you must use raw DataStore, implement manual session locking with
UpdateAsync
by writing a lock field containing the server's
game.JobId
and checking it before loading.
如果没有会话锁定(即使用原生DataStore),以下场景会导致数据丢失:
  1. 玩家离开服务器A。触发
    PlayerRemoving
    事件,开始保存数据。
  2. 玩家在服务器A的保存完成前加入服务器B。
  3. 服务器B加载到过期数据(服务器A尚未完成写入)。
  4. 服务器A完成保存。服务器B之后保存其过期数据,覆盖了服务器A的保存结果。
**这就是为什么要使用ProfileStore。**它会自动处理会话锁定。如果必须使用原生DataStore,需通过
UpdateAsync
手动实现会话锁定,写入包含服务器
game.JobId
的锁定字段,并在加载前检查该字段。

Studio Testing Gotchas

Studio测试注意事项

  • PlayerRemoving
    does NOT fire when you press Stop in Studio. Data will not save on exit during testing unless you also test via
    BindToClose
    .
  • DataStore calls fail in Studio unless Enable Studio Access to API Services is checked in Game Settings > Security.
  • Studio and live game share the same DataStore if using the same place. Use different DataStore names or a prefix for testing:
luau
local RunService = game:GetService("RunService")
local PREFIX = RunService:IsStudio() and "Dev_" or ""
local dataStore = DataStoreService:GetDataStore(`{PREFIX}PlayerData_v1`)
  • 在Studio中按下停止按钮时,
    PlayerRemoving
    事件不会触发。测试期间退出游戏时数据不会保存,除非同时测试
    BindToClose
    逻辑。
  • 除非在游戏设置 > 安全中勾选启用Studio对API服务的访问,否则Studio中的DataStore调用会失败。
  • 如果使用同一游戏实例,Studio和线上游戏会共享同一DataStore。测试时请使用不同的DataStore名称或前缀:
luau
local RunService = game:GetService("RunService")
local PREFIX = RunService:IsStudio() and "Dev_" or ""
local dataStore = DataStoreService:GetDataStore(`{PREFIX}PlayerData_v1`)

Other Pitfalls

其他陷阱

  • NaN values: If a NaN (not a number) sneaks into your data (e.g.,
    0/0
    ),
    SetAsync
    /
    UpdateAsync
    will error silently or corrupt the entry. Validate numeric fields.
  • Empty tables: An empty table
    {}
    can deserialize as either an array or a dictionary depending on context. Be consistent.
  • Key naming: Keys are case-sensitive.
    "Player_123"
    and
    "player_123"
    are different keys. Standardize your key format.
  • UpdateAsync callback: The callback passed to
    UpdateAsync
    must be pure (no yields, no side effects). It may be called multiple times if there is contention. Return
    nil
    to cancel the update.
  • **NaN值:**如果NaN(非数字)混入数据中(例如
    0/0
    ),
    SetAsync
    /
    UpdateAsync
    会静默报错或损坏数据条目。请验证数值字段。
  • **空表:**空表
    {}
    在不同上下文下可能被反序列化为数组或字典。请保持一致性。
  • **键命名:**键区分大小写。
    "Player_123"
    "player_123"
    是不同的键。请标准化键的格式。
  • **UpdateAsync回调:**传递给
    UpdateAsync
    的回调必须是纯函数(无yield,无副作用)。如果存在竞争,它可能会被多次调用。返回
    nil
    可取消更新。",