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选择理由C11的高性能网络库。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)。中央消息路由Redis Pub/Sub选择理由实现简单性能足够。对于聊天场景消息的实时性要求高于绝对的可靠性和顺序性好友添加通知晚零点几秒收到通常可接受。Redis Pub/Sub足以胜任节点间的事件广播。每个服务器节点订阅一个公共频道如cluster_chat_event同时将自己需要广播的事件发布到该频道。注意事项Redis Pub/Sub的消息是“fire-and-forget”的如果某个节点在消息发布时宕机它会永久丢失这条消息。对于“添加好友请求”这种需要可靠送达的消息我们会在业务层设计确认和重试机制来弥补而不是依赖Redis本身的可靠性。数据存储MySQL RedisMySQL作为权威数据源Source of Truth存储所有持久化数据。好友关系表friend是核心至少包含userid、friendid、status如0-已发送/1-已同意/2-已拒绝、createtime等字段。这里必须建立联合唯一索引(userid, friendid)防止重复的好友关系。Redis作为热数据缓存和会话存储。缓存用户信息、好友列表。更重要的是用Redis Hash或String来存储user_id - server_id的映射即用户当前连接到了哪个服务器节点。这是实现跨节点消息精准路由的“指挥中心”。服务发现与注册中心集成在Redis中我们没有引入ZooKeeper或etcd等重型组件。每个ChatServer节点启动时向Redis的一个Set如online_servers注册自己的server_id可以用IP:Port或生成UUID。下线时主动移除。其他节点或网关可以通过查询这个Set来感知集群状态。这个架构的数据流可以概括为客户端请求 - 本地业务逻辑读写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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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发送请求PendingA向服务器发送AddFriendRequest(A, B)。服务器逻辑检查friend表中是否存在(A, B)或(B, A)且状态为0请求中或1已是好友的记录。如果存在直接返回“请求已发送”或“已是好友”。防重向friend表插入两条记录(A, B, status0),(B, A, status0)。这里必须在一个数据库事务中完成保证原子性。更新Redis中A和B的好友列表缓存标记为待更新。发布事件到Redis频道{“event”: “friend_request”, “from”: A, “to”: B, “msg”: “hello”}。B处理请求Accept/Reject服务器节点可能不是A所在的节点收到事件如果发现用户B正连接在本节点则通过TCP连接将请求推送给B的客户端。B的客户端选择“同意”或“拒绝”。服务器逻辑B所在节点处理开启事务。将(B, A, status0)和(A, B, status0)两条记录的status同时更新为1同意或2拒绝。必须同时更新两条记录保证关系对称。提交事务。清除Redis中A和B相关的好友列表缓存。发布事件{“event”: “friend_accept”, “from”: B, “to”: A}或friend_reject。A收到处理结果NotificationA所在的服务器节点订阅到friend_accept事件。如果用户A在线本地缓存有记录则通过TCP连接直接推送“B已同意您的好友请求”的消息。更新A的本地会话或前端界面。实操心得状态更新必须用事务包裹并且更新条件要加上status0即UPDATE friend SET status1 WHERE userid IN (A,B) AND friendid IN (B,A) AND status0。这可以防止重复处理幂等。例如网络延迟导致B客户端发送了两次“同意”指令第二次更新会因为status已不为0而影响行数为0业务层据此判断为重复操作。4. 集群环境下的关键实现细节4.1 用户会话定位与跨节点消息路由这是集群架构中最精妙的部分。假设用户A在Node1用户B在Node2。A添加B为好友后如何让B立刻收到通知会话映射存储当任何一个用户成功登录后处理登录请求的服务器节点需要做两件事本地记录在内存的ConcurrentHashMap中插入user_id - TcpConnectionPtr。全局注册向Redis写入一条记录Key为user:session:user_idValue为当前节点的server_id如node1_ip:port并设置一个合理的过期时间如30分钟配合心跳续期。事件发布与路由逻辑当Node1需要通知用户B时例如A发送了好友请求它首先检查本地缓存。如果B不在本地则去查询RedisGET user:session:B_id。如果查到B在Node2Node1并不直接联系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连接将好友请求推送出去。节点故障处理如果Node2宕机Redis中user:session:B_id的键会因过期而消失。当Node1发布事件后没有任何节点响应。为此我们需要一个离线消息存储。在发布事件前如果查询到目标用户user:session不存在即不在线则将此条“好友请求”作为离线消息存入MySQL或Redis的一个有序集合Sorted Set中Key为offline_msg:user_id。待用户B重新登录任何节点时该节点会拉取并处理这些离线消息。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 核心代码片段解析C11以下是一些关键环节的伪代码展示了核心逻辑的实现思路。1. 处理添加好友请求位于某个ChatServer节点的业务处理器中// 伪代码展示逻辑流程 void ChatService::addFriend(const TcpConnectionPtr conn, json js, Timestamp time) { // 1. 解析请求用户id好友id验证信息等 int userid js[userid].getint(); int friendid js[friendid].getint(); string verifymsg js[verifymsg]; // 2. 数据库校验防重、黑名单等 { std::lock_guardstd::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_mutexC17实现读写锁允许多个线程同时读。日志输出这是容易被忽略的性能杀手。避免在核心数据路径如处理每个消息的循环中进行同步文件日志输出。使用异步日志库如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的连接。根因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业务场景中。