主题
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("玩家金币转账");
#endifForUser 传入未在 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(...) 记录领取、购买或补偿等业务上下文,便于和回读结果对应;无需为此遵循固定的类名、方法名或描述格式。