2026年3月14日
如何设计一个即时聊天软件
设计聊天系统 —— 完整讨论汇总
1. 通信协议:为什么用 WebSocket
- HTTP 是"一问一答",服务器无法主动推送;Polling 浪费资源,Long Polling 本质仍是一问一答只是拖延响应
- WebSocket 是全双工长连接,服务器可随时主动推送
- 代价:连接是有状态的,断线是常态(需指数退避重连 + 断线补发),维持连接本身有内存/扩展性/心跳开销
- 因此 Chat Server(有状态)和 API Server(无状态)要分开设计,扩展策略完全不同
2. 消息路由、持久化与故障恢复
- 核心原则:消息必须先写入持久化数据库(如 Cassandra),再考虑推送——持久化和实时推送是两条独立的保障机制,互为兜底
- 路由机制:Redis 维护
user_id → server_id映射(高频读写、可容忍偶尔丢失,适合 Redis;而非 ZooKeeper——后者适合"哪些 Server 存活"这种低频强一致性数据) - 跨 Server 转发:A 所在 Server 查 Redis 找到 B 所在 Server,通过 Kafka/内部 RPC 转发,再由 B 所在 Server 通过已有 WebSocket 推送
- 故障恢复:Server 心跳检测(可用 ZooKeeper 临时节点)→ 摘除故障机 → B 客户端自动重连到新 Server → 更新 Redis 映射 → 拉取错过消息补发。消息不会丢,只是这次实时推送失败了
3. 群聊消息分发(Fan-out)
- Fan-out on Write(写扩散):发送时立即推给所有成员,读快但大群时写/推送开销暴增("大群问题")
- Fan-out on Read(读扩散):只写一份,查看时才拉取,写开销恒定但读变慢、不够实时
- 实际系统按群大小混合使用:小群用写扩散,超大群用读扩散
- 优化1:批量查询 Redis(用 MGET 代替逐个 GET)
- 优化2:按目标 Server 分组转发,把跨机器调用次数从"人数级别"降到"Server 数量级别"
- 独立 Fan-out Service 的意义:可靠性靠消息队列 + 确认机制(而非"换个专门服务"本身),拆分 Service 的真正价值是职责分离 + 独立扩展性(Chat Server 和 Fan-out 负载特征完全不同)
4. 在线状态系统(Presence)
- 状态判定:不能"连接存在=在线",需结合心跳机制(连续几个周期收不到心跳才判定离线),过滤网络抖动
- 广播的"大V问题":好友数极多时,每次上下线都全量广播会重演 Fan-out 的大群问题
- 优化方向:类似读扩散——打开好友列表时才批量查询当前可见好友状态;配合"订阅机制"维持实时感(只通知真正在关注你的人,而非广播给所有好友)
5. 消息去重与幂等性
- 问题:重连重试可能导致同一消息被重复写入/推送
- 解法:客户端生成唯一 msg_id(不依赖网络是否成功),服务器判重
- 实现:把 msg_id 设为消息表的唯一索引/主键,用
INSERT IF NOT EXISTS原子操作合并"判重"与"写入",无需额外 KV 存储
6. 消息顺序保证
- TCP 本身保证同连接内顺序,乱序主要来自多 Server 转发链路耗时不均、客户端重试
- 不能依赖客户端本地时间戳(存在 clock skew)
- 解法:服务器在收到消息的那一刻(而非写库时)用 Redis
INCR为每个对话生成单调递增序列号(seq) - 向量时钟不适用(解决的是"多写冲突判定"问题,不是聊天需要的"确定性线性排序");HLC 技术上可行但对单一对话场景是过度设计,中心化计数器已经足够
7. 已读回执
- 复用 WebSocket 通道,已读回执视为一种特殊消息类型
- 只有"打开/查看"时才有意义,不该逐条触发
- 核心优化:用"已读水位线"(up_to_seq)代替逐条确认——一次性告知"读到第几条了",避免 10 条消息=10 次事件
- 群聊场景:每个成员独立水位线,大群的"已读详情"功能出于成本考量常被砍掉
8. 端到端加密(E2EE)
- 区分传输加密(信任服务器)与 E2EE(不信任服务器,服务器只见密文)
- 密钥交换:Diffie-Hellman,双方在被监听的情况下仍能协商出只有彼此知道的共享密钥(混色比喻/离散对数难题)
- 对称+非对称组合拳:DH 协商一次性共享密钥,后续内容用速度快的 AES 对称加密
- 中间人攻击:如果公钥分发被篡改,加密对攻击者完全透明
- CA 证书模式不能照搬:让聊天 App 服务器充当 CA,等于把信任权重新交还给"本来就不信任"的服务器,自相矛盾
- 实际解法:带外人工验证("安全码"当面比对),理论有解但实践中很少被真正使用
9. 容量估算
- 完整推算链条:DAU → 消息 QPS → 同时在线连接数(Little's Law)→ 所需 Server 台数 → 负载均衡策略
- 5000 万 DAU、人均 40 条/天 → 平均 2.3 万 QPS,高峰(×5)约 11.6 万 QPS
- 读写比需要给出估算依据(如"假设每条消息平均被读取 10 次"),不能凭空定倍数
- 同时在线数 ≈ DAU×(人均在线时长/一天时长),用的是 Little's Law 思想,不能直接套用 DAU
- 由此推出所需 Chat Server 台数,配合 Auto Scaling
- WebSocket 是长连接,负载均衡器只在连接建立时做一次路由决策,之后需绑定同一台 Server(区别于 HTTP 的无状态转发);AWS 里 NLB(4层)比 ALB(7层)更适合这种场景,但面试更看重原理理解而非具体产品选型
核心设计原则
- 持久化优先于实时性
- 有状态服务(Chat Server)与无状态服务(API Server)分开设计
- 不计算/传输用户看不到的数据(按需+批量是反复出现的优化关键词)
- 可靠性来自架构(队列+确认机制),而非"换个专门服务"
- 同一套优化思路可跨场景迁移(群聊 Fan-out 与 Presence 广播是同一类问题)