脉脉 C++ 一面:网络编程、TCP 与 IM 长连接设计
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 项目开场:你做过网络编程吗?用的什么模型,最大支持多少连接?
- TCP 三次握手?为什么不是两次或四次?
- TCP 拥塞控制的四个阶段?讲一下慢启动。
- TCP 粘包拆包怎么处理?IM 协议怎么设计?
- IM 长连接怎么保活?心跳包怎么设计?
- 断线重连怎么做?消息怎么补?
- 服务端十万长连接,你怎么设计架构?
- 写扩散和读扩散?IM 群消息怎么选?
《参考解析》
Reactor 与连接数要答出「模型 + 指标 + 踩坑」。 单 Reactor 单线程(一个 epoll 循环处理 accept、读、写、业务)实现简单,但业务阻塞会拖垮整个循环;单 Reactor 多线程把业务丢给线程池;主从 Reactor 由主线程负责 accept、多个从 Reactor 负责 IO,适合高并发。连接数不是拍脑袋的数字,要结合内存(每连接读缓冲 + 写缓冲 + 连接对象)、文件描述符上限(ulimit -n、fs.file-max)、以及内核参数(somaxconn、tcp_max_syn_backlog)。十万连接的常见配置是:epoll 边缘触发 + 非阻塞 IO、每连接固定小缓冲、避免每连接一线程、开启 SO_REUSEPORT 多进程分担、以及调大 fd 限制。踩坑可以讲:惊群(多进程 accept)、假死(心跳缺失导致半开连接堆积)、雪崩(大量连接同时重连)、以及内存碎片。
三次握手为什么是三次。 核心是「双方都要确认对方的收发能力,并且同步初始序列号」。两次的问题:服务端收到 SYN 就建立连接并分配资源,但客户端可能根本没收到 SYN-ACK(比如延迟的旧 SYN),服务端就挂着一个无人使用的连接,既浪费资源又会被 SYN Flood 利用。四次则没必要:服务端的 ACK 和 SYN 可以合并成一个报文(SYN-ACK),三次刚好完成「客户端确认自己能发、服务端确认自己能收能发、客户端确认服务端能收」。四次挥手则是因为 TCP 全双工,两个方向要分别关闭:主动方发 FIN 只表示「我没数据要发了」,被动方先 ACK,等自己数据发完再发 FIN,所以需要四次;延迟确认和「被动方已无数据」时 ACK 与 FIN 可能合并,表现为三次,但不是常态。补一句 TIME_WAIT 等 2MSL 的原因:保证最后 ACK 能重传、让旧连接的报文在网络中消散。
拥塞控制四阶段。 慢启动:cwnd 从初始值(1~10 MSS)开始,每收到一个 ACK 就翻倍,按 RTT 指数增长,直到慢启动阈值 ssthresh;拥塞避免:cwnd 超过 ssthresh 后每个 RTT 只加 1 MSS,线性增长;快速重传:收到 3 个重复 ACK 就立即重传丢失报文,不等超时;快速恢复:把 ssthresh 设为当前 cwnd 的一半,cwnd 设为 ssthresh(或 +3 MSS),进入拥塞避免,而不是回到慢启动。超时重传则更严重:ssthresh 减半、cwnd 回到 1,重新慢启动。要能解释「慢启动不慢」:初始窗口保守是为了探测路径容量,指数增长很快;以及现代内核的 CUBIC/BBR 与经典 Reno 的差异(BBR 基于带宽和 RTT 建模,不把丢包一律当拥塞)。
粘包拆包与 IM 协议。 TCP 是字节流,没有消息边界,所以「粘包」不是 TCP 的 bug,是应用层没定边界。三种解法:固定长度(浪费、不灵活)、分隔符(内容里不能出现分隔符,要转义)、长度字段(最常用)。IM 协议通常设计成「固定头 + 变长体」:magic(魔数)、version、header length、body length、command、sequence、flags、checksum,body 用 protobuf/JSON。要补:消息要有全局唯一 msgId(用于去重和幂等)、会话 id、发送时间、以及 ack 机制;上行下行都要有确认,超时重传要分级(普通消息尽力而为、指令消息必须确认)。大消息要分片,分片带总片数和序号,接收端重组并设超时。
心跳与断线重连。 心跳解决三个问题:探测半开连接(NAT 表项过期、对端进程卡死但 TCP 没断)、维持 NAT/防火墙映射、双向证明存活。设计上客户端定时发 ping、服务端回 pong,间隔通常 30 秒到几分钟,按网络类型调整(WiFi 可以短、移动网络考虑省电);服务端也要主动探测,否则无法判断客户端是否真在线。超时判定要连续多次失败,避免一次抖动就断线。重连用指数退避 + 随机抖动(1s、2s、4s… 加 jitter),防止大量客户端同时重连打爆服务端;重连成功后先拉离线消息,再同步会话状态。消息补齐靠序列号:客户端上报「已收到的最大 seq」,服务端从该点之后推送;如果 seq 有空洞或服务端消息已过期,就走全量同步兜底。多端在线还要同步已读位和编辑状态,这是 IM 最容易出 bug 的地方。
十万长连接的架构。 分三层:接入层(多台网关,按用户 id 哈希或一致性哈希固定落到某台,网关只做协议解析、鉴权、路由,不执行业务)、逻辑层(无状态服务,处理消息、关系链、群成员、写扩散/读扩散)、存储层(消息表、会话表、离线消息、在线状态)。关键组件:在线表(Redis 存 user_id → gateway_id)、消息总线(网关与逻辑层之间用 Kafka/RocketMQ 解耦)、可靠投递(ack + 重试队列 + 去重表)、以及连接迁移(网关故障时客户端重连到新网关,重新注册路由)。扩容时网关可以水平加机器,但要注意「同一用户固定网关」带来的热点;可用一致性哈希 + 虚拟节点均衡。监控要看连接数、心跳失败率、消息延迟、以及各网关的 fd/内存。
写扩散与读扩散。 写扩散(推模式):发消息时给每个群成员写一份收件箱,读的时候直接读自己的收件箱,读快、写放大,适合小群、成员活跃度低、读远多于写的场景。读扩散(拉模式):消息只存一份,读的时候按群成员关系拉取并合并,写便宜、读放大,适合超大群(万人群)、成员多但活跃少的场景。IM 群消息常用混合策略:小群写扩散、大群读扩散,按群规模阈值切换;同时用「在线推、离线拉」降低无效写。要补代价:写扩散的存储成本与扩散延迟、读扩散的聚合成本与分页一致性;以及删除/撤回消息时两种模式各自的处理复杂度。