1. 项目概述:从单机到集群的聊天室进化
最近在重构一个老项目,把之前用C++写的单机版聊天室升级成支持集群部署的版本。这不仅仅是加几台服务器那么简单,整个架构、通信协议、数据一致性都要重新设计。今天重点聊聊在集群环境下,如何实现一个健壮的“添加好友”功能。这个功能看似简单——不就是A发送请求,B同意就完事了吗?但在集群里,你要考虑请求可能被路由到不同服务器节点,好友关系数据要在多节点间同步,还要处理各种并发和网络异常。很多面试官特别喜欢问这类问题,因为它能考察你对网络编程、数据一致性和系统设计的综合理解。如果你也在准备C++高级开发岗位,这篇文章里的实现思路和踩坑经验,或许能给你一些启发。
2. 集群聊天室架构设计与核心挑战
2.1 为什么需要集群架构?
早期的单机聊天室,所有用户连接、消息转发、业务逻辑都集中在一台服务器上。用户量上来之后,单机的CPU、内存、网络连接数很快会成为瓶颈。更麻烦的是,一旦这台机器宕机,整个服务就不可用了。集群架构的核心目标就是解决扩展性和高可用性问题。
在我的设计里,集群主要由三种角色构成:
- 网关服务器:负责维护与客户端的TCP长连接,处理最基础的消息编解码、心跳保活。它本身不处理业务逻辑,只做消息的转发。客户端连接网关,网关再根据用户ID或业务类型,将请求转发到后端的业务逻辑服务器。
- 业务逻辑服务器:这是核心,负责处理具体的业务,比如登录、私聊、群聊,以及我们今天要讲的添加好友。一个集群里会有多台逻辑服务器,它们是无状态的(或者说业务状态被剥离到了存储层),可以水平扩展。
- 中心化存储与协调服务:这是集群的“大脑”。通常会用Redis来缓存在线状态、会话信息;用MySQL来持久化用户数据、好友关系;用ZooKeeper或etcd来做服务发现,让网关知道当前有哪些可用的逻辑服务器。
当用户A想要添加用户B为好友时,请求的流转可能是这样的:A的客户端将请求发给自己连接的网关服务器G1;G1通过查询服务发现,知道处理“好友业务”的逻辑服务器是L1和L2,它根据某种策略(比如一致性哈希)将请求转发到L1;L1需要查询B是否在线(可能要去Redis查),如果在线,还要知道B连接在哪个网关上(比如G2),以便将好友请求实时推送给B。这中间任何一个环节出问题,功能就失效了。
2.2 添加好友功能的特殊挑战
在单机环境下,添加好友可能就是内存里一个std::map的操作。在集群里,它变成了一个分布式事务问题。我总结了几大核心挑战:
- 请求的全局唯一性与幂等性:用户A可能因为网络延迟,重复点击“添加好友”,发送了多个相同的请求。这些请求可能被负载均衡到不同的逻辑服务器上。系统必须保证,无论收到多少次相同请求,最终只建立一条好友关系。这通常需要为每个好友请求生成一个全局唯一的请求ID(如UUID),并在处理前先检查这个ID是否已处理过。
- 数据一致性:好友关系是双向的。在A的好友列表里增加了B,也必须在B的好友列表里增加A。这个“原子性”操作在分布式系统中很难保证。如果A服务器成功写入了“A->B”的关系,但B服务器在写入“B->A”关系时宕机了,就会导致数据不一致。我们需要引入分布式事务机制,或者采用最终一致性的补偿策略。
- 实时通知:添加好友是一个交互过程。A发送请求后,B需要几乎实时地收到通知(比如一个弹窗)。这就要求系统能快速定位B当前连接的网关服务器,并将消息推送过去。如果B不在线,则需将请求持久化,待B上线后再次通知。
- 并发控制:极端情况下,A和B可能同时向对方发送好友请求。如果不加控制,可能会创建出两条重复的好友关系记录。我们需要一种机制(比如利用数据库的唯一索引,或者更复杂的分布式锁)来防止这种情况。
注意:在设计初期,不要一味追求强一致性。对于聊天室这种场景,最终一致性往往在性能和复杂度上更有优势。例如,允许短暂的好友关系单向存在,通过后台异步任务进行核对和修复。
3. 核心数据结构与协议设计
3.1 消息协议设计
客户端与服务器、服务器与服务器之间通信,需要一个统一的协议。我采用了经典的“长度字段+协议头+协议体”的二进制格式,这样效率高,解析快。
首先是协议头,它包含所有消息的元信息:
// ProtocolHeader.h #pragma once #include <cstdint> struct ProtocolHeader { uint32_t magic; // 魔数,用于快速校验,例如0x12345678 uint32_t version; // 协议版本 uint32_t msg_type; // 消息类型,如:1=登录,2=单聊,3=好友请求 uint32_t seq; // 消息序列号,用于请求-响应匹配 uint32_t body_len; // 协议体长度 uint64_t user_id; // 发送者用户ID(服务器间转发时可能填充) // ... 其他字段如时间戳、压缩标志等 };对于“添加好友”这个业务,我们需要定义几种具体的消息类型(msg_type):
MSG_FRIEND_REQUEST:用户A发送好友请求给B。MSG_FRIEND_REQUEST_NOTIFY:服务器通知B,有人想加你为好友。MSG_FRIEND_RESPONSE:B对好友请求的回复(同意/拒绝)。MSG_FRIEND_RESULT_NOTIFY:服务器通知A,B已经处理了你的请求。
然后是协议体,对于MSG_FRIEND_REQUEST,它的结构可能是:
// 添加好友请求体 struct FriendRequest { uint64_t from_user_id; // 请求方A的ID uint64_t to_user_id; // 接收方B的ID char request_id[37]; // 全局唯一的请求ID (UUID字符串) char verify_msg[101]; // 验证消息,例如“我是张三” // 时间戳等信息可以放在协议头里 };使用固定长度的字符数组是为了避免动态内存分配带来的复杂性和潜在的性能问题,当然在实际中可以根据需要调整大小或使用更灵活的方案。
3.2 关键业务数据结构
在业务逻辑服务器中,我们需要一些数据结构来管理进行中的好友请求。
// FriendManager.h #include <unordered_map> #include <string> #include <mutex> #include <memory> // 一个正在进行的好友请求 struct PendingFriendRequest { std::string request_id; uint64_t from_user_id; uint64_t to_user_id; int64_t create_time; // 创建时间戳 std::string verify_msg; int status; // 0=等待处理,1=已同意,2=已拒绝,3=已过期 }; class FriendRequestManager { public: // 单例模式获取实例 static FriendRequestManager& GetInstance(); // 添加一个新的待处理请求 bool AddPendingRequest(const std::string& req_id, uint64_t from_uid, uint64_t to_uid, const std::string& msg); // 根据请求ID查找请求 std::shared_ptr<PendingFriendRequest> FindRequest(const std::string& req_id); // 更新请求状态(如同意或拒绝) bool UpdateRequestStatus(const std::string& req_id, int new_status); // 清理过期请求(定时任务调用) void CleanupExpiredRequests(int64_t timeout_seconds); private: FriendRequestManager() = default; std::unordered_map<std::string, std::shared_ptr<PendingFriendRequest>> pending_requests_; std::mutex mutex_; // 保护哈希表的并发访问 };这里有几个设计考量:
- 使用
std::shared_ptr:因为请求对象可能在多个地方被引用(比如在超时定时器中),使用智能指针可以避免内存管理错误。 - 引入互斥锁
std::mutex:这个管理器会被多个网络IO线程同时访问(比如处理请求的线程和处理响应的线程),必须加锁保证线程安全。对于高性能场景,可以考虑读写锁(std::shared_mutex)或更细粒度的锁策略。 - 请求ID作为Key:
request_id全局唯一,是查找和更新请求的最直接依据。
实操心得:这个
FriendRequestManager只在内存中维护“进行中”的请求。一旦请求被处理(同意/拒绝)或过期,就应该将其移除,并将最终结果持久化到数据库。内存结构只解决“处理中”状态的管理问题,不承担数据持久化的职责,这样逻辑更清晰,也避免了服务器重启导致数据丢失的问题(因为持久化层是数据库)。
4. 添加好友功能的完整实现流程
4.1 步骤一:客户端发起请求
用户在客户端界面输入B的用户ID或昵称,点击“添加好友”。客户端需要完成以下工作:
- 生成一个全局唯一的
request_id。可以用时间戳+随机数+本机IP等方式,但更推荐使用标准的UUID库生成。 - 填充
FriendRequest结构体。 - 将结构体序列化为二进制流,加上我们之前定义的
ProtocolHeader,通过TCP连接发送给网关服务器。
客户端代码示例(伪代码):
void Client::SendFriendRequest(uint64_t to_user_id, const std::string& verify_msg) { // 1. 生成请求ID std::string req_id = GenerateUUID(); // 2. 构造协议体 FriendRequest req_body; req_body.from_user_id = this->current_user_id_; req_body.to_user_id = to_user_id; strncpy(req_body.request_id, req_id.c_str(), sizeof(req_body.request_id)-1); strncpy(req_body.verify_msg, verify_msg.c_str(), sizeof(req_body.verify_msg)-1); // 3. 构造协议头 ProtocolHeader header; header.magic = PROTOCOL_MAGIC; header.version = 1; header.msg_type = MSG_FRIEND_REQUEST; header.seq = GetNextSeq(); // 获取下一个序列号 header.body_len = sizeof(FriendRequest); header.user_id = this->current_user_id_; // 4. 序列化并发送 SendPacket(&header, &req_body); }4.2 步骤二:网关路由与逻辑服务器处理
网关服务器收到数据包后:
- 解析协议头,根据
msg_type判断这是一个好友请求。 - 查询服务发现(例如连接到的ZooKeeper),获取当前处理好友业务的所有逻辑服务器地址列表。
- 使用负载均衡策略(这里我用了基于
to_user_id的一致性哈希),选择一台逻辑服务器,比如L1。 - 将整个数据包原样转发给L1。网关不解析协议体,只做透传。
逻辑服务器L1收到请求后:
void LogicServer::OnFriendRequest(const ProtocolHeader* header, const void* body_data) { const FriendRequest* req = static_cast<const FriendRequest*>(body_data); // 1. 参数基础校验 if (req->to_user_id == 0 || req->from_user_id == 0) { SendErrorResponse(header->seq, "Invalid user id"); return; } // 2. 幂等性检查:通过request_id查询,是否已处理过相同请求 auto existing_req = FriendRequestManager::GetInstance().FindRequest(req->request_id); if (existing_req && existing_req->status != 0) { // 已处理,直接返回之前的结果,避免重复操作 SendFriendRequestResult(req->from_user_id, existing_req->status); return; } // 3. 业务逻辑校验(可扩展) // 例如:检查B是否允许被添加(黑名单、隐私设置) // 检查A和B是否已经是好友(查数据库) if (IsAlreadyFriends(req->from_user_id, req->to_user_id)) { SendErrorResponse(header->seq, "Already friends"); return; } // 4. 将请求存入内存管理器(状态为“等待处理”) if (!FriendRequestManager::GetInstance().AddPendingRequest( req->request_id, req->from_user_id, req->to_user_id, req->verify_msg)) { SendErrorResponse(header->seq, "System busy"); return; } // 5. 异步持久化到数据库(可选,用于审计或服务器重启后恢复) // db::SaveFriendRequestAsync(req->request_id, ...); // 6. 尝试实时通知用户B NotifyUserOfFriendRequest(req->to_user_id, req->request_id, req->from_user_id, req->verify_msg); // 7. 给用户A一个“请求已发送”的ACK SendAckResponse(header->seq); }第6步的NotifyUserOfFriendRequest是关键,它需要:
- 查询用户B的在线状态和连接位置。这通常通过查询一个全局的在线状态缓存(如Redis)来实现。Redis里可能存着
user:10086 -> {gateway_ip: "10.0.0.1", gateway_port: 8000, conn_id: "abc123"}这样的键值对。 - 如果B在线,则构造一个
MSG_FRIEND_REQUEST_NOTIFY消息,通过B所在的网关G2推送过去。 - 如果B不在线,则将这个通知任务放入一个延迟队列(如Redis的Sorted Set或专业的消息队列RocketMQ/Kafka),并设置一个过期时间(比如7天)。等B上线时,由登录流程去拉取这些未读的通知。
4.3 步骤三:对方处理与关系建立
用户B在线并收到了通知,他可以选择同意或拒绝。客户端发送MSG_FRIEND_RESPONSE消息。
逻辑服务器(可能是另一台,比如L2,因为负载均衡)收到响应后:
void LogicServer::OnFriendResponse(const ProtocolHeader* header, const FriendResponse* rsp) { // 1. 查找对应的Pending Request auto pending_req = FriendRequestManager::GetInstance().FindRequest(rsp->request_id); if (!pending_req) { // 请求可能已过期或被清理 SendErrorResponse(header->seq, "Friend request expired or not found"); return; } // 2. 检查权限:确保响应者确实是请求的接收方 if (pending_req->to_user_id != header->user_id) { SendErrorResponse(header->seq, "Permission denied"); return; } // 3. 更新内存中的请求状态 if (!FriendRequestManager::GetInstance().UpdateRequestStatus(rsp->request_id, rsp->action)) { SendErrorResponse(header->seq, "Update status failed"); return; } // 4. 如果同意,则建立双向好友关系(这里是难点!) if (rsp->action == ACTION_AGREE) { // 方案一(简易,存在不一致风险): // db::AddFriendRelation(pending_req->from_user_id, pending_req->to_user_id); // db::AddFriendRelation(pending_req->to_user_id, pending_req->from_user_id); // 方案二(推荐,最终一致性): // 向消息队列发送一个“建立好友关系”的事件 // 事件内容包含:request_id, user_id_a, user_id_b // 由一个独立的关系处理服务消费这个事件,负责原子性地创建双向关系 MessageQueue::SendEvent("friend.relation.create", pending_req->request_id, pending_req->from_user_id, pending_req->to_user_id); } // 5. 通知请求方A最终结果 NotifyFriendRequestResult(pending_req->from_user_id, rsp->request_id, rsp->action); // 6. 清理内存数据(或标记为可清理) // FriendRequestManager::GetInstance().RemoveRequest(rsp->request_id); }这里第4步是分布式系统中的经典问题。直接在业务逻辑服务器里写两条数据库记录,如果中间发生故障,会导致数据不一致。更稳健的做法是引入“事件驱动”和“最终一致性”:
- 逻辑服务器只负责更新请求状态和发出一个事件。
- 一个专门的关系处理服务(可以是另一个微服务,或者一个后台线程)订阅这个事件。它收到事件后,在一个数据库事务中,同时插入两条好友记录(A->B 和 B->A)。如果失败,可以重试。这样保证了操作的原子性。
4.4 步骤四:结果同步与清理
关系处理服务成功创建好友关系后,可以向消息队列发送一个“关系创建成功”的事件。逻辑服务器(或一个通知服务)订阅此事件,然后:
- 给A和B双方各发送一条系统消息:“你已添加了B/A为好友”。
- 更新双方客户端的本地好友列表。
同时,FriendRequestManager中的定时清理任务CleanupExpiredRequests会定期(比如每分钟)扫描所有pending_requests_,将创建时间超过设定阈值(如3天)的请求状态置为“过期”,并通知请求方。这避免了僵尸请求永远占用内存。
5. 集群环境下的深度问题与优化策略
5.1 分布式锁与并发请求处理
如果用户A和B几乎同时向对方发送好友请求,在没有控制的情况下,可能会创建两条好友关系记录,甚至可能因为唯一约束冲突导致失败。解决方法之一是使用分布式锁。
以Redis分布式锁为例,在创建好友请求的关键步骤上加锁:
bool LogicServer::TryCreateFriendRequest(const FriendRequest& req) { // 锁的Key可以设计为:friend_lock:{min_user_id}:{max_user_id} // 例如用户100和用户200的交互,锁key为 friend_lock:100:200 uint64_t uid1 = req.from_user_id; uint64_t uid2 = req.to_user_id; std::string lock_key = "friend_lock:" + std::to_string(std::min(uid1, uid2)) + ":" + std::to_string(std::max(uid1, uid2)); // 尝试获取锁,超时时间设为3秒 RedisLock lock(redis_client_, lock_key, 3000); if (!lock.TryLock()) { // 获取锁失败,说明另一个请求正在处理,可以返回“系统繁忙”或让客户端稍后重试 return false; } // 持有锁的情况下,执行核心检查与创建逻辑 // 1. 再次检查是否已存在好友关系(双检) // 2. 检查是否已存在来自对方的待处理请求 // 3. 创建自己的请求 // ... // 锁会在RedisLock析构时自动释放 return true; }这个锁确保了对于同一对用户,同时只能有一个添加好友的请求被处理。锁的粒度是用户对,比较细,不会成为系统瓶颈。超时机制也避免了死锁。
5.2 消息的可靠投递与去重
在网关、逻辑服务器、消息队列、数据库之间流转时,消息可能丢失或重复。我们需要一套机制来保证至少一次或恰好一次的投递语义。
对于关键操作,比如“建立好友关系”的事件,我采用了以下策略:
- 生产者端:逻辑服务器发送事件到消息队列时,在事件体中携带一个全局唯一的
event_id(可以用request_id衍生),并将(event_id, status)先写入一个本地数据库或Redis,标记为“已发送”。 - 消息队列:选用支持幂等性和事务消息的中间件(如RocketMQ)。
- 消费者端:关系处理服务消费事件时,先查一下本地是否已处理过这个
event_id(建立一个已处理事件ID表)。如果已处理,则直接返回成功,实现消费端的去重。
对于实时推送(MSG_FRIEND_REQUEST_NOTIFY),由于TCP本身是可靠的,我们主要依赖应用层的ACK机制。网关给客户端推送通知后,需要等待客户端的ACK。如果超时未收到,网关需要从逻辑服务器重新拉取通知并尝试再次推送。逻辑服务器需要记录通知的送达状态。
5.3 缓存与数据库的一致性
用户的好友列表会被频繁查询。我们肯定要用缓存(如Redis)来加速。常见的模式是:
- 读操作:先查缓存,缓存命中则返回;未命中则查数据库,并将结果写入缓存。
- 写操作(如添加好友):先更新数据库,然后删除缓存中对应用户的好友列表数据。
注意,这里是“删除”缓存,而不是“更新”缓存。这是为了避免在并发写场景下,数据库更新和缓存更新的时序问题导致脏数据。删除后,下一次读请求自然会从数据库加载最新数据到缓存。这就是经典的“Cache-Aside”模式结合“写时删除缓存”策略。
在集群环境下,数据库更新和缓存删除可能不在同一台机器上。为了确保删除操作能覆盖所有可能缓存了该数据的节点,我们可以:
- 将缓存key设计成与用户ID强相关,例如
friends:{user_id}。这样,任何服务器处理完该用户的好友变更后,都去删除这个特定的key。 - 如果使用了Redis集群,删除操作会自动路由到正确的节点。
- 对于特别关键的场景,可以考虑使用Redis的Pub/Sub功能,在数据库更新后,发布一个“好友列表变更”的事件,所有订阅了该频道的业务服务器节点,都去删除自己本地可能存在的相关缓存(如果用了本地缓存的话)。
5.4 容错与降级策略
任何依赖的外部服务(Redis、MySQL、ZooKeeper、其他微服务)都可能失败。我们的代码必须有容错能力。
- 依赖服务失败:当发现Redis连接超时或MySQL插入失败时,不能直接让整个请求失败。对于添加好友请求,如果实时通知B的环节失败(比如查不到B的在线状态),我们可以将请求持久化到数据库,并标记为“待推送”。然后返回给A“请求已发送,等待对方处理”的提示,而不是一个错误。系统通过后台任务不断重试这个推送。
- 超时控制:对所有网络调用(RPC、数据库查询、缓存访问)设置合理的超时时间。例如,数据库查询超过500ms就认为失败,走降级逻辑(比如返回一个默认的空好友列表,并记录日志告警)。
- 熔断与降级:如果某个逻辑服务器节点故障,网关通过服务发现能及时感知并将其从健康列表中剔除,后续请求就不会再发往该节点。对于非核心功能(比如在好友请求通知里携带请求者的头像和签名),如果获取这些信息的服务不稳定,可以降级为只发送基础文本信息。
6. 性能调优与监控要点
6.1 性能瓶颈分析与优化
在压力测试中,我发现以下几个常见瓶颈点:
网关的转发性能:网关是纯IO密集型服务,主要工作是解包、封包和转发。优化手段包括:
- 使用非阻塞IO和IO多路复用(如
epoll)。 - 采用内存池管理连接和缓冲区对象,避免频繁的
malloc/free。 - 将编解码(序列化/反序列化)操作放到独立的线程池,不阻塞IO线程。
- 使用非阻塞IO和IO多路复用(如
逻辑服务器的锁竞争:前面提到的
FriendRequestManager使用了全局互斥锁,在超高并发下会成为热点。优化方案:- 分片:创建多个
FriendRequestManager实例,每个实例负责一个用户ID范围的请求。根据request_id或from_user_id的哈希值决定使用哪个实例。这样就将一把大锁拆成了多把小锁。 - 无锁队列:对于请求的写入,可以将其推入一个无锁队列。由后台的消费者线程从队列中取出请求,批量进行持久化和状态管理。这样处理请求的IO线程几乎不会阻塞。
- 分片:创建多个
数据库写入压力:好友关系的最终持久化在MySQL。大量用户同时添加好友会导致INSERT压力大。优化:
- 使用批量插入。关系处理服务可以积累一批事件(比如每100条或每200毫秒),一次性插入多条好友记录。
- 对数据库表进行分库分表。例如,按用户ID的哈希值对好友关系表进行水平拆分。
- 考虑异步写入。先写本地WAL(Write-Ahead Logging)日志或一个高性能的中间存储(如Redis List),再异步同步到MySQL。这会降低写入延迟,但牺牲了一点数据持久化的实时性。
6.2 可观测性建设:监控与日志
一个线上系统,必须有完善的可观测性,否则出了问题就是两眼一抹黑。
关键指标监控:
- QPS/TPS:每秒好友请求数、处理成功数、失败数。
- 延迟:从客户端发送请求到收到ACK的端到端延迟(P50, P95, P99)。
- 资源使用率:各服务器节点的CPU、内存、网络IO。
- 缓存命中率:Redis缓存的命中率,低于阈值要告警。
- 错误率:各类错误(网络超时、数据库错误、校验失败)的比例。
结构化日志: 在代码的关键决策点、异常分支处打日志。日志不要用纯文本,要用结构化的JSON格式,方便后续用ELK(Elasticsearch, Logstash, Kibana)等工具进行分析。
// 不好的日志 LOG_INFO("Friend request from %lu to %lu failed.", from_uid, to_uid); // 好的结构化日志 LOG_JSON(INFO, {{"event", "friend_request_failed"}, {"from_uid", from_uid}, {"to_uid", to_uid}, {"reason", "already_friends"}, {"request_id", req_id}, {"cost_ms", GetCurrentTimeMs() - start_time}});这样,我们可以很容易地筛选出所有因为“已是好友”而失败的请求,并分析其发生的频率和关联的用户。
分布式追踪: 一个请求会经过网关、逻辑服务器、数据库、缓存等多个服务。我们需要一个唯一的
trace_id贯穿整个调用链。可以在协议头里增加一个trace_id字段,每个服务在处理时都将其传递下去,并记录到自己的日志中。这样,当某个请求出错时,我们可以通过trace_id把所有相关的日志串联起来,快速定位问题根因。
7. 面试常见问题与实战回答思路
很多C++高级开发的面试,都会深入到这种分布式系统的设计。面试官可能不会直接问你“添加好友怎么实现”,而是会从一个点切入,层层深入。
问题一:“如果逻辑服务器在处理好友请求时宕机了,内存中的Pending请求数据丢失怎么办?”
回答思路:首先承认这是内存管理的局限性,然后给出解决方案。可以分两层:
- 快速恢复:服务器重启后,内存是空的。但我们可以从持久化存储中恢复一部分。例如,我们在收到请求时,除了写入内存管理器,还异步地将请求的概要信息(
request_id,from_uid,to_uid,status,create_time)写入一个高可用的存储,比如Redis或MySQL。服务器启动时,可以加载最近一段时间(如过去1小时)内状态为“等待处理”的请求,重建内存状态。这能覆盖大部分短时间宕机的情况。 - 兜底补偿:对于因宕机确实丢失、且未超时的请求,我们需要一个补偿机制。可以为每个好友请求在数据库设置一个“最终状态截止时间”。客户端在发送请求后,如果在合理时间内(比如30秒)没收到“请求已发送”的ACK,或者长时间没收到对方处理结果,可以主动发起查询。服务器端也可以有一个定时任务,扫描数据库中状态为“处理中”但更新时间很久的请求,主动向请求方和接收方进行状态同步或超时处理。
问题二:“如何防止用户频繁发送好友请求进行骚扰?”
回答思路:这是一个业务风控问题。可以从多个维度设计限流策略:
- 频率限制:对单个用户(
from_user_id)在单位时间(如1分钟)内发送的好友请求总数进行限制。可以在网关或逻辑服务器的入口处,使用一个滑动窗口计数器(用Redis实现很方便)来检查。 - 对象限制:限制同一个用户对另一个特定用户(
to_user_id)在短时间内重复发送请求。即使用户A对用户B取消后又添加,也需要有冷却时间(如24小时)。 - 全局黑名单:对于被大量用户举报为骚扰的账号,可以直接将其加入一个全局黑名单,禁止其发起好友请求。
- 验证码挑战:当系统检测到某个用户的行为模式异常(如短时间内向大量不同用户发送请求)时,可以要求其在下次操作前完成图形验证码或短信验证码挑战,增加其作恶成本。
问题三:“你如何测试这个分布式添加好友功能?”
回答思路:测试要分层进行:
- 单元测试:针对
FriendRequestManager、消息编解码函数等核心类和方法,编写单元测试,模拟各种正常和异常输入。 - 集成测试:搭建一个小型测试集群,包含1个网关、2个逻辑服务器、1个Redis、1个MySQL。编写测试脚本,模拟两个客户端,完整走通添加好友的流程,验证消息流转、数据一致性是否正确。
- 压力测试与混沌测试:
- 压力测试:使用工具(如wrk, Locust)模拟海量用户并发添加好友,观察系统各环节的QPS、延迟、错误率,找到瓶颈。
- 混沌测试:在系统运行时,随机杀死某个逻辑服务器进程、断开Redis网络、模拟数据库高延迟等,观察系统的容错和自愈能力。验证在部分服务失效时,是优雅降级还是全面崩溃,数据是否会大面积不一致。
实现一个集群环境下的功能,远不止写出正确的单机代码。你需要像侦探一样,思考每一条消息可能在哪里丢失,每一处数据可能在哪个时刻不一致,每一个服务挂了会有什么连锁反应。然后通过设计模式、中间件和运维手段,把这些风险一个个化解。这个过程很烧脑,但当你看到系统能平稳处理每秒成千上万的请求时,那种成就感也是单机程序无法比拟的。