C++集群聊天服务器:基于Redis与MySQL的分布式好友添加架构设计与实践
1. 项目概述与核心需求解析
最近在重构一个老旧的C++聊天服务,核心需求之一就是实现一个健壮的“添加好友”业务。这听起来简单,不就是往数据库里插一条记录吗?但当你把它放到一个集群环境下,事情就变得复杂起来了。用户A在服务器节点1上发送好友请求给用户B,而用户B的会话可能正连接在服务器节点2上,如何确保这个请求能准确、及时、不重复地送达,并且状态能跨节点一致?这就是集群聊天服务器中“添加好友”业务要解决的核心问题。它远不止是一个简单的数据库CRUD操作,而是一个涉及网络通信、状态同步、数据一致性和业务逻辑完整性的分布式系统问题。
我这次的目标,是设计并实现一个能支撑高并发、保证最终一致性、并且易于扩展的添加好友模块。适合阅读这篇内容的,是那些已经掌握了C++网络编程基础(比如用过muduo、libevent等)、对MySQL/Redis有一定了解,并且开始尝试将单机服务改造为分布式服务的开发者。我们会从最朴素的单机实现开始,一步步推到集群环境,把其中每一个坑都踩一遍,并给出经过线上验证的解决方案。
2. 整体架构设计与技术选型考量
2.1 为什么选择“业务逻辑与通信分离”的架构
在单机聊天服务器里,添加好友的逻辑可能直接写在处理TCP连接的业务类里。但在集群环境下,我们必须把“业务逻辑”和“节点间通信”解耦。我的设计是:每个服务器节点运行完全相同的业务逻辑代码,但它们不直接感知其他节点的存在。所有跨节点的交互,都通过一个中央消息路由组件(通常是Redis Pub/Sub或专业的消息队列如RocketMQ/Kafka)来完成。节点本身只关心三件事:1. 处理客户端请求;2. 执行本地业务逻辑(读写数据库);3. 将需要广播的事件发布到消息总线上。
这样设计的好处非常明显。首先,业务代码保持纯净,与单机版本差异很小,降低了开发和维护成本。其次,扩展性极佳,增加节点时,新节点只需要连接到同一个消息总线,就能立刻融入集群。最后,容错能力强,单个节点的故障不会影响消息总线的运转,其他节点可以照常工作。当然,引入了中间件,系统的复杂度和运维成本会上升,这是为了获得分布式能力必须付出的代价。
2.2 核心组件拆解与选型理由
一个完整的集群聊天服务器添加好友模块,通常包含以下核心组件,我的选型基于性能、成熟度和社区支持度:
网络通信框架:muduo/libevent
- 选择理由:C++11的高性能网络库。muduo的Reactor模型和“one loop per thread”思想非常适合聊天这种高并发、长连接场景。它封装了底层的epoll和线程池,让我们能专注于业务逻辑。如果团队更熟悉libevent,它也是完全可行的选择,原理相通。
业务逻辑服务器(ChatServer):
- 这是我们的主进程,承载所有业务。它内嵌了:
- 数据库访问层(DAO):封装对MySQL的增删改查。这里必须使用数据库连接池(如
sqlpp11配合自定义池,或使用libmysqlclient自己封装),避免频繁创建销毁连接带来的巨大开销。 - 本地缓存(Local Cache):使用
std::unordered_map或更高效的folly::ConcurrentHashMap,在内存中维护user_id -> TCP连接的映射关系。这是实现“消息直达”的关键,查询效率是O(1)。
- 数据库访问层(DAO):封装对MySQL的增删改查。这里必须使用数据库连接池(如
- 这是我们的主进程,承载所有业务。它内嵌了:
中央消息路由(Redis Pub/Sub):
- 选择理由:实现简单,性能足够。对于聊天场景,消息的实时性要求高于绝对的可靠性和顺序性(好友添加通知晚零点几秒收到通常可接受)。Redis Pub/Sub足以胜任节点间的事件广播。每个服务器节点订阅一个公共频道(如
cluster_chat_event),同时将自己需要广播的事件发布到该频道。 - 注意事项:Redis Pub/Sub的消息是“fire-and-forget”的,如果某个节点在消息发布时宕机,它会永久丢失这条消息。对于“添加好友请求”这种需要可靠送达的消息,我们会在业务层设计确认和重试机制来弥补,而不是依赖Redis本身的可靠性。
- 选择理由:实现简单,性能足够。对于聊天场景,消息的实时性要求高于绝对的可靠性和顺序性(好友添加通知晚零点几秒收到通常可接受)。Redis Pub/Sub足以胜任节点间的事件广播。每个服务器节点订阅一个公共频道(如
数据存储(MySQL + Redis):
- MySQL:作为权威数据源(Source of Truth),存储所有持久化数据。好友关系表
friend是核心,至少包含userid、friendid、status(如0-已发送/1-已同意/2-已拒绝)、createtime等字段。这里必须建立联合唯一索引(userid, friendid),防止重复的好友关系。 - Redis:作为热数据缓存和会话存储。缓存用户信息、好友列表。更重要的是,用
Redis Hash或String来存储user_id -> server_id的映射,即用户当前连接到了哪个服务器节点。这是实现跨节点消息精准路由的“指挥中心”。
- MySQL:作为权威数据源(Source of Truth),存储所有持久化数据。好友关系表
服务发现与注册中心(集成在Redis中):
- 我们没有引入ZooKeeper或etcd等重型组件。每个ChatServer节点启动时,向Redis的一个
Set(如online_servers)注册自己的server_id(可以用IP:Port或生成UUID)。下线时主动移除。其他节点或网关可以通过查询这个Set来感知集群状态。
- 我们没有引入ZooKeeper或etcd等重型组件。每个ChatServer节点启动时,向Redis的一个
这个架构的数据流可以概括为:客户端请求 -> 本地业务逻辑(读写MySQL,更新本地缓存) -> 如需跨节点通知,则发布事件到Redis -> 其他节点订阅到事件,执行本地更新(如更新本地缓存,若目标用户在本节点则直接推送)。
踩坑心得:在早期版本中,我曾尝试用数据库表来同步节点状态,通过轮询或触发器来感知变化。这在节点数超过3个后,数据库压力巨大,且延迟非常高。最终统一改用Redis作为集群的“神经系统”,问题迎刃而解。记住,数据库是用来持久化数据的,不是用来做实时消息总线的。
3. 数据库表设计与业务状态机
3.1 核心表结构设计
所有花哨的架构最终都要落地到数据库。friend表的设计是业务的基石。
CREATE TABLE `friend` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', `userid` bigint(20) NOT NULL COMMENT '用户id', `friendid` bigint(20) NOT NULL COMMENT '好友id', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0-请求中,1-已同意,2-已拒绝,3-已拉黑', `createtime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updatetime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_friend` (`userid`,`friendid`), -- 防止重复关系 KEY `idx_userid` (`userid`), KEY `idx_friendid` (`friendid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友关系表';字段设计解析:
userid和friendid:标识一段关系的双方。注意,添加好友是双向操作,但我们在业务上通常视为两条记录(A->B, B->A),这样查询“我的好友”时逻辑简单(select friendid from friend where userid = ? and status = 1)。status:这是业务状态机的核心。我定义了4个状态,足以覆盖主流IM场景。uk_user_friend:这是生命线。没有这个唯一索引,在高并发下,可能插入两条一模一样的(A, B)请求,导致业务逻辑混乱。
3.2 添加好友业务状态机流转
添加好友不是一个瞬间动作,而是一个有状态的流程。理解状态机是写出正确业务逻辑的前提。
- 初始状态:用户A和B无任何关系。
- A发送请求(Pending):
- A向服务器发送
AddFriendRequest(A, B)。 - 服务器逻辑:检查
friend表中是否存在(A, B)或(B, A)且状态为0(请求中)或1(已是好友)的记录。如果存在,直接返回“请求已发送”或“已是好友”。(防重) - 向
friend表插入两条记录:(A, B, status=0),(B, A, status=0)。这里必须在一个数据库事务中完成,保证原子性。 - 更新Redis中A和B的好友列表缓存(标记为待更新)。
- 发布事件到Redis频道:
{“event”: “friend_request”, “from”: A, “to”: B, “msg”: “hello”}。
- A向服务器发送
- B处理请求(Accept/Reject):
- 服务器节点(可能不是A所在的节点)收到事件,如果发现用户B正连接在本节点,则通过TCP连接将请求推送给B的客户端。
- B的客户端选择“同意”或“拒绝”。
- 服务器逻辑(B所在节点处理):
- 开启事务。
- 将
(B, A, status=0)和(A, B, status=0)两条记录的status同时更新为1(同意)或2(拒绝)。必须同时更新两条记录,保证关系对称。 - 提交事务。
- 清除Redis中A和B相关的好友列表缓存。
- 发布事件:
{“event”: “friend_accept”, “from”: B, “to”: A}(或friend_reject)。
- A收到处理结果(Notification):
- A所在的服务器节点订阅到
friend_accept事件。 - 如果用户A在线(本地缓存有记录),则通过TCP连接直接推送“B已同意您的好友请求”的消息。
- 更新A的本地会话或前端界面。
- A所在的服务器节点订阅到
实操心得:状态更新必须用事务包裹,并且更新条件要加上
status=0(即UPDATE friend SET status=1 WHERE userid IN (A,B) AND friendid IN (B,A) AND status=0)。这可以防止重复处理(幂等)。例如,网络延迟导致B客户端发送了两次“同意”指令,第二次更新会因为status已不为0而影响行数为0,业务层据此判断为重复操作。
4. 集群环境下的关键实现细节
4.1 用户会话定位与跨节点消息路由
这是集群架构中最精妙的部分。假设用户A在Node1,用户B在Node2。A添加B为好友后,如何让B立刻收到通知?
会话映射存储:当任何一个用户成功登录后,处理登录请求的服务器节点需要做两件事:
- 本地记录:在内存的
ConcurrentHashMap中插入user_id -> TcpConnectionPtr。 - 全局注册:向Redis写入一条记录,Key为
user:session:<user_id>,Value为当前节点的server_id(如node1_ip:port),并设置一个合理的过期时间(如30分钟,配合心跳续期)。
- 本地记录:在内存的
事件发布与路由逻辑:
- 当Node1需要通知用户B时(例如,A发送了好友请求),它首先检查本地缓存。如果B不在本地,则去查询Redis:
GET user:session:<B_id>。 - 如果查到B在Node2,Node1并不直接联系Node2(那会变成节点间紧耦合)。Node1只需向Redis的公共频道
cluster_chat_event发布一个事件消息。 - 消息体设计如下:
{ “event_type”: “friend_request”, “target_user_id”: 123456, // 目标用户B “source_user_id”: 654321, // 发起用户A “payload”: {“msg”: “Hi, add me!”}, “msg_id”: “uuid_v4”, // 全局唯一消息ID,用于去重 “timestamp”: 1678880000 } - 关键点:消息体中必须包含
target_user_id。所有节点都订阅了cluster_chat_event,但只有发现target_user_id正在自己节点上连接的节点,才需要处理这条消息。Node2订阅到消息后,发现目标用户B正在本地,于是从本地缓存中找到B的TCP连接,将好友请求推送出去。
- 当Node1需要通知用户B时(例如,A发送了好友请求),它首先检查本地缓存。如果B不在本地,则去查询Redis:
节点故障处理:
- 如果Node2宕机,Redis中
user:session:<B_id>的键会因过期而消失。当Node1发布事件后,没有任何节点响应。 - 为此,我们需要一个离线消息存储。在发布事件前,如果查询到目标用户
user:session不存在(即不在线),则将此条“好友请求”作为离线消息存入MySQL或Redis的一个有序集合(Sorted Set)中,Key为offline_msg:<user_id>。待用户B重新登录任何节点时,该节点会拉取并处理这些离线消息。
- 如果Node2宕机,Redis中
4.2 保证最终一致性的策略
在分布式系统中,我们追求的是最终一致性,而不是强一致性。对于“添加好友”业务,允许短暂的不一致(如A显示已发送,B端稍后才显示通知),但最终状态必须正确。
- 数据库作为唯一权威:任何好友关系的最终状态,以MySQL中的
friend表为准。缓存(Redis)和内存映射都是它的“视图”,可以失效,但最终需要与数据库对齐。 - 缓存更新策略:采用“写后失效(Write-Invalidate)”策略。任何对
friend表的写操作(插入、更新状态)成功后,立即删除Redis中相应用户的好友列表缓存(如friend_list:<user_id>)。下次查询时,缓存未命中,从数据库加载并重新填充缓存。 - 消息的幂等性处理:网络可能重复传递消息。在每个需要跨节点同步的事件消息中,必须携带一个全局唯一的
msg_id(如UUID)。接收节点在处理前,先用Redis的SETNX命令(或检查本地已处理消息ID集合)判断该msg_id是否已处理过。如果已处理,则直接丢弃,确保业务逻辑只执行一次。 - 补偿机制:定时运行一个后台任务,扫描
friend表中状态为0(请求中)但创建时间超过24小时的记录,自动将其状态更新为2(已拒绝),并清理相关缓存。防止“僵尸”好友请求永远挂在那里。
4.3 核心代码片段解析(C++11)
以下是一些关键环节的伪代码,展示了核心逻辑的实现思路。
1. 处理添加好友请求(位于某个ChatServer节点的业务处理器中)
// 伪代码,展示逻辑流程 void ChatService::addFriend(const TcpConnectionPtr& conn, json& js, Timestamp time) { // 1. 解析请求:用户id,好友id,验证信息等 int userid = js["userid"].get<int>(); int friendid = js["friendid"].get<int>(); string verifymsg = js["verifymsg"]; // 2. 数据库校验(防重、黑名单等) { std::lock_guard<std::mutex> lock(mutex_); // 实际应用更细粒度锁或CAS if (friendModel_.isFriend(userid, friendid)) { // 已是好友,直接返回 sendError(conn, “already_friend”); return; } if (friendModel_.hasPendingRequest(userid, friendid)) { // 请求已发送 sendError(conn, “request_pending”); return; } } // 3. 数据库事务:插入两条状态为0的记录 if (!friendModel_.insertFriendRequest(userid, friendid)) { sendError(conn, “database_error”); return; } // 4. 清除Redis缓存(异步进行,避免阻塞主线程) redisClient_->del(“friend_list:” + std::to_string(userid)); redisClient_->del(“friend_list:” + std::to_string(friendid)); // 5. 准备跨节点事件消息 json eventMsg; eventMsg[“event_type”] = “friend_request”; eventMsg[“target_user_id”] = friendid; eventMsg[“source_user_id”] = userid; eventMsg[“payload”][“msg”] = verifymsg; eventMsg[“msg_id”] = generateUUID(); // 生成唯一ID eventMsg[“timestamp”] = time.microSecondsSinceEpoch(); // 6. 查询目标用户所在节点 string targetServer = redisClient_->get(“user:session:” + std::to_string(friendid)); if (!targetServer.empty()) { // 目标在线,发布事件到公共频道 redisClient_->publish(“cluster_chat_event”, eventMsg.dump()); } else { // 目标离线,存储离线消息 offlineMsgModel_.insert(friendid, eventMsg.dump()); } // 7. 回复请求发送者 json response; response[“msgid”] = ADD_FRIEND_ACK; response[“errno”] = 0; response[“errmsg”] = “request sent”; conn->send(response.dump()); }2. 订阅并处理集群事件(独立线程或IO线程中)
// 伪代码:事件处理线程主循环 void ChatServer::eventListenerThread() { auto sub = redisClient_->subscriber(); sub.subscribe(“cluster_chat_event”); while (running_) { auto msg = sub.consume_message(); // 阻塞等待消息 if (msg) { json event = json::parse(msg->get_message()); string eventType = event[“event_type”]; int targetUserId = event[“target_user_id”]; // 幂等性检查 string msgId = event[“msg_id”]; if (isMessageProcessed(msgId)) { continue; // 已处理,跳过 } // 检查目标用户是否连接在本节点 if (localUserSessionMap_.find(targetUserId) != localUserSessionMap_.end()) { // 找到本地连接,处理事件 if (eventType == “friend_request”) { handleIncomingFriendRequest(event); } else if (eventType == “friend_accept”) { handleFriendRequestAccepted(event); } // 标记消息已处理 markMessageProcessed(msgId); } // 如果用户不在本节点,则忽略此消息(由其他节点处理) } } }5. 性能优化与常见问题排查
5.1 性能瓶颈分析与优化点
- 数据库连接池:这是第一个瓶颈。必须使用连接池,并且根据压测结果设置合适的初始大小、最大大小。我一般设置初始连接数为CPU核心数的2倍,最大连接数不超过50(视数据库配置而定)。使用
std::shared_ptr包装连接对象,配合智能指针的引用计数实现自动归还。 - Redis连接与序列化:每个服务器节点与Redis保持一个长连接池。消息的序列化/反序列化成本也要考虑。JSON虽然易读,但性能不如Protobuf或MessagePack。在内部节点通信的消息格式上,可以优先考虑性能更高的二进制协议。
- 本地缓存锁竞争:
std::unordered_map不是线程安全的,频繁的find和insert需要加锁,会成为并发瓶颈。可以改用支持并发读写的容器,如folly::ConcurrentHashMap,或者使用shared_mutex(C++17)实现读写锁,允许多个线程同时读。 - 日志输出:这是容易被忽略的性能杀手。避免在核心数据路径(如处理每个消息的循环中)进行同步文件日志输出。使用异步日志库(如spdlog的异步模式),将日志写入内存队列,由后台线程刷盘。
- 网络小包问题:频繁的“添加好友”请求和通知都是小数据包。TCP的Nagle算法和延迟确认(ACK)机制可能导致延迟。对于实时性要求高的IM,可以考虑在Socket上设置
TCP_NODELAY选项来禁用Nagle算法。
5.2 典型问题与排查实录
在实际部署和压测中,我遇到了以下典型问题,这里记录下排查思路和解决方案。
问题1:好友请求偶尔会重复插入数据库,违反唯一索引约束导致服务异常。
- 现象:错误日志显示
Duplicate entry for key 'uk_user_friend'。 - 排查:
- 检查客户端代码,确认没有在超时后自动重发请求。
- 检查服务器业务逻辑,发现在高并发下,两个几乎同时到达的、针对同一对用户的请求,可能都通过了“防重校验”(因为此时数据库还没有记录),然后先后尝试插入,后者就会失败。
- 解决方案:将“查询是否存在”和“插入记录”这两个操作,放在一个数据库事务中,并且对查询条件
(userid, friendid)使用SELECT ... FOR UPDATE进行行级锁。或者,更优雅的方式是,直接尝试插入,然后捕获唯一键冲突异常,在业务层将其转化为“请求已发送”的友好提示返回给客户端。后者性能更好,对数据库更友好。
问题2:用户明明在线,却收不到实时好友请求通知。
- 现象:用户A发送请求后,用户B的客户端没有立刻弹出提示,但刷新列表后能看到。
- 排查:
- 检查Redis中
user:session:<B_id>的值是否正确,是否过期。 - 检查发布事件的Node1日志,确认事件已成功发布到Redis频道。
- 检查Node2的
eventListenerThread日志,看是否收到了该事件。 - 发现Node2收到了事件,但
localUserSessionMap_中没有找到B的连接。
- 检查Redis中
- 根因:B的TCP连接因为网络波动断开了,但连接断开的清理函数(
onConnectionClose)由于异常未被调用,导致localUserSessionMap_和Redis中的会话映射没有及时清理。Node2认为B已离线,所以忽略了事件。 - 解决方案:
- 强化连接保活和断线检测机制。使用TCP的KEEPALIVE选项,并在应用层增加心跳包。如果超过一定时间未收到心跳,主动断开连接并触发清理逻辑。
- 在
onConnectionClose函数中增加异常捕获和重试机制,确保清理逻辑一定被执行。 - 增加一个定时任务,定期扫描
localUserSessionMap_,与每条连接的实际状态进行比对,清理“僵尸”映射。
问题3:集群节点数增加后,Redis出现性能瓶颈。
- 现象:Redis CPU使用率持续高位,响应变慢。
- 排查:使用
redis-cli --stat或INFO commandstats命令查看,发现PUBLISH和SUBSCRIBE相关的命令耗时占比很高。 - 解决方案:
- 垂直拆分频道:不要所有事件都发到一个
cluster_chat_event频道。可以按用户ID哈希到多个频道(如cluster_chat_event:0到cluster_chat_event:7),每个服务器节点订阅其中一个子集。这需要更复杂的路由逻辑,但能大幅减少每个频道的数据量和订阅者的处理压力。 - 升级Redis架构:使用Redis Cluster或者用Codis等代理分片方案,将数据和发布订阅的负载分散到多个Redis实例上。
- 评估更换消息队列:如果业务量持续增长,需要考虑使用专业的消息队列(如Kafka、RocketMQ)替代Redis Pub/Sub。它们为高吞吐、持久化、顺序消息提供了更强大的保障。
- 垂直拆分频道:不要所有事件都发到一个
问题4:服务器重启后,大量用户重连,瞬间打满数据库。
- 现象:服务器发布新版本重启,所有用户重连登录,数据库连接数飙升,慢查询增多。
- 解决方案:
- 缓存预热:在服务器启动后,主动从数据库加载一部分热点用户数据(如最近7天活跃的用户)到Redis缓存中。
- 登录限流:在登录接口处实现令牌桶或漏桶算法,控制单位时间内处理登录请求的数量,将流量曲线拉平。
- 降级策略:在数据库压力过大时,对于非核心的查询(如用户个人资料详情),直接返回缓存中的旧数据或默认数据,并在日志中告警,保护数据库不被击穿。
通过以上从架构设计到代码实现,再到问题排查的完整梳理,一个能在生产环境稳定运行的集群聊天服务器“添加好友”业务模块就清晰了。其核心思想在于:利用Redis作为集群的神经中枢,解耦节点间的直接依赖;将数据库作为唯一真相源,通过缓存失效和事件驱动来保证最终一致性;在每一层(网络、缓存、数据库)都做好防重、幂等和容错处理。这套模式不仅适用于添加好友,也可以平滑地扩展到群聊、消息推送等其他需要跨节点同步的IM业务场景中。
