总览
基于聊天室项目做的扩展改进,引入完善logic模块、Kafka消息队列、comet服务器、job模块。
各模块功能:
logic:鉴权/token;消息持久化(MySQL)、路由/房间缓存(Redis);计算 fanout→ Kafka。
comet:维护长连接/心跳,userid->conn_list,room_id->user_set;上行经gRPC送logic,下行接收 job 推送并本机 fanout。
job:Kafka consumer group,按 comet_id 将下行消息推给对应comet(gRPC,同步+线程池)
web_demo:HTTP 登录、WS 收发示例页面。

用户1连接到comet1服务器,发送消息给用户2(用户2可能连接 到了comet2和comet3服务器,因为支持多条连接,可能开启了多个网页访问),消息通过grpc服务提交给logic业务处理,logic会把消息存储到MySQL和Redis(存储房间相关信息),再把消息发送到kafka,job模块负责从Kafka取出消息,选择一个comet进行发送(job会连接到所有的comet)。用户2在websocket登录的时候,就要在redis里面存储用户id 连接uuid cometid 对应的映射,这样在job选择发送的时候有所依据避免广播所有的comet。
提问:
在websocket登录的时候,就要在redis里面存储用户id 连接uuid cometid 对应的映射。具体的实现流程是怎么样的呢?对应的代码又怎么实现?
实现原理
在logic模块的redis_store.cpp里面有实现封装 Redis 访问方法,其中就有一个GetUserRoutes方法,用来获取用户所有 comet 路由。
- 在 Redis 里,每个房间会维护一个集合(Set),key 形如:room:comets:<room_id>。
- 这个集合里存的是当前有该房间在线用户的 comet 节点的 ID集合(如
"comet1"、"comet2"等)
cpp
redisReply* reply = (redisReply*)redisCommand(
ctx, "SMEMBERS room:comets:%lld", static_cast<long long>(room_id));
- 这行代码向 Redis 发送
SMEMBERS命令,获取集合 room:comets:<room_id> 的所有成员。然后再把返回的结果填入传入的vector集合
具体流程
具体流程
用户上线/加入房间时
记录该用户所在的 comet_id,并把 comet_id 加入房间的 comet 集合 (
room_id -> set<comet_id>)。推送消息时
(1)先查出房间涉及的所有 comet_id,只向这些 comet 推送消息。
(2)comet 收到后再分发给本节点上的所有房间成员。
**3.**用户下线/离开房间时
如果该 comet 上已无该房间用户,可以把 comet_id 从集合中移除。
项目框架
comet服务器
- app.cpp / app.h
Comet 的主流程实现,负责服务的整体启动、停止和资源管理。
主要功能:
初始化 WebSocket 服务和 gRPC 服务,加载配置。
主线程启动muduo 网络层(WebSocket 服务)
cpp
CometServer server(&loop, cfg);
server.SetThreadNum(cfg.comet_io_threads);
server.Start();
创建子线程启动 gRPC CometService服务。
cpp
std::thread grpc_thread([&grpcServer]() {
grpcServer->Wait(); //gRPC 服务器的 Wait() 方法会阻塞,它会一直等待job发送 gRPC 请求,直到服务器关闭
});
- comet_server.cpp / comet_server.h
实现一个CometServe类
主要功能:
(1)实现muduo需要的两个回调:OnMessage和OnConnection
(2)监听指定端口,接受客户端 WebSocket 连接,接收客户端的消息,维护连接映射(用户ID <--> 连接)。
(3)根据客户端请求,封装grpc请求logic服务的数据内容,利用gRPC stub,调用logic服务的方法,并且根据logic服务的响应状态给用户返回ACK。
- comet_grpc_service.cpp / comet_grpc_service.h
gRPC 下行服务实现,供 Job 调用。
主要功能:
实现了 gRPC 服务端的 PushToComet 方法,让 logic/job 能向 comet 推送消息给指定用户。
具体推送逻辑:
Comet 侧收到 job/logic 的下行推送请求:
1.如果 targets 列表不为空,表示要精确推送给某些用户,调用 CometServer的方法PushToUsers()。
2.如果 targets 为空,则根据 session_id 判断是房间广播(如 r_123)还是全服广播(broadcast),分别调用 CometServer的方法PushToRoom() 或 PushToAll()。
3.comet1服务器不会发送给comet2服务器上面的用户,需要调用方自己判断要发送的用户在哪个comet服务器上面
- websocket_utils.cpp / websocket_utils.h
WebSocket 协议相关工具函数。
主要功能:
(1)解析 WebSocket 帧包裹的 JSON 数据,提取路由信息(如 type、to_user_id、group_id 等)。
(2)构造 WebSocket 文本帧用于下发消息。
comet服务器函数间配合关系:
app.h启动comet服务器的网络层(Websocket)监听和grpc服务层监听。用户连接上comet服务器(前端根据配置选择一个可用的 Comet 地址),触发comet_server.cpp中实现的CometServe类的OnConnection回调,与用户建立HTTP连接。等用户发送来消息,触发OnMessage回调,首次调用会进入HandleHandshake处理 HTTP 升级握手,在HandleHandshake里面会调用 logic 的 VerifyToken方法 做鉴权。成功后,comet服务器返回握手响应,随后用户发来websocket帧,触发OnMessage回调,会进入HandleWebSocketFrame解析websocket帧取出里面的json数据,并且调用OnTextMessage方法。在OnTextMessage里面,会调用websocket_utils.h解析json数据,获取路由信息(比如是单人聊天还是群聊,要发给哪个用户,或者发在哪个群,发送内容是什么等),封装成grpc请求logic服务的数据内容,调用logic的SendUpstreamMessage方法,进行业务处理。具体logic服务处理完后,要推送给用户的消息内容,会写入Kafka ,再由Job 从Kafka 中拉取,然后选择comet服务器,调用comet_grpc_service.h实现的comet gRPC 服务端的 PushToComet 方法,精准推送给用户。
**更新补充:**Websocket握手鉴权过程:(背景:用户登录后,有了token。)
comet模块的tcpserver服务器监听到用户消息,触发OnMessage回调,第一次调用这个回调,需要先进行Websocket握手把HTTP服务升级。正常的获取字段回复字段就不赘述了,这里补充项目中的特点:1.会在鉴权把token发给logic检查的时候,顺便也把comet_id发过去了,这样要是logic那边返回鉴权ok,logic也能在redis里顺便存下user_id与comet_id的映射。一举两得。2.可能是之前遗漏的一点,就是muduo的Tcpserver服务器还有提供一个上下文字段,而且还有对应的set方法,目的就是提供给开发人员,看看你有没有什么东西要绑定到这个TcpConnection上面的。而我们这里的鉴权成功后,刚好需要把user_id绑定上去,因为后面是websocket长连接,一次鉴权成功后,后续这个连接就和这个user_id绑定了,会高效很多。
图中redis_store 是 project 中对 Redis 的封装层(封装连接池 + 常用 Redis 操作)。

