AI 点评
cover

FoolChat 项目面试八股

香蕉牛奶 Lv.6 2026-10-04 19:01 41666 字 浏览 67 点赞 评论 0 C++

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 长连接):

  1. 客户端 POST /user_login(JSON body:user+passwd)→ GateServer(GateServer/LogicSystem.cpp:195-245)
  2. GateServer 查 MySQL 校验密码(LogicSystem.cpp:215 → MysqlDao.cpp:161-202)
  3. GateServer → gRPC StatusService.GetChatServer(uid) → StatusServer 按 Redis logincount 选负载最低的 ChatServer,生成 boost uuid token 写 utoken_<uid>,返回 host/port/token(生成 StatusServiceImpl.cpp:7-15、写入 :56)
  4. 客户端拿到 uid/token/host/port,再开一条 TCP 连 ChatServer,发 1005 CHAT_LOGIN {uid, token}(login.cpp:316-330)
  5. ChatServer 验 token:先比 Redis utoken_<uid>(LogicSystem.cpp:330-341,这一步是真生效的),再调 gRPC StatusService.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() 唤醒等待者
  • 归还用 Defer RAII 保证(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 二进制)
  • 核心三件套:
    1. Protobuf:IDL + 二进制序列化,message.proto 定义接口,protoc 生成多语言 stub(项目里 start.bat 就是 protoc 生成脚本,CLAUDE.md:35)
    2. HTTP/2:多路复用(一条连接跑多个流)、HPACK 头部压缩、全双工——所以 gRPC 能做服务端流式推送
    3. 四层架构:Call → Channel → Stub → Server,Stub 分同步(blocking)和异步(async)
  • 项目中的三个 service(message.proto,共 12 个 rpc):VarifyService(1 rpc) / StatusService(2 rpc) / ChatService(9 rpc,实际实现 6 个,未实现 RplyAddFriend/SendChatMsg/NotifyKickUser)

简历写的「单服务器 8000 连接,多服务器 1W~2W 活跃用户」,为什么不是正比关系

这题答「为什么不线性」比答「能扛多少」重要。

单机 8000 → 两台不是 16000,原因有五层:

  1. 共享资源不会被复制:多台 ChatServer 仍共享同一个 MySQL 和 Redis。在线数 hash logincount、路由 uip_、缓存 ubaseinfo_ 全打在一套 Redis 上,Redis 单线程模型 QPS 有上限,这是第一个天花板
  2. 跨服转发开销是单机没有的:单机上 A→B 是进程内 session->Send(LogicSystem.cpp:881-890);两台后要走 gRPC NotifyTextChatMsg(LogicSystem.cpp:908)→ 序列化 → 网络 → 对端解包再下发。消息量越大,转发占比越高,吞吐被 CPU 和网络吃掉
  3. 负载均衡本身有成本:每次登录 StatusServer 要做 N 次 Redis HGET(StatusServiceImpl.cpp:73-104),且整段持有 _server_mtx,机器越多锁内网络 IO 越多
  4. 活跃用户 ≠ 连接数:8000 是「能维持的 TCP 长连接」,1W~2W 是「会真正发消息的活跃用户」,后者的消息处理开销远高于前者。这两个指标本来就不能换算
  5. 连接分布不均:按在线数分配是近似均衡——两个并发请求可能同时选中同一台最低负载机(读改写不在同一把锁里),实际分布总比理论值差

标准答法:单机瓶颈是 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 这一个模板复制粘贴维护)。


对并发和异步的思考(异步时如何处理共享数据,如何平衡并发和异步)

本项目的三层拆解:

  1. IO 层(异步、多线程):io_context 上全是异步操作(async_read_some/async_write),回调由池线程执行。同一 session 的回调天然串行——因为它的 io_context 只有 1 个线程在 run(),所以读写状态机不需要锁也不需要 strand
  2. 跨线程共享数据(加锁):Send() 会被业务线程和 gRPC 线程同时调,用 std::mutex _send_lock 保护「入队 + 判断是否已有写在飞」的临界区(CSession.cpp:44-59);UserMgr 的 uid→session 表用 _session_mtx;会话表 _sessions 用 _mutex
  3. 业务层(异步转同步):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 助手) 发布节奏、用户群、技术栈都与实时链路无关

拆分标准(面试直接说这四条):

  1. 变化速率不同:网关协议稳定,业务功能高频迭代 → 分开
  2. 技术栈不同:C++ 实时链路 / Node 邮件 / Go 后台 → 各用各的
  3. 扩展维度不同:只有 ChatServer 需要水平扩展 → 单独拆
  4. 故障域隔离:发验证码挂了不能影响聊天

代价(主动承认):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),积压表现为消息延迟上升而不是拒绝

这套方案的真实缺陷(主动讲加分):

  1. 是「近似均衡」——logincount 读改写在不同进程,两个并发登录会同时选中同一台
  2. 只在登录时决策,用户不会因后续负载变化被迁移
  3. 计数非原子,应改 HINCRBY
  4. 进一步优化:加权(按机器配置)、心跳上报真实 CPU/内存、一致性哈希按 uid 分片(可减少跨服转发)

怎么测出单服务器的连接数的,连接数的瓶颈在哪儿

先诚实说明:仓库里没有压测脚本和压测数据(全仓搜 stress/benchmark/wrk/压测 零命中,docs/ 只有截图)。所以这题答方法论 + 瓶颈分析,不编造数字来源。

怎么测(可执行方案):

  1. 建连测试:写一个压测客户端,每个连接只做 connect + 发 1005 登录 + 收 1006,用 epoll 单进程模拟 8000 连接(一个模拟器进程可管上万连接,不需要真开 8000 个进程)
  2. 指标采集:服务端看线程数、内存占用、CPU 利用率、连接数统计
  3. 压力测试:连接建立后再并发发 1017 聊天消息,看消息延迟(日志打时间戳)和吞吐
  4. 逐步加压找拐点:加到某个数量后延迟陡增 / 出现 connection refused,那就是上限
  5. Windows 下可用 netstat -an | find /c ":8090" 统计连接

瓶颈在哪(按出现顺序):

  1. 文件描述符/句柄:Linux 是 ulimit -n(默认 1024,必须调到几万)—— 第一道墙
  2. 内存:每条连接一个 CSession + 收缓冲 char _data[MAX_LENGTH](8KB)+ 发送队列。8000 × 8KB ≈ 64MB 光缓冲区
  3. CPU:不是空闲连接烧 CPU,而是消息量——每条消息要过 IO 线程解析 + 入队 + 业务线程出队
  4. 逻辑线程串行:DealMsg 单线程且持锁执行回调(LogicSystem.cpp:45-77),这是本项目最真实的瓶颈——消息量一大就排队
  5. 共享存储: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 条实际未生效):

  1. RAII 归还:Defer 宏(const.h:43-55,析构执行 lambda),CheckPwd 等方法都是 Defer defer([this,&con]{ pool_->returnConnection(std::move(con)); }),异常路径也能还
  2. 健康检查:后台线程每 60s 扫描,_last_oper_time 超过 5s 未用的连接发 SELECT 1,失败原地重建(MysqlDao.h:35-74)——同时解决 MySQL wait_timeout(默认 8h)单方面踢空闲连接的问题
  3. 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:72 CheckEmail 查询无结果时 return true; 没归还连接
  • GateServer/VarifyGrpcClient.cpp:11-19 只在 status.ok() 分支归还 stub,失败分支直接 return,池 5 条漏 5 次就永久卡死(对比 StatusGrpcClient 用 Defer 是对的)

