主题
云数据系统 (CloudData System)
系统概述
云数据系统是一个企业级的用户数据管理框架,提供统一的API来处理用户数据的查询、存储和事务操作。该系统设计用于支持大规模多人在线游戏的用户数据管理需求。
重要概念区分:Player.Id vs User.UserId
在使用云数据系统之前,必须理解以下重要概念:
| 概念 | 类型 | 含义 | 持久性 | 用途 |
|---|---|---|---|---|
| Player.Id | int | 当局游戏的槽位编号(0,1,2...) | 临时 | 当前游戏会话内的玩家标识 |
| User.UserId | long | 平台用户的全局唯一标识 | 持久 | 云数据存储的用户标识 |
核心原则:
- 玩家云数据使用 User.UserId,因为它是持久的用户身份标识;非玩家数据可使用项目约定的保留 UserId
- Player.Id 仅在当局游戏有意义,不同游戏会话中同一用户的 Player.Id 可能不同
- 便利API自动处理转换,开发者可以直接传入 Player 对象,系统会自动提取对应的 UserId
使用保留 UserId 保存全局数据
云数据按 userId 区分数据归属,可以把不同 userId 理解为不同的逻辑数据表,再通过 key 区分表中的数据。传入的 userId 不要求对应真实存在的平台账号,因此也可以保存不属于任何玩家的全局数据。
正式平台用户的 UserId 大于 10000,通常为 9 位数及以上。项目可以自行约定 0 < userId < 10000 的数值作为保留 ID,用于天梯表、游戏统计、赛季状态、全服活动进度等。建议集中定义有业务含义的常量,并保持各用途的 ID 稳定,例如:
| 项目约定的 UserId | 用途示例 |
|---|---|
1 | 天梯表、赛季信息 |
2 | 游戏统计 |
3 | 全服活动进度 |
这些数值由项目自行分配,不需要注册账号或创建 Player。这里的“全局”指项目内非玩家的数据归属,数据仍遵循云数据既有的项目和环境隔离规则。
下面的服务端示例向游戏统计表写入一份统计快照,再按相同 ID 和 key 查询:
csharp
#if SERVER
const long GameStatisticsUserId = 2;
await CloudData.WaitUntilReadyAsync();
var writeResult = await CloudData.ForUser(GameStatisticsUserId)
.SetData("completed_matches", 1200L)
.SetData("statistics_date", "2026-09-12")
.ExecuteAsync();
if (writeResult == UserCloudDataResult.Success)
{
var queryResult = await CloudData.QueryUserDataAsync(
userIds: [GameStatisticsUserId],
keys: ["completed_matches", "statistics_date"]);
if (!queryResult.IsSuccess)
{
Game.Logger.LogWarning("读取游戏统计失败: {Result}", queryResult.Result);
}
}
else
{
Game.Logger.LogWarning("保存游戏统计失败: {Result}", writeResult);
}
#endif保留 ID 已由项目确定,因此只需等待云数据服务就绪,无需等待玩家连入或获取玩家 UserId。上例是覆盖写入统计快照;多个游戏局共同累加统计时,应使用适合该数据类型的事务增量操作,避免各局读后覆盖写入造成更新丢失。保存天梯数据也不会自动完成排名计算,排名规则仍由项目实现。
调试模式下,模拟用户 ID 从 100 起随槽位递增;示例采用 1、2、3,便于与调试玩家数据区分。
核心特性
- 统一API入口 - 解决查询和修改操作分离的不一致问题
- 流式事务构建 - 类型安全的链式API设计
- 智能优化 - 自动合并和优化事务操作
- 异步编程模式 - 完整的async/await支持
- 类型安全 - 编译时错误检查和null安全
- ACID事务 - 保证数据一致性和完整性
- 批量操作 - 支持高效的批量查询和更新
支持的数据类型
| 数据类型 | 描述 | 用途示例 |
|---|---|---|
| BigInt | 64位整数 | 玩家等级、经验值、积分 |
| VarChar255 | 可变长度字符串(≤255字符) | 玩家昵称、状态信息 |
| Blob | 二进制数据 | 序列化对象、配置数据 |
| Currency | 货币类型 | 金币、钻石、代币 |
| CappedData | 有上限数据(支持定时重置为0) | 体力、每日活跃度、周签到进度、月度任务积分 |
| ListItem | 列表项 | 背包物品、好友列表 |
架构设计
分层架构
┌─────────────────────────────────────────────────────────┐
│ 统一API入口层 │
│ CloudDataApi / CloudData │
└─────────────────────┬───────────────────────────────────┘
│
┌─────────────────────┼───────────────────────────────────┐
│ 操作实现层 │
│ CloudDataOperations + TransactionBuilder │
└─────────────────────┬───────────────────────────────────┘
│
┌─────────────────────┼───────────────────────────────────┐
│ 管理服务层 │
│ CloudDataManager │
└─────────────────────┬───────────────────────────────────┘
│
┌─────────────────────┼───────────────────────────────────┐
│ 引擎接口层 │
│ IUserCloudDataProvider │
└─────────────────────────────────────────────────────────┘核心组件
1. CloudDataApi
- 职责: 统一的API入口点
- 特点: 简化别名,委托实现
- 使用:
CloudData.QueryUserDataAsync(),CloudData.ForUser()
2. CloudDataOperations
- 职责: 具体操作实现
- 特点: 参数验证,错误处理,请求管理
- 功能: 查询操作、事务执行、ID生成
3. TransactionBuilder
- 职责: 流式事务构建
- 特点: 链式API,智能优化,类型安全
- 功能: 事务构建、操作验证、批量处理
4. CloudDataManager
- 职责: 底层管理服务
- 特点: Provider生命周期,异步响应处理
- 功能: 事件管理、等待器管理、日志记录
5. Internal组件
- AsyncWaiter: 异步操作等待器,支持直接await
- IdGenerator: 雪花算法ID生成器,全局唯一
- ListItemReference: 类型安全的列表项引用
API参考
基础查询操作
批量查询用户数据
csharp
// 方式1: 使用 UserId (直接指定用户ID)
var result = await CloudData.QueryUserDataAsync(
userIds: [123L, 456L],
keys: ["level", "score", "last_login"]
);
// 方式2: 使用 Player 对象 (便利方法,自动提取UserId)
var players = [player1, player2];
var playerResult = await CloudData.QueryPlayersDataAsync(
players: players,
keys: ["level", "score", "last_login"]
);
if (result.IsSuccess)
{
foreach (var userResult in result.Data)
{
Game.Logger.LogInformation("User {UserId}:", userResult.UserId);
Game.Logger.LogInformation(" Level: {Level}", userResult.BigIntData["level"]);
Game.Logger.LogInformation(" Score: {Score}", userResult.BigIntData["score"]);
}
}查询货币数据
csharp
var currencyResult = await CloudData.QueryCurrencyAsync(
userIds: [userId],
keys: ["gold", "diamond", "energy"]
);查询列表项
csharp
var inventoryResult = await CloudData.QueryUserListItemsAsync(
userId: userId,
key: "inventory",
maxCount: 50
);QueryUserListItemsAsync 适合读取最新的若干条列表项,或读取确认很小的列表。对于可能持续增长的大列表,不要通过不断调大 maxCount 来尝试一次性读完,应使用游标扫描。
分批扫描列表项
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)
{
Game.Logger.LogInformation("扫描到列表项: {ItemUUID}", item.ItemUUID);
}
cursor = batch.Data.NextBeforeItemUuid;
}
while (cursor is not null);ScanUserListItemsAsync 每批按 IListItemRecord.ItemUUID 倒序返回,并用 ListItemScanBatch.NextBeforeItemUuid 作为下一批的排他游标。正常情况下,沿着这个游标向后扫描不会重复返回已经读过的列表项,适合后台整理、迁移、审计、恢复缓存等需要分次读完整个列表的场景。
扫描不是强一致数据库快照:扫描过程中新增的列表项通常会留到下一轮完整扫描;删除或移动列表项会影响之后批次还能读到的内容。如果只需要展示最近记录,继续使用 QueryUserListItemsAsync(..., maxCount) 会更简单。
按ID查找列表项
csharp
var itemResult = await CloudData.FindListItemByIdAsync(itemId);
if (itemResult.IsSuccess && itemResult.Data.IsFound)
{
var item = itemResult.Data.Record;
Game.Logger.LogInformation("Found item: {ItemUUID}", item.ItemUUID);
}事务操作
简单数据操作
csharp
// 方式1: 使用 UserId (当你有User.UserId时)
var result = await CloudData.ForUser(userId) // userId是User.UserId,不是Player.Id!
.SetData("level", 25)
.SetData("last_login", DateTime.UtcNow.ToString())
.AddToData("experience", 1000)
.ExecuteAsync();
// 方式2: 使用 Player 对象 (便利方法,推荐用于游戏逻辑)
var playerResult = await CloudData.ForPlayer(player) // 自动提取User.UserId
.SetData("level", 25)
.SetData("last_login", DateTime.UtcNow.ToString())
.AddToData("experience", 1000)
.WithDescription("玩家升级")
.ExecuteAsync();货币操作
csharp
// 方式1: 使用 UserId
var result = await CloudData.ForUser(userId)
.AddCurrency("gold", 100)
.CostCurrency("energy", 10)
.WithDescription("完成任务奖励")
.ExecuteAsync();
// 方式2: 使用 Player 对象 (推荐)
var playerResult = await CloudData.ForPlayer(player)
.AddCurrency("gold", 100)
.CostCurrency("energy", 10)
.WithDescription("完成任务奖励")
.ExecuteAsync();列表项操作
添加列表项
csharp
var builder = CloudData.ForUser(userId);
// 准备新物品
var weaponRef = builder.PrepareListItem("inventory", weaponData);
var armorRef = builder.PrepareListItem("inventory", armorData);
// 执行事务
var result = await builder
.AddListItem(weaponRef)
.AddListItem(armorRef)
.AddCurrency("gold", 500)
.WithDescription("战斗胜利奖励")
.ExecuteAsync();
// 获取生成的物品ID
var weaponId = weaponRef.Id;
var armorId = armorRef.Id;添加 UTF-8 / Double 列表项
列表项默认仍然保存 raw byte[]。如果列表项内容本身就是长文本或小数值,可以使用带类型标记的便利方法;底层仍是 ListItem,只是在 payload 包头中标记为 Utf8 或 Double。
csharp
var builder = CloudData.ForUser(userId);
var tradeLogRef = builder.PrepareUtf8ListItem(
"trade_logs",
JsonSerializer.Serialize(new
{
tradeId,
targetUserId,
amount = 12.5
}));
var scoreRef = builder.PrepareDoubleListItem("damage_samples", 128.75);
var result = await builder
.AddListItem(tradeLogRef)
.AddListItem(scoreRef)
.WithDescription($"记录交易 {tradeId}")
.ExecuteAsync();查询列表项时,Value / ValueSpan 仍返回解包后的 payload 字节;如果需要按类型读取,可以看 ValueType 或使用 TryGetUtf8Value / TryGetDoubleValue:
csharp
var queryResult = await CloudData.QueryUserListItemsAsync(userId, "trade_logs");
if (queryResult)
{
foreach (var record in queryResult.Data)
{
if (record.TryGetUtf8Value(out var json))
{
Game.Logger.LogInformation("交易记录: {Json}", json);
}
}
}更新列表项
csharp
// 方式1: 直接使用全局唯一ID更新
var updateResult = await CloudData.ForUser(userId)
.UpdateListItem(itemId, newItemData)
.WithDescription("升级武器")
.ExecuteAsync();
// 方式2: 使用ListItemReference更新
var updateResult2 = await CloudData.ForUser(userId)
.UpdateListItem(itemRef, enhancedItemData)
.WithDescription("强化装备")
.ExecuteAsync();
// UTF-8 / Double payload 也可以直接更新
var updateLogResult = await CloudData.ForUser(userId)
.UpdateListItemUtf8(logItemId, updatedJson)
.WithDescription("更新交易日志")
.ExecuteAsync();
var updateScoreResult = await CloudData.ForUser(userId)
.UpdateListItemDouble(scoreItemId, 256.5)
.WithDescription("更新小数列表项")
.ExecuteAsync();删除列表项
csharp
// 方式1: 直接使用全局唯一ID删除
var deleteResult = await CloudData.ForUser(userId)
.DeleteListItem(itemId)
.WithDescription("出售物品")
.ExecuteAsync();
// 方式2: 使用ListItemReference删除
var deleteResult2 = await CloudData.ForUser(userId)
.DeleteListItem(itemRef)
.WithDescription("丢弃过期物品")
.ExecuteAsync();
// 方式3: 批量删除
var deleteItemRefs = await GetExpiredItems(userId);
var batchDeleteResult = await CloudData.ForUser(userId)
.DeleteListItems(deleteItemRefs)
.WithDescription("清理过期物品")
.ExecuteAsync();移动列表项
csharp
// 将物品从背包移动到仓库
var moveResult = await CloudData.ForUser(userId)
.MoveListItem(itemId, "warehouse")
.WithDescription("存储物品到仓库")
.ExecuteAsync();
// 使用引用移动
var moveResult2 = await CloudData.ForUser(userId)
.MoveListItem(itemRef, "equipped_items")
.WithDescription("装备物品")
.ExecuteAsync();有上限数据操作
重要说明:重置机制会将当前值重置为0。对于体力系统,这意味着已消耗体力重置为0,即体力恢复满值。
重置机制详解:
- 重置时间到达时,系统会自动将当前值设置为 0
- 上限值保持不变
- 存储的是"已消耗量"而不是"剩余量"
体力系统的正确用法:
核心理念:存储"已消耗体力"而非"剩余体力",重置为0 = 体力恢复满值
csharp
// 体力系统:存储已消耗的体力,重置为0表示体力恢复满值
.ModifyCappedData("energy_consumed", 20, 100, UserDataResetOption.Daily()) // 消耗20点体力
// UI显示计算:剩余体力 = 最大体力(100) - 已消耗体力(energy_consumed)
var remainingEnergy = maxEnergy - energyConsumed;
// 其他适用场景:每日任务次数
.ModifyCappedData("daily_quest_count", 1, 10, UserDataResetOption.Daily())
// PVP挑战次数
.ModifyCappedData("pvp_attempts", 1, 5, UserDataResetOption.Daily())为什么这样设计?
- 重置语义清晰:重置为0 = "清空已消耗量" = 体力恢复满值
- 防护机制完善:上限值防止过度消耗,事务自动失败
- 逻辑统一:所有有上限资源都遵循"重置为0"的统一语义
线性回复体力系统的实现
对于需要连续回复体力的游戏(如每分钟回复1点),可以利用CappedData的LastUpdateTime字段:
csharp
// 方案1:利用CappedData的LastUpdateTime实现线性回复
public async Task<EnergyInfo> GetCurrentEnergyWithRecovery(long userId)
{
var result = await CloudData.QueryCappedDataAsync(
userIds: [userId],
keys: ["energy_consumed"]
);
if (!result.IsSuccess) return null;
var energyData = result.Data.First().CappedData["energy_consumed"];
var maxEnergy = energyData.Cap;
var consumedEnergy = energyData.Value;
var lastUpdateTime = energyData.LastUpdateTime;
// 计算自上次更新以来的回复量
var timeSinceUpdate = DateTime.Now - lastUpdateTime;
var recoveredEnergy = (long)(timeSinceUpdate.TotalMinutes * ENERGY_RECOVERY_RATE); // 每分钟回复率
// 计算当前实际剩余体力
var actualConsumed = Math.Max(0, consumedEnergy - recoveredEnergy);
var currentEnergy = Math.Min(maxEnergy, maxEnergy - actualConsumed);
return new EnergyInfo
{
CurrentEnergy = currentEnergy,
MaxEnergy = maxEnergy,
LastUpdateTime = lastUpdateTime
};
}
// 消耗体力时更新LastUpdateTime
public async Task<UserCloudDataResult> ConsumeEnergyWithRecovery(long userId, long amount)
{
// 先获取当前状态(包含回复计算)
var currentState = await GetCurrentEnergyWithRecovery(userId);
if (currentState.CurrentEnergy < amount)
return UserCloudDataResult.CapExceeded;
// 计算消耗后的新已消耗总量(同步回复效果到数据库)
var currentConsumed = currentState.MaxEnergy - currentState.CurrentEnergy;
var newConsumed = currentConsumed + amount;
// 先查询当前数据库值,然后设置为新的已消耗量
var queryResult = await CloudData.QueryCappedDataAsync([userId], ["energy_consumed"]);
if (!queryResult.IsSuccess) return queryResult;
var oldConsumed = queryResult.Data.First().CappedData["energy_consumed"].Value;
var deltaConsumed = newConsumed - oldConsumed; // 需要调整的差值
return await CloudData.ForUser(userId)
.ModifyCappedData("energy_consumed", deltaConsumed, currentState.MaxEnergy)
.WithDescription($"消耗体力 {amount}(同步回复效果)")
.ExecuteAsync();
}混合方案:普通数据 + 货币数据
csharp
// 方案2:使用普通数据存储时间,货币数据控制消耗
public class HybridEnergySystem
{
private const long ENERGY_RECOVERY_RATE = 1; // 每分钟回复1点
private const long MAX_ENERGY = 100;
public async Task<EnergyInfo> GetCurrentEnergy(long userId)
{
// 查询上次离线时间和当前体力
var dataResult = await CloudData.QueryUserDataAsync(
userIds: [userId],
keys: ["last_offline_time", "current_energy"]
);
if (!dataResult.IsSuccess) return null;
var userData = dataResult.Data.First();
var lastOfflineStr = userData.VarChar255Data.GetValueOrDefault("last_offline_time");
var currentEnergy = userData.BigIntData.GetValueOrDefault("current_energy", MAX_ENERGY);
if (DateTime.TryParse(lastOfflineStr, out var lastOfflineTime))
{
// 计算离线期间的回复量
var offlineMinutes = (DateTime.UtcNow - lastOfflineTime).TotalMinutes;
var recoveredEnergy = (long)(offlineMinutes * ENERGY_RECOVERY_RATE);
currentEnergy = Math.Min(MAX_ENERGY, currentEnergy + recoveredEnergy);
}
return new EnergyInfo
{
CurrentEnergy = currentEnergy,
MaxEnergy = MAX_ENERGY,
LastUpdateTime = lastOfflineTime
};
}
public async Task<UserCloudDataResult> ConsumeEnergy(long userId, long amount)
{
var currentState = await GetCurrentEnergy(userId);
if (currentState.CurrentEnergy < amount)
return UserCloudDataResult.LocalOperationFailed;
var newEnergy = currentState.CurrentEnergy - amount;
return await CloudData.ForUser(userId)
.SetData("current_energy", newEnergy)
.SetData("last_offline_time", DateTime.UtcNow.ToString("O"))
.WithDescription($"消耗体力 {amount}")
.ExecuteAsync();
}
}三种体力系统方案对比:
| 方案 | 适用场景 | 优势 | 注意事项 |
|---|---|---|---|
| CappedData重置 | 每日重置体力 | 简单、自动重置 | 固定重置时间 |
| CappedData+LastUpdateTime | 线性回复 | 利用现有字段、防护完善 | 需要同步回复效果到数据库 |
| 普通数据+货币数据 | 复杂回复逻辑 | 灵活、可控 | 需要手动管理上限 |
选择建议:
- 如果体力系统是定时重置(如每日0点满血复活),推荐使用CappedData重置方案
- 如果体力系统是连续回复(如每分钟回复1点),可以考虑CappedData+LastUpdateTime方案
- 如果需要复杂的回复逻辑(如VIP加速、道具加速),推荐使用混合方案
csharp
// 基础体力系统 - 存储已消耗体力,每日0点重置为0(体力恢复满值),上限100
var result = await CloudData.ForUser(userId)
.ModifyCappedData("energy_consumed", 20, 100, UserDataResetOption.Daily())
.WithDescription("进入副本消耗体力")
.ExecuteAsync();
// 每周活跃度 - 每周一重置为0,上限1000
var weeklyResult = await CloudData.ForUser(userId)
.ModifyCappedData("weekly_activity", 50, 1000, UserDataResetOption.Weekly(1, DayOfWeek.Monday))
.WithDescription("每周活跃度增加")
.ExecuteAsync();
// 消耗体力(增加已消耗量)
var consumeResult = await CloudData.ForUser(userId)
.ModifyCappedData("energy_consumed", 20, 100) // 消耗20点体力(增加已消耗量)
.WithDescription("进入副本")
.ExecuteAsync();
// 调整体力上限值
var upgradeResult = await CloudData.ForUser(userId)
.ModifyCappedData("energy_consumed", 0, 120) // 不修改当前已消耗值,只提升体力上限
.WithDescription("体力上限提升")
.ExecuteAsync();
// 查询当前有上限数据
var cappedDataResult = await CloudData.QueryCappedDataAsync(
userIds: [userId],
keys: ["energy_consumed", "weekly_activity"]
);
// UI显示计算示例
if (cappedDataResult.IsSuccess)
{
var userData = cappedDataResult.Data.First();
var energyConsumed = userData.CappedData["energy_consumed"].Value;
var maxEnergy = userData.CappedData["energy_consumed"].Cap;
var remainingEnergy = maxEnergy - energyConsumed; // 剩余体力
Game.Logger.LogInformation("剩余体力: {RemainingEnergy}/{MaxEnergy}", remainingEnergy, maxEnergy);
}重置有上限数据
csharp
// 立即重置体力(将已消耗体力重置为0,即恢复满体力)
var resetResult = await CloudData.ForUser(userId)
.ResetCappedData("energy_consumed")
.WithDescription("使用体力药水")
.ExecuteAsync();
// 重置每日任务进度
var resetQuestResult = await CloudData.ForUser(userId)
.ResetCappedData("daily_quest_progress")
.WithDescription("每日重置")
.ExecuteAsync();
// 批量重置多个有上限数据
var batchResetResult = await CloudData.ForUser(userId)
.ResetCappedData("pvp_attempts")
.ResetCappedData("daily_quest_count")
.ResetCappedData("weekly_activity")
.WithDescription("周度重置")
.ExecuteAsync();批量用户操作
csharp
var result = await CloudData.ForUsers(user1, user2, user3)
.ForAllUsers(builder => builder
.AddCurrency("daily_reward", 50)
.SetData("last_daily_reward", DateTime.UtcNow.ToString())
)
.ExecuteAllAsync();ExecuteAllAsync() 会按用户分别提交,返回每个用户自己的结果,适合批量发奖、签到补偿等允许部分成功的场景。如果业务要求跨用户原子性,例如两个玩家交易、互换排名奖励、转移公会资产,应改用单次提交。
ForUsers(...) 只声明批次中的用户集合。ForAllUsers(...) 会给所有已声明用户添加相同操作;连续调用 ForUser(userId, ...) 则可以给不同用户添加不同操作。ForUser(...) 的用户必须已出现在最初的 ForUsers(...) 中,未声明的用户会立即触发 ArgumentException,不会被静默忽略。
csharp
var tradeResult = await CloudData.ForUsers(buyerUserId, sellerUserId)
.ForUser(buyerUserId, builder => builder
.CostCurrency("gold", price)
.SetData("last_trade_id", tradeId)
)
.ForUser(sellerUserId, builder => builder
.AddCurrency("gold", price)
.SetData("last_trade_id", tradeId)
)
.ExecuteSingleCommitAsync($"玩家交易 {tradeId}");少数情况下,如果需要完全手动组织 operation list,也可以直接调用 CloudDataApi.ExecuteTransactionAsync(...):
csharp
var operations = new List<TransactionOperation>
{
TransactionOperation.CostCurrency(buyerUserId, "gold", price),
TransactionOperation.AddCurrency(sellerUserId, "gold", price),
TransactionOperation.SetVarChar255(buyerUserId, "last_trade_id", tradeId),
TransactionOperation.SetVarChar255(sellerUserId, "last_trade_id", tradeId),
};
var result = await CloudDataApi.ExecuteTransactionAsync(operations, $"玩家交易 {tradeId}");如果手动 operation list 里需要新增 ListItem,不要自己拼 itemId。先用现有 builder 的 PrepareListItem 系列方法生成 ListItemReference,再把引用转成添加操作:
csharp
var builder = CloudData.ForUser(userId);
var itemRef = builder.PrepareUtf8ListItem("trade_logs", tradeJson);
var operations = new List<TransactionOperation>
{
itemRef.ToListItemAddOperation(),
TransactionOperation.SetVarChar255(userId, "last_trade_id", tradeId),
};
var result = await CloudDataApi.ExecuteTransactionAsync(operations, $"记录交易 {tradeId}");
var listItemId = itemRef.Id;这两种单次提交方式都只返回整笔事务的结果。任一 operation 失败时,后端会回滚整个 commit,不能得到逐用户成功/失败列表。
复杂事务示例
csharp
var builder = CloudData.ForUser(userId);
// 准备物品
var rewardItems = new[]
{
("sword", swordData),
("shield", shieldData),
("potion", potionData)
};
var itemRefs = builder.PrepareListItems("inventory",
rewardItems.Select(item => item.Item2).ToArray());
// 构建复杂事务
var result = await builder
.AddListItems(itemRefs)
.AddCurrency("gold", 1000)
.AddCurrency("experience", 500)
.CostCurrency("energy", 20)
.SetData("level", newLevel)
.SetData("last_quest_completion", DateTime.UtcNow.ToString())
.WithDescription("完成史诗任务")
.WithOptimization(true)
.ExecuteAsync();
// 使用生成的物品ID
for (int i = 0; i < itemRefs.Length; i++)
{
var itemName = rewardItems[i].Item1;
var itemId = itemRefs[i].Id;
Game.Logger.LogInformation("Created {ItemName} with ID: {ItemId}", itemName, itemId);
}名称管理
检查名称可用性
csharp
var statusResult = await CloudData.CheckNameClaimedStatusAsync("guilds", "AwesomeGuild");
if (statusResult.IsSuccess)
{
if (!statusResult.Data.IsClaimed)
{
// 名称可用,可以注册
}
else
{
// 名称已被占用
}
}搜索相似名称
csharp
var searchResult = await CloudData.SearchClaimedNamesAsync("guilds", "Awesome");
if (searchResult.IsSuccess)
{
foreach (var name in searchResult.Data)
{
Game.Logger.LogInformation("Similar name: {Name}", name.Name);
}
}注册新名称
csharp
var result = await CloudData.ForUser(userId)
.ClaimNewName("guilds", "MyGuildName")
.SetData("guild_level", 1)
.SetData("guild_created", DateTime.UtcNow.ToString())
.WithDescription("创建公会")
.ExecuteAsync();删除名称
csharp
// 删除公会名称
var deleteResult = await CloudData.ForUser(userId)
.DeleteName("guilds", "MyGuildName")
.SetData("guild_deleted", DateTime.UtcNow.ToString())
.WithDescription("解散公会")
.ExecuteAsync();
// 删除玩家昵称
var nicknameDeleteResult = await CloudData.ForUser(userId)
.DeleteName("player_nicknames", "CoolPlayer123")
.WithDescription("更换昵称")
.ExecuteAsync();
// 批量删除多个名称
var batchDeleteResult = await CloudData.ForUser(userId)
.DeleteName("temp_channels", "TempChannel1")
.DeleteName("temp_channels", "TempChannel2")
.WithDescription("清理临时频道")
.ExecuteAsync();使用指南
快速开始
- 引用命名空间
csharp
using GameCore.UserCloudData;
using static GameCore.UserCloudData.CloudData; // 简化调用- 基础查询
csharp
// 查询单个用户数据
var userData = await QueryUserDataAsync([userId], ["level", "score"]);
// 查询多个用户数据
var usersData = await QueryUserDataAsync(userIds, keys);- 简单事务
csharp
// 基础数据更新
var result = await ForUser(userId)
.SetData("level", 10)
.AddCurrency("gold", 100)
.ExecuteAsync();- 错误处理
csharp
var result = await ForUser(userId).SetData("level", 10).ExecuteAsync();
if (result == UserCloudDataResult.Success)
{
// 操作成功
Game.Logger.LogInformation("数据更新成功");
}
else
{
// 处理错误
Game.Logger.LogError("操作失败: {Result}", result);
}性能优化建议
1. 批量操作
csharp
// 推荐:批量查询
var result = await QueryUserDataAsync(
userIds: allUserIds,
keys: requiredKeys
);
// 避免:循环单个查询
foreach (var userId in allUserIds)
{
await QueryUserDataAsync([userId], requiredKeys);
}2. 事务合并
csharp
// 推荐:一个事务完成所有操作
var result = await ForUser(userId)
.SetData("level", newLevel)
.AddCurrency("gold", reward)
.AddCurrency("experience", expReward)
.ExecuteAsync();
// 避免:多个独立事务
await ForUser(userId).SetData("level", newLevel).ExecuteAsync();
await ForUser(userId).AddCurrency("gold", reward).ExecuteAsync();
await ForUser(userId).AddCurrency("experience", expReward).ExecuteAsync();3. 启用事务优化
csharp
var result = await ForUser(userId)
.AddCurrency("gold", 100)
.AddCurrency("gold", 50) // 会自动合并为 +150
.AddCurrency("gold", -20) // 最终结果: +130
.WithOptimization(true) // 启用优化(默认已启用)
.ExecuteAsync();4. 服务限额友好
云数据服务会限制查询频率、提交频率和消息大小。以下为当前服务端代码的默认值;读写次数和响应大小可由服务端管理配置调整,项目应预留余量并处理限额错误。
默认硬限制与计数规则
| 项目 | 默认值 | 统计范围与超限结果 |
|---|---|---|
| 查询次数 | 每窗口 300 次读请求 | 每个游戏局共享;后续读请求返回 TooManyReadOperations |
| 事务提交次数 | 每窗口 300 次提交 | 每个游戏局共享,与读次数分别计数;后续提交返回 TooManyWriteOperations |
| 累计提交数据量 | 每窗口 48 MiB(50,331,648 字节)阈值 | 累加序列化后的提交消息大小,包含协议开销;达到阈值后,后续提交返回 MessageSizeTooLarge |
| 单次响应大小 | 32 MiB - 100 KiB(33,452,032 字节) | 完整序列化响应超过此值时返回 MessageSizeTooLarge;这是默认 32 MiB 响应配置扣除传输余量后的限制 |
- 额度属于游戏局。 同局所有玩家、保留 UserId 和各业务模块共用额度;换 userId 或 key 不会获得新额度。同局运营邮件等经过同一读计数入口的请求也会占用读额度。
- 正常 1 倍速下,一个窗口为 60 秒。 实现按游戏局累计计时检查,距上次刷新超过一个窗口后,才在下一次请求时同时清零读次数、写次数和累计提交量;不是自然分钟对齐,也不是滑动窗口或每秒匀速补充额度。非默认倍速时,计时阈值为
60000 / (服务端播放倍速 × 游戏局倍速)毫秒;游戏局暂停时该累计计时不推进,不能假定墙上时间过了 60 秒就一定恢复额度。 - 按实际请求计数,不按字段、返回行数或事务内操作数计数。 例如一次
QueryUserDataAsync(userIds, keys)批量查询计 1 次读,一次ScanUserListItemsAsync(...)扫描批次计 1 次读;分别查询普通数据与货币是两次请求。多个 key、用户或操作合并到一次 commit 后计 1 次写,仍受数据量限制。 - 区分批量提交 API。
ExecuteAllAsync()为各有操作的用户分别提交,N 个用户有操作就消耗 N 次写额度;ExecuteSingleCommitAsync()合并为一次 commit,消耗 1 次写额度,并保持跨用户事务原子性。应按业务原子性选择,再控制请求量。 - 发送后失败也可能已占用额度。 计数在请求交给云数据后端之前增加,后端业务失败或超时不会退还次数;写次数检查先于数据量检查,因此因累计数据量超限被拒绝的提交也已消耗一次写额度。
- 48 MiB 是累计阈值,不是单次消息上限。 当前实现先检查已有累计量,再累加本次消息,因此最后一次获准提交可能跨过阈值;不要依赖这部分余量,也不要据此构造一个 48 MiB 存档。查询响应限制独立生效,写入的数据若单次读回过大仍会失败。
设计读写流程时,应优先批量查询、合并相关事务,并避免在失败后用短间隔循环重试。存档、签到、背包、排行榜刷新等逻辑最好统一调度。例如 10 位玩家各每 2 秒提交一次,正常 60 秒窗口内就会用完默认 300 次写额度,其他业务将没有写额度;不要把上限当成目标吞吐量。大列表使用游标分批扫描,并同时控制每批大小和批次频率。
常见结果码:
| 结果 | 常见原因 | 推荐处理 |
|---|---|---|
TooManyReadOperations | 短时间内查询过多 | 合并查询 key 和 userId,降低轮询频率,稍后重试 |
TooManyWriteOperations | 短时间内提交事务过多 | 把相关改动合并到一次事务,避免逐字段提交 |
MessageSizeTooLarge | 窗口累计提交量达到阈值,或单次响应过大 | 累计量超限应等待窗口刷新;响应过大应减少返回数据,大列表用 ScanUserListItemsAsync 分批读取 |
错误处理策略
1. 结果检查模式
csharp
var result = await CloudData.ForUser(userId)
.SetData("level", 10)
.ExecuteAsync();
if (result == UserCloudDataResult.Success)
{
// 成功处理
}
else
{
// 根据错误类型处理
switch (result)
{
case UserCloudDataResult.QueryUserIdMissing:
Game.Logger.LogError("用户ID无效");
break;
case UserCloudDataResult.FailedToSend:
Game.Logger.LogError("网络连接问题");
break;
case UserCloudDataResult.TooManyReadOperations:
Game.Logger.LogWarning("云数据查询过于频繁,稍后重试");
break;
case UserCloudDataResult.TooManyWriteOperations:
Game.Logger.LogWarning("云数据提交过于频繁,合并事务后稍后重试");
break;
case UserCloudDataResult.MessageSizeTooLarge:
Game.Logger.LogWarning("云数据消息过大,缩小或拆分数据后重试");
break;
case UserCloudDataResult.TransactionCommitEmpty:
Game.Logger.LogError("空事务错误");
break;
case UserCloudDataResult.InsufficientCurrency:
Game.Logger.LogError("货币不足");
break;
case UserCloudDataResult.CapExceeded:
Game.Logger.LogError("上限数据值超出设定上限");
break;
default:
Game.Logger.LogError("其他错误: {ErrorType}", result);
break;
}
}2. 异常处理模式
csharp
try
{
var builder = CloudData.ForUser(userId);
builder.SetData("invalid_key_length_over_180_characters...", value); // 会抛出异常
var result = await builder.ExecuteAsync();
}
catch (ArgumentException ex)
{
// 参数验证错误
Game.Logger.LogError(ex, "参数错误: {Message}", ex.Message);
}
catch (InvalidOperationException ex)
{
// 操作逻辑错误
Game.Logger.LogError(ex, "操作错误: {Message}", ex.Message);
}数据类型最佳实践
1. 选择合适的数据类型
csharp
// 数值数据使用BigInt
.SetData("level", 25)
.SetData("experience", 15000L)
// 文本数据使用VarChar255
.SetData("player_name", "PlayerName")
.SetData("status", "online")
// 复杂对象使用Blob
var playerConfig = JsonSerializer.SerializeToUtf8Bytes(config);
.SetData("config", playerConfig)
// 货币使用专门的货币操作
.AddCurrency("gold", 100)
.CostCurrency("diamond", 10)
// 有上限数据使用ModifyCappedData,支持定时重置为0
.ModifyCappedData("energy_consumed", 20, 100, UserDataResetOption.Daily()) // 体力系统(存储已消耗量)
.ModifyCappedData("weekly_points", 5, 500, UserDataResetOption.Weekly()) // 每周积分
.ModifyCappedData("dungeon_attempts", 1, 5) // 副本次数(无重置)2. 键名规范
远端云数据数据库保存和比较 key 时按大小写不敏感语义处理。不要依赖 Gold / gold / GOLD 表示不同数据;这些写法在远端可能命中同一个字段。WasiCore 包装层的按 key 取值便利方法和批量查询返回字典也按大小写不敏感比较,以贴近远端语义。
项目内仍应统一使用小写 ASCII 与下划线,并通过常量集中管理 key,避免同一业务字段出现多种大小写写法。
csharp
// 推荐的键名规范
"level" // 简短明确
"last_login" // 下划线分隔
"guild_id" // 统一前缀
"inventory_size" // 描述性名称
// 避免的键名
"l" // 过于简短
"lastLogin" // 驼峰式(不一致)
"LEVEL" // 全大写
"user.level" // 包含特殊字符最佳实践
1. 事务设计原则
原子性操作
csharp
// 相关操作放在同一事务中
var result = await CloudData.ForUser(userId)
.CostCurrency("energy", 10) // 消耗体力
.AddCurrency("experience", 100) // 获得经验
.SetData("last_battle", DateTime.UtcNow.ToString()) // 更新时间
.WithDescription("完成战斗")
.ExecuteAsync();
// 避免拆分相关操作
await CloudData.ForUser(userId).CostCurrency("energy", 10).ExecuteAsync();
await CloudData.ForUser(userId).AddCurrency("experience", 100).ExecuteAsync();操作顺序
csharp
// 先检查资源,再消耗资源,最后给予奖励
var result = await CloudData.ForUser(userId)
.CostCurrency("gold", upgradeCost) // 先消耗
.SetData("weapon_level", newLevel) // 再更新
.AddCurrency("experience", expBonus) // 最后奖励
.ExecuteAsync();2. 列表项管理
准备-添加模式
csharp
var builder = CloudData.ForUser(userId);
// 准备阶段:生成ID,不执行数据库操作
var newItems = new[]
{
builder.PrepareListItem("inventory", weaponData),
builder.PrepareListItem("inventory", armorData),
builder.PrepareListItem("inventory", potionData)
};
// 执行阶段:原子性添加所有物品
var result = await builder
.AddListItems(newItems)
.AddCurrency("gold", 500)
.WithDescription("任务奖励")
.ExecuteAsync();
// 使用生成的ID
foreach (var item in newItems)
{
Game.Logger.LogInformation("Created item with ID: {ItemId}", item.Id);
}批量操作
csharp
// 批量准备物品
var itemsData = lootTable.GenerateRewards();
var itemRefs = builder.PrepareListItems("inventory", itemsData);
// 一次性添加所有物品
await builder.AddListItems(itemRefs).ExecuteAsync();3. 查询优化
合理使用maxCount
csharp
// 限制返回数量,避免大量数据传输
var recentItems = await CloudData.QueryUserListItemsAsync(
userId: userId,
key: "inventory",
maxCount: 50 // 只获取最新的50个物品
);
// 不限制数量(小列表)
var friends = await CloudData.QueryUserListItemsAsync(
userId: userId,
key: "friends" // maxCount: null,获取所有好友
);精确查询键
csharp
// 只查询需要的数据
var essentialData = await CloudData.QueryUserDataAsync(
userIds: [userId],
keys: ["level", "experience", "gold"] // 明确指定需要的键
);
// 避免查询不需要的数据
var allData = await CloudData.QueryUserDataAsync(
userIds: [userId],
keys: GetAllPossibleKeys() // 查询所有可能的键
);4. 错误恢复策略
重试机制
csharp
public async Task<UserCloudDataResult> ExecuteWithRetry(
Func<Task<UserCloudDataResult>> operation,
int maxRetries = 3)
{
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
var result = await operation();
if (result.IsSuccess || result != UserCloudDataResult.FailedToSend)
{
return result; // 成功或非网络错误,不重试
}
if (attempt < maxRetries)
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 指数退避
await Game.Delay(delay);
}
}
catch (Exception ex) when (attempt < maxRetries)
{
// 记录日志,继续重试
Game.Logger.LogWarning("Attempt {Attempt} failed: {Message}", attempt, ex.Message);
}
}
return UserCloudDataResult.FailedToSend;
}性能指南
1. 查询性能
批量查询优化
csharp
// 一次查询多个用户
var leaderboard = await CloudData.QueryUserDataAsync(
userIds: topUserIds, // 批量用户ID
keys: ["level", "score"] // 只查询需要的字段
);
// 循环单个查询(效率低)
var results = new List<UserData>();
foreach (var userId in topUserIds)
{
var userData = await CloudData.QueryUserDataAsync(
[userId],
["level", "score"]
);
results.AddRange(userData.Data);
}2. 事务性能
操作合并
csharp
// 智能合并(自动优化)
var result = await CloudData.ForUser(userId)
.AddCurrency("gold", 100)
.AddCurrency("gold", 50) // 自动合并为 AddCurrency("gold", 150)
.AddCurrency("silver", 200)
.AddCurrency("silver", -50) // 自动合并为 AddCurrency("silver", 150)
.SetData("level", 10)
.SetData("level", 11) // 自动优化为 SetData("level", 11)
.WithOptimization(true) // 启用优化
.ExecuteAsync();操作验证
csharp
// 启用验证,检测冲突操作
var result = await CloudData.ForUser(userId)
.SetData("status", "online")
// .DeleteData("status") // 如果取消注释,会抛出异常
.WithValidation(true) // 启用验证
.ExecuteAsync();3. 内存管理
大数据对象处理
csharp
// 使用流式处理大对象
public async Task SaveLargeGameData(long userId, GameSaveData saveData)
{
// 分块存储
var chunks = SplitIntoChunks(saveData, maxChunkSize: 64 * 1024); // 64KB chunks
var builder = CloudData.ForUser(userId);
for (int i = 0; i < chunks.Length; i++)
{
builder.SetData($"save_chunk_{i}", chunks[i]);
}
var result = await builder
.SetData("save_chunk_count", chunks.Length)
.SetData("save_timestamp", DateTime.UtcNow.ToString())
.WithDescription("保存游戏数据")
.ExecuteAsync();
}故障排查
常见问题
1. 事务失败
csharp
// 问题:事务执行失败
var result = await CloudData.ForUser(userId)
.CostCurrency("gold", 1000)
.ExecuteAsync();
if (result != UserCloudDataResult.Success)
{
// 检查具体错误
switch (result)
{
case UserCloudDataResult.InsufficientCurrency:
Game.Logger.LogWarning("金币不足");
break;
case UserCloudDataResult.FailedToSend:
Game.Logger.LogError("网络连接问题");
break;
case UserCloudDataResult.TransactionCommitEmpty:
Game.Logger.LogError("空事务");
break;
}
}2. 参数验证错误
csharp
try
{
// 可能抛出参数异常的操作
var result = await CloudData.ForUser(0) // 无效的用户ID
.SetData("", "value") // 空键名
.ExecuteAsync();
}
catch (ArgumentException ex)
{
Game.Logger.LogError(ex, "参数错误: {Message}, 参数名: {ParamName}", ex.Message, ex.ParamName);
}3. 网络超时
csharp
// 添加超时处理
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
var result = await CloudData.ForUser(userId)
.SetData("level", 10)
.ExecuteAsync()
.WaitAsync(cts.Token);
}
catch (OperationCanceledException)
{
Game.Logger.LogWarning("操作超时");
}调试技巧
1. 操作追踪
csharp
// 添加详细描述便于追踪
var result = await CloudData.ForUser(userId)
.AddCurrency("gold", rewardAmount)
.WithDescription($"任务奖励 - QuestId:{questId}, Timestamp:{DateTime.UtcNow}")
.ExecuteAsync();2. 事务内容检查
csharp
var builder = CloudData.ForUser(userId)
.SetData("level", 10)
.AddCurrency("gold", 100);
// 检查构建的操作
var operations = builder.Build();
foreach (var op in operations)
{
Game.Logger.LogDebug("Operation: {Type}, Key: {Key}, Value: {Value}", op.Type, op.Key, op.Value);
}
// 然后执行
var result = await CloudData.ExecuteTransactionAsync(operations, "调试事务");3. 日志记录
系统自动记录关键操作日志,可在CloudDataManager中查看:
- Provider初始化
- 请求ID映射
- 异步响应处理
- 错误和警告信息