目录
  1. 1. 一、聊天系统的需求分析
    1. 1.1. 聊天系统的功能与非功能需求细化
  2. 2. 二、通信协议的选择
    1. 2.1. WebSocket帧协议详解
    2. 2.2. WebSocket连接的心跳与保活
  3. 3. 三、消息的存储设计
    1. 3.1. HBase消息表的详细设计
    2. 3.2. 消息ID的雪花算法时钟回拨解决方案
  4. 4. 四、消息的发送与接收流程
    1. 4.1. 消息有序性保证
    2. 4.2. 消息去重
  5. 5. 五、群聊的设计
    1. 5.1. 超大群的消息扩散策略
    2. 5.2. 群成员变更的同步
  6. 6. 六、已读回执的设计
    1. 6.1. 已读回执的存储优化
    2. 6.2. 已读回执的隐私权衡
  7. 7. 七、访问限制系统的设计
    1. 7.1. 四种限流算法的详细比较
    2. 7.2. 令牌桶的Go语言实现
  8. 8. 八、分布式限流的挑战
    1. 8.1. 混合限流的详细设计
  9. 9. 九、多层限流架构
    1. 9.1. 多层限流架构的ASCII示意图
    2. 9.2. 限流的降级与防护
  10. 10. 十、聊天协议族:XMPP与MQTT的对比
    1. 10.1. XMPP(可扩展消息与存在协议)
    2. 10.2. MQTT(消息队列遥测传输)
    3. 10.3. 消息同步协议:Sync协议设计
  11. 11. 十一、聊天系统的多数据中心部署
    1. 11.1. 数据中心架构模型
    2. 11.2. 跨DC同步的延迟与一致性
  12. 12. 十二、面试常见追问
    1. 12.1. 扩展阅读:即时通讯系统的工业界实现参考
系统设计之聊天系统与访问限制系统

一、聊天系统的需求分析

即时通讯(Instant Messaging)是移动互联网最基础的应用场景之一。设计一个支持十亿用户的一对一聊天和群聊系统,需要考虑以下核心需求。功能需求包括:一对一文字消息发送与接收、群聊消息(最多五百人)、消息已读/未读状态、离线消息推送、历史消息查询、消息送达保证(至少一次送达)。非功能需求包括:端到端消息延迟小于一百毫秒、系统可用性99.99%、支持消息持久化存储至少一年。

容量估算:假设DAU为五亿,平均每个用户每天发送五十条消息,则每日消息量约为二百五十亿条。平均写QPS = 250亿 / 86400 ≈ 289352 QPS。消息读取远比写入频繁:用户打开聊天窗口时加载历史消息,每条消息可能被读取数十次。因此系统是典型的读写不对称场景,写路径和读路径需要独立优化。

聊天系统的功能与非功能需求细化

除了一对一和群聊基础功能外,一个成熟的聊天系统通常还需要支持:消息引用/回复、消息撤回、消息转发、文件传输(图片、视频、文件)、消息搜索、语音/视频通话信令、正在输入状态提示、消息加密(端到端加密E2EE)。

非功能需求方面,消息延迟是一个多维度指标:发送确认延迟(用户点击发送到看到”已发送”状态,目标<100ms)、送达延迟(发送到接收者看到消息,目标<500ms)、通知延迟(离线用户收到推送通知的延迟,目标<5秒)。可用性方面,99.99%意味着全年不可用时间不超过52分钟。

二、通信协议的选择

实时通讯的传输层协议有三种主流选择:WebSocket、长轮询(Long Polling)和HTTP/2 Server Push。WebSocket是首选方案,它在单个TCP连接上提供全双工通信通道,客户端和服务端都可以随时发送数据。WebSocket握手阶段使用HTTP Upgrade协议,握手成功后协议切换为WebSocket帧协议,每个帧仅需2到14字节的头部开销,远小于HTTP请求的数百字节头部。对于不支持WebSocket的老旧网络环境(如某些企业防火墙),长轮询作为降级方案:客户端发起HTTP请求,服务端保持连接直到有新消息或超时(通常30秒),返回后客户端立即重新发起请求。

连接管理方面,每个在线用户与聊天服务器维持一条持久连接。如果我们有五亿DAU,假设同时在线峰值比例为20%,则同时在线用户约为一亿。每个服务器节点维护约五万到十万个WebSocket连接(受限于文件描述符和内存),需要约一千到两千个服务器节点。连接建立后,服务端维护一个session mapMap<userId, connectionId>,记录每个用户当前连接到哪个服务器节点的哪个连接。