排查手段:给连接打 tag(记录取用者 + 时间戳),池里定期打印「借出未还」列表;或用 wait_for + 借用时长上限强制回收。


MySQL 连接池的设计要点

  1. 池大小:不是越大越好,连接数 ≈ CPU核数×2 + 磁盘数;更关键是缩短持有时间(把 gRPC、网络调用挪出连接使用期)
  2. 预热 vs 懒加载:本项目构造时建满(MysqlDao.h:26-33)——首个请求不付建连成本
  3. 生产-消费 + 条件变量:queue + mutex + condvar,取 wait(!empty)、还 notify_one;b_stop_ 时唤醒所有阻塞者返回 nullptr 优雅退出
  4. 健康检查(必须有):MySQL wait_timeout 默认 8h 会单方面踢空闲连接 → 池里的连接可能已死。本项目 60s 探活 + SELECT 1 失败重建(MysqlDao.h:35-74)
  5. 归还必须 RAII:Defer/unique_ptr 自定义 deleter,杜绝异常路径泄露
  6. 取用要有超时:wait_for + 上限,避免池空时全线程雪崩(本项目缺失)
  7. 连接属性:setSchema(schema_) 选库(MysqlDao.h:29,72)、事务后 setAutoCommit(true) 复位(MysqlDao.cpp:486-491)。本项目没调过 setCharacterSet(全仓零命中,只有 setSchema/setAutoCommit)——中文靠建表与连接的默认字符集,稳妥做法是建连后 setCharacterSet("utf8mb4")、归还前复位
  8. 事务要小心:借来的连接可能带着上次的事务状态,归还前必须 commit/rollback + 恢复自动提交
  9. 可观测:池大小、当前空闲、阻塞等待数、借出超时数打点

gRPC 在项目中解决的什么问题

核心解决「跨进程/跨机器的服务间调用」,三个场景:

  1. 服务间调用(Gate → Status/Varify):登录时调 StatusService.GetChatServer 拿分配结果,验证码调 VarifyService.GetVarifyCode。网关因此保持无状态,可多开
  2. 跨 ChatServer 消息转发(Chat ↔ Chat):A 和 B 不在同一台机器时,A 所在机器通过 ChatService.NotifyTextChatMsg 推给 B 所在机器,对端查本地 session 表下发 TCP(ChatServiceImpl.cpp:93-122)。这是 gRPC 在本项目最核心的作用
  3. 强契约 + 多语言:同一份 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

为什么好:

  1. 消息天然有序:同一连接的帧按到达顺序入队、单消费者按序出队 → 不需要给业务数据加锁
  2. IO 与业务隔离:慢业务(同步 gRPC、慢 SQL)不阻塞收包
  3. 可扩展:想并发就把单消费者换成「按 uid hash 到多个 worker」,接口不变

加分收尾:同时说明它的缺陷(锁作用域覆盖回调执行,LogicSystem.cpp:45-77)和我会怎么改。主动指出自己设计的边界,比只夸功能更有说服力。


大量用户连接时,负载的处理和断连的处理

负载处理(四道闸):

  1. accept 层:async_accept 异步,内核 backlog 缓冲突发连接
  2. IO 层:round-robin 散到 N 个 io_context(AsioIOServicePool.cpp:22-28),每核一个线程
  3. 业务层:无界队列 + 单消费者,过载表现为延迟上升(LogicSystem.cpp:22-41)
  4. 发送层:发送队列上限 MAX_SENDQUE = 1000,超过直接丢弃该条消息并打日志(CSession.cpp:47-50),不关连接——「保连接、丢消息」的降级策略

断连处理(四步清理):

  1. 触发:读错误/写错误/长度校验失败 → Close() 关 socket + ClearSession(CSession.cpp:94-138)
  2. 会话表移除:_sessions.erase(session_id)(CServer.cpp:36-45)
  3. 用户态清理(UserMgr.cpp:31-72):logincount - 1 → 删 uip_<uid> → MySQL UpdateUserStatus(uid, 0) → BroadcastFriendStatus(uid, 0) 广播离线(同服直发 TCP,跨服走 gRPC NotifyFriendStatus,UserMgr.cpp:74-111)
  4. 客户端侧: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)。


数据库中用户密码的加密存储

实际做法(要诚实):

  1. 客户端单次 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 和窗口决定,高延迟链路尤其明显

本项目的具体解法:

  1. 连接级并行:连接散到多个 io_context / 多台 ChatServer(round-robin + 负载均衡)
  2. 业务级并行:消息进队列后由工作线程处理,收和处理解耦
  3. 跨服分片:用户分散到不同机器,每台只承载部分连接

如果单用户真的需要更高带宽(大文件/视频):

  • 多连接分片:一个用户开多条连接分别传不同分片(QUIC/HTTP3 就是这个思路)
  • 绕过服务器:实时音视频用 WebRTC P2P,媒体流不走服务器——本项目已埋了这个设计:ID_CALL_SIG_REQ(1046) 只转发信令 offer/answer/ice(LogicSystem.cpp:912-981),媒体本身点对点
  • 大文件走对象存储:上传 OSS/CDN,聊天里只发 URL,不占用长连接

为什么要用线程池

四个理由 + 本项目的真实情况:

  1. 线程创建/销毁代价大:一次创建约几十微秒,还有栈分配(默认 1MB 栈 × 8000 = 8GB)。复用消除开销
  2. 控制并发度、防止资源耗尽:线程数无上限会导致 CPU 花在切换而非执行上
  3. 任务削峰:本项目 AsioIOServicePool 固定 N=CPU 核数(AsioIOServicePool.h:19),突发连接不会导致线程爆炸
  4. 简化并发:本项目 IO 池「每 io_context 1 线程」,同一连接的回调天然串行 → 免锁、免 strand

本项目的三种「池」:

池 数量 作用
AsioIOServicePool CPU 核数个 io_context,各 1 线程 跑网络回调
LogicSystem 工作线程 1 个(不是池) 串行处理业务,保序
资源连接池 MySQL 5 / Redis 5 / gRPC 5/peer 复用昂贵的连接(同一个思想)

反面提醒:线程池不是万能的。本项目业务层用单线程而不是线程池,是为了保序——多线程处理同一用户消息就必须加锁或按 uid 分片,复杂度陡增。选择单线程是权衡后的决定,不是没想到。


