目录
  1. 1. 一、聊天系统的需求分析
  2. 2. 二、通信协议的选择
  3. 3. 三、消息的存储设计
  4. 4. 四、消息的发送与接收流程
  5. 5. 五、群聊的设计
  6. 6. 六、已读回执的设计
  7. 7. 七、访问限制系统的设计
  8. 8. 八、分布式限流的挑战
  9. 9. 九、多层限流架构
  10. 10. 十、面试常见追问
系统设计之聊天系统与访问限制系统

一、聊天系统的需求分析

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

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

二、通信协议的选择

实时通讯的传输层协议有三种主流选择: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(如心跳间隔的三倍)。

三、消息的存储设计

消息存储是聊天系统的核心。消息的数据模型包括:消息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的方案。

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

用户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在群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}

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

六、已读回执的设计

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

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

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

七、访问限制系统的设计

访问限制(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命令往返的网络开销和竞态条件。

八、分布式限流的挑战

单机限流实现简单,但分布式环境下存在额外挑战。如果限流规则是“每个用户每分钟最多发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的速率和并发连接数限制。

九、多层限流架构

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

第一层:硬件防火墙/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(配额重置时间)。客户端应根据这些信息实现退避策略(如指数退避重试)。

十、面试常见追问

问题一: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)本身不保证送达,只做唤醒用途,消息内容仍通过应用自身的同步机制获取。

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

文章作者: Leo·Cheung
文章链接: http://tufusi.com/2024/04/05/%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
打赏
  • 微信
  • 支付宝

评论