下载 App
10/16案例分享
【案例分享】服务端云变量实战:排行榜只认整数、昵称要另查、写请求每分钟 300 次封顶
投稿方向:星火编辑器论坛「案例分享」专区 / 生态贡献度
类型:图文案例
素材来源:dontstop 项目 serverCloud 接入实测 + 引擎 serverCloud 接口文档
一句话结论
凡是玩家改客户端就能白拿好处的数据,写入动作必须落在服务端的 serverCloud 上 —— 客户端只负责发请求和画界面。
我上一个版本把计分写在客户端,排行榜里冒出过 12 秒 4 万分的记录。把 SetInt 那几行从客户端搬到服务端之后,同样的玩法,榜首是 1 万 2 千分。中间没加任何反外挂模块,只是换了写入位置。
先把两块 API 的边界认清
同一个项目里跑着两个进程:客户端(UrhoXRuntime)和服务端(UrhoXServer)。云变量接口也是两套,名字差一个字母。
clientCloud:serverCloud
跑在哪:客户端 服务端进程
能操作谁的数据:只有当前登录用户自己 任意用户(每个接口第一个参数就是 uid)
覆盖范围:Score + Rank Score + Rank + Money + List + Item + Message + Quota
跨域原子事务:无 BatchCommit
记住一句话就够:clientCloud 是"我的设置",serverCloud 是"服务器说了算"。
服务端拿 uid 的方式固定这么写:
local connection = eventData["Connection"]:GetPtr("Connection")
local uid = connection.identity["user_id"]:GetInt64()
数据落在哪:三个域,一个排行榜
这块是踩坑最集中的地方,先列表。
写入接口:落到哪个域 排行榜收录 读回来从哪个参数取
serverCloud:Set(uid, key, value):scores ❌ 不收 回调第 1 个参数 scores
serverCloud:SetInt(uid, key, n):iscores ✅ 收 回调第 2 个参数 iscores
serverCloud:Add(uid, key, delta):iscores ✅ 收 回调第 2 个参数 iscores
子对象 money / list / item / message / quota:各自独立域 ❌ 不收 各自回调
排行榜是拿 iscores 里的整数排的。 你写进去的是字符串、浮点、table,排行榜那一栏就是空的 —— 不报错,GetRankList 也不报错,就是没有你。
回调签名也顺手记一下:ok = function(scores, iscores)。两个表,别只接一个。
完整代码一:进房间加载玩家数据
-- 服务端脚本。playerCache 是服务端进程内的内存表,不是云变量
local playerCache = {}
function OnPlayerJoined(eventType, eventData)
local connection = eventData["Connection"]:GetPtr("Connection")
local uid = connection.identity["user_id"]:GetInt64()
serverCloud:BatchGet(uid)
:Key("level")
:Key("gold")
:Key("kills")
:Key("high_score")
:Fetch({
ok = function(scores, iscores)
playerCache[uid] = {
level = iscores.level or 1,
gold = iscores.gold or 0,
kills = iscores.kills or 0,
high_score = iscores.high_score or 0,
}
local resData = VariantMap()
resData["Data"] = Variant(cjson.encode(playerCache[uid]))
connection:SendRemoteEvent("PlayerDataLoaded", true, resData)
end,
error = function(code, reason)
print("[Server] 读档失败", code, reason)
end,
})
end
四个 key 一次请求拿完。之后这局游戏里的读写都走 playerCache,不再碰网络 —— 这是把写请求压到 300 次/分钟以下的主要手段。
完整代码二:排行榜查询(含昵称)
排行榜返回的只有 userId 和分数,没有昵称。昵称得用全局函数 GetUserNickname() 单独查,服务端调用是同步的,读 SERVER_PLAYER_AUTH_INFOS,不发网络请求。
function HandleLeaderboardRequest(eventType, eventData)
local connection = eventData["Connection"]:GetPtr("Connection")
local uid = connection.identity["user_id"]:GetInt64()
-- 参数:key, 起始名次, 条数, [是否升序], events
serverCloud:GetRankList("high_score", 1, 10, {
ok = function(rankList)
local userIds, rows = {}, {}
for i, item in ipairs(rankList) do
userIds[#userIds + 1] = item.userId
rows[#rows + 1] = {
rank = i,
uid = item.userId,
score = item.iscore.high_score or 0,
nick = "",
}
end
-- 一次批量查 10 个昵称
if #userIds > 0 then
GetUserNickname({
userIds = userIds,
onSuccess = function(nicks)
local map = {}
for _, info in ipairs(nicks) do
map[info.userId] = info.nickname or ""
end
for _, row in ipairs(rows) do
row.nick = map[row.uid] or ""
end
end,
})
end
-- 自己的名次单独查,查完一次性回包,客户端只画一遍
serverCloud:GetUserRank(uid, "high_score", {
ok = function(rank, score)
local resData = VariantMap()
resData["Data"] = Variant(cjson.encode({
list = rows,
myUid = uid,
myRank = rank or 0, -- rank 为 nil 表示未上榜
myScore = score or 0,
}))
connection:SendRemoteEvent("LeaderboardResponse", true, resData)
end,
})
end,
error = function(code, reason)
print("[Server] 排行榜查询失败", code, reason)
end,
})
end
配套两个接口:GetRankTotal(key, events) 拿总人数,升序榜把第 4 个参数传 true(GetRankList(key, 1, 10, true, events))。
完整代码三:服务端权威写入 + 内存攒批
计分和发钱全部走事务,一次提交。
-- 服务端结算一次击杀。客户端发来的任何数字都不作为写入依据
function AwardKill(killerUid, victimUid)
local c = serverCloud:BatchCommit("击杀奖励")
c:ScoreAddInt(killerUid, "kills", 1)
c:MoneyAdd(killerUid, "gold", 50)
c:ListAdd(killerUid, "kill_log", { target = victimUid, time = os.time() })
c:Commit({
ok = function()
local cache = playerCache[killerUid]
if cache then cache.gold = cache.gold + 50 end
end,
error = function(code, reason)
print("[Server] 结算失败", code, reason) -- code == -429 就是超频
end,
})
end
高频分数不能一条一条写云。先在服务端内存里攒,攒够 20 条或定时器每 30 秒扫一遍再落盘:
local pending = {}
function AddScore(uid, delta)
local p = pending[uid] or { score = 0, n = 0 }
p.score = p.score + delta
p.n = p.n + 1
pending[uid] = p
if p.n >= 20 then FlushScore(uid) end
end
function FlushScore(uid)
local p = pending[uid]
if not p or p.n == 0 then return end
pending[uid] = nil
serverCloud:BatchSet(uid)
:Add("high_score", p.score)
:Add("kills", p.n)
:Save("分数落盘", {
ok = function()
print("[Server] 落盘", uid, p.score)
end,
error = function(code, reason)
pending[uid] = p -- 写失败要把数据塞回去,别丢
print("[Server] 落盘失败", code, reason)
end,
})
end
一局游戏 3 分钟,玩家打 400 次命中。逐条写 = 400 次写请求,1 分 20 秒就撞线。攒批之后是 20 次。
硬限制清单
超限一律走 error 回调,code = -429。
限制项:值
读请求频率:300 次/分钟
写请求频率:300 次/分钟
数据量:48 MB/分钟
单次 BatchCommit / BatchSet 的操作数:1000
被 -429 打回来的时候,事务是整体失败的 —— 钱没扣、道具没发,但你以为成功了的那次结算也没发生。所以 error 回调里一定要打日志,别写成空函数。
数据量那一栏最容易误判。一条普通记录序列化后 100 到 200 字节,按 300 次/分钟写满,也就 60 KB/分钟,离 48 MB 差三个数量级。真正会撞的是把整份存档往单个 key 里塞:
-- ❌ 一个 key 装 8 MB 的对局回放,写 6 次就超 48 MB/分钟
serverCloud:Set(uid, "replay", bigReplayTable)
-- ✅ 大块数据走 list 拆项,或者干脆落到服务端自己的存储
serverCloud.list:Add(uid, "replay_chunk", { frame = i, data = chunk })
频次限制是"次数"约束,数据量限制是"体积"约束,两者独立。小记录写得多只会撞 300 次/分钟;大记录写得少也可能先撞 48 MB/分钟。
服务端 vs 客户端:这条线画在哪
逻辑:放哪 理由
计分规则(命中加分、通关加分):服务端 服务端才算数,客户端算的只是"预估"
成绩提交:客户端发事件 → 服务端校验后写 客户端手里没有写权限
货币增减、商店购买:服务端 客户端能扣自己的钱,就能给自己发钱
每日签到、限次挑战:服务端 quota 客户端计数清个存档就重置了
排行榜拉取 + 补昵称:服务端 昵称要服务端同步查,客户端拉不到全量
本地存档(音量、画质、按键):客户端 clientCloud 只影响自己,不影响公平
血条动画、伤害数字、镜头抖动:客户端 纯表现,改它没有收益
判断标准就一句:改客户端能不能白拿好处。能白拿的,搬服务端。
踩过的坑
坑 1:用 Set 写排行榜字段
-- ❌ GetRankList 里永远没有这条记录,也不报错
serverCloud:Set(uid, "high_score", 9999)
-- ✅ 排行榜只排 iscores,走整数接口
serverCloud:SetInt(uid, "high_score", 9999)
serverCloud:Add(uid, "high_score", 120) -- 增量
坑 2:从 scores 里取整数
-- ❌ scores 里没有 gold,打印出来永远是 nil
serverCloud:Get(uid, "gold", {
ok = function(scores, iscores)
print(scores.gold)
end,
})
-- ✅ SetInt / Add 写的都在第 2 个参数
serverCloud:Get(uid, "gold", {
ok = function(scores, iscores)
print(iscores.gold or 0)
end,
})
坑 3:客户端直接写成绩
-- ❌ 客户端。玩家改一行脚本就是满分
clientCloud:SetInt("high_score", 999999)
-- ✅ 客户端只发事件
SendRemoteEvent("SubmitScore", true, { Score = Variant(myScore) })
服务端收事件,自己卡范围、自己比大小:
function OnSubmitScore(eventType, eventData)
local connection = eventData["Connection"]:GetPtr("Connection")
local uid = connection.identity["user_id"]:GetInt64()
local score = eventData["Score"]:GetInt()
if score < 0 or score > 100000 then return end -- 上限由服务端定
serverCloud:Get(uid, "high_score", {
ok = function(scores, iscores)
if score > (iscores.high_score or 0) then
serverCloud:SetInt(uid, "high_score", score)
end
end,
})
end
坑 4:排行榜直接显示 userId
-- ❌ 界面上是一串 19 位数字
label:SetText("第 1 名 " .. tostring(item.userId))
-- ✅ 昵称批量查,服务端同步返回
GetUserNickname({
userIds = userIds,
onSuccess = function(nicks) --[[ 填进 rows ]] end,
})
坑 5:分两步写钱和道具
-- ❌ 中间挂掉 = 扣了钱没拿到东西
serverCloud.money:Cost(uid, "gold", 500)
serverCloud.list:Add(uid, "inventory", { name = "火焰剑" })
-- ✅ 一次 BatchCommit,跨域原子
local c = serverCloud:BatchCommit("购买火焰剑")
c:MoneyCost(uid, "gold", 500)
c:ListAdd(uid, "inventory", { key = "weapon", name = "火焰剑" })
c:Commit({
ok = function() print("购买成功") end,
error = function(code, reason) print("余额不足", reason) end,
})
MoneyCost 的 amount 必须大于 0,余额不足会走 error 回调,钱和道具都不动。
坑 6:签到次数自己数
-- ❌ 内存变量记次数,服务重启就清零,玩家无限领
if checkinCount[uid] == nil then checkinCount[uid] = 1; giveGold(uid) end
-- ✅ 配额带上限和刷新周期,塞进同一个事务
local c = serverCloud:BatchCommit("每日签到")
c:QuotaAdd(uid, "daily_checkin", 1, 1, "day", 1) -- 加 1 次、上限 1 次、按天刷新
c:MoneyAdd(uid, "gold", 200)
c:Commit({
ok = function() print("签到成功 +200 金币") end,
error = function(code, reason) print("今日已签到", reason) end,
})
配额超限走 error,那 200 金币也不会加进去。分开写就会变成"金币发了、次数没记上"。
坑 7:每次命中都写云
-- ❌ 一局 400 次命中 = 400 次写请求,撞 -429
function OnHit(uid)
serverCloud:Add(uid, "hits", 1)
end
-- ✅ 内存攒批,20 条或 30 秒落一次
AddScore(uid, 1) -- 见上面 FlushScore
坑 8:事务里用 ScoreSetStr
-- ❌ 已废弃接口,别在新代码里出现
c:ScoreSetStr(uid, "name", "abc")
-- ✅ 字符串走 ScoreSet
c:ScoreSet(uid, "nickname_cache", "abc")
ScoreDeleteStr 同理,一并避开。接口表里还留着它们,但新代码不该引用。
坑 9:把 userId 当 string
排行榜返回的 userId 是整数,GetInt64() 拿到的也是整数。做 key 拼字符串、做表索引、做相等比较,一路都是数字语义。
-- ❌ uid 是 number,字符串比较拿不到结果
if entry.userId == tostring(uid) then ... end
-- ✅ 需要拼字符串才转,比较保持数字
local idStr = tostring(entry.userId)
if entry.userId == uid then ... end
我在 playerCache[uid] 上踩过一次:某处把 uid 转成了字符串当 key 写,另一处用原始整数读,读出来永远是 nil。缓存"命中不了"但不报错,排查了半小时。
坑 10:玩家离线时把待提交的批次丢了
-- ❌ 玩家一走就清缓存,连没落盘的那一批一起丢了
function OnPlayerLeft(uid)
pending[uid] = nil
playerCache[uid] = nil
end
-- ✅ 先刷批次,再清缓存
function OnPlayerLeft(uid)
FlushScore(uid) -- 提交 pending[uid]
playerCache[uid] = nil
end
FlushScore 提交的是已经攒在 pending 里的数字,不读 playerCache,所以清缓存的顺序无所谓,但 pending 一定要在玩家走之前提交出去。那 20 条没到阈值、也没等到 30 秒定时器的分数,就靠这一下捞回来。
提交失败时 FlushScore 会把批次塞回 pending。玩家已经离场,这一批就挂在内存里等下一次同 uid 的 AddScore 触发 —— 或者服务端重启时丢掉。真要保底,FlushScore 的 error 分支里再记一条 log:Write(LOG_ERROR, ...),至少让日志留痕。
可迁移的经验
写完一段玩家数据逻辑,问自己一句:"我删掉客户端里这几行,把同样的数字直接发给服务器,能不能拿到一样的结果?"
答"能" —— 这段逻辑的位置就是对的,客户端真的只是个显示器。
答"不能" —— 说明服务端在信客户端。那就把判断挪过去,客户端只留一个 SendRemoteEvent。
排行榜、货币、限次,这三类东西被改一次的收益最大,也是玩家最先去试的地方。
(完)