项目还有哪些不足和改进点

技术层(挑 3 条展开,每条带「怎么改」):

  1. 没有应用层心跳:ID_HEART_BEAT_REQ(1023) 只有枚举定义(const.h:84),服务端 RegisterCallBacks 没注册 handler(LogicSystem.cpp:83-116),也无任何定时器 → 半开连接检测不到(NAT 掉线、客户端崩溃服务端不知道)
    • 改:服务端 30-60s 收不到任何包就 Close();客户端 30s 发一次心跳
  2. 没有离线消息:查不到 uip_ 直接 return,消息永久丢失(LogicSystem.cpp:873-876)
    • 改:落 offline_msg(to_uid, from_uid, msg_id, content, create_time) 表,登录时随 friend_list 一起拉取,收到 ACK 后删除(顺便回答「离线消息」)
  3. 并发计数非原子: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、跨服走 gRPC Notify*(LogicSystem.cpp:869-908)
  • 副作用:Redis 挂了 = 整个路由系统失效,这是可用性换简单性

收尾:这三个挑战分别对应「协议层」「并发层」「分布式层」,正好是这个项目的三层结构。


其中的技术难点是什么

首选答案:跨服务器消息转发的正确性,同时牵扯路由、顺序、失败处理三个维度。

难点拆解:

  1. 定位:怎么知道接收方在哪台机器 → Redis uip_ 路由(LogicSystem.cpp:871-873)
  2. 同服与跨服两条路径的语义必须一致:同服是进程内 session->Send,跨服是 gRPC → 对端 ChatServiceImpl 再 session->Send(ChatServiceImpl.cpp:93-122)。两条路径 JSON 结构必须完全一样,否则客户端解析炸掉
  3. 顺序:同一用户多条消息,跨服时 gRPC 是同步调用且在同一个逻辑线程里串行发出(LogicSystem.cpp:908)→ 顺序得以保证。代价是这个同步调用会阻塞全服的消息处理
  4. 失败语义: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 + 重试 + 离线消息表)。说得出局限才是真做过。


为什么要做即时通信项目

  1. 它是网络编程的「全谱系」:一个 IM 同时逼你面对 TCP 粘包、长连接管理、并发模型、序列化协议、连接池、缓存一致性、负载均衡、分布式路由——别的题目很难一次覆盖这么多
  2. 即时性带来真实约束:消息必须秒级送达、必须保序、断线要重连——这些约束会逼出正确的架构(异步 IO + 队列 + 长连接),而不是「能跑就行」的同步实现
  3. 可水平扩展的样本:单机做完了自然会问「一万用户怎么办」,于是必须拆服务、做负载均衡、做跨服转发——分布式问题是被需求推出来的,不是硬套的
  4. 有完整的产品闭环:客户端 UI + 服务端 + 后台管理,能看到全链路,容易验证实现对不对
  5. 踩坑空间大:粘包、心跳、半开连接、顶号、离线消息……每个都是面试官爱问的经典问题,做完一个项目能沉淀一整套八股(就是这份文档)

为什么选择项目中的这个网络库,以及这些技术

技术栈选型与理由:

技术 理由 备选与放弃原因
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 顺手

核心判断标准(可抽象成方法论):

  1. 选「有标准」的,不选「自造的」——网络 IO 和 RPC 都有成熟方案
  2. 同生态优先——Asio/Beast/gRPC 都是异步回调风格,思维模型统一
  3. 性能敏感路径用二进制(protobuf),人要参与调试的路径用文本(JSON)
  4. 引入的每个依赖都要能回答「为什么不是 X」

为什么选择封装 tcp(为什么不用 udp、为什么不直接用库里的 tcp)

先澄清:「封装」指 CSession 这一层——把 socket 读写、协议头编解码、发送队列、生命周期打包成一个类,上层只见 Send(json, msg_id) 和回调。

为什么是 TCP 不是 UDP:

  1. IM 的核心承诺是可靠有序——UDP 丢包、乱序、重复都得在应用层重做(ACK、重传、序号、排序),等于重写一个 TCP
  2. 消息必须按序到达——1005 登录回包跑到 1017 聊天消息后面,客户端状态机会乱
  3. 拥塞控制免费——弱网下 TCP 自动降速,UDP 会加剧拥塞
  4. 业务不需要超低延迟——文字聊天 100ms 和 30ms 用户无感。UDP 的价值场景是音视频(丢帧可容忍、延迟敏感),本项目音视频走 WebRTC(ID_CALL_SIG_REQ 信令 + P2P 媒体),那部分确实是 UDP/SRTP

为什么不直接用「库里的 tcp」:

  1. Boost.Asio 只给原语,不给协议——async_read_some 返回的是字节流,没有「一条消息」的概念,粘包拆包必须自己做
  2. 没有会话概念——不帮你管理「这个连接对应哪个用户」「怎么踢人」「怎么广播」
  3. 没有发送顺序保证——如果每次 Send 直接 async_write,多线程同时写会把两帧交错成乱码(TCP 保证字节流有序,不保证你的多次写调用不交错)。所以必须发送队列 + mutex,保证同一时刻只有一个写在飞(CSession.cpp:44-59)
  4. 没有生命周期管理——异步操作还在飞时对象不能销毁,必须 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 的做法)

  1. 客户端向 GateServer 申请上传凭证
  2. HTTP multipart/form-data 上传到 OSS/MinIO,拿到 file_id/URL
  3. 聊天消息只发 {type:"file", url, name, size, md5},对端拉 URL 下载
  4. 优点:不占聊天长连接、天然断点续传和 CDN 加速、文件服务可独立扩展

方案 B:走现有 TCP 长连接分片(小文件、无第三方依赖)

  1. 新增协议:ID_FILE_HEAD(文件名/大小/md5/分片数)→ ID_FILE_SLICE(序号+数据)→ ID_FILE_END
  2. body 上限 8KB,每片控制在 4-8KB,序号保证乱序重组
  3. 接收端按序号组装,md5 校验完整性
  4. 必须解决:发送队列上限 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 就通过 gRPC Notify* 转发到对端(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 主要实现了哪些功能

  1. TCP 服务器(CServer):tcp::acceptor 监听 + async_accept 异步接受连接(CServer.cpp:17-34)
  2. 异步读:async_read_some 分段读 + asyncReadFull/asyncReadLen 递归补读到指定长度,实现「先读 4 字节头、再读 body」的状态机(CSession.cpp:87-177 状态机本体,:205-233 递归补读)
  3. 异步写:async_write 整帧写,配合发送队列保证帧完整性和顺序(CSession.cpp:44-59,179-191)
  4. io_context 线程池:N 个 io_context 各跑一个线程,executor_work_guard 防止空队列退出,round-robin 分配连接(AsioIOServicePool.cpp)
  5. HTTP 服务(GateServer):Boost.Beast 的 http::async_read / http::async_write + dynamic_body(HttpConnection.cpp:108-138)
  6. 超时定时器:steady_timer 是给 HTTP 60s deadline 用的(HttpConnection.h:22)——但只定义未启动(CheckDeadline() 无人调用)
  7. 优雅退出:boost::asio::signal_set 捕获 SIGINT/SIGTERM → 停线程池 → 关 gRPC(ChatServer.cpp:41-46)
  8. 字节序转换: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 更现代,本项目用回调风格)。


