分布式消息推送系统协议设计【C++ grpc kafka】

文章目录

概要

分布式这一概念在后端开发中是必不可少的话题。之前腾讯一面的面试官问了我项目的一个问题,"假如你这个系统不仅仅是面对一个大学里的学生,而是全国所有的大学生,你要怎么考虑设计呢 ?"

回答这个问题核心从这几个方面来考虑:
数据分片
一致性分布式共识
高可用
负载均衡
持久化

这里基于C++,grpc,kafka,mysql,redis实现一种分布式消息推送系统

整体架构

分布式消息系统按功能可以分为三种模块

  1. 用于维护长连接websocket的网关comet
  2. 用于路由消息无状态的逻辑处理节点logic
  3. 用于推送消息的消费节点job

协议设计

外部-消息协议

消息协议分为http消息和websocket消息

复制代码
#http请求
POST /api/login HTTP/1.1\r\n
Host: logic_host:9101\r\n
Content-Type: application/json\r\n
Content-Length: 45\r\n
\r\n
{
  "account": "alice",
  "password": "md5hash"
}

#http响应
HTTP/1.1 200 OK\r\n
Connection: Keep-Alive\r\n
Content-Length: 85\r\n
Content-Type: application/json; charset=utf-8\r\n
\r\n
{
  "code": 0,
  "message": "ok",
  "data": {
    "user_id": 1001,
    "token": "tk-1001-1713782400",
    "name": "Alice"
  }
}

websocket握手请求
GET /ws?token=tk-1001-1713782400 HTTP/1.1\r\n
Host: comet_host:9000\r\n
Upgrade: websocket\r\n
Connection: Upgrade\r\n
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n
Sec-WebSocket-Version: 13\r\n
\r\n

websocket握手响应
HTTP/1.1 101 Switching Protocols\r\n
Upgrade: websocket\r\n
Connection: Upgrade\r\n
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbTK+xw=\r\n
\r\n

websocket帧结构(RFC 6455)

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (if payload len==126/127)   |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
|     Extended payload length continued, if payload len == 127  |
+ - - - - - - - - - - - - - - - +-------------------------------+
|                               |Masking-key, if MASK set to 1  |
+-------------------------------+-------------------------------+
| Masking-key (continued)       |           Payload Data         |
+-------------------------------- - - - - - - - - - - - - - - - +
:                     Payload Data continued ...                :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|                     Payload Data continued ...                |
+---------------------------------------------------------------+

#### 字段说明

| 字段 | 位数 | 说明 |
|------|------|------|
| FIN | 1 bit | 是否为最后一帧(1=是,0=分片帧) |
| RSV1-3 | 3 bits | 保留位,必须为 0 |
| Opcode | 4 bits | 操作码(0x1=文本帧,0x2=二进制帧,0x8=关闭帧) |
| MASK | 1 bit | 是否掩码(客户端必须为 1,服务端必须为 0) |
| Payload Length | 7/16/64 bits |0-125(0-125 字节) ,126(126-65535 字节),127(65536-2^64-1 字节)|
| Masking Key | 32 bits | 掩码密钥(仅 MASK=1 时存在) |
| Payload Data | 变长 | 实际数据 |
cpp 复制代码
//服务端websocket帧构建
std::string BuildWebSocketTextFrame(const std::string& payload) {
    std::string frame;
    unsigned char b1 = 0x81;  // FIN=1, text frame (opcode=0x1)
    frame.push_back(static_cast<char>(b1));
    
    size_t len = payload.size();
    if (len < 126) {
        frame.push_back(static_cast<char>(len));
    } else if (len <= 0xFFFF) {
        frame.push_back(126);
        frame.push_back(static_cast<char>((len >> 8) & 0xFF));   //大端序
        frame.push_back(static_cast<char>(len & 0xFF));   //小端序
    } else {
        frame.push_back(127);
        for (int i = 7; i >= 0; --i) {
            frame.push_back(static_cast<char>((len >> (8 * i)) & 0xFF)); //后续8个字节写实际长度
        }
    }
    frame.append(payload);
    return frame;
}

