ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

聊天系统设计面试:从需求到高可用的完整架构复盘

聊天系统设计面试:从需求到高可用的完整架构复盘

聊天系统设计面试,到底在面什么?——一次完整的架构拆解与实战复盘

“聊天系统设计”这道题,初看不难,但真正能答好的候选人寥寥无几。我做后端面试官这些年,几乎每轮都会拿它做考察,它也确实是高频中的高频。很多人一上来就画网关、挂Redis、上Kafka,方案听起来很全,但一问到“怎么保证离线消息读不到”“两个端的消息顺序为什么乱了”“只让用户看到未读的50条要怎么存”,就答得含糊了。

这篇文章,我想换个角度来复盘这个题目。不给你背一套面试答案,而是把我这几年准备和考察这道题的心得,拆成一套从需求对齐到高可用设计的完整过程,把每步为什么这么做、哪些细节最容易翻车,都一并交代清楚。无论你两周后就要面试,还是刚开始系统设计入门,都应该能从这里拿到一套直接能用的思考框架。

1. 面试官到底想听什么:从“闲聊式提问”看系统设计的考核重点

你回答这道题的姿态,其实暴露了你的真实经验水平。面试官问“聊天系统”三个字的时候,他脑海里有三种答案样板:第一种是背题型,一口气吐出“TCP长连接、分布式消息队列、NoSQL存储”这个三板斧,词汇都对,但问深一层就碎;第二种是局部深挖型,逮住一个点猛讲,比如把消息队列的幂等和顺序说得头头是道,但对整个系统的边界、成本、演进路径完全没有概念;第三种是全局把控型,不急着画图,而是先花三分钟把需求边界、用户规模、一致性要求这些前提理清楚,再层层展开,并始终让面试官觉得“这部分是重点、那部分可以简化”。

这道题的核心考核维度不是“知识量”,而是下面四点:

  • 需求分析与边界划定。聊天的功能听起来简单,但“单聊还是群聊”“要不要已读回执”“离线消息保留多久”“多端同步怎么做”,这些都会让方案走向完全不同的分支。能不能主动问清楚这些,而不是闭着眼假设,是区分有经验和没经验的第一道分水岭。
  • 技术选型的取舍度量。选WebSocket还是TCP长连接,选推模式还是拉模式,选RocketMQ还是Kafka,你怎么说得出“为什么是这个,而不是那个”,背后用到的是协议原理、业务特征、运维成本的综合判断,不是背名词。
  • 一致性、可靠性与性能的权衡。在网络不稳定的现实世界,消息会不会丢、会不会乱、会不会重复,每一类问题都有对应的解决思路。这一部分最考验工程直觉。
  • 表达和节奏感。把“时间分片”“长连接心跳”“离线消息补偿”这些点组织成一条故事线讲出来,和零散地想到哪说到哪,效果天差地别。面试官不会打断你,但他在心里已经给结构感打了分。

所以如果你现在正为这道题焦虑,先不用去背新知识,而是建立一个完整的分析框架,让所有知识点在框架里有各自的位置。

2. 功能拆分与核心设计思路:聊天系统的基础蓝图

2.1 别急着画架构图:先和面试官对齐两个边界问题

有不少候选人一上来就画了一堆组件,但忽视了“聊天系统”四个字本身存在巨大的解释空间。我给自己定了一条习惯:开口第一句话,永远是确认需求。

我会先问面试官两个问题:“咱们要聊的是单聊、群聊还是一个类似社区的产品?对消息不丢失的要求是强一致,还是允许极端情况下丢一条,换来更低的延迟?”这两个问题直接决定了后面协议和存储的取舍。如果是面试场景,对方通常会给你一个相对宽松的空间,比如“按日活百万、群聊几千人的规模来设计”,这就够了。

在拿到明确前提的前提下,我习惯把聊天系统拆成四个子域:

  • 连接层:负责维护客户端与服务器之间的长连接,完成消息的收发通道。
  • 消息处理层:负责消息的落库、扩散、推送,以及离线消息的存储与获取。
  • 在线状态与推送层:负责维护在线状态,并触发实时推送。
  • 检索与辅助功能层:负责历史记录、会话列表、未读计数这类外围能力。

这四个子域不是物理分模块,而是逻辑上的分析单元。我发现大多数候选人把时间耗在了“连接层和消息队列”上,而忽略了离线消息存储、会话列表设计、多端同步这些问题。但恰恰是后者,往往能决定一个聊天系统做出来好不好用。