为什么使用多线程模型,如何处理多个请求

为什么多线程:

  1. 单线程跑 8000 连接的读写回调,任何一个慢操作(同步 gRPC、慢 SQL)会卡住所有连接
  2. 多核 CPU 单线程只能用 1/N 的算力
  3. 异步回调本身是并发到达的,多线程才能并行消费

本项目的多线程分工(每个进程线程数固定,见「数据共享与锁」的完整表):

  • 主线程:accept + 信号
  • N 个 IO 线程(N = CPU 核数):跑网络回调
  • 1 个逻辑线程:跑业务
  • 1 个 gRPC 线程:收跨服请求
  • 连接池保活线程

如何处理多个请求(关键在于「分层 + 排队」):

  1. 连接层分散:新连接 round-robin 分到不同 io_context,不同连接的请求天然并行
  2. 连接内串行:同一连接的读回调在一个单线程 io_context 上 → 不用加锁
  3. 业务层排队:所有连接的消息投进同一个队列,单逻辑线程按 FIFO 消费 → 不同连接的请求在业务层是串行的(这是本项目的取舍:用吞吐换简单和有序)
  4. 阻塞操作用连接池: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 / 低频存储

要点:

  1. 热冷分离:最近 100 条放 Redis(ZSET 按时间排序),翻页更早的走 MySQL
  2. MySQL 分库分表:单表十亿行撑不住。按 session_id(会话维度,保证同一会话在一张表,范围查询高效)或 uid 分片;分页用游标(上一页最后一条的 id/时间戳)而不是 OFFSET,深分页会随页数线性变慢
  3. 大文件绝不进数据库:视频/音频/图片上传对象存储,DB 只存 {url, size, md5, duration}。数据库存 blob 会导致表膨胀、备份困难、锁表
  4. 写入异步化:聊天消息先落 MQ,发送方立刻收到回执(本项目已经是先回执再转发),存储由消费者异步做,不阻塞实时链路
  5. 读写分离:读历史走从库,实时推送不查库
  6. 消息 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 自带的机制(可以答这个,是事实):

  1. Channel 状态机:IDLE → CONNECTING → READY → TRANSIENT_FAILURE → IDLE
  2. 自动重连:连接断开后 channel 进入 TRANSIENT_FAILURE,gRPC 会按指数退避(默认初始 1s,倍增,有上限)自动重连,业务代码不用写
  3. 状态回调:channel->WaitForConnected(deadline) 或注册 RegisterStateChangeCallback 可以感知状态
  4. 子通道级: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 倍。

如果让我做完整方案:

  1. 统一 channel(共享)
  2. 调用前 set_deadline(3s)——没有 deadline,对端挂了逻辑线程会永久阻塞
  3. 失败后有限次重试 + 指数退避 + 抖动(gRPC 自带 retry policy,grpc.service_config 配置)
  4. 连续失败 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

关键点:

  1. 谁在线、在哪台,全靠 Redis uip_ 这一张路由表
  2. 两条路径的 JSON 结构必须一致,客户端 handler 只写一份
  3. 消息 ID msgid(uuid)用于送达回执,客户端 _pendingMsgs.insert(msgid) 等回执(chatwid.cpp:450),服务端原样回显(LogicSystem.cpp:857-861)
  4. 当前不做的:消息不落库、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):

  1. 验 token(Redis 比对生效;gRPC 二次校验调了但没判返回值)
  2. logincount++
  3. session->SetUserId(uid) + SetUserSession(uid, session)(内存里 uid→session 的索引,UserMgr::_uid_to_session)
  4. Set(uip_<uid>, 本服名)(告诉全系统我在这)
  5. 若该账号已在线 → 给旧 session 发 1021 踢下线
  6. UpdateUserStatus(uid, 1) 写 MySQL + 广播好友上线

查询「B 在哪」:Get(uip_<B>) → 值 == 本服名 → 查内存 _uid_to_session;值 ≠ 本服名 → gRPC 转发;取不到 → 不在线。

在线状态判定(好友列表里的绿点):两套算法并存,别记混——

  1. 登录回包 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)维护——所以是「上次落库的值」,不是实时探测
  2. 主动刷新好友列表 RefreshFriendList 才是实时算的:obj["status"] = ExistsKey(uip_<uid>)(LogicSystem.cpp:1029)
  3. 好友申请通知里附带的 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

设计要点(面试展开):

  1. 验证码存 Redis 不存 MySQL:高频读写 + 需要自动过期(TTL 600s)
  2. 为什么不把验证码返回给客户端:/get_varifycode 只回 error + email(LogicSystem.cpp:49-52),验证码只能通过邮件到达 —— 防接口被刷成「邮箱轰炸器」
  3. 防刷:已有码复用不重发(server.js:15-16);再完善应加:同 IP 限频、发送次数上限、图形验证码前置
  4. 4 位码的熵(要诚实):uuidv4 前 4 位是十六进制字符,16^4 ≈ 6.5 万种,10 分钟窗口内配合「已有码复用」还算够用,但服务端没有失败次数限制,理论上可被穷举 —— 这是个可以主动提的改进点(连续错 5 次作废 + 换 6 位或字母数字集)
  5. SET + EXPIRE 两条命令非原子(redis.js:69-80),极端情况下进程崩在两条命令之间会留下永不过期的 key —— 应改成 SET key value EX 600

项目背景,怎么学的,怎么选择的这个开源项目

注意:这不是「选了个开源项目改改」,是从零自己写的,回答时要明确这点(面试官问「怎么选择开源项目」往往是在试探你是不是只抄了)。

怎么学的(学习路径,答出递进感):

  1. 先打地基:TCP/IP(粘包、TIME_WAIT、阻塞/非阻塞)、Linux IO 模型(select → poll → epoll → Reactor/Proactor)
  2. 再啃框架:Boost.Asio 官方文档 + examples,重点理解 async_* 回调模型和 io_context 的事件循环
  3. 然后做最小闭环:单进程 echo server → 加协议头解决粘包 → 加线程池 → 加连接管理,每一步都能自己测出来
  4. 遇到分布式问题才拆服务:单机做完后自然产生「多机怎么办」「消息怎么跨机送达」的问题,于是引入 gRPC、Redis 路由、负载均衡

技术选型的参考来源:

  • 网络层:参考了业界成熟方案的通用设计(Reactor、连接池、生产者消费者),实现是自己写的
  • 协议:参考 IM 通用做法(定长头 + 长度字段),这是行业标准解法
  • 架构:网关/状态/聊天的拆分参考微服务通用模式