内部-grpc接口

系统模块内部通讯分别为comet(STUB)到logic(SERVICE),job(STUB)到comet(SERVICE)

实现grpc通信需要编写protobuf文件

logicService接口列表

接口 说明 请求 响应
VerifyToken 鉴权 VerifyTokenRequest VerifyTokenReply
SendUpstreamMessage 上行消息(聊天/弹幕) UpstreamMessageRequest UpstreamMessageReply
UserOffline 用户下线通知 UserOfflineRequest SimpleReply
ReportRoomJoin 房间加入通知 RoomReportRequest SimpleReply
ReportRoomLeave 房间离开通知 RoomReportRequest SimpleReply

cometService接口列表

接口 说明 请求 响应
PushToComett 推送消息到 comet PushToCometRequest PushToCometReply

内部-kafka载荷

主题 说明 载荷
push_to_comet 推送消息到 comet (comet_id,payload)
broadcast_task 广播到所有 comet (task_id,payload)

各接口及其响应

注册

POST /api/register

json 复制代码
// 请求
{ "account": "alice", "password": "md5hash", "name": "Alice" }

// 响应
{ "code": 0, "message": "ok", "data": { "user_id": 1001, "token": "tk-1001-1713792000", "name": "Alice" } }
登录

POST /api/login

json 复制代码
// 请求
{ "account": "alice", "password": "md5hash" }

// 响应
{ "code": 0, "message": "ok", "data": { "user_id": 1001, "token": "tk-1001-1713792000", "name": "Alice" } }

token 格式"tk-{user_id}-{timestamp}" → 写入 Redis,有效期 24 小时

单聊

客户端通过websocket帧发送 JSON 文本到comet,由comet解析并封装grpc请求。

json 复制代码
{
  "type": "single_chat",
  "to_user_id": 1002,
  "client_msg_id": "uuid-abc",
  "content": { "text": "hello" }
}

这里client_msg_id用于用于客户端去重和重试:如果网络超时,客户端可以用同一个 client_msg_id 重发,服务端检测到已存在则跳过

comet把grpc请求发送到logic

protobuf 复制代码
message UpstreamMessageRequest {
  int64 from_user_id = 1;
  string client_msg_id = 2;
  string scene = 3;          // "single"
  int64 to_user_id = 4;      // ← 单聊使用此字段
  int64 group_id = 5;        // 不使用
  string video_id = 6;       // 不使用
  int64 timeline_ms = 7;     // 不使用
  string content_json = 8;   // 客户端原始 JSON
}

logic收到grpc请求后

  1. 查 Redis GetUserRoutes(to_user_id) → 获取目标用户所在的 comet_id 列表
  2. 创建/获取单聊会话 GetOrCreateSingleSession(from_user, to_user)
  3. 注入服务端字段(msg_idmsg_seqcreate_timesender 信息)
  4. AppendMessage 持久化到 DB
  5. 填充 UpstreamMessageReply.response.message(ChatMessage)
  6. 构造 PushToCometRequest 写入 Kafka 异步
cpp 复制代码
// logic/grpc_service.cpp L287-302
PushToCometRequest req;
req.set_comet_id(comet_id);
*req.mutable_message() = *cm;                  // ChatMessage
for (int64_t uid : users) {
    auto* target = req.add_targets();
    target->set_user_id(uid);                   // 单聊:targets = [to_user_id]
}
std::string payload;
req.SerializeToString(&payload);
producer_->Send(comet_id, payload);             // key=comet_id

其中

protobuf 复制代码
message PushToCometRequest {
  string comet_id = 1;
  ChatMessage message = 2;
  repeated FanoutTarget targets = 3;
}
protobuf 复制代码
message FanoutTarget {
  int64 user_id = 1;
}
  1. 返回 gRPC 回复
protobuf 复制代码
// 回复
message UpstreamMessageReply {
  ErrorInfo error = 1;       // code=0 表示成功
  ChatMessage message = 2;   // Logic 存储后的完整消息(含服务端分配的 msg_id / msg_seq / timestamp_ms等)
}

