文章目录
概要
分布式这一概念在后端开发中是必不可少的话题。之前腾讯一面的面试官问了我项目的一个问题,"假如你这个系统不仅仅是面对一个大学里的学生,而是全国所有的大学生,你要怎么考虑设计呢 ?"
回答这个问题核心从这几个方面来考虑:
数据分片
一致性分布式共识
高可用
负载均衡
持久化
这里基于C++,grpc,kafka,mysql,redis实现一种分布式消息推送系统
整体架构
分布式消息系统按功能可以分为三种模块
- 用于维护长连接websocket的网关comet
- 用于路由消息无状态的逻辑处理节点logic
- 用于推送消息的消费节点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请求后
- 查 Redis
GetUserRoutes(to_user_id)→ 获取目标用户所在的comet_id列表 - 创建/获取单聊会话
GetOrCreateSingleSession(from_user, to_user) - 注入服务端字段(
msg_id、msg_seq、create_time、sender信息) AppendMessage持久化到 DB- 填充
UpstreamMessageReply.response.message(ChatMessage) - 构造
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;
}
- 返回 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帧并推送给客户端
- 判断
targets是否非空 - 如果
targets非空:提取用户列表,调用PushToUsers(message, users) - 如果
targets为空:解析session_id"r_{room_id}"→ 调用PushToRoom(message, room_id)"broadcast"→ 调用PushToAll(message)
PushToUsers/PushToRoom/PushToAll(均非rpc调用)遍历本机连接,构造 WebSocket 帧并同步发送- 填充
PushToCometReply.error(code=0) - 返回 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后
- 收到
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 加入房间路由集合
- 收到
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
- 填充
SimpleReply.error(code=0) - 返回 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。
- 验证
group_id(room_id) > 0 - 创建/获取聊天室会话
GetOrCreateRoomSession(room_id) - 查 Redis
GetRoomComets(room_id):- V2 方案(优先) :如果 Redis 有数据
- 记录 comet_id 列表到
comet_to_users(targets 为空)
- 记录 comet_id 列表到
- 降级方案 :如果 Redis 无数据
- 查 DB
ListRoomMembers(room_id)获取所有成员 - 对每个成员查 Redis
GetUserRoutes(uid)获取 comet_id - 聚合到
comet_to_users[cid].push_back(uid)(targets 非空)
- 查 DB
- V2 方案(优先) :如果 Redis 有数据
- 注入服务端字段(
msg_id、msg_seq、create_time、sender信息) AppendMessage持久化到 DB- 填充
UpstreamMessageReply.response.message(ChatMessage) - 构造
PushToCometRequest写入 Kafka
Kafka 载荷 :与单聊相同的push_to_comettopic,但 targets 可能为空。 - 返回 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 后
- 判断
targets是否非空 - 如果
targets非空:提取用户列表,调用PushToUsers(message, users) - 如果
targets为空:解析session_id"r_{room_id}"→ 调用PushToRoom(message, room_id)"broadcast"→ 调用PushToAll(message)
PushToUsers/PushToRoom/PushToAll遍历本机连接,构造 WebSocket 帧并发送- 填充
PushToCometReply.error(code=0) - 返回 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):
- 生成
task_id("bcast-{timestamp}-{rand()}") - 构造
BroadcastTaskRequest并序列化 - 写入 Kafka
broadcast_tasktopic(key = task_id) - 返回 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;
}
Kafka :broadcast_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):
- 判断
targets是否非空 → 为空 - 解析
session_id→"broadcast" - 调用
PushToAll(message),遍历本机所有在线连接,构造 WebSocket 帧并同步发送 - 填充
PushToCometReply.error(code=0) - 返回 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 请求后:
- Redis 查 token → 反查
user_id(鉴权) - 构造
content_json:
{"type":"danmaku","video_id":"video_123","timeline_ms":5000,"content":{"text":"666"}} - MySQL
InsertDanmaku→video_danmaku表(持久化,写库失败只打日志不中断推送) - 固定映射
kDanmakuRoomId = 1001作为房间 ID GetOrCreateRoomSession(1001)+AppendMessage(会话模型 + 消息持久化)- 构造
ChatMessage(填充 msg_id、session_id、msg_seq 等) - 路由:查 Redis
GetRoomComets(1001)→ 获取 Comet 列表- V2 方案(优先) :Redis 有数据 → targets 为空,由 Comet 侧
PushToRoom二次分发 - 降级方案 :Redis 无数据 → 查 DB
ListRoomMembers→ targets 非空,精确推送
- V2 方案(优先) :Redis 有数据 → targets 为空,由 Comet 侧
- 构造
PushToCometRequest,session_id = "r_1001",写入 Kafkapush_to_comet - 返回 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
}
Kafka :push_to_comet topic(与单聊/聊天室共用),key = comet_id,3 个分区
消息在 Kafka 中由 Job 进行消费,通过 gRPC 推送给 Comet
与单聊/聊天室相同的消费路径(job/service.cpp L118-153):
HandleMessage回调 → 线程池 → 反序列化PushToCometRequest→ProcessPushRequest→ gRPCPushToComet
Comet 收到消息后,构建 WebSocket 帧并推送给客户端
Comet 收到 PushToCometRequest 后(comet/comet_grpc_service.cpp L12-51):
- 判断
targets是否非空 - 如果
targets非空(降级方案):调用PushToUsers(message, users) - 如果
targets为空(V2 方案):解析session_id = "r_1001"→ 调用PushToRoom(message, 1001) - 遍历本机
room_users_[1001]中的所有连接,构造 WebSocket 帧并同步发送 - 填充
PushToCometReply.error(code=0) - 返回 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 请求后:
- 解析请求参数(
video_id、from_ms、to_ms、limit) - 调用
DanmakuDao::ListDanmaku查询 MySQLvideo_danmaku表 - 将查询结果序列化为 JSON
- 返回 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 和发送时间戳。