面试话术模板:「项目是自己从零写的,不是 fork 开源项目。学习路径是协议 → 单机框架 → 分布式问题驱动的架构演进,每一步都是遇到具体问题才引入对应技术(比如遇到跨机消息发不出去才做了 Redis 路由)。」

关键:不要说「我参考了某个 IM 开源项目然后改的」——如果真这么做了,面试官会一路问下去你改了什么、原作者为什么那么写,很容易穿帮。


为什么需要网关服务,没有行不行

没有行不行?——功能上可以(客户端直接连 StatusServer/ChatServer 也行),但架构上会丢掉 4 个关键能力:

  1. 统一入口 = 统一安全策略:限流、防刷、日志、审计、后续加 WAF/签名验签,只需要在一处做。如果每个客户端直接连 4 个服务,这 4 件事要做 4 遍且不一致
  2. 协议收敛:对外只暴露 HTTP(客户端好接、防火墙好放行),内部服务间用 gRPC(高性能强契约)。网关就是协议转换层——HttpConnection 解 HTTP,VarifyGrpcClient/StatusGrpcClient 发 gRPC
  3. 隐藏内部拓扑:客户端只知道网关地址,不知道 StatusServer/MySQL/Redis 的存在。服务扩缩容、换端口、加实例,客户端不用发版
  4. 无状态可水平扩展:网关不持有会话状态(token 在 Redis,分配结果靠 gRPC),挂了直接再起一个,前面挂个 SLB 即可

本项目的具体收益:登录流程里「查 MySQL 校验密码」和「调 StatusServer 分配机器」这两步耦合在网关,客户端只需要一次 HTTP 往返就拿到全部连接信息,而不需要自己先调 Status 再调别的。

代价(可以提):多一跳网络、多一个故障点。缓解方式是网关无状态多实例 + 健康检查。

面试金句:网关解决的是「把 N×M 的服务暴露关系收敛成 N×1」——没有它,客户端要直接对接所有服务,耦合和安全面都爆炸。


以什么标准进行服务拆分呢

四条标准(「微服务拆分与职责边界」的展开版):

  1. 按变化速率拆:变更频繁的和稳定的分开。本项目:网关协议稳定、聊天业务高频迭代 → 分开;这样改聊天功能不用动网关、不用重新过安全评审
  2. 按技术栈拆:不同问题用最合适的工具。邮件发送用 Node 的 nodemailer 生态(C++ 没那么顺手);后台管理用 Go(并发 + 快速开发);实时链路用 C++(性能 + Asio)
  3. 按扩展维度拆:只有需要水平扩展的部分才单独成服务。ChatServer 是唯一会随用户量线性增长的,单独拆出来才能只加这一层的机器;StatusServer/VarifyServer 是低频服务,单实例足够
  4. 按故障域拆:不希望一起挂的就分开。验证码服务挂了,聊天应该照常能用(只有新注册不可用);后台挂了,线上用户无感

反过来,不该拆的信号(面试加分——说明你知道拆分的代价):

  • 两个服务总是一起变更 → 应该合
  • 拆开后必须频繁跨服务调用才能完成一个业务 → 微服务变成了分布式单体,比不拆还糟
  • 团队只有一个人维护 → 运维成本 > 收益

本项目的落地检验:每个服务能用一句话说清职责(「各服务器职能」的表),且代码上确实是独立进程独立 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);
    }
});

五个设计要点:

  1. 指数退避:3s → 6s → 12s → 24s → 30s 封顶。避免服务端故障时所有客户端同时重连造成惊群
  2. 连接成功复位:connected 回调里 _reconnectDelay = 3000 复位(tcpmgr.cpp:22)
  3. 被踢不重连:_kicked 标志(收到 1021 顶号通知或主动退出时置 true,tcpmgr.cpp:334,525),只有重新走登录流程才复位(tcpmgr.cpp:532)—— 否则会陷入「连上→被踢→重连」死循环
  4. 清理残留状态:connected 时 _buffer.clear()(tcpmgr.cpp:21),丢弃上一条连接的半帧,避免脏数据污染新连接
  5. 重连后要重新登录: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 是否单调增长、连接数是否只增不减

关键方法论(面试官真正想听的):

  1. 控制变量:先测「只建连不发消息」的纯连接容量,再测「建连 + 消息」,两者差值就是消息处理的开销
  2. 找拐点而不是报数字:压到延迟陡增/错误率上升的那一点就是容量上限,比「我支持 8000」有意义得多
  3. 瓶颈定位:perf top / 火焰图看 CPU 热点;iostat 看是否卡在磁盘;Redis INFO commandstats 看是否 Redis 成瓶颈;队列长度看是否逻辑线程积压
  4. 区分两类指标:空闲连接容量(内存+fd 决定)和 活跃消息吞吐(CPU+逻辑线程决定)——简历上的 8000 属于前者

针对本项目的具体瓶颈假设(可作为测试前的预测):

  • 单逻辑线程 + 持锁执行回调(LogicSystem.cpp:45-77)→ 预计消息量上去后最先饱和
  • 同步 gRPC 无 deadline → 跨服消息占比高时会显著拉高 P99

tcp 长连接的作用

三个不可替代的作用:

  1. 服务端主动推送(最核心)——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)
  2. 省掉反复建连的开销:TCP 三次握手 + 可能的 TLS 至少 1-2 个 RTT。聊天是高频交互,每次消息都握手会让延迟翻倍
  3. 连接即会话(连接状态可复用):服务端在 session 上直接绑定了 uid(session->SetUserId),后续消息不需要每次都带身份认证,也不用每条消息查 token —— 连接本身就是登录态

长连接的代价(面试一定会追问):

  • 服务端要维护海量 fd 和内存 → 所以要 io_context 池 + 连接数上限
  • 连接可能假死(NAT 超时、网络中断)→ 需要心跳(本项目预留了 1023/1024 但未实现)
  • 服务端重启会全部断开 → 所以客户端必须有自动重连(「如何实现断线重连」)
  • 负载变化时连接不会自动迁移 → 用户永远黏在登录时分配的那台机器上

如何解决大量 tcp 连接的性能问题,如果用户登录后长时间没有请求(心跳机制),如果又突然要发消息了,需要重新登陆吗

