面试做多了之后,我对“IM面试题”这一类题目特别有感情。不是说它有多难,而是它实在太适合用来分辨候选人是在背八股,还是真的理解过系统。有一次我出过这样一道题:“用户A给用户B发一条消息,从点击发送按钮开始,后台到底发生了什么?”能提到WebSocket、TCP长连接、心跳这些名词的人很多,但能把消息ID、ack、重传、离线补偿、多端同步这些环节全部串起来、且每一个点都经得起追问的人,我印象里真的不多。这一篇不谈题库,我想把这条考察链路完整拆开,聊聊面试官在看什么,以及候选人怎么回答才算真正过关。如果你正在准备面试,或者你需要给团队出这套题,都值得读下去。
1. 面试官到底在看什么:从一道“发消息全流程”题目说起
1.1 为什么这道题能当试金石
先说说为什么很多面试官喜欢拿IM场景开刀。原因其实很简单:发消息这个动作,背后牵扯到的技术栈非常密集。网络通信、并发模型、分布式状态同步、存储方案、幂等去重、弱网容错、多端场景,几乎每一个后端开发的经典分支都能在IM里找到一个具体落点。
面试官出“发一条消息的全流程”这种题,表面上是让你讲流程,实际上他在听三个层面的东西:
- 第一层,你知不知道有哪些环节,比如应用层收到请求、存储、推送、ack、离线补偿;
- 第二层,你能不能解释每个环节为什么存在,比如为什么需要ack、为什么离线消息不能只靠推送;
- 第三层,你面对追问时能不能扛住,比如“消息突然重复了怎么办”“服务端还没ack客户端就断了怎么办”。
这三个层面分别对应三种能力:系统广度、原理深度、实战韧性。很多候选人死在第二层和第三层之间,因为第一层的东西背一背就能说全,但再往下问就露馅了。
1.2 面试中怎么表达才算合格
我见过一个比较好的回答,节奏是这样走的。先分阶段:发送端处理、服务端处理、接收端处理、失败补偿。然后每个阶段再展开关键点,发送端说消息ID生成、本地草稿、重发策略;服务端说接入层鉴权、消息路由、持久化、推拉结合;接收端说ack、去重、多端同步。末了再补一句“以上是正常路径,还要考虑连接断开、消息重复、接收端离线这些异常分支”。
这个回答之所以好,不是因为他把每个技术名词都堆上去了,而是因为他始终带着“如果这是我自己的系统,哪里会出问题”的心态在组织内容。这种结构化的表达能力,恰恰是工作两三年以上的工程师应该具备的。
2. 长连接与心跳机制:近乎必考的“网络地基”
2.1 长连接解决的问题比你想的多
IM和普通请求-响应系统的最大区别,在于服务端需要主动往客户端推数据。你给别人发消息,对方的消息通道上可能同时有十个人给他发内容,如果全靠客户端定时轮询,不仅延迟不可控,网络开销也大得离谱。这时候就必须有一条客户端和服务端之间的长连接,让服务端能随时把消息顺着这条通道推下来。
所以第一个必考点就是长短连接的取舍。你要能说清楚:短连接每次请求都要重新走TCP握手,如果算上TLS握手,成本更高;而长连接建立一次连接后可以复用,适合高频双向通信。反过来,长连接也带来新问题——怎么维护这条连接,怎么感知它断了,自然就引出心跳机制。
2.2 心跳的真正难点在状态判断
很多人把心跳理解成“客户端每隔一段时间发一个包给服务端”,这个理解没错,但太浅了。心跳真正解决的问题是两端对连接状态的一致性判断。
网络环境是很不可靠的。路由器重启、手机从Wi-Fi切到4G/5G、NAT映射过期,都会让一条TCP连接变成“半开连接”——一端以为还活着,另一端其实早就把它断掉了。更麻烦的是,这种断开往往没有FIN包通知你,因为中间设备的清理是静默的。
所以应用层心跳必须自己做:客户端定时发心跳包,服务端如果在超时时间内没收到任意数据包,就判定连接失效,然后回收连接、标记用户离线、触发补推逻辑。这里面有两个细节值得在面试里讲出来:一是服务端要更新“最近收到任意数据包”的时间,而不是只更新“最近收到心跳包”的时间;二是超时阈值不能设得太死,一般会设成心跳间隔的2到3倍,容忍偶发的网络抖动。
2.3 一个真实的半开连接故障复原
我在自己的实践里踩过这种坑。当时的服务端逻辑是:客户端每隔40秒发一次心跳,服务端超过60秒没收到就断开连接。看起来合理,但在某次网络出口设备调整之后,大量在线用户的服务端连接在半夜被静默清掉,客户端却浑然不知,直到用户主动发消息失败才触发重连,期间消息推送大面积丢失,只能靠用户下次登录时的拉取来弥补。
这个故障本质上就是心跳机制只看“有没有发包”,没有做双向探测。后来我们改进了一个小地方:客户端发心跳时带上一个自增序号,服务端在心跳响应里回显这个序号;客户端发现连续几次收到的心跳响应序号不是自己最新发的,就主动重连。另外,客户端本地还会维护一个假离线状态,一旦发现TCP连接断开,就把这期间产生的消息标记为待重发,等重连成功后再补。这才是心跳机制完整的设计思路,面试时能说到这一步基本就很加分了。
3. 消息可靠性链路:ack、重传、去重与离线补偿
3.1 消息ID与全局有序的由来
聊到消息可靠性,离不开消息ID。很多人不理解为什么需要服务端重新生成一个全局有序的消息ID,客户端自己用UUID生成一个不就行了吗?
关键在于排序。聊天记录要按时间顺序展示,如果每台客户端各自生成ID,服务端就无法为同一个会话里的历史消息做全局排序,多端拉取出来的顺序可能完全不一样。所以业界通行做法是:客户端生成一个临时的本地消息ID用于标识幂等,服务端收到后再生成一个全局单调递增的消息ID,真正的排序、已读游标、离线增量都围绕这个服务端消息ID展开。
有一个边边角角的知识点也常被追问:全局单调递增不等于严格按时间排序。服务端收到两条消息的先后顺序就是排定顺序,哪怕这两条消息实际发出的时间倒挂,展示时也以服务端顺序为准。这里的本质是把“排序权”从客户端手里收回到服务端手里,避免分布式场景下各个客户端时钟不一致带来的混乱。
3.2 ack与重传策略怎么跟面试官讲
ack是可靠投递里的核心概念,但只说出“收到消息后回复ack”是不够的。要区分两件事:客户端收到消息后要给服务端回ack,告诉它“我收到了,你可以从这个连接上的重发队列里移除了”;服务端收到客户端发来的消息后也要回ack,告诉客户端“你的消息我已经落到存储了,你本地那个转圈状态可以消掉了”。
这里有个隐藏的难点:ack丢了怎么办?客户端和服务端都没法区分“对端没收到”和“对端的ack在路途中丢了”,所以只能靠超时重传。客户端超时后重新发送同一条消息,服务端靠消息ID做幂等,发现已经处理过一个ID,就直接回一个最新的ack,不再重复写存储。这套“超时重传+幂等去重”的组合,意味着系统大概率会出现重复消息,但重复不会造成数据错乱。
面试追问里经常出现这种问题:那消息是不是一定不丢?诚实但高水平的回答是:IM系统里普遍采用“至少一次”的投递语义,在服务端持久化成功后,即使某台服务器宕机,消息也还在,重连后可以重新拉取。如果非要做“恰好一次”,要引入分布式事务或更复杂的去重机制,对消息场景来说往往不划算。
3.3 离线消息与拉取兜底
长连接只对在线用户有意义,用户离线时消息怎么办,这道题答得好不好,直接决定面试官对你系统观的判断。我看过太多候选人讲到推送就结束了,完全不提离线消息,这就是系统观缺了一块。
标准答案是:每个用户维护一个离线消息区,或者按会话记录拉取游标。用户离线期间到达的消息,服务端落到离线存储中;用户重新上线时,长连接建立成功之后先触发一次增量拉取,把本地游标之后的消息补回来,然后再进入实时推送模式。这里通常要设计成“推拉结合”——刚上线时先推一条“你有新消息”的信号,客户端再拉取详细内容,避免在连接刚建立、消息量还不确定的时候盲目推送,造成连接的瞬时拥塞。
拉取兜底还有一个额外好处:它天然是修复状态不一致的最终手段。哪怕推送全挂了,只要用户登录后能拉取,消息就不会丢。所以面试时如果能说一句“推送是优化路径,拉取是最终兜底”,基本就能体现出你做过多端消息系统的经验。
4. 多端同步与已读回执:状态一致性的一次小型考试
4.1 已读回执的本质
聊完消息投递,面试官很容易把话题引到已读回执上。为什么?因为已读回执看起来是个小功能,实际却是一个很标准的分布式状态同步问题。
以单聊为例,最简单的已读模型是“每个会话维护一个已读位置”,也就是某用户在这个会话里读到哪条消息为止。A给B发了三条消息,B读到第一条,已读位置就是第一条;B读完所有,已读位置就到第三条。这个已读位置要存到服务端,因为A的设备需要知道B到底读到了哪里,才能正确展示“已读/未读”的状态。
但难点来了:现在的产品普遍支持同一个账号在手机、电脑、平板等多端登录。B的手机已经读完了全部消息,B的电脑可能还停在两天前。这时“B是否已读”就从一个布尔值,变成了一个需要按设备分别计算的位置集合。群聊里更复杂,得分别记录每个成员读到的位置,形成一个已读成员集合。
4.2 版本号与增量同步
多端同步的工程实现,核心是一个单调递增的同游标机制。服务端为每个会话维护的已读位置不直接推全量,而是下发一个“已读事件”,里面带上会话ID和新的已读游标。A的手机收到这个事件后,只会把这个会话的已读位置往前挪,并不会重新拉取整个历史列表。
每个设备还会把自己本地的已读游标定期上报给服务端。服务端发现设备上报的游标比服务端记录的还新,就更新记录;发现更旧,就忽略。这套机制的关键是“游标单调递增”,任何设备都不能把一个大的已读游标回退成小的,否则会出现已读状态反复横跳的诡异体验。
4.3 常见的错误设计
这道题最容易暴露的坏毛病是先写实现、不写状态。举个例子:有人会设计一个接口,每次客户端展示聊天列表时,把本地未读消息ID列表传给服务端,服务端再去逐条标记已读。这种设计在特殊场景踩过坑:设备A拉取了十条消息,但还没上报已读;设备B也拉取了同样的十条,已读了其中三条;两个设备在短时间内同时把已读记录上传,结果互相覆盖,未读数会出现先减后加的神奇现象。
正确的思路永远是:服务端记录的是“每个会话、每个设备的读取进度”,而不是“哪些消息被谁读了”。读取进度是个连续的位置,天然适合用整数游标表示,也天然适合做增量同步。如果你在面试里能主动讲出“已读回执本质上不是对消息集合的操作,而是对游标位置的操作”,面试官会记住你的。
5. 高并发消息存储与推送:容量和延迟的平衡
5.1 单聊与群聊的存储模型差异
聊完状态同步,再往下往往就是系统设计题:如果在线用户很多,消息量很大,怎么设计存储和推送?这个问题没有标准答案,但有两个模型必须会区分——读扩散和写扩散。
读扩散是“消息只存一份,收件人读的时候去遍历自己的会话列表拉取消息”,优点是写放大很小,适合读写比较均衡的场景;写扩散是“发送一条消息时,给群内每个成员的消息列表都插一条记录”,读很快,但写放大明显,一个五千人群发一条消息就产生几千次写。实际系统一般不会只用一种。单聊天然适合读扩散,因为只有一条消息、两个参与者,不需要复制;群聊要看群规模,比如几十人的小群可以用写扩散保证低延迟,几百人以上的大群就要转化为读扩散,否则一次群发的写放大成本会失控。
面试官丢出“群聊按人数切换扩散模式”这种设计时,你要能接住追问:阈值定多少?哪类群适合写扩散?切换后历史消息怎么兼容?能想到这些问题,说明你真的在从成本和体验两个维度做取舍,而不仅仅是在背架构图。
5.2 消息ID生成与时钟回拨
前面提到服务端要生成全局有序的消息ID,实际工程里最常用的方案之一是时间戳结合机器标识和自增序号的发号方案。它的核心是ID二进制里形成“时间部分+机器序号+序列号”的结构,这样既有序,又能在分布式节点各自独立发号,不用每次去请求一个中心节点。
这个方案有个经典坑:时钟回拨。如果某台机器因为NTP校准把时间往回拨了几十毫秒,而它的发号器还按本地时间生成ID,就可能出现新生成的ID比之前小,导致消息乱序。我曾在一个内部系统里见过这种脏数据:一台机器回拨后,用户新发的消息排到了几分钟前的旧消息前面,整个会话时间线直接乱掉。
处理办法也分几档。最简单的:记录上一次发号的时间戳,如果发现当前时间小回去了,就在本机内存里等一会儿,等时间追平再继续发号。严厉一点的:宁可让这个节点暂时拒绝发号,也不允许发出逆序ID。
5.3 推送路径上的排队与批量
存储之外,推送链路也要扛住瞬时压力。热点事件发生时,一个大群可能在几秒内涌入大量消息,如果每条消息都实时触发一对一的连接写操作,接入层的线程池会被打满。
成熟的方案是引入队列削峰:发送消息先写入消息存储,再以异步方式投递到推送组件。推送组件同时做两件事,一是按用户聚合,把同一个用户的多条消息合并成一次下行推送,降低网络包数量;二是做批量控制,防止单个连接被突发的消息量冲垮。这种“写了一条消息,但可能会触发多次推送”的异步链,还会带来新的问题:推送失败后如何补偿。答案又回到上一章讲的拉取兜底。你看,整个IM系统就是一套互相兜底、互相补偿的逻辑链。
6. 复盘:候选人最容易翻车的五个细节
6.1 从背诵到理解的差距
这些年我接过很多简历,把“熟悉IM系统”写进技能栏的不少,但面到细节就参差不齐了。我总结了五个最容易翻车的地方:
第一,只会讲协议,不会讲状态。候选人能说出WebSocket是长连接,但问他服务端怎么判断用户掉线,就含糊其辞。第二,把在线状态和消息通道混为一谈。在线状态只是“当前有没有活跃连接”的一个业务表现,它不等于消息一定能实时送达,两者要分开设计。第三,只考虑在线接收,不考虑离线补偿。开口闭口推送,问到“用户当时断网了怎么办”就答不上来。第四,不做幂等设计。认为消息发一次就是一次,完全没有重发和去重的概念。第五,不画过程。讲解整个链路时东一句西一句,没有输入、处理、输出、异常分支的脉络。
你要是能对着白板,把“发送端->接入层->存储->推送->接收端->ack->补偿”这条主链路画出来,再把异常分支自然接上,就已经超过很多候选人了。
6.2 怎么用一个小项目完成闭环
复盘之后我想说一点真心话。如果你想认真准备IM相关的岗位,与其花时间背几十道面试题,不如动手写一个极简版本的和通讯Demo。不需要做界面,只需要一个服务端、两个客户端,实现这几件事:建立TCP连接、应用层心跳、消息发送和确认、断线重连、本地待重发队列、离线消息拉取。
这个小型实践做完,你对“为什么要有消息ID”“为什么推送需要拉取兜底”“为什么心跳超时不能设得太长”的理解会完全不一样。因为你在真实环境里遇到过那种“连接看起来还在、消息就是发不出去”的时刻。面试的时候,这种亲身踩坑带来的表达细节,是任何背诵都替代不了的。
如果时间允许,再多想一层:把单机服务扩展到多节点,消息存储从内存挪到通用存储,推送从直连改为异步队列,每一次改动都会让你对分布式系统的理解更进一步。到面试现场,你就不再是背题的人,而是真正做过这个系统的人。