连接的路由通过服务发现完成:客户端启动时向连接路由服务请求一个可用的聊天服务器地址(基于地理位置就近分配),然后与该服务器建立WebSocket连接。连接建立后,该用户的在线状态和连接信息注册到Redis中,key为user:online:{user_id},value为server_ip:connection_id,设置合理的TTL(如心跳间隔的三倍)。

WebSocket帧协议详解

WebSocket帧格式(RFC 6455):

 0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Masking-key (4 bytes, if MASK set to 1) |
+---------------------------------------------------------------+
| Payload Data |
+---------------------------------------------------------------+

一个典型的WebSocket文本帧头部只有2字节(FIN=1, opcode=1表示文本, MASK=1, payload len < 126),加上4字节Masking-key,总头部6字节。对比HTTP/1.1请求头部的数百字节,WebSocket的帧头部开销微乎其微。对于聊天消息这种小数据量高频次场景,这一差异非常显著。

WebSocket连接的心跳与保活

WebSocket协议定义了Ping/Pong控制帧(opcode=9/10),可用于心跳检测。服务端每30秒发送一个Ping帧,客户端回复Pong帧。如果连续三次未收到Pong,服务端认为客户端已断开,关闭连接并更新在线状态。客户端端也应实现心跳——如果连续三次未收到服务端的Ping,主动重连。

此外,中间网络设备(NAT网关、负载均衡器、代理)通常有无连接超时设置(如180秒)。心跳间隔必须小于这个超时时间,以防止连接被中间设备静默断开而两端不知。

三、消息的存储设计

消息存储是聊天系统的核心。消息的数据模型包括:消息ID(全局唯一)、发送者ID、接收者ID(或群ID)、消息类型(文本、图片、语音、视频)、消息内容、发送时间戳、消息状态(已发送、已送达、已读)。

对于一对一聊天,消息存储以conversation_id(会话ID)为分区键。会话ID由两个用户ID按字典序拼接而成:conversation_id = min(userA, userB) + "_" + max(userA, userB)。这样无论谁发起会话,消息都落在同一个分区内,便于查询整个会话历史。

消息存储的技术选型上,关系型数据库(MySQL)不适合海量消息场景——每天二百五十亿条消息,一年约九万亿条,MySQL的分库分表方案在运维和数据迁移上极其痛苦。HBase和Cassandra这类宽列存储系统是更合适的选择。以HBase为例,行键设计为conversation_id + "_" + timestamp,列族msg包含消息内容的各个字段。由于行键按字典序排列,同一会话的消息自然按时间顺序相邻存储,范围扫描效率极高。

消息ID的生成是分布式系统中的经典问题。要求全局唯一且趋势递增(便于按时间排序)。Snowflake算法是Twitter开源的分布式ID生成方案,64位Long类型ID的结构如下:

[1位保留][41位时间戳(毫秒)][10位工作机器ID][12位序列号]

41位时间戳可以支撑约69年(从自定义起始时间算起),10位工作机器ID支持1024个节点,12位序列号支持单节点每毫秒生成4096个ID。Snowflake的优点是本地生成,无需远程调用,性能极高;缺点是有时钟回拨问题——如果服务器时钟被回拨,可能产生重复ID。解决方案包括:等待时钟追上之前的时间、使用备用机器ID、或者改用美团Leaf等依赖数据库或ZooKeeper的方案。

HBase消息表的详细设计

-- HBase表: chat_messages
-- 行键: {conversation_id}_{reverse_timestamp} (Long.MAX_VALUE - timestamp)
-- 为什么用反转时间戳?因为HBase按键的字典序升序排列,最新消息需要排在最前面。
-- 反转时间戳使得最新的消息排在行键的最前面(时间戳越大反转后越小),
-- Scan时自然获取到最新消息。

-- 列族: msg
-- 列: sender_id (Long)
-- 列: msg_type (Int) -- 1:文本 2:图片 3:语音 4:视频 5:文件 6:系统消息
-- 列: content (String/byte[]) -- 文本内容或媒体元数据JSON
-- 列: created_at (Long) -- 原始时间戳(用于客户端展示)
-- 列: status (Int) -- 0:已发送 1:已送达 2:已读
-- 列: client_msg_id (String) -- 客户端生成的唯一ID(用于去重)

-- TTL: ColumnFamily级别设置 TTL = 1年 (31536000秒)
-- HBase自动在Compaction时清理过期数据,无需手动清理

消息ID的雪花算法时钟回拨解决方案