其中

protobuf 复制代码
message ErrorInfo {
  int32 code = 1;      // 0 表示成功,非 0 代表错误码
  string message = 2;  // 具体错误原因
}

message ChatMessage {
  string msg_id = 1;
  string session_id = 2;
  int64 msg_seq = 3;
  int64 sender_id = 4;
  int64 timestamp_ms = 5;
  string msg_type = 6;   // text / system / danmaku / ...
  string content_json = 7;
  string client_msg_id = 8;
}

这里msg_id是消息的服务端全局唯一标识,msg_id 是服务端分配的权威 ID。
msg_seq是在同一session内消息按序号递增,可用于历史查询。

comet收到回复后

cpp 复制代码
UpstreamMessageReply rep;
grpc::ClientContext rpc_ctx;
auto status = logic_stub_->SendUpstreamMessage(&rpc_ctx, req, &rep);
if (!status.ok() || rep.error().code() != 0) {
    // 失败 → 返回错误帧
    conn->send(BuildWebSocketTextFrame(
        "{\"type\":\"error\",\"message\":\"send failed\"}"));
    return;
}
// 成功 → 从回复中取出 msg_id,返回 ACK 给发送方
conn->send(BuildWebSocketTextFrame(
    "{\"type\":\"ack\",\"msg_id\":\"" + rep.message().msg_id() + "\"}"));

消息在kafka中由job进行消费,通过grpc推送给comet ,单聊Topic有3个Partition按key(comet_id同分区保序)

消费消息触发回调

cpp 复制代码
void JobRunner::HandleMessage(const std::string& key, const std::string& value) {
    (void)key;                                  // key 在 Job 端被忽略
    rpc_pool_.Submit([this, payload = value]() {
        PushToCometRequest req;
        if (!req.ParseFromString(payload)) {    // 反序列化 Protobuf
            LOG_ERROR << "Failed to parse PushToCometRequest";
            return;
        }
        ProcessPushRequest(req);                // → gRPC PushToComet
    });
}

void JobRunner::ProcessPushRequest(const PushToCometRequest& req) {
    const std::string& comet_id = req.comet_id();
    CometService::Stub* stub = GetStub(comet_id);  // 获取目标 Comet 的 gRPC stub
    if (!stub) return;

    PushToCometReply reply;
    grpc::ClientContext ctx;
    stub->PushToComet(&ctx, req, &reply);            // 调用 Comet gRPC
    if (reply.error().code() != 0) {
        LOG_ERROR << "Comet response error";
    }
}

comet收到消息后,构建websocket帧并推送给客户端

  1. 判断 targets 是否非空
  2. 如果 targets 非空:提取用户列表,调用 PushToUsers(message, users)
  3. 如果 targets 为空:解析 session_id
    • "r_{room_id}" → 调用 PushToRoom(message, room_id)
    • "broadcast" → 调用 PushToAll(message)
  4. PushToUsers / PushToRoom / PushToAll (均非rpc调用)遍历本机连接,构造 WebSocket 帧并同步发送
  5. 填充 PushToCometReply.error(code=0)
  6. 返回 gRPC 回复

这个rpc接口的request前面已经写过了,这里再展示一下reply

protobuf 复制代码
message PushToCometReply {
  ErrorInfo error = 1;
}

一个下行的websocket帧payload(json文本)

json 复制代码
{
  "msg_id": "sess_1001_1002-5",
  "msg_seq": 5,
  "create_time": 1713792000000,
  "from_user_id": 1001,
  "sender": { "uid": 1001, "name": "Alice" },
  "client_msg_id": "uuid-abc",
  "text": "hello"
}

至此一条单聊消息就走完了。

