从零开始:如何设计一个现代化聊天系统
- Published on
在当今数字化时代,聊天系统已成为我们日常生活和工作中不可或缺的一部分。从个人交流到团队协作,从客户服务到社交网络,聊天应用程序在各个领域都发挥着重要作用。正因如此,理解并掌握聊天系统的设计原理对于软件工程师来说尤为重要。
该博客将深入探讨聊天系统的设计,涵盖从基础架构到高级功能的各个方面。我们将分析当前市场上流行的聊天应用,研究它们的成功之处,并探讨如何将这些经验应用到我们自己的设计中。
图1呈现了目前市场上最受欢迎的几款聊天应用。这些应用之所以能够脱颖而出,不仅因为它们满足了用户的基本通讯需求,还因为它们在用户体验、功能创新和技术实现上都有独到之处。通过学习这些成功案例,我们可以更好地理解用户需求,把握技术趋势,从而设计出更加优秀的聊天系统。
在开始设计聊天系统之前,明确系统的具体需求是至关重要的。聊天应用的多样性意味着它们可以服务于广泛的用途和用户群体,因此在设计过程中,我们需要特别注意以下几点:
- 明确目标用户: 是面向普通消费者的即时通讯工具,还是针对企业内部沟通的协作平台?了解目标用户群体有助于确定核心功能和用户界面设计。
- 确定主要使用场景: 是以一对一聊天为主,还是以群组交流为重点?不同的使用场景会影响系统的架构设计和功能优先级。
- 功能需求分析: 除了基本的文字聊天,是否需要支持语音通话、视频会议、文件传输等功能?每增加一项功能都会影响系统的复杂度和资源需求。
- 性能要求: 系统需要支持多少同时在线用户?消息传递的延迟要求是什么?这些因素会直接影响到系统的架构设计和技术选型。
- 安全性和隐私考虑: 是否需要端到端加密?如何保护用户数据?在某些应用场景中,这可能是最关键的需求之一。
- 可扩展性: 系统是否需要能够快速扩展以适应用户增长?这将影响到底层架构的设计。
- 跨平台支持: 是否需要支持多种设备和操作系统?这会影响到客户端的开发策略。
- 集成需求: 是否需要与其他系统或服务集成?例如,与社交媒体平台或企业管理系统的集成。
好的系统设计始于对需求的深入理解。在实际工作中,这个阶段可能需要多次迭代和讨论。通过仔细分析和明确需求,我们可以为接下来的设计过程奠定坚实的基础,最终交付一个既满足用户需求又技术先进的聊天系统。
第一步 - 理解问题并确立设计范围
确定要设计的聊天应用类型至关重要。市场上有各种类型的聊天应用,如专注于一对一聊天的Facebook Messenger、微信和WhatsApp,侧重群聊的办公应用Slack,以及注重大群交互和低延迟语音聊天的游戏聊天应用Discord。
在该博客中,我们专注于设计一个类似Facebook Messenger的聊天应用,重点关注以下功能:
- 低延迟的一对一聊天
- 小型群聊(最多100人)
- 在线状态显示
- 多设备支持。同一账户可以同时登录多个设备。
- 推送通知
确定设计规模也很重要。我们将设计一个支持5000万日活跃用户的系统。这些需求为我们的设计提供了清晰的框架和边界。通过明确这些关键点,我们可以更有针对性地进行系统设计,避免在不必要的功能上浪费时间,同时确保能够满足核心需求和性能指标。
在实际的系统设计中,这个阶段的重要性往往被低估。然而,正是这个阶段的深入讨论和明确定义,为后续的架构设计和技术选型奠定了基础。它不仅有助于我们集中精力于最关键的功能,还能帮助我们预见可能遇到的挑战和瓶颈。例如,知道系统需要支持5000万日活跃用户,我们就可以开始考虑如何设计一个高度可扩展的架构。了解到需要支持多设备登录,我们就需要考虑如何同步不同设备间的消息和状态。而"永久存储聊天历史"这一需求则提醒我们需要设计一个高效的数据存储和检索系统。
通过这个过程,我们不仅明确了设计目标,还初步勾勒出了系统的轮廓。这为接下来的详细设计和技术讨论提供了明确的方向,使我们能够更有效地进行后续的设计工作。
第二步 - 提出高层设计并获得认可
要开发高质量的设计,我们应该对客户端和服务器如何通信有基本的了解。在聊天系统中,客户端可以是移动应用或网页应用。客户端之间不直接通信,而是每个客户端连接到一个支持上述所有功能的聊天服务。让我们专注于基本操作。聊天服务必须支持以下功能:
- 接收来自其他客户端的消息。
- 为每条消息找到正确的接收者并将消息转发给接收者。
- 如果接收者不在线,则在服务器上保留该接收者的消息,直到他们上线。
图2展示了客户端(发送者和接收者)与聊天服务之间的关系。
当客户端打算开始聊天时,它使用一个或多个网络协议连接聊天服务。对于聊天服务来说,网络协议的选择很重要。
对于大多数客户端/服务器应用程序,请求是由客户端发起的。这对聊天应用的发送方也是如此。在图2中,当发送者通过聊天服务向接收者发送消息时,它使用经过时间考验的HTTP协议,这是最常见的网络协议。在这种情况下,客户端与聊天服务建立HTTP连接并发送消息,通知服务将消息发送给接收者。这里使用keep-alive很有效,因为keep-alive头允许客户端与聊天服务保持持久连接,同时也减少了TCP握手的次数。HTTP在发送方是一个不错的选择,许多流行的聊天应用,如Facebook,最初就使用HTTP发送消息。
然而,接收方的情况稍微复杂一些。由于HTTP是客户端发起的,从服务器发送消息并不简单。多年来,许多技术被用来模拟服务器发起的连接:轮询(polling)、长轮询(long polling)和WebSocket。这些都是系统设计面试中广泛使用的重要技术,让我们逐一研究。
轮询(Polling)
如图3所示,轮询是一种客户端定期询问服务器是否有可用消息的技术。根据轮询频率的不同,轮询可能会消耗大量资源。它可能会消耗宝贵的服务器资源来回答一个大多数时候答案都是"否"的问题。
- 工作原理:
- 客户端以固定的时间间隔向服务器发送HTTP请求。
- 服务器立即响应,无论是否有新的消息可用。
- 如果有新消息,服务器会在响应中包含这些消息。
- 如果没有新消息,服务器会发送一个空响应。
- 优点:
- 实现简单:客户端和服务器端的逻辑都相对直接。
- 兼容性好:几乎所有的浏览器和服务器都支持这种方式。
- 防火墙友好:使用标准的HTTP请求,不会被防火墙阻挡。
- 缺点:
- 资源浪费:即使没有新消息,也会频繁发送请求,浪费带宽和服务器资源。
- 实时性差:消息的接收存在延迟,取决于轮询间隔。
- 服务器负载高:当有大量客户端同时轮询时,可能会给服务器带来巨大压力。
- 优化策略:
- 自适应轮询间隔:根据消息频率动态调整轮询间隔。
- 批量获取消息:每次轮询获取多条消息,减少请求次数。
- 结合其他技术:例如,可以用轮询来检查是否有新消息,有的话再建立更高效的连接获取消息内容。
在设计聊天系统时,轮询可能会作为一种降级策略或备用方案使用,例如当WebSocket连接失败时。但对于主要的消息传递机制,我们通常会选择更高效的方法,如WebSocket或长轮询。
长轮询(Long Polling)
由于轮询可能效率低下,下一个进展是长轮询(如图4所示)。
在长轮询中,客户端保持连接打开,直到实际有新消息可用或达到超时阈值。一旦客户端接收到新消息,它立即向服务器发送另一个请求,重新启动这个过程。长轮询有几个缺点:
- 发送者和接收者可能不会连接到同一个聊天服务器。基于HTTP的服务器通常是无状态的。如果您使用轮询(Round Robin)进行负载均衡,接收消息的服务器可能与接收消息的客户端没有长轮询连接。
- 服务器没有好的方法来判断客户端是否已断开连接。
- 它是低效的。如果用户不经常聊天,长轮询仍会在超时后定期建立连接。
- 工作原理:
- 客户端向服务器发送HTTP请求。
- 如果服务器没有可用的新数据,不会立即发送响应,而是保持请求打开。
- 当新数据可用时,服务器立即响应该请求。
- 客户端收到响应后,立即发送新的请求,重新开始这个过程。
- 优点:
- 相比简单轮询,减少了不必要的请求和响应。
- 能够更接近实时地接收消息。
- 使用标准的HTTP协议,兼容性好。
- 缺点:
- 服务器资源消耗:长时间保持连接可能会占用服务器资源。
- 可能的延迟:如果消息恰好在一个长轮询请求结束后到达,需要等待下一个请求才能接收。
- 连接管理复杂:需要处理连接超时、重连等情况。
- 分布式系统中的挑战:
- 消息路由:在多服务器环境中,接收消息的服务器可能不是持有长轮询连接的服务器。
- 需要额外的机制(如消息队列或发布-订阅系统)来确保消息能正确地路由到持有客户端连接的服务器。
- 优化策略:
- 使用超时机制:设置合理的超时时间,平衡实时性和资源消耗。
- 自适应超时:根据用户活跃度动态调整超时时间。
- 批量处理:在一个响应中返回多条消息,提高效率。
在设计聊天系统时,长轮询可能是一个折中的选择,特别是在不支持WebSocket的环境中。然而,对于大规模、高并发的聊天应用,WebSocket通常是更好的选择。理解长轮询的这些特性和局限性,有助于我们在系统设计中做出更informed的决策,选择最适合特定需求和约束的技术方案。
WebSocket
WebSocket是从服务器向客户端发送异步更新最常用的解决方案。图5展示了它的工作原理。
WebSocket连接由客户端发起。它是双向的且持久的。它最初作为HTTP连接开始,然后通过一些定义良好的握手过程"升级"为WebSocket连接。通过这个持久连接,服务器可以向客户端发送更新。WebSocket连接通常即使在有防火墙的情况下也能工作。这是因为它们使用80或443端口,这也是HTTP/HTTPS连接使用的端口。
早些时候我们说在发送方使用HTTP协议就足够了,但由于WebSocket是双向的,从技术上讲没有强烈的理由不也将其用于发送。图6展示了如何在发送方和接收方都使用WebSocket(ws)。
通过在发送和接收两端都使用WebSocket,可以简化设计并使客户端和服务器端的实现更加直接。由于WebSocket连接是持久的,在服务器端进行高效的连接管理至关重要。
- 工作原理:
- 客户端初始化WebSocket连接请求。
- 服务器响应并升级连接从HTTP到WebSocket。
- 建立全双工、持久的连接通道。
- 客户端和服务器可以随时互相发送消息。
- 优点:
- 实时性强:消息可以立即双向传递。
- 效率高:相比HTTP,减少了连接建立和头部信息的开销。
- 支持推送:服务器可以主动向客户端推送消息。
- 跨平台:广泛支持,包括Web、移动和桌面应用。
- 在聊天系统中的应用:
- 即时消息传递:消息可以立即发送和接收。
- 在线状态更新:可以实时通知用户状态变化。
- 多设备同步:可以轻松实现跨设备的消息同步。
- 性能考虑:
- 连接管理:需要有效管理大量并发的长连接。
- 心跳机制:实现心跳来保持连接活跃并检测断开。
- 重连策略:在连接断开时实现智能重连机制。
- 安全性:
- 加密:WebSocket支持WSS(WebSocket Secure),类似于HTTPS。
- 认证:初始连接时可以进行身份验证。
- 消息验证:可以实现消息级别的安全检查。
- 扩展性设计:
- 负载均衡:使用专门的WebSocket负载均衡器。
- 集群化:实现WebSocket服务器的集群以提高可用性。
- 消息队列:使用消息队列系统来处理大量并发消息。
- 客户端实现注意事项:
- 断线重连:实现自动重连机制。
- 消息缓存:在断线期间缓存消息,重连后发送。
- 状态同步:重连后同步离线期间的状态变化。
- 服务器端实现注意事项:
- 连接池管理:高效管理大量并发连接。
- 资源限制:设置合理的连接数限制和超时机制。
- 消息路由:在分布式系统中正确路由消息到指定的接收者。
- 移动端特殊考虑:
- 电池影响:保持长连接可能增加电池消耗,需要智能管理连接。
- 网络切换:在网络状态变化时(如从WiFi切换到4G)保持连接的策略。
在设计大规模聊天系统时,WebSocket通常是首选的技术。它提供了实时性、效率和灵活性。然而,成功实施WebSocket还需要考虑诸多因素,包括连接管理、扩展性、安全性和异常处理等。通过仔细设计和优化这些方面,可以构建出高效、可靠的实时聊天系统。
高层设计
我们刚才提到选择WebSocket作为客户端和服务器之间的主要通信协议,因为它支持双向通信。但重要的是要注意,其他所有功能并不一定都要使用WebSocket。实际上,聊天应用的大多数功能(如注册、登录、用户资料等)都可以使用传统的HTTP请求/响应方法。让我们深入探讨一下系统的高层组件。
如图7所示,聊天系统被分为三大类:无状态服务、有状态服务和第三方集成。
无状态服务
无状态服务是传统的面向公众的请求/响应服务,用于管理登录、注册、用户资料等。这些是许多网站和应用程序中常见的功能。
无状态服务位于负载均衡器之后,负载均衡器的工作是根据请求路径将请求路由到正确的服务。这些服务可以是单体的或独立的微服务。我们不需要自己构建许多这些无状态服务,因为市场上有可以轻松集成的服务。我们将在深入探讨中更多讨论的一个服务是服务发现。它的主要工作是为客户端提供一个客户端可以连接的聊天服务器的DNS主机名列表。
特征:
- 不保存客户端会话状态。
- 每个请求都是独立的,包含处理该请求所需的所有信息。
- 可以轻松扩展,因为任何服务器都可以处理任何请求。
常见的无状态服务:
a) 认证服务:
- 处理用户登录和注册。
- 可能使用JWT(JSON Web Tokens)来管理用户会话。
- 集成第三方认证(如OAuth)。
b) 用户资料服务:
- 管理用户个人信息。
- 处理资料更新请求。
- 可能集成头像存储和处理功能。
c) 好友管理服务:
- 处理添加、删除、阻止好友的请求。
- 管理好友列表。
d) 消息历史服务:
- 提供历史消息的存储和检索。
- 可能涉及分页和搜索功能。
e) 文件传输服务:
- 处理文件上传和下载。
- 可能集成云存储服务。
服务发现:
- 为客户端提供可用聊天服务器的列表。
- 可以使用DNS轮询或更复杂的服务发现机制。
- 帮助实现负载均衡和故障转移。
有状态服务
唯一的有状态服务是聊天服务。这个服务是有状态的,因为每个客户端都与一个聊天服务器保持持久的网络连接。在这个服务中,只要服务器仍然可用,客户端通常不会切换到另一个聊天服务器。服务发现与聊天服务密切协调,以避免服务器过载。我们将在深入探讨中详细介绍。
特征:
- 维护客户端的会话状态。
- 使用持久连接(通常是WebSocket)。
- 每个客户端通常与特定服务器实例保持长期连接。
聊天服务的核心功能:
a) 连接管理:
- 建立和维护WebSocket连接。
- 处理连接的认证和授权。
- 实现心跳机制以检测连接状态。
b) 消息路由:
- 接收来自客户端的消息并转发给正确的接收者。
- 处理一对一聊天和群组聊天的消息分发。
c) 在线状态跟踪:
- 实时更新和广播用户的在线/离线状态。
- 管理用户的"正在输入"状态。
d) 消息持久化:
- 将消息存储到数据库以供历史记录查询。
- 处理离线消息的存储和后续发送。
服务发现与负载均衡:
- 与服务发现组件协作,动态分配客户端到合适的服务器。
- 实现智能负载均衡策略,考虑服务器负载、地理位置等因素。
扩展性考虑:
a) 水平扩展:
- 增加服务器实例以处理更多并发连接。
- 实现无状态的会话管理,允许在服务器之间迁移连接。
b) 分片:
- 基于用户ID或聊天室ID进行数据分片。
- 确保相关的用户或群组被路由到同一服务器或服务器组。
高可用性:
- 实现故障检测和自动故障转移机制。
- 使用多区域部署以提高可用性和减少延迟。
性能优化:
- 使用内存数据库(如Redis)存储临时数据和状态信息。
- 实现消息的批处理和异步处理。
- 优化数据库操作,如使用写入缓冲和读取缓存。
安全性:
- 实现端到端加密以保护消息内容。
- 使用安全的WebSocket连接(WSS)。
- 实施速率限制以防止滥用。
第三方集成
对于聊天应用来说,推送通知是最重要的第三方集成。它是一种在应用程序未运行时也能通知用户有新消息到达的方式。正确集成推送通知至关重要。
推送通知系统:
a) 重要性:
- 保持用户参与度。
- 确保重要消息及时送达。
- 提高用户回复率和应用使用频率。
b) 主要功能:
- 发送新消息通知。
- 群组活动提醒。
- 系统公告和更新通知。
c) 实现考虑:
- 集成主要平台的推送服务:
- iOS: Apple Push Notification Service (APNS)
- Android: Firebase Cloud Messaging (FCM)
- 处理不同设备类型和操作系统版本。
- 实现智能通知策略,避免过度打扰用户。
- 支持富媒体通知(如图片、动作按钮等)。
d) 挑战:
- 确保通知的及时性和可靠性。
- 处理设备令牌更新和失效。
- 遵守平台特定的限制和最佳实践。
e) 优化:
- 实现批量推送以提高效率。
- 使用本地化内容增强用户体验。
- 提供通知设置,允许用户自定义通知偏好。
其他潜在的第三方集成:
a) 身份验证服务:
- 集成如 OAuth、OpenID Connect 的标准协议。
- 支持社交媒体登录(如 Facebook、Google、Twitter)。
b) 内容审核服务:
- 使用 AI 驱动的内容审核服务来过滤不当内容。
- 实现实时文本和图像审核。
c) 语音和视频通话:
- 集成如 Twilio、Agora 或 WebRTC 的服务。
- 支持一对一和群组音视频通话。
d) 文件存储和共享:
- 使用云存储服务如 AWS S3、Google Cloud Storage。
- 支持大文件传输和多媒体内容分享。
e) 翻译服务:
- 集成如 Google Translate 或 DeepL 的 API。
- 提供实时消息翻译功能。
f) 分析和监控:
- 使用如 Google Analytics、Mixpanel 的服务跟踪用户行为。
- 集成错误追踪工具如 Sentry 或 Crashlytics。
g) 支付系统:
- 如果应用涉及电子商务功能,集成支付网关。
h) 地理位置服务:
- 集成地图服务如 Google Maps 或 Mapbox。
- 支持位置共享功能。
可扩展性
单服务器的理论可能性:从理论上讲,即使是为100万并发用户设计的系统,也可能在一个现代云服务器上容纳所有用户连接。假设每个用户连接需要10KB的服务器内存,100万用户仅需要约10GB内存。这个估算确实展示了现代硬件的强大能力。
单服务器设计的问题:尽管理论上可行,但在实际的系统设计中,单服务器方案确实会引起严重的担忧。您正确地指出,没有技术专家会为如此规模的系统设计单服务器方案。主要问题包括:
- 单点故障:这是最大的问题。服务器故障将导致整个系统瘫痪。
- 可扩展性限制:难以应对突发流量或未来增长。
- 维护困难:升级或维护需要停机。
- 性能瓶颈:单服务器的处理能力和I/O终将成为瓶颈。
- 地理分布:难以为全球用户提供低延迟服务。
从单服务器开始的策略:在系统设计面试中,从单服务器设计开始是一个很好的策略,只要确保面试官知道这只是起点。这种方法有几个优点:
- 简化初始讨论,聚焦于核心功能。
- 展示您理解系统的基本需求。
- 为后续的扩展讨论奠定基础。
调整后的高层设计: 图8展示的调整后的高层设计很好地体现了一个可扩展的聊天系统架构。让我们详细分析一下每个组件:
图 8a) 客户端与WebSocket连接:
- 使用WebSocket保持与聊天服务器的持久连接,实现实时消息传递。
- 考虑客户端的重连逻辑和离线消息同步机制。
b) 聊天服务器:
- 处理消息的发送和接收。
- 可以水平扩展以处理更多并发连接。
- 考虑使用服务发现机制动态分配客户端到服务器。
c) 存在服务器:
- 管理用户的在线/离线状态。
- 可以使用分布式缓存(如Redis)来提高性能。
d) API服务器:
- 处理非实时请求,如登录、注册、资料更改等。
- 无状态设计,易于扩展。
e) 通知服务器:
- 发送推送通知。
- 集成各平台的推送服务(如APNS, FCM)。
f) 键值存储:
- 存储聊天历史。
- 选择适合大规模读写的数据库(如Cassandra或HBase)。
- 考虑数据分片策略以提高性能。
扩展性考虑:
a) 负载均衡:
- 在各层服务之前添加负载均衡器。
- 对WebSocket连接使用特殊的负载均衡策略。
b) 数据库扩展:
- 实现读写分离。
- 使用数据分片处理大规模数据。
c) 缓存策略:
- 引入分布式缓存层减轻数据库负载。
- 缓存热门对话和频繁访问的用户数据。
d) 异步处理:
- 使用消息队列(如Kafka)处理非实时任务。
e) 微服务架构:
- 考虑将大型服务拆分为微服务,提高系统的模块性和可维护性。
f) 全球化部署:
- 使用CDN加速静态资源交付。
- 考虑多区域部署以降低延迟。
监控和警报:
- 实施全面的监控系统,跟踪关键指标。
- 设置自动警报以快速响应问题。
安全性考虑:
- 实现端到端加密。
- 使用适当的认证和授权机制。
- 防御DDoS攻击和其他安全威胁。
这种分布式设计解决了单服务器方案的主要问题,提供了高可用性、可扩展性和容错能力。它允许系统根据需求独立扩展不同的组件,同时保持整体架构的灵活性。在实际实现中,可能还需要根据具体需求和约束进行进一步的优化和调整。
存储
现在,服务器已经搭建完毕,服务正在运行,第三方集成也已完成。在整个技术栈的最底层,就是数据存储部分。选择正确的数据存储方式至关重要。本小节将探讨聊天系统中应该使用哪种类型的数据库:关系型数据库还是非关系型数据库?
聊天系统通常包含两种类型的数据。第一种是通用数据,例如用户资料、设置、好友列表等。这些数据需要存储在稳定可靠的关系型数据库中。通常会采用复制和分片等技术来满足可用性和可扩展性的需求;第二种数据是聊天历史记录,这是聊天系统独有的数据类型。理解其读写模式非常重要。
聊天系统的聊天记录数据量会非常庞大。研究表明,Facebook Messenger 和 Whatsapp 每天处理的消息数量高达 600 亿条。然而,用户频繁访问的只有最近的聊天记录。他们通常不会去查找很久之前的聊天记录。
虽然大多数情况下用户查看的都是最近的聊天记录,但一些功能还是需要随机访问历史数据,例如搜索、查看@ 提及、跳转到特定消息等。数据访问层需要支持这些场景。对于一对一聊天的应用来说,读写比例大约是 1:1。
选择合适的存储系统来支持所有用例至关重要。我们推荐使用键值存储 (Key-Value Store) 来存储聊天记录,原因如下:
- 键值存储易于水平扩展。
- 键值存储提供极低的访问延迟。
- 关系型数据库在处理长尾数据时效率低下。当索引变得庞大时,随机访问的成本会很高。
- 键值存储已经被其他可靠的聊天应用所采用。例如,Facebook Messenger 使用 HBase,Discord 使用 Cassandra。
数据模型
刚才我们讨论了使用键值存储作为数据存储层。其中最重要的数据就是消息数据,下面让我们仔细看一下 1 对 1 聊天的消息表结构。
1 对 1 聊天消息表
表格展示了 1 对 1 聊天消息的表格结构。主键是 message_id
,它用于确定消息的顺序。我们不能仅仅依赖 created_at
字段来判断消息顺序,因为有可能出现两条消息同时创建的情况。
字段名 | 数据类型 | 描述 | 备注 |
---|---|---|---|
message_id | 字符串 | 消息的唯一标识符 | 主键 |
from_user_id | 字符串 | 发送消息的用户 ID | 外键,引用用户表 |
to_user_id | 字符串 | 接收消息的用户 ID | 外键,引用用户表 |
content | 字符串 | 消息内容 | 文本内容或其他格式数据 |
created_at | 时间戳 | 消息创建时间 |
群聊消息表
表格10 展示了群聊消息的表格结构。复合主键是 (channel_id, message_id)
。这里 channel 和 group 代表相同的含义,channel_id
是分区键,因为群聊中的所有查询都针对特定的频道进行。
字段名 | 数据类型 | 描述 | 备注 |
---|---|---|---|
channel_id | 字符串 | 频道的唯一标识符 | 复合主键的一部分 |
message_id | 字符串 | 消息的唯一标识符 | 复合主键的一部分 |
from_user_id | 字符串 | 发送消息的用户 ID | 外键,引用用户表 |
content | 字符串 | 消息内容 | 文本内容或其他格式数据 |
created_at | 时间戳 | 消息创建时间 |
消息 ID 生成
如何生成 message_id
是一个值得探讨的有趣话题。message_id
承担着确保消息顺序的重任,为了实现消息排序,message_id
需要满足以下两个要求:
- 唯一性: 每个消息的 ID 必须是唯一的。
- 时间可排序: ID 应该具有时间顺序,即较新的消息的 ID 应该大于较旧的消息的 ID。
那么如何才能满足这两个保证呢?
方案一:自增主键 (Auto-increment)
这是最直观的想法,在关系型数据库中我们可以使用 MySQL 的
auto_increment
特性来实现。但是,NoSQL 数据库通常不提供类似的功能。方案二:全局递增序列号生成器
另一种方案是使用全局的 64 位序列号生成器,例如 Snowflake提供的这类服务。这种方式在 “分布式系统中设计唯一 ID 生成器” 章节中有详细讨论。
方案三:本地递增序列号生成器
最后一种方案是使用本地递增序列号生成器。这里的 “本地” 意味着 ID 在某个组内是唯一的。之所以本地 ID 可以工作,是因为我们只需要保证一对一聊天或群聊频道内的消息顺序即可。与全局 ID 实现相比,这种方式更加易于实现。
第三步 - 系统设计深度剖析
在系统设计面试中,通常会要求你深入探讨高级设计中的一些组件。对于聊天系统来说,服务发现、消息流和在线/离线指示器值得更深入地研究。
服务发现 (Service Discovery)
服务发现的主要作用是根据地理位置、服务器容量等标准,为客户端推荐最合适的聊天服务器。它可以注册所有可用的聊天服务器,并根据预定义的标准为客户端挑选最合适的服务器。它是帮助应用程序定位其他服务的机制。在聊天系统中,可能有多个微服务协同工作,例如用户服务、消息服务和推送服务。服务发现使应用程序不必关心底层基础设施细节,例如服务的主机名和端口号。Apache Zookeeper是一个流行的开源服务发现解决方案。
以下是一些常用的服务发现技术:
- DNS (Domain Name System): 传统且广泛使用的服务发现方式,可以通过域名解析服务将服务名称转换为 IP 地址和端口号。
- ZooKeeper : 一个集中式的分布式协调服务,可用于存储和检索服务元数据。
- Consul : 功能类似于 ZooKeeper 的开源服务发现工具,提供服务注册、健康检查和 KV 存储等功能。
- etcd : 由 CoreOS 开发的开源分布式键值存储,常用于服务发现和配置管理。
图 11 展示了服务发现 (Zookeeper) 的工作流程:
- 用户 A 尝试登录应用。
- 负载均衡器将登录请求发送到 API 服务器。
- 后台验证用户之后,服务发现会为用户 A 找到最合适的聊天服务器。在这个例子中,选择了服务器 2,并将其信息返回给用户 A。
- 用户 A 通过 WebSocket 连接到聊天服务器 2。
消息流 (Messaging Flows)
消息流是指消息在聊天系统中从发送方传递到接收方的过程。它通常涉及以下步骤:
1 对 1 聊天
用户 A 发送聊天消息到聊天服务器 1
- 用户 A 在聊天界面输入消息并点击发送按钮。
- 用户 A 的客户端应用将消息内容、接收方用户 ID 等信息封装成消息对象。
- 通过 WebSocket 连接,客户端应用将消息对象发送到聊天服务器 1。
聊天服务器 1 获取消息 ID
- 聊天服务器 1 调用消息 ID 生成器 (如上文讨论的几种方案) 获取一个唯一的消息 ID。
- 消息 ID 将被附加到消息对象上,用于后续的消息跟踪和排序。
聊天服务器 1 发送消息到消息同步队列
- 聊天服务器 1 将包含消息内容、消息 ID、发送方和接收方信息等数据的完整消息对象放入消息同步队列。
- 消息同步队列可以是内存队列或持久化队列,具体取决于系统的需求。例如,内存队列处理速度快,但容易丢失数据;持久化队列可以保证消息可靠性,但处理速度可能稍慢。
消息存储到键值存储
- 聊天服务器 1 将包含完整消息数据的消息对象存储到键值存储中。
- 键值存储可以高效地存储和检索消息数据,并支持根据不同的键进行查询(例如根据
message_id
查询消息内容)。
消息传递到接收方
根据接收方用户 (用户 B) 的在线状态,
聊天系统采用不同的策略传递消息:
- a. 用户 B 在线
- 聊天服务器 1 通过服务发现 (Zookeeper 等) 找到用户 B 当前连接的聊天服务器 (假设为服务器 2)。
- 聊天服务器 1 将消息对象转发到聊天服务器 2。
- b. 用户 B 离线
- 聊天服务器 1 不会直接将消息发送到离线用户。
- 聊天服务器 1 将消息的概要信息 (例如发送方昵称、消息摘要) 发送到推送通知 (PN) 服务器。
- PN 服务器根据用户 B 的设备信息,通过推送通知服务将消息概要信息发送到用户 B 的设备。
- a. 用户 B 在线
接收方收到消息
- a. 用户 B 在线
- 用户 B 的客户端通过 WebSocket 连接一直监听聊天服务器 2 发送的消息。
- 当聊天服务器 2 转发消息对象过来时,用户 B 的客户端收到消息并将其展示在聊天界面上。
- b. 用户 B 离线
- 用户 B 需要通过其他方式 (例如打开应用) 拉取最新的消息记录,才能看到完整的聊天消息内容。
消息同步多个设备
许多用户在使用多个设备进行聊天。下面我们将介绍如何在多个设备之间同步消息,确保用户在任何设备上看到的聊天记录都是一致的。
图 13 展示了消息同步的一个示例。图中,用户 A 拥有手机和笔记本电脑两个设备。当用户 A 用手机登录聊天应用时,手机会通过 WebSocket 连接到聊天服务器 1。同样,笔记本电脑也会建立与其的连接。
1. 客户端游标 (cur_max_message_id)
每个设备都会维护一个变量,称为 cur_max_message_id
。它用来跟踪设备上本地存储的最新消息 ID。
2. 新消息识别
系统会根据以下两个条件来判断一条消息是否对当前设备而言是新消息:
- 消息的接收方用户 ID 与当前登录的用户 ID 相等。
- 消息在键值存储中的 ID 大于设备上存储的
cur_max_message_id
。
3. 消息拉取
由于每个设备的 cur_max_message_id
都不同,因此消息同步变得简单。每个设备都可以从键值存储中拉取满足上述条件的消息,即消息的接收方为自己且 ID 大于本地存储的最新消息 ID 的消息。
4. 更新游标
当设备成功拉取到新消息后,会将 cur_max_message_id
更新为拉取到的最大消息 ID,以便后续仅拉取新消息。
5. 消息展示
拉取到的新消息将会展示在聊天界面上。
小型群聊消息流
与一对一聊天相比,群聊的逻辑更加复杂。图 14 和图 15 展示了群聊消息的发送和接收流程。
图 14 展示了用户 A 在群聊中发送消息时的流程,假设群聊中有 3 个成员(用户 A、用户 B 和用户 C)。
- 用户 A 的客户端将消息内容、群聊 ID 等信息封装成消息对象。
- 聊天服务器 1 将消息对象复制一份,并分别放入用户 B 和用户 C 的消息同步队列中。
- 每个群成员都可以通过自己对应的消息同步队列接收来自不同发送方的消息。
设计选择: 这种将消息拷贝一份到每个群成员的消息同步队列的方案适用于小型群聊,原因如下:
- 简化消息同步流程:每个客户端只需要检查自己的消息同步队列即可获取新消息,降低了复杂性。
- 小型群聊成员数量较少,为每个成员存储一份消息副本的代价可以接受。
局限性: 微信也采用了类似的方案,并且将群聊人数限制在了 500 人以内。但是,对于拥有大量成员的群聊来说,为每个成员存储一份消息的副本将造成非常大的存储开销,变得不可接受。
在线/离线状态管理
在线/离线指示器是许多聊天应用必不可少的功能,通常用户可以在其他用户的名字或头像旁边看到一个绿色的点,表示该用户是否在线。本节将介绍在线/离线指示器背后的实现原理。
在高级设计中,Presence Server (存在感服务器) 负责管理用户的在线状态,并通过 WebSocket 与客户端进行通信。以下是一些会触发用户在线状态改变的常见场景:
用户登录
用户登录流程已经在 “服务发现” 章节有所提及。当客户端通过服务发现找到合适的聊天服务器并建立 WebSocket 连接后,用户 A 的在线状态和最后活跃时间戳 (last_active_at
) 会被保存到键值存储中。此时,Presence Server 会通知客户端更新界面,显示用户 A 为在线状态。
用户登出
图 17 展示了用户登出时的流程。
- 用户点击退出按钮,客户端向 Presence Server 发送下线消息。
- Presence Server 收到下线消息后,会将该用户的状态更新为离线,并将其从在线用户列表中移除。
- Presence Server 会向其他在线用户推送该用户下线的消息,更新其他用户的好友列表中的在线状态。
- 键值存储中,该用户的在线状态和最后活跃时间戳 (
last_active_at
) 也将被更新为反映离线状态。 - 客户端断开与 Presence Server 的 WebSocket 连接。
用户断线
网络连接并不总是可靠稳定的。用户断线后,客户端与服务器之间会失去连接。一种简单粗暴的处理方式是将用户直接标记为离线,当重连后又将其标记为在线。然而,这种方式存在着明显的缺陷:
- 用户在短时间内频繁断线和重连的情况很常见,例如在穿过隧道时网络信号可能会断断续续。
- 如果在线状态跟随每一次断线/重连进行更新,则 Presence 指示器将频繁闪烁,带来糟糕的用户体验。
为了解决这个问题,我们引入了心跳检测机制。
- 在线客户端会定期向 Presence Server 发送心跳包。
- Presence Server 会记录收到心跳包的最后时间。
- 如果在 预设的超时时间 (例如 x 秒) 内没有收到该用户的心跳包,Presence Server 会将该用户的状态标记为离线,并将其从在线用户列表中移除。
- 同时,Presence Server 会向其他在线用户推送该用户下线的消息,更新其他用户的好友列表中的在线状态。
图 18 展示了一个示例:
- 客户端每隔 5 秒向服务器发送一个心跳包。
- 在发送了 3 个心跳包之后,客户端断线,并且在 超时时间 x = 30 秒 内没有重新连接 (此数值仅用于演示逻辑,实际超时时间需要根据具体情况设定)。
- 此时,Presence Server 会检测到心跳包超時,并将该用户的状态标记为离线。
在线状态推送
上文介绍了 Presence Server 如何跟踪用户在线状态,那么其他用户如何得知好友的在线状态更新呢?图 19 展示了常见的一种实现方式。
Presence Server 采用发布-订阅 (Publish-Subscribe) 的模式来实现在线状态的推送。
- 每个好友对之间会维护一个唯一的频道 (channel)。
- 当用户 A 的在线状态发生改变时,Presence Server 会将这个事件发布到与其相关的频道,例如 A-B、A-C 和 A-D 频道。
- 用户 B、C 和 D 分别订阅了与之对应的频道,因此他们可以及时收到好友 A 的在线状态更新消息。
- 客户端和服务器之间的通信通过实时的 WebSocket 连接进行。
这种方案对于用户规模较小的场景是比较高效的。例如,微信将群聊人数限制在了 500 人以内,也采用了类似的发布-订阅模型进行在线状态推送。
局限性:
对于用户规模较大的场景,例如一个拥有 100,000 名成员的群聊,当一名成员的在线状态发生变化时,需要向所有成员推送消息,会产生大量的事件 (100,000 个),带来严重的性能瓶颈。
解决方法:
- 拉取在线状态 (Pull Updates): 仅在用户进入群聊或手动刷新好友列表时,再向服务器请求好友的在线状态。
- 其他优化方案: 还可以采用分级推送、批量更新等方式来优化在线状态推送的性能。
第四步 - 系统设计总结
该博客介绍了一个支持一对一聊天和小群聊的聊天系统架构。WebSocket 用于客户端和服务器之间的实时通信。聊天系统包含以下组件:
- 用于实时消息传递的聊天服务器
- 用于管理在线状态的 Presence 服务器
- 用于发送推送通知的推送通知服务器
- 用于保存聊天历史记录的键值存储
- 用于提供其他功能的 API 服务器
如果您在面试结束时还有额外的时间,可以讨论以下附加话题:
- 支持媒体文件 扩展聊天应用程序以支持照片和视频等媒体文件。媒体文件的大小远大于文本,我们可以讨论压缩、云存储和缩略图等相关技术。
- 端到端加密 Whatsapp 支持消息的端到端加密,只有发送者和接收者可以阅读消息。
- 客户端缓存 在客户端缓存消息可以有效减少客户端和服务器之间的数据传输。
- 优化加载时间 Slack 构建了一个地理分布式网络来缓存用户的數據、频道等信息,从而改善加载时间。
- 错误处理
- 聊天服务器错误:聊天服务器可能需要处理成千上万的持久连接。如果聊天服务器宕机,服务发现 (Zookeeper) 将为客户端提供新的聊天服务器以建立新的连接。
- 消息重发机制:重试和队列是常见的用于消息重发的技术。
恭喜您走到这一步!为自己的出色表现鼓掌吧!