时钟回拨是Snowflake在实际生产中的头号问题。除了等待和切换机器ID,还有以下方案:

  1. 美团Leaf方案:使用ZooKeeper持久化每个工作节点的当前时间戳和序列号。启动时检查与ZK记录的时间是否发生了回拨。
  2. 百度UidGenerator:使用RingBuffer预生成ID,缓存未来一段时间的可用ID号段,减少时钟回拨对实时生成的影响。
  3. 混合方案:在Snowflake ID中嵌入一个随机数位(如2位),略微降低时间精度但提供了额外的唯一性保证。即使时间戳完全重复,4倍随机数空间也能保证4096×4个不重复ID。

四、消息的发送与接收流程

用户A给用户B发送一条消息的完整流程如下:

第一步,A的客户端通过WebSocket将消息发送到与其连接的聊天服务器S1。消息体包含:接收者B的用户ID、消息类型、消息内容、客户端生成的消息本地ID。

第二步,聊天服务器S1为消息分配全局消息ID(使用Snowflake),将消息写入消息队列(Kafka)。使用消息队列的目的是解耦——消息发送和消息存储、消息推送异步进行,提高系统的吞吐和容错能力。Topic可以按receiver_id分区,保证发给同一个用户的消息有序。

第三步,消息持久化服务从Kafka消费消息,写入HBase。同时,每条一对一消息写两份——一份存入min(A,B)_max(A,B)的会话键中,另一份可选的备份策略视具体需求而定。

第四步,消息推送服务从Kafka消费消息,查询Redis获取接收者B的在线状态。如果B在线,根据user:online:B的值找到B连接的聊天服务器S2,通过内部RPC将消息推送到S2,S2再通过WebSocket推送给B的客户端。

第五步,B的客户端收到消息后,发送ACK确认消息。S2将确认回传给S1,S1更新消息状态为”已送达”。如果B打开了聊天窗口,客户端自动上报”已读”状态,S1更新消息状态。

对于离线消息的处理:如果B不在线,消息推送服务无法找到B的连接信息。此时消息已经在HBase中持久化存储。当B下次上线时,客户端向服务器请求离线期间的消息(带上本地最后一条消息的ID或时间戳),服务器从HBase中查询该时间戳之后的消息返回。同时,服务器通过移动推送通道(APNs/FCM)发送推送通知,提醒用户有新消息。

消息有序性保证

在分布式环境中保证消息有序是一个挑战。如果用户A在一秒内向用户B发送了消息M1、M2、M3,B必须按相同顺序收到。Kafka的分区机制提供了有序性保证:同一个分区内的消息严格按写入顺序排列。只要A→B的所有消息都写入Kafka的同一个分区(如按conversation_id分区),消费者按顺序消费即可保证B看到正确的消息顺序。

对于群聊,消息按group_id分区,保证同一群内的消息顺序。

消息去重

消息去重发生在多个层面:

  1. 发送端去重:客户端为每条消息生成唯一的client_msg_id(UUID),如果发送超时(未收到服务端ACK),客户端用相同client_msg_id重试。服务端检查client_msg_id是否已存在,已存在则只返回ACK,不重复存储。
  2. 推送端去重:服务端向客户端推送消息时,携带消息的全局消息ID(msg_id)。客户端维护已接收的最大msg_id,如果收到的消息msg_id <= 已确认的msg_id,忽略该消息。

五、群聊的设计

群聊是一对多通信,核心挑战在于消息扩散。假设一个五百人的群,每发一条消息需要将消息推送给五百人。如果采用简单的逐一推送模式,写放大严重。

群聊消息的发送流程:用户A在群G中发送消息,消息先写入Kafka(topic按group_id分区),消息持久化服务将其写入HBase(行键为group_id + "_" + timestamp)。消息推送服务查询群成员列表(从Redis或MySQL中获取),然后查询每个成员的在线状态。对于在线的成员,通过其连接的聊天服务器推送消息;对于离线成员,依靠移动推送通知和上线后的消息同步。

群成员列表的存储与管理:群成员信息存储在MySQL中,group_members(group_id, user_id, role, joined_at)。热数据(活跃群的成员列表)缓存在Redis中,使用Set数据结构:group:members:{group_id}

群聊的读扩散优化:对于超大群(如万人群),查询所有在线成员并逐一推送的成本太高。可以采用”信箱模式”——每个用户维护一个消息信箱(类似新鲜事时间线),群消息写入所有成员的收件箱。成员上线后从自己的收件箱拉取消息。这实际上将群聊转化为类似新鲜事系统的推拉结合模式。

超大群的消息扩散策略

对于万人群(甚至数十万人的直播聊天室),逐一推送的成本不可接受。以下是分层扩散策略:

