在本项目中, RabbitMQ 扮演着"消息中转站"和"流量缓冲池"的核心角色,主要解决了 写 扩散带来的数据库压力 和 系统解耦 问题。
1. RabbitMQ 存储了什么?
RabbitMQ 本质上是一个消息队列,它 暂存 了需要被异步处理的业务消息。在本项目中,主要存储的是:
- 待持久化的聊天消息 :
- 生产者 :Transmite Service(转发服务)。
- 消费者 :Message Service(消息存储服务)。
- 内容 :序列化后的 Protobuf 消息对象(包含发送者 ID、接收者/群 ID、消息内容、时间戳等)。
- 流向 : Transmite Service -> Exchange -> Queue -> Message Service 。
2. 为什么要用 RabbitMQ? (核心价值)
A. 削峰填谷 (Traffic Shaping) - 最核心原因
- 场景 :假设一个 500 人的群,有人发了一条消息。在"写扩散"模式下,如果不加 MQ,Message Service 需要瞬间向数据库写入 500 条记录(或者更新 500 个会话的最新消息)。如果此时是跨年夜,每秒有 1万条群消息,数据库瞬间面临百万级的 QPS 写入压力,直接宕机。
- MQ 的作用 :
- Transmite Service 收到消息后,只负责推送到 MQ,耗时极短(毫秒级),立刻返回成功。
- Message Service 可以根据自己的处理能力(比如每秒处理 2000 条),平滑地从 MQ 拉取消息写入 MySQL。
- 举例 :MQ 像一个大水库,把瞬间的洪水(流量高峰)蓄起来,然后通过水管(消费者)平缓地流向下游(数据库),保护了脆弱的数据库。
B. 异步解耦 (Asynchronous Decoupling)
- 同步调用的痛点 :如果没有 MQ,Transmite Service 必须通过 RPC 同步调用 Message Service。如果 Message Service 卡顿或挂了,Transmite Service 也会跟着卡顿,甚至导致整个聊天功能不可用(级联故障)。
- MQ 的作用 :Transmite Service 只需要把消息扔给 MQ 就不管了(Fire-and-Forget)。即使 Message Service 挂了,消息也会安全地存在 MQ 里,等 Message Service 重启后继续消费,不会丢消息,也不影响用户发送消息的体验。
C. 提升响应速度 (Low Latency)
- 用户体验 :用户发消息时,最在乎的是"转圈圈"转多久。
- 对比 :
- 无 MQ :等待(数据库写磁盘 + 索引构建 + 网络 IO)= 50ms ~ 200ms。
- 有 MQ :等待(写入内存队列)= 1ms ~ 5ms。
- 结论 :引入 MQ 将耗时的"落库"操作从主链路中剥离,实现了极致的发送速度。
3. 代码实现细节分析 (rabbitmq.hpp)
- 底层库 :使用了 AMQP-CPP 库,配合 libev 事件循环库。这意味着 MQ 的客户端是 全异步、非阻塞 的,非常适合高并发场景。
- 核心方法 :
- declareComponents : 声明交换机 (Exchange) 和队列 (Queue) 并绑定。这里使用的是 direct 类型交换机,通过 routing_key 精确匹配。
- publish : 发送消息。注意它是异步的,调用后立即返回。
- consume : 消费消息。通过注册回调函数 cb 来处理收到的消息,处理完后必须手动 ack (确认),确保消息不丢失。
- 线程模型 :开启了一个独立的线程 _loop_thread 运行 ev_run 事件循环,处理网络包的收发,不会阻塞主业务线程。
3. 总结
RabbitMQ 是该即时通讯系统应对 高并发写入 的"减震器"。它用极小的延迟换取了系统极大的稳定性,是工业级 IM 架构中不可或缺的组件。
4. 待持久化的聊天消息的存储详细过程
"待持久化的聊天消息"是指那些已经由用户发送、经过转发服务处理,但尚未写入数据库和搜索引擎的"在途"消息。整个持久化过程是一个经典的生产者-消费者模型,涉及多个服务和组件的协作。
以下是代码级别的详细解析,涉及 Transmite Service(生产者)和 Message Service(消费者)两部分。
4.1 第一阶段:消息生产 (Transmite Service)
当用户发送消息时, Transmite Service 负责将消息封装并发布到 MQ,而不直接写库。
核心代码位置 : transmite_server.hpp 中的 GetTransmitTarget 方法。
-
完整性校验与封装 :
- 调用 User Service : 根据 user_id 获取发送者的详细信息(昵称、头像等),因为前端发来的消息只有 ID。
transmite_server.hpp:L67 : stub.GetUserInfo(...)
- 生成 MessageID : 为消息生成全局唯一的 message_id 。
transmite_server.hpp:L73 : message.set_message_id(uuid())
- 调用 User Service : 根据 user_id 获取发送者的详细信息(昵称、头像等),因为前端发来的消息只有 ID。
-
发布到 MQ :
- 序列化 : 将封装好的 MessageInfo 对象序列化为二进制字符串。
- 异步发布 : 调用
_mq_client->publish将消息发送到 RabbitMQ 的 msg_exchange 交换机。 transmite_server.hpp:L81 : _mq_client->publish(_exchange_name, message.SerializeAsString(), ...)- 关键点 : 这里只发 MQ,不写 MySQL。只要 MQ 返回 true ,就认为消息发送成功,极大降低了用户等待时间。
4.2 第二阶段:消息消费与持久化 (Message Service)
Message Service 作为一个后台消费者,持续从 MQ 拉取消息并落库。
核心代码位置 : message_server.hpp 中的 onMessage 方法。
-
监听与反序列化 :
- Message Service 启动时会订阅 MQ 队列,当有新消息时触发 onMessage 回调。
message_server.hpp:L255 : void onMessage(const char *body, size_t sz)message_server.hpp:L259 : message.ParseFromArray(body, sz)
-
分类处理与文件上传 :
- 如果是 文本消息 :直接准备存入 ES。
- 如果是 多媒体消息 (图片/语音/文件):
- 消息体中包含文件的二进制数据,不能直接存 MySQL(太大了)。
- 调用 File Service 上传文件,获取返回的 file_id 。
message_server.hpp:L286 : _PutFile(...)
-
双重持久化 (Dual Persistence) :
- 写 ES (全文检索) :
- 仅对文本消息 ( MessageType::STRING ) 建立索引,方便后续搜索。
message_server.hpp:L271 : _es_message->appendData(...)
- 写 MySQL (永久存储) :
- 无论什么类型的消息,都要存入 MySQL。对于文件消息,只存 file_id 、 file_name 等元数据,不存文件内容。
message_server.hpp:L329 : _mysql_message->insert(msg)
- 写 ES (全文检索) :
4.3 总结
整个过程的代码流向为: NewMessageReq (用户请求)
-> Transmite::GetTransmitTarget (补全信息)
-> MQ Publish (RabbitMQ)
-> Message::onMessage (异步消费)
-> File Service (上传大文件)
-> MySQL Insert & ES Index (最终落盘)
这种设计确保了高并发下的系统稳定性,即使 MySQL 暂时写入缓慢,MQ 也能堆积消息,不会阻塞用户的发送操作。
5. 消息的一致性
"消息的一致性"是 IM 系统的核心挑战,即 确保消息不丢、不重、不乱 。在本项目中,主要通过 RabbitMQ 的可靠性机制 和 业务层的去重逻辑 来保证。
以下是代码参与的关键环节:
5.1 生产端一致性:确保消息成功发给 MQ
在 Transmite Service 中,必须保证消息成功投递到 MQ,否则会返回错误给客户端,让客户端重试。
核心代码 : transmite_server.hpp:L81-L85
cpp
// 尝试发布消息
bool ret = _mq_client->publish
(_exchange_name, message.
SerializeAsString(), _routing_key);
if (ret == false) {
// 如果发布失败(例如网络断了),打印
错误并返回失败响应
// 客户端收到失败响应后,会触发重试机
制(客户端逻辑)
return err_response
(request->request_id(), "持久化消
息发布失败:!");
}
- 机制 :同步检查 publish 的返回值。虽然 AMQP-CPP 的 publish 是异步的,但如果有连接级错误会立即感知。
- 优化空间 :当前代码只检查了 publish 调用的返回值。更严格的做法是开启 RabbitMQ 的 Confirm 模式 (Publisher Confirms),等待 Broker 返回 Ack 才算真正成功。
5.2 消费端一致性:确保消息成功落库
在 Message Service 中,必须保证从 MQ 拿到的消息被成功写入 MySQL/ES,不能"拿出来就丢了"。
核心代码 : rabbitmq.hpp:L89
cpp
_channel->consume(queue,
"consume-tag")
.onReceived([this, cb](const
AMQP::Message &message,
uint64_t deliveryTag, bool
redelivered) {
// 1. 执行业务回调(即
MessageServer::onMessage,负
责写库)
cb(message.body(), message.
bodySize());
// 2. 只有业务处理完了,才手动发
送 Ack 给 MQ
_channel->ack(deliveryTag);
})
- 机制 : 手动 Ack (Manual Acknowledge) 。
- RabbitMQ 推送消息给消费者后,消息处于 Unacked 状态。
- 只有当 cb(...) (即写数据库逻辑)执行完毕后,代码才调用
_channel->ack(deliveryTag) 。 - 异常处理 :如果 Message Service 在执行 cb 过程中挂了(没来得及 Ack),RabbitMQ 会发现连接断开,自动将该消息 重新入队 ,发给其他消费者,确保 消息不丢失 。
5.3 幂等性:防止消息重复
由于 RabbitMQ 的"失败重试"机制(或者网络抖动), Message Service 可能会收到两条一样的消息。如果不处理,数据库会有重复聊天记录。
核心代码 : message_server.hpp:L320-L329
cpp
// 构造消息对象,包含全局唯一的
message_id
bite_im::Message msg(message.
message_id(), ...);
// 尝试插入 MySQL
ret = _mysql_message->insert(msg);
- 机制 : 数据库唯一索引 (Unique Key) 。
- 虽然代码里没明写"去重逻辑",但 message_id 是由 UUID 生成的全局唯一 ID。
- 在 MySQL 建表时( sql/message.sql ), message_id 应该是主键或唯一索引。
- 当插入重复的 message_id 时,MySQL 会报错( Duplicate entry ),插入失败。这样就天然保证了 业务幂等性 。
总结
- 发没发成功? 靠 Transmite Service 检查 MQ 发布结果 + 客户端超时重试。
- 存没存成功? 靠 RabbitMQ 的 手动 Ack 机制,存完再删 MQ 里的消息。
- 会不会重? 靠 MySQL 的 message_id 唯一约束 自动去重。