
聊天系统设计面试到底在面什么——一次完整的架构拆解与实战复盘“聊天系统设计”这道题初看不难但真正能答好的候选人寥寥无几。我做后端面试官这些年几乎每轮都会拿它做考察它也确实是高频中的高频。很多人一上来就画网关、挂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长轮询、SSEServer-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返回ACKA的客户端再把“发送中”状态更新为“已发送”。如果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最后没拿到这套设计能力也会跟着你很久。