┌─────────────────────────────────────────────┐
│ 第一层: 在线活跃用户 (约10-20%) │
│ → 长连接WebSocket直接推送 │
├─────────────────────────────────────────────┤
│ 第二层: 在线但静默用户 (约30-50%) │
│ → 收件箱模式, 用户拉取新消息数 │
├─────────────────────────────────────────────┤
│ 第三层: 离线用户 (约30-50%) │
│ → APNs/FCM推送 + 上线后拉取 │
└─────────────────────────────────────────────┘

具体实现:聊天服务器为每个在线用户维护一个未读消息计数(在Redis中,unread:group:{group_id}:{user_id}),原子递增。用户与该群的WebSocket连接上,服务端只发送”有新消息”的轻量通知(而非完整消息内容),客户端收到通知后按需拉取。这样将服务端推的消息从”群成员数 × 消息大小”降低为”在线成员数 × 20字节的轻量通知”。

群成员变更的同步

群成员加入/退出时,需要向所有在线成员广播成员变更事件,同时在本地维护成员列表的一致性。实现方式:

  1. 群服务更新MySQL中的群成员表
  2. 群服务发送成员变更事件到Kafka(group_member_change topic)
  3. 聊天服务消费事件,向在线的群成员推送变更通知
  4. 客户端收到通知后,从服务端拉取最新的完整成员列表
  5. Redis中的群成员缓存设置较短的TTL(如5分钟),保证最终一致性

六、已读回执的设计

已读回执(Read Receipt)是即时通讯的重要体验功能。在一对一聊天中,每条消息的状态分为:已发送(客户端发出)、已送达(服务器转发给接收者且接收者ACK)、已读(接收者打开了聊天窗口并看到了消息)。

对于一对一聊天,已读回执的实现相对直接:当接收者打开与发送者的聊天窗口时,客户端向服务器发送一个”会话已读”事件,包含会话ID和已读到的最后一条消息ID。服务器更新该会话的已读游标,并通知发送者(如果在线)。

对于群聊,已读回执的实现更为复杂。如果记录每个人对每条消息的已读状态,存储量将爆炸式增长——一个五百人的群每发一条消息需要五百条已读记录。常见的折中方案是:群聊只记录每个用户在每个群中已读的最后一条消息序号,不细化到每条消息。用户进入群聊时,标记当前最新消息序号为已读;用户向上滚动查看历史时,不更新已读位置。

已读回执的存储优化

一对一聊天场景中,每个会话只有两条已读记录(A的已读游标和B的已读游标),数据量极小。存储方案:在Redis中维护read_cursor:{conversation_id}:{user_id} → 最后已读消息ID,设置永久TTL。

群聊场景中,如果使用相同方案,一个500人群需要存储500条游标。但由于群聊的已读状态查询频率相对低(只在用户进入群聊时查询),可以存储在HBase中(行键 = group_id:user_id),并在Redis中做热点缓存。

已读回执的隐私权衡

很多聊天应用(如WhatsApp)提供关闭已读回执的选项。技术实现:当用户关闭已读回执后,服务端不更新已读游标,也不向发送者推送已读状态。但用户自己仍然可以看到消息已读状态(本地标记)。这需要在消息发送时不依赖全局的已读状态来决定UI展示。

七、访问限制系统的设计

访问限制(Rate Limiting)是保护后端服务不被过量请求击垮的关键组件。在一个高并发系统中,没有限流的系统就像没有保险丝的电闸——一次流量尖峰就可能导致级联故障。

限流算法的选择有三个经典方案。令牌桶算法(Token Bucket):以恒定速率向桶中添加令牌,桶有最大容量。每个请求需要消耗一个令牌,如果桶中没有令牌则拒绝请求。令牌桶允许一定的突发流量——桶中积累的令牌可以在瞬间被消耗掉。适用于允许短时突发但需要控制平均速率的场景。

漏桶算法(Leaky Bucket):请求先进入一个队列(桶),以恒定速率从桶中取出请求处理。如果桶满则丢弃新请求。漏桶强制平滑输出速率,不允许多余的突发。适用于需要严格控制处理速率的场景。

滑动窗口计数器(Sliding Window Counter):将时间划分为固定窗口(如1分钟),统计每个窗口内的请求数。超过阈值则限流。固定窗口的问题在于边界——假设每分钟限制100次请求,用户在0:59和1:00各发100次,实际2秒内发出了200次请求,但两个窗口都没超限。滑动窗口的改进方案是维护最近N秒的请求计数,利用Redis的有序集合(Sorted Set)实现:以请求时间戳为score,以请求唯一标识为member,每次请求时ZREMRANGEBYSCORE移除N秒之前的记录,然后ZCARD统计当前窗口内请求数。