2.2 从登录到消息落库:拆出四个核心子域

第一个子域是连接层。移动端的网络环境,决定了我们必须用长连接来承载实时消息。经典方案是客户端和接入层之间建TCP或WebSocket长连接,一个进程管理数万连接,内部用内存缓存连接元信息,例如用户ID到连接ID的映射,再挂一个心跳机制识别死链。

第二个子域是消息处理层。客户端把一条消息发到接入层之后,接入层在这里做合法性校验、内容过滤、消息ID生成,然后投递给消息队列。这里要控制好粒度,一条消息对应一个消息体,但一个群聊中,一条消息可能要被拆成N份投递给N个群成员,这就是后面要讲的“扩散写”策略。

第三个子域是在线状态与推送层。这个层主要负责和Redis打交道。在线状态存一份,用户会话列表缓存一份。推送的活其实很脏很累,因为客户端永远会离线、弱网、杀后台,推送层必须和连接层紧密配合,才能把“在线即时收、离线等会拉”这件事做顺。

第四个是持久化与索引层,承担消息历史、会话列表、未读数的存取。这一层是数据量最大的地方,也是整个设计的容量天花板。很多方案在这个环节用了MySQL当主库,配合对象存储存历史消息,再加一个NoSQL兜底热点会话。

我在面试复盘时最大的体会是:能在五分钟内把这四个子域画清楚的人,即使细节有些瑕疵,面试官也会愿意给高分,因为他的全局视野是完整的。

3. 关键协议与消息可靠性的深入拆解:长连接、心跳与消息不丢不乱不重

3.1 为什么新系统主流选WebSocket,而不是裸TCP或HTTP轮询

面向面试的设计,协议选型是一个绕不开的话题。常见的选项有这么几类:HTTP短轮询、HTTP长轮询、SSE(Server-Sent Events)、WebSocket和裸TCP自定义协议。它们各有适用场景,但放到聊天这个场景里,主流的答案基本集中在WebSocket。

HTTP短轮询是最直观的方案,客户端每隔几秒问一次服务器“有没有新消息”,适合IM工具的前身,比如一些后台管理系统的简易站内信。它的缺点显然易见:延时不可控、空请求浪费带宽、对服务器压力大。到了聊天这种需要“及时性”和“双向通信”的场景,它不够格。

HTTP长轮询是短轮询的改良版,服务端把请求hold住,有消息再返回。它比短轮询节省了请求次数,但依然存在连接超时、重复建连、长连接占用等问题,属于过渡方案。在移动网络环境下,这个方案不如WebSocket稳定简洁。

WebSocket的优势在于一条TCP连接上实现了全双工通信。客户端和服务端都能主动发数据,且协议自带掩码、分片、心跳等机制,成熟度高。对聊天来说,它是成本和实现难度最平衡的方案。

还有个容易被忽略的点:并不是所有聊天场景都需要WebSocket。如果是一个异步客服工单系统,用户发一条消息,客服过几分钟才看到,那用HTTP轮询加Redis队列反而简单得多。面试时你可以主动提一句“这里我假设是即时聊天,所以用长连接”,这会让面试官觉得你会根据场景选型。

至于裸TCP自定义协议,性能更高,但协议设计、编解码、喘包拆包都需要自己做,适合竞品级别的、追求极致性能的团队,不适合普通项目。

3.2 心跳保活、断线重连与消息补偿的常见工程细节

长连接建立之后,第一个坑就是连接保活。移动网络下,连接会被运营商、系统省电策略、网络切换打断,客户端和服务端都不能确定连接是否真的存活。所以必须有心跳机制:客户端每隔一段时间发送一个心跳包,服务端如果在两个周期内没收到,就判定连接已死,把连接元信息踢掉。

心跳间隔的选值有学问。太短会让服务端压力大,太长会让消息丢失窗口变大。常见做法是30秒到75秒之间,取值再乘以超时倍数。例如Android系统网络库对省电模式敏感,心跳间隔如果是45秒,超时判断就得给到90秒左右。我自己的经验是“心跳要带业务数据”,也就是在心跳包里顺便带一下客户端的最后同步时间,服务端可以根据这个时间判断离线消息的拉取起点。

