← 返回首页

ARTICLE

从序列化到实时通信:数据的完整旅程

在分布式服务器中,数据需要在多个系统间流转:从内存对象到数据库存储、从一个 Grain 到另一个 Grain、从服务器推送给客户端。每次流转都涉及 序列化 ——将复杂的内存对象转换成可传输的格式。而当客户端需要实时接收消息时,仅有序列化是不够的,还需要一个 可靠的消息传输系统 。本文深度解析这两个层

在分布式服务器中,数据需要在多个系统间流转:从内存对象到数据库存储、从一个 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 高一些(但游戏可以接受)

长轮询的基本流程:

  1. 客户端发送:Pull(idx=5, timeout=9000) —— “我已确认收到消息 1-5,请给我新消息,最多等 9 秒”
  2. 服务器等待:如果有新消息立即返回,否则等最多 9 秒
  3. 客户端接收:包含所有新消息的列表
  4. 客户端更新:下次 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 });
}

内部流程:

  1. Dispatch() 调用 UserMessagingGrain.Push(proc, data)
  2. Grain 将消息写入 _channel(消息队列)
  3. 如果有客户端正在 Pull(等待消息),立即唤醒它
  4. 消息被添加到 _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>() };
}

关键机制:

  1. 确认机制:

    • 参数 idx 表示客户端已确认收到的最后一条消息
    • 服务器据此清理待确认列表
  2. 断线恢复:

    • 如果客户端断线重连,用旧的 idx 重新 Pull
    • 服务器从待确认列表中重新发送这些消息
  3. 批量返回:

    • 每次最多返回 200 条消息
    • 减少网络开销
  4. 可重入处理:

    • 如果客户端快速重新连接,新的 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 的消息

这个流程展示了:

  1. 序列化:用户邀请数据先从 C# 对象序列化为消息
  2. 传输:通过消息队列和长轮询系统传输
  3. 确认:客户端确认后清理服务器的待确认列表
  4. 恢复:即使客户端断线,下次连接时仍能恢复消息

总结

设计的优雅之处

设计点 解决的问题
SQL 序列化 数据持久化到数据库
JSON 序列化 缓存存储和日志记录
Protobuf 序列化 网络传输效率和版本兼容
长轮询 避免 WebSocket 连接管理的复杂性
消息队列 缓冲突发消息,防止内存溢出
确认机制 保证消息不丢失,支持断线恢复
Redis 持久化 Silo 重启时恢复消息
共置优化 降低 Push 延迟
可重入性 支持高并发

核心洞察

序列化不是单独的话题,它是分布式系统的基础设施。选择合适的序列化格式(SQL、JSON、Protobuf)直接影响系统性能。而消息系统则是建立在序列化之上,确保数据能够可靠、有序、可恢复地从一个地点传送到另一个地点。

长轮询 + 确认机制 + 缓冲队列 这个组合,看似简单,实际上用最少的工程复杂性,实现了接近 WebSocket 的可靠性和性能。这个设计模式在很多分布式系统中都能看到,理解它对于设计可扩展的服务器至关重要。

最终的收获:从数据在内存中的对象形态,经过序列化转换为二进制格式,再通过可靠的消息传输系统送达客户端,这个完整的旅程体现了模块化设计和关注点分离的威力。每个环节都有清晰的职责,每个设计决策都基于具体的问题和权衡。