第一问:大量 TCP 连接的性能问题(四层解法)

  1. IO 层:多 io_context + 每个单线程,round-robin 分摊(本项目已做,AsioIOServicePool);避免「一个大锁串行所有回调」
  2. 内存层:按需分配缓冲(本项目每 session 固定 8KB 收缓冲,可改成 flat_buffer 按需增长);发送队列设上限防 OOM(MAX_SENDQUE=1000)
  3. 业务层:网络与业务解耦(生产消费队列),别在 IO 线程里做慢操作;本项目业务层单线程是瓶颈 → 改成按 uid 分片的多 worker
  4. 系统层: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)
  • 写回:设 content_length → http::async_write → shutdown_send 半关闭(:135-138)
  • 有 60s steady_timer deadline(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 写响应。

注意的坑(可主动讲):

  1. keep_alive(false) + shutdown_send → 实际是短连接,HTTP/1.1 keep-alive 被关掉,每请求都要重新握手
  2. POST 路由匹配用原始 _request.target()(含 query),/user_login?x=1 会 404
  3. 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 底层怎么工作:

  1. Windows:IOCP(PostQueuedCompletionStatus + GetQueuedCompletionStatus),真正的内核级 Proactor
  2. Linux:Asio 用 epoll 模拟 Proactor——async_read_some 实际是「注册 epoll 事件,等 EPOLLIN 后在事件循环里调 ::read(),再把结果打包成完成事件调你的回调」
  3. 事件循环:run() 就是取完成队列 → 执行 handler → 没事件就阻塞在 epoll_wait
  4. 任务队列:post()/dispatch() 投 handler 进队列,run() 的线程取出执行
  5. 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 + 多个单线程事件循环 + 连接池」三者协作,不是单靠多线程撑起来的:

  1. 异步 IO 是根本:async_accept / async_read_some / async_write 全非阻塞,一条空闲连接不占任何线程、不做任何阻塞等待——这是能到 8000 的前提。同步模型下 8000 连接 = 8000 线程,根本不可能
  2. 多线程负责并行:AsioIOServicePool 起 hardware_concurrency() 个 io_context、每个一个线程,新连接 round-robin 分配(AsioIOServicePool.cpp:22-28)→ 8000 个连接的回调摊到所有核
  3. 单线程的连接上下文:每个 io_context 只 1 个线程 → 同一连接读写状态机串行,不用锁不用 strand
  4. 连接池吸收下游压力: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。


不同客户端之间实现消息转发的工作原理

核心:服务端是「消息路由器」,不是「消息终点」。

四步转发:

  1. 寻址:A 的 ChatServer 收到 1017,取 touid 查 Redis uip_<touid>(LogicSystem.cpp:871-873)得 B 所在服务器名
  2. 分支:
    • 同机:查内存 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
  3. 落地:对端 ChatServiceImpl::NotifyTextChatMsg(ChatServiceImpl.cpp:93-122)→ 查本机 session → 有就 Send(1019),没有直接 return Status::OK(消息丢弃)
  4. 回执:同时给 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)

为什么自己定义:

  1. 现有库不给消息边界:Asio/QTcpSocket 都是字节流,必须自己声明长度(这是自定义协议的唯一必要性)
  2. JSON 可读性:抓包/日志直接看懂,不用配 proto 描述文件
  3. 轻量:IM 消息字段少(fromuid/touid/content),protobuf 优势发挥不出来,4 字节头更省

为什么不用现成协议(被追问时):

  • 不用裸 HTTP:服务端要主动推送(见「tcp 长连接的作用」)
  • 不用 MQTT:那是物联网发布/订阅模型,IM 是点对点 + 好友关系,引入 broker 反而多一层
  • 不用 WebSocket:原生桌面客户端不是浏览器,直接 TCP 更简单(WebSocket 握手/帧头在非浏览器场景是纯开销)

诚实补充:2 字节 length 意味着理论上限 65535、实际被 MAX_LENGTH=8KB 卡死;协议没有版本号字段,升级只能靠 msg_id 扩展——可改进点。


为什么要将登录和聊天分开不同的服务器

  1. 扩展维度完全不同:登录是低频瞬时(查库 + 分配机器 + 签 token),聊天是高频持续(每秒上万条)。绑在一起,聊天量暴涨会把登录拖垮。拆开后只需给 ChatServer 加机器,网关单实例就够
  2. 生命周期不同:登录是短连接(HTTP 一次往返),聊天是长连接(持续数小时)。长连接的机器扛 fd 和内存,短连接的机器扛并发请求——容量规划指标完全不同
  3. 故障域与发布节奏:登录链路涉及 MySQL/验证码/负载均衡,变更谨慎;聊天业务迭代频繁。发聊天新功能不用重启登录服务,用户不掉线
  4. 职责边界清晰:登录回答「你是谁、去哪台机器」(一次性决策),聊天回答「消息怎么送达」(持续服务)。不拆就会出现「登录逻辑混着 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 邮件标准协议

关键区分点:

  1. 登录为什么不用 TCP:登录时客户端还不知道连哪台 ChatServer(分配结果就是登录的返回值),必须先有一个「已知地址」的入口——网关的固定 HTTP 端口就是。鸡生蛋问题的解法:HTTP 换票 → 拿地址 → 再建长连接
  2. 聊天为什么不用 HTTP:服务端要主动推送(好友上线、新消息),HTTP/1.1 一问一答,长连接是唯一自然的解
  3. 服务间为什么不用 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_ 残留指向死机器,别人发的消息转过去被丢弃。

如果让我做,四层方案:

  1. 心跳 + 超时摘除(最快见效)
    • 客户端 30s 心跳,服务端 90s 无活动 Close()
    • uip_ 设 TTL 90s 并随心跳续期 → 机器挂了 90s 后 key 自动过期 → 路由自然失效
  2. 客户端感知 + 重新分配
    • 重连失败 N 次 → 客户端重新走 HTTP /user_login,让 StatusServer 分配健康机器
    • StatusServer 侧用 gRPC health check 标记不健康节点,getChatServer() 跳过
    • 拿到新 host/port → 只需重发 1005,不用重新输密码(token 还有效)
  3. 登录态不丢(本项目已做对)
    • token 存 Redis(utoken_)不在 ChatServer 内存 → 换机器后照常验 token,登录态是全局的
    • 这是能做无损切换的基础
  4. 消息不丢(最难)
    • 离线消息表:目标不在线时落 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 时,如果客户端和服务器不在一个内网,才需要一个公网入口。

「另一台机器怎么访问私网机器」的原理:网关和路由决定一切,「私网」不等于「不可达」。

  1. 同机房两台机器:配置结构是 [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)
  2. 跨机房 / 不同网段:内网 IP 天然不可路由,三种做法
    • 专线/VPC peering:路由表加一条,把对方网段引到自己的网络
    • 公网 IP 映射:NAT / 负载均衡器暴露 公网:端口,Host 填公网地址
    • 反向连接:A 连不上 B → 让 B 主动连 A(服务发现里叫「反向拨号」),两边都只出网不入网
  3. 本地开发:两个实例同机,Host=127.0.0.1,端口区分(config.ini 里 Port=8090/8091、gRPC 50055/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。

常见误区(要主动澄清):

  1. ❌「GET 参数在 URL 所以更安全」——URL 进访问日志、进浏览器历史、被 Referer 带走,比 body 更容易泄露
  2. ❌「POST 参数加密所以更安全」——都是明文(除非 TLS),body 同样要 TLS 才安全
  3. ❌「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):

  1. 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)
  2. 入队:PostMsgToQue 放进 LogicSystem 的 queue + mutex + condvar,唤醒业务线程
  3. 业务线程:DealMsg 出队 → _fun_callbacks[1017] → DealChatTextMsg(:846):读 body 里的 fromuid/touid/text_array(:852-855)→ 查 Redis uip_<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+ 树范围扫描正是「时间序列消息」的正确选择。

