Skip to content

CloudData 事务与排错 ​

本文集中说明身份、初始化、原子提交、限额和失败结果。完整数据类型与 API 见 CloudData 系统,首次接入见快速指南。

重要概念提醒 ​

访问数据时先区分以下标识:

  • Player.Id (int):临时的游戏槽位编号,不适用于云数据
  • User.UserId (long):持久的用户标识,用于玩家云数据
  • 保留 UserId (long):项目自行约定的 0 < userId < 10000 数值,用于天梯表、游戏统计等非玩家全局数据,不要求对应真实账号。正式平台用户 ID 大于 10000,通常为 9 位数及以上;用法见全局数据说明

身份参数的含义:

  • 使用 long userId 参数表示玩家 UserId 或项目保留 UserId;保留 ID 建议定义为 GameStatisticsUserId 等有业务含义的常量
  • 使用 Player player 参数在游戏逻辑中自动提取 UserId
  • 避免使用 long playerId 参数名,容易误导为 Player.Id

Key 大小写语义:

  • 远端云数据数据库保存和比较 key 时按大小写不敏感语义处理,不要用 Gold 和 gold 表示不同业务字段
  • WasiCore 包装层的按 key 取值便利方法和批量查询返回字典也使用大小写不敏感比较
  • 项目可集中定义 key 常量,避免无意引用不同拼写;小写或下划线属于项目约定

查询与提交的结果类型不同 ​

csharp
#if SERVER
var query = await CloudData.QueryUserDataAsync([userId], ["level"]);
if (!query)
{
    Game.Logger.LogWarning("读取存档失败: {Result}", query.Result);
    return;
}

var result = await CloudData.ForUser(userId)
    .SetData("level", newLevel)
    .WithDescription("更新关卡进度")
    .ExecuteAsync();
if (result != UserCloudDataResult.Success)
{
    Game.Logger.LogWarning("提交存档失败: {Result}", result);
}
#endif

片段中的 userId、newLevel 是业务参数。查询返回 UserCloudDataResult<T>,支持布尔判断;事务返回 UserCloudDataResult 枚举,需与 Success 比较,没有 IsSuccess 属性。错误不一定以异常形式返回,单纯捕获网络异常不能处理这些结果。

有 Player 时可以使用 CloudData.ForPlayer(player),需要离线用户或保留 UserId 时使用 ForUser(userId)。客户端传入的 userId、金额或领取状态仍需由服务端按请求者身份和业务规则验证。

等服务入口和身份就绪 ​

读取玩家存档前,分别确认云数据服务已就绪、目标玩家已有有效 UserId,以及依赖的玩法初始化已完成。

csharp
#if SERVER
var userId = player.User?.UserId ?? 0;
if (userId <= 0)
{
    return; // 在身份就绪的流程中再次发起加载。
}

await CloudData.WaitUntilReadyAsync();
var archive = await CloudData.QueryUserDataAsync([userId], ["archive_level"]);
if (!archive)
{
    Game.Logger.LogWarning("读取存档失败: {Result}", archive.Result);
    return;
}
#endif

也可检查 CloudData.IsReady 或订阅 Game.OnUserCloudDataServiceInitialization,再进入统一加载入口。多条就绪回调可能先后到达,项目应避免重复加载和重复应用存档。

ServiceNotInitialized 表示服务尚未就绪;每 100~200ms 重试不会修复初始化顺序。其他失败结果应按具体含义处理,不能统一理解成“没有存档”并覆盖默认值。

同一次业务提交使用同一事务 ​

同一用户的扣款、发货、进度和领取条件,应在能表达该业务的同一个事务中提交。先查余额再写入不能替代事务内校验,查询后余额仍可能被其他请求修改。

  • 扣款使用 CostCurrency(key, amount),要求 amount > 0;余额不足会使事务失败。
  • AddCurrency 是增量操作,允许负值且可能扣成负余额;不能用负的 AddCurrency 替代要求余额充足的扣款。
  • 消耗资源本身不保证请求幂等;重复领取仍需持久的领取条件或业务记录。
  • WithDescription(...) 用于审计上下文,不是去重键。为每次重试生成一个新 GUID 也不能防止重复发货。

跨用户原子提交 ​

转账等需要跨用户一致性的业务,使用 ForUsers 声明涉及的全部用户,再通过 ExecuteSingleCommitAsync 一次提交:

csharp
#if SERVER
// fromUserId/toUserId 是已验证的不同用户,amount 为已验证的正数。
var result = await CloudData.ForUsers(fromUserId, toUserId)
    .ForUser(fromUserId, b => b.CostCurrency("gold", amount))
    .ForUser(toUserId, b => b.AddCurrency("gold", amount))
    .ExecuteSingleCommitAsync("玩家金币转账");
#endif