断线重连需要设计退避策略。客户端掉线后不能一秒钟重连一次,这样会让服务端进入雪崩。常见做法是:第一次立即重连,然后以指数级别拉长间隔,2秒、4秒、8秒,最多拉到60秒封顶。每轮重连成功之后,客户端要做的第一件事不是恢复收消息,而是先拉取离线消息和未读会话,这里就需要一个“同步游标”概念。服务器为每个用户保存一个最新消息序号,客户端重连后带上来自己的最后序号,服务器比对后把缺失区间补发过去。

消息补偿主要靠“发送端重试+幂等”和“服务端去重”。客户端发消息时,本地会生成一个全局唯一ID(通常是UUID加递增序号),服务端收到后先落到一个“去重表”里,同一个ID只做一次真正的写库。这样即使客户端因为网络超时重发了三次,消息也只会被存一条、被推一次。我见过很多项目在这一点上偷懒,结果线上总有“同一句话收到三遍”的问题,客户体验非常糟糕。设计聊天系统,幂等是底层第一原则,不是可选项。

3.3 消息可靠性:不丢、不乱、不重,三层保障的逻辑

聊天的“可靠”不像交易系统那么严格,但在产品体验上,用户感知是很敏感的。可以把核心目标拆成三条:

消息不丢,基本手段是客户端本地先行写入一条“发送中”消息,然后发到服务端;服务端落库成功后再返回ACK;客户端收到ACK后才把状态改为“已发送”。如果客户端没收到ACK,就要自动重试。注意这里的ACK不是指对方已读,而是指服务端已接收。

消息不乱,单聊场景比较容易做到,因为同一个会话内的消息流由同一个连接承载,只要发送端序号连续,服务端按序号入库即可。群聊场景要复杂得多,同一条消息要分发给多人,必须为每个接收人维护独立的“拉取游标”,否则不同人的消息顺序就会错乱。

消息不重,除了前面提到的幂等设计,还要考虑接收方的去重。移动端的消息列表是按ID去重的,如果同一个消息ID被推送了两次,客户端只显示一次。服务端推送组件一般是“至少一次”语义,所以必须配合客户端去重才能达到“恰好一次”的用户感知。

在面试表达时,不用一上来就抛出这三大目标,而是应该顺着发送路径自然带出来。比如讲完连接层之后,自然而然地补充“一条消息从用户A到用户B,会经过客户端本地入库、服务端落库、ACK返回、推送到B、B的客户端确认已接收这五步”。这样面试官会觉得你的方案是一个完整闭环,而不是东拼西凑。

4. 群聊扩散、消息存储与多端同步的架构选择

4.1 群聊的“扩散读”与“扩散写”:两种模型怎么选

聊天系统设计里最考量架构功底的地方,是群聊场景。一个群有500人,一个人发消息,是往500个人的收件箱里各写一份(扩散写),还是只在群里存一份,等人来读时再聚合(扩散读)?

扩散写的好处是读路径很快,用户拉取自己的会话列表时,消息已经存在自己的收件箱里,直接按时间倒序读取即可。缺点是写放大量被放大:一个500人群,一天发1000条消息,就是50万次写放大;如果这个群有2万成员,那写放大就更恐怖了,基础设施很难扛住。

扩散读则是反过来的思路,群消息只存一份,拉到用户消息列表时,服务端临时聚合该用户所在的所有群的最新消息。它的优点是存储成本低,适合超大群。缺点是读路径复杂,需要维护“每个用户在每个群的最后已读位置”,还要做多路聚合排序,最后返回给客户端。

业界方案基本是折中:普通群(比如少于500人)用扩散写,直接把消息写到每个成员收件箱;超大群(比如上万人的“全员群”)用扩散读,动态拉取。这一点需要在面试时主动讲清楚,因为很多候选人只会说“我选扩散写”,而不会计算写放大的代价。不止一次,我追问一句“如果群有1万人呢”,对方就开始冒汗了。

4.2 消息内容与索引分离:一套支撑亿级消息的存储设计

聊到存储,就要对数据分类。聊天数据至少可以分成三类:消息内容本身、消息索引关系、用户与会话的元数据。

消息内容适合存对象存储,因为它是只写一次、很少修改、偶尔批量删除的冷数据。可以按“时间分片+会话ID分片”组织存储路径,例如 message/2024/06/{sessionId}/{msgId}.bin,这个设计让扩容和清理都非常好做。

消息索引关系需要存到数据库里,主要是为了回答这样一个问题:“用户A的所有会话有哪些?每个会话最新的消息是什么?”这一部分很适合用宽表存储,一个会话一行,每行存最新消息摘要和未读数,命中场景时读得非常快。