通用方法论(回答「怎么加索引」的框架):

  1. 先写出实际查询的 WHERE/ORDER BY
  2. 按最左前缀建联合索引,等值列在前、范围/排序列在后
  3. 覆盖索引让查询不回表
  4. 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 查本地已有则丢弃(幂等去重)
  • 这就是 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 发送缓冲区

几个被追问的点:

  1. write() 是异步的:把数据丢进内核缓冲区立即返回,不等对端确认;真正的发送由 Qt 事件循环 + 内核完成。所以要连续发多条直接连续调,Qt 会排队
  2. write() 之后没有「发送成功」的信号——bytesWritten() 只表示进了内核缓冲。所以业务层需要 1018 应用层回执判断「对方收到了」(见「离线消息」)
  3. 大消息要分帧:单帧受 8KB 限制,文件/图片不在 TCP 里发(见「如何存储大量聊天消息」,走对象存储 URL)
  4. 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

为什么 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 的三个作用(要分层答):

  1. 序列化格式:字段按 tag(field_number) + wire_type 编码,字段名不上线,只传 field number → 比 JSON 省 5-10 倍、解析快一个量级;且加字段向后兼容(旧代码忽略未知 field number)
  2. 强契约的 IDL:.proto 是跨语言接口定义——本项目只有 message.proto(ChatServer/message.proto,StatusServer/GateServer/ChatServer2 各放一份相同拷贝,VarifyServer/message.proto 额外带 VarifyService)。C++ 服务端拿它生成 gRPC stub,Qt 客户端不用(客户端走 TCP JSON),但 4 个 C++ 服务 + Node 验证服务共享同一份契约,改接口先改 proto 不会两边对不上
  3. 代码生成:protoc 生成 ChatServiceImpl 基类、ChatGrpcClient stub、序列化/反序列化函数(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):踩过的坑按「现象→根因→修复」讲:

  1. 连接泄露(真实存在,MysqlDao.cpp:72):getConnection 后异常路径没 returnConnection,池 5 条被耗尽后全员 cond_.wait 卡死 → 修复用 Defer RAII(const.h:43-55)保证归还;同类问题 GateServer/VarifyGrpcClient.cpp:11-19 仍存在
  2. 越界类:4 字节头解析处必须先判 _buffer.size() >= 4 再取,否则半包时读越界(客户端 tcpmgr.cpp:33 就是先 size() < 4 直接 return;服务端没有 OnRead,是 asyncReadLen 递归补读 CSession.cpp:212-233,天然不会越界)
  3. 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(grep spdlog/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 模块)批量建连压心跳,代替手动开客户端

④ 流程(体现工程素养):

  1. 复现 → 固定步骤/最小用例
  2. 定位 → 日志缩小范围 → 断点/gdb 精确到行
  3. 修复 → 加回归用例
  4. 验证 → ASan/valgrark 跑一遍确认无新问题

介绍项目的亮点

按「有代码证据 + 有数字 + 有对比」挑 3-4 个讲,不要罗列:

  1. 多线程网络模型的取舍:N 个单线程 io_context 而不是 1 个多线程 io_context AsioIOServicePool 起 hardware_concurrency() 个 io_context、每个只 1 线程 round-robin 分连接(AsioIOServicePool.cpp:11-28)——拿到多核并行,又天然免掉 strand 锁。很多人大而化之「asio 多线程」,答不出为什么不用 strand,这是区分度。

  2. 业务单线程 + 消息队列,消灭业务层数据竞争 IO 线程只做拆包入队,所有业务回调在一个线程里跑(LogicSystem.cpp:22-81)→ 好友表、session 表全程无锁。并主动讲它的代价(锁覆盖回调是瓶颈)和修复方案(出队先 swap 释放锁)——知道边界的亮点才是真亮点。

  3. 跨服路由:Redis 一张 uip_ 表解决「用户在哪台机器」 uip_<uid> = server_name,同机走内存直发、跨机走 gRPC(LogicSystem.cpp:871-908)。把「分布式定位」简化成一次 O(1) Redis 查——上万连接不用广播,两台 ChatServer 热插拔只改 config。

  4. 诚实的缺陷清单(反向亮点) 心跳、离线消息、消息落库、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)

关键设计点(被追问时展开):

  1. 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)
  2. 申请单只有 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) 联合索引那回事,历史申请会全部重新渲染
  3. 这是明确的缺陷,主动讲:ON DUPLICATE KEY UPDATE status=0 会把已经同意过的好友申请重新打回待处理(再申请一次同一个人 → 红点重现、对方收到重复通知);也没有幂等错误码,重复申请只是静默覆盖 descs。如果让我做:申请单加 status=0/1/2/3(待处理/同意/拒绝/过期)+ handled_at,GetApplyList 追加 and status=0,重复申请先查状态直接返回错误码
  4. 三档策略 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。这是产品化的点,别漏讲
  5. 权限:拉黑/陌生人不能申请(项目里简化了,改进点可提)
  6. 资料快照:推送申请时带 name/icon/sex/nick 字段(LogicSystem.cpp:548-558),B 不在线很久后同意也不用再查 A 的最新资料
  7. 已知字段不一致(真实 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 同意了」这个结果。

本项目的实际实现(两段式,依托登录拉取):

  1. B 同意的瞬间 A 在线: 服务端 LogicSystem::AuthFriendApply 调完 MysqlDao::AuthFriend(单事务把 friend_apply.status=1 和 friend 两行一起落库)后,查 Redis uip_<touid>——这里 touid 就是申请方 A——A 在线则同机 session->Send(ID_NOTIFY_AUTH_FRIEND_REQ=1015, {fromuid: B, name, nick, icon, sex})(LogicSystem.cpp:811-836),跨机走 gRPC NotifyAuthFriend(:838-843,对端 ChatServiceImpl.cpp:49-91)——和聊天消息完全同一条转发链路(「消息如何识别与转发」)

  2. 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 层):

  1. io_context(旧称 io_service):事件循环 + 任务队列。run() 就是「取消费完成事件/回调」,本质是 Proactor 模式的容器
  2. 异步操作 + 完成回调:async_read_some / async_write / async_accept / async_connect——每个都带 handler(error_code, bytes_transferred)
  3. 同步对象包装: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 就是为此新增的