ForUser 传入未在 ForUsers 中声明的用户会抛出 ArgumentException。

ExecuteAllAsync 是按用户分别提交并返回各自结果,允许部分成功。需要整体回滚的转账不能用它替代 ExecuteSingleCommitAsync,也不应拆成两次提交再靠局内的委托列表“保证退款”。跨越其他系统、无法在同一云数据事务完成的业务,才需要另外设计可追踪的补偿与重试流程。

失败和超时后的处理 ​

先区分明确的业务拒绝、初始化未完成、限额,以及发送失败或超时。超时不能直接证明服务端未执行;重新提交扣款、奖励或退款前,按业务记录核对结果。只重试已确认可安全重放的操作,并限制次数和退避间隔。

交易条件、去重和完整事务示例见 CloudData 系统。

控制查询范围与分页 ​

以下列表片段在服务端执行;ProcessItem 表示项目自己的逐项处理函数。

多个用户或多个 key 可以合并查询;只读取业务需要的数据。一次查询没有返回的数据不能直接当作全部为空。

限制返回数量与完整扫描 ​

csharp
// 大列表限制返回数量
var recentItems = await CloudData.QueryUserListItemsAsync(
    userId: userId,
    key: "inventory",
    maxCount: 50  // 避免一次性加载所有物品
);

如果业务目标是“分次读完整个列表”,用 ScanUserListItemsAsync 的 NextBeforeItemUuid 游标继续下一批:

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);

这个模式按 ItemUUID 倒序推进,正常情况下不会重复读取已经扫描过的列表项;它不提供强一致快照,扫描中的新增项通常留到下一轮,删除或移动项会影响后续批次。

有上限数据的重置语义 ​

CappedData 的周期重置把当前值重置为 0。若用它实现每日恢复的体力,可以存“已消耗体力”,显示剩余值时用“上限减已消耗”;不能把重置理解成自动将当前值填满。

线性回复、VIP 加速等是项目业务逻辑,读取记录并计算剩余体力不会自动将计算结果持久化。重置周期、上限变更与数据类型说明见 CloudData 系统。

请求限额和消息大小 ​

云数据默认按游戏局共享额度:每窗口 300 次读请求、300 次事务提交、48 MiB 累计提交量阈值;正常 1 倍速下窗口为 60 秒。单次完整序列化响应默认最多 32 MiB - 100 KiB(33,452,032 字节)。读写次数和响应大小可由服务端管理配置调整。

额度不按玩家或 key 分配;同局存档、背包、签到、运营邮件等相关请求应统一预算。窗口按游戏局累计计时刷新,受倍速和暂停影响,不是自然分钟或滑动窗口;具体时间规则见默认硬限制与计数规则。一次批量查询计 1 次读,一次 commit 计 1 次写;ExecuteAllAsync() 按有操作的用户分别消耗写次数,ExecuteSingleCommitAsync() 只提交一次。后端业务失败或超时不退还已消耗的次数。

例如 10 位玩家各每 2 秒提交一次,正常 60 秒窗口内就会用完 300 次写额度。应合并脏数据、降低非关键存档频率,并为结算、购买、领取和其他业务留出余量。48 MiB 统计包含协议开销,且是先检查已有累计量再累加本次消息;不要依赖最后一次可能跨过阈值的行为,也不要把它理解为单次存档上限。

出现 TooManyReadOperations、TooManyWriteOperations 或 MessageSizeTooLarge 时,不要立即原样重试;请求频率或累计提交量超限需要等待窗口刷新,响应过大则需要缩小查询范围。

  • TooManyReadOperations:合并查询,减少轮询,把多个 key / userId 放进同一次查询。
  • TooManyWriteOperations:合并事务,避免逐字段、逐奖励、逐列表项单独提交。
  • MessageSizeTooLarge:累计提交量达到 48 MiB 阈值时等待窗口刷新;单次响应过大时减少查询数据,读取大 ListItem 列表用 ScanUserListItemsAsync 分批扫描。写入大型存档时控制各数据块与总提交量,拆分后也要核算读写次数。

验证事务行为 ​

在明确的测试环境和测试数据范围内,覆盖余额不足、条件失败、重复领取、并发写入以及超时后重试。提交后回读数据,并结合事务描述核对实际执行结果;失败或超时不能直接视为“肯定没写入”。

测试初始化时,AddCurrency 是增量操作,重复执行会累加余额;不要把它当成重置数据的方法。项目测试不能直接套用示例 UserId 去写不明归属的数据。游戏内用户和保留 UserId 的区别见本文开头。

key 的大小写不敏感语义见本文重要概念提醒。事务可通过 WithDescription(...) 记录领取、购买或补偿等业务上下文,便于和回读结果对应;无需为此遵循固定的类名、方法名或描述格式。