Redis实现的滑动窗口限流器:

-- Lua脚本保证原子性
local key = KEYS[1] -- 限流key,如 rate_limit:user:{user_id}
local window = ARGV[1] -- 窗口大小,秒
local limit = ARGV[2] -- 窗口内允许的最大请求数
local now = ARGV[3] -- 当前时间戳
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < tonumber(limit) then
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('EXPIRE', key, window)
return 1 -- 允许通过
else
return 0 -- 限流
end

这段Lua脚本在Redis服务端原子执行,避免了多次Redis命令往返的网络开销和竞态条件。

四种限流算法的详细比较

算法 突发允许 实现复杂度 内存开销 适用场景
固定窗口计数器 有限 极低 极低 粗粒度限流、Nginx层
滑动窗口日志 精确控制 API Gateway、用户级限流
令牌桶 允许 网关入口、允许短时突发
漏桶 不允许 流量整形、严格速率控制

令牌桶的Go语言实现

type TokenBucket struct {
rate float64 // 令牌生成速率 (令牌/秒)
capacity float64 // 桶容量
tokens float64 // 当前令牌数
lastRefill time.Time // 上次填充时间
mu sync.Mutex
}

func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()

now := time.Now()
elapsed := now.Sub(tb.lastRefill).Seconds()
tb.tokens = math.Min(tb.capacity, tb.tokens + elapsed * tb.rate)
tb.lastRefill = now

if tb.tokens >= 1 {
tb.tokens--
return true
}
return false
}

这个实现的核心思想是在每次Allow()调用时惰性计算应补充的令牌数,而不是启动一个后台goroutine定期补充,避免了定时器开销。

八、分布式限流的挑战

单机限流实现简单,但分布式环境下存在额外挑战。如果限流规则是”每个用户每分钟最多发100条消息”,而用户请求可能被负载均衡分发到多个服务器节点,那么每个节点只看到部分请求,各自独立限流会导致实际通过的请求远超阈值。

解决方案一:集中式限流,所有限流判断统一通过Redis进行。上面的Lua脚本就是集中式方案——无论请求落到哪个应用服务器,都到同一个Redis集群中计数。缺点是每次请求多一次Redis网络调用,增加约0.5到1毫秒的延迟。

解决方案二:混合限流,结合本地限流和远端限流。例如,每分钟100次的配额,本地节点预分配30次,超出30次再向远端Redis申请。这样大部分请求在本地内存中判断,只有少数需要远端调用,减少了Redis的压力和网络开销。Google的Guava RateLimiter就是本地限流的典型实现,使用令牌桶算法。

解决方案三:Nginx层限流,在反向代理层面进行简单的IP级别限流,过滤掉明显的恶意流量,减轻应用层的压力。Nginx的limit_req_zonelimit_conn_zone模块可以配置基于IP的速率和并发连接数限制。

混合限流的详细设计

混合限流的实现方案:

1. 本地桶容量 = 总配额 / N (如100次/分钟 → 本地容量30次)
2. 本地计数器 tracking:
- 本地已消耗次数
- 本地窗口起始时间
3. 请求到达:
a. 如果本地计数 < 本地容量 → 本地放行, 计数+1
b. 如果本地计数 ≥ 本地容量 → 向Redis申请新配额
- Redis执行: DECR quota:{user_id} {batch_size}
- 如果返回值 > 0 → 配额申请成功, 重置本地计数
- 如果返回值 <= 0 → 配额耗尽, 限流
4. 窗口到期时, 未使用的本地配额自动作废

这个方案的优点:将Redis调用频率从”每请求1次”降低到”每消耗本地容量1次”,Redis负载降低30倍(以本地容量30为例)。缺点:配额分配的精度降低(因为预分配了离散批次),但对于大多数场景足够。

九、多层限流架构

一个健壮的限流系统通常采用多层防线:

第一层:硬件防火墙/DDoS防护,在入口处过滤掉超大规模的流量攻击。

第二层:负载均衡/反向代理层(Nginx/HAProxy),基于IP的简单限流,处理明显的流量异常。

第三层:API网关层,基于用户身份(JWT Token中的user_id)和API路径的精细化限流。网关层可以从Redis中读取每个用户+API的配额使用情况。

第四层:应用服务层,对于核心业务逻辑进行业务级别的限流。例如聊天系统中,除了用户级别的消息发送频率限制,还需要对单个会话的消息频率进行限制,防止单用户对某一会话刷屏。

