Skip to content

第 11 章:数据存储与排行榜

在服务端安全读写 DataStore,并设计更新、排序、失败处理和测试命名空间。

你会学到什么

  • 为什么存档必须由 server 读取和写入。
  • 如何区分普通 DataStore 与可排序的 OrderedDataStore
  • 为什么所有 Async 方法都要在协程中调用,并用 pcall 处理失败。
  • 如何用 UpdateAsync 并发安全地保存“历史最高分”。
  • 如何把读取、保存和排行榜查询封装成可复用模块。

本章只保存一个整数最高分。先把最小链路做对,再考虑背包、任务进度、版本迁移和重试队列。

开始前:先隔离测试数据

DataStore 会写入持久化数据,不要拿正式玩家存档直接练习。第一次运行前先确认:

  1. 使用专门的测试地图或测试账号;目标环境不允许存储时,只做代码检查,不把失败当成教程逻辑错误。
  2. 存储名带教程前缀和版本,例如 Tutorial_PlayerBestScore_v1,不要复用正式项目现有名称。
  3. 测试 key 只使用测试玩家,并提前写好“失败时保留局内分数、不覆盖旧值”的预期。
  4. 完成练习后记录实际使用的地图、账号范围、存储名和结果,不在日志里输出完整玩家存档。

下面的名称仍是示例。复制到真实项目时,应改成项目自己的、带环境和版本含义的稳定名称。

先认识两种存储

存储本章用途value
DataStore保存每位玩家的历史最高分可以是任意受支持的数据;本章只存整数
OrderedDataStore按最高分生成排行榜必须是整数

二者不能互相替代:普通存档适合保存玩家数据,排行榜存储负责按整数排序。示例会把同一个最高分分别写入两处;这两次写入不是事务,可能出现一处成功、另一处失败,因此必须分别记录结果并准备重试。

三条硬规则

  1. DataStoreService 是 server-only。client 可以发出“结算请求”,但权威分数必须由 server 校验、计算和保存。
  2. GetAsyncSetAsyncUpdateAsyncIncrementAsyncGetSortedAsync 等异步方法必须在协程中调用,本章统一使用 Task:Spawn
  3. 每次异步存储调用都要包在 pcall 中。失败时保留局内数据、使用默认值或进入重试队列,不能让一次存储故障终止整局游戏。

GetDataStoreGetOrderedDataStore 只取得存储对象;真正访问数据的是对象上的 Async 方法。

key 与整数化规则

本章用稳定前缀加 UserId 生成每位玩家的 key:

text
PlayerBestScore
  player_12345 -> 80
  player_67890 -> 120

排行榜只接受整数,所以输入分数要先转成非负整数。不要直接相信 client 传来的分数;正式玩法应从 server 的局内权威状态读取。

完整模块:读取、保存与排行榜

运行端:server 文件:server/score_store.lua

下面是一份完整文件,可以直接作为后续示例的依赖。代码块内没有省略前置变量。

lua
local DataStoreService = game:GetService("DataStoreService")
local Task = game:GetService("Task")

local bestScoreStore = DataStoreService:GetDataStore("Tutorial_PlayerBestScore_v1")
local rankingStore = DataStoreService:GetOrderedDataStore("Tutorial_BestScoreRanking_v1")

local ScoreStore = {}

local function GetPlayerKey(player)
    return "player_" .. tostring(player.UserId)
end

local function ToIntegerScore(score)
    score = tonumber(score) or 0
    return math.max(0, math.floor(score))
end

local function UpdateMaximum(store, key, score)
    return pcall(function()
        return store:UpdateAsync(key, function(previousValue)
            local previousScore = tonumber(previousValue) or 0
            return math.max(previousScore, score)
        end)
    end)
end

function ScoreStore.Load(player, onLoaded)
    local key = GetPlayerKey(player)

    Task:Spawn(function()
        local ok, valueOrError = pcall(function()
            return bestScoreStore:GetAsync(key)
        end)

        if not ok then
            print("[SE Lua Guide][WARN] 读取最高分失败,使用默认值 0:", valueOrError)
            onLoaded(0)
            return
        end

        onLoaded(ToIntegerScore(valueOrError))
    end)
