FoolChat 项目面试八股
FoolChat 项目面试八股(84 题)
速览:项目硬事实
| 项 | 事实 |
|---|---|
| 服务拓扑 | GateServer(HTTP :8080) / VarifyServer(gRPC :50051, Node) / StatusServer(gRPC :50052) / ChatServer(TCP :8090 + gRPC :50055) / ChatServer2(:8091/:50056) / Go 后台 :9100 |
| TCP 协议 | 4 字节头(2B msg_id + 2B body 长度,网络字节序)+ JSON body,单包上限 MAX_LENGTH = 1024*8(8KB,ChatServer/const.h:58) |
| 网络模型 | AsioIOServicePool:hardware_concurrency() 个 io_context,每个 io_context 只 1 个线程,round-robin 分配连接 |
| 业务模型 | 单工作线程 + queue + mutex + condvar 生产消费(LogicSystem.cpp:22-81),回调表 map<msg_id, FunCallBack> |
| 连接池 | MySQL 池 5(60s SELECT 1 保活)、Redis 池 5(30s PING)、gRPC 每 peer 池 5 stub |
| 负载均衡 | 线性扫描 Redis hash logincount 取最小值(StatusServiceImpl.cpp:73-104) |
| 路由 | Redis uip_<uid> = 所在 ChatServer 名,跨服走 gRPC Notify* |
| 未实现 | 心跳、离线消息、聊天消息落库、文件/图片传输、分布式锁、gRPC deadline、压测脚本、TLS/加密签名 |
怎样做用户登录,为什么用异步不用同步,怎样设计多线程模式,连接池的实现原理(如何设计连接数)
登录(两段式,HTTP 换票 + TCP 长连接):
- 客户端
POST /user_login(JSON body:user+passwd)→ GateServer(GateServer/LogicSystem.cpp:195-245) - GateServer 查 MySQL 校验密码(
LogicSystem.cpp:215→MysqlDao.cpp:161-202) - GateServer → gRPC
StatusService.GetChatServer(uid)→ StatusServer 按 Redislogincount选负载最低的 ChatServer,生成 boost uuid token 写utoken_<uid>,返回host/port/token(生成StatusServiceImpl.cpp:7-15、写入:56) - 客户端拿到
uid/token/host/port,再开一条 TCP 连 ChatServer,发1005 CHAT_LOGIN {uid, token}(login.cpp:316-330) - ChatServer 验 token:先比 Redis
utoken_<uid>(LogicSystem.cpp:330-341,这一步是真生效的),再调 gRPCStatusService.Login二次校验(LogicSystem.cpp:346)——但返回值rsp没被检查,这层校验目前形同虚设(面试要主动承认,改法:判断rsp.error()才放行 + 加set_deadline)。之后绑定 session、写uip_<uid>、logincount++、踢旧连接(LogicSystem.cpp:403-433)
为什么异步不用同步:
- 同步模型下一个线程绑死一条连接,8000 连接需要 8000 线程,光栈空间就 GB 级,线程切换开销巨大
- IM 场景 99% 时间连接是空闲的,同步线程绝大部分时间在阻塞等待,CPU 利用率极低
- 异步(Reactor/Proactor)用少量线程管理海量连接,只在真正有数据可读写时唤醒
多线程模式(本项目是「IO 多线程 + 业务单线程」的混合):
- 主线程跑 acceptor(
ChatServer.cpp:49) - N 个 IO 池线程(N = CPU 核数),每个线程独占一个 io_context 处理读写回调(
AsioIOServicePool.cpp:11-15) - 1 个业务工作线程消费消息队列(
LogicSystem.cpp:13),保证消息按序处理 - 1 个 gRPC server 线程(
ChatServer.cpp:36-38) - 若干连接池保活线程(MySQL 60s / Redis 30s)
连接池原理(以 MySQL 为例,MysqlDao.h:26-106):
- 构造时一次性建满
poolSize_ = 5条连接,放进queue<unique_ptr<SqlConnection>> getConnection():加锁 +cond_.wait(lock, !pool_.empty() || b_stop_),取队头returnConnection():加锁 push +notify_one()唤醒等待者- 归还用
DeferRAII 保证(const.h:43-55),异常路径也不泄漏 - 后台线程每 60s 扫一遍,超过 5s 未用的连接发
SELECT 1,失败原地重建(MysqlDao.h:35-74)
如何设计连接数:
- 经验值
连接数 = CPU核数 × 2 + 有效磁盘数(磁盘 IO 密集则更大) - 本项目 MySQL/Redis 各 5 是小规模经验值,瓶颈不在连接数而在持有时长——真正的优化是「缩短连接持有时间」(把慢查询、远程调用挪出临界区),而不是无脑加大池
- gRPC 池按 peer 一个池各 5 条(
ChatGrpcClient.cpp:233),按「对端实例数 × 并发调用深度」设计
gRPC 怎么来的,为什么叫 gRPC
- g = Google,Google Remote Procedure Call。Google 2015 年开源,源自内部 Stubby 框架
- 演进脉络:SOAP(XML,太重)→ JSON-RPC(文本,无强约束)→ gRPC(HTTP/2 + Protocol Buffers 二进制)
- 核心三件套:
- Protobuf:IDL + 二进制序列化,
message.proto定义接口,protoc生成多语言 stub(项目里start.bat就是 protoc 生成脚本,CLAUDE.md:35) - HTTP/2:多路复用(一条连接跑多个流)、HPACK 头部压缩、全双工——所以 gRPC 能做服务端流式推送
- 四层架构:Call → Channel → Stub → Server,Stub 分同步(blocking)和异步(async)
- Protobuf:IDL + 二进制序列化,
- 项目中的三个 service(
message.proto,共 12 个 rpc):VarifyService(1 rpc) /StatusService(2 rpc) /ChatService(9 rpc,实际实现 6 个,未实现RplyAddFriend/SendChatMsg/NotifyKickUser)
简历写的「单服务器 8000 连接,多服务器 1W~2W 活跃用户」,为什么不是正比关系
这题答「为什么不线性」比答「能扛多少」重要。
单机 8000 → 两台不是 16000,原因有五层:
- 共享资源不会被复制:多台 ChatServer 仍共享同一个 MySQL 和 Redis。在线数 hash
logincount、路由uip_、缓存ubaseinfo_全打在一套 Redis 上,Redis 单线程模型 QPS 有上限,这是第一个天花板 - 跨服转发开销是单机没有的:单机上 A→B 是进程内
session->Send(LogicSystem.cpp:881-890);两台后要走 gRPCNotifyTextChatMsg(LogicSystem.cpp:908)→ 序列化 → 网络 → 对端解包再下发。消息量越大,转发占比越高,吞吐被 CPU 和网络吃掉 - 负载均衡本身有成本:每次登录 StatusServer 要做 N 次 Redis
HGET(StatusServiceImpl.cpp:73-104),且整段持有_server_mtx,机器越多锁内网络 IO 越多 - 活跃用户 ≠ 连接数:8000 是「能维持的 TCP 长连接」,1W~2W 是「会真正发消息的活跃用户」,后者的消息处理开销远高于前者。这两个指标本来就不能换算
- 连接分布不均:按在线数分配是近似均衡——两个并发请求可能同时选中同一台最低负载机(读改写不在同一把锁里),实际分布总比理论值差
标准答法:单机瓶颈是 IO 线程数和内存,多机后瓶颈转移到共享存储(Redis/MySQL)和跨服转发开销,所以是「先近似线性、后收益递减」的曲线,不是直线。
怎么封装的 http 和 tcp
HTTP 封装(GateServer,Boost.Beast,三层):
| 层 | 类 | 职责 |
|---|---|---|
| Acceptor | CServer(GateServer/CServer.cpp) |
tcp::acceptor 监听 8080,async_accept 后从 IO 池取 io_context 建连接,回调里 Start() 完再递归续接下一个 accept |
| Session | HttpConnection(HttpConnection.h:5-28) |
enable_shared_from_this + socket + http::request/response<dynamic_body> + 60s deadline(成员在、未启动,见「数据库的连接池是怎么设置的」那题);async_read → HandleReq → async_write |
| Router | LogicSystem(LogicSystem.h:7-21) |
两张表 map<string, HttpHandler>(GET/POST 各一张),构造函数一次性注册 6 条路由 |
- POST 取 body:
boost::beast::buffers_to_string(connection->_request.body().data())→ jsoncpp 解析(LogicSystem.cpp:196-202) - GET 取参数:自己写的
PreParseGetParam(),按?/&切分 +UrlDecode(HttpConnection.cpp:70-104)
TCP 封装(ChatServer,CSession):
CSession同样enable_shared_from_this,管一个 socket 的完整生命周期- 封装了:读头状态机、读体、发送队列、协议封包(
SendNode构造函数里就地写 4 字节头,MsgNode.cpp:8-17)、错误统一走Close()+ClearSession() - 上层业务只面对
Send(string, msg_id)和回调表,完全不碰 socket
两者关系(要说清楚):没有统一抽象基类。HTTP 和 TCP 分属两个进程,只共享同一套模式——Singleton CRTP 单例、AsioIOServicePool 连接分摊、同款连接池(MySQL/Redis/gRPC 都是 queue+mutex+condvar 这一个模板复制粘贴维护)。
对并发和异步的思考(异步时如何处理共享数据,如何平衡并发和异步)
本项目的三层拆解:
- IO 层(异步、多线程):io_context 上全是异步操作(
async_read_some/async_write),回调由池线程执行。同一 session 的回调天然串行——因为它的 io_context 只有 1 个线程在run(),所以读写状态机不需要锁也不需要 strand - 跨线程共享数据(加锁):
Send()会被业务线程和 gRPC 线程同时调,用std::mutex _send_lock保护「入队 + 判断是否已有写在飞」的临界区(CSession.cpp:44-59);UserMgr的uid→session表用_session_mtx;会话表_sessions用_mutex - 业务层(异步转同步):IO 线程读完就
PostMsgToQue投队列立刻返回(CSession.cpp:112),单工作线程DealMsg消费(LogicSystem.cpp:43-81)——用队列把「并发到达」变成「串行处理」,共享数据就不用锁了
平衡并发与异步的判断标准:
- 数据只在一个线程内访问 → 不要锁(session 状态机、逻辑回调表)
- 数据会被多线程读写 → 缩小临界区(只锁「入队」,不锁整个发送过程)
- 低频冲突 → 粗粒度 mutex(
_server_mtx粗但简单) - 高并发热点计数 → 原子操作或 Redis
HINCRBY(本项目这里是短板:logincount是HGet→++→HSet三条命令非原子,LogicSystem.cpp:406-414,并发登录丢更新,我会改成HINCRBY)
反面教材(可主动讲):DealMsg 的 unique_lk 从 LogicSystem.cpp:45 加锁后一直活到本轮循环结束(:80 才析构),回调 :77 执行期间锁仍被持有,意味着执行同步 gRPC 转发时全服的消息入队都被阻塞。改进:出队后立即释放锁再执行回调(代价是同一连接有序性需额外保证,比如按 session 分队列)。
项目背景,项目中微服务的拆分,职责边界如何划分
项目背景:一套完整分布式 IM,四个服务端进程 + Qt 桌面客户端 + Go 后台管理。起手是学 Boost.Asio 网络编程,发现单进程写完后无法回答「多机怎么扩展」「消息怎么跨服转发」,于是按职责拆成微服务。
拆分与边界:
| 服务 | 单一职责 | 边界判断依据 |
|---|---|---|
| GateServer | 唯一 HTTP 入口:注册/登录/重置密码/验证码转发/改资料 | 对外暴露 HTTP 的地方只能有一个——收敛安全策略、限流、鉴权 |
| VarifyServer | 邮箱验证码(生成 + SMTP 发送 + 写 Redis) | 用了 Node 的 nodemailer,技术栈不同 → 独立部署 |
| StatusServer | 登录态管理 + 负载均衡 + token 签发 | 全局唯一状态必须集中,否则多台 ChatServer 各自为政 |
| ChatServer ×N | 实时会话、消息路由、跨服转发 | 可以水平扩展的那部分才单独拆出来 |
| Go 后台 | 运营管理(用户/动态/公告/审核/AI 助手) | 发布节奏、用户群、技术栈都与实时链路无关 |
拆分标准(面试直接说这四条):
- 变化速率不同:网关协议稳定,业务功能高频迭代 → 分开
- 技术栈不同:C++ 实时链路 / Node 邮件 / Go 后台 → 各用各的
- 扩展维度不同:只有 ChatServer 需要水平扩展 → 单独拆
- 故障域隔离:发验证码挂了不能影响聊天
代价(主动承认):message.proto、const.h、ConfigMgr/RedisMgr/MysqlDao/Singleton 在 4 个目录里是复制粘贴副本,没有共享库(CLAUDE.md:118-124)。服务拆了但代码没抽公共层。改进:抽 common/ 静态库 + proto 单一来源。
聊天服务器如何实现负载均衡,当有大量请求到来时,如何实现连接的均匀分布
两层负载均衡:
第一层:登录时分配(StatusServer,最小连接数算法)
// StatusServer/StatusServiceImpl.cpp:73-104
std::lock_guard<std::mutex> guard(_server_mtx); // 整段加锁
for (auto& server : _servers) {
auto count_str = RedisMgr::GetInstance()->HGet(LOGIN_COUNT, server.second.name);
server.second.con_count = count_str.empty() ? INT_MAX : std::stoi(count_str);
if (server.second.con_count < minServer.con_count) { minServer = server.second; }
}
- 数据源是 Redis hash
logincount(field = 服务器名),ChatServer 登录 +1(LogicSystem.cpp:406-414)、断连 -1(UserMgr.cpp:50-58)、启动清零(ChatServer.cpp:23)、退出HDEL(ChatServer.cpp:50) - 容错(别过度背书):field 不存在 →
INT_MAX→ 不参与选中,但 field 通常都存在:启动就HSet 0(ChatServer.cpp:23),只有优雅退出才HDel(SIGINT/SIGTERM 正常路径ChatServer.cpp:50、异常路径:56)。进程崩溃/被 kill 时不走 HDel,残留旧计数,下次扫描照样被当成最低负载选中——「宕机自动排除」只对「从未启动」或「优雅退出过」的机器成立;更极端的是所有 field 都缺时,minServer初值就是_servers.begin()(StatusServiceImpl.cpp:75-80),INT_MAX也会被原样返回 - 结果连同 token 通过 HTTP 返回,客户端自己去连
第二层:连接在 IO 线程上的均匀分摊(round-robin)
// ChatServer/AsioIOServicePool.cpp:22-28
auto& service = _ioServices[_nextIOService++];
if (_nextIOService == _ioServices.size()) { _nextIOService = 0; }
每次 accept 前轮转取一个 io_context,新连接均匀散到 N 个核。
大量请求同时到来时的排队行为:
- accept 异步,内核 backlog 先缓冲,处理不过来时 connect 变慢或超时,不丢连接
- 逻辑层单工作线程 + 无界
std::queue(LogicSystem.h:49),积压表现为消息延迟上升而不是拒绝
这套方案的真实缺陷(主动讲加分):
- 是「近似均衡」——
logincount读改写在不同进程,两个并发登录会同时选中同一台 - 只在登录时决策,用户不会因后续负载变化被迁移
- 计数非原子,应改
HINCRBY - 进一步优化:加权(按机器配置)、心跳上报真实 CPU/内存、一致性哈希按 uid 分片(可减少跨服转发)
怎么测出单服务器的连接数的,连接数的瓶颈在哪儿
先诚实说明:仓库里没有压测脚本和压测数据(全仓搜 stress/benchmark/wrk/压测 零命中,docs/ 只有截图)。所以这题答方法论 + 瓶颈分析,不编造数字来源。
怎么测(可执行方案):
- 建连测试:写一个压测客户端,每个连接只做 connect + 发 1005 登录 + 收 1006,用 epoll 单进程模拟 8000 连接(一个模拟器进程可管上万连接,不需要真开 8000 个进程)
- 指标采集:服务端看线程数、内存占用、CPU 利用率、连接数统计
- 压力测试:连接建立后再并发发 1017 聊天消息,看消息延迟(日志打时间戳)和吞吐
- 逐步加压找拐点:加到某个数量后延迟陡增 / 出现
connection refused,那就是上限 - Windows 下可用
netstat -an | find /c ":8090"统计连接
瓶颈在哪(按出现顺序):
- 文件描述符/句柄:Linux 是
ulimit -n(默认 1024,必须调到几万)—— 第一道墙 - 内存:每条连接一个
CSession+ 收缓冲char _data[MAX_LENGTH](8KB)+ 发送队列。8000 × 8KB ≈ 64MB 光缓冲区 - CPU:不是空闲连接烧 CPU,而是消息量——每条消息要过 IO 线程解析 + 入队 + 业务线程出队
- 逻辑线程串行:
DealMsg单线程且持锁执行回调(LogicSystem.cpp:45-77),这是本项目最真实的瓶颈——消息量一大就排队 - 共享存储:
logincount、uip_的 Redis 读写随连接数线性增长
一句话总结:空闲连接的成本主要是内存和 fd;真正的上限由「消息吞吐 × 逻辑线程串行度」决定,而不是连接本身。
数据库的连接池是怎么设置的,当有大量请求到来时,如何处理连接超时(连接泄露)问题
设置(ChatServer/MysqlDao.cpp:12):
pool_.reset(new MySqlPool(host + ":" + port, user, pwd, schema, 5)); // 池大小硬编码 5
- 4 个服务都是 5,构造时一次性建满(
MysqlDao.h:26-33),不是懒加载 - 结构:
queue<unique_ptr<SqlConnection>> + mutex + condition_variable + atomic<bool> b_stop_ - 取:
cond_.wait(lock, !pool_.empty() || b_stop_);还:push +notify_one()
大量请求到来时:池只有 5,第 6 个请求在 cond_.wait 上阻塞排队(不报错、不新建)。天然限流——把超过 MySQL 承载能力的请求挡在应用层。
连接超时:没有实现。getConnection() 用的是无参 cond_.wait 而非 wait_for(MysqlDao.h:79-92),池空时无限期阻塞。改进:wait_for(lock, 500ms) 超时返回 nullptr → 上层回「服务繁忙」错误码。
连接泄露的防线(第 3 条实际未生效):
- RAII 归还:
Defer宏(const.h:43-55,析构执行 lambda),CheckPwd等方法都是Defer defer([this,&con]{ pool_->returnConnection(std::move(con)); }),异常路径也能还 - 健康检查:后台线程每 60s 扫描,
_last_oper_time超过 5s 未用的连接发SELECT 1,失败原地重建(MysqlDao.h:35-74)——同时解决 MySQLwait_timeout(默认 8h)单方面踢空闲连接的问题 - HTTP 超时:死代码、未生效——60s
steady_timer成员在(HttpConnection.h:22),但CheckDeadline()只有定义无人调用(HttpConnection.cpp:124),全仓无expires_after/expires_at→ 定时器从未启动,async_wait根本没注册;写完回包时只有deadline_.cancel()(HttpConnection.cpp:138)在 cancel 一个空定时器。要生效必须在Start()发起async_read前expires_after(60s)+ 调用CheckDeadline()
已知泄露点(可作为「我发现的问题」讲):
MysqlDao.cpp:72CheckEmail查询无结果时return true;没归还连接GateServer/VarifyGrpcClient.cpp:11-19只在status.ok()分支归还 stub,失败分支直接 return,池 5 条漏 5 次就永久卡死(对比StatusGrpcClient用 Defer 是对的)
排查手段:给连接打 tag(记录取用者 + 时间戳),池里定期打印「借出未还」列表;或用 wait_for + 借用时长上限强制回收。
MySQL 连接池的设计要点
- 池大小:不是越大越好,
连接数 ≈ CPU核数×2 + 磁盘数;更关键是缩短持有时间(把 gRPC、网络调用挪出连接使用期) - 预热 vs 懒加载:本项目构造时建满(
MysqlDao.h:26-33)——首个请求不付建连成本 - 生产-消费 + 条件变量:
queue + mutex + condvar,取wait(!empty)、还notify_one;b_stop_时唤醒所有阻塞者返回nullptr优雅退出 - 健康检查(必须有):MySQL
wait_timeout默认 8h 会单方面踢空闲连接 → 池里的连接可能已死。本项目 60s 探活 +SELECT 1失败重建(MysqlDao.h:35-74) - 归还必须 RAII:
Defer/unique_ptr自定义 deleter,杜绝异常路径泄露 - 取用要有超时:
wait_for+ 上限,避免池空时全线程雪崩(本项目缺失) - 连接属性:
setSchema(schema_)选库(MysqlDao.h:29,72)、事务后setAutoCommit(true)复位(MysqlDao.cpp:486-491)。本项目没调过setCharacterSet(全仓零命中,只有 setSchema/setAutoCommit)——中文靠建表与连接的默认字符集,稳妥做法是建连后setCharacterSet("utf8mb4")、归还前复位 - 事务要小心:借来的连接可能带着上次的事务状态,归还前必须 commit/rollback + 恢复自动提交
- 可观测:池大小、当前空闲、阻塞等待数、借出超时数打点
gRPC 在项目中解决的什么问题
核心解决「跨进程/跨机器的服务间调用」,三个场景:
- 服务间调用(Gate → Status/Varify):登录时调
StatusService.GetChatServer拿分配结果,验证码调VarifyService.GetVarifyCode。网关因此保持无状态,可多开 - 跨 ChatServer 消息转发(Chat ↔ Chat):A 和 B 不在同一台机器时,A 所在机器通过
ChatService.NotifyTextChatMsg推给 B 所在机器,对端查本地 session 表下发 TCP(ChatServiceImpl.cpp:93-122)。这是 gRPC 在本项目最核心的作用 - 强契约 + 多语言:同一份
message.proto给 C++(protoc 生成 pb.cc)和 Node(@grpc/proto-loader运行时加载,VarifyServer/proto.js:5-9)用,接口变更两边编译期报错
为什么不用 HTTP/REST 做服务间通信:
- 二进制 protobuf 比 JSON 小一个量级、解析快得多
- HTTP/2 多路复用,一条连接并发多路,不需要每 RPC 建连接
- proto 是 IDL,天然生成 stub,不用手写序列化
- 支持流式(本项目未用,但大文件/实时场景可以开流)
redis 缓存的应用场景,如何保证一致性
本项目的 6 类用法:
| key | 场景 | 代码 |
|---|---|---|
code_<email> |
验证码,600s TTL(唯一有 TTL 的 key) | VarifyServer/server.js:23 |
utoken_<uid> |
登录 token,网关签发 → ChatServer 校验 | StatusServiceImpl.cpp:172-177、LogicSystem.cpp:330 |
uip_<uid> |
用户路由表(uid → 所在 ChatServer),跨服转发依据 | LogicSystem.cpp:419 |
logincount (hash) |
各服务器在线人数,负载均衡数据源 | ChatServer.cpp:23、StatusServiceImpl.cpp:76 |
ubaseinfo_<uid> / nameinfo_<name> |
用户资料缓存(read-through) | ChatServiceImpl.cpp:167-208 |
lock_ |
分布式锁前缀——只有宏定义,未实现(全仓无 SETNX 调用) | const.h:116-123 |
怎么保证一致性(Cache-Aside,但只做了一半):
- 读路径 read-through:先查 Redis,miss → 查 MySQL → 回填 Redis(
ChatServiceImpl.cpp:170-207) - 写路径:先写 DB,成功后删缓存(
GateServer/LogicSystem.cpp:296-307:UpdateProfile成功 →Del(ubaseinfo_)+Del(nameinfo_))——顺序正确(先删后写会有脏数据窗口) - 弱保证的地方(主动讲):
RedisMgr::Set是裸SET,除验证码外所有 key 永不过期(RedisMgr.cpp:44-50)- 改密码
UpdatePwd、改状态UpdateUserStatus、好友关系变更都不删缓存 - 没有延迟双删、没有 binlog 订阅失效、没有版本号
为什么这样还能接受:缓存的都是低频变更的用户资料,且登录校验密码走的是直连 MySQL(GateServer/MysqlDao.cpp:187)不走缓存,不会出现「改了密码还能用旧密码登录」。
如果让我做全套:① 所有 key 加 TTL(随机抖动防雪崩)② 每个写操作配失效 ③ 高一致场景用延迟双删或 Canal 订阅 binlog ④ 缓存击穿用 singleflight/互斥锁重建(lock_ 宏正好是给这个留的,但没落地)。
讲项目做的好的一个点
推荐讲「网络层与业务层的生产者-消费者解耦」,全项目最能体现设计能力的地方:
问题:IO 线程有 N 个(CPU 核数),但业务逻辑(如好友验证的三表事务)有状态依赖,直接在 IO 线程里跑会乱序 + 到处加锁。
方案(LogicSystem.cpp:22-81):
- IO 线程读完一帧就
PostMsgToQue({session, recvnode})立刻返回,不阻塞 - 单工作线程
DealMsg用condvar.wait消费,while(!empty)防伪唤醒,退出时先排空队列 - 回调表
map<msg_id, handler>集中注册(LogicSystem.cpp:83-116),加消息只需加一个 ID + 一个 handler
为什么好:
- 消息天然有序:同一连接的帧按到达顺序入队、单消费者按序出队 → 不需要给业务数据加锁
- IO 与业务隔离:慢业务(同步 gRPC、慢 SQL)不阻塞收包
- 可扩展:想并发就把单消费者换成「按 uid hash 到多个 worker」,接口不变
加分收尾:同时说明它的缺陷(锁作用域覆盖回调执行,LogicSystem.cpp:45-77)和我会怎么改。主动指出自己设计的边界,比只夸功能更有说服力。
大量用户连接时,负载的处理和断连的处理
负载处理(四道闸):
- accept 层:
async_accept异步,内核 backlog 缓冲突发连接 - IO 层:round-robin 散到 N 个 io_context(
AsioIOServicePool.cpp:22-28),每核一个线程 - 业务层:无界队列 + 单消费者,过载表现为延迟上升(
LogicSystem.cpp:22-41) - 发送层:发送队列上限
MAX_SENDQUE = 1000,超过直接丢弃该条消息并打日志(CSession.cpp:47-50),不关连接——「保连接、丢消息」的降级策略
断连处理(四步清理):
- 触发:读错误/写错误/长度校验失败 →
Close()关 socket +ClearSession(CSession.cpp:94-138) - 会话表移除:
_sessions.erase(session_id)(CServer.cpp:36-45) - 用户态清理(
UserMgr.cpp:31-72):logincount - 1→ 删uip_<uid>→ MySQLUpdateUserStatus(uid, 0)→BroadcastFriendStatus(uid, 0)广播离线(同服直发 TCP,跨服走 gRPCNotifyFriendStatus,UserMgr.cpp:74-111) - 客户端侧:
disconnected信号 → 指数退避重连(3s 起 ×2 封顶 30s,tcpmgr.cpp:54-69)
边界情况:
- 顶号:新登录给旧 session 发
1021 ID_NOTIFY_OFF_LINE_REQ并Close()(LogicSystem.cpp:423-430),旧连接关闭又触发一次清理 - 缺陷:
logincount - 1发生在「是否当前 session」判断之前(UserMgr.cpp:50-58),重复清理会重复扣减;且非原子(应HINCRBY) - 服务器崩溃:
uip_、logincount无 TTL 也不清理 → 脏路由残留(见「服务器挂了如何无损切换」)
客户端请求到达服务端后的通信链路
以发一条聊天消息为例(最完整链路):
① UI 线程:chatwid.cpp:715 emit TcpMgr::sig_send_data(1017, json)
└─ 信号自连到 TcpMgr::slot_send_data(tcpmgr.cpp:71)—— 序列化收敛到单点
② 封包:4字节头(2B msg_id=1017 + 2B len, BigEndian) + JSON (tcpmgr.cpp:551-568)
③ QTcpSocket::write → 网络
④ ChatServer IO 线程:async_read_some 读4字节头 → network_to_host_short
→ 校验长度 → asyncReadBody 读满 body(CSession.cpp:142-171, 205-233)
⑤ 投递:PostMsgToQue({session, recvnode}) (CSession.cpp:112)
⑥ 业务线程:DealMsg 出队 → _fun_callbacks[1017] → DealChatTextMsg (LogicSystem.cpp:43-81, 846-909)
⑦ 路由:查 Redis uip_<touid>
├─ 同服 → UserMgr::GetSession(touid) → session->Send(json, 1019)
└─ 跨服 → ChatGrpcClient::NotifyTextChatMsg → 对端 ChatServiceImpl → session->Send
⑧ 回执:Defer 回 1018 ID_TEXT_CHAT_MSG_RSP(LogicSystem.cpp:863-866)
⑨ 对端 IO 线程:SendNode 封包 → async_write → 客户端 readyRead 解包 → handler 分发
关键设计点:读状态机(头→体→头循环)、消息按序(单连接单读 + 单消费者 FIFO)、双向解耦(Send() 用队列 + mutex 保证同一时刻只有一个 async_write 在飞,CSession.cpp:44-59)。
数据库中用户密码的加密存储
实际做法(要诚实):
- 客户端单次 SHA-256、无盐:
// Fool_Chat_Client/src/core/global.cpp:12-19
QByteArray hash = QCryptographicHash::hash(input.toUtf8(), QCryptographicHash::Sha256);
return QString(hash.toHex());
注册(regsterdialog.cpp:392)、登录(login.cpp:273)、重置密码(resetdialog.cpp:315)都先哈希
2. 服务端直接存:注册走存储过程 reg_user(?,?,?,@result)(GateServer/MysqlDao.cpp:38),参数已是哈希值,存储过程里没有任何哈希/加盐逻辑
3. 登录比对是字符串相等:if (pwd != origin_pwd) return false;(GateServer/MysqlDao.cpp:187)——服务端不再二次哈希
4. 库里 pwd 全是 64 位 hex,无 bcrypt 格式、无盐字段
这个方案的问题(面试必须主动说):
- 无盐 → 相同密码哈希值相同,可彩虹表批量反查
- 单次快速哈希 → SHA-256 每秒可算上百亿次,弱密码瞬间爆破
- 客户端哈希后仍等同明文 → 服务端被拖库,哈希值可直接用于登录
- 顺带:注册回包把
passwd明文回显(GateServer/LogicSystem.cpp:116),密码还打印到日志(GateServer/MysqlDao.cpp:183)
标准正确姿势:
- 服务端用 bcrypt/scrypt/Argon2(自带随机盐 + 可调成本因子),
password_hash()/password_verify() - 传输层用 TLS,密码明文只在 TLS 里存在
- 要防「哈希值泄露后重放」,加 pepper(服务端环境变量里的密钥)
- 客户端 SHA-256 可保留作为传输前预哈希,但必须服务端再做慢哈希
高并发场景下单个连接数不足的问题具体讲讲
先把概念分清:「连接数不足」有两种完全不同的问题。
类型一:服务端能建的连接不够(fd/内存上限)
- 瓶颈:文件描述符、内存(每连接一个
CSession+ 8KB 收缓冲) - 解法:
ulimit -n调大、SO_REUSEPORT 多进程监听、水平加机器
类型二(更常见):单条连接的吞吐不够
- 一条 TCP 连接上的读写是串行的:本项目同一 session 同时只有一次未完成读(读完 body 才
AsyncReadHead,CSession.cpp:114),发送也是同一时刻只有一个async_write在飞(CSession.cpp:44-59) - 单连接上限 ≈ 由 RTT 和窗口决定,高延迟链路尤其明显
本项目的具体解法:
- 连接级并行:连接散到多个 io_context / 多台 ChatServer(round-robin + 负载均衡)
- 业务级并行:消息进队列后由工作线程处理,收和处理解耦
- 跨服分片:用户分散到不同机器,每台只承载部分连接
如果单用户真的需要更高带宽(大文件/视频):
- 多连接分片:一个用户开多条连接分别传不同分片(QUIC/HTTP3 就是这个思路)
- 绕过服务器:实时音视频用 WebRTC P2P,媒体流不走服务器——本项目已埋了这个设计:
ID_CALL_SIG_REQ(1046)只转发信令 offer/answer/ice(LogicSystem.cpp:912-981),媒体本身点对点 - 大文件走对象存储:上传 OSS/CDN,聊天里只发 URL,不占用长连接
为什么要用线程池
四个理由 + 本项目的真实情况:
- 线程创建/销毁代价大:一次创建约几十微秒,还有栈分配(默认 1MB 栈 × 8000 = 8GB)。复用消除开销
- 控制并发度、防止资源耗尽:线程数无上限会导致 CPU 花在切换而非执行上
- 任务削峰:本项目
AsioIOServicePool固定 N=CPU 核数(AsioIOServicePool.h:19),突发连接不会导致线程爆炸 - 简化并发:本项目 IO 池「每 io_context 1 线程」,同一连接的回调天然串行 → 免锁、免 strand
本项目的三种「池」:
| 池 | 数量 | 作用 |
|---|---|---|
AsioIOServicePool |
CPU 核数个 io_context,各 1 线程 | 跑网络回调 |
LogicSystem 工作线程 |
1 个(不是池) | 串行处理业务,保序 |
| 资源连接池 | MySQL 5 / Redis 5 / gRPC 5/peer | 复用昂贵的连接(同一个思想) |
反面提醒:线程池不是万能的。本项目业务层用单线程而不是线程池,是为了保序——多线程处理同一用户消息就必须加锁或按 uid 分片,复杂度陡增。选择单线程是权衡后的决定,不是没想到。
项目还有哪些不足和改进点
技术层(挑 3 条展开,每条带「怎么改」):
- 没有应用层心跳:
ID_HEART_BEAT_REQ(1023)只有枚举定义(const.h:84),服务端RegisterCallBacks没注册 handler(LogicSystem.cpp:83-116),也无任何定时器 → 半开连接检测不到(NAT 掉线、客户端崩溃服务端不知道)- 改:服务端 30-60s 收不到任何包就
Close();客户端 30s 发一次心跳
- 改:服务端 30-60s 收不到任何包就
- 没有离线消息:查不到
uip_直接return,消息永久丢失(LogicSystem.cpp:873-876)- 改:落
offline_msg(to_uid, from_uid, msg_id, content, create_time)表,登录时随friend_list一起拉取,收到 ACK 后删除(顺便回答「离线消息」)
- 改:落
- 并发计数非原子:
logincount是HGet→++→HSet(LogicSystem.cpp:406-414),并发丢更新- 改:
HINCRBY logincount <server> 1,一条命令原子完成
- 改:
架构层:
4. 无共享库:proto/const.h/RedisMgr/MysqlDao 4 份复制粘贴副本(CLAUDE.md:118-124)→ 抽 common/ 静态库
5. gRPC 调用无 set_deadline(全仓零命中)→ 对端挂了会无限阻塞逻辑线程,必须加 deadline + 重试
6. DealMsg 锁作用域覆盖回调(LogicSystem.cpp:45-77)→ 出队即释放锁
7. Redis key 全部无 TTL,服务器崩溃留脏数据(uip_ 残留导致消息路由到死服)→ 关键 key 加 TTL + 心跳续期
工程质量:
8. 无自动化测试、无 CI、无 lint(CLAUDE.md:20 明说)
9. 两个已知连接泄露点(MysqlDao.cpp:72、VarifyGrpcClient.cpp:16-19)
10. 密码无盐 SHA-256 + 明文日志 + 无 TLS(见「密码加密存储」)
讲法建议:挑 1、2、5 讲——都是「我主动发现的」,且有明确改造方案。
客户端和服务端网络通信的过程,包括具体的协议
登录阶段(HTTP/1.1):
POST /user_login HTTP/1.1
Content-Type: application/json
{"user":"foo","passwd":"<sha256>"}
HTTP/1.1 200 OK
{"error":0,"uid":1001,"token":"<uuid>","host":"127.0.0.1","port":"8090"}
- 服务端 Boost.Beast 解析(
HttpConnection.cpp:108-116) - 响应
keep_alive(false)+shutdown_send→ 实际是短连接(HttpConnection.cpp:145,137)
聊天阶段(自定义 TCP 协议):
+--------+--------+------------------+
| msg_id | length | JSON body |
| 2 字节 | 2 字节 | length 字节 |
+--------+--------+------------------+
大端序 大端序
- 4 字节定长头,
HEAD_TOTAL_LEN=4 / HEAD_ID_LEN=2 / HEAD_DATA_LEN=2(const.h:60-64) - 必须网络字节序(大端):服务端
network_to_host_short转换(CSession.cpp:148,161),客户端QDataStream::BigEndian(tcpmgr.cpp:559) - 单包 body 上限 8KB(
const.h:58) - 为什么自己定义而不用 protobuf over TCP:JSON 人眼可读,抓包调试直接看懂;proto 用于服务间(要性能和强契约),客户端-服务器间更看重灵活性。这是权衡,不是不知道 protobuf
服务间(gRPC over HTTP/2 + Protobuf):message.proto 定义 3 个 service、12 个 rpc(Varify 1 + Status 2 + Chat 9,ChatServer/message.proto)
消息 ID 分段(const.h:69-107):1005 登录 → 1007 搜索 → 1009/1013 好友 → 1017 聊天 → 1021 踢下线 → 1023 心跳(未实现) → 1025/1027 动态 → 1031 删好友 → 1036/1038 公告 → 1040/1042 备注/策略 → 1044 好友列表 → 1046 音视频信令
项目背景
一句话:一套完整的分布式即时通讯系统,服务端 C++(Boost.Asio + gRPC),客户端 Qt6,验证码服务 Node.js,后台管理 Go,共 7 个可独立部署的组件。
为什么做:网络编程教材和 demo 都停留在「单进程 echo server」,而真实 IM 必须回答的问题——用户分散在多台机器时消息怎么送达、登录怎么分配服务器、跨服状态怎么同步——单进程根本遇不到。所以从一开始就按多进程设计,逼自己把分布式问题做出来。
规模:4 个 C++ 服务进程 + 2 个第三方语言服务;TCP 自定义协议;Redis 承担 token/路由/负载均衡三类在线状态;MySQL 承担账号(含 user.add_policy 加好友策略,MysqlDao.cpp:563,587)、好友关系(:695)、好友申请(:164)、动态 dynamic(:337)、公告(StarNotice/admin_notice,:511,:528)、后台运营数据(Go 侧 4 表,fool_chat_admin_go/main.go:88,120,142,160)。
功能面:注册登录(邮箱验证码)、好友申请/验证/删除/备注/策略、实时聊天、朋友圈动态(带审核)、公告通知、音视频通话信令(WebRTC)、Qt 仿微信客户端、Go 后台(含 AI 助手 + 高危操作二次确认)。
面试三段式:背景(单机 demo 遇到的天花板)→ 拆分(4 服务职责边界,见「微服务拆分与职责边界」)→ 难点(跨服消息路由与有序性,见「技术难点」)。
项目对你的技术挑战在什么地方
按「从没想到 → 想到但做错 → 做对了」讲三层:
1. 粘包/拆包(从没意识到 TCP 是流)
- TCP 没有消息边界,一次
read可能收到半条消息,也可能收到三条粘在一起 - 解法:4 字节定长头声明长度 + 循环补读到满(
asyncReadLen递归,CSession.cpp:205-233),读完一帧立即回到读头状态,剩余字节留给下一帧 - 客户端实现更稳:累积 buffer + 偏移判等,一个
forever循环吐出多帧(tcpmgr.cpp:26-46)
2. 多线程下的顺序保证(最难的一个)
- IO 是多线程的(N 个 io_context),但业务要求同一用户的消息严格有序
- 解法:两层——同一 session 的回调跑在同一个单线程 io_context(天然串行)+ 单工作线程消费 FIFO 队列(
LogicSystem.cpp:43-81) - 关键洞察:「每 io_context 只开 1 个线程」这个看似保守的设计,换来了免锁和免 strand
3. 分布式下的消息路由(跨服转发)
- 用户可能在任何一台 ChatServer 上,A 发消息给 B 必须先知道 B 在哪
- 解法:登录时把
uip_<uid> = 本服名写进 Redis,发消息时查路由 → 同服直发 session、跨服走 gRPCNotify*(LogicSystem.cpp:869-908) - 副作用:Redis 挂了 = 整个路由系统失效,这是可用性换简单性
收尾:这三个挑战分别对应「协议层」「并发层」「分布式层」,正好是这个项目的三层结构。
其中的技术难点是什么
首选答案:跨服务器消息转发的正确性,同时牵扯路由、顺序、失败处理三个维度。
难点拆解:
- 定位:怎么知道接收方在哪台机器 → Redis
uip_路由(LogicSystem.cpp:871-873) - 同服与跨服两条路径的语义必须一致:同服是进程内
session->Send,跨服是 gRPC → 对端ChatServiceImpl再session->Send(ChatServiceImpl.cpp:93-122)。两条路径 JSON 结构必须完全一样,否则客户端解析炸掉 - 顺序:同一用户多条消息,跨服时 gRPC 是同步调用且在同一个逻辑线程里串行发出(
LogicSystem.cpp:908)→ 顺序得以保证。代价是这个同步调用会阻塞全服的消息处理 - 失败语义:gRPC 挂了怎么办?失败分支写
rsp.set_error(RPCFailed)且不重试不落库(ChatGrpcClient.cpp:79-82)——但这个信号靠不住:函数头注册的Defer(:60-64)在退出时无条件把error写回Success,返回值会不会被覆写取决于编译器做不做 NRVO(同一段最小复现实测:MSVC/O2、/Od都保留RPCFailed;clang-O2被覆写成Success,加-fno-elide-constructors又变回)。更硬的事实是三个调用方都直接丢弃返回值(LogicSystem.cpp:577、:908、:1085)→ 失败一律被吞;另外_pools.find未命中走:66-69早退,返回的是默认0(= Success)
次选答案:消息按序处理与不加锁的平衡(见「项目的技术挑战」第 2 点、「对并发和异步的思考」)
面试话术:结论(跨服路由)→ 三步(查路由 / 分支转发 / 对端下发)→ 主动说局限和改进(加 ACK + 重试 + 离线消息表)。说得出局限才是真做过。
为什么要做即时通信项目
- 它是网络编程的「全谱系」:一个 IM 同时逼你面对 TCP 粘包、长连接管理、并发模型、序列化协议、连接池、缓存一致性、负载均衡、分布式路由——别的题目很难一次覆盖这么多
- 即时性带来真实约束:消息必须秒级送达、必须保序、断线要重连——这些约束会逼出正确的架构(异步 IO + 队列 + 长连接),而不是「能跑就行」的同步实现
- 可水平扩展的样本:单机做完了自然会问「一万用户怎么办」,于是必须拆服务、做负载均衡、做跨服转发——分布式问题是被需求推出来的,不是硬套的
- 有完整的产品闭环:客户端 UI + 服务端 + 后台管理,能看到全链路,容易验证实现对不对
- 踩坑空间大:粘包、心跳、半开连接、顶号、离线消息……每个都是面试官爱问的经典问题,做完一个项目能沉淀一整套八股(就是这份文档)
为什么选择项目中的这个网络库,以及这些技术
技术栈选型与理由:
| 技术 | 理由 | 备选与放弃原因 |
|---|---|---|
| Boost.Asio | 跨平台异步 IO 事实标准;Proactor 模型成熟;和 gRPC/Beast 同源(Beast 建在 Asio 上) | 原生 epoll/select 要手写 reactor、跨平台两套代码;libevent 回调风格难维护状态 |
| Boost.Beast | 做 HTTP 不用再引 libcurl/cpprest,与 Asio 共用同一套 io_context,网关零额外依赖 | cpprestsdk 太重;libcurl 是同步模型,和异步架构不搭 |
| gRPC + Protobuf | 服务间调用行业标准;IDL 强契约;C++/Node/Go 三语言同 proto | 自写 TCP + JSON 序列化 → 没有超时/重试/流式/代码生成,等于重造轮子 |
| JSON(客户端-服务器) | 人眼可读,抓包直接看懂;Qt 有 QJsonDocument 原生支持 |
全链路 protobuf → 客户端调试痛苦,改字段要同步两端编译 |
| Redis | 在线状态/路由需要毫秒级读写;单线程无锁天然适合做共享状态 | 全放 MySQL → 每条消息都要查询,扛不住 |
| Qt6 | 信号槽机制,网络回调天然能跨线程投递到 UI 线程 | MFC 生态不如 Qt 顺手 |
核心判断标准(可抽象成方法论):
- 选「有标准」的,不选「自造的」——网络 IO 和 RPC 都有成熟方案
- 同生态优先——Asio/Beast/gRPC 都是异步回调风格,思维模型统一
- 性能敏感路径用二进制(protobuf),人要参与调试的路径用文本(JSON)
- 引入的每个依赖都要能回答「为什么不是 X」
为什么选择封装 tcp(为什么不用 udp、为什么不直接用库里的 tcp)
先澄清:「封装」指 CSession 这一层——把 socket 读写、协议头编解码、发送队列、生命周期打包成一个类,上层只见 Send(json, msg_id) 和回调。
为什么是 TCP 不是 UDP:
- IM 的核心承诺是可靠有序——UDP 丢包、乱序、重复都得在应用层重做(ACK、重传、序号、排序),等于重写一个 TCP
- 消息必须按序到达——1005 登录回包跑到 1017 聊天消息后面,客户端状态机会乱
- 拥塞控制免费——弱网下 TCP 自动降速,UDP 会加剧拥塞
- 业务不需要超低延迟——文字聊天 100ms 和 30ms 用户无感。UDP 的价值场景是音视频(丢帧可容忍、延迟敏感),本项目音视频走 WebRTC(
ID_CALL_SIG_REQ信令 + P2P 媒体),那部分确实是 UDP/SRTP
为什么不直接用「库里的 tcp」:
- Boost.Asio 只给原语,不给协议——
async_read_some返回的是字节流,没有「一条消息」的概念,粘包拆包必须自己做 - 没有会话概念——不帮你管理「这个连接对应哪个用户」「怎么踢人」「怎么广播」
- 没有发送顺序保证——如果每次
Send直接async_write,多线程同时写会把两帧交错成乱码(TCP 保证字节流有序,不保证你的多次写调用不交错)。所以必须发送队列 + mutex,保证同一时刻只有一个写在飞(CSession.cpp:44-59) - 没有生命周期管理——异步操作还在飞时对象不能销毁,必须
enable_shared_from_this+shared_from_this()传进回调(CSession.cpp:40-59)
一句话:库解决「怎么收发字节」,封装解决「怎么收发消息」。
客户端和服务端只有一个单 tcp 连接吗,没有其他吗
不是。完整连接拓扑:
Qt客户端 ──HTTP(短连接)──▶ GateServer:8080 注册/登录/重置密码/改资料
Qt客户端 ──TCP(长连接)──▶ ChatServer:8090/8091 登录 + 所有实时消息
GateServer ──gRPC──▶ StatusServer:50052 分配服务器(**只调 GetChatServer**,GateServer/LogicSystem.cpp:225;GateServer 的 Login 客户端方法被注释,StatusGrpcClient.h:90)
ChatServer ──gRPC──▶ StatusServer:50052 二次验 token(StatusService.Login,ChatServer/LogicSystem.cpp:346)
GateServer ──gRPC──▶ VarifyServer:50051 验证码
ChatServer ──gRPC──▶ ChatServer2:50056 跨服消息转发
各服务 ──连接池──▶ MySQL:3308 / Redis:6380
Qt客户端 ──直连──▶ MySQL:3308 DBManager 查重用户名/邮箱
一个用户在线时至少持有:1 条到 ChatServer 的 TCP 长连接 + 若干到 GateServer 的 HTTP 短连接(登录/验证码/改资料时才建)+ 服务端之间的 gRPC 连接。
为什么登录后主要走一条 TCP:
- 登录是一次性「换票」动作,HTTP 短连接最简单(无状态、网关可扩展)
- 聊天是高频双向推送,必须长连接(服务端要主动推好友上线、新消息)——HTTP 短连接做不到服务端主动推送(HTTP/1.1 一问一答),这是长连接的不可替代性
延伸点(主动讲):真实 IM 一般还会给文件/图片单独走一条连接或对象存储上传通道(本项目未实现,见「如果要做文件传输」)。
如果要进行文件传输,应该怎么做
现状:没有实现。全仓搜 QHttpMultiPart/upload/sendFile 零命中;图片分支只渲染本地气泡不发包(chatwid.cpp:677-682),file 分支是空实现(chatwid.cpp:683-686),图片 content 存的是本地绝对路径(messagetextedit.cpp:168)。
三个方案按场景选:
方案 A:对象存储 + URL 分享(推荐,绝大多数 IM 的做法)
- 客户端向 GateServer 申请上传凭证
- HTTP
multipart/form-data上传到 OSS/MinIO,拿到file_id/URL - 聊天消息只发
{type:"file", url, name, size, md5},对端拉 URL 下载 - 优点:不占聊天长连接、天然断点续传和 CDN 加速、文件服务可独立扩展
方案 B:走现有 TCP 长连接分片(小文件、无第三方依赖)
- 新增协议:
ID_FILE_HEAD(文件名/大小/md5/分片数)→ID_FILE_SLICE(序号+数据)→ID_FILE_END - body 上限 8KB,每片控制在 4-8KB,序号保证乱序重组
- 接收端按序号组装,md5 校验完整性
- 必须解决:发送队列上限 1000(
CSession.cpp:47)会让大文件把聊天消息挤掉 → 需要独立通道或令牌桶限速
方案 C:P2P 直传:复用已有的音视频信令通道(ID_CALL_SIG_REQ)协商,WebRTC DataChannel 直传,服务器只做信令。
关键工程细节(面试要提到):断点续传(分片偏移量)、去重(相同 md5 秒传)、病毒扫描、大小/类型限制、传输进度回调(Qt 用信号槽发进度)。
redis 缓存怎么用的,为什么不直接用 MySQL
怎么用的(5 类,见「redis 缓存场景与一致性」的表):验证码 code_、token utoken_、路由 uip_、负载 logincount(hash)、资料缓存 ubaseinfo_。访问模式是 read-through(miss 回源 MySQL 再回填,ChatServiceImpl.cpp:170-207)+ 写后删缓存(GateServer/LogicSystem.cpp:296-307)。
为什么不能只用 MySQL(逐个场景说):
| 场景 | 为什么必须用 Redis |
|---|---|
消息路由 uip_ |
每发一条消息都要查一次目标在哪。MySQL 查询是「磁盘 IO + 网络往返」几毫秒,Redis 是亚毫秒。按每秒 1 万条消息算,MySQL 这一项就吃掉 10 秒 CPU/网络,Redis 是内存操作 |
负载均衡 logincount |
每次登录要读 N 台服务器的在线数,而且要求多台 ChatServer 实时共享同一份计数——MySQL 写库有事务和磁盘刷盘开销,读还要走连接池 |
token 校验 utoken_ |
每个 TCP 登录都要验一次,高频读、低频写,天然 KV |
验证码 code_ |
需要自动过期(600s),MySQL 要靠定时任务清理,Redis 原生 TTL |
资料缓存 ubaseinfo_ |
read-through 热点数据,避免每次登录都 SELECT * FROM user |
MySQL 的定位:持久化、事务、关系查询(friend_apply join user、多表 join、聚合统计)——这些 Redis 做不了或做不好。
两者分工:Redis 存「热的、临时的、高频读的」,MySQL 存「冷的、持久的、要事务的」。不是二选一,是分层。
注意:本项目 Redis 挂了 = 路由和负载均衡全挂(没有 fallback 到 MySQL),这是可用性上的取舍,可作为改进点提(加本地缓存兜底)。
项目中遇到的困难,如何解决
三个真实困难 + 解法(挑 2 个讲):
困难 1:粘包导致客户端解析死锁
- 现象:两条消息粘在一起时,客户端解析完第一条就卡住不动
- 原因:旧实现用
QDataStream读头后用mid()重新赋值_buffer,粘包时偏移算错,剩余字节永远读不出下一帧 - 解决:改成累积缓冲 + 偏移判等,一个
forever循环里先判断「头够不够 → body 够不够 → 消费 → 继续」,不够就return等下次readyRead(tcpmgr.cpp:26-46,代码注释里记录了这个坑) - 收获:理解了 TCP 是字节流,消息边界必须由应用层协议声明
困难 2:跨服消息发不出去(用户明明在线)
- 现象:A 和 B 分别登录不同 ChatServer 时,消息发不到
- 原因:只实现了同服直发,忘了用户分布在多台机器
- 解决:登录时把
uip_<uid> = 本服名写进 Redis,发消息先查路由,取不到本机 session 就通过 gRPCNotify*转发到对端(LogicSystem.cpp:869-908),对端ChatServiceImpl查自己的 session 表下发 - 收获:分布式系统的「共享状态」是所有跨机协作的基础
困难 3:MySQL 空闲连接被服务端踢掉
- 现象:服务跑一段时间后偶发「MySQL server has gone away」
- 原因:连接池里的连接长时间没用,被 MySQL
wait_timeout(默认 8 小时)单方面关闭 - 解决:连接池加后台保活线程,每 60s 扫描、超过 5s 未用的连接发
SELECT 1探活,失败就原地重建(MysqlDao.h:35-74) - 收获:连接池不是「建好就完事」,健康检查是必需品
讲法:现象 → 根因 → 解法 → 收获。困难必须是真困难,不要说「一开始不熟悉 API」这种。
介绍各服务器的职能
| 服务 | 端口 | 语言/框架 | 单一职能 |
|---|---|---|---|
| GateServer | HTTP 8080 | C++ / Boost.Beast | 唯一 HTTP 入口:注册、登录、重置密码、验证码转发、改资料。查 MySQL 校验密码 → 调 StatusServer 分配机器 → 把 uid/token/host/port 回给客户端。无状态,可多开 |
| VarifyServer | gRPC 50051 | Node.js / @grpc/grpc-js | 邮箱验证码:uuidv4 取前 4 位转大写 → 写 Redis code_<email>(600s TTL)→ nodemailer 走 163 SMTP 发邮件。已有码则复用不重发(防刷) |
| StatusServer | gRPC 50052 | C++ / gRPC | 登录态与负载均衡:按 Redis logincount 选负载最低的 ChatServer → 生成 boost uuid token → 写 utoken_<uid> → 返回 host/port/token。另有 Login RPC 做 token 二次校验(调用方没检查返回值,实际不生效) |
| ChatServer / 2 | TCP 8090/8091 + gRPC 50055/50056 | C++ / Boost.Asio + gRPC | 实时消息:会话管理、TCP 协议解析、业务回调、跨服消息转发(Notify*)、好友/动态/公告/通话信令。唯一可水平扩展的服务,两份实例代码完全相同,靠 config.ini 的 [SelfServer] 区分身份 |
| fool_chat_admin_go | HTTP 9100 | Go | 后台管理:用户/动态/公告/通知/管理员申请、系统监控、数据备份、AI 助手(高危操作二次密码确认) |
| Fool_Chat_Client | — | Qt6 | 桌面客户端:聊天、通讯录、动态、音乐、公告,内嵌后台管理页(QWebEngineView) |
| 共享存储 | MySQL 3308 / Redis 6380 | — | MySQL 存账号(含 add_policy)/好友关系/好友申请/动态 dynamic/公告(StarNotice/admin_notice)/后台运营数据(Go 4 表);Redis 存 token/验证码/路由/在线数 |
一句话记忆:网关管入口、验证管发码、状态管分配、聊天管消息、后台管运营——每个服务一句话就能说清边界。
用 asio 主要实现了哪些功能
- TCP 服务器(
CServer):tcp::acceptor监听 +async_accept异步接受连接(CServer.cpp:17-34) - 异步读:
async_read_some分段读 +asyncReadFull/asyncReadLen递归补读到指定长度,实现「先读 4 字节头、再读 body」的状态机(CSession.cpp:87-177状态机本体,:205-233递归补读) - 异步写:
async_write整帧写,配合发送队列保证帧完整性和顺序(CSession.cpp:44-59,179-191) - io_context 线程池:N 个
io_context各跑一个线程,executor_work_guard防止空队列退出,round-robin 分配连接(AsioIOServicePool.cpp) - HTTP 服务(GateServer):Boost.Beast 的
http::async_read/http::async_write+dynamic_body(HttpConnection.cpp:108-138) - 超时定时器:
steady_timer是给 HTTP 60s deadline 用的(HttpConnection.h:22)——但只定义未启动(CheckDeadline()无人调用) - 优雅退出:
boost::asio::signal_set捕获 SIGINT/SIGTERM → 停线程池 → 关 gRPC(ChatServer.cpp:41-46) - 字节序转换:
socket_ops::network_to_host_short(收包,CSession.cpp:149,160)、host_to_network_short(发包封头,MsgNode.cpp:11,14)
没用到但值得提:strand(因为每 io_context 单线程不需要)、timer 做心跳(心跳未实现)、async_spawn/coroutine(C++20 协程版 Asio 更现代,本项目用回调风格)。
为什么使用多线程模型,如何处理多个请求
为什么多线程:
- 单线程跑 8000 连接的读写回调,任何一个慢操作(同步 gRPC、慢 SQL)会卡住所有连接
- 多核 CPU 单线程只能用 1/N 的算力
- 异步回调本身是并发到达的,多线程才能并行消费
本项目的多线程分工(每个进程线程数固定,见「数据共享与锁」的完整表):
- 主线程:accept + 信号
- N 个 IO 线程(N = CPU 核数):跑网络回调
- 1 个逻辑线程:跑业务
- 1 个 gRPC 线程:收跨服请求
- 连接池保活线程
如何处理多个请求(关键在于「分层 + 排队」):
- 连接层分散:新连接 round-robin 分到不同 io_context,不同连接的请求天然并行
- 连接内串行:同一连接的读回调在一个单线程 io_context 上 → 不用加锁
- 业务层排队:所有连接的消息投进同一个队列,单逻辑线程按 FIFO 消费 → 不同连接的请求在业务层是串行的(这是本项目的取舍:用吞吐换简单和有序)
- 阻塞操作用连接池:MySQL/Redis/gRPC 调用借池里的连接,用完归还,不新建线程
这个模型的真实代价(主动说):业务层是全局单线程,一条慢 gRPC(无 deadline)会拖慢全服消息。改进方向:按 uid 分片到多个逻辑 worker(同一用户的消息仍有序,不同用户并行),或把跨服转发改成异步 gRPC + 完成回调。
如何存储大量的聊天消息(包括视频音频,以及各种大文件)
现状(先说清):项目里没有聊天消息表——ChatServer/MysqlDao.cpp 里的 INSERT 只有三处:friend_apply(:164)、dynamic(:337)、friend 双向各一条 INSERT IGNORE(:442、:463);注册写用户走存储过程 CALL reg_user(?,?,?,@result)(GateServer/MysqlDao.cpp:38)。一条消息都不落库,服务端转发完就丢(LogicSystem.cpp:846-909)。所以这题答的是设计方案。
分层存储设计(面试标准答案):
写入 读取
发送方 ──▶ MQ(Kafka/RocketMQ) ──▶ 消息写服务 ──▶ 存储层
│
最近 N 条会话 ──▶ Redis ZSET(会话列表 + 缓存最近消息)
历史消息 ──▶ MySQL 分库分表(按 uid hash 或按会话 id)
大文件/媒体 ──▶ 对象存储(S3/OSS),DB 只存元数据 + URL
冷数据归档 ──▶ 归档到 HDFS / 低频存储
要点:
- 热冷分离:最近 100 条放 Redis(ZSET 按时间排序),翻页更早的走 MySQL
- MySQL 分库分表:单表十亿行撑不住。按
session_id(会话维度,保证同一会话在一张表,范围查询高效)或uid分片;分页用游标(上一页最后一条的 id/时间戳)而不是 OFFSET,深分页会随页数线性变慢 - 大文件绝不进数据库:视频/音频/图片上传对象存储,DB 只存
{url, size, md5, duration}。数据库存 blob 会导致表膨胀、备份困难、锁表 - 写入异步化:聊天消息先落 MQ,发送方立刻收到回执(本项目已经是先回执再转发),存储由消费者异步做,不阻塞实时链路
- 读写分离:读历史走从库,实时推送不查库
- 消息 ID:用雪花算法(时间戳+机器ID+序列号)保证全局有序且可排序,比自增 ID 更适合分布式
如果面试官问「你项目为什么不存」:老实说「实时链路优先,当前只做转发没做持久化,聊天记录在客户端本地留痕」——然后给出上面的方案,说明是设计取舍 + 后续规划。
gRPC 如何实现断线重连
现状(要诚实):项目里没有显式的 gRPC 重连代码——没有健康检查、没有退避重试、没有 stub 失效重建,全仓无 set_deadline。失败分支写 rsp.set_error(ErrorCodes::RPCFailed)(ChatGrpcClient.cpp:79-82),但这行在 NotifyAddFriend 里靠不住:函数开头先注册的 Defer(:60-64)退出时会无条件把 error 写回 Success,返回值最终保不保留 RPCFailed 取决于编译器做不做 NRVO(MSVC 实测保留、clang -O2 被覆写)。更硬的事实是三个调用方都直接丢弃返回值(LogicSystem.cpp:577、:908、:1085)→ 失败一律被吞。同类写法还有 NotifyDeleteFriend(:241-244);而 NotifyTextChatMsg(:154-163)、NotifyAuthFriend(:92-95)的 Defer 只回填 fromuid/touid/textmsgs,不碰 error,RPCFailed 才能传出去——同一个文件里两种写法,是能主动讲的 bug。
gRPC 自带的机制(可以答这个,是事实):
- Channel 状态机:
IDLE → CONNECTING → READY → TRANSIENT_FAILURE → IDLE - 自动重连:连接断开后 channel 进入
TRANSIENT_FAILURE,gRPC 会按指数退避(默认初始 1s,倍增,有上限)自动重连,业务代码不用写 - 状态回调:
channel->WaitForConnected(deadline)或注册RegisterStateChangeCallback可以感知状态 - 子通道级:HTTP/2 连接断了,gRPC 会在 channel 内重建子通道,已有的 stub 不用重建——所以「重连」对上层是透明的
本项目的一个隐患(可作为深度回答):
// ChatGrpcClient.cpp:6-9 —— 每个 stub 各自 CreateChannel
for (size_t i = 0; i < poolSize_; ++i) {
auto channel = grpc::CreateChannel(host + ":" + port, grpc::InsecureChannelCredentials());
connections_.push(ChatService::NewStub(channel));
}
5 个 stub = 5 条独立的 HTTP/2 连接,各自重连。正确做法是1 个 channel + 5 个 stub(channel 内部自己做连接复用和负载均衡),这样重连只需一次、连接数减 5 倍。
如果让我做完整方案:
- 统一 channel(共享)
- 调用前
set_deadline(3s)——没有 deadline,对端挂了逻辑线程会永久阻塞 - 失败后有限次重试 + 指数退避 + 抖动(gRPC 自带 retry policy,
grpc.service_config配置) - 连续失败 N 次 → 健康检查标记为不健康 → 负载均衡摘除该节点(配合
uip_路由失效)
两个客户端之间如何实现聊天功能
完整链路(A 发给 B,A 和 B 可能在同一台也可能在不同台 ChatServer):
同服(B 的 session 在本机):
A 客户端 emit sig_send_data(1017, {fromuid,touid,text_array})
→ TcpMgr 封包 → TCP → A 的 ChatServer
→ IO 线程解包 → PostMsgToQue → 逻辑线程 DealChatTextMsg (LogicSystem.cpp:846)
→ 查 Redis uip_<B> == 本服名 → UserMgr::GetSession(B) 找到 session
→ session->Send(json, ID_NOTIFY_TEXT_CHAT_MSG_REQ) (LogicSystem.cpp:881-890)
→ TCP → B 客户端 readyRead 解包 → handler[1019] → emit sig_text_chat_msg → 气泡渲染
→ 同时回 A 一个 1018 送达回执(按 msgid 刷「已送达」状态)
跨服(B 在另一台 ChatServer):
A 的 ChatServer 查 uip_<B> = "chatserver2" ≠ 本服名
→ ChatGrpcClient::NotifyTextChatMsg("chatserver2", proto) (LogicSystem.cpp:908)
→ gRPC/HTTP2 → B 的 ChatServer 的 ChatServiceImpl::NotifyTextChatMsg (ChatServiceImpl.cpp:93-122)
→ 查本机 session 表 → session->Send(json, 1019) → TCP → B
关键点:
- 谁在线、在哪台,全靠 Redis
uip_这一张路由表 - 两条路径的 JSON 结构必须一致,客户端 handler 只写一份
- 消息 ID
msgid(uuid)用于送达回执,客户端_pendingMsgs.insert(msgid)等回执(chatwid.cpp:450),服务端原样回显(LogicSystem.cpp:857-861) - 当前不做的:消息不落库、B 不在线直接丢(见「离线消息」)、msgid 不做去重
服务器如何确定用户连接到哪个聊天服务器(用户状态管理)
核心是 Redis 里三张表:
| key | 含义 | 写 | 读 |
|---|---|---|---|
uip_<uid> |
用户在哪台 ChatServer(值 = 服务器名) | 登录成功 Set(ipkey, server_name)(LogicSystem.cpp:419-420) |
每次跨服路由前 Get(LogicSystem.cpp:496,530,801,871,935,1059);断线时 Del(UserMgr.cpp:68) |
logincount (hash) |
每台服务器的在线人数 | 登录 +1、断连 -1、启动清零、退出 HDEL |
StatusServer 负载均衡 HGET(StatusServiceImpl.cpp:76) |
utoken_<uid> |
登录凭证 | StatusServer 签发(StatusServiceImpl.cpp:172-177) |
ChatServer 登录时比对(LogicSystem.cpp:330-341) |
登录时的状态建立(LogicSystem.cpp:403-433):
- 验 token(Redis 比对生效;gRPC 二次校验调了但没判返回值)
logincount++session->SetUserId(uid)+SetUserSession(uid, session)(内存里 uid→session 的索引,UserMgr::_uid_to_session)Set(uip_<uid>, 本服名)(告诉全系统我在这)- 若该账号已在线 → 给旧 session 发 1021 踢下线
UpdateUserStatus(uid, 1)写 MySQL + 广播好友上线
查询「B 在哪」:Get(uip_<B>) → 值 == 本服名 → 查内存 _uid_to_session;值 ≠ 本服名 → gRPC 转发;取不到 → 不在线。
在线状态判定(好友列表里的绿点):两套算法并存,别记混——
- 登录回包 1006 里的
friend_list[].status来自 MySQL:obj["status"] = friend_ele->status(LogicSystem.cpp:399),而friend_ele是GetFriendList→MysqlDao::GetFriendList(select * from friend where self_id=?,MysqlDao.cpp:695)→ 逐个GetUser(friend_id)读user.status字段(MysqlDao.cpp:217)来的;这个字段由登录UpdateUserStatus(uid,1)(LogicSystem.cpp:432)、断连UpdateUserStatus(uid,0)(MysqlDao.cpp:670)维护——所以是「上次落库的值」,不是实时探测 - 主动刷新好友列表
RefreshFriendList才是实时算的:obj["status"] = ExistsKey(uip_<uid>)(LogicSystem.cpp:1029) - 好友申请通知里附带的 status 同样是
ExistsKey(LogicSystem.cpp:511,557,在AddFriend里);跨服副本在ChatServiceImpl.cpp:41,81
三处都是判断 Redis key 是否存在(:1029/511/557/ChatServiceImpl:41,81)而不是查内存 session,只有登录那一处是查 MySQL。
缺陷(主动讲):uip_ 无 TTL,客户端进程崩溃不会清理 → 对端会一直显示「在线」,消息还会被路由到那台服务器然后因为 session 为空被丢弃。改进:uip_ 设 TTL + 心跳续期,或登录时校验 uip_ 指向的服务器是否还活着。
如何实现发送验证码功能
完整链路(4 步):
① 客户端:HttpMgr::PostHttpReq(ID_GET_VARIFYCODE, {email}) → POST /get_varifycode
② GateServer(LogicSystem.cpp:20-54):
解析 JSON → 校验 email 字段存在
→ VarifyGrpcClient::GetVarifyCode(email) (连接池取 stub,池 5)
③ VarifyServer(server.js:12-23):
a. 先查 Redis Get(code_<email>)——已有值就直接复用,不重发 ← 防刷
b. 没有则 uuidv4() 取前 4 位 → toUpperCase() → 4 位十六进制大写码(server.js:17-22)
c. SetRedisExpire(code_<email>, code, 600) ← 10 分钟过期
d. nodemailer 走 smtp.163.com:465 SSL 发邮件(text + html 双格式)
④ 客户端收到后填码,注册/改密时随表单提交
GateServer 校验:Get(code_<email>) 取出比对(LogicSystem.cpp:86-95)
取不到 → 错误码 VarifyExpired(1003) ← 过期或从未生成
不相等 → 错误码 VarifyCodeErr(1004)
相等 → 调存储过程 reg_user / UpdatePwd
设计要点(面试展开):
- 验证码存 Redis 不存 MySQL:高频读写 + 需要自动过期(TTL 600s)
- 为什么不把验证码返回给客户端:
/get_varifycode只回error+email(LogicSystem.cpp:49-52),验证码只能通过邮件到达 —— 防接口被刷成「邮箱轰炸器」 - 防刷:已有码复用不重发(
server.js:15-16);再完善应加:同 IP 限频、发送次数上限、图形验证码前置 - 4 位码的熵(要诚实):uuidv4 前 4 位是十六进制字符,16^4 ≈ 6.5 万种,10 分钟窗口内配合「已有码复用」还算够用,但服务端没有失败次数限制,理论上可被穷举 —— 这是个可以主动提的改进点(连续错 5 次作废 + 换 6 位或字母数字集)
SET+EXPIRE两条命令非原子(redis.js:69-80),极端情况下进程崩在两条命令之间会留下永不过期的 key —— 应改成SET key value EX 600
项目背景,怎么学的,怎么选择的这个开源项目
注意:这不是「选了个开源项目改改」,是从零自己写的,回答时要明确这点(面试官问「怎么选择开源项目」往往是在试探你是不是只抄了)。
怎么学的(学习路径,答出递进感):
- 先打地基:TCP/IP(粘包、TIME_WAIT、阻塞/非阻塞)、Linux IO 模型(select → poll → epoll → Reactor/Proactor)
- 再啃框架:Boost.Asio 官方文档 + examples,重点理解
async_*回调模型和io_context的事件循环 - 然后做最小闭环:单进程 echo server → 加协议头解决粘包 → 加线程池 → 加连接管理,每一步都能自己测出来
- 遇到分布式问题才拆服务:单机做完后自然产生「多机怎么办」「消息怎么跨机送达」的问题,于是引入 gRPC、Redis 路由、负载均衡
技术选型的参考来源:
- 网络层:参考了业界成熟方案的通用设计(Reactor、连接池、生产者消费者),实现是自己写的
- 协议:参考 IM 通用做法(定长头 + 长度字段),这是行业标准解法
- 架构:网关/状态/聊天的拆分参考微服务通用模式
面试话术模板:「项目是自己从零写的,不是 fork 开源项目。学习路径是协议 → 单机框架 → 分布式问题驱动的架构演进,每一步都是遇到具体问题才引入对应技术(比如遇到跨机消息发不出去才做了 Redis 路由)。」
关键:不要说「我参考了某个 IM 开源项目然后改的」——如果真这么做了,面试官会一路问下去你改了什么、原作者为什么那么写,很容易穿帮。
为什么需要网关服务,没有行不行
没有行不行?——功能上可以(客户端直接连 StatusServer/ChatServer 也行),但架构上会丢掉 4 个关键能力:
- 统一入口 = 统一安全策略:限流、防刷、日志、审计、后续加 WAF/签名验签,只需要在一处做。如果每个客户端直接连 4 个服务,这 4 件事要做 4 遍且不一致
- 协议收敛:对外只暴露 HTTP(客户端好接、防火墙好放行),内部服务间用 gRPC(高性能强契约)。网关就是协议转换层——
HttpConnection解 HTTP,VarifyGrpcClient/StatusGrpcClient发 gRPC - 隐藏内部拓扑:客户端只知道网关地址,不知道 StatusServer/MySQL/Redis 的存在。服务扩缩容、换端口、加实例,客户端不用发版
- 无状态可水平扩展:网关不持有会话状态(token 在 Redis,分配结果靠 gRPC),挂了直接再起一个,前面挂个 SLB 即可
本项目的具体收益:登录流程里「查 MySQL 校验密码」和「调 StatusServer 分配机器」这两步耦合在网关,客户端只需要一次 HTTP 往返就拿到全部连接信息,而不需要自己先调 Status 再调别的。
代价(可以提):多一跳网络、多一个故障点。缓解方式是网关无状态多实例 + 健康检查。
面试金句:网关解决的是「把 N×M 的服务暴露关系收敛成 N×1」——没有它,客户端要直接对接所有服务,耦合和安全面都爆炸。
以什么标准进行服务拆分呢
四条标准(「微服务拆分与职责边界」的展开版):
- 按变化速率拆:变更频繁的和稳定的分开。本项目:网关协议稳定、聊天业务高频迭代 → 分开;这样改聊天功能不用动网关、不用重新过安全评审
- 按技术栈拆:不同问题用最合适的工具。邮件发送用 Node 的 nodemailer 生态(C++ 没那么顺手);后台管理用 Go(并发 + 快速开发);实时链路用 C++(性能 + Asio)
- 按扩展维度拆:只有需要水平扩展的部分才单独成服务。ChatServer 是唯一会随用户量线性增长的,单独拆出来才能只加这一层的机器;StatusServer/VarifyServer 是低频服务,单实例足够
- 按故障域拆:不希望一起挂的就分开。验证码服务挂了,聊天应该照常能用(只有新注册不可用);后台挂了,线上用户无感
反过来,不该拆的信号(面试加分——说明你知道拆分的代价):
- 两个服务总是一起变更 → 应该合
- 拆开后必须频繁跨服务调用才能完成一个业务 → 微服务变成了分布式单体,比不拆还糟
- 团队只有一个人维护 → 运维成本 > 收益
本项目的落地检验:每个服务能用一句话说清职责(「各服务器职能」的表),且代码上确实是独立进程独立 config.ini —— 但也付出了代价(proto/基础设施 4 份副本),说明拆服务必须同时抽公共库,否则是假拆。
如何实现断线重连
客户端重连的完整实现(tcpmgr.cpp:54-69):
// 断开时:启动退避定时器
QObject::connect(&_socket, &QTcpSocket::disconnected, [&]() {
emit sig_link_state(false);
if (!_kicked && !_host.isEmpty()) {
_reconnectTimer->start(_reconnectDelay);
_reconnectDelay = qMin(_reconnectDelay * 2, 30000); // 指数退避封顶 30s
}
});
// 定时器到点:真正重连
_reconnectTimer->setSingleShot(true);
connect(_reconnectTimer, &QTimer::timeout, [this]() {
if (!_kicked && _socket.state() == QAbstractSocket::UnconnectedState) {
_socket.connectToHost(_host, _port);
}
});
五个设计要点:
- 指数退避:3s → 6s → 12s → 24s → 30s 封顶。避免服务端故障时所有客户端同时重连造成惊群
- 连接成功复位:
connected回调里_reconnectDelay = 3000复位(tcpmgr.cpp:22) - 被踢不重连:
_kicked标志(收到 1021 顶号通知或主动退出时置 true,tcpmgr.cpp:334,525),只有重新走登录流程才复位(tcpmgr.cpp:532)—— 否则会陷入「连上→被踢→重连」死循环 - 清理残留状态:
connected时_buffer.clear()(tcpmgr.cpp:21),丢弃上一条连接的半帧,避免脏数据污染新连接 - 重连后要重新登录:
connected→emit sig_con_success→ login 界面重新发 1005 CHAT_LOGIN(login.cpp:316-330),因为服务端已经把旧 session 清掉了,TCP 连上 ≠ 登录态恢复
未实现的部分(诚实 + 补充方案):
- 服务端没有心跳,半开连接(NAT 超时、网线拔了)服务端感知不到 → 应加 30s 心跳 + 服务端超时清理
- 退避没有加随机抖动,大规模断网恢复时客户端会同时发起重连 → 应在基础退避上加 ±20% 随机
errorOccurred只打日志不触发重连(tcpmgr.cpp:48-52),重连完全依赖disconnected信号
如何获得服务器性能的(测试)
先说事实:仓库里没有压测脚本/数据(见「怎么测出单服务器连接数」),所以这题要答方法论,把「我会怎么测」讲得可执行。
测试分四层,每层有明确指标:
| 层 | 指标 | 怎么测 |
|---|---|---|
| 连接容量 | 最大并发连接数、建连速率 | 压测客户端用 epoll 模拟 N 条连接,只 connect+登录,逐步加压直到 connect 失败/超时;同时监控 fd 数、内存 |
| 吞吐 | 消息条数/秒(TPS) | 连接建立后并发发 1017 文本消息,统计服务端处理数和客户端收到数,注意回执要算进去 |
| 延迟 | 端到端 P50/P99 延迟 | 消息里带客户端时间戳,接收端算差值;或服务端日志打 recv → dispatch → send 三个时间点,定位瓶颈在队列还是网络 |
| 稳定性 | 长跑下内存/句柄泄漏 | 24h 长跑,观察 RSS 是否单调增长、连接数是否只增不减 |
关键方法论(面试官真正想听的):
- 控制变量:先测「只建连不发消息」的纯连接容量,再测「建连 + 消息」,两者差值就是消息处理的开销
- 找拐点而不是报数字:压到延迟陡增/错误率上升的那一点就是容量上限,比「我支持 8000」有意义得多
- 瓶颈定位:
perf top/ 火焰图看 CPU 热点;iostat看是否卡在磁盘;RedisINFO commandstats看是否 Redis 成瓶颈;队列长度看是否逻辑线程积压 - 区分两类指标:空闲连接容量(内存+fd 决定)和 活跃消息吞吐(CPU+逻辑线程决定)——简历上的 8000 属于前者
针对本项目的具体瓶颈假设(可作为测试前的预测):
- 单逻辑线程 + 持锁执行回调(
LogicSystem.cpp:45-77)→ 预计消息量上去后最先饱和 - 同步 gRPC 无 deadline → 跨服消息占比高时会显著拉高 P99
tcp 长连接的作用
三个不可替代的作用:
- 服务端主动推送(最核心)——HTTP/1.1 是一问一答,服务端无法主动说话。有了长连接:
- 好友上线/下线实时推(
ID_NOTIFY_FRIEND_STATUS_REQ 1035) - 收到新消息实时推(
ID_NOTIFY_TEXT_CHAT_MSG_REQ 1019) - 有人申请加好友实时推(
ID_NOTIFY_ADD_FRIEND_REQ 1011) - 被顶号踢下线通知(
ID_NOTIFY_OFF_LINE_REQ 1021) - 音视频通话信令(
ID_NOTIFY_CALL_SIG_REQ 1048)
- 好友上线/下线实时推(
- 省掉反复建连的开销:TCP 三次握手 + 可能的 TLS 至少 1-2 个 RTT。聊天是高频交互,每次消息都握手会让延迟翻倍
- 连接即会话(连接状态可复用):服务端在 session 上直接绑定了
uid(session->SetUserId),后续消息不需要每次都带身份认证,也不用每条消息查 token —— 连接本身就是登录态
长连接的代价(面试一定会追问):
- 服务端要维护海量 fd 和内存 → 所以要 io_context 池 + 连接数上限
- 连接可能假死(NAT 超时、网络中断)→ 需要心跳(本项目预留了 1023/1024 但未实现)
- 服务端重启会全部断开 → 所以客户端必须有自动重连(「如何实现断线重连」)
- 负载变化时连接不会自动迁移 → 用户永远黏在登录时分配的那台机器上
如何解决大量 tcp 连接的性能问题,如果用户登录后长时间没有请求(心跳机制),如果又突然要发消息了,需要重新登陆吗
第一问:大量 TCP 连接的性能问题(四层解法)
- IO 层:多
io_context+ 每个单线程,round-robin 分摊(本项目已做,AsioIOServicePool);避免「一个大锁串行所有回调」 - 内存层:按需分配缓冲(本项目每 session 固定 8KB 收缓冲,可改成
flat_buffer按需增长);发送队列设上限防 OOM(MAX_SENDQUE=1000) - 业务层:网络与业务解耦(生产消费队列),别在 IO 线程里做慢操作;本项目业务层单线程是瓶颈 → 改成按 uid 分片的多 worker
- 系统层:
ulimit -n、TCP 参数调优(somaxconn、tcp_tw_reuse、SO_REUSEPORT多进程)、网卡中断绑核(RSS/RPS)
第二问:长时间没请求,靠什么保活
- 正确答案是心跳,但本项目没实现:
ID_HEART_BEAT_REQ(1023)/RSP(1024)只有枚举(const.h:84-85),服务端RegisterCallBacks没注册 handler,客户端也不发 - 现在的实际行为:长时间没请求 → 什么也不做。风险有二:① NAT/防火墙在 60s~几分钟不活动后单方面回收连接,服务端因没有读超时检测会一直留着死 session(直到某次写失败才触发
Close);② 客户端崩了服务端不知道,uip_残留导致别人发给他的消息被路由过来又丢掉 - 应该实现的方案:
- 客户端每 30s 发一个 1023(只有 msg_id + 空 body,几乎零开销)
- 服务端用
steady_timer记录每 session 最后活跃时间,超过 90s(3 个周期)Close()并走ClearSession清理 - 顺带解决半开连接检测
第三问:突然要发消息,需要重新登录吗
- TCP 连接还活着 → 不需要。长连接的意义就是连接即会话,session 上绑着 uid(
session->GetUserId()),直接发 1017 即可 - TCP 还在但服务端认为你掉了(比如心跳超时被 Close)→ 需要。客户端重连后必须重发 1005 CHAT_LOGIN,因为服务端的
_uid_to_session已经把你移除了 - 本项目的实际情况:客户端
disconnected→ 退避重连 →connected→ 重新走 1005 登录(login.cpp:316-330),token 还在内存里有效(utoken_无 TTL),所以是「重连 + 重发登录包」而不是「重新输密码走 HTTP」——这是两者的区别,要答清楚
gatesever 是怎么实现的
三层结构(Acceptor → HttpConnection → LogicSystem 路由):
① Acceptor(GateServer/CServer.cpp:5-34)——构造函数空实现(只初始化 _ioc/_acceptor,不 Start()),Start() 由 main 里 std::make_shared<CServer>(ioc, port)->Start() 调(GateServer.cpp:122):
CServer::CServer(net::io_context& ioc, unsigned short port): _ioc(ioc),
_acceptor(ioc, tcp::endpoint(tcp::v4(), port))
{
}
void CServer::Start()
{
auto self = shared_from_this(); // 先把自身引用抓在手里
auto& io_context = AsioIOServicePool::GetInstance()->GetIOService();
auto new_con = std::make_shared<HttpConnection>(io_context);
_acceptor.async_accept(new_con->GetSocket(), [self, new_con](beast::error_code ec) {
try {
if (ec) { self->Start(); return; } // 失败也续接下一个 accept
new_con->Start(); // 交给 HttpConnection 自己去 async_read
self->Start(); // 递归式地续接(不是尾递归,是新的异步操作)
} catch (std::exception& exp) {
std::cout << "exception is " << exp.what() << std::endl;
self->Start();
}
});
}
要点:self 是 shared_from_this() 的捕获(构造体里没有 self 这个东西,必须在 Start() 内取),new_con 每次新建、io_context 从池里轮转取,accept 成功/失败都会重新 Start() 形成「永远有下一个 accept 在飞」。
② Session(HttpConnection.cpp)
Start()→http::async_read→HandleReq()→Start()(递归读下一个请求)HandleReq()(:142-176):设置_response.version→keep_alive(false)(:145)→ 按 method 分支- GET:
PreParseGetParam()解析 query →LogicSystem::HandleGet(url, conn) - POST:
LogicSystem::HandlePost(_request.target(), conn)(:164)
- GET:
- 写回:设
content_length→http::async_write→shutdown_send半关闭(:135-138) - 有 60s
steady_timerdeadline(HttpConnection.h:22),但CheckDeadline()全项目无调用(死代码)
③ 路由表(LogicSystem.cpp)
typedef function<void(shared_ptr<HttpConnection>)> HttpHandler- 两张表
map<string, HttpHandler>,RegGet/RegPost注册、HandleGet/HandlePost查表 - 构造函数集中注册 6 条:
/get_test(GET)、/get_varifycode、/user_register、/reset_pwd、/user_login、/mod_profile(POST)
handler 做什么(以 /user_login 为例):取 body → jsoncpp 解析 → MysqlMgr::CheckPwd → StatusGrpcClient::GetChatServer → 拼 JSON 写响应。
注意的坑(可主动讲):
keep_alive(false)+shutdown_send→ 实际是短连接,HTTP/1.1 keep-alive 被关掉,每请求都要重新握手- POST 路由匹配用原始
_request.target()(含 query),/user_login?x=1会 404 - handler 同步阻塞(MySQL + gRPC 都在 IO 线程里跑),没有 ChatServer 那样的独立逻辑线程 → GateServer 并发上限 ≈ CPU 核数个在途请求
asio 网络库底层原理
核心:Proactor 模式(完成事件驱动),不是 Reactor(就绪事件驱动):
| Reactor (epoll) | Proactor (Asio) | |
|---|---|---|
| 内核告诉你 | 「fd 可读了」(就绪) | 「读完成了,数据在这」(完成) |
| 实际读取 | 你拿到通知后自己调 read() | 内核已经把数据读进你的 buffer |
| 框架代表 | libevent、libuv、手写 epoll | Boost.Asio、Windows IOCP |
io_context 底层怎么工作:
- Windows:
IOCP(PostQueuedCompletionStatus+GetQueuedCompletionStatus),真正的内核级 Proactor - Linux:Asio 用 epoll 模拟 Proactor——
async_read_some实际是「注册 epoll 事件,等 EPOLLIN 后在事件循环里调::read(),再把结果打包成完成事件调你的回调」 - 事件循环:
run()就是取完成队列 → 执行 handler → 没事件就阻塞在epoll_wait - 任务队列:
post()/dispatch()投 handler 进队列,run()的线程取出执行 executor_work_guard:空闲时阻止run()返回的假任务,reset()后run()才退出(AsioIOServicePool.cpp:6-8)
单线程 vs 多线程下的 io_context(对应「io_context 单线程 vs 多线程」):
- 单线程 run():所有 handler 串行,天然无并发问题,但一个慢 handler 卡住全部
- 同一个 io_context 多线程 run():handler 可能并发,同一 socket 的多个 handler 可能在不同线程跑 → 必须用 strand 串行化
- 本项目的选择:多个 io_context,每个只 1 个线程(
AsioIOServicePool.cpp:11-15)→ 等价于多个单线程事件循环,同一连接 handler 天然串行,既拿多核并行又不用 strand——这是本项目最聪明的设计点
简历上的连接数,具体是通过什么实现的,多线程?异步 io?还是其他?
答案是「异步 IO + 多个单线程事件循环 + 连接池」三者协作,不是单靠多线程撑起来的:
- 异步 IO 是根本:
async_accept/async_read_some/async_write全非阻塞,一条空闲连接不占任何线程、不做任何阻塞等待——这是能到 8000 的前提。同步模型下 8000 连接 = 8000 线程,根本不可能 - 多线程负责并行:
AsioIOServicePool起hardware_concurrency()个 io_context、每个一个线程,新连接 round-robin 分配(AsioIOServicePool.cpp:22-28)→ 8000 个连接的回调摊到所有核 - 单线程的连接上下文:每个 io_context 只 1 个线程 → 同一连接读写状态机串行,不用锁不用 strand
- 连接池吸收下游压力:MySQL/Redis/gRPC 各 5 条连接被 8000 个连接复用,而不是每连接各建下游连接
资源账(会被追问):
- 内存:每连接一个
CSession+ 收缓冲char _data[8192](CSession.h:51)+ 发送队列(上限 1000)→ 8000 连接约几十 MB - fd:8000 个 socket,Linux 必须先调
ulimit -n - CPU:空闲连接几乎零 CPU,成本全在消息量上
一句话:异步 IO 解决「一条连接不占线程」,多线程解决「多条连接并行处理」,连接池解决「下游资源复用」——三者缺一不可,但把连接数顶上去的功劳主要归异步 IO。
不同客户端之间实现消息转发的工作原理
核心:服务端是「消息路由器」,不是「消息终点」。
四步转发:
- 寻址:A 的 ChatServer 收到 1017,取
touid查 Redisuip_<touid>(LogicSystem.cpp:871-873)得 B 所在服务器名 - 分支:
- 同机:查内存
UserMgr::_uid_to_session拿到 B 的CSession→session->Send(json, 1019)(LogicSystem.cpp:881-890)——进程内函数调用 - 跨机:组 protobuf
TextChatMsgReq{fromuid, touid, repeated TextChatData}→ChatGrpcClient::NotifyTextChatMsg(目标服名, req)(LogicSystem.cpp:908)→ 发到对端 50056
- 同机:查内存
- 落地:对端
ChatServiceImpl::NotifyTextChatMsg(ChatServiceImpl.cpp:93-122)→ 查本机 session → 有就Send(1019),没有直接return Status::OK(消息丢弃) - 回执:同时给 A 回 1018(原样回显
text_array),A 按msgid标气泡已送达
三个基础设施:
| 基础设施 | 作用 | 位置 |
|---|---|---|
Redis uip_ 路由表 |
全局「谁在哪」 | LogicSystem.cpp:419 写、:871 读 |
| gRPC 连接池 | 机器间通道,按 peer 名各 5 条 stub | ChatGrpcClient.cpp:215-235 |
UserMgr 内存 session 表 |
本机「谁连着我」 | UserMgr.h:21 _uid_to_session |
两条路径一致性:最终都拼成同样结构的 JSON 下发,客户端 handler(tcpmgr.cpp:433)只写一份,感知不到走了哪条路径。
已知缺陷(主动讲):目标不在线 → 消息直接丢弃,不落库不重试——同机路径是 GetSession(touid) 为空就什么都不发(LogicSystem.cpp:882-887)、跨机路径是 session == nullptr 直接 return Status::OK(ChatServiceImpl.cpp:101-103,NotifyTextChatMsg 里),Redis 里根本查不到 uip_ 也直接 return(LogicSystem.cpp:873-876)——没有离线消息。
消息发送的协议是自定义的还是用了什么
分层回答(别一上来就说「自定义」):
| 层 | 协议 | 是不是自定义 |
|---|---|---|
| 传输层 | TCP | 标准 |
| 客户端 ↔ ChatServer | 自定义应用层协议:4 字节定长头 + JSON body | 是,自己定义的 |
| 服务间(gRPC) | HTTP/2 + Protobuf | 标准(proto schema 是自己写的) |
| 客户端 ↔ GateServer | HTTP/1.1 + JSON | 标准 |
自定义协议具体定义:
偏移 0-1: msg_id (uint16, 大端) 消息类型,取值 const.h 的 MSG_IDS
偏移 2-3: length (uint16, 大端) body 字节数
偏移 4+: body (length 字节) UTF-8 JSON
上限 8KB(MAX_LENGTH,const.h:58)
为什么自己定义:
- 现有库不给消息边界:Asio/QTcpSocket 都是字节流,必须自己声明长度(这是自定义协议的唯一必要性)
- JSON 可读性:抓包/日志直接看懂,不用配 proto 描述文件
- 轻量:IM 消息字段少(fromuid/touid/content),protobuf 优势发挥不出来,4 字节头更省
为什么不用现成协议(被追问时):
- 不用裸 HTTP:服务端要主动推送(见「tcp 长连接的作用」)
- 不用 MQTT:那是物联网发布/订阅模型,IM 是点对点 + 好友关系,引入 broker 反而多一层
- 不用 WebSocket:原生桌面客户端不是浏览器,直接 TCP 更简单(WebSocket 握手/帧头在非浏览器场景是纯开销)
诚实补充:2 字节 length 意味着理论上限 65535、实际被 MAX_LENGTH=8KB 卡死;协议没有版本号字段,升级只能靠 msg_id 扩展——可改进点。
为什么要将登录和聊天分开不同的服务器
- 扩展维度完全不同:登录是低频瞬时(查库 + 分配机器 + 签 token),聊天是高频持续(每秒上万条)。绑在一起,聊天量暴涨会把登录拖垮。拆开后只需给 ChatServer 加机器,网关单实例就够
- 生命周期不同:登录是短连接(HTTP 一次往返),聊天是长连接(持续数小时)。长连接的机器扛 fd 和内存,短连接的机器扛并发请求——容量规划指标完全不同
- 故障域与发布节奏:登录链路涉及 MySQL/验证码/负载均衡,变更谨慎;聊天业务迭代频繁。发聊天新功能不用重启登录服务,用户不掉线
- 职责边界清晰:登录回答「你是谁、去哪台机器」(一次性决策),聊天回答「消息怎么送达」(持续服务)。不拆就会出现「登录逻辑混着 session 管理」「聊天逻辑混着密码校验」的纠缠
本项目的实际验证:GateServer 无状态(token 在 Redis,分配结果来自 StatusServer)→ 可随意重启/多开;ChatServer 有状态(_uid_to_session 在内存)→ 要靠 Redis uip_ 让别人找得到。「把有状态的东西集中到能水平扩展的那一层」是这道题的本质。
不同聊天服务器,是物理上的分离,还是逻辑上的分离
两者都做到了,要说清层次:
物理分离(部署形态):
- ChatServer 和 ChatServer2 是两个独立进程,各自监听不同端口(TCP 8090/8091,gRPC 50055/50056)
- 各自有
config.ini([SelfServer]段区分身份:Name=chatserver1/2) - 可部署在不同机器(
[PeerServer] Servers=只列对端名字,地址写在同名子段[chatserver2] Host/Port) - 代码是同一份:
LogicSystem/MysqlDao/CSession/ChatServiceImpl等核心文件与 ChatServer2 逐字节相同(MD5 一致);只有ChatServer.cpp差一行注释、CSession.cpp差一行空行,没有任何功能差异,真正的差异全在config.ini
逻辑分离(架构隔离):
- 每台机器的用户 session 只在本进程内存(
UserMgr::_uid_to_session),互相看不见 - 通过 Redis
uip_这张全局路由表才知道对方在哪——这就是逻辑分片 - 跨片通信必须显式走 gRPC,不存在共享内存
扩展视角(面试官真正想问的):
- 同一台机器跑多个实例(改端口和 Name)→ 逻辑分离
- 多台机器各跑一个 → 物理分离
- 本项目让两种部署方式无缝切换:代码零改动,只改
config.ini - 上到 K8s:每 Pod 一个 ChatServer + Service 做服务发现,就是云原生的物理分离
一句话:代码同一份(逻辑同构分片),进程部署独立(物理可分离),靠配置决定身份——「无状态镜像 + 有状态路由」的经典做法。
登录和聊天分别是用什么协议
| 阶段 | 协议 | 端口 | 序列化 | 为什么 |
|---|---|---|---|---|
| 登录/注册/改密/验证码 | HTTP/1.1(POST JSON) | 8080 | JSON | 一次性请求-响应、过防火墙/代理、可 curl 测试、网关可水平扩展 |
| 聊天(登录后所有实时消息) | 自定义 TCP(4 字节头 + JSON) | 8090/8091 | JSON | 服务端要主动推送、长连接复用、msg_id 分发到 handler |
| 服务间调用 | gRPC over HTTP/2 | 50051/50052/50055/50056 | Protobuf | 二进制高效、强契约、多语言、自动生成 stub |
| 邮件验证码 | SMTP(163 邮箱) | 465 SSL | MIME | 邮件标准协议 |
关键区分点:
- 登录为什么不用 TCP:登录时客户端还不知道连哪台 ChatServer(分配结果就是登录的返回值),必须先有一个「已知地址」的入口——网关的固定 HTTP 端口就是。鸡生蛋问题的解法:HTTP 换票 → 拿地址 → 再建长连接
- 聊天为什么不用 HTTP:服务端要主动推送(好友上线、新消息),HTTP/1.1 一问一答,长连接是唯一自然的解
- 服务间为什么不用 HTTP+JSON:高频内部调用 + 要强类型,protobuf 快一个量级
登录的具体流程
完整 9 步(必背题,要能一口气说清):
【HTTP 阶段:拿连接凭证】
① Qt 客户端:HttpMgr POST /user_login {user, passwd(SHA256)}
httpmgr.cpp:14-38,login.cpp:274-278
② GateServer 解析 body → 路由到 /user_login (LogicSystem.cpp:195)
③ 查 MySQL:SELECT * FROM user WHERE name=? 比对 pwd (MysqlDao.cpp:173,187)
失败 → PasswdInvalid(1009)
④ gRPC → StatusServer.GetChatServer(uid) (LogicSystem.cpp:225)
a. 线性扫描 Redis hash logincount 找在线数最少的 ChatServer
(StatusServiceImpl.cpp:73-104;field 不存在 → INT_MAX 不选)
b. boost::uuids::random_generator() 生成 token
c. SET utoken_<uid> <token> (StatusServiceImpl.cpp:172-177)
d. 返回 {host, port, token}
⑤ GateServer 回 200:{error:0, uid, user, email, token, host, port}
(LogicSystem.cpp:235-243)
【TCP 阶段:建立会话】
⑥ 客户端 connectToHost(host, port) (tcpmgr.cpp:530-537)
⑦ connected 信号 → 发 1005 CHAT_LOGIN {uid, token} (login.cpp:316-330)
⑧ ChatServer LoginHandler (LogicSystem.cpp:313-435):
a. Redis 比对 utoken_<uid> (:330-341)
b. gRPC StatusService.Login (:346,返回值未检查→不生效)
c. 拉资料/好友申请/策略/好友列表 (:350-401)
d. logincount++ (:406-414)
e. session->SetUserId + SetUserSession (:417,431)
f. SET uip_<uid> = 本服名 (:419-420)
g. 已在线 → 给旧连接发 1021 + Close (:423-430)
h. MySQL status=1 + 广播好友上线 (:432-433)
⑨ 回 1006 CHAT_LOGIN_RSP {error, uid, name, apply_list, friend_list...}
客户端 → emit sig_switch_chatdlg → 进主界面 (tcpmgr.cpp:119)
为什么 token 验两次(Redis + gRPC):Redis 是快速判定(省一次网络调用),gRPC 是权威判定(StatusServer 是登录态 owner,可扩展校验多端状态)。当前两次内容相同属冗余,可说成「性能层 + 权威层」,也可主动承认「可以合并省一次 RPC」。
每个聊天服务器都是单独的 ip,如果一个服务器突然挂了,如何做到无损地切换服务器
先诚实说明现状:项目里没有实现无损切换。逐项证据:
| 能力 | 结论 | 证据 |
|---|---|---|
| peer 探活 | 没有 | 连接池只在构造时建一次,永不重建不探测(ChatGrpcClient.cpp:215-235) |
| gRPC 失败重试 | 没有 | 全是单次调用 + 判 status.ok(),无退避;更糟的是 NotifyAddFriend 的 Defer(ChatGrpcClient.cpp:60-64)会把 :79-82 写下的 RPCFailed 覆写风险(是否真覆写取决于 NRVO),且三个调用方都丢弃返回值 → 失败照样被吞 |
| session 迁移 | 没有 | 对端 session 空直接 return OK,文本消息丢在 ChatServiceImpl.cpp:101-103(好友申请那条同样是 :28-30) |
| 客户端重连到别的机器 | 没有 | _host/_port 只在登录时赋值一次,重连永远连回同一台(tcpmgr.cpp:533-535,67) |
| 心跳 | 没有 | 1023 只有枚举,服务端无 handler、无定时器 |
uip_ 清理 |
崩溃不清理 | 无 TTL,Del 只在正常断连时执行(UserMgr.cpp:68) |
真实故障表现:机器挂 → 该机用户全掉线 → 客户端退避重连 → 连的还是死机器 → 永远连不上 → 必须手动重新走 HTTP 登录;同时 Redis 里 uip_ 残留指向死机器,别人发的消息转过去被丢弃。
如果让我做,四层方案:
- 心跳 + 超时摘除(最快见效)
- 客户端 30s 心跳,服务端 90s 无活动
Close() uip_设 TTL 90s 并随心跳续期 → 机器挂了 90s 后 key 自动过期 → 路由自然失效
- 客户端 30s 心跳,服务端 90s 无活动
- 客户端感知 + 重新分配
- 重连失败 N 次 → 客户端重新走 HTTP
/user_login,让 StatusServer 分配健康机器 - StatusServer 侧用 gRPC health check 标记不健康节点,
getChatServer()跳过 - 拿到新 host/port → 只需重发 1005,不用重新输密码(token 还有效)
- 重连失败 N 次 → 客户端重新走 HTTP
- 登录态不丢(本项目已做对)
- token 存 Redis(
utoken_)不在 ChatServer 内存 → 换机器后照常验 token,登录态是全局的 - 这是能做无损切换的基础
- token 存 Redis(
- 消息不丢(最难)
- 离线消息表:目标不在线时落
offline_msg,登录时拉取 + ACK 后删(「离线消息」方案) - 或会话副本:每个用户留一个备份节点,主挂从接管
- 极致:消息队列 + 消费位点(Kafka 模式),新机器上线后继续消费
- 离线消息表:目标不在线时落
分级回答:
- 快速恢复(分钟级):心跳 TTL 让路由失效 + 客户端重新登录换机器 + 离线消息补投 → 用户掉线但消息不丢
- 无感切换(秒级):需要会话状态可迁移 + VIP/DNS 切换 → 工程量大,一般只对核心服务做
面试金句:「无损切换的本质是把有状态的东西从单机内存挪到共享存储——登录态我已经在 Redis 了,缺的是消息持久化和心跳驱动的路由失效。」
项目中用的是私域 ip 还是公网 ip,如何获得一个公网 ip
先答现状:本项目所有 config.ini 里的地址全是 127.0.0.1(GateServer/ChatServer/ChatServer2 三个 config 的 [PeerServer] 子段都是 Host = 127.0.0.1,仓库里没有任何 192.168.x.x)——也就是说当前就是同机多实例的本地开发形态,服务间 gRPC 走回环地址,根本不需要公网 IP;只有客户端连 GateServer 时,如果客户端和服务器不在一个内网,才需要一个公网入口。
「另一台机器怎么访问私网机器」的原理:网关和路由决定一切,「私网」不等于「不可达」。
- 同机房两台机器:配置结构是
[PeerServer]下只有Servers(逗号分隔的对端名字),真正的地址在同名子段里——ChatServer/config.ini实际长这样:
同机房跨机部署就把[PeerServer] Servers = chatserver2 [chatserver2] Name = chatserver2 Host = 127.0.0.1 Port = 50056 ; 对端 gRPC 端口Host改成对方内网 IP(比如192.168.1.22)→ 机器间 gRPC 走内网;ChatGrpcClient构造时按Servers逐个make_unique<ChatConPool>(5, Host, Port)建池(ChatGrpcClient.cpp:215-235) - 跨机房 / 不同网段:内网 IP 天然不可路由,三种做法
- 专线/VPC peering:路由表加一条,把对方网段引到自己的网络
- 公网 IP 映射:
NAT/ 负载均衡器暴露公网:端口,Host填公网地址 - 反向连接:A 连不上 B → 让 B 主动连 A(服务发现里叫「反向拨号」),两边都只出网不入网
- 本地开发:两个实例同机,
Host=127.0.0.1,端口区分(config.ini里Port=8090/8091、gRPC50055/50056)
如何获得一个公网 IP(四种途径,按成本讲):
| 途径 | 做法 | 成本/限制 |
|---|---|---|
| 云服务器 | 买阿里云/腾讯云 ECS,默认分配一个公网 IP | 最简单,按带宽计费 |
| 弹性公网 IP(EIP) | 已有内网机器时单独买 EIP 绑上去,可随时解绑迁移 | 适合「机器可换、IP 不变」 |
| NAT 网关 / 端口映射 | 公司出口路由器做一个端口映射(外网 8080 → 内网服务器) | 家里/自建机房用,要公网 IP 的路由器 |
| 内网穿透 | frp / Cloudflare Tunnel:本机主动连公网中转服务器 | 开发调试用,生产不推荐(流量过第三方) |
关键点:公网 IP 是运营商级可路由地址,家宽通常只有动态公网 IP(甚至运营商 NAT 内网 IP),要固定公网 IP 得申请企业宽带或直接上云。
本项目的代码印证:没有 GetChatStub 这种方法,真实写法是「按名字查池 → 借一条 stub」:
// ChatGrpcClient.cpp:66-73(每个 Notify* 都是这个套路)
auto find_iter = _pools.find(server_ip);
if (find_iter == _pools.end()) { return rsp; } // 找不到直接返回,不报错
auto& pool = find_iter->second;
auto stub = pool->getConnection(); // 池里借一条,Defer 归还
Status status = stub->NotifyAddFriend(&context, req, &rsp);
连接池在构造函数里按配置建:读 [PeerServer] Servers 按逗号拆 → 每个名字去读同名子段的 Name/Host/Port → _pools[Name] = make_unique<ChatConPool>(5, Host, Port)(ChatGrpcClient.cpp:215-235)。地址全部来自配置——代码不知道也不关心 IP 是私网还是公网,只管拿字符串建 channel。这种「配置决定拓扑」是正确做法,换部署环境零改代码。(顺带可讲:参数名叫 server_ip 但传进来的其实是服务器名(chatserver2,取自 Redis uip_),命名是历史遗留。)
常被追问的点:
- 监听
0.0.0.0= 绑所有网卡(含内网网卡),所以内网 IP 也能连上。本项目两处都是这个语义:TCP 是CServer构造_acceptor(io_context, tcp::endpoint(tcp::v4(), port))(ChatServer/CServer.cpp:6-7,不给地址即任意地址);gRPC 是拿配置拼地址cfg["SelfServer"]["Host"] + ":" + cfg["SelfServer"]["RPCPort"]后AddListeningPort(ChatServer.cpp:25-29),config.ini里[SelfServer] Host = 0.0.0.0。ChatServer.cpp:19只是读服务器名(cfg["SelfServer"]["Name"]),跟 bind 无关 - 私网 IP 不是安全措施:同一 VPC 内机器互相可达,真正的隔离要靠安全组/防火墙规则
登录用的是 http 的哪个方法,为什么用 post,不用 get
先直接答:登录用 POST(POST /user_login,GateServer/LogicSystem.cpp:195)。因为登录要改服务器状态(校验通过后签发 token、写在线状态),且密码在请求体里——POST 语义就是「提交数据产生副作用」,GET 把密码放进 URL 会进日志/历史/Referer,是安全反模式。下面把 GET/POST 的区别一次讲清。
语义层(RFC 7231 规定,这是「为什么不同」的根源):
| 维度 | GET | POST |
|---|---|---|
| 语义 | 安全(只读,不改变服务器状态) | 非安全(创建/修改资源,有副作用) |
| 幂等 | 幂等(重复调用结果一样) | 不幂等(重复调用创建多条) |
| 缓存 | 可缓存 | 默认不缓存 |
| 参数位置 | URL query(可书签、可进日志) | body(不进 URL) |
| 浏览器行为 | 直接导航 | 弹「确认重新提交表单」 |
| 大小限制 | URL 通常 2-8KB | body 受服务端配置 |
报文层:两者结构完全一样,都是「请求行 + 头 + 空行 + body」,GET 只是习惯上不带 body。
常见误区(要主动澄清):
- ❌「GET 参数在 URL 所以更安全」——URL 进访问日志、进浏览器历史、被 Referer 带走,比 body 更容易泄露
- ❌「POST 参数加密所以更安全」——都是明文(除非 TLS),body 同样要 TLS 才安全
- ❌「GET 不能传 body」——协议允许,但语义上不该用
本项目中的使用:
- ❗
/get_varifycode是 POST 不是 GET:注册在RegPost("/get_varifycode", ...)(GateServer/LogicSystem.cpp:20-54),客户端也用PostHttpReq(QUrl(gate_url_prefix + "/get_varifycode"), {email})(regsterdialog.cpp:292、resetdialog.cpp:94)。LogicSystem.cpp:180那行在/reset_pwd的 handler 里(RegPost从:125起),不是验证码 POST /user_login、/user_register、/reset_pwd、/mod_profile——改服务器状态、body 含密码,用 POST 正确- 唯一的 GET 是
/get_test(GateServer/LogicSystem.cpp:11-18,调试用,打印 query 参数) - 客户端没有
QUrlQuery:PostHttpReq(QUrl url, QJsonObject json, ...)(httpmgr.cpp:14-38)把 JSON 序列化成 body 直接POST,URL 只拼路径不带 query,所以也不存在「GET 拼参数」的写法
一句话答法:GET 是读、POST 是写——语义差异决定了缓存、幂等、日志、重放这一整套行为差异,「传参位置」只是表象。
项目中 MySQL 主要存储哪些数据
先说事实:仓库里没有任何 .sql 文件(全仓 *.sql 搜索为空),init.sql 不存在;也没有 user.cpp、user_contact_apply 这种东西。表结构只能从 MysqlDao(C++)和 fool_chat_admin_go(Go 后台)实际执行的 SQL 反推,下面是代码里真实出现过的列:
| 表 | 代码里真实出现的列 | 用途 / 证据 |
|---|---|---|
user |
uid, name, pwd, email, nick, desc, sex, icon, status, add_policy |
账号资料:登录 SELECT * FROM user WHERE name=? 比对 pwd(GateServer/MysqlDao.cpp:173,187);nick/desc/sex/icon 读取(ChatServer/MysqlDao.cpp:212-215);status 在线标记(:217 读、:670 写);add_policy 加好友策略(:563,587)。没有 duty 字段(全仓搜不到) |
friend |
self_id, friend_id, back |
双向好友关系:INSERT IGNORE INTO friend(self_id, friend_id, back) 正反各插一行(ChatServer/MysqlDao.cpp:442,463)、查列表 select * from friend where self_id = ?(:695)、改备注 UPDATE friend SET back=? WHERE self_id=? AND friend_id=?(:619)、删除(:738) |
friend_apply |
id, from_uid, to_uid, status, descs |
好友申请:插入 INSERT ... (from_uid,to_uid,descs) ON DUPLICATE KEY UPDATE status=0(:164-165)、登录拉取 join user(:284-296)、同意时 UPDATE friend_apply SET status=1(:419)、删除(:747) |
dynamic |
id, uid, content, image_urls, status, like_count, create_time |
动态:INSERT(:337)、SELECT ... JOIN user 拉列表(:371-372) |
StarNotice |
id, title, author, content, delivered, create_time |
公告(ChatServer/MysqlDao.cpp:509-511),后台写入 fool_chat_admin_go/handler_notice.go:293 |
admin_notice |
id, target_uid, title, content, level, delivered, create_time |
定向/广播通知(MysqlDao.cpp:525-529),建表语句在 Go 侧 fool_chat_admin_go/main.go:75-87(自带 idx_target_uid / idx_delivered / idx_create_time) |
| 后台自建 4 表 | admin_operation_log、admin_application、email_draft、ai_chat_message |
全在 fool_chat_admin_go/main.go(CREATE TABLE IF NOT EXISTS::88、:142、:160、:120) |
总结一句:MySQL 存账号资料 + 好友关系 + 好友申请 + 动态 + 公告/定向通知 + 后台运营数据(强一致、低频读写);Redis 存token/验证码/路由/在线数(高频、可过期);聊天消息当前不落任何存储(见「聊天数据如何产生传递存储」)。
一个要主动说的坑:密码是客户端 SHA256 无盐 → 服务端字符串比对(GateServer/MysqlDao.cpp:187),等于明文存储。正确做法是服务端 bcrypt/scrypt 加盐慢哈希,客户端传明文走 TLS。
聊天过程中的数据如何产生,如何传递,如何存储
产生:客户端输入 → Qt 拼 JSON {fromuid, touid, text_array:[{msgid, content}]}(chatwid.cpp:413-423、retrySendMessage 同款 :437-448;msgid 是 QUuid::createUuid().toString())→ emit sig_send_data(1017, json) → slot_send_data 封 4 字节头(2B msg_id=1017 + 2B len,大端,tcpmgr.cpp:551-562)→ QTcpSocket::write。
传递(服务端三段,LogicSystem.cpp:846-909):
- IO 线程:
async_read_some收字节 →CSession::AsyncReadHead解 4 字节头(CSession.cpp:122-177)→AsyncReadBody拼满 body →PostMsgToQue投递(CSession.cpp:112)。(没有CSession::OnRead、也没有ParseMsg这个函数,JSON 解析是在业务回调里reader.parse(msg_data, root)做的,LogicSystem.cpp:848-850) - 入队:
PostMsgToQue放进LogicSystem的queue + mutex + condvar,唤醒业务线程 - 业务线程:
DealMsg出队 →_fun_callbacks[1017]→DealChatTextMsg(:846):读 body 里的fromuid/touid/text_array(:852-855)→ 查 Redisuip_<touid>→ 同机直接session->Send(1019),跨机ChatGrpcClient::NotifyTextChatMsg→ Defer 回 1018 ACK 给发送方(:863-866)
能主动讲的点:
fromuid是直接读 body 的(:852),服务端没有用 session 上的_user_id去覆盖——即信任客户端声明的发送者,这是可改进点(应该uid = session->GetUserId()为准)。
存储(当前的真实答案):
- 服务端不存:
DealChatTextMsg(LogicSystem.cpp:846)转发完即丢,不写 MySQL 不写 Redis(:873-908);目标不在线直接return,消息丢弃 - 客户端存:本地渲染气泡 + 会话列表(Qt 内存模型,可扩展存 SQLite)
- 服务端重启 = 聊天历史全丢
改进方案(必须能讲):
CREATE TABLE chat_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
from_id INT, to_id INT,
content VARCHAR(2048),
msg_id VARCHAR(36), -- 幂等去重
create_time DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
INDEX idx_to_time (to_id, create_time)
);
- 写入时机:
DealChatTextMsg里先落库再转发(或异步批量写,避免阻塞单线程 LogicSystem) - 离线消息:
chat_history中to_id未 ACK 的行即离线消息,登录 1006 返回 + ACK 后标read(细节见「离线消息」) - 量大了:分库分表(按 uid hash)+ 冷热分离(7 天内热数据 MySQL,历史走 ClickHouse/HBase);视频/图片等大文件走对象存储(OSS/S3)存 URL,绝不进消息表
全链路保存视角:客户端输入 → 封包(4B+JSON) → TCP → LogicSystem 转发 → 对端 session Send → 客户端解包 → 气泡渲染 + 本地存 SQLite/文件。客户端侧有本地记录,服务端侧没有——「双端状态」是回答「离线消息」的基础。
存储聊天数据的表如何设计,如何建立索引
核心三表(「MySQL 主要存哪些数据」已列),这里讲索引设计的推理:
1. user 表
ALTER TABLE user ADD UNIQUE INDEX uk_name (name); -- 登录按 name 查,唯一约束防重名
CheckPwd执行SELECT * FROM user WHERE name=?(GateServer/MysqlDao.cpp:173,比对在:187)→ B+ 树唯一索引,O(log n) 命中- 加 UNIQUE 不只是查询快,更是业务约束:但注册不是应用层
SELECT查重,而是存储过程CALL reg_user(?,?,?,@result)(GateServer/MysqlDao.cpp:38,返回 0/-1 由GateServer/LogicSystem.cpp:104-111判重)——仓库里没有.sql文件,过程体看不到,能诚实说的是「查重逻辑在数据库侧,不在 C++ 里」 email不能按「/get_varifycode按邮箱查」来推理:该 handler 只调 gRPC(GateServer/LogicSystem.cpp:20-54),全程不碰 MySQL;全仓也没有任何按 email 的 SQL(CheckEmail是WHERE name=?再比对 email,GateServer/MysqlDao.cpp:72)。所以 email 加索引目前没有查询支撑,只能是唯一性/后台按邮箱查的预留
2. friend 表(列是 self_id, friend_id, back)
ALTER TABLE friend ADD UNIQUE KEY uk_self_friend (self_id, friend_id);
ALTER TABLE friend ADD INDEX idx_friend_id (friend_id); -- 反向按 friend_id 查
- 真实查询:
select * from friend where self_id = ?(ChatServer/MysqlDao.cpp:695)→ 走(self_id, ...)最左前缀;UPDATE friend SET back=? WHERE self_id=? AND friend_id=?(:619)、DELETE ... WHERE (self_id=? AND friend_id=?) OR (...)(:738)同样命中 - 唯一键是从代码反推的:插入写的是
INSERT IGNORE(:442,463)——只有表上已存在唯一键时IGNORE才有去重意义,否则跟普通INSERT没区别 - 双向两行不会撞键:好友落成
(self=A, friend=B)和(self=B, friend=A)两行,唯一键按self_id分组,self_id=A下friend_id=B只会出现一次——既防重复好友,又不误伤反向那行
3. friend_apply 表(列是 from_uid, to_uid, status, descs)
ALTER TABLE friend_apply ADD INDEX idx_to_uid_id (to_uid, id);
- 登录拉取的真实谓词是
WHERE apply.to_uid = ? AND apply.id > ? ORDER BY apply.id ASC LIMIT ?(ChatServer/MysqlDao.cpp:284-296)——没有status谓词,所以索引按(to_uid, id)建:to_uid等值定位 +id范围/排序一次扫完。不要套(to_uid, status)的「最左前缀两列都用上」那套推理——那是拿不存在的条件当依据 - 另一条路径:同意好友时
UPDATE friend_apply SET status = 1 WHERE (from_uid=? AND to_uid=?) OR (...)(:419-420)→ 需要(from_uid, to_uid);再配合插入的ON DUPLICATE KEY UPDATE status = 0(:164-165)可推断(from_uid, to_uid)唯一键已经存在
4. chat_history(改进方案里的表)
INDEX idx_to_time (to_id, create_time), -- 离线消息按「接收方+时间」拉取
INDEX idx_from_to_time (from_id, to_id, create_time) -- 会话窗口按双方+时间分页
- 聊天记录查询永远是 「某对用户 + 时间范围」,所以
(from_id, to_id, create_time)是主查询索引 - 分页用
WHERE create_time > ? ORDER BY create_time LIMIT 50,走索引避免OFFSET深翻页
为什么不用 Redis 存聊天:消息量大、要范围查询、要持久化——Redis 是 KV,按时间分页要 ZSET 但内存成本高。MySQL 行存储 + B+ 树范围扫描正是「时间序列消息」的正确选择。
通用方法论(回答「怎么加索引」的框架):
- 先写出实际查询的 WHERE/ORDER BY
- 按最左前缀建联合索引,等值列在前、范围/排序列在后
- 覆盖索引让查询不回表
EXPLAIN看type别出现ALL(全表扫)、rows别过大
如果离线消息过大,需要等所有数据发送完之后再删除吗,如果发送过程中服务断掉,那下一次要重复发送吗
先答现状:LogicSystem.cpp:873-876 目标不在线 → return,消息直接丢弃,当前没有离线消息,所以这题答的是设计。两个子问题的答案:① 不是等发完才删,是「接收方 ACK 才删」;② 服务断掉后下次会重发,但靠 msg_id 幂等去重,不会重复展示。
离线消息方案(三选一,讲清楚权衡):
| 方案 | 实现 | 优点 | 缺点 |
|---|---|---|---|
| A. MySQL 离线表(推荐) | chat_history 加 ack_flag,不在线写 flag=0,登录 1006 拉取,客户端收齐回 ACK 更新 |
简单、可持久化、量大够用 | 登录瞬间 IO 压力(分页拉取解决) |
| B. Redis List | LPUSH offline_<uid>,登录 LRANGE + DEL |
读写快 | 内存成本高,宕机丢数据 |
| C. 只存最近 N 条 | 每用户 Redis ZSET 截断 100 条 | 轻量 | 超出丢历史 |
方案 A 细节:
发送链路:DealChatTextMsg
① 查 Redis uip_<touid> 判断是否在线
② 在线 → 转发 + 写 chat_history(ack=0)
③ 离线 → 只写 chat_history(ack=0),不转发
登录链路:LoginHandler
④ SELECT ... WHERE to_id=? AND ack=0 ORDER BY create_time LIMIT 100(分页,防一次拉爆)
⑤ 塞进 1006 的 offline_msg 字段
客户端:收到 1006 里的离线消息 → 渲染 → 回发 1030 ACK{msg_ids}
服务端:UPDATE chat_history SET ack=1 WHERE id IN (...)
子问题 1:离线消息过大,是等发送完再删除吗?
- 删除时机 = 接收方 ACK,不是「发送完」。发送完只代表字节进了 socket 缓冲区,不代表对方收到
- 大消息分块:单条离线消息按 8KB(
MAX_LENGTH)分片,每片带msg_id + seq + total,逐片 ACK 或整条 ACK 后统一标ack=1 - 保守做法:收齐全部分片并渲染成功后回一条总 ACK,服务端才删——宁可重发不可丢
子问题 2:发送过程中服务断掉,下次要重复发送吗?
- 要重发(至少一次语义),但不会重复展示:
- 服务端断掉 → 客户端没收到 → 没回 ACK → 记录仍是
ack=0→ 下次登录照样拉取重发 - 客户端收到但 ACK 没送回去就断了 → 服务端重发 → 客户端按
msg_id查本地已有则丢弃(幂等去重)
- 服务端断掉 → 客户端没收到 → 没回 ACK → 记录仍是
- 这就是 at-least-once + 应用层幂等 = effectively exactly-once,分布式消息系统的标准答法
ACK 机制——三层 ACK:
| 层 | ACK 对象 | 实现 |
|---|---|---|
| 传输层 | 单条消息送达 | msgid 透传,客户端收到回 1018(当前已有雏形:服务端 Send(1018) 是给发送方的回执,Defer 里 session->Send(return_str, ID_TEXT_CHAT_MSG_RSP),LogicSystem.cpp:863-866;客户端 tcpmgr.cpp:399 按 msgid 刷送达状态) |
| 存储层 | 离线消息已拉取 | 上面的 ack=1 |
| 会话层 | 已读回执 | msg_id + read_time,推给对方「对方已读」 |
发送方可靠性(对比 TCP 自身 ACK 要讲清):TCP 已保证字节不丢,但应用层仍需要 ACK——因为「进了 OS 发送缓冲区」≠「进了对方 session」≠「对方客户端渲染了」。TCP ACK 只管第 1 步,应用 ACK 管第 2、3 步,两层是互补不是重复。
去重:msg_id(客户端生成 UUID)+ 服务端按 msg_id 幂等(INSERT IGNORE 或查重),网络重发不会产生重复气泡。
如何实现 A 发消息给 B,服务器如何识别不同客户端并实现消息转发
两步:识别发送方 + 寻址接收方,靠 uid 而不是靠连接猜。
① 识别发送方——session 上绑了 uid,但收消息时用的是 body(这是可改进点):
- 登录 1005 校验 token 通过后
session->SetUserId(uid)(LogicSystem.cpp:417),身份落在CSession::_user_id(CSession.h:50)上——身份来自连接,不来自消息内容,这是正确的一半 - 但实际收消息时服务端取的是 body:聊天
auto uid = root["fromuid"].asInt()(LogicSystem.cpp:852)、加好友root["uid"](:466)、验证好友root["fromuid"](:766)——uid 可伪造,能冒充别人发言,这是实打实的安全缺陷 - 全项目只有刷新好友列表走 session:
int uid = session->GetUserId()(LogicSystem::RefreshFriendList,LogicSystem.cpp:993,注释就写着「以会话绑定的 uid 为准」) - 主动讲法:「正确做法是所有取 uid 处改成
session->GetUserId()(照抄:993),我已定位到:852/:466/:766三处」——指出自己项目缺陷 + 给出改法,比硬说「服务端不信任 body」可信得多
② 寻址接收方——三层查找:
touid
├─ Redis uip_<touid> → 目标在哪台机器 (LogicSystem.cpp:871)
├─ 同机:UserMgr::_uid_to_session[touid] → 目标 CSession (LogicSystem.cpp:881)
└─ 跨机:ChatGrpcClient::NotifyTextChatMsg(机器名) → 对端自查 (LogicSystem.cpp:908)
对端 ChatServiceImpl::NotifyTextChatMsg 再查一次 GetSession(touid)
(ChatServiceImpl.cpp:97;GetSession 实现 UserMgr.cpp:14-23)
③ 回包怎么发给「正确的客户端」:
- 服务端
session->Send(json, 1019)写的是目标 CSession 的 socket(发送队列空时async_write(_socket, ...),CSession.cpp:57;队列里后续帧由HandleWrite续写CSession.cpp:188)——一个 session 对应一个 TCP 连接,物理上就只有那一个客户端能收到 - 客户端
tcpmgr.cpp收到后按msg_id查_handlers分发:1019 →ID_NOTIFY_TEXT_CHAT_MSG_REQ(注册在tcpmgr.cpp:433)→ 回调emit sig_text_chat_msg(...)(:459)→ChatDialog::slot_text_chat_msg(chatdialog.cpp:80)。分发只看 msg_id,渲染气泡头像只看 body 里的fromuid,与「谁的连接」无关
一句话:连接绑定 uid(登录时),uid 查路由(Redis),路由找 session(内存/跨机 gRPC),session 就是唯一的 TCP 通道——消息永远点对点写回那条 socket,不存在广播或猜测。
客户端和服务端的消息是如何封装和解析的,数据包有哪些字段
数据包结构(4 字节头 + JSON body,全双工两个方向完全一致):
偏移 0-1: msg_id (uint16, 大端) 消息类型,取值 const.h 的 MSG_IDS
偏移 2-3: length (uint16, 大端) body 字节数
偏移 4+: body (length 字节) UTF-8 JSON
上限 MAX_LENGTH = 1024*8(8KB,ChatServer/const.h:58)
封装(客户端 → 服务端,TcpMgr::slot_send_data,tcpmgr.cpp:539-569):
QByteArray block;
QDataStream out(&block, QIODevice::WriteOnly);
out.setByteOrder(QDataStream::BigEndian); // tcpmgr.cpp:559(显式声明大端)
out << id << len; // tcpmgr.cpp:562 uint16 id + uint16 len = 4 字节头
block.append(dataBytes); // tcpmgr.cpp:565 body(调用方已序列化好的 JSON)
_socket.write(block); // tcpmgr.cpp:568
- 代码里没有
setVersion(QDataStream::Qt_5_15)——QDataStream只写两个quint16,版本号对它无影响,别背错 - 超长保护:
len只有 16 位,dataBytes.length() > UINT16_MAX直接拒绝不发(tcpmgr.cpp:545-549),否则声明长度 ≠ 实际字节数,对端按声明截取后多出来的字节会被当下一帧头,帧同步永久错位
body 字段按 msg_id 不同(举例):
| msg_id | 方向 | body 字段 |
|---|---|---|
| 1005 CHAT_LOGIN | C→S | {uid, token}(login.cpp:324-330) |
| 1017 TEXT_CHAT | C→S | {fromuid, touid, text_array: [{msgid, content}]}(chatwid.cpp:641-652;服务端也信 body 的 fromuid,LogicSystem.cpp:852) |
| 1006 CHAT_LOGIN_RSP | S→C | {error, uid, name, email, add_policy, apply_list[], friend_list[]}(LogicSystem.cpp:343-401) |
| 1019 TEXT_CHAT_NOTIFY | S→C | {error, fromuid, touid, text_array: [{msgid, content}]}(LogicSystem.cpp:857-861) |
服务端封装:CSession::Send(json, msg_id) 把消息塞进 _send_que(上限 MAX_SENDQUE=1000,const.h:66),头由 SendNode 构造时拼好——host_to_network_short(msg_id) + host_to_network_short(len) + body(MsgNode.cpp:8-17)→ 队列空才 async_write(CSession.cpp:57),完成后 HandleWrite 出队续写(:179-190)。
解析(客户端 tcpmgr.cpp:26-46,readyRead 回调里):
_buffer.append(_socket.readAll()); // 1. 收字节先入缓冲
forever { // 2. 偏移量解析(:32-45)
if (_buffer.size() < 4) return; // 头没齐,buffer 原样等下次
const auto* p = reinterpret_cast<const uchar*>(_buffer.constData());
quint16 msgId = (p[0] << 8) | p[1]; // 直接按字节拼大端,不走 QDataStream
quint16 msgLen = (p[2] << 8) | p[3];
if (_buffer.size() < 4 + msgLen) return; // 3. 半包,不动 buffer 等数据到齐
QByteArray body = _buffer.mid(4, msgLen);
_buffer.remove(0, 4 + msgLen); // 4. 拆走这一帧
handleMsg(ReqId(msgId), msgLen, body); // 5. 按 msg_id 查 _handlers 分发(:512)
}
- 这段有真实踩坑史:旧实现用
QDataStream in(_buffer.left(4))读头再mid()重赋值_buffer,粘包时流位置错位会永久卡在 pending 等一个不存在的长度,之后所有入站消息静默丢——现在改成偏移量直接取p[0..3],注释就留在tcpmgr.cpp:28-31 - 服务端是同一状态机,但没有
CSession::OnRead这个函数(别背错名字):AsyncReadHead(4)收头并network_to_host_short解出 msg_id/msg_len(CSession.cpp:122-171)→AsyncReadBody(msg_len)收满后PostMsgToQue再回头读头(:87-114)。成员是_recv_head_node/_recv_msg_node(CSession.h:57-60),已收长度在MsgNode::_cur_len(MsgNode.h:27),循环补读在asyncReadLen(CSession.cpp:212-233)——半包/粘包就是靠「长度前缀 + 累积缓冲」解决的
客户端是用什么接口进行消息发送的
直接答:QTcpSocket::write(),项目里包了一层 TcpMgr::slot_send_data(ReqId, QByteArray)(tcpmgr.cpp:539-569,声明 tcpmgr.h:38)。JSON 由调用方序列化,TcpMgr 只负责加 4 字节头:
// 业务层(chatwid.cpp:641-652):自己 toJson,只交给 TcpMgr「msg_id + 字节」
textObj["fromuid"] = user_info->_uid;
textObj["touid"] = _user_info->_uid;
textObj["text_array"] = textArray;
emit TcpMgr::GetInstance()->sig_send_data(ReqId::ID_TEXT_CHAT_MSG_REQ,
QJsonDocument(textObj).toJson(QJsonDocument::Compact));
// tcpmgr.cpp:71 构造函数里把 sig_send_data 自连到 slot_send_data(同线程,直接调用)
void TcpMgr::slot_send_data(ReqId reqId, QByteArray dataBytes) {
QByteArray block;
QDataStream out(&block, QIODevice::WriteOnly);
out.setByteOrder(QDataStream::BigEndian);
out << quint16(reqId) << quint16(dataBytes.length()); // 2+2 字节头
block.append(dataBytes);
_socket.write(block); // ← 最终就是这个接口(:568)
}
没有
TcpMgr::Send(quint16, QJsonObject)这个函数——发送入口是信号sig_send_data→ 槽slot_send_data,槽签名是(ReqId, QByteArray)。全客户端 20+ 处发送都是emit TcpMgr::GetInstance()->sig_send_data(...)。
分层关系(要讲清楚「谁调谁」):
业务层(聊天页/登录页)
↓ emit sig_send_data(1017, json_bytes) ← 业务只构造 JSON
TcpMgr::slot_send_data(协议封装:4 字节头 + 已序列化 body)
↓ QTcpSocket::write(QByteArray) ← Qt 网络接口
内核 TCP 发送缓冲区
几个被追问的点:
write()是异步的:把数据丢进内核缓冲区立即返回,不等对端确认;真正的发送由 Qt 事件循环 + 内核完成。所以要连续发多条直接连续调,Qt 会排队write()之后没有「发送成功」的信号——bytesWritten()只表示进了内核缓冲。所以业务层需要 1018 应用层回执判断「对方收到了」(见「离线消息」)- 大消息要分帧:单帧受 8KB 限制,文件/图片不在 TCP 里发(见「如何存储大量聊天消息」,走对象存储 URL)
- HTTP 请求走的是另一套接口:
HttpMgr用QNetworkAccessManager::post()(httpmgr.cpp:14-38),与 TCP 长连接完全分开
是否了解 Qt 的槽机制
- 本质:松耦合的观察者模式。信号(
emit sig_xxx(...))发出后,所有连接的槽自动被调用,发送方不需要知道谁在监听 - 连接方式:
connect(sender, &Class::sig, receiver, &Class::slot)(Qt5 语法,本项目用法见tcpmgr.cpp:71(sig_send_data → slot_send_data自连接)、login.cpp:78(HttpMgr::sig_login_mod_finish → login::slot_login_mod_finish)) - 线程安全:跨线程连接时 Qt 自动用
QueuedConnection——信号在线程 A 发出,槽在对象所属线程 B 的事件循环里执行,天然线程安全。但本项目全客户端没有QThread/moveToThread(全仓只有f_setting.cpp:15的#include <QThread>和:688的QThread::msleep(50)),TcpMgr也在 GUI 线程,tcpmgr.h里也没有sig_loginSuccess这种信号——别硬说「网络线程跨线程投递」。被追问就答:「如果按改进把收包挪进QThread,connect第 5 个参数显式给Qt::QueuedConnection才是必须的」 - 项目用法举例(真实链路):
- HTTP 登录:
HttpMgr::PostHttpReq的finished回调emit sig_http_finish(...)(httpmgr.cpp:34)→HttpMgr::slot_http_finish(:40)按模块分发 →emit sig_login_mod_finish(:49)→login::slot_login_mod_finish(连接login.cpp:78、实现:281)→_handlers[id]解析 →emit sig_connect_tcp(si)(:192)→TcpMgr::slot_tcp_connect(连接login.cpp:80) TcpMgr收到 1006 →emit sig_switch_chatdlg()(tcpmgr.cpp:119)→MainWindow::SlotSwitchChat(连接mainwindow.cpp:39);失败另有emit sig_login_failed(err)(tcpmgr.cpp:87)→login::slot_login_failed- 自定义类型传参要注册:
Q_DECLARE_METATYPE(AddFriendApplyPtr)(userdata.h:38),qRegisterMetaType全客户端只有c_window.cpp:53一处(就写在connect(sig_friend_apply)上一行)。其余shared_ptr<SearchInfo>/<TextChatMsg>信号没注册也能用,因为同线程直连是DirectConnection,不走队列就不需要 metatype——这句能体现你真懂 Qt
- HTTP 登录:
为什么 IM 客户端要用信号槽(结合项目答,别说错线程):Qt 规定 GUI 只能在主线程操作;本项目 TcpMgr 本身就在 GUI 线程,信号槽在这里的实际作用是把协议层和界面层解耦(tcpmgr.cpp 不 include 任何窗口头文件,全靠 _handlers + emit)。一旦按改进把收包挪进工作线程,QueuedConnection 就升级成「网络事件安全投递到 UI 线程事件循环」的必需品——答「现状是解耦、改进后是跨线程投递」,比一口咬定本项目跨线程更可信(呼应「对并发和异步的思考」)。
gRPC 通信用什么协议,protobuf 的作用
gRPC 的协议栈(自底向上):
应用数据
↓
Protocol Buffers(二进制序列化,.proto 定义消息结构)
↓
gRPC 框架(RPC 语义:方法名、status、metadata、流)
↓
HTTP/2(多路复用、头压缩 HPACK、二进制帧、一个连接并行多请求)
↓
TCP
- 传输层用 HTTP/2(不是 HTTP/1.1):一个 TCP 连接上并行多路流,服务端推送、头压缩,正好匹配 RPC 高频小包场景
- 序列化用 Protobuf:gRPC 只支持 protobuf 做默认序列化(也有 JSON 插件但没人用)
protobuf 的三个作用(要分层答):
- 序列化格式:字段按
tag(field_number) + wire_type编码,字段名不上线,只传 field number → 比 JSON 省 5-10 倍、解析快一个量级;且加字段向后兼容(旧代码忽略未知 field number) - 强契约的 IDL:
.proto是跨语言接口定义——本项目只有message.proto(ChatServer/message.proto,StatusServer/GateServer/ChatServer2 各放一份相同拷贝,VarifyServer/message.proto额外带VarifyService)。C++ 服务端拿它生成 gRPC stub,Qt 客户端不用(客户端走 TCP JSON),但 4 个 C++ 服务 + Node 验证服务共享同一份契约,改接口先改 proto 不会两边对不上 - 代码生成:
protoc生成ChatServiceImpl基类、ChatGrpcClientstub、序列化/反序列化函数(message.pb.h/message.grpc.pb.h)——网络和编解码全是生成的,业务只写方法体
本项目的落地证据:
message.proto定义AddFriendReq/AuthFriendReq/TextChatMsgReq/FriendStatusReq/GetChatServerRsp等(ChatServer/message.proto:47-168)。没有ChatServer/proto/子目录,也没有NotifyAddFriendApplyReq——加好友跨机通知用的是AddFriendReq(ChatGrpcClient::NotifyAddFriend)- gRPC 调用点:
StatusGrpcClient::GetChatServer、ChatGrpcClient::NotifyTextChatMsg、VarifyGrpcClient::GetVarifyCode(GateServer/VarifyGrpcClient.cpp:4) - 没用到流式 RPC:全是 unary(一问一答);gRPC 还支持 server-stream/client-stream/bidirectional-stream,视频/弹幕这类连续数据可以用
一句话:gRPC = HTTP/2 传 + Protobuf 编 + 代码生成绑——三者各管一层,缺一就不叫 gRPC。
项目中有没有遇到死锁的情况,有没有遇到内存的 crush 等情况
死锁:没有实际踩到,但有一处「差点死锁」的隐患,主动讲出来加分:
LogicSystem.cpp:45-77:业务线程持有 _mutex(LogicSystem.h 里的消息队列锁)的同时执行回调(回调里做 Redis 查路由、gRPC 跨服转发、MySQL 写好友)——unique_lk 从 :45 一直活到本轮循环结束,:77 调回调时锁没放:
- 当前不会死锁(只有一个业务线程、锁只被这一处持有,没有环)
- 但违反「锁内不做 IO」原则:一旦将来加第二个工作线程 + 回调里再拿别的锁,立刻可能成环
- 修复:出队时先
swap出本地 vector、释放锁后再执行回调——这属于「发现隐患主动改」的答题素材
死锁的四个必要条件 + 对策(背诵框架):
| 条件 | 对策 | 本项目对应 |
|---|---|---|
| 互斥 | 读写锁分离 | _mutex 只在队列操作短暂持有 |
| 持有并等待 | 一次性拿齐 / trylock+回退 | 连接池 getConnection 只拿一把锁 |
| 不可剥夺 | 超时锁 try_lock_for |
未用(可改进) |
| 循环等待 | 规定全局加锁顺序 | 当前单线程,天然无环 |
内存 crash(crush 应该是 crash):踩过的坑按「现象→根因→修复」讲:
- 连接泄露(真实存在,
MysqlDao.cpp:72):getConnection后异常路径没returnConnection,池 5 条被耗尽后全员cond_.wait卡死 → 修复用DeferRAII(const.h:43-55)保证归还;同类问题GateServer/VarifyGrpcClient.cpp:11-19仍存在 - 越界类:4 字节头解析处必须先判
_buffer.size() >= 4再取,否则半包时读越界(客户端tcpmgr.cpp:33就是先size() < 4直接 return;服务端没有OnRead,是asyncReadLen递归补读CSession.cpp:212-233,天然不会越界) - use-after-free 防护:
CSession全程shared_ptr+enable_shared_from_this,回调捕获shared_from_this()而不是裸this,防止对象提前析构后回调还跑
调试工具链(衔接「项目怎么调试」):Linux 下 valgrind --leak-check=full、gdb + core dump、ASan/TSan 编译选项(-fsanitize=address,thread)——面试时说「我会先 ASan 跑一遍回归」比说「我看过代码」可信得多。
项目怎么调试
分层回答(服务端 / 客户端 / 网络协议三层):
① 服务端(C++,Linux/Windows 双平台):
- 日志(现状先说实话):服务端没有任何日志库,全仓只有
std::cout/std::cerr(grepspdlog/glog零命中),排障靠 stdout。改进:引入 spdlog 按 level(debug/info/warn/error)+ 按天滚动,关键路径打 msg_id + uid + 耗时——线上排障第一手段(客户端反而已经有:自研Logger.h,四级 + 按天滚动log/foolchat_YYYYMMDD.log+installCrashHook) - gdb + core dump:崩溃时
gdb ./chatserver core→bt看调用栈、info threads切线程——「死锁与 crash 排查」的 crash 排查就靠它 - Sanitizer(开发期):
-fsanitize=address抓越界/UAF,-fsanitize=thread抓数据竞争——ASan 会把出错那一行和泄漏栈直接打出来 - valgrind:
valgrind --leak-check=full ./chatserver,跑完出「definitely lost」清单
② Qt 客户端:
- qDebug + 环境变量:
QT_LOGGING_RULES、qputenv("QT_LOGGING_RULES", "*.debug=true")动态开关 - Qt Creator 断点 + Debug 模式:变量 watch、堆栈、单步;信号槽断点可以直接断在槽函数
- 界面问题先二分:是「信号没发」还是「槽没执行」——在
emit和connect处各打一条日志立刻定位
③ 网络协议层(最容易出问题的一层):
- Wireshark 抓包:过滤
tcp.port == 8090,直接看 4 字节头 + JSON 明文(这就是选 JSON 而非 protobuf 的调试红利) - 半包/粘包复现:用
nc手工发半个包,看服务端是否按预期等待 - 自测小工具:写一个裸 socket 脚本(Python
socket模块)批量建连压心跳,代替手动开客户端
④ 流程(体现工程素养):
- 复现 → 固定步骤/最小用例
- 定位 → 日志缩小范围 → 断点/gdb 精确到行
- 修复 → 加回归用例
- 验证 → ASan/valgrark 跑一遍确认无新问题
介绍项目的亮点
按「有代码证据 + 有数字 + 有对比」挑 3-4 个讲,不要罗列:
-
多线程网络模型的取舍:N 个单线程 io_context 而不是 1 个多线程 io_context
AsioIOServicePool起hardware_concurrency()个 io_context、每个只 1 线程 round-robin 分连接(AsioIOServicePool.cpp:11-28)——拿到多核并行,又天然免掉 strand 锁。很多人大而化之「asio 多线程」,答不出为什么不用 strand,这是区分度。 -
业务单线程 + 消息队列,消灭业务层数据竞争 IO 线程只做拆包入队,所有业务回调在一个线程里跑(
LogicSystem.cpp:22-81)→ 好友表、session 表全程无锁。并主动讲它的代价(锁覆盖回调是瓶颈)和修复方案(出队先 swap 释放锁)——知道边界的亮点才是真亮点。 -
跨服路由:Redis 一张
uip_表解决「用户在哪台机器」uip_<uid> = server_name,同机走内存直发、跨机走 gRPC(LogicSystem.cpp:871-908)。把「分布式定位」简化成一次 O(1) Redis 查——上万连接不用广播,两台 ChatServer 热插拔只改 config。 -
诚实的缺陷清单(反向亮点) 心跳、离线消息、消息落库、gRPC deadline、压测脚本都没有,每个都能给出「如果让我做」的完整方案(本文档「离线消息」「服务器无损切换」「gRPC 超时与压测」三题)。面试官对「能说出自己项目哪里不行」的候选人信任度远高于「全是亮点」的。
不讲的:番茄钟/登录页美化这类没技术含量的;「独立完成全栈」这种没有边界感的自夸。
具体的添加好友的业务是怎么实现的
完整链路(申请 → 同意 → 双向建关系 → 上线同步),消息 ID 一律以 ChatServer/const.h:69-107 为准:
【A 发起申请】
A 客户端 → TCP 1009 ID_ADD_FRIEND_REQ {uid, applyname, bakname, desc, touid}
字段构造+发送 applyfriend.cpp:540-564
→ 注册 LogicSystem.cpp:88 → LogicSystem::AddFriend (LogicSystem.cpp:461)
→ 先读被加方 B 的三档策略 add_policy (MysqlDao::GetAddPolicy, MysqlDao.cpp:576)
policy=0 拒绝 → 直接回 1010,error=AddPolicyRefused=1012 (LogicSystem.cpp:483-486)
policy=2 自动同意 → 跳过申请单,直接建好友并双向通知 (LogicSystem.cpp:487-524)
policy=1 需验证(默认)→ 继续走申请
→ INSERT friend_apply(from_uid, to_uid, descs)
ON DUPLICATE KEY UPDATE status = 0, descs = VALUES(descs) (MysqlDao.cpp:164-165)
→ 查 Redis uip_<touid> 找 B 在哪台机器 (LogicSystem.cpp:529-535)
取不到 ip(离线)→ 静默 return,等 B 下次登录拉 apply_list (LogicSystem.cpp:533-535)
同机在线 → session->Send(ID_NOTIFY_ADD_FRIEND_REQ=1011,
{applyuid, name, desc, icon, sex, nick, status}) (LogicSystem.cpp:544-563)
跨机 → gRPC ChatGrpcClient::NotifyAddFriend → 对端 ChatServiceImpl::NotifyAddFriend
再查一次 session 发同一个 1011 (ChatServiceImpl.cpp:15-46)
→ B 客户端 1011 handler → emit sig_friend_apply (tcpmgr.cpp:170-198)
【B 同意(走验证)】
B 客户端 → TCP 1013 ID_AUTH_FRIEND_REQ {fromuid: B, touid: A, back}
字段构造+发送 authenfriend.cpp:545-564
→ 注册 LogicSystem.cpp:90 → LogicSystem::AuthFriendApply (LogicSystem.cpp:760)
服务端 uid=发包方(B)、touid=原申请者(A),两个值都从 body 读 (:766-767)
→ MysqlDao::AuthFriend(B, A, back)(MysqlDao.cpp:401-497,setAutoCommit(false) 单事务):
① UPDATE friend_apply SET status=1
WHERE (from_uid=A AND to_uid=B) OR (from_uid=B AND to_uid=A) (:417-436)
② INSERT IGNORE friend(self_id=B, friend_id=A, back) (:440-457)
③ INSERT IGNORE friend(self_id=A, friend_id=B, back='') (:460-478)
④ commit,任一步 rowAffected<0 先 rollback (:480-496)
→ 回 1014 ID_AUTH_FRIEND_RSP 给 B(带对方资料 + status) (LogicSystem.cpp:791-794)
→ 推 1015 ID_NOTIFY_AUTH_FRIEND_REQ 给 A(申请方):
同机 → session->Send (LogicSystem.cpp:811-836)
跨机 → gRPC NotifyAuthFriend → 对端 ChatServiceImpl::NotifyAuthFriend
(ChatServiceImpl.cpp:49-91)
→ A 客户端 1015 handler → emit sig_add_auth_friend (tcpmgr.cpp:200-228)
→ 双方刷新好友列表:客户端 emit sig_send_data(ID_GET_FRIEND_LIST_REQ)
(chatdialog.cpp:36、contactdialog.cpp:40)→ 1045 回包合并进 UserMgr
(注册 LogicSystem.cpp:108,实现 RefreshFriendList :984-1032,回包 handler tcpmgr.cpp:462-481)
关键设计点(被追问时展开):
- friend 表双向各插一行(A-B 和 B-A),查询
select * from friend where self_id = ?(MysqlDao.cpp:695)就是完整好友列表,不用OR——用写放大换读性能;列名是self_id/friend_id(不是 userid/friendid,见「表设计与索引」),INSERT IGNORE让重复同意不报错(:442、:463) - 申请单只有 0/1 两态,没有状态机:
status=0待处理、status=1已同意(MysqlDao.cpp:164-165、:419),没有拒绝态、没有过期态;登录/刷新拉的是where apply.to_uid=? and apply.id>? order by apply.id ASC LIMIT ?(MysqlDao.cpp:284-296),不过滤 status、没有(toid, state)联合索引那回事,历史申请会全部重新渲染 - 这是明确的缺陷,主动讲:
ON DUPLICATE KEY UPDATE status=0会把已经同意过的好友申请重新打回待处理(再申请一次同一个人 → 红点重现、对方收到重复通知);也没有幂等错误码,重复申请只是静默覆盖descs。如果让我做:申请单加status=0/1/2/3(待处理/同意/拒绝/过期)+handled_at,GetApplyList追加and status=0,重复申请先查状态直接返回错误码 - 三档策略
add_policy(user.add_policy,0 拒绝 / 1 需验证 / 2 自动同意,MysqlDao.cpp:576;登录时读出并随 1006 带回客户端LogicSystem.cpp:386-387):分支全在LogicSystem.cpp:482-524——policy=2 不落申请单直接AuthFriend+ 双向推 1015,policy=0 直接回AddPolicyRefused。这是产品化的点,别漏讲 - 权限:拉黑/陌生人不能申请(项目里简化了,改进点可提)
- 资料快照:推送申请时带
name/icon/sex/nick字段(LogicSystem.cpp:548-558),B 不在线很久后同意也不用再查 A 的最新资料 - 已知字段不一致(真实 bug,主动讲):1015 有两个发送点——自动同意路径发
applyuid(LogicSystem.cpp:506),验证路径发fromuid(:817),而客户端 handler 读的是applyuid(tcpmgr.cpp:218)→ 走验证流程时 A 收到的通知 uid=0;验证路径还漏了status(客户端:226读到 0)。改法:两个发送点对齐同一份 JSON schema
主动添加方的消息推送是怎么做的(因为被添加方同意时,主动添加方可能不在线)
先说清角色:主动添加方 = A(发起申请的人),B 同意时 A 可能不在线——A 要在上线时拿到「B 同意了」这个结果。
本项目的实际实现(两段式,依托登录拉取):
-
B 同意的瞬间 A 在线: 服务端
LogicSystem::AuthFriendApply调完MysqlDao::AuthFriend(单事务把friend_apply.status=1和friend两行一起落库)后,查 Redisuip_<touid>——这里touid就是申请方 A——A 在线则同机session->Send(ID_NOTIFY_AUTH_FRIEND_REQ=1015, {fromuid: B, name, nick, icon, sex})(LogicSystem.cpp:811-836),跨机走 gRPCNotifyAuthFriend(:838-843,对端ChatServiceImpl.cpp:49-91)——和聊天消息完全同一条转发链路(「消息如何识别与转发」) -
A 不在线(关键路径):
- 结果不依赖推送,依赖拉取:
friend表双向已插入、friend_apply.status=1已落 MySQL——状态是持久化的,不依赖连接 - A 下次登录:
LoginHandler拉friend表(GetFriendList,LogicSystem.cpp:389-401)→ 1006 的friend_list自然包含 B;同时拉申请列表(GetFriendApplyInfo:366-379→MysqlDao::GetApplyList) - 推送型通知(申请动态):登录时一并返回
apply_list,SQL 是friend_apply JOIN user WHERE to_uid=? AND id>? ORDER BY id ASC LIMIT ?(MysqlDao.cpp:284-296,注意:不过滤 status,历史申请全量返回),客户端渲染红点 - A 一进主界面,好友列表和申请红点一次性对齐,等于「迟到的推送」
- 结果不依赖推送,依赖拉取:
为什么这样做是对的(讲设计理由):
- IM 的铁律:连接状态不可信。任何「必须在线才收得到」的设计都会在掉线瞬间丢事件
- 解法是把事件转成状态:「B 同意了」不存内存事件队列,而是落成
friend表两行 +friend_apply.status=1——状态可查询、可重放,登录即同步 - 这与「多端登录的消息同步」「跨设备的好友同步」两题是同一套思想:状态在服务端,登录即全量/增量同步
如果要做得更好(进阶答法):
status=1只是「同意」,A 还没看过 → 加read_flag,登录返回unread_apply_result列表做应用级红点,A 点开后置已读- 要实时性强的系统(微信级):离线时走离线消息队列(「离线消息」方案 A),上线补推,实现真正的「事件不丢」
如果同一账号可以多端登录,如何解决消息同步问题
先说现状:本项目单端登录——LoginHandler 检测到已在线就给旧连接发 1021 踢下线(LogicSystem.cpp:423-430)。所以这题答多端方案设计。
多端登录的三个设计维度:
① 连接层:一台服务器怎么管同 uid 的多条连接
UserMgr::_uid_to_session从map<uid, session>改成map<uid, vector<session*>>- 登录不再踢旧连接,而是追加;断开时从 vector 移除
- 每个 session 加属性
device_type(iOS/Android/PC/Web)+device_id
② 同步层:消息怎么送到所有端(两条路线)
| 路线 | 做法 | 优缺点 |
|---|---|---|
| 在线端扇出(推荐) | 服务端给该 uid 的每条在线 session 各发一份 1019 | 实现简单;离线端靠拉取补 |
| 端侧拉取兜底 | 上线时按 last_msg_time 增量拉 chat_history |
解决「掉线期间的消息」 |
扇出实现(设计):DealChatTextMsg(LogicSystem.cpp:846,当前实现是「取 touid 的那一个 session」)改成找到 touid 的 session vector,for 循环 Send——**同机多端就是多几次 async_write,跨机不重复(路由表还是一个 uip_,多端必须连同一台 ChatServer,或改成 uip_<uid> 存服务器列表)。
③ 状态层:多端状态同步(已读/在线状态/草稿)
- 已读回执:
read_time存服务端,任一端已读 → 推给其他端(微信的「手机看过电脑就已读」) - 在线状态:多端在线 = 整体在线;任一端在线即在线
- 草稿/输入中:走 Redis
SET user:typing:<uid>短 TTL,别的端订阅——这是纯实时状态,不适合落库
难点题:跨服务器的多端怎么办
- 约束:同 uid 的所有端必须路由到同一台 ChatServer(否则
uip_单值冲突、扇出找不到) - 方案:登录时
GetChatServer按 uid 一致性哈希取模定机器(当前是选负载最小的——多端场景要换),保证同账号进同机器 - 该机器就是该用户的「锚点服务器」,所有端连它,扇出在本进程内完成
一句话:多端 = 连接层一对多 + 消息层扇出 + 状态层服务端为准,再加「同 uid 定锚点机器」保证扇出可行。
同一账号,在 a 设备上时添加的好友,之后在 b 设备上登陆时,如何同步这个好友信息
先答本项目的现状:天然已同步——因为好友关系存在 MySQL friend 表,不在任何设备的本地。A 设备加好友时服务端双向 INSERT(「添加好友的业务实现」②),B 设备下次登录 LoginHandler 查 friend 表拉全量列表塞进 1006 的 friend_list(GetFriendList 调用在 LogicSystem.cpp:389、回包组装 :390-401)→ 新设备一登录就是最新数据。
这就是「以服务端为唯一真相源(Single Source of Truth)」的直接收益:
设备A:INSERT friend (A,B) → MySQL ← 状态写在服务端
设备B:登录 → select * from friend where self_id=?(MysqlDao.cpp:695) → 1006.friend_list → 渲染
要展开讲的三个同步策略(区分数据类型):
| 数据类型 | 例子 | 同步策略 | 本项目 |
|---|---|---|---|
| 强一致、低频 | 好友关系、资料 | 登录全量拉取(数据量小,一次拉完最简单可靠) | ✅ 就是这么做的 |
| 增量、高频 | 聊天消息 | 登录增量拉:WHERE create_time > last_sync_time(见「离线消息」方案) |
❌ 未实现 |
| 实时状态 | 好友在线/离线 | 不落库,上线时推快照 + 后续事件流:登录后 BroadcastFriendStatus(uid,1),断线 RmvUserSession 里广播 0(UserMgr.cpp:74-111、调用点 LogicSystem.cpp:433/UserMgr.cpp:70) |
✅ 已实现(ID_NOTIFY_FRIEND_STATUS_REQ=1035,const.h:93;客户端 tcpmgr.cpp:341 → sig_friend_status) |
| 实时状态 | 正在输入 | Redis SET user:typing:<uid> 短 TTL + 其他端订阅 |
❌ 未实现(如果让我做) |
为什么好友用「全量」而不是「增量」(会被追问):
- 好友列表撑死几百条,一条 SQL 几毫秒——增量的复杂度(游标、乱序、去重)不值得
- 全量天然自愈:任何设备的任何脏数据,登录一次就被服务端数据覆盖
- 微信/Telegram 同理:关系链永远全量同步,只有消息走增量
如果好友量变大(进阶):
- 登录先拉「版本号」
friend_list_version(自增),本地版本不一致才拉差异 - 或 cursor 分页拉取,首屏快、后台补全
主动提的坑:多端场景下「登录全量拉」要和「多端登录的消息同步」的扇出配合——新设备上线前的消息,靠离线消息表补;上线后的新好友,靠推送或下次拉取。
什么是 asio,具体可以实现哪些功能
定义:Asio(asynchronous I/O)是 Boost 体系的跨平台 C++ 异步 IO 库(boost::asio,也有 standalone 版),核心是用「完成事件」模型统一所有阻塞式 OS 接口——你不等 read() 返回,而是注册一个回调「读完了叫我」。
核心抽象(要能说出 3 层):
io_context(旧称io_service):事件循环 + 任务队列。run()就是「取消费完成事件/回调」,本质是 Proactor 模式的容器- 异步操作 + 完成回调:
async_read_some / async_write / async_accept / async_connect——每个都带handler(error_code, bytes_transferred) - 同步对象包装:
tcp::socket / tcp::resolver / steady_timer——定时器、DNS 解析都是异步一等公民
具体能做什么(按本项目列,比干背 API 强):
| 功能 | 本项目用法 | 代码位置 |
|---|---|---|
| TCP 服务端 | async_accept 循环接新连接 |
GateServer/CServer.cpp、ChatServer/CServer |
| TCP 收发 | async_read_some 收、async_write 发、串行保证同 socket 不并发写 |
CSession.cpp |
| 定时器 | 连接超时、steady_timer deadline |
HttpConnection 60s deadline(死代码,没启动) |
| DNS 解析 | tcp::resolver / async_resolve——本项目没用(配置里直接给 IP,连接池走 async_connect,全仓 grep resolver 零命中) |
未用,属于 asio 能力 |
| 多线程事件循环 | N 个 io_context 各 1 线程(本项目的池) | AsioIOServicePool.cpp |
| 协程(C++20) | awaitable<T> + co_await(asio 7.x 起支持) |
本项目未用(可提改进) |
同族对比(体现广度):
- libevent/libuv:Reactor 模型(告诉你「可读了」,你自己 read)——Node.js 底层就是 libuv
- Asio:Proactor 模型(告诉你「读完了,数据在这」)
- fPIC 追问:为什么 Asio 选 Proactor?——Windows 原生就是 IOCP(Proactor),Linux 用 epoll 模拟,一套 API 双平台行为一致
一句话:asio = 把「socket/定时器/DNS」这些阻塞资源全部变成「回调 + 事件循环」的异步原语,本项目拿它做了 accept、收发、连接池保活和多线程事件循环。
两个 chatserver 间怎么通信(答:gRPC),那如果有成千上万台服务器呢
先答两台的现状:gRPC 点对点——ChatGrpcClient 按 peer 名建连接池(每 peer 5 stub,池在 ChatGrpcClient.cpp:215-235 建),调用方 LogicSystem 先查 Redis uip_<uid> 拿目标机器名(如 LogicSystem.cpp:871),再 _pools.find(server_ip) 取对应 channel 发 unary(ChatGrpcClient.cpp:66-84)。
成千上万台:三个层次的演进(这是架构题,按规模答)
① 规模化寻址:服务发现替代配置文件(最先要改的)
- 现状:
config.ini的[PeerServer]手写死对端地址 —— 2 台行,10000 台不可能 - 改成:gRPC 自带的 name resolver + load balancing
grpc::CreateChannel("dns:///chat-server.namespace.svc.cluster.local", creds); // 每次调用自动解析 → 多 IP 轮询/最少连接(内置 LB policy) - 或注册中心(Consul/etcd/Nacos):服务注册 → 发现 → 健康检查 → 摘除,K8s 里直接用 Service + Headless Service
② 寻址粒度:从「找机器」到「找服务」(消除 N×N 问题)
- 现状是 ChatServer 两两互联,N 台 = N² 条 channel,爆炸
- 改成分层:
ChatServer(业务,海量) ↓ 只连 路由层/网关层(少量,按 uid 一致性哈希分片) ↓ 横向服务(用户、消息存储、推送——各自集群化) - 服务间不再「互相找」,只找「下一跳」——这就是微服务的拓扑收敛
③ 通信模式:unary RPC → 消息队列(海量异步的分水岭)
- 1v1 实时消息:保留 gRPC(低延迟、双向)
- 广播/系统公告/离线扩散:走 Kafka/RocketMQ——1 条消息发 100 万在线用户,不能让 1 台机器扛 100 万次 RPC,MQ 负责扇出和削峰
- 跨地域:MQ 做多机房复制,gRPC 只走同城
④ 数据层必须动:uip_ 单键路由 → 分片路由表(Redis Cluster 按 uid 分 slot,或一致性哈希直接算出归属机器,连查都省了)
面试收束句:
「两台靠配置 + 点对点 gRPC;一万台靠服务发现 + 分层拓扑 + MQ 扇出 + 路由分片——本质是从'机器互联'进化到'服务互联',通信对象从'某台机器'变成'某个服务名'。」
如果需要添加一个视频聊天功能,你觉得应该怎么设计,视频需要通过服务器转发吗
先纠正前提:视频/语音通话已经实现了,我答「已有实现 + 我会怎么增强」(诚实原则:别当成从零设计背一遍):
【信令面——1046/1047/1048,走现有 TCP 长连接 + 跨服 gRPC,很轻】
A ←1046 CALL_SIG_REQ→ ChatServer::DealCallSigMsg (LogicSystem.cpp:912,注册在 :94)
├ 对端在本机:peer_session->Send(..., 1048) (LogicSystem.cpp:953-958)
└ 对端在别机:ChatGrpcClient::NotifyCallMsg → gRPC (LogicSystem.cpp:969-980)
→ ChatServiceImpl::NotifyCallMsg → 1048 (ChatServiceImpl.cpp:143)
信令类型:invite / accept / reject / offer / answer / ice / hangup(callmgr.h:26-30)
【媒体面——libdatachannel P2P,重,不过服务器】
A ←=====UDP/SRTP 直连=====→ B (mediaengine.cpp:640-642 只配了两个 STUN,走 host/srflx 候选)
打洞失败 → sig_media_failed (mediaengine.h:50) → CallManager 直接挂断 (callmgr.cpp:49)
⚠ 没有 TURN 中继兜底——当前最大的缺口
代码地图(被追问时能指到文件):
| 层 | 实现 | 位置 |
|---|---|---|
| 协议 | ID_CALL_SIG_REQ=1046 / RSP=1047 / NOTIFY=1048 |
ChatServer/const.h:104-106(客户端 global.h:84) |
| 服务端信令 | 解析 call_id/sig_type/payload → 查 uip_<touid> → 本机直发或跨服转发 |
LogicSystem.cpp:912-981 |
| 跨服 RPC | NotifyCallMsg(对端 session 为 null 时静默丢弃) |
ChatServiceImpl.cpp:143 |
| 客户端状态机 | CallManager:Idle → Outgoing/Incoming → Connecting,主叫 60s 无应答自动挂断 |
callmgr.h:11-16、:92,callmgr.cpp:22-30 |
| 媒体层 | MediaEngine:libdatachannel 建 rtc::PeerConnection + 收发 track,ffmpeg/openh264/libopus 编解码,Qt Multimedia 采集与渲染 |
mediaengine.h:11-17,mediaengine.cpp:640-642 |
| 通话 UI | 接听/挂断/通话时长窗口 | callwindow.cpp |
「视频需要通过服务器转发吗」的准确答法:不需要——服务器只转发 KB 级信令(1046 → gRPC → 1048),媒体是 libdatachannel 打洞后的 P2P 直连;信令面/媒体面分离正是为了不为视频付带宽钱(对比下表)。
为什么要 P2P 而不是全过服务器(对比着答):
| 服务器转发 | P2P 直连 | |
|---|---|---|
| 服务器带宽 | 1 路视频 ≈ 上行 2-4Mbps × 双向,1 万人同时聊 = 几十 Gbps,带宽成本爆炸 | 只付信令流量(KB 级) |
| 延迟 | 多一跳 | 更低 |
| 实现 | 简单,复用现有链路 | 打洞有失败率,要 TURN 兜底 |
| 适用 | 少量、企业内部 | C 端 IM(微信/Zoom 都这么做) |
协议选型(已定型,讲清楚「为什么是这套」):
- 媒体库 = libdatachannel(
mediaengine.h:11-17注释写得很清楚):WebRTC 协议栈的 C++ 实现,SRTP 加密媒体、ICE/STUN、DTLS、NACK;不是 libwebrtc(libwebrtc 要自己编一整套,且没有 Qt 友好的轨道接口) - 编解码:ffmpeg + openh264 + libopus(H.264 视频 / Opus 音频),采集渲染走 Qt Multimedia(
QCamera/QAudioDevice/QVideoWidget,mediaengine.cpp:773-834) - 信令没有标准协议,本项目用现有 TCP 长连接发 JSON——1046 这个 msg_id 就是为此新增的
当前实现的三个真实缺陷(主动讲,比背架构分高):
- 没有 TURN 兜底:
mediaengine.cpp:640-642只有stun:stun.miwifi.com:3478/stun:stun.l.google.com:19302,对称 NAT 或 4G/5G 下打洞失败就sig_media_failed挂断,没有中继可走——生产必须自建 coturn 并下发长期凭据 - 信令不校验身份:
DealCallSigMsg直接取root["fromuid"](LogicSystem.cpp:918),从不和session->GetUserId()比对(全项目只有RefreshFriendList在LogicSystem.cpp:993做了这个校验)→ 冒充别人发起通话、发 1017 文本消息都行;改法是每个 handler 入口if (uid != session->GetUserId()) → 回错误码 - NAT 映射没有保活:
ID_HEART_BEAT_REQ(1023)只有枚举没实现(const.h:84-85,呼应「心跳机制」),打洞出来的 UDP 映射空闲被 NAT 回收,长通话会掉;改法是通话中每 15-30s 发 STUN 绑定请求(或应用层 keepalive)
如果让我增强(设计题的增量部分):
- TURN 兜底:部署 coturn,
rtc::Configuration加 turn 服务器 + 凭据,STUN/relay 候选都留 - 弱网:码率/分辨率自适应、断线自动 ICE restart 重协商——现在是直接挂断
- 多人/连麦才上 SFU(mediasoup/Janus)——单人对聊 P2P 已经够了,这是很多人答错的点;录制也必须中心化,那是另一条链路
- 通话产品化:通话记录落库、未接来电推送(
callwindow.cpp的 UI 已经有了,缺服务端持久化) - 安全:信令加
fromuid校验 + 好友关系校验,SDP/ICE 只接受该通话call_id关联的对端
一句话:信令 1046/1047/1048 走现有 TCP + 跨服 gRPC(轻、复用 uip_ 寻址),媒体走 libdatachannel P2P 直连(重、不过服务器)——已经实现;缺的是 TURN 兜底、信令身份校验、NAT 保活这三件,也是我下一步要补的。
项目中是否有关注过内存泄漏问题,如何检查内存泄漏
先答「有没有」:重点关注过两处真实的资源问题(把「关注」落到具体代码):
- 连接池归还泄露(仍未修复):
ChatServer/MysqlDao.cpp:72——CheckEmail查无结果时走裸return true(不是异常路径,是正常返回),unique_ptr<SqlConnection>出作用域直接把底层连接销毁、根本不回池;同一个类里 catch 分支(:75)和CheckPwd(:116-118)都正确归还,唯独这条路漏了。丢 5 个槽位后所有调用者阻塞在MysqlDao.h:81的cond_.wait。方案是改成DeferRAII(const.h:43-55)——方案有、代码没改,这点必须说实话 - 同类问题仍在的:
GateServer/VarifyGrpcClient.cpp其实有RPConPool(5)复用 stub(:27建池、:10取),真实缺陷是失败分支:16-19不归还——status.ok()为假时直接return reply,槽位不回池,连续 5 次失败后getConnection()同样卡死在cond_.wait;CSession队列上限 1000(const.h:66)防的是内存无界增长而不是泄漏
「怎么检查」——工具链按场景答:
| 工具 | 查什么 | 用法/输出 |
|---|---|---|
| ASan(首选,开发期) | 堆越界、UAF、LeakSanitizer 默认开启 | 编译加 -fsanitize=address -g,跑完直接打印分配/泄漏调用栈 + 行号 |
| valgrind | 泄漏、UAF、未初始化读 | valgrind --leak-check=full --show-leak-kinds=definite ./chatserver,输出「definitely lost」清单 |
| gdb + core | crash 现场 | bt 看栈;info proc mappings 看内存 |
| tcmalloc/jemalloc 剖析 | 内存碎片、top 分配点 | pprof 看谁分配最多 |
| RSS 监控(线上) | 慢泄漏 | ps/Prometheus 看进程 RSS 是否单调涨——慢泄漏要看趋势不是单点 |
排查方法论(比工具更重要):
- 怀疑泄漏 → 最小复现:写个循环脚本(建连→登录→断开 ×10000)跑 ASan,涨不涨一看便知
- 区分「泄漏」和「正常缓存」:连接池、日志缓冲是有意持有——看 ASan 报告的**「still reachable」vs「definitely lost」**
- C++ 特有三类:
new没配delete→ 用unique_ptr/shared_ptr根治- 循环引用(
shared_ptr互指)→weak_ptr打破 - 注册回调捕获
this(裸指针)→ 对象析构后回调悬空(UAF),本项目用shared_from_this()捕获规避(CSession)
- 本项目的结构性答案:核心对象全用智能指针(
shared_ptr<CSession>、unique_ptr<SqlConnection>),手动delete为零——泄漏面集中在「算法容器里的对象没清」,如_uid_to_session断连时erase(UserMgr.cpp:45,同一段的:68是RedisMgr::Del(ipkey))
收尾:主动说「上线后还会有慢泄漏(fd/内存缓慢增长),靠 RSS 监控 + 定期 ASan 回归压测兜底」——体现不只会在开发机查。
聊天项目中,客户端给服务器发消息,有 tcp 连接过程吗,还是直接用 http 发送
结论:登录后发聊天消息走「已建立的 TCP 长连接」,没有新建连接的过程,也不是 HTTP。但建立这条连接本身有过完整 TCP 三次握手。按阶段拆:
阶段 1:连接是怎么来的(有 TCP 过程)
客户端 connectToHost(host, port) ← TCP 三次握手在这里(SYN→SYN-ACK→ACK)
↓ connected 信号
发 1005 CHAT_LOGIN {uid, token} ← 应用层登录,跑在刚建好的 TCP 上
↓ 1006 登录成功
连接进入长连接保持态
(tcpmgr.cpp:530-537 连接、login.cpp:316-330 发登录包)
阶段 2:发一条聊天消息(没有 TCP 连接过程,直接复用)
输入框 → emit sig_send_data(1017, json)(tcpmgr.h:42 信号 → :539 slot_send_data)
→ 拼 4 字节头(QDataStream BigEndian,tcpmgr.cpp:551-562)→ QTcpSocket::write
→ 直接进这条已存在 TCP 的发送缓冲区,一次网络往返都没有握手
(客户端根本没有 TcpMgr::Send(msg_id, json) 这个接口,所有发送都是 emit sig_send_data(ReqId, QByteArray),由 slot_send_data 统一加头)
「有没有 TCP 连接过程」的准确答法:连接过程只有登录那一次;之后每条消息都在同一条连接上传输,TCP 对上层透明。
为什么不用 HTTP 发消息(对比着答,呼应「tcp 长连接的作用」「登录和聊天分别用什么协议」):
- 服务端要主动推送:B 回消息、好友申请、上线通知,都要服务端主动写——HTTP/1.1 一问一答做不到(长轮询/Server-Sent Events 是绕路方案)
- 握手开销:HTTP/1.1 短连接每条消息 1 个 RTT 握手;即使 keep-alive,中间层(代理/NAT)空闲也会掐断
- 全双工:TCP 长连接上客户端边发边收,HTTP 请求期间收推送很别扭
那什么走 HTTP(分清边界):登录、注册、验证码、改资料——一次性的请求-响应,HTTP 正确(「登录和聊天分别用什么协议」的表)。分界线:需要服务端主动推送/持续双向 → TCP;一次性拿数据 → HTTP。
聊天服务器如何验证客户端的 token
完整验证链路(号称两层校验 + 路由,实际发生在登录 1005 时)——注意第 ③ 层当前是坏的:
① 客户端发 1005 {uid, token}(token 是 GateServer 登录时发的)
② ChatServer LoginHandler 第一层:Redis 快查
GET utoken_<uid> → 比对是否 == 客户端带来的 token (LogicSystem.cpp:330-341)
不等/不存在 → 回 1006 error,断开
③ 第二层:gRPC 权威校验——**代码写了,但返回值没人看,等于没验**
auto rsp = StatusGrpcClient::Login(uid, token) → StatusServer 查同一个 utoken_ key (LogicSystem.cpp:346)
**rsp.error() 从未被检查**:Success / TokenInvalid / RPCFailed 都继续往下走 → 这层校验形同虚设
还有一层尴尬:StatusServer 查的也是同一份 Redis(StatusServiceImpl.cpp:148-170 和 ChatServer :330-341 是同一个 key),
所以它的真实价值本该是「登录态 owner 做 TTL/多端/踢下线策略」,现在连策略也没有
改进:if (rsp.error() != ErrorCodes::Success) { rtvalue["error"] = rsp.error(); return; },并给 ClientContext 设 set_deadline()
④ 通过 → session->SetUserId(uid)、写 uip_<uid>、logincount++、拉资料回 1006
token 是怎么生成的(上一步,要连着讲):
boost::uuids::random_generator()生成 UUID 作为 token:生成函数generate_unique_string(StatusServiceImpl.cpp:7-15,uuid 在:9),GetChatServer里:55reply->set_token(...)→:56insertToken写 Redis(实现:172-177)SET utoken_<uid> <token>写 Redis(当前无 TTL,永不过期——真实系统应设 7 天滑动过期)- 同时返回该 uid 负载最低的 ChatServer
host/port
关键设计点(被追问时展开):
- token 放 Redis 不放 ChatServer 内存——ChatServer 挂了/重启,登录态全局不丢,换机器重连只需重发 1005(「心跳机制」「服务器无损切换」的基础)
- 验证发生在长连接上而不是 HTTP 上:HTTP 登录只发 token(凭证),真正的身份绑定在 TCP 的 1005——「换票 + 验票」两段式
- session 绑定即授权(设计如此,实现打了折):验证通过后
SetUserId(LogicSystem.cpp:417),理想情况后续所有消息从 session 取 uid、不再验 token——但实际只有RefreshFriendList用了session->GetUserId()(LogicSystem.cpp:993),DealChatTextMsg:852、DealCallSigMsg:918都直接信任 body 里的fromuid,可以冒充他人发消息/发起通话(这也是掉线后必须重新 1005 的原因,但校验没落到每个 handler 上) - 诚实的缺陷:token 无 TTL、无签名(不可离线验签,必须查 Redis)、无多端互踢策略——改进:JWT(RS256 签名,
exp过期)+ Redis 黑名单做主动注销;但 JWT 每请求验签 vs Redis 查一次,内网场景后者更简单,能讲出这个权衡是加分项
客户端发送的消息是如何封装的,如何区别不同类型的消息(文字、表情、图片等)
封装(二层:协议帧 + 消息类型)—— 「消息封装与解析」的客户端视角:
// 第一层:协议帧(所有消息统一)
[2B msg_id][2B length][JSON body] // 大端,body ≤ 8KB
// 第二层:msg_id 区分消息类型
1005 登录 / 1017 发聊天 / 1009 加好友 ...(1012 不是 msg_id,是错误码 AddPolicyRefused,const.h:37)
「怎么区别不同类型」——三层区分机制(这是本题核心):
| 层 | 机制 | 区分什么 |
|---|---|---|
| 1. 协议类型 | msg_id(uint16) |
登录、聊天、加好友……服务端 _fun_callbacks[msg_id] 直接路由到 handler(LogicSystem.cpp:83-116 注册表;心跳 1023 只有枚举、没进注册表) |
| 2. 内容类型 | body 里的字段/content_type |
文字、表情、图片是同一类(1017)下的子类型 |
| 3. 展示类型 | 客户端渲染分支 | 收到后按 content_type 决定画气泡文字、表情图还是缩略图 |
文字/表情/图片的具体区分(本项目的做法 + 标准做法):
- 文字 vs 表情:
- 项目做法:表情用 Unicode emoji 字符混在文字里(😀 本身就是合法 UTF-8 文本),JSON
content一段字符串搞定,服务端无感 - 标准做法(微信式):
content_type: "text"+ 自定义表情用[smile_01]占位符,客户端解析成图片
- 项目做法:表情用 Unicode emoji 字符混在文字里(😀 本身就是合法 UTF-8 文本),JSON
- 图片/文件(本项目未实现,给设计):
{ "content_type": "image", "content": "https://oss.xxx/img/abc.jpg", // 只传 URL,不传二进制 "meta": {"w": 720, "h": 1280, "size": 81234} }- 二进制绝不过 8KB 的聊天 TCP 通道(「如何存储大量聊天消息」):先传 OSS → 聊天消息只带 URL → 客户端按 URL 懒加载
- 传输本身走 HTTP 上传接口(
QNetworkAccessManager::postmultipart)——这个接口本项目没写:httpmgr.h:18的PostHttpReq只发 JSON,没有 multipart 上传,属于「如果让我做」
- 消息类型枚举建议:
{"content_type": "text|emoji|image|file|audio|card|system", "content": "...", "msgid": "uuid"}
为什么用 content_type 字段而不是继续加 msg_id(架构答点):
msg_id是协议方法(动词:登录/发消息),数量有限、改动要全端同步content_type是数据种类(名词),加一种图片/视频消息客户端只需加一个渲染分支,服务端零改动——协议层稳定,内容层可扩展,这是分层的意义
介绍聊天项目,gRPC 是怎么用的,同步调用还是异步调用,有考虑过超时的问题吗,项目有做过性能测试吗
① gRPC 在项目里怎么用的(三个场景 + 连接池):
| 调用方 | 服务 | 方法 | 用途 |
|---|---|---|---|
| GateServer | StatusServer | GetChatServer |
登录时选负载最低的聊天服务器 |
| GateServer | VarifyServer | GetVarifyCode |
邮箱验证码 |
| ChatServer | StatusServer | Login |
二次校验 token(调用方没检查返回值,实际不生效,见「聊天服务器如何验证客户端的 token」) |
| ChatServer | ChatServer2 | NotifyTextChatMsg 等 |
跨服转发消息/好友事件 |
- 连接管理:
ChatGrpcClient按 peer 名建 5 条 stub 的连接池(ChatGrpcClient.cpp:215-235),复用 channel 不每次新建 - 序列化:
.proto定义消息,protoc生成 stub(「gRPC 协议与 protobuf」)
② 同步还是异步(诚实答 + 讲清代价):
- 项目现状:同步阻塞调用——
stub->Method(&ctx, &req, &resp)一行等到底 - 代价:gRPC 调用跑在持有业务队列锁
_mutex的线程里(成员名LogicSystem.h:50,锁覆盖回调LogicSystem.cpp:45-77),一次跨服 RPC 几 ms,期间整个服务器的业务处理全停——这是本项目已知的最大瓶颈 - 正确做法(要会讲):gRPC 自带 completion queue 异步 API(
CompletionQueue+AsyncService),或future版stub->async()->Method()——调用立即返回,往io_context或专用线程池投回调- 进阶:C++20 协程版
co_await stub->async()->Method(...),代码像同步一样简单且不阻塞
- 进阶:C++20 协程版
- 面试口径:「当前是同步,我会改成异步 + 出队先释放锁,两处一起改」
③ 超时考虑了吗(没做,但会设计——必须诚实):
- 现状:没有。
grpc::ClientContext没设set_deadline(),对端挂了最长卡到 TCP 超时(几十秒甚至分钟级),叠加锁覆盖回调 → 整服假死 - 设计:
grpc::ClientContext ctx; ctx.set_deadline(std::chrono::milliseconds(500)); // 跨服转发 500ms 足够 auto status = stub->NotifyTextChatMsg(&ctx, req, &resp); if (!status.ok()) { /* 降级:写本地队列重试 / 直接丢弃记日志 */ } - 配套:重试(gRPC 自带 retry policy,只重试幂等调用)+ 熔断(连续失败快速失败,别把线程耗在死对端上)
④ 性能测试做过吗(没做正式压测——给方法论):
- 现状:没有压测脚本,「8000 连接」是经验值不是实测——别编数字,面试官一追问细节就穿帮
- 会怎么做(三件套):
- 连接数:脚本批量
connectToHost+ 每 30s 发登录,看 RSS/CPU/fd(ulimit -n先放开) - 消息吞吐:多客户端互发 1017,统计服务端消息/秒、p99 延迟(1017 发出 → 1018 收到的时间差)
- 工具:自写 Python/Go 压测客户端(最灵活),或
wrk压 HTTP 网关;perf/火焰图找热点
- 连接数:脚本批量
- 已知会先爆的瓶颈(体现预判):业务单线程 + 锁覆盖回调 → 吞吐先卡在这,不是网络层
网络模型如何工作的,需要处理什么样的并发场景
本项目的网络模型(分层描述,代码有据):
┌─ 主线程:Acceptor,只做 async_accept (CServer.cpp:33),主线程 run (ChatServer.cpp:49)
│
ChatServer 进程 ────┼─ N 个 IO 线程(N=CPU核数)
│ 每线程独占 1 个 io_context (AsioIOServicePool.cpp:11-15)
│ 负责:async_read_some 收、拆包、async_write 发
│ 连接 round-robin 分配(AsioIOServicePool.cpp:22-28),读永远在该 IO 线程;
│ 写的发起方可能是业务/gRPC 线程 → CSession::_send_lock(CSession.cpp:45,183)
│
├─ 1 个业务线程:queue+mutex+condvar 消费 (LogicSystem.cpp:13)
│ 所有回调在这跑,业务层天然无并发
│
└─ 1 个 gRPC server 线程 + 连接池保活线程(MySQL 60s/Redis 30s)
(ChatServer.cpp:36-38;MysqlDao.h:38;RedisMgr.h:26)
模型本质:Proactor(asio)+ IO 多线程并行 + 业务单线程串行 的混合——「并行在 IO 层,串行在业务层」。
要处理的并发场景(按「谁和谁并发」枚举,这是答题结构):
| # | 并发场景 | 风险 | 本项目的解法 |
|---|---|---|---|
| 1 | 多连接同时收包(8000 连接同时发消息) | 收包状态机互相踩 | 每连接一个 CSession 独立缓冲,且同连接绑定固定 IO 线程(round-robin 一次性分配)→ 收包无锁;发送队列例外,见下面的 _send_lock |
| 2 | IO 线程 vs IO 线程 | 共享结构竞争 | 几乎不共享:session 表只在业务线程改 |
| 3 | IO 线程 vs 业务线程 | 消息队列、session 归属 | 队列 mutex+condvar;入队/出队加锁,回调在业务线程独占执行 |
| 4 | 业务回调之间的隐性并发(本项目特意消灭) | 好友表/uid 表数据竞争 | 单业务线程——所有回调串行,回调之间零锁(跨线程读 uid→session 表的 gRPC handler 另有 _session_mtx,见最后的锁题) |
| 5 | 连接建立/断开 vs 消息处理 | session 用到一半被析构 | shared_ptr<CSession> + 回调捕获 shared_from_this();断连从 UserMgr 摘除 |
| 6 | 跨服 gRPC 响应(异步回来时上下文已变) | 回调时序 | 当前同步调用没这个问题;改异步后要靠绑定 uid 快照 |
| 7 | 连接数暴涨/洪水 | fd 耗尽、内存爆 | 发送队列上限 1000、包长 8KB 校验;改进:per-IP 限流 |
主动讲的已知问题(区分度):
- 锁覆盖回调(
LogicSystem.cpp:45-77):gRPC/MySQL 在锁内执行 → 场景 3 的锁持有时间被 IO 放大 → 改法:出队 swap 后先放锁再执行回调 - 单业务线程上限:业务吞吐 = 1 核;量大后要分片多工作线程(按 uid hash),代价是引入锁/分片内串行
一句话:IO 层用「多线程 + 每连接独占线程」解决规模,业务层用「单线程 + 队列」消灭竞争——并发场景 1-7 中,5、7 靠智能指针和限流,3 靠队列锁,4 被架构性消灭。
io_context 在单线程和多线程下分别是怎么工作的
io_context 是什么(先一句话):事件循环 + 任务队列的容器——run() 循环里「取一个完成事件/任务 → 执行它的 handler → 没有就阻塞等待」。
单线程 run()(本项目每个 io_context 就是这个形态):
while (有任务或有未完成异步操作) {
等待事件(epoll_wait / IOCP)
取出完成事件 → 执行 handler ← 所有 handler 严格串行
}
- handler 永不并发:同一 io_context 上的回调一个接一个跑 → 操作同一 socket/同一数据无需任何锁和 strand
- 代价:一个 handler 慢(比如里面做 MySQL 查询),整个 io_context 上所有连接的回调全部排队——所以本项目 IO 线程里只做拆包/入队,重活扔给业务线程
多线程 run()(同一个 io_context 被 N 个线程同时 run):
// 危险用法:N 个线程 run 同一个 io_context
for (int i = 0; i < N; ++i) pool.emplace_back([&]{ io.run(); });
- handler 可能并发:任务队列被 N 线程争抢,两个 handler 可能同时执行
- 同一 socket 的两个 handler 可能在不同线程跑(比如读回调和写回调)→ 数据竞争风险
- 解法:strand(串行化器):把 socket 相关操作包进
strand,io_context 保证同一 strand 的 handler 不并发,不同 strand 仍并行——粒度化的锁,替代手工 mutex
三种姿势对比(面试就答这张表):
| 模式 | 并发性 | 同步需求 | 适用 |
|---|---|---|---|
| 1 个 io_context + 1 线程 | 串行 | 零同步 | 简单/低负载 |
| 1 个 io_context + N 线程 run | 并行 | 必须 strand | 高负载单循环 |
| N 个 io_context 各 1 线程(本项目) | 并行且组内串行 | 组内零同步,组间不共享就零同步 | ✅ 池化标准做法 |
为什么本项目选第三种(亮点答法):
- 每个 io_context 只被 1 个线程 run → 单线程语义,不用 strand、不用锁
- N 个 io_context 跨多核 → 拿到并行
- 连接 round-robin 绑定(
AsioIOServicePool.cpp:22-28)→ 负载均衡 - 等价于「N 个独立单线程事件循环」,比「1 个 io_context + N 线程 + strand」少一层抽象、少一类 bug——这是和「asio 底层原理」「网络模型如何工作」两题串起来的完整模型
补充(被追问时):
io_context::run()返回 = 没有任务也没有未完成操作 → 用executor_work_guard保活(AsioIOServicePool.cpp:6-8)post(io, f)投任务到指定 io_context,跨线程 post 是线程安全的——这就是 IO 线程往业务线程投递的方式(本项目用 queue+condvar 等价实现)
你的项目有没有数据共享情况的处理,是怎么控制并发中的锁的
有,而且是这个项目线程模型的核心设计题。先给全景:「分层数据,分层策略——能无锁就无锁、能串行就串行、非锁不可才加锁」:
| 数据 | 谁读谁写 | 控制策略 | 证据 |
|---|---|---|---|
收包缓冲(_data / _recv_head_node / _recv_msg_node) |
只在自己的 IO 线程读写 | 无锁(连接 round-robin 固定绑定线程) | CSession.h:51,57,60;AsioIOServicePool.cpp:22-28 |
发送队列(_send_que) |
IO 线程 + 业务线程 + gRPC 线程都调 Send |
std::mutex _send_lock(入队 :45/:62,写完成出队 :183) |
CSession.h:55、CSession.cpp:45,183 |
| uid→session 表 | 业务线程写,gRPC server 线程读(GetSession) |
std::mutex _session_mtx(:16/:27/:42,作用域只包 map 操作) |
UserMgr.h、UserMgr.cpp:14-48 |
| 业务共享数据(好友表、消息队列里的业务状态) | IO 线程投递,业务线程独占执行 | 架构性消灭并发(单业务线程) | LogicSystem.cpp:22-81 |
| 跨线程投递通道(消息队列本身) | 多 IO 线程写,业务线程读 | mutex + condvar(_mutex/_consume) |
LogicSystem.h:50-51、LogicSystem.cpp:45-49 |
StatusServer 的选主表 _servers |
gRPC 多线程并发进 getChatServer() |
std::mutex _server_mtx(:74) |
StatusServiceImpl.cpp:73-74 |
业务队列这把锁,怎么用的(具体到代码,注意成员名):
// 生产者(任意 IO 线程,LogicSystem.cpp:24-31)
{
std::unique_lock<std::mutex> unique_lk(_mutex);
_msg_que.push(msg);
if (_msg_que.size() == 1) { unique_lk.unlock(); _consume.notify_one(); } // 先解锁再 notify
}
// 消费者(业务线程,LogicSystem.cpp:45-49)
std::unique_lock<std::mutex> unique_lk(_mutex);
while (_msg_que.empty() && !_b_stop) {
_consume.wait(unique_lk); // 不带谓词,靠 while 重判防虚假唤醒
}
// 取出后执行回调(注意:当前实现锁没及时释放,见下方隐患)
- 模式:生产者-消费者;成员名是
_mutex/_consume(LogicSystem.h:50-51),代码里没有_msg_lock/_cond这两个名字;wait在LogicSystem.cpp:48,外层while重判代替谓词(MysqlDao.h:81那种cond_.wait(lock, pred)是连接池的写法,别记混) - 锁的粒度:入队侧只包
push(:24-31,解锁后才notify)——正确姿势;消费侧把整个回调都包进去了——见下方隐患
已知隐患(主动讲,体现深度):
LogicSystem.cpp:45-77回调在持锁状态下执行(锁作用域覆盖了 Redis/gRPC/MySQL 调用)→ 锁持有时间从 μs 变 ms,将来加第二个工作线程立刻退化成全局串行甚至死锁风险- 修复:出队时把消息
swap到局部容器、释放锁后再执行回调——锁只保护容器不保护业务
其他共享点的控制(不止队列和连接池):
UserMgr::_uid_to_session:不是「单线程天然安全」——业务线程写(登录SetUserSession、断连RmvUserSession),gRPC server 线程也读(ChatServiceImpl的六个Notify*handler 全都调GetSession,ChatServiceImpl.cpp:19/54/97/127/146/215),所以配了std::mutex _session_mtx(UserMgr.cpp:16/27/42),锁只包 map 查找CSession::_send_lock:发送队列被业务/gRPC 线程与 IO 线程共享,入队(CSession.cpp:45,62)和写完成出队(:183)都加锁,靠「队列非空就不重复发起async_write」保证同一时刻只有一个写在飞- 连接池(MySQL / Redis / gRPC stub):多线程争抢 → 池内部
mutex + condvar(MysqlDao.h:79-100,锁成员:126-127;getConnection在:81cond_.wait、returnConnection在:100notify_one),RAIIDefer保证异常也归还——但MysqlDao.cpp:72和VarifyGrpcClient.cpp:16-19两处漏了没归还(见前面内存泄漏那题) StatusServiceImpl::_server_mtx(StatusServiceImpl.cpp:74):StatusServer 被 gRPC 多线程并发调getChatServer(),锁住「读logincount→ 选最小 → 改计数」这段- asio 层:同 socket 的读回调因「单 io_context 单线程」串行,无需 strand(对比「io_context 单线程 vs 多线程」)
方法论总结(收尾句):
「控并发我优先级是:① 能不共享就不共享(收包状态绑线程)→ ② 能串行就串行(业务单线程消灭竞争)→ ③ 非共享不可才加锁(队列表、session 表、连接池、选主表),且锁只包数据操作、绝不包 IO。锁是最后手段,不是第一手段——本项目真锁有五把:业务队列
_mutex、发送队列_send_lock、UserMgr::_session_mtx、连接池mutex_(StatusServer 另有_server_mtx),其余靠架构设计。」
最后更新时间:2026-10-13 17:38
评论(0) 最新 热门
登录 后才能评论。