当前实现的三个真实缺陷(主动讲,比背架构分高):

  1. 没有 TURN 兜底:mediaengine.cpp:640-642 只有 stun:stun.miwifi.com:3478 / stun:stun.l.google.com:19302,对称 NAT 或 4G/5G 下打洞失败就 sig_media_failed 挂断,没有中继可走——生产必须自建 coturn 并下发长期凭据
  2. 信令不校验身份:DealCallSigMsg 直接取 root["fromuid"](LogicSystem.cpp:918),从不和 session->GetUserId() 比对(全项目只有 RefreshFriendList 在 LogicSystem.cpp:993 做了这个校验)→ 冒充别人发起通话、发 1017 文本消息都行;改法是每个 handler 入口 if (uid != session->GetUserId()) → 回错误码
  3. NAT 映射没有保活:ID_HEART_BEAT_REQ(1023) 只有枚举没实现(const.h:84-85,呼应「心跳机制」),打洞出来的 UDP 映射空闲被 NAT 回收,长通话会掉;改法是通话中每 15-30s 发 STUN 绑定请求(或应用层 keepalive)

如果让我增强(设计题的增量部分):

  1. TURN 兜底:部署 coturn,rtc::Configuration 加 turn 服务器 + 凭据,STUN/relay 候选都留
  2. 弱网:码率/分辨率自适应、断线自动 ICE restart 重协商——现在是直接挂断
  3. 多人/连麦才上 SFU(mediasoup/Janus)——单人对聊 P2P 已经够了,这是很多人答错的点;录制也必须中心化,那是另一条链路
  4. 通话产品化:通话记录落库、未接来电推送(callwindow.cpp 的 UI 已经有了,缺服务端持久化)
  5. 安全:信令加 fromuid 校验 + 好友关系校验,SDP/ICE 只接受该通话 call_id 关联的对端

一句话:信令 1046/1047/1048 走现有 TCP + 跨服 gRPC(轻、复用 uip_ 寻址),媒体走 libdatachannel P2P 直连(重、不过服务器)——已经实现;缺的是 TURN 兜底、信令身份校验、NAT 保活这三件,也是我下一步要补的。


项目中是否有关注过内存泄漏问题,如何检查内存泄漏

先答「有没有」:重点关注过两处真实的资源问题(把「关注」落到具体代码):

  1. 连接池归还泄露(仍未修复):ChatServer/MysqlDao.cpp:72——CheckEmail 查无结果时走裸 return true(不是异常路径,是正常返回),unique_ptr<SqlConnection> 出作用域直接把底层连接销毁、根本不回池;同一个类里 catch 分支(:75)和 CheckPwd(:116-118)都正确归还,唯独这条路漏了。丢 5 个槽位后所有调用者阻塞在 MysqlDao.h:81 的 cond_.wait。方案是改成 Defer RAII(const.h:43-55)——方案有、代码没改,这点必须说实话
  2. 同类问题仍在的: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 是否单调涨——慢泄漏要看趋势不是单点

排查方法论(比工具更重要):

  1. 怀疑泄漏 → 最小复现:写个循环脚本(建连→登录→断开 ×10000)跑 ASan,涨不涨一看便知
  2. 区分「泄漏」和「正常缓存」:连接池、日志缓冲是有意持有——看 ASan 报告的**「still reachable」vs「definitely lost」**
  3. C++ 特有三类:
    • new 没配 delete → 用 unique_ptr/shared_ptr 根治
    • 循环引用(shared_ptr 互指)→ weak_ptr 打破
    • 注册回调捕获 this(裸指针)→ 对象析构后回调悬空(UAF),本项目用 shared_from_this() 捕获规避(CSession)
  4. 本项目的结构性答案:核心对象全用智能指针(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 长连接的作用」「登录和聊天分别用什么协议」):

  1. 服务端要主动推送:B 回消息、好友申请、上线通知,都要服务端主动写——HTTP/1.1 一问一答做不到(长轮询/Server-Sent Events 是绕路方案)
  2. 握手开销:HTTP/1.1 短连接每条消息 1 个 RTT 握手;即使 keep-alive,中间层(代理/NAT)空闲也会掐断
  3. 全双工: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 里 :55 reply->set_token(...) → :56 insertToken 写 Redis(实现 :172-177)
  • SET utoken_<uid> <token> 写 Redis(当前无 TTL,永不过期——真实系统应设 7 天滑动过期)
  • 同时返回该 uid 负载最低的 ChatServer host/port

关键设计点(被追问时展开):

  1. token 放 Redis 不放 ChatServer 内存——ChatServer 挂了/重启,登录态全局不丢,换机器重连只需重发 1005(「心跳机制」「服务器无损切换」的基础)
  2. 验证发生在长连接上而不是 HTTP 上:HTTP 登录只发 token(凭证),真正的身份绑定在 TCP 的 1005——「换票 + 验票」两段式
  3. session 绑定即授权(设计如此,实现打了折):验证通过后 SetUserId(LogicSystem.cpp:417),理想情况后续所有消息从 session 取 uid、不再验 token——但实际只有 RefreshFriendList 用了 session->GetUserId()(LogicSystem.cpp:993),DealChatTextMsg:852、DealCallSigMsg:918 都直接信任 body 里的 fromuid,可以冒充他人发消息/发起通话(这也是掉线后必须重新 1005 的原因,但校验没落到每个 handler 上)
  4. 诚实的缺陷: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] 占位符,客户端解析成图片
  • 图片/文件(本项目未实现,给设计):
    {
      "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::post multipart)——这个接口本项目没写: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(...),代码像同步一样简单且不阻塞
  • 面试口径:「当前是同步,我会改成异步 + 出队先释放锁,两处一起改」

③ 超时考虑了吗(没做,但会设计——必须诚实):

  • 现状:没有。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 连接」是经验值不是实测——别编数字,面试官一追问细节就穿帮
  • 会怎么做(三件套):
    1. 连接数:脚本批量 connectToHost + 每 30s 发登录,看 RSS/CPU/fd(ulimit -n 先放开)
    2. 消息吞吐:多客户端互发 1017,统计服务端消息/秒、p99 延迟(1017 发出 → 1018 收到的时间差)
    3. 工具:自写 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 到局部容器、释放锁后再执行回调——锁只保护容器不保护业务

其他共享点的控制(不止队列和连接池):

  1. 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 查找
  2. CSession::_send_lock:发送队列被业务/gRPC 线程与 IO 线程共享,入队(CSession.cpp:45,62)和写完成出队(:183)都加锁,靠「队列非空就不重复发起 async_write」保证同一时刻只有一个写在飞
  3. 连接池(MySQL / Redis / gRPC stub):多线程争抢 → 池内部 mutex + condvar(MysqlDao.h:79-100,锁成员 :126-127;getConnection 在 :81 cond_.wait、returnConnection 在 :100 notify_one),RAII Defer 保证异常也归还——但 MysqlDao.cpp:72 和 VarifyGrpcClient.cpp:16-19 两处漏了没归还(见前面内存泄漏那题)
  4. StatusServiceImpl::_server_mtx(StatusServiceImpl.cpp:74):StatusServer 被 gRPC 多线程并发调 getChatServer(),锁住「读 logincount → 选最小 → 改计数」这段
  5. 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) 最新 热门

登录 后才能评论。