end

function ScoreStore.Save(player, score, onSaved)
    local key = GetPlayerKey(player)
    local finalScore = ToIntegerScore(score)

    Task:Spawn(function()
        local saveOk, savedValueOrError = UpdateMaximum(bestScoreStore, key, finalScore)
        if not saveOk then
            print("[SE Lua Guide][WARN] 最高分存档写入失败:", savedValueOrError)
        end

        local rankingOk, rankingValueOrError = UpdateMaximum(rankingStore, key, finalScore)
        if not rankingOk then
            print("[SE Lua Guide][WARN] 排行榜写入失败:", rankingValueOrError)
        end

        if onSaved then
            onSaved(saveOk, rankingOk, savedValueOrError, rankingValueOrError)
        end
    end)
end

function ScoreStore.PrintTop(limit)
    limit = math.max(1, math.floor(tonumber(limit) or 10))

    Task:Spawn(function()
        local ok, pagesOrError = pcall(function()
            -- false 表示降序,高分排在前面。
            return rankingStore:GetSortedAsync(false, limit)
        end)

        if not ok then
            print("[SE Lua Guide][WARN] 排行榜读取失败:", pagesOrError)
            return
        end

        local items = pagesOrError:GetCurrentPage()
        print("[SE Lua Guide] ===== 最高分排行榜 =====")
        for rank, item in ipairs(items) do
            print(string.format("第 %d 名: %s, %d", rank, item.Key, item.Value))
        end
    end)
end

return ScoreStore

为什么保存要用 UpdateAsync

不要用“先 GetAsync,再 SetAsync”保存最高分。两次并发保存可能同时读到旧值,随后较晚完成的低分把高分覆盖掉。

UpdateAsync 会把当前值交给转换函数,并在冲突时重试。因此转换函数必须满足两点:

  • 只根据参数计算并返回新值,不在里面打印、发事件或修改外部状态;它可能执行多次。
  • 若要取消本次更新,可以返回 nil。本章始终返回新旧分数的最大值。

为什么两个写入要分别处理

bestScoreStorerankingStore 是两个存储对象。普通存档写成功,不代表排行榜也成功;反过来也一样。示例分别得到 saveOkrankingOk,这样上层可以只重试失败的一项。

在玩家生命周期中使用模块

运行端:server 文件:server/main.lua

lua
local Players = game:GetService("Players")
local ScoreStore = require("server.score_store")

local sessionScores = {}
local boundPlayers = {}

local function OnPlayerAdded(player)
    if boundPlayers[player] then
        return
    end
    boundPlayers[player] = true
    sessionScores[player] = 0

    ScoreStore.Load(player, function(savedBest)
        -- 玩家可能在异步读取完成前离开。
        if not boundPlayers[player] then
            return
        end

        sessionScores[player] = savedBest
        print("[SE Lua Guide] 已读取历史最高分:", player.Name, savedBest)
    end)
end

Players.PlayerAdded:Connect(OnPlayerAdded)
for _, player in ipairs(Players:GetPlayers()) do
    OnPlayerAdded(player)
end

Players.PlayerRemoving:Connect(function(player)
    local finalScore = sessionScores[player] or 0

    ScoreStore.Save(player, finalScore, function(saveOk, rankingOk)
        if saveOk and rankingOk then
            print("[SE Lua Guide] 玩家最高分保存完成:", player.Name)
        end
    end)

    boundPlayers[player] = nil
    sessionScores[player] = nil
end)

-- 玩法逻辑只更新 server 内存中的权威分数。
-- 例如结算时:sessionScores[player] = math.max(sessionScores[player] or 0, roundScore)

这里先订阅 PlayerAdded,再处理当前在线玩家,避免脚本启动期间漏掉玩家。异步读取完成时还会检查玩家是否仍在线,避免把过期结果写回已清理的会话状态。

示例中的 sessionScores 只是最小教学状态。真实项目应由 server 玩法逻辑更新它;不要把 client 上报的数字直接当作权威分数。

