主题
第 11 章:数据存储与排行榜
在服务端安全读写 DataStore,并设计更新、排序、失败处理和测试命名空间。
你会学到什么
- 为什么存档必须由 server 读取和写入。
- 如何区分普通
DataStore与可排序的OrderedDataStore。 - 为什么所有
Async方法都要在协程中调用,并用pcall处理失败。 - 如何用
UpdateAsync并发安全地保存“历史最高分”。 - 如何把读取、保存和排行榜查询封装成可复用模块。
本章只保存一个整数最高分。先把最小链路做对,再考虑背包、任务进度、版本迁移和重试队列。
开始前:先隔离测试数据
DataStore 会写入持久化数据,不要拿正式玩家存档直接练习。第一次运行前先确认:
- 使用专门的测试地图或测试账号;目标环境不允许存储时,只做代码检查,不把失败当成教程逻辑错误。
- 存储名带教程前缀和版本,例如
Tutorial_PlayerBestScore_v1,不要复用正式项目现有名称。 - 测试 key 只使用测试玩家,并提前写好“失败时保留局内分数、不覆盖旧值”的预期。
- 完成练习后记录实际使用的地图、账号范围、存储名和结果,不在日志里输出完整玩家存档。
下面的名称仍是示例。复制到真实项目时,应改成项目自己的、带环境和版本含义的稳定名称。
先认识两种存储
| 存储 | 本章用途 | value |
|---|---|---|
DataStore | 保存每位玩家的历史最高分 | 可以是任意受支持的数据;本章只存整数 |
OrderedDataStore | 按最高分生成排行榜 | 必须是整数 |
二者不能互相替代:普通存档适合保存玩家数据,排行榜存储负责按整数排序。示例会把同一个最高分分别写入两处;这两次写入不是事务,可能出现一处成功、另一处失败,因此必须分别记录结果并准备重试。
三条硬规则
DataStoreService是 server-only。client 可以发出“结算请求”,但权威分数必须由 server 校验、计算和保存。GetAsync、SetAsync、UpdateAsync、IncrementAsync、GetSortedAsync等异步方法必须在协程中调用,本章统一使用Task:Spawn。- 每次异步存储调用都要包在
pcall中。失败时保留局内数据、使用默认值或进入重试队列,不能让一次存储故障终止整局游戏。
GetDataStore 和 GetOrderedDataStore 只取得存储对象;真正访问数据的是对象上的 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。本章始终返回新旧分数的最大值。
为什么两个写入要分别处理
bestScoreStore 与 rankingStore 是两个存储对象。普通存档写成功,不代表排行榜也成功;反过来也一样。示例分别得到 saveOk 和 rankingOk,这样上层可以只重试失败的一项。
在玩家生命周期中使用模块
运行端: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,本章使用其中的 Key 和 Value。如果要连续翻页,请在同一个 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 必须是整数。写入前统一用 tonumber、math.floor 和 math.max 规整。
把两次写入当成一个事务
普通存档和排行榜可能只成功一个。分别记录结果,并让重试逻辑只补失败部分。
练习任务
- 在 server 内存中模拟玩家本局得分,并在结算时调用
ScoreStore.Save。 - 调用
ScoreStore.PrintTop(5),观察降序排列的前 5 名。 - 为
Save的回调增加“仅重试失败存储”的待办记录,不要写无限重试。
本章验收标准
- [ ] 我知道 DataStore 只能由 server 访问。
- [ ] 我知道所有
Async方法必须在协程中调用,并使用pcall。 - [ ] 我能解释
UpdateAsync为什么比GetAsync+SetAsync更适合保存最高分。 - [ ] 我知道
UpdateAsync的转换函数可能执行多次,因此不能包含副作用。 - [ ] 我能用
OrderedDataStore:GetSortedAsync(false, 10)读取降序排行榜。 - [ ] 我知道普通存档与排行榜的两次写入不是事务。
- [ ] 我使用隔离的测试地图、账号范围和带版本前缀的存储名练习。
本章产物
- 一份 server-only 的
score_store.lua,集中封装读取、最高分更新和排行榜查询。 - 一份接入
PlayerAdded/PlayerRemoving的server/main.lua。 - 一张待完善清单:关服兜底、有限重试、失败项补写和存档版本迁移。
本章代码的目标是建立正确的最小存储边界,不是直接成为生产级存档系统。涉及真实玩家资产前,还要为失败重试、限频、数据迁移和观测告警做专项设计。
本章 API 对照
下一章预告
核心玩法的数据闭环到这里就完整了。下一章进入按需进阶内容:物理、Raycast、约束和运动器。