限流响应的处理:当请求被限流时,应该返回HTTP 429 (Too Many Requests)状态码,并在响应头中包含限流信息,如X-RateLimit-Limit(总配额)、X-RateLimit-Remaining(剩余配额)、X-RateLimit-Reset(配额重置时间)。客户端应根据这些信息实现退避策略(如指数退避重试)。

多层限流架构的ASCII示意图

外部流量


┌──────────────────────┐
│ 第一层: DDoS防护 │ (硬件防火墙/云清洗)
│ 过滤超大规模攻击 │
└──────────┬───────────┘


┌──────────────────────┐
│ 第二层: Nginx限流 │ (IP级别, 并发连接数限制)
│ limit_req_zone │
└──────────┬───────────┘


┌──────────────────────┐
│ 第三层: API网关限流 │ (用户+API级别, Redis滑动窗口)
│ Kong / APISIX │
└──────────┬───────────┘


┌──────────────────────┐
│ 第四层: 应用限流 │ (业务级别, 本地+分布式)
│ 聊天频率/会话频率 │
└──────────────────────┘

限流的降级与防护

当Redis不可用时,所有集中式限流失效。此时有两种降级策略:

  1. Fail Open:Redis不可用时放行所有请求。风险是可能被流量击垮,适用于对外部依赖可用性有信心的场景。
  2. Fail Closed:Redis不可用时拒绝所有请求(或只放行一定比例)。保护了后端,但影响了用户体验。通常使用Fallback到本地限流(使用最近同步的配额信息)作为折中。

最佳实践:本地维护一个简化的限流器(令牌桶),正常情况下被Redis集中限流覆盖。当Redis不可用时,自动切换为本地限流模式,虽然精度下降但提供了基本保护。

十、聊天协议族:XMPP与MQTT的对比

除了WebSocket这种通用传输协议,即时通讯领域还有两大专用协议族值得深入理解。

XMPP(可扩展消息与存在协议)

XMPP(Extensible Messaging and Presence Protocol,原Jabber)是IETF标准化的即时通讯协议(RFC 6120/6121),WhatsApp早期就基于XMPP构建。