复制代码
Client A (WebSocket)                Comet-1              Logic              Kafka           Job              Comet-2           Client B (WebSocket)
    │                                  │                   │                  │               │                 │                    │
    │─{"type":"single_chat",...}──────→│                   │                  │               │                 │                    │
    │                                  │──gRPC────────────→│                  │               │                 │                    │
    │                                  │  SendUpstream     │                  │               │                 │                    │
    │                                  │  Message          │──Redis查路由────→│               │                 │                    │
    │                                  │                   │──AppendMessage   │               │                 │                    │
    │                                  │                   │──Send(comet_id,─→push_to_comet   │                 │                    │
    │                                  │                   │   payload)       │               │                 │                    │
    │                                  │←─Reply(msg_id)────│                  │               │                 │                    │
    │←─{"type":"ack","msg_id":...}─────│                   │                  │──consume────→│                  │                    │
    │                                  │                   │                  │               │──gRPC Push────→│                     │
    │                                  │                   │                  │               │  ToComet        │──WebSocket下行─────→│
    │                                  │                   │                  │               │                 │  {"msg_id":...}     │
聊天室

聊天室涉及两层操作

  • HTTP:订阅管理(加入/退出成员表,持久化到 DB)

MySQL 表设计sql/schema.sql):

表名 用途 关键字段
user 用户账户 id, account, name, password_hash
session 会话(单聊/群聊/聊天室) session_id, type, user1_id, user2_id, group_id, last_msg_seq
message 消息存储 session_id, msg_seq, sender_id, msg_type, content_json, timestamp_ms
user_session_state 用户已读游标 user_id, session_id, read_seq, last_visit_at
im_group 群组/聊天室元数据 id, name, owner_id, group_type
group_member 群组成员关系 group_id, user_id, role, join_at
video_danmaku 弹幕(按视频时间轴) video_id, timeline_ms, sender_id, content_json
broadcast 系统广播任务 task_id, title, scope, content_json
  • WebSocket:实时消息收发 + 在线房间维护(Comet 本地内存)
json 复制代码
// POST /api/chatroom/join --- 将用户加入 DB 成员表
{ "room_id": 2001, "user_id": 1001 }

// POST /api/chatroom/unsubscribe --- 将用户从 DB 成员表移除
{ "room_id": 2001, "user_id": 1001 }

这一步只是"订阅关系"的持久化,不影响实时消息的接收。

用户还需要通过 WebSocket 发送 chatroom_join 才能在 Comet 上接收实时消息。

这就涉及到comet基于websocket的在线房间维护了。我们通过redis进行路由来判断一个room_id的消息应该发往哪些comet中的哪些链接,comet中维护了room_id---user_conn的集合。

房间维护

客户端通过 WebSocket 发送控制命令:

json 复制代码
// 加入房间
{ "type": "chatroom_join", "group_id": 2001 }

// 离开房间
{ "type": "chatroom_leave", "group_id": 2001 }

Comet 处理在本地维护房间集合,每个房间又是一个集合,集合内存储房间内用户id。AddUserToRoom还会调用grpc请求,要求logic更新redis中的路由表。

cpp 复制代码
if (type == "chatroom_join") {
    AddUserToRoom(room_id, ctx.user_id);    // 加入本机房间成员表(内存)
    // → 触发 NotifyRoomJoin → gRPC ReportRoomJoin → Logic 维护 Redis room:comets
    conn->send(BuildWebSocketTextFrame(
        "{\"type\":\"ack\",\"op\":\"chatroom_join\"}"));
} else {
    RemoveUserFromRoom(room_id, ctx.user_id);
    // → 触发 NotifyRoomLeave → gRPC ReportRoomLeave
    conn->send(BuildWebSocketTextFrame(
        "{\"type\":\"ack\",\"op\":\"chatroom_leave\"}"));
}
protobuf 复制代码
message RoomReportRequest {
  int64 user_id = 1;
  int64 room_id = 2;
  string comet_id = 3;    // 告知 Logic 该用户在哪个 Comet
}