读取更多排行榜页

GetSortedAsync(false, 10) 返回分页对象:

  • false:按 value 降序排列。
  • 10:每页最多 10 条。
  • GetCurrentPage():读取当前页的数组。
  • IsFinished:当前页是否已经是最后一页。
  • AdvanceToNextPageAsync():异步移动到下一页,也必须在协程和 pcall 中调用。

每个排行榜条目是 OrderedDataStoreKeyValueInfo,本章使用其中的 KeyValue。如果要连续翻页,请在同一个 Task:Spawn 回调里完成读取,并为每次 AdvanceToNextPageAsync() 单独处理失败。

保存时机与退出兜底

推荐在这些节点保存:

  • 一局结算完成;
  • 获得重要进度;
  • 玩家离开;
  • 服务器关闭前。

不要每帧保存,也不要每得 1 分就写一次。PlayerRemoving 中启动的异步保存不保证在服务器关闭前完成。正式项目还应使用公开的 game:BindToClose(...) 做关服兜底,并配合待保存队列、有限重试和超时策略。第 22 章的综合项目会把这些工程化问题放回完整玩法上下文中讨论。

常见错误

在 client 中直接使用 DataStoreService

DataStore 是服务端能力。client 只能表达操作意图;server 必须校验意图,并从权威状态计算要保存的值。

在正式存储名上直接练习

教程代码也会真的写数据。使用带 Tutorial_ 和版本号的隔离名称,在测试地图验证成功、失败和重试边界;不要把示例直接指向正式玩家存档。

裸调用 Async 方法

所有 Async 方法都要在协程里调用。本章统一放进 Task:Spawn(function() ... end),并用 pcall 处理运行失败。

用 GetAsync + SetAsync 保存最高分

这会产生“后完成的旧数据覆盖新数据”的竞态。需要基于旧值修改时优先使用 UpdateAsync

在 UpdateAsync 转换函数里做副作用

转换函数可能因冲突重试而执行多次。不要在里面发奖励、打印一次性日志或修改外部表。

最高分排行榜使用 IncrementAsync

IncrementAsync 表示累加,适合累计次数或累计金币;最高分应使用 UpdateAsync 取新旧最大值。

OrderedDataStore 存非整数

排行榜 value 必须是整数。写入前统一用 tonumbermath.floormath.max 规整。

把两次写入当成一个事务

普通存档和排行榜可能只成功一个。分别记录结果,并让重试逻辑只补失败部分。

练习任务

  1. 在 server 内存中模拟玩家本局得分,并在结算时调用 ScoreStore.Save
  2. 调用 ScoreStore.PrintTop(5),观察降序排列的前 5 名。
  3. Save 的回调增加“仅重试失败存储”的待办记录,不要写无限重试。

本章验收标准

  • [ ] 我知道 DataStore 只能由 server 访问。
  • [ ] 我知道所有 Async 方法必须在协程中调用,并使用 pcall
  • [ ] 我能解释 UpdateAsync 为什么比 GetAsync + SetAsync 更适合保存最高分。
  • [ ] 我知道 UpdateAsync 的转换函数可能执行多次,因此不能包含副作用。
  • [ ] 我能用 OrderedDataStore:GetSortedAsync(false, 10) 读取降序排行榜。
  • [ ] 我知道普通存档与排行榜的两次写入不是事务。
  • [ ] 我使用隔离的测试地图、账号范围和带版本前缀的存储名练习。

本章产物

  • 一份 server-only 的 score_store.lua,集中封装读取、最高分更新和排行榜查询。
  • 一份接入 PlayerAdded / PlayerRemovingserver/main.lua
  • 一张待完善清单:关服兜底、有限重试、失败项补写和存档版本迁移。

本章代码的目标是建立正确的最小存储边界,不是直接成为生产级存档系统。涉及真实玩家资产前,还要为失败重试、限频、数据迁移和观测告警做专项设计。

本章 API 对照

下一章预告

核心玩法的数据闭环到这里就完整了。下一章进入按需进阶内容:物理、Raycast、约束和运动器。