**更新补充:**保活机制设计:
在websocket连接建立后,客户端和服务器互相发"心跳"以表明连接存活,每固定时间发送业务心跳(特定 JSON 消息)。comet的tcpserver服务器,就需要连接redis,然后在接收到这种心跳帧就把user_id为key的键值对的TTL进行刷新,默认就是60s,在有任何业务处理也会刷新TTL。如果超时了,其实也就类似用户退出了,就需要清理两个地方:1、redis的路由,2、cometserver服务器栈上保存的user_id到Tcpcon的映射。

logic服务器
主要工作:
- 处理客户端业务请求,登录、鉴权、消息发送等。
- gRPC模块通信,处理来自 Comet 的 gRPC 请求。
- 为前端提供HTTP API 服务,登录、发消息、获取历史消息等。
- 与 MySQL、Redis 的数据交互
- 消息写入 Kafka
- 弹幕功能实现
通用/主流程
- main.cpp
主要功能:
1、加载命令行传入的配置文件,通过配置文件内容,初始化Kafka (传入 Kafka broker 地址和 topic)、mysql连接池(传入主机、端口、账号、密码、数据库名、池大小等)、redis连接池(主机、端口、密码、数据库号、池大小、超时等)。用连接池实例初始化各 DAO(里面封装了对数据库某个表的增删改查操作),SessionDao(session 表)、MessageDao(message 表)、UserSessionStateDao(user_session_state 表)、RedisStore(对 Redis 缓存进行操作)等
2、启动gRPC服务监听和HTTP服务监听。
3、**初始化面向业务的数据访问对象,**不同的业务有不同的数据访问对象,数据访问对象会持有不同的DAO来完成对数据库的操作,目的是让业务层不用关心底层 SQL 或 Redis 命令,只需调用这些类的方法即可完成数据操作。比如
| 类名 | 面向业务 | 依赖/需要的 DAO |
|---|---|---|
| ConversationStore | 会话、消息、未读数管理 | SessionDao、MessageDao、UserSessionStateDao、RedisStore |
| GroupMemberDao | 群成员管理 | 主要自己就是 DAO,可能用 UserDao、GroupDao |
| UserSessionStateDao | 用户会话状态管理 | 主要自己就是 DAO |
- model.cpp / model.h
定义统一业务数据结构,如用户(User)、会话(Session)、消息(Message)、用户会话状态(UserSessionState)等核心结构体。
cpp
Session ≈ QQ/微信的"聊天窗口",你和某人/某群的聊天历史都属于一个 session
enum class SessionType {
kSingle = 0,
kGroup = 1,
kChatroom = 2,
};
struct Session {
std::string id; // session_id
SessionType type{SessionType::kSingle};
int64_t user1_id{0};
int64_t user2_id{0};
int64_t group_id{0};
int64_t last_msg_seq{0};
};
gRPC/HTTP 服务
更新对grpc的理解:在logic的main函数里面,会用主线程去启动HTTP服务器,再用一个子线程去启动gRPC服务器。
归根结底,我们知道HTTP服务器是启动muduo中的HTTPServer(继承自最基础的TcpServer),具体见http_server.h。
而gRPC服务器,是启动/build/proto/generated/spark_push.grpc.pb.h里面实现的server基类服务器,具体见grpc_service.h。
而这个spark_push.grpc.pb.h,明显是在build里面的,其实也就是在编译阶段,由spark_push.proto文件利用protoc工具自动生成的,就像项目实战2里面的RPC一样,但是自动生成的.grpc.pb.h里面的基类server与stub都只有简单的函数格式规定,具体的业务代码需要继承重写,所以有了grpc_service.h里面的LogicServiceImpl新服务器。
- grpc_service.cpp / grpc_service.h
实现 Logic 的 gRPC 服务端,处理来自 Comet 的 gRPC 请求(如消息上行、用户状态同步等)。
- 用户登录、鉴权、连接建立时:调用
VerifyToken。 - 用户通过 WebSocket 发送消息时:调用
SendUpstreamMessage。 - 用户断开连接时:调用
UserOffline。 - 用户进入/离开聊天室时:调用
ReportRoomJoin、ReportRoomLeave。 - 需要全员广播时:调用
Broadcast。
logic把消息推送到Kafka的具体实现:
- comet调用logic服务的SendUpstreamMessage后,在SendUpstreamMessage里,会根据消息类型(单聊/聊天室),获取或创建会话,并查找需要推送的目标 comet 节点和用户。
- 在推送之前,根据消息类型,就会从redis中获取到comet_to_users,目标对象
- 把comet_id、消息内容、usr_id等打包成protobuf对象再通过protobuf序列化
- 通过 Kafka 官方 C/C++ 客户端库Producer类的produce方法,把消息发送到对应的topic
- http_server.cpp / http_server.h
实现 HTTP API 服务(如登录、发单聊消息、获取历史消息、发送弹幕消息、获取历史弹幕列表(不是为了展示,而是为了断线/刷新重连)等),前端直接向 logic 服务器发 HTTP 请求(如 POST/GET)访问这些接口,实现登录、注册、发消息、拉取历史、弹幕等功能。
注意与comet的作用区分:comet:负责长连接与实时消息推送(WebSocket),HTTP 适合短连接、请求-响应、表单登录、拉历史、发起动作(登录/注册/发消息)。前端直接调用方便、兼容性好。
插入弹幕功能的具体实现:
- 检验并解析请求,获取请求参数, video_id、timeline_ms、text、token 等
- 鉴权判断是否登录
- 组装弹幕消息内容为 JSON 字符串(包含类型、视频ID、时间线、文本内容)
- 写入MySQL(持久化)和Redis(更新 Redis 中的 last_seq,便于获取未读消息数)
- 构造数据对象 ChatMessage
- 从 Redis 查询房间在线的 comet 节点
- 再推送到Kafka然后由job推给comet服务器。(让视频(聊天室)所有在线用户都能看到消息)
- 返回响应。让前端知道弹幕已被受理,并可据此做 UI 反馈。
特点:是被动查询 ,客户端主动请求,服务端一次性返回结果。而客户端通过websocket帧发送给comet再通过grpc到logic再返回给客户端数据的流程,是长连接,是一种实时消息的**主动推送,**比如新消息、群聊、系统通知等。
数据访问层(DAO)
都是封装对数据库的操作,然后提供给业务层(http_server)的接口方法。
-
user_dao.cpp / user_dao.h
用户信息的数据库操作。
实现功能:创建用户、按账号查询用户、按 id 查询用户
-
group_dao.cpp / group_dao.h
群组/聊天室相关数据库操作。
实现功能:创建聊天室、按 id 查询聊天室信息、分页列出聊天室列表
-
session_dao.cpp / session_dao.h
会话(如单聊、群聊)的数据库操作。
实现功能:获取或创建单聊会话、获取或创建聊天室会话、按 session_id 查询会话、为会话分配下一个消息序列号、列出用户参与的单聊会话
-
message_dao.cpp / message_dao.h
消息的存储、查询等数据库操作。
实现功能:插入一条消息记录、按会话分页查询历史消息
-
user_session_state_dao.cpp / user_session_state_dao.h
用户会话状态(如在线/离线、活跃时间等)的数据库操作。
实现功能:插入或更新用户会话已读序列、查询用户会话已读序列
-
danmaku_dao.cpp / danmaku_dao.h
弹幕相关的数据操作(如聊天室弹幕消息)。
实现功能:插入一条弹幕记录、获取视频弹幕列表
-
redis_store.cpp / redis_store.h
也是封装,但只是Redis 相关操作封装,做缓存、状态同步等。属于DAO层
主要功能:添加/移除/获取用户路由 comet 集合、未读相关:会话最新 seq 与用户已读 seq、聊天室房间路由:room_id -> set<comet_id>、聊天室在线人数:room_id -> online_count
存储与缓存
- conversation_store.cpp / conversation_store.h
会话与消息的统一数据访问和业务封装类,它把"会话、消息、未读数"等相关操作整合到一起,对外提供一套简单的接口。比如:
像 AppendMessage 这样的函数就会用到多个 DAO,比如:
先用 session_dao_ 分配消息序号
再用 message_dao_ 插入消息
最后用 redis_store_ 更新缓存
logic服务器函数间配合关系:
用户先通过 logic 服务器的 HTTP API 完成登录,获取token(这个 token 会被写入 Redis,后续用于鉴权),此时logic在监听两个服务,一个对接前端的HTTP服务,一个对接comet的gRPC服务。
(1)如果用户在聊天室发送一条消息,logic就会接收到comet发送的gRPC服务请求,调用grpc_service.h 里的SendUpstreamMessage方法。在这个函数里面,会调用conversation_store.h里面的GetOrCreateRoomSession,获取聊天室的session会话,调用conversation_store.h的AppendMessage,持久化消息到MySQL(会话消息表)和redis(更新seq序号)。再通过redis_store.h的GetRoomComets方法,查出该房间涉及的所有 comet 节点(comet 收到后再分发给本节点上的所有房间成员),组装成ChatMessage(protobuf序列化)后,最后通过KafkaProducer::Send(对Kafka官方produce发送到topic的封装)发送。
(2)如果接收到的是前端调用的HTTP请求,如登录、发单聊消息、获取历史消息、发送弹幕消息、获取历史弹幕列表(不是为了展示,而是为了断线/刷新重连)等,比如发送弹幕,就会调用http_server 里的handleDanmakuSend函数,在函数里面,会通过danmaku_dao.h的InsertDanmaku方法,实现弹幕内容的持久化(写入mysql弹幕表,++用于弹幕轨道的查询和展示++ ),再通过conversation_store.h里面的GetOrCreateRoomSession,获取视频(聊天室)的session会话,然后再调用conversation_store.h里面的AppendMessage,作为一条消息写入会话消息表(如 im_message),++用于聊天室消息推送++ 。最后也是通过redis_store.h的GetRoomComets方法获取room_id->comet_id,再打包通过protobuf序列化后push到Kafka上面。
job服务器
main.cpp
程序入口,负责初始化配置、日志系统、依赖组件(如 Kafka、Redis 等),创建并启动 Job 服务主循环。
service.h
声明 Job 服务的主要类(JobRunner),定义需要实现的业务方法和接口,供其他模块调用。
service.cpp
实现 Job 服务的核心业务逻辑,包括从 Kafka 消费消息、解析消息、分发处理、推送到 comet 等。
job工作流程:
main函数读取配置文件,获取Kafka相关配置(broker地址、topic名称),comet节点的地址列表(comet_id -> ip:port 映射),Redis 相关配置,服务线程池中线程数量等。然后拿这些配置参数cfg初始化service.h 里的JobRunner类,在JobRunner::init初始化函数里面会初始化Kafka 消费者和线程池,其中Kafka消费者有两个,普通推送消费者consumer_,订阅 push topic;广播任务消费者broadcast_consumer_:订阅 broadcast_task topic。这个初始化消费者的KafkaConsumer::Init由Kafka库提供,在里面还要设置回调函数 ,会在 Kafka 消费者拉取到新消息时被自动调用。而消费者的启动,以及消费者如何从Kafka拉取到消息,都在**common目录下的Kafka_consumer中实现。**而回调函数的具体内容,是在service.cpp里面编写,具体工作就是,将Kafka中取出的消息,经过protobuf请求解析后,投递到线程池来完成调用comet的RPC服务 发送grpc请求的任务。线程入口函数:(JobRunner::ProcessPushRequest 或者 JobRunner::HandleBroadcastTask)因为有两类消费者,他们执行的业务不同。
common层:通用基础组件
为整个项目提供配置、日志、消息队列、数据库、缓存、并发等底层能力的统一封装。让业务模块可以方便、安全、高效地使用这些基础服务,提升开发效率和系统健壮性。
common层会通过自己的CMakelist.txt,CMake编译成一个spark_push_common静态库,在logic、comet这样的业务模块的CMakelist.txt里面链接上使用。
kafka_consumer.cpp / .h
cpp
#include <librdkafka/rdkafkacpp.h>
std::unique_ptr<RdKafka::KafkaConsumer> consumer_;
主要功能:
kafka消费者封装,提供下面两个方法,三个功能:
(1)Init方法初始化消费者,conf是从job服务器的配置文件里面传下来的,从job服务器main.cpp:RunJob(cfg) ->JobRunner runner(cfg)->runner.Init()->consumer_.Init()
cpp
consumer_.reset(RdKafka::KafkaConsumer::create(conf, errstr));
(2)订阅topic,通过Kafka提供的subscribe(topics)函数
cpp
RdKafka::ErrorCode err = consumer_->subscribe(topics);
(3)Loop方法开启while循环,每间隔1s通过Kafka提供的consume函数,主动向 Kafka 拉取消息。
cpp
std::unique_ptr<RdKafka::Message> msg(consumer_->consume(1000));
kafka_producer.cpp / .h
Kafka 生产者封装,提供初始化与发送接口。
cpp
#include <librdkafka/rdkafkacpp.h>
std::unique_ptr<RdKafka::Producer> producer_;
(1)init方法,初始化生产者
cpp
producer_.reset(RdKafka::Producer::create(conf, errstr));
(2)send方法,推送到Kafka队列,然后再用poll方法,通知Kafka内部的回调,进行消息投递。
cpp
producer_->produce(
topic_.get(),
RdKafka::Topic::PARTITION_UA,
RdKafka::Producer::RK_MSG_COPY,
const_cast<char*>(value.data()),
value.size(),
key.empty() ? nullptr : &key,
nullptr);
producer_->poll(0);
logging.cpp / .h
日志系统封装,提供日志记录、格式化、输出等功能
mysql_pool.cpp / .h
把底层的 MySQL 连接管理(创建、复用、回收、健康检查等)都封装在 MySqlConnectionPool 类里。提供了线程安全的获取和归还连接接口,支持自动扩容、空闲回收、超时等待等功能。
但是注意是同步的MySQL连接池,不是异步的,没有建立一个线程池来实现异步。
CreateContextUnlocked 负责建立真实连接:
cpp
#include <mysql/mysql.h>
MYSQL* conn = mysql_init(nullptr);
连接获取和自动扩容:
cpp
// 阻塞获取连接;timeout_ms < 0 表示一直等待,=0 表示立即返回,大于 0 表示等待指定毫秒
MySqlConnGuard Acquire(int timeout_ms = -1);
// 自动扩容
if (cfg_.enable_auto_grow && total_conns_ < cfg_.max_pool_size) {
conn = CreateConnectionUnlocked();
if (conn) {
++total_conns_;
break;
}
}
CleanupIdleUnlocked 负责空闲回收:
cpp
mysql_close(entry.conn);
idle_.pop_front(); //idle_是存放MySQL真实连接的双端队列 deque
--total_conns_;
redis_pool.cpp / .h
Redis 连接池封装,管理 Redis 连接的创建、复用和回收。也是同步实现的,实现方式和MySQL连接池类似,也是由一个双端队列来存储连接,然后对外提供两个接口:Init初始化和Acquire获取连接
对内:
cpp
redisContext* CreateContextUnlocked();//负责建立真实连接
redisContext* PopIdleUnlocked();//负责从空闲队列弹出/归还并做通知
void CleanupIdleUnlocked();//负责空闲回收
bool Validate(redisContext** ctx);//负责判定连接健康
thread_pool.cpp / .h
和正常线程池实现一样。线程池封装,提供任务并发执行、线程管理等功能。支持提交普通函数任务到队列queue里。