logic收到上报的request后

  1. 收到 ReportRoomJoin
    • INCRBY room:online_count:{room_id} 1 → 维护房间全局在线人数
    • INCRBY room:comet_count:{room_id}:{comet_id} 1 → 维护该 Comet 上的用户计数
    • 若该计数从 0→1,则 SADD room:comets:{room_id} {comet_id} → 将该 Comet 加入房间路由集合
  2. 收到 ReportRoomLeave
    • INCRBY room:online_count:{room_id} -1 → 递减房间全局在线人数
    • INCRBY room:comet_count:{room_id}:{comet_id} -1 → 递减该 Comet 上的用户计数
    • 若该计数降至 0,则 SREM room:comets:{room_id} {comet_id} → 从房间路由集合移除该 Comet
  3. 填充 SimpleReply.error(code=0)
  4. 返回 gRPC 回复

重要 :Logic 在所有 Redis 操作完成后才返回 gRPC 回复(包括 INCRBY 和 SADD/SREM)。

protobuf 复制代码
message SimpleReply {
  ErrorInfo error = 1;
}
消息收发

上行,发送到comet。

json 复制代码
{
  "type": "chatroom",
  "group_id": 2001,
  "client_msg_id": "uuid-abc",
  "content": { "text": "hi everyone" }
}

同单聊一样调用 SendUpstreamMessage,但 scene = "chatroom",发送到logic。

  1. 验证 group_id (room_id) > 0
  2. 创建/获取聊天室会话 GetOrCreateRoomSession(room_id)
  3. 查 Redis GetRoomComets(room_id)
    • V2 方案(优先) :如果 Redis 有数据
      • 记录 comet_id 列表到 comet_to_users(targets 为空)
    • 降级方案 :如果 Redis 无数据
      • 查 DB ListRoomMembers(room_id) 获取所有成员
      • 对每个成员查 Redis GetUserRoutes(uid) 获取 comet_id
      • 聚合到 comet_to_users[cid].push_back(uid)(targets 非空)
  4. 注入服务端字段(msg_idmsg_seqcreate_timesender 信息)
  5. AppendMessage 持久化到 DB
  6. 填充 UpstreamMessageReply.response.message(ChatMessage)
  7. 构造 PushToCometRequest 写入 Kafka
    Kafka 载荷 :与单聊相同的 push_to_comet topic,但 targets 可能为空。
  8. 返回 gRPC 回复

消息在kafka中由job进行消费,通过grpc推送给comet ,单聊Topic有3个Partition按key(comet_id同分区保序)

消费消息触发回调

cpp 复制代码
void JobRunner::HandleMessage(const std::string& key, const std::string& value) {
    (void)key;                                  // key 在 Job 端被忽略
    rpc_pool_.Submit([this, payload = value]() {
        PushToCometRequest req;
        if (!req.ParseFromString(payload)) {    // 反序列化 Protobuf
            LOG_ERROR << "Failed to parse PushToCometRequest";
            return;
        }
        ProcessPushRequest(req);                // → gRPC PushToComet
    });
}

void JobRunner::ProcessPushRequest(const PushToCometRequest& req) {
    const std::string& comet_id = req.comet_id();
    CometService::Stub* stub = GetStub(comet_id);  // 获取目标 Comet 的 gRPC stub
    if (!stub) return;

    PushToCometReply reply;
    grpc::ClientContext ctx;
    stub->PushToComet(&ctx, req, &reply);            // 调用 Comet gRPC
    if (reply.error().code() != 0) {
        LOG_ERROR << "Comet response error";
    }
}

Comet 收到 PushToCometRequest

  1. 判断 targets 是否非空
  2. 如果 targets 非空:提取用户列表,调用 PushToUsers(message, users)
  3. 如果 targets 为空:解析 session_id
    • "r_{room_id}" → 调用 PushToRoom(message, room_id)
    • "broadcast" → 调用 PushToAll(message)
  4. PushToUsers / PushToRoom / PushToAll 遍历本机连接,构造 WebSocket 帧并发送
  5. 填充 PushToCometReply.error(code=0)
  6. 返回 gRPC 回复
protobuf 复制代码
message PushToCometReply {
  ErrorInfo error = 1;
}

推送给房间成员的 WebSocket 下行帧(JSON 文本):