XMPP的核心概念:

  • JID(Jabber ID)user@domain/resource格式的全局唯一标识。domain是XMPP服务器域,resource标识设备(如alice@whatsapp.com/android-phone
  • Presence:在线状态订阅机制。用户订阅好友的Presence,好友状态变化时自动推送
  • Roster:联系人名单,存储在XMPP服务器上,跨设备同步
  • Stanza:XML格式的消息原语。三种核心Stanza:<message>(聊天消息),<presence>(在线状态),<iq>(Info/Query,请求-响应模式)

XMPP的优点:成熟的标准协议(20+年历史),丰富的扩展(XEP系列),联合协议(Federation——不同域的用户可以通信)。缺点:XML的解析开销大(二进制替代方案如Google的Protobuf更高效),协议较重(移动设备上电力和带宽消耗高),Presence传送在网络不稳定时产生大量信令开销。

实际使用中,WhatsApp对XMPP做了大量简化(自定义二进制编码替代XML、简化Presence机制),本质上是”受了XMPP启发的自定义协议”。

MQTT(消息队列遥测传输)

MQTT是IBM为物联网设计的轻量级发布/订阅协议,近年来也被用于即时通讯(尤其是移动端)。

MQTT的核心特性:

  • 极轻量:最小的MQTT控制包仅2字节(CONNACK:连接确认),适合低带宽、高延迟网络
  • 发布/订阅模型:客户端发布消息到Topic,订阅该Topic的客户端收到消息。天然支持群聊(一个Topic = 一个群)
  • 三种QoS级别:QoS 0(At most once,消息可能丢失),QoS 1(At least once,消息可能重复),QoS 2(Exactly once,消息确保送达且不重复)
  • 遗嘱消息(Last Will and Testament, LWT):客户端意外断开时,Broker自动发布一条预先设定的”遗嘱消息”,通知其他客户端该用户离线
  • 持久会话(Persistent Session):Broker保存客户端的订阅和未发送消息,客户端重连后自动恢复

MQTT的QoS 2(Exactly Once)是它的差异化特性,其四次握手流程:PUBLISH → PUBREC → PUBREL → PUBCOMP。对于金融级消息和支付通知等场景,QoS 2提供了关键的消息不丢失保证。

WebSocket vs MQTT的选择:WebSocket适合Web/App环境(浏览器原生支持、防火墙友好),MQTT适合IoT和移动网络弱网环境(极小开销、天然支持离线消息、QoS分级)。许多现代即时通讯系统将两者结合——客户端与Broker使用MQTT协议(通过TCP或WebSocket传输),服务端内部使用gRPC进行服务间通信。

消息同步协议:Sync协议设计

当用户更换设备或多设备同时在线时,消息同步是一个复杂的问题。设计良好的Sync协议需要考虑:

客户端离线期间:
1. 服务端为每个用户维护一个消息游标 (message cursor)
2. 新消息到达 → 持久化到HBase → 游标递增
3. 客户端上线 → 发送本地最后已知游标
4. 服务端返回游标之后的所有消息(支持分页)

多设备同步:
1. 每个设备维护独立的同步游标
2. 消息写入时,为每个在线设备推送(实时)
3. 离线设备上线后拉取(增量同步)
4. 首次登录新设备时拉取全量历史(全量同步,可通过最近N条消息做种子同步)

冲突处理:
- 以服务端时间戳为准(而不是客户端时间戳)
- 相同消息ID去重(通过client_msg_id)

十一、聊天系统的多数据中心部署

为实现99.99%以上的可用性和全球化低延迟体验,聊天系统需要多数据中心部署。

数据中心架构模型

                   ┌──────────┐
│ Global │
│ DNS LB │ ← 按用户地理位置路由到最近DC
└────┬─────┘

┌────────────────┼────────────────┐
│ │ │
┌──────▼──────┐ ┌─────▼──────┐ ┌─────▼──────┐
│ DC-Asia │ │ DC-Europe │ │ DC-America │
│ (Singapore)│ │ (Frankfurt)│ │ (Virginia) │
└──────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
┌──────▼──────┐ ┌─────▼──────┐ ┌─────▼──────┐
│HBase Cluster│ │HBase Cluster│ │HBase Cluster│
│ (本DC写入) │ │ (本DC写入) │ │ (本DC写入) │
└──────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
└────────────────┼────────────────┘

┌───────▼────────┐
│ Cross-DC Sync │
│ (异步复制) │
└────────────────┘

用户按注册时选择或当前地理位置分配到最近的DC。用户A(亚洲)和用户B(美洲)的一对一聊天:

  1. A发送消息 → 写入DC-Asia的Kafka和HBase
  2. Cross-DC同步服务将消息复制到DC-America的Kafka
  3. B在DC-America的连接上收到推送
  4. B读取历史消息时从DC-America的HBase副本读取(无需跨DC查询)

跨DC同步的延迟与一致性

跨太平洋的光纤延迟约100-150ms(理论极限,加上路由器跳数约150-200ms)。这意味着A的消息到达B的客户端最快约150ms(如果B在线且DC-America的推送立即执行)。对于即时通讯来说,这个延迟在可接受范围内。

但如果Cross-DC复制链路故障(太平洋光缆断裂等),将出现数据不一致——DC-Asia有消息但DC-America没有。解决方案:在Cross-DC复制恢复前,DC-America代理请求到DC-Asia读取(增加延迟但保证数据完整性);或者接受短暂的数据不可用(DC-America的用户暂时看不到跨DC用户的消息),在复制恢复后补同步。

十二、面试常见追问

问题一:WebSocket连接断了怎么办?

客户端需要实现自动重连机制。重连策略通常采用指数退避:第一次重连等待1秒,第二次2秒,第三次4秒,以此类推,最大等待时间设上限(如30秒)。连接断开期间的消息通过离线消息同步机制补偿——重连成功后,客户端发送本地最后一条消息的时间戳,服务端返回该时间戳之后的所有消息。心跳机制用于检测连接存活:客户端每30秒发送一个ping帧,服务端回复pong帧。如果连续三次没有收到pong,客户端认为连接已断开并启动重连。

问题二:为什么消息存储选择HBase而不是MySQL?

HBase是为海量写入和范围扫描优化的LSM-Tree结构,随机写入性能远超MySQL的B+树。消息系统的负载特征是大规模顺序写入和按时间范围扫描查询,与HBase的设计完美匹配。MySQL在这种场景下,随着数据量增长,索引维护成本急剧上升,需要频繁的分库分表扩容,运维复杂度高。此外,HBase天然支持按时间范围做TTL(Time-To-Live)自动过期,消息存储可以在一年后自动删除,无需手动清理。

问题三:如何保证消息不丢失?

消息可靠性的保障贯穿整个链路。发送端:客户端在消息体中带唯一ID(客户端生成),如果发送后没有收到服务端ACK,客户端重试。服务端通过消息ID去重,保证同一条消息不会被存储两次。服务端:消息先写入Kafka(配置acks=all,确保所有ISR副本确认),再异步写入HBase。Kafka的多副本机制保证即使节点故障消息也不会丢失。推送端:服务端向接收者客户端推送消息后,等待客户端的ACK。如果超时未收到ACK,消息标记为未送达,客户端下次上线时通过同步机制拉取。移动推送(APNs/FCM)本身不保证送达,只做唤醒用途,消息内容仍通过应用自身的同步机制获取。

问题四:限流中滑动窗口和令牌桶如何选择?

滑动窗口适合需要精确控制时间窗口内请求数量的场景(如”每分钟最多100次”),实现简单直观。令牌桶适合允许一定突发流量的场景(如”平均每秒10次,但瞬时可以发出20次”),更灵活。对于API限流场景,令牌桶更常用——因为需要允许客户端在正常使用中偶尔的请求突发(如页面加载时同时发多个API请求),同时保证长期的平均速率不超标。滑动窗口更适合硬限(Hard Limit)场景,如短信验证码发送(严格N次/分钟)。

问题五:如何设计消息的端到端加密(E2EE)?

端到端加密保证即使是服务端也无法读取消息内容。Signal Protocol(由Open Whisper Systems开发)是当前最广泛使用的E2EE协议,被WhatsApp、Signal、Skype(私密对话)采用。其核心机制:

  1. 使用Double Ratchet算法——每次消息发送都更新密钥(前向安全性)
  2. 使用X3DH(Extended Triple Diffie-Hellman)密钥协商——在双方首次通信时安全地建立共享密钥
  3. 每个参与者的设备有一对长期身份密钥(Identity Key)和一组预密钥(Pre-Key)
  4. 消息发送方从服务器获取接收方的预密钥Bundle(存储为公开值),在本地执行X3DH计算生成共享密钥
  5. 服务端只看到加密后的密文,无法解密
  6. 对于群聊,使用Sender Key分发机制——每个成员向群发布加密的Sender Key,之后使用该Key对群消息进行对称加密

E2EE对服务端的功能提出了限制——服务端无法对消息内容进行搜索、内容审核、智能回复等功能,这些需要在客户端本地完成。

本文全面解析了即时通讯系统和限流系统的核心设计,涵盖了从协议选型、消息存储、ID生成到分布式限流的各个层面。掌握了这些知识,你就可以在系统设计面试中有条不紊地应对聊天系统和访问控制相关的设计问题。

扩展阅读:即时通讯系统的工业界实现参考

微信的消息系统架构(基于公开技术分享推断):

  • 使用长连接(可能基于MMTLS协议,微信自研的加密传输协议)
  • 消息同步使用序列号(Seq)机制——每个用户维护一个单调递增的全局消息序列号
  • 消息存储使用自研的PaxosStore(基于Paxos协议的分布式KV存储),替代了早期的HBase
  • 群聊采用收件箱模式——群消息写入所有在线成员的收件箱,离线成员通过序列号增量同步

WhatsApp的技术栈(2014年Facebook收购时的公开资料):

  • FreeBSD + Erlang/OTP(用于实时消息路由)服务器
  • XMPP协议的简化自定义版本(二进制编码替代XML)
  • 单台Erlang服务器可维持200万+活跃WebSocket连接
  • 使用Soft-Layer裸金属服务器(而非云虚拟机),追求极致的单机连接密度

Slack的实时消息架构(基于工程博客):

  • 使用WebSocket(通过Phoenix框架的Channel抽象,Elixir语言)
  • 消息分发通过Pub/Sub模式——每个频道是一个Pub/Sub频道
  • 使用Flannel(自研)在Erlang节点间高效广播消息
  • 消息持久化使用Vitess(MySQL水平扩展中间件),按Team ID分片

核心经验总结

  1. 长连接管理是技术核心——单机10万+ WebSocket连接需要精细的OS调优(文件描述符、TCP参数、内存管理)
  2. 消息队列是解耦利器——发送、存储、推送三条路径异步化,互不阻塞
  3. 离线消息同步使用序列号而非时间戳更可靠——序列号单调递增,无时钟同步问题
  4. 移动推送(APNs/FCM)只做唤醒用途,真正的消息同步通过应用自己的长连接完成
文章作者: Leo·Cheung
文章链接: http://tufusi.com/2021/12/10/%E7%B3%BB%E7%BB%9F%E8%AE%BE%E8%AE%A1%E4%B9%8B%E8%81%8A%E5%A4%A9%E7%B3%BB%E7%BB%9F%E4%B8%8E%E8%AE%BF%E9%97%AE%E9%99%90%E5%88%B6%E7%B3%BB%E7%BB%9F/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 ONE·PIECE
打赏
  • 微信
  • 支付宝

评论