主题
CloudData 云数据 Skill
何时使用
任务涉及以下内容时使用本 Skill:
- 读取或写入玩家云存档、货币、背包、签到、任务进度、排行榜数据
- 使用项目约定的保留 UserId 保存非玩家的全局数据,如天梯表、游戏统计、赛季状态或全服活动进度
- 处理
CloudData/CloudDataApi/TransactionBuilder/UserCloudDataResult - 排查
TableNotFound、ServiceNotInitialized、FailedToSend、云数据请求超时或启动阶段多次失败 - 设计“服务端读取云数据,再推送快照给客户端 UI”的流程
- 判断代码是否误用
Player.Id作为云数据用户标识
硬规则
- CloudData 只能在服务端逻辑中使用;客户端 UI、纯客户端调试和本地表现层不能直接访问 CloudData。
- 玩家云数据使用
User.UserId,不能用本局槽位编号Player.Id代替。非玩家全局数据使用项目约定的保留 UserId,见下节。 - 使用
Player便利 API 前,确认它是真人玩家且player.User?.UserId > 0。 - 玩家首轮存档加载必须同时等待 CloudData ready 和玩家 UserId ready;保留 UserId 已知的全局数据只需等待 CloudData ready。
- 云数据 key 在远端数据库保存和比较时按大小写不敏感语义处理;不要生成依赖
Gold/gold区分字段的方案,统一推荐小写 ASCII + 下划线 key,并集中定义常量。 - WasiCore 包装层按 key 取值的便利方法和批量查询返回字典也按大小写不敏感比较;排查读取问题时仍应先检查项目内是否混用了不同大小写写法。
- UI 不应直接阻塞在云数据请求上;服务端应先加载/容错,再通过快照、消息或同步属性更新客户端。
- 查询返回
UserCloudDataResult<T>,可以用if (result)/result.IsSuccess;事务返回UserCloudDataResult枚举,用== UserCloudDataResult.Success。 - 云数据默认每个游戏局、每窗口共享 300 次读请求、300 次事务提交、48 MiB 累计提交量阈值;正常 1 倍速窗口为 60 秒。单次完整响应默认最多 32 MiB - 100 KiB。生成方案时必须据此核算全局读写预算,预留其他业务余量,见下节。
- 多用户事务中,
ForUsers(...)只声明用户集合;ForAllUsers(...)为所有已声明用户添加相同操作,ForUser(userId, ...)为指定用户添加不同操作。指定用户必须已包含在最初的ForUsers(...)中,否则会抛出ArgumentException。 ExecuteAllAsync()按用户分别提交并返回逐用户结果,适合允许部分成功的批量操作;跨用户交易等需要整体成功或整体回滚时,使用ExecuteSingleCommitAsync()。不要为了实现不同用户执行不同操作而默认退回手动构造TransactionOperation。- ListItem 默认是 raw
byte[];需要长文本或小数列表项时,用PrepareUtf8ListItem/PrepareDoubleListItem、UpdateListItemUtf8/UpdateListItemDouble。读取时保留Value/ValueSpan原始 payload 字节,按类型读取用ValueType或TryGetUtf8Value/TryGetDoubleValue。 - 需要分次读完大 ListItem 列表时,使用
ScanUserListItemsAsync/ScanPlayerListItemsAsync和ListItemScanBatch.NextBeforeItemUuid游标,不要建议把QueryUserListItemsAsync(..., maxCount)无限调大。 - 手动构造
TransactionOperation列表时,如果要新增 ListItem,先用 builder 的PrepareListItem/PrepareUtf8ListItem/PrepareDoubleListItem拿到ListItemReference,再调用itemRef.ToListItemAddOperation();不要建议用户自己生成或猜测列表项 UUID。
TableNotFound 与首次发布
TableNotFound 表示当前项目在所选部署环境中缺少对应的数据表。常见原因是修改了 project/map_settings.json 中的项目 ID,或将旧环境的项目放到另一个环境调试,而该项目尚未在目标环境发布。这种迁移后的缺表是预期状态,不能仅据此判断无头调试、玩家 ID 或邮件代码有 Bug。
编辑器正常新建项目时会创建配套的邮件数据表;复制项目目录、切换安装/环境、执行 prepare 或双端编译不等于在新环境完成建表。在目标环境成功发布一次项目可自动建表;这不会迁移旧环境的玩家数据。
- 核对实际安装/登录所选的部署环境、项目 ID 和该 ID 在目标环境的发布记录。部署环境与本局
Debug/Test/Formal运行环境是不同概念;不要通过更改邮件查询的env绕过部署环境缺表。 - 若确为迁移后尚未发布,向用户说明原因和首次发布的作用;已有明确的目标项目、环境及发布授权时再走正常发布流程。日志提示本身不授予发布权限,也不要为消除警告擅自改 ID。
- 首次发布成功后重新调试,检查同一项目的云数据/邮件读取。已在该环境发布仍报缺表时,保留项目、环境、时间及原始错误交由后台排查,不无限重试或反复发布。
缺表不同于查询成功但玩家尚无记录。邮件已读/领取状态、货币及存档读取应保留失败结果,不将 TableNotFound 改成成功空数据,不以默认值回写或继续发奖。GameSystemUI 的 MailSystem: load mail player data failed: TableNotFound 来自 CloudData 状态读取,处理方式相同。
执行硬上限与预算
以下为当前服务端代码默认值;读写次数和响应大小可由服务端管理配置调整。
| 限制 | 默认值 | 超限结果 |
|---|---|---|
| 每窗口读请求 | 300 次 | TooManyReadOperations |
| 每窗口事务提交 | 300 次 | TooManyWriteOperations |
| 每窗口累计提交量阈值 | 48 MiB(50,331,648 字节,含序列化协议开销) | MessageSizeTooLarge |
| 单次完整序列化响应 | 32 MiB - 100 KiB(33,452,032 字节) | MessageSizeTooLarge |
- 读写分别计数,但同局所有玩家、保留 UserId 和业务模块共享各自额度;运营邮件等使用同一读计数入口的请求也消耗读额度。不能按“每位玩家 300 次/分钟”设计。
- 正常 1 倍速下窗口为 60 秒,按游戏局累计计时,在超过窗口后的下一次请求时一起清零读、写和累计提交量;不是自然分钟或滑动窗口。非默认倍速时计时阈值为
60000 / (服务端播放倍速 × 游戏局倍速)毫秒;暂停时该累计计时不推进。 - 按实际发出的请求计数:一次多用户、多 key 的
QueryUserDataAsync(...)为 1 次读;普通数据与货币分开查询各计一次;一次列表扫描批次为 1 次读。不要把一个业务加载函数默认视为一次请求。 - 一次 commit 无论包含多少用户、key 或操作,计 1 次写。
ExecuteAllAsync()对 N 个有操作的用户分别提交,计 N 次写;ExecuteSingleCommitAsync()合并为一次,计 1 次写。仍需按业务决定是否需要跨用户原子性。 - 请求交给后端前已计数,后端业务失败或超时不退还次数;因累计提交量超限被拒绝的提交也已先消耗一次写额度。
- 48 MiB 检查的是此前累计量,再累加本次提交;最后一次可能跨过阈值,不应依赖此行为。它不是单次存档上限,单次响应大小另有限制。
- 例如 10 位玩家各每 2 秒提交一次,就会用完正常 60 秒窗口的 300 次写额度;应通过脏数据合并、批量查询和统一调度留出余量。累计量超限需等窗口刷新,响应过大需缩小查询;拆分请求时同时核算次数。
完整规则见默认硬限制与计数规则。
保留 UserId 与全局数据表
CloudData 按 userId 区分逻辑数据表,再按 key 区分数据,不要求 userId 对应真实存在的平台账号。正式平台用户的 UserId 大于 10000,通常为 9 位数及以上。生成全局数据方案时,可使用项目自行约定的 0 < userId < 10000 数值:例如 1 用于天梯表,2 用于游戏统计,3 用于全服活动进度。这些是可自行分配的示例,不是框架内置账号。
- 集中定义有业务含义的
long常量,保持 ID 与用途稳定;通过CloudData.ForUser(id)写入,并传同一 ID 查询。 - 无需注册账号、构造
Player或等待玩家连入;服务端等待CloudData.WaitUntilReadyAsync()后即可读写。 - 全局数据仍遵循云数据的项目和环境隔离规则。天梯排名计算及多局并发统计的更新策略由业务实现;避免把各局读后覆盖写入当成可靠累加。
- 调试模拟用户 ID 从
100起随槽位递增;示例优先选1、2、3便于区分,不把调试编号当作正式账号范围。
完整示例见使用保留 UserId 保存全局数据。评审时不要因 ID 没有对应真实用户就否定这种用法。
ListItem 分批扫描
QueryUserListItemsAsync(..., maxCount) 适合读取最新 N 条或小列表。要分次读完整个列表,使用游标扫描:
csharp
long? cursor = null;
do
{
var batch = await CloudData.ScanUserListItemsAsync(
userId: userId,
key: "inventory",
batchSize: 100,
beforeItemUuid: cursor
);
if (!batch)
{
Game.Logger.LogWarning("扫描列表失败: {Result}", batch.Result);
break;
}
foreach (var item in batch.Data.Items)
{
ProcessItem(item);
}
cursor = batch.Data.NextBeforeItemUuid;
}
while (cursor is not null);扫描按 IListItemRecord.ItemUUID 倒序推进,正常情况下不会重复返回已经扫描过的列表项。它不是强一致快照:扫描过程中新增的列表项通常会留到下一轮完整扫描,删除或移动列表项会影响后续批次。
启动阶段推荐模式
启动时序可能是:脚本注册完成、GameStart、玩家连入、CloudData provider ready 交错到达。不要在 GameStart 后立刻无条件读云数据。
csharp
#if SERVER
using GameCore.UserCloudData;
private bool _gameStarted;
private bool _bootstrapCompleted;
private Player? _mainPlayer;
private async Task TryLoadArchiveAsync()
{
if (!_gameStarted || !_bootstrapCompleted || _mainPlayer is null)
{
return;
}
var userId = _mainPlayer.User?.UserId ?? 0;
if (userId <= 0)
{
Game.Logger.LogInformation("等待玩家 UserId 后再读取云存档");
return;
}
await CloudData.WaitUntilReadyAsync();
var result = await CloudData.QueryUserDataAsync(
userIds: [userId],
keys: ["archive_level", "archive_progress"]
);
if (!result)
{
Game.Logger.LogWarning("云存档读取失败: {Result}", result.Result);
return;
}
ApplyArchive(result.Data.First());
}
#endif事件驱动写法也可以:
- 在
Game.OnUserCloudDataServiceInitialization中调用统一的TryStartArchiveLoads()。 - 在玩家连入事件中缓存
eventArgs.User.UserId,再调用同一个TryStartArchiveLoads()。 - 在 GameStart/bootstrap 完成时也调用同一个函数。
TryStartArchiveLoads()内部一次性检查_gameStarted && _bootstrapCompleted && CloudData.IsReady && userId > 0。
错误处理
ServiceNotInitialized 和 FailedToSend 不要混为一类。
| 结果 | 含义 | 推荐处理 |
|---|---|---|
ServiceNotInitialized | CloudData 服务入口尚未 ready | 用 CloudData.WaitUntilReadyAsync() 或 Game.OnUserCloudDataServiceInitialization 解决时序 |
FailedToSend | 请求发送失败,常见于网络或 provider 临时异常 | 有限次数重试,使用退避,不要无限刷日志 |
TooManyReadOperations | 短时间内查询过多 | 合并查询、降低轮询频率,稍后重试 |
TooManyWriteOperations | 短时间内事务提交过多 | 合并相关改动,避免逐字段提交,稍后重试 |
MessageSizeTooLarge | 窗口累计提交量达到阈值,或单次响应过大 | 累计量超限需等窗口刷新;响应过大需缩小查询或分批扫描 |
QueryUserIdMissing | UserId 缺失或无效 | 玩家数据确认 User.UserId > 0;全局数据检查传入的项目保留 ID 为正数 |
InsufficientCurrency / CapExceeded | 业务规则失败 | 返回业务错误,不要当网络错误重试 |
重试只适合 FailedToSend、超时等临时发送问题。对 ServiceNotInitialized 做 100~200ms 高频轮询会制造启动日志噪声,并掩盖真正的 ready gate 缺失。对 TooManyReadOperations / TooManyWriteOperations / MessageSizeTooLarge 原样立即重试通常没有帮助,应先降频、合并或缩小数据。
UI 与快照分层
客户端 UI 可以立即创建,但内容应有“等待服务端快照”的状态。不要把 UI 控件创建绑定到云数据读取成功。
推荐流程:
- 客户端
OnGameUIInitialization创建基础 UI。 - 服务端等待 CloudData ready 后读取数据;玩家存档还需等待玩家 UserId ready。
- 服务端构建一个完整快照并发送给客户端。
- 客户端收到快照后刷新 UI;未收到前显示加载或默认占位。
- 云数据失败时服务端发送降级快照或明确错误状态,客户端不反复重建 UI。
排障清单
看日志时优先确认这些时间点:
CloudDataManager: Service marked as initialized.Game Starting...- 玩家连入事件里拿到的
User.UserId - 首次云数据查询开始时间和结果
- 是否出现
ServiceNotInitialized、FailedToSend、TooManyReadOperations、TooManyWriteOperations、MessageSizeTooLarge或超时 - 项目是否把同一业务 key 写成了不同大小写;远端语义不区分大小写,本地代码仍应统一常量写法
- 是否存在按帧/短间隔轮询云数据、逐字段提交事务,或一次提交过大的 Blob / ListItem payload
判断方式:
- CloudData ready 早于 GameStart,通常不会复现启动竞态。
- GameStart/玩家连入早于 CloudData ready,项目若没有 ready gate 就可能先打出多次
ServiceNotInitialized。 - 客户端日志里出现 UI 等待,不代表客户端在访问 CloudData;CloudData 请求应主要看服务端日志。
- 纯客户端调试没有服务端和 CloudData,不能用来验证云数据流程。
相关文档
docs/guides/CloudDataQuickStart.mddocs/best-practices/CloudDataBestPractices.mddocs/systems/CloudDataSystem.mddocs/guides/FrameworkLifecycle.md