json 复制代码
{
  "msg_id": "r_2001-15",
  "session_id": "r_2001",
  "msg_seq": 15,
  "create_time": 1713792000000,
  "from_user_id": 1001,
  "sender": { "uid": 1001, "name": "Alice" },
  "client_msg_id": "uuid-abc",
  "msg_type": "text",
  "text": "hello everyone"
}
广播

广播由管理员通过 HTTP 发起(不走 WebSocket,直接打到logic),走 Kafka broadcast_task topic。

管理员通过 HTTP 发起广播

json 复制代码
// POST /api/admin/broadcast
// 请求
{ "scope": "all", "text": "系统维护通知" }
// 或
{ "scope": "chatroom", "room_id": 2001, "text": "房间公告" }

// 响应
{ "code": 0, "data": { "task_id": "bcast-1713792000000-12345", "scope": "all", "group_id": 0 } }

Logic 收到 HTTP 请求后logic/http_server.cpp L1339-1441):

  1. 生成 task_id"bcast-{timestamp}-{rand()}")
  2. 构造 BroadcastTaskRequest 并序列化
  3. 写入 Kafka broadcast_task topic(key = task_id)
  4. 返回 HTTP 响应
cpp 复制代码
// logic/http_server.cpp L1398-1432
BroadcastTaskRequest task;
task.set_task_id("bcast-" + std::to_string(now_ms) + "-" + std::to_string(rand()));
task.set_scope(real_scope);        // "all" 或 "group"
task.set_group_id(group_id);
task.set_content_json(content_json);
// content_json 示例:{"type":"system_broadcast","scope":"all","content":{"text":"通知"}}

std::string payload;
task.SerializeToString(&payload);
broadcast_producer_->Send(task_id, payload);   // Kafka broadcast_task topic

其中

protobuf 复制代码
message BroadcastTaskRequest {
  string task_id = 1;      // "bcast-{timestamp}-{rand()}"
  string scope = 2;        // "all" / "group"
  int64 group_id = 3;
  string content_json = 4;
}

Kafkabroadcast_task topic,key = task_id(随机),3个分区

消息在 Kafka 中由 Job 进行消费,通过 gRPC 推送给所有 Comet

消费消息触发回调(job/service.cpp L155-211):

cpp 复制代码
void JobRunner::HandleBroadcastTask(const std::string& key, const std::string& value) {
    (void)key;
    BroadcastTaskRequest task;
    task.ParseFromString(value);

    // 构造 ChatMessage
    ChatMessage msg;
    msg.set_msg_id(task.task_id());
    msg.set_session_id("broadcast");    // 特殊 session_id
    msg.set_msg_type("broadcast");
    msg.set_content_json(task.content_json());

    // 遍历所有已知 Comet,逐一推送
    for (const auto& kv : comet_addrs_) {
        PushToCometRequest req;
        req.set_comet_id(kv.first);
        *req.mutable_message() = msg;
        // targets 为空 → Comet 侧 PushToAll

        PushToCometReply reply;
        grpc::ClientContext ctx;
        stub->PushToComet(&ctx, req, &reply);   // 调用 Comet gRPC
    }
}

与单聊/聊天室不同,广播的 Job 处理不经过线程池,而是在回调中直接遍历所有 Comet 逐一调用 gRPC。

Comet 收到消息后,构建 WebSocket 帧并推送给客户端

Comet 收到 PushToCometRequest 后(comet/comet_grpc_service.cpp L12-51):

  1. 判断 targets 是否非空 → 为空
  2. 解析 session_id"broadcast"
  3. 调用 PushToAll(message),遍历本机所有在线连接,构造 WebSocket 帧并同步发送
  4. 填充 PushToCometReply.error(code=0)
  5. 返回 gRPC 回复
protobuf 复制代码
message PushToCometReply {
  ErrorInfo error = 1;
}

一个下行的 WebSocket 帧 payload(JSON 文本)

json 复制代码
{
  "type": "system_broadcast",
  "scope": "all",
  "content": { "text": "系统维护通知" }
}

至此一条广播消息就走完了。