会话列表可以用Redis缓存,也可以用NoSQL。需要考虑的核心是读多写少。用户每天打开聊天App的次数,远大于他发消息的次数,所以会话列表数据适合多级缓存。缓存更新策略可以用“读时更新+写时失效”,尽量避免用复杂的一致性协议。

单聊消息表怎么设计也是一个经典考点。正确的做法是“双写一份”:消息表按照会话ID分片,每个会话两条记录收件人视角各一条,两个分片里各存一份内容快照。这样做的代价是存储翻了一倍,但换来了极快的会话拉取和天然隔离的用户数据。当然,如果数据量没那么大,也可以只用一张表加索引,但面试中能提出双写方案,往往会让面试官眼前一亮。

4.3 多端同步的核心:本地ID与云端ID的映射、同步游标、冲突解决

刷到聊天系统多端同步的题目,有些候选人就慌了,因为他们只会写单机版。多端同步的难点在于:用户在手机、电脑、平板上同时登录,消息状态要一致。

核心设计是“三列表”:设备表、会话同步状态表、消息同步队列。设备表记录用户的所有登录设备;会话同步状态表记录每个设备在每个会话里已经同步到的消息游标;消息同步队列负责把增量推送到所有在线设备。

客户端本地有一个自增ID,服务端也有一个自增ID。真正的消息ID以服务端为准,客户端本地ID只作为展示时的临时占位。服务端在ACK里返回云端ID和消息时间戳,客户端用它替换本地占位。这里要特别注意,不要用时间戳做主键,因为毫秒级时间戳在并发情况下极易重复。

“本地已读”和“云端已读”也需要分开存储。用户查看一个会话时,先把本地消息标记为已读,同步时把这个会话的“已读游标”上报给服务器,服务端记录并作为后续未读数的计算依据。如果两个设备几乎同时上报了不同的已读游标,需要一个简单的合并规则,通常取最大值(即已读位置更靠后的),这是最简单也最符合直觉的解决方式。

在实操中,多端同步做好之后,离线消息也会变得非常好处理。因为本质上离线消息就是“同步游标之后还没有推送给该设备的消息”。

5. 高可用架构与面试实战问答复盘

5.1 接入层状态迁移、Redis缓存与消息队列的协同工作

实际搭建架构时,我会用一组接入层节点专门维护用户连接,用Redis存储用户在线状态、会话状态和推送游标,用消息队列削峰填谷。

客户端A发送消息,请求先到接入层,接入层拿到消息后:

  • 先查询Redis,判断接收方B是否在线;如果在线,走长连接的推送通道;如果不在线,则不推送,等B上线后拉取。
  • 然后把消息写入消息存储集群和索引集群,这一步是异步的,主要为了降低用户感知延迟。
  • 最后给A返回ACK,A的客户端再把“发送中”状态更新为“已发送”。

如果B在线,推送动作也要异步化。因为推送组件要跟移动网络的不可靠性做斗争,如果同步推送,A的请求会被B的慢连接拖到超时。

这里你可以顺便把消息失败时的处理流程也讲出来:B在线但推送超时,消息队列会重试几次,还失败就标记为“待拉取”,等B下次同步时把它带回去。这个设计里,连接状态、离线游标、消息持久化这三者互相配合,才形成了一个干活不打架的整体。

我也遇到过一个候选人,他在这个环节选择不引入消息队列,而是用数据库应用层自研一个简单投递任务表。这在初期是可行的,只是到了日活千万级之后,任务表的锁竞争会成为瓶颈。面试时如果时间有限,我建议说“这里用消息队列来削峰和异步化”,同时补一句“小规模阶段也可以先用任务表顶住,看量级再演进”。这句话会显得你对“过度设计”有概念。

5.2 演练一次面试官的高频追问:一边答一边改方案

下面是一段我复盘过的面试对话,也是我见过最多的追问模式:

面试官:“你刚刚提到的扩散写,如果群里有几万个成员,怎么处理?”

候选人:“几万人的群确实少见,我会分成两种情况。五千人以下的群,我依然用扩散写,因为这类群的发消息频率不会太高,写放大的总量是可控的。真正的大群,我会切换成扩散读,群消息只存一份,同时为每个成员维护最后已读位置,用户拉取会话时做多路归并。”

面试官:“同一个用户多端在线,A端发了一条消息,B端怎么实时收到?”

