Skip to content

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 或双端编译不等于在新环境完成建表。在目标环境成功发布一次项目可自动建表;这不会迁移旧环境的玩家数据。

  1. 核对实际安装/登录所选的部署环境、项目 ID 和该 ID 在目标环境的发布记录。部署环境与本局 Debug/Test/Formal 运行环境是不同概念;不要通过更改邮件查询的 env 绕过部署环境缺表。
  2. 若确为迁移后尚未发布,向用户说明原因和首次发布的作用;已有明确的目标项目、环境及发布授权时再走正常发布流程。日志提示本身不授予发布权限,也不要为消除警告擅自改 ID。
  3. 首次发布成功后重新调试,检查同一项目的云数据/邮件读取。已在该环境发布仍报缺表时,保留项目、环境、时间及原始错误交由后台排查,不无限重试或反复发布。

缺表不同于查询成功但玩家尚无记录。邮件已读/领取状态、货币及存档读取应保留失败结果,不将 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 不要混为一类。

结果含义推荐处理
ServiceNotInitializedCloudData 服务入口尚未 ready用 CloudData.WaitUntilReadyAsync() 或 Game.OnUserCloudDataServiceInitialization 解决时序
FailedToSend请求发送失败,常见于网络或 provider 临时异常有限次数重试,使用退避,不要无限刷日志
TooManyReadOperations短时间内查询过多合并查询、降低轮询频率,稍后重试
TooManyWriteOperations短时间内事务提交过多合并相关改动,避免逐字段提交,稍后重试
MessageSizeTooLarge窗口累计提交量达到阈值,或单次响应过大累计量超限需等窗口刷新;响应过大需缩小查询或分批扫描
QueryUserIdMissingUserId 缺失或无效玩家数据确认 User.UserId > 0;全局数据检查传入的项目保留 ID 为正数
InsufficientCurrency / CapExceeded业务规则失败返回业务错误,不要当网络错误重试

重试只适合 FailedToSend、超时等临时发送问题。对 ServiceNotInitialized 做 100~200ms 高频轮询会制造启动日志噪声,并掩盖真正的 ready gate 缺失。对 TooManyReadOperations / TooManyWriteOperations / MessageSizeTooLarge 原样立即重试通常没有帮助,应先降频、合并或缩小数据。

UI 与快照分层 ​

客户端 UI 可以立即创建,但内容应有“等待服务端快照”的状态。不要把 UI 控件创建绑定到云数据读取成功。

推荐流程:

  1. 客户端 OnGameUIInitialization 创建基础 UI。
  2. 服务端等待 CloudData ready 后读取数据;玩家存档还需等待玩家 UserId ready。
  3. 服务端构建一个完整快照并发送给客户端。
  4. 客户端收到快照后刷新 UI;未收到前显示加载或默认占位。
  5. 云数据失败时服务端发送降级快照或明确错误状态,客户端不反复重建 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.md
  • docs/best-practices/CloudDataBestPractices.md
  • docs/systems/CloudDataSystem.md
  • docs/guides/FrameworkLifecycle.md