复制代码
Admin (HTTP)              Logic              Kafka              Job              Comet-1           Client A
    │                       │                  │                  │                 │                  │
    │──POST /admin/broadcast→│                  │                  │                 │                  │
    │                       │──Send(task_id,──→broadcast_task     │                 │                  │
    │                       │   payload)       │                  │                 │                  │
    │←─{code:0,task_id}────│                  │                  │                 │                  │
    │                       │                  │──consume────────→│                 │                  │
    │                       │                  │                  │──gRPC Push────→│                  │
    │                       │                  │                  │  ToComet        │──WebSocket下行──→│
    │                       │                  │                  │  (targets=空    │  {broadcast...}  │
    │                       │                  │                  │   sid=broadcast) │                  │
    │                       │                  │                  │←─Reply(ok)──────│                  │
    │                       │                  │                  │                 │                  │
    │                       │                  │                  │──gRPC Push────→Comet-2 ──WS──→ Client B
    │                       │                  │                  │──gRPC Push────→Comet-3 ──WS──→ Client C

弹幕

弹幕是唯一走 HTTP 发送但仍实时推送的功能 。弹幕不走 WebSocket 上行,而是直接 HTTP POST 到 Logic,Logic 在 HTTP 处理函数中直接写 Kafka(不经过 gRPC SendUpstreamMessage)。

客户端通过 HTTP 发送弹幕

json 复制代码
// POST /api/danmaku/send(必须携带 token)
{
  "token": "tk-1001-xxx",
  "video_id": "video_123",
  "timeline_ms": 5000,
  "text": "666"
}

// 响应
{ "code": 0, "message": "ok",
  "data": { "msg_id": "room_1001-5", "pushed": true } }

Logic 收到 HTTP 请求后

  1. Redis 查 token → 反查 user_id(鉴权)
  2. 构造 content_json
    {"type":"danmaku","video_id":"video_123","timeline_ms":5000,"content":{"text":"666"}}
  3. MySQL InsertDanmakuvideo_danmaku 表(持久化,写库失败只打日志不中断推送)
  4. 固定映射 kDanmakuRoomId = 1001 作为房间 ID
  5. GetOrCreateRoomSession(1001) + AppendMessage(会话模型 + 消息持久化)
  6. 构造 ChatMessage(填充 msg_id、session_id、msg_seq 等)
  7. 路由:查 Redis GetRoomComets(1001) → 获取 Comet 列表
    • V2 方案(优先) :Redis 有数据 → targets 为空,由 Comet 侧 PushToRoom 二次分发
    • 降级方案 :Redis 无数据 → 查 DB ListRoomMembers → targets 非空,精确推送
  8. 构造 PushToCometRequestsession_id = "r_1001",写入 Kafka push_to_comet
  9. 返回 HTTP 响应
cpp 复制代码
// logic/http_server.cpp L394-417
for (const auto& kv : comet_to_users) {
    const std::string& comet_id = kv.first;
    const auto& users = kv.second;

    PushToCometRequest req_pb;
    req_pb.set_comet_id(comet_id);
    *req_pb.mutable_message() = cm;
    for (int64_t uid : users) {
        auto* t = req_pb.add_targets();
        t->set_user_id(uid);
    }
    std::string payload;
    req_pb.SerializeToString(&payload);
    kafka_producer_->Send(comet_id, payload);    // key=comet_id
}

Kafkapush_to_comet topic(与单聊/聊天室共用),key = comet_id,3 个分区

消息在 Kafka 中由 Job 进行消费,通过 gRPC 推送给 Comet

与单聊/聊天室相同的消费路径(job/service.cpp L118-153):

  • HandleMessage 回调 → 线程池 → 反序列化 PushToCometRequestProcessPushRequest → gRPC PushToComet

Comet 收到消息后,构建 WebSocket 帧并推送给客户端

Comet 收到 PushToCometRequest 后(comet/comet_grpc_service.cpp L12-51):

  1. 判断 targets 是否非空
  2. 如果 targets 非空(降级方案):调用 PushToUsers(message, users)
  3. 如果 targets 为空(V2 方案):解析 session_id = "r_1001" → 调用 PushToRoom(message, 1001)
  4. 遍历本机 room_users_[1001] 中的所有连接,构造 WebSocket 帧并同步发送
  5. 填充 PushToCometReply.error(code=0)
  6. 返回 gRPC 回复

