在分布式服务器中,数据需要在多个系统间流转:从内存对象到数据库存储、从一个 Grain 到另一个 Grain、从服务器推送给客户端。每次流转都涉及序列化——将复杂的内存对象转换成可传输的格式。而当客户端需要实时接收消息时,仅有序列化是不够的,还需要一个可靠的消息传输系统。本文深度解析这两个层次如何协作,构建高效的实时通信。
第一部分:序列化的必要性
为什么需要序列化?
在内存中,数据是 C# 对象——有属性、有引用、有复杂的结构关系。但当这个对象需要离开内存、存储或传输时,就面临一个问题:存储系统和网络传输都不能直接理解 C# 对象。
内存中的对象
↓
需要存储到数据库 → 转换为 SQL
需要存储到缓存 → 转换为 JSON
需要网络传输 → 转换为二进制
需要 Grain 通信 → 转换为 Protobuf
服务器序列化的核心目的:在多个系统之间高效转换数据。
一个内存对象的多重生命
同一个用户对象,需要以不同的格式出现在不同的地方:
┌─────────────────────────────────────┐
│ 内存中的用户模型 │
│ IUserModel { │
│ Uid = "user_1" │
│ Name = "张三" │
│ Level = 10 │
│ Balance = 1000 │
│ } │
└────────────┬────────────────────────┘
│
┌────────┼────────┬────────┬─────────┐
↓ ↓ ↓ ↓ ↓
MySQL Redis 网络 Grain 日志
SQL JSON Protobuf Protobuf JSON
关键:同一个数据在不同场景下以不同格式存在,但表示的是同一个对象。
第二部分:三种序列化格式的权衡
1. SQL 格式(数据库持久化)
// 内存对象
IUserModel _userModel = new UserModel
{
Uid = "user_1",
Name = "张三",
Level = 10,
Balance = 1000
};
// 转换为 SQL
// INSERT INTO mem_user (uid, name, level, balance)
// VALUES ('user_1', '张三', 10, 1000)
特点:
- ✅ 数据库原生格式,支持查询和索引
- ✅ 便于复杂的 SQL 操作
- ❌ 需要 ORM 转换,性能开销较大
2. JSON 格式(缓存和日志)
// 序列化为 JSON
var json = JsonSerializer.Serialize(userModel);
// {"uid":"user_1","name":"张三","level":10,"balance":1000}
// 存储到 Redis
await redisDb.HashSetAsync("user_cache", "user_1", json);
特点:
- ✅ 人类可读,易于调试
- ✅ 跨语言兼容
- ❌ 体积大,网络传输效率低(约 45 字节)
- ❌ 速度慢,浮点数精度问题
3. Protobuf 格式(网络传输和 Grain 通信)
// 序列化为 Protobuf 二进制
MsgUserInfo msg = _userModel.PackProtocol();
// 二进制数据:[0x0A, 0x06, 0x75, 0x73, 0x65, 0x72, ...]
// 直接发送给客户端
await Grain.Dispatch(msg);
特点:
- ✅ 体积小,网络传输效率高(约 15 字节,比 JSON 节省 66%)
- ✅ 序列化速度快
- ✅ 向后兼容,支持版本演进
- ❌ 不易调试(二进制格式)
- ❌ 需要 .proto 文件定义
性能对比:
同样的用户数据:
JSON: {"uid":"user_1","name":"张三","level":10} (45 字节)
Protobuf: [0x0A, 0x06, 0x75, 0x73, 0x65, ...] (15 字节)
↑
节省了 66% 的带宽
第三部分:Protobuf 详解
为什么 Protobuf 这么高效?
Protobuf 是 Google 开发的二进制序列化格式,设计目标就是在网络传输中实现最小体积和最快速度。
第一步:定义 .proto 文件
// user.proto
syntax = "proto3";
message UserInfo {
string uid = 1;
string name = 2;
int32 level = 3;
int64 balance = 4;
}
字段编号的含义:
1, 2, 3, 4不是字段的值,而是编号- 编号决定了二进制编码的大小
- 1-15 的编号占用 1 字节 ← 常用字段使用小编号
- 16-2047 的编号占用 2 字节
这个设计很巧妙:频繁使用的字段用小编号,减少每条消息的体积。
第二步:编译生成代码
protoc --csharp_out=. user.proto
生成的 C# 代码包含:
UserInfo类- 属性(Uid, Name, Level, Balance)
- 序列化方法(
ToByteArray()) - 反序列化方法(
Parser.ParseFrom())
第三步:使用序列化
// 创建对象
var userInfo = new UserInfo
{
Uid = "user_1",
Name = "张三",
Level = 10,
Balance = 1000
};
// 序列化为二进制
byte[] binaryData = userInfo.ToByteArray();
// 传输/存储这个二进制数据
await network.SendAsync(binaryData);
// 反序列化回对象
var deserializedUser = UserInfo.Parser.ParseFrom(binaryData);
二进制编码的秘密:Varint
Protobuf 使用一种称为 Varint 的编码方式,用尽可能少的字节来表示数字。
例子:编码数字 300
十进制:300
二进制:0000 0001 0010 1100
Protobuf Varint 编码步骤:
1. 分成 7 位一组(从右到左)
→ 0000010 0101100
2. 每组前面加一个标志位
- 1 = 后面还有更多组
- 0 = 这是最后一组
→ 10101100 00000010
结果:2 字节表示 300
对比:
JSON: "300" = 3 字节
Protobuf: 0xAC 0x02 = 2 字节
字段编码
每个字段在消息中的格式是:[字段标签][字段值]
字段标签 = (字段编号 << 3) | 类型
类型编号:
0 = Varint (int32, int64, bool)
1 = 64-bit (double, fixed64)
2 = Length-delimited (string, bytes)
5 = 32-bit (float, fixed32)
例子:uid = "user_1"
字段编号 1,类型 2(string)
标签 = (1 << 3) | 2 = 10 = 0x0A
编码结果:
0x0A (字段标签)
0x06 (字符串长度 = 6)
75 73... (字符串内容)
向后兼容性
Protobuf 的版本演进能力很强:
版本 1:
message UserInfo {
string uid = 1;
string name = 2;
int32 level = 3;
}
版本 2:添加新字段
message UserInfo {
string uid = 1;
string name = 2;
int32 level = 3;
int64 balance = 4; // 新字段
}
为什么兼容?
- 旧客户端收到新数据:不认识 balance 字段,但能解析前三个字段
- 新客户端收到旧数据:期望 balance,没有就用默认值 0
- 字段编号不能改变,改变就会破坏兼容性
第四部分:消息系统架构
问题的提出
序列化解决了数据的格式转换问题。但在实时游戏中,还有另一个需求:服务器如何主动推送消息给客户端?
比如:
- 其他用户邀请你
- 任务完成提醒
- 系统公告
- 排队状态更新
这不是简单的请求-响应模式(客户端请求 → 服务器响应),而需要:
- 服务器能够主动发送消息
- 客户端能够可靠接收消息
- 掉线后能够恢复未读消息
为什么选择长轮询而不是 WebSocket?
两种方案的对比:
WebSocket:
- ✅ 实时性强(双向通道)
- ❌ 服务器需要维护长连接(内存压力大)
- ❌ 扩展到数千万用户时,连接管理复杂
长轮询:
- ✅ 利用 HTTP 无状态特性,易于扩展
- ✅ 消息确认机制清晰
- ✅ 断线重连简单
- ⚠️ 延迟比 WebSocket 高一些(但游戏可以接受)
长轮询的基本流程:
- 客户端发送:
Pull(idx=5, timeout=9000)—— “我已确认收到消息 1-5,请给我新消息,最多等 9 秒” - 服务器等待:如果有新消息立即返回,否则等最多 9 秒
- 客户端接收:包含所有新消息的列表
- 客户端更新:下次 Pull 时用新的 idx 继续
消息数据结构
// 单个消息单元
public class MsgProcData
{
public int Proc { get; set; } // 协议号(消息类型)
public int Idx { get; set; } // 序列号(全局递增)
public byte[] Body { get; set; } // 消息体(JSON → UTF8 字节)
}
// 消息列表容器
public class MsgProcDataList
{
public List<MsgProcData> ProcDataList { get; set; }
}
设计细节:
Proc:协议号,对应不同的消息类型(邀请、任务、公告等)Idx:全局递增的序列号,用于排序、去重、确认Body:序列化后的消息体(通常是 Protobuf 二进制,在这里被转为 UTF8 字节供网络传输)
UserMessagingGrain:每用户一个消息 Grain
[PreferLocalPlacement] // 与 UserGrain 共置
public class UserMessagingGrain : Grain,
IUserMessagingPushGrain, // 推送接口
IUserMessagingPullGrain // 拉取接口
{
// 消息队列:有界缓冲,容量 10k
private BoundedChannel<MsgProcData> _channel;
// 当前消息序列号(递增)
private int _idx;
// 待确认消息列表(用于断线恢复)
private List<MsgProcData> _waitingConfirmMessageList;
// 当前正在进行的 Pull 操作的取消令牌
private CancellationTokenSource _pullingCTS;
}
为什么用 BoundedChannel?
- 固定容量(10k)防止内存无限增长
- 性能高,支持单读者优化
- 满了自动丢弃最旧消息,并记录警告
第五部分:推送和拉取流程
推送流程(Push Side)
当某个 Grain 想给用户发消息:
// 在 UserGrain 中
public async Task NotifyInvite(string inviterUid, string inviterName)
{
var uid = this.GetPrimaryKeyString();
// 调用消息服务推送
await _messagingService.Dispatch(uid, MessagingType.Invite,
new { inviterUid, inviterName });
}
内部流程:
Dispatch()调用UserMessagingGrain.Push(proc, data)- Grain 将消息写入
_channel(消息队列) - 如果有客户端正在 Pull(等待消息),立即唤醒它
- 消息被添加到
_waitingConfirmMessageList(待确认列表)
关键特性:
[AlwaysInterleave]属性:Push 和 Pull 可以并发执行,不相互阻塞[OneWay]变体:火焰忘记式推送(不等待确认,适合非关键消息)
拉取流程(Pull Side)
客户端定期调用 Pull 获取新消息:
public async Task<MsgProcDataList> Pull(int idx, int timeoutMillisecond, int timestamp)
{
// 1. 验证时间戳(防止过期客户端)
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > 3)
{
throw new InvalidOperationException("Timestamp mismatch");
}
// 2. 清理已确认的消息
_waitingConfirmMessageList.RemoveAll(m => m.Idx <= idx);
// 3. 检查是否有新消息
var newMessages = _channel.Reader.TryReadAll(out var items)
? items.Where(m => m.Idx > idx).ToList()
: new List<MsgProcData>();
if (newMessages.Count > 0)
{
// 有新消息,立即返回(最多 200 条)
return new MsgProcDataList
{
ProcDataList = newMessages.Take(200).ToList()
};
}
// 4. 没有新消息,等待最多 timeoutMillisecond
var cts = new CancellationTokenSource(
TimeSpan.FromMilliseconds(timeoutMillisecond));
_pullingCTS = cts;
try
{
var hasData = await _channel.Reader.WaitToReadAsync(cts.Token);
if (hasData)
{
_channel.Reader.TryReadAll(out var newItems);
return new MsgProcDataList
{
ProcDataList = newItems
.Where(m => m.Idx > idx)
.Take(200)
.ToList()
};
}
}
finally
{
_pullingCTS = null;
cts.Dispose();
}
// 5. 超时,返回空列表
return new MsgProcDataList { ProcDataList = new List<MsgProcData>() };
}
关键机制:
-
确认机制:
- 参数
idx表示客户端已确认收到的最后一条消息 - 服务器据此清理待确认列表
- 参数
-
断线恢复:
- 如果客户端断线重连,用旧的
idx重新 Pull - 服务器从待确认列表中重新发送这些消息
- 如果客户端断线重连,用旧的
-
批量返回:
- 每次最多返回 200 条消息
- 减少网络开销
-
可重入处理:
- 如果客户端快速重新连接,新的 Pull 会取消旧的等待
- 避免僵尸连接
消息确认的妙处
这个设计巧妙地解决了消息丢失的问题:
客户端状态:
已确认:消息 1-5(已处理)
待确认:消息 6-10(已收到但未处理)
场景:客户端断线 → 重连
1. 客户端发送 Pull(idx=5)
—— "我只确认到 5"
2. 服务器返回消息 6-10
—— 从待确认列表重新发送
3. 客户端重新处理这些消息
4. 处理完后发送 Pull(idx=10)
—— "我确认到 10 了"
5. 服务器清理待确认列表
这样做的好处:
- 即使客户端崩溃,重启后也能恢复消息
- 不需要复杂的事务机制
- 消息顺序有保证(通过 Idx)
- 简单可靠
第六部分:持久化和优化
Redis 持久化
为了在 Silo(服务器实例)重启时不丢失消息,系统使用 Redis:
public class MsgLongPollingCache
{
public List<MsgProcData> ProcDataList { get; set; }
public int Idx { get; set; }
}
// Grain 激活时从 Redis 恢复
public override async Task OnActivateAsync(CancellationToken cancellationToken)
{
var cache = await _redisService.Get<MsgLongPollingCache>($"msg:{uid}");
if (cache != null)
{
_idx = cache.Idx;
_waitingConfirmMessageList = cache.ProcDataList;
}
}
// Grain 停用时保存到 Redis
public override async Task OnDeactivateAsync(
DeactivationReason reason, CancellationToken cancellationToken)
{
var cache = new MsgLongPollingCache
{
ProcDataList = _waitingConfirmMessageList,
Idx = _idx
};
await _redisService.Set($"msg:{uid}", cache, TimeSpan.FromHours(3));
}
TTL 设置为 3 小时:
- 足够长,覆盖大多数 Silo 重启场景
- 足够短,不会无限占用 Redis 内存
性能优化
1. 共置优化(PreferLocalPlacement)
UserMessagingGrain与UserGrain在同一个 Silo- Push 操作不需要跨网络,直接内存调用
- 大幅降低延迟
2. 可重入性(AlwaysInterleave)
- Push 和 Pull 可以并发执行
- 不会因为一个客户端的 Pull 等待而阻塞其他推送
- 支持高并发
3. 批量返回
- 每次最多 200 条消息
- 减少网络往返
- 适合突发消息场景
4. 有界缓冲
- 10k 消息容量
- 防止内存爆炸
- 溢出时自动丢弃最旧消息
第七部分:完整的数据流示例
用户邀请的完整流程
现在我们用一个实际的例子来看,数据是如何在序列化和消息系统中流转的:
场景:用户 A 邀请用户 B 玩游戏
T1: 用户 A 发送邀请请求
HTTP POST /api/invite
Body: { "targetUid": "user_B" }
格式:JSON
↓
T2: GateServer 接收请求
JSON → 反序列化 → C# 对象
↓
T3: 调用 UserGrain
cluster.GetGrain<IUserGrain>("user_A")
↓
T4: UserGrain 处理邀请逻辑
修改内存中的状态
↓
T5: 发送通知消息给 user_B
await _messagingService.Dispatch("user_B",
MessagingType.Invite,
new { inviterUid = "user_A", inviterName = "张三" });
↓
T6: UserMessagingGrain 接收消息
将消息写入 _channel(消息队列)
消息被赋予 idx(序列号)
添加到 _waitingConfirmMessageList(待确认列表)
↓
T7: 用户 B 客户端正在 Pull
Pull(idx=10, timeout=9000)
—— "我已确认到 10,请给我新消息"
↓
T8: UserMessagingGrain 立即返回
检查 idx > 10 的消息
找到刚刚的邀请消息(idx=11)
返回 MsgProcDataList 包含这条消息
↓
T9: 客户端接收消息列表
反序列化消息体
显示"用户 A 邀请了你"
↓
T10: 用户处理了邀请
下次 Pull 时发送 idx=11
—— "我已确认消息 11"
↓
T11: UserMessagingGrain 清理
从 _waitingConfirmMessageList 删除 idx <= 11 的消息
这个流程展示了:
- 序列化:用户邀请数据先从 C# 对象序列化为消息
- 传输:通过消息队列和长轮询系统传输
- 确认:客户端确认后清理服务器的待确认列表
- 恢复:即使客户端断线,下次连接时仍能恢复消息
总结
设计的优雅之处
| 设计点 | 解决的问题 |
|---|---|
| SQL 序列化 | 数据持久化到数据库 |
| JSON 序列化 | 缓存存储和日志记录 |
| Protobuf 序列化 | 网络传输效率和版本兼容 |
| 长轮询 | 避免 WebSocket 连接管理的复杂性 |
| 消息队列 | 缓冲突发消息,防止内存溢出 |
| 确认机制 | 保证消息不丢失,支持断线恢复 |
| Redis 持久化 | Silo 重启时恢复消息 |
| 共置优化 | 降低 Push 延迟 |
| 可重入性 | 支持高并发 |
核心洞察
序列化不是单独的话题,它是分布式系统的基础设施。选择合适的序列化格式(SQL、JSON、Protobuf)直接影响系统性能。而消息系统则是建立在序列化之上,确保数据能够可靠、有序、可恢复地从一个地点传送到另一个地点。
长轮询 + 确认机制 + 缓冲队列 这个组合,看似简单,实际上用最少的工程复杂性,实现了接近 WebSocket 的可靠性和性能。这个设计模式在很多分布式系统中都能看到,理解它对于设计可扩展的服务器至关重要。
最终的收获:从数据在内存中的对象形态,经过序列化转换为二进制格式,再通过可靠的消息传输系统送达客户端,这个完整的旅程体现了模块化设计和关注点分离的威力。每个环节都有清晰的职责,每个设计决策都基于具体的问题和权衡。