候选人:“B端在线的话,连接层可以直接推送。但推送不能保证不丢,所以我的设计里,每个设备都有一个独立的同步游标,B端收到推送后,会再主动做一次增量同步,从游标位置拉取所有未收到消息。这样就算推送丢了,下一次同步也一定能补回来。”

面试官:“如果Redis挂了,会怎样?”

候选人:“在线的状态丢了,可以降级,把所有用户都当作离线处理,消息只落库不推送,等Redis恢复之后重新建连。会话列表缓存挂了,请求直接查数据库,通过缓存预热慢慢恢复。核心原则是宁可体验降级,也不能让消息丢失。”

面试里面试官追问“挂了会怎样”,不是希望你给出一个高深的分布式容灾方案,而是想看你对故障有没有感知,并且能够说出降级路径。能不慌不忙地给出降级方案,这一问基本就稳了。

5.3 回看经典失误:接线式答题和“我全都要”式设计的反面案例

这道题最容易跌进的坑有两个。第一个是“接线式答题”:没有任何逻辑主线,知识模块之间没有因果关系。说了连接层,突然跳去说NoSQL,然后又跳回Kafka,面试官很难跟着你走,分数自然不高。

第二个是“我全都要”式设计:不管什么技术都往上堆,项目里用了Redis、Kafka、Elasticsearch、MongoDB、Flink,但没有一个说得清它到底解决什么问题。面试的本质是考察决策力,而不是技术的堆叠能力。

我在电话面试中遇到过一位候选人,他把消息队列的Topic设计得很豪华,每个会话一个Topic,说这样消息隔离性好。我一算,日活百万,平均会话时长两分钟,光Topic就是千万级别。这个方案在真实工程里完全是灾难。能用少量Topic加消息路由解决的事,就不需要制造额外复杂度——后来我总结出一个表达技巧:当你给出一个技术方案时,用“考虑到XXX,我选择YYY,虽然它有ZZZ的代价,但在当前规模可接受”这样的句式,面试官就能立刻感受到你的权衡能力。

5.4 面试通用的节奏感:三分钟讲清楚一个模块

最后想分享一套通用的回答节奏。我把聊天系统的各个模块按“讲多久”排了个优先级:

  • 需求定位:30秒到1分钟,讲清楚业务范围、用户规模、性能指标。
  • 整体架构:1到2分钟,画出接入层、消息处理层、状态层、存储层四层模型,简述每条消息的流向。
  • 核心模块细节:5到8分钟,重点讲消息可靠性、离线消息和多端同步,这部分是加分区。
  • 存储设计:2到3分钟,讲消息表、索引表、会话缓存和冷热分离。
  • 高可用与降级方案:1到2分钟,讲故障应对和重试策略。

这个节奏的好处是,面试官随时可以打断追问,而你已经把骨架亮出去了,追问基本不会跑出你铺好的范围。如果你没有信心自己掌握节奏,建议写面试稿,背熟,再用手机录下来自己回听。回听的时候你会非常直观地发现哪一段啰嗦、哪一段逻辑断了。

6. 一些实操心得:准备这道题时,我踩过最深的三个坑

准备这个题目的时候,我一度陷入“追求完整方案”的误区,面面俱到,结果在面试官追问时反而露怯。后来我意识到,面试不是做毕设,不需要在每个环节都讲得完美,但必须在关键路径上建立完整的因果链路。我现在更倾向于把“发送一条消息到对方已读的完整链路”反复讲透,再把其他模块挂到这个主链上。

另一个坑是光看不练。很多人觉得看几篇系统设计文章就能应付,但真到面试时嘴跟不上脑子。我的建议是,自己做一次完整的白板演练,把前面提到的四个子域用箭头串起来,再对着录音把整段流程讲一遍,讲到顺畅为止。你会发现“想明白”和“讲明白”之间差了整整一个量级的练习量。

第三个坑是忽略了“量的估算”。要不要用消息队列、要不要做冷热分离、要不要引入搜索引擎,全都取决于量级。日活十万、百万、千万的方案是完全不同的。常见估算思路是:先预估峰值QPS、用户平均消息条数、存储增长量,再推导组件选型。这套推导过程本身就很有说服力。

聊天系统设计这道题,我到现在还在学。每次面试都会有人给我带来新的视角,比如某次有候选人提到“把消息列表从时间驱动改成游标驱动,可以规避分页深翻问题”,这个思路后来我直接用到了项目优化里。所以不妨把面试当成一次同行切磋,你准备得越细,得到的收获就越多,哪怕那个offer最后没拿到,这套设计能力也会跟着你很久。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进