一个下行的 WebSocket 帧 payload(JSON 文本)

json 复制代码
{
  "type": "danmaku",
  "video_id": "video_123",
  "timeline_ms": 5000,
  "content": { "text": "666" }
}

至此一条弹幕消息就走完了。

复制代码
Client (HTTP)             Logic              Kafka           Job              Comet-1           Client A (WS)
    │                       │                  │               │                 │                  │
    │──POST /danmaku/send──→│                  │               │                 │                  │
    │                       │──Redis查token    │               │                 │                  │
    │                       │──InsertDanmaku──→MySQL           │                 │                  │
    │                       │──AppendMessage   │               │                 │                  │
    │                       │──GetRoomComets──→Redis           │                 │                  │
    │                       │──Send(comet_id,─→push_to_comet  │                 │                  │
    │                       │   payload)       │               │                 │                  │
    │←─{code:0,msg_id}─────│                  │               │                 │                  │
    │                       │                  │──consume─────→│                 │                  │
    │                       │                  │               │──gRPC Push────→│                  │
    │                       │                  │               │  ToComet        │──WebSocket下行──→│
    │                       │                  │               │  (sid=r_1001)   │  {danmaku...}    │
    │                       │                  │               │←─Reply(ok)─────│                  │

拉取弹幕 /api/danmaku/list
客户端通过 HTTP 请求查询弹幕历史(不是实时推送,是主动拉取):

json 复制代码
// 请求
{ "video_id": "video_123", "from_ms": 0, "to_ms": 10000, "limit": 100 }

// 响应
{ "code": 0, "data": {
    "danmaku": [
      { "timeline_ms": 1000, "text": "666" },
      { "timeline_ms": 3000, "text": "up" },
      { "timeline_ms": 5000, "text": "666" },
      { "timeline_ms": 8000, "text": "哈哈哈" }
    ],
    "has_more": false } }

Logic 收到 HTTP 请求后

  1. 解析请求参数(video_idfrom_msto_mslimit
  2. 调用 DanmakuDao::ListDanmaku 查询 MySQL video_danmaku
  3. 将查询结果序列化为 JSON
  4. 返回 HTTP 响应给客户端

从 MySQL video_danmaku 表按时间轴查询(logic/danmaku_dao.cpp L118-185):

sql 复制代码
SELECT id, video_id, timeline_ms, sender_id, content_json, timestamp_ms
FROM video_danmaku
WHERE video_id = ?
  AND timeline_ms >= {from_ms}
  AND timeline_ms < {to_ms}
ORDER BY timeline_ms ASC
LIMIT {limit}

即按 video_id 过滤,在 [from_ms, to_ms) 时间范围内按 timeline_ms 升序返回弹幕记录,包含发送者 ID、弹幕内容 JSON 和发送时间戳。

https://github.com/0voice

相关推荐
ward RINL2 小时前
分布式推理框架 xDit
分布式
无限进步_2 小时前
二叉树的前序遍历(非递归实现)
开发语言·数据结构·c++·windows·git·visual studio
ximu_polaris2 小时前
设计模式(C++)-结构型模式-组合模式
c++·设计模式·组合模式
青瓦梦滋2 小时前
Linux线程的同步与互斥
linux·c++
南境十里·墨染春水2 小时前
C++流类库 文件流操作
开发语言·c++
HUGu RGIN2 小时前
分布式与集群,二者区别是什么?
分布式
C++ 老炮儿的技术栈2 小时前
工业视觉检测:用 C++ 和 Snap7 库快速读写西门子 S7-1200
c语言·c++·git·qt·系统架构·visual studio·snap
橙子也要努力变强2 小时前
信号捕捉的底层机制-内核态和用户态初识
linux·服务器·c++
juniperhan2 小时前
Flink 系列第14篇:Flink Metrics 监控指标详解(生产环境版)
大数据·数据仓库·分布式·flink