OpenAI Slack 题目的完整解答:服务边界、数据模型、先持久化再发布的消息路径、针对小型和超大型频道的自适应扇出、Redis 与 Kafka 的对比、多设备同步、通知、文件、删除以及工作区隔离。

作者:The Forward Deployed 编辑团队已审阅

属于 OpenAI 系统设计题库。该题目具有该轮面试的代表性。分析与解答为本站原创。

问题描述

用户隶属于工作区,每个公司一个工作区。在工作区内,用户可以发送私信并在频道中发言。消息需在不到一秒内出现在每个成员的在线设备上。离线设备在重新连接后补齐消息。提及会触发通知。搜索、应用和通话不在范围内,除非额外要求。

澄清问题

  • 规模? 练习场景:2000 万日活用户,分布在 50 万个工作区,每用户每天发送 50 条消息。
  • 频道大小? 大多数频道成员少于 50 人;也有一些全公司频道,成员达 5 万。
  • 设备? 每用户最多约五台设备:台式机、笔记本、手机、平板、网页。
  • 送达目标? 在线成员 500 毫秒以内。
  • 历史消息? 按工作区的保留策略保存;按页加载。
  • 删除? 发送者可以删除自己的消息;管理员也可以。
  • 隔离? 工作区之间绝不能看到彼此的数据;一些大客户要求数据驻留。

团队聊天为什么难

三个问题叠加在一起。

扇出差异巨大。一条私信只发给一个人。而一条发到 5 万人频道的消息要送达 5 万人,其中许多人同时在多台设备上在线。一种投递策略无法同时兼顾两者:按接收者投递对小频道精确,但对超大频道会爆炸。

每个用户有多台设备,每台设备对已读内容有自己的视图。一条消息必须到达所有设备,而且在一台设备上阅读后,其他设备的未读角标也应清除。

同时,投递既要快又要持久。经过内存 pub/sub 的快速路径可能丢消息;经过存储的持久路径则较慢。

因此核心矛盾是扇出成本与投递延迟,解决办法是根据频道大小切换扇出策略,并把持久性与速度分离。

flowchart LR C([Clients: many devices]):::user <-->|WebSocket| GW[Gateways]:::svc GW --> MS[Message service]:::svc --> DB[(Messages)]:::store MS --> PS[[Pub/sub]]:::svc --> GW MS --> NS[Notification service]:::svc --> PUSH([Mobile push]):::user CH[Channel service: membership]:::svc --> MS classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
核心思想。 先把消息持久化存储一次,再根据频道大小选择扇出策略进行快速投递。

关键概念

服务边界

按数据形态和负载来划分服务。消息数量巨大且以追加写入为主。成员关系(谁在哪个频道中)数据量小、读取频繁并被缓存。网关只持有连接,不含业务逻辑。通知决定何时推送到手机。

推送与拉取投递

推送在消息产生时立即发送给接收者。拉取让客户端在需要时获取新消息。推送速度快,但每个接收者的成本高;拉取成本低,但需要客户端主动请求。聊天系统对小受众使用推送,对大受众采用混合方式。

游标

设备在某个频道中的游标是它已拥有的最后一条消息的 ID。重连时,它会请求每个频道中游标之后的消息,从而找出所有缺口。

可按时间排序的 ID

按时间排序的消息 ID,例如时间戳加序列号,使频道的消息历史成为一个有序区间,并让游标变得简单。

核心思想。 按数据形态拆分服务,对小受众采用推送、对大受众采用信号加拉取的方式投递,并让游标修复所有缺口。

需求

在继续阅读之前。 列出需求。哪些事情绝不能发生?什么因素驱动了架构设计?

1.1 功能需求

  • 发送和接收私信及频道消息。
  • 投递到用户所有在线设备;为离线设备补齐消息。
  • 显示未读计数,并在多设备间同步已读状态。
  • 在提及和私信时发送通知,并尊重用户偏好设置。
  • 在消息中分享文件;删除消息。
  • 保持工作区相互隔离。

1.2 非功能需求

  • 服务器确认之后绝不丢失消息。
  • 频道内的消息有序。
  • 到在线接收者的延迟低于 500 毫秒。
  • 可扩展到 5 万成员的频道。
  • 每个工作区具备隔离性与数据驻留。

1.3 约束与特性

持久、有序的投递是系统特性。 扇出成本是约束。 它迫使系统针对不同规模的频道采用不同的策略。

粗略估算

  • 消息:2000 万用户 × 50 = 每天 10 亿条;平均每秒约 11,600 条,峰值约 35,000 条。
  • 存储:10 亿 × 500 字节 = 每天 500 GB,复制前每年约 180 TB。
  • 连接数:峰值 1000 万在线,每人 1.5 台设备,约 1500 万个套接字。按每台网关主机 50 万连接计算,约需 30 台网关主机,另加余量。
  • 扇出:大多数消息发往小频道;假设平均投递到 20 个设备连接。35,000 × 20 = 峰值时每秒 70 万次投递。
  • 大频道:一个 5 万成员的频道中,一条消息按 60% 在线、每人 1.5 台设备计算,需要 45,000 次套接字写入。如果该频道在公告期间每秒收到 5 条消息,那么仅这一个频道就产生每秒 225,000 次写入。
核心要点。 消息的数量并不大;投递的数量是消息的 20 倍,而一个繁忙的大型频道就能与所有其他流量相匹敌。

API 设计

阅读前思考。 客户端应该通过 WebSocket 还是 HTTP 发送消息?

两者都可以。用 HTTP POST 发送消息更容易重试、认证和负载均衡;WebSocket 用于接收。许多聊天系统通过 HTTP 发送、通过 socket 接收。无论哪种方式,都要包含一个客户端消息 ID,用于去重。

3.1 HTTP

POST /v1/channels/:id/messages   {client_msg_id, text, file_ids?, thread_ts?}
  -> {message_id, ts}
DELETE /v1/channels/:id/messages/:message_id
GET  /v1/channels/:id/messages?after=:message_id&limit=100    // catch-up
GET  /v1/channels/:id/messages?before=:message_id&limit=50    // history
POST /v1/channels/:id/read       {last_read_message_id}
POST /v1/files                   {name, size, mime} -> {file_id, upload_url}

3.2 WebSocket 事件

server -> client
  message.new       {channel_id, message}
  message.deleted   {channel_id, message_id}
  channel.activity  {channel_id, latest_message_id}     // for huge channels
  read.updated      {channel_id, last_read_message_id}  // from another device
  presence.changed  {user_id, status}
client -> server
  subscribe.focus   {channel_id}                        // user is viewing this channel
  ping

数据模型

workspaces (id, name, plan, region, kms_key_ref)
users      (workspace_id, id, name, ...)
channels   (workspace_id, id, name, type: public|private|dm, member_count, created_at)
members    (workspace_id, channel_id, user_id, role, last_read_message_id, notify_pref)
           two access paths: by channel (fan-out) and by user (sidebar)
messages   (workspace_id, channel_id, message_id, sender_id, client_msg_id,
            text, file_ids, thread_root_id, edited_at, deleted_at)
           primary key (channel_id, message_id); message_id sorts by time
           unique (channel_id, sender_id, client_msg_id)
devices    (user_id, device_id, push_token, platform, last_seen_at)
files      (workspace_id, id, uploader_id, storage_key, size, scan_status, deleted_at)

高层设计

5.1 单台聊天服务器

所有客户端连接到一台服务器,该服务器存储消息并将其转发给已连接的成员。它适用于小型办公室,但在 1500 万个 socket 和第一次宕机时就会失败。

5.2 修复 1:网关与消息服务

大量无状态网关持有 socket。消息服务将消息写入按频道 ID 分片的数据库,然后发布它们。网关将消息投递到其本地连接。

5.3 修复 2:小频道的按用户扇出

对于私信和小频道,将消息发布到每个接收用户一个的 pub/sub 主题。每个网关订阅其持有用户的主题。投递是精确的:只有持有接收者的网关才会收到消息。

flowchart LR MS[消息服务]:::svc -->|publish user:alice| PS[[Pub/sub]]:::svc MS -->|publish user:bob| PS PS --> G1[网关 1:持有 alice]:::new PS --> G2[网关 2:持有 bob]:::new classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

5.4 修复 3:大频道的按频道扇出

对于一个 5 万成员的频道,按用户发布意味着一条消息要发布 5 万次。改为每个频道发布一次。每个至少持有一名成员的网关订阅该频道主题一次,并投递给其本地成员。

5.5 修复 4:游标与补拉

每个设备为每个频道保存一个游标。重连时,它请求游标之后的消息。Pub/sub 可能丢消息;补拉读取可以修复任何缺口。

5.6 修复 5:通知

通知服务对每个接收者进行判断:他们是否在任意设备上活跃?如果是,应用内投递就足够了。如果不是,且该消息是提及或私信,则发送移动推送,并遵守免打扰时段和频道偏好设置。

5.7 组合后的设计

sequenceDiagram
  autonumber
  actor A as Sender
  participant M as Message service
  participant D as Messages DB
  participant C as Channel service
  participant P as Pub/sub
  participant G as Gateways
  participant N as Notifications
  A->>M: POST message (client_msg_id)
  M->>D: insert (dedupe on client_msg_id), assign message_id
  M-->>A: message_id
  M->>C: member count, small or large?
  alt small channel or DM
    M->>P: publish to each member's user topic
  else large channel
    M->>P: publish once to channel topic
  end
  P->>G: deliver
  G->>G: write to local member sockets (all devices)
  M->>N: mentions and DMs
  N->>N: recipient active anywhere? preferences?
  N-->>N: send mobile push if needed
核心要点。 先持久化写入,然后按规模选择按用户或按频道发布,投递到每台设备,用游标修复缺口,并且只在没人看手机时才推送。

深入探讨

6.1 自适应扇出

阅读前思考。 你会把从按用户到按频道扇出的切换点设在哪里?切换时网关侧会发生什么?

按用户发布时,每个成员消耗一次发布;按频道发布时,每条消息消耗一次发布,外加每个持有该频道成员的网关一次订阅。成员数在几十以下时,按用户发布既便宜又精确。超过这个规模,按频道发布更优。选定一个阈值,比如 50 名成员,当频道增长跨过阈值时切换模式,并在切换期间短暂地同时向两条路径发布,确保不丢失任何消息。

对于按频道扇出,网关需要知道其用户属于哪些频道。当用户连接时,网关加载该用户的大型频道成员关系,并订阅这些频道的主题。一个拥有 50 万用户的网关可能订阅许多频道,但每个订阅只是一条记录,而不是每个用户一条。

对于超大频道,把每条消息推送到每个打开的连接往往很浪费:大多数成员并没有在看那个频道。只向正在关注该频道的成员发送完整消息,向其余成员发送一个轻量

channel.activity

信号。当用户打开频道时,其客户端再拉取消息。未读角标仍然通过该信号更新。

区分答案的关键:扇出

弱对所有频道一律采用按用户发布

每个接收者都要发布一次,在 5 万成员的公告频道上会崩溃。

良好小频道按用户,大频道按频道

根据规模切换策略,并能解释网关对频道主题的订阅机制。

强兼顾规模与关注度

还会只向正在查看频道的成员发送完整消息,向其余成员发送信号;在阈值切换频道时不留空隙;并能量化繁忙大型频道的负载。

6.2 服务与网关之间使用 Redis 还是 Kafka

Redis 发布/订阅延迟低且即发即弃:不存储任何内容,未连接的订阅者会丢失消息。这适合网关投递路径,因为持久性由消息数据库保证,游标可以修复缺口。

Kafka 是一个持久化、分区的日志,支持消费者偏移量和重放,但延迟和运维成本更高。它适合必须处理每条消息的消费者:搜索索引、合规归档、数据分析和机器人。消息服务同时写入两者:Redis 用于实时投递,Kafka 用于所有下游处理。

要能明确解释广播决策:向所有网关发布(简单但浪费),还是只向有感兴趣用户的网关发布(需要订阅跟踪)。按用户建主题和按频道建主题并配合网关订阅属于后者。

6.3 多设备与已读状态

在继续阅读之前。 用户在笔记本电脑上读了一个频道。他们手机的未读角标如何清除?

每个设备有自己的游标,用于追赶消息。已读状态是按用户存储的,保存在

last_read_message_id

成员关系行上。当笔记本电脑上报已读时,服务更新该行并向该用户的主题发布

read.updated

消息。该用户的每台设备都会收到并清除角标。

不要为每台设备创建每条消息的副本。这会使存储量随设备数量成倍增长,并导致已读状态不一致。消息在每个频道只存储一份;设备只记录自己的位置。

在线状态是按用户计算的,由任意活跃设备推导得出:心跳刷新一个会过期的键,状态变更则发布给关注该用户的用户。“最后在线时间”只在断开连接时写入,绝不在每次心跳时写入。

6.4 通知

针对每个接收者、每条消息的决策:

  • 是私信、提及,还是设置为全部通知的频道?如果不是,停止。
  • 用户当前是否在任意设备上活跃?如果是,应用内送达就足够了;不推送。
  • 是否处于该用户的免打扰时段?如果是,暂缓。
  • 向用户的移动设备发送推送,并将同一频道的重复推送合并为一条。

对最近活跃的用户,将推送延迟几秒,这样在桌面端已读的消息就不会同时让手机震动。

6.5 文件与删除

文件通过预签名 URL 直接上传到对象存储,然后经过恶意软件扫描。消息引用文件 ID。下载通过短时效的签名 URL 进行,且只有在确认请求者是该频道成员后才签发。

删除消息会设置

deleted_at

,并且该服务发布

message.deleted

。在线客户端立即将其移除;离线客户端在追赶同步时得知删除,追赶同步包含其游标之后的删除操作。一旦保留规则允许,后台任务会移除文本和所有文件。企业保留策略可能要求为合规目的保留已删除内容;将其建模为一个独立的、受访问控制的归档。

6.6 工作区隔离与规模

在继续阅读之前。 你如何确保一个 bug 不会把一家公司的消息展示给另一家公司?

在每个键和每张表中都放入工作区 ID。从经过身份验证的会话中派生它,绝不要从请求参数中获取。在每个服务中检查它,并让存储将其作为键的一部分强制执行。Pub/sub 主题名称中包含工作区 ID。

在规模方面,在一个区域内按频道 ID 对消息进行分片。为了满足数据驻留和防止邻居干扰,将每个工作区放置在一个归属区域;大型企业可以获得专用分片和自己的加密密钥。一个热门频道,例如全员大会期间的全公司频道,会集中在一个分片上:缓存其最新消息,并对超大频道中的发帖进行限流。

区分答案优劣的关键:隔离与规模

弱:事后才考虑隔离

提到了工作区,但允许 ID 来自客户端,并在租户之间共享缓存。

良好:每个键都包含工作区

从会话中派生工作区 ID,并据此限定存储和主题的范围。

强:隔离加部署位置

增加用于数据驻留的归属区域、面向大客户的专用分片和密钥,以及针对热门频道应对方案。

6.7 大规模下的未读计数

阅读前先思考。 一个用户加入了 400 个频道。侧边栏为每个频道显示未读计数。你如何快速计算这 400 个计数,并保持它们实时更新?

统计比以下时间更新的消息:

last_read_message_id

每次侧边栏加载时用 400 条查询逐频道统计太慢了。两个更廉价的机制配合工作:

  • 每个频道的最新消息 ID 保存在频道行和缓存中。侧边栏将其与用户在每个频道的 last_read_message_id 进行比较。如果不同,该频道加粗显示。这只需一次批量读取 400 个小值。
  • 只在显示处提供精确计数。 提及徽章和私信显示数字;普通频道只需要加粗与否。为每个用户维护每个频道的未读提及计数器,由消息服务在记录提及(mention)时递增,在已读时重置。

更新以事件形式到达。新消息会更新频道的最新 ID,网关向成员推送

channel.activity

,因此侧边栏实时加粗。在另一台设备上已读时推送

read.updated

.

6.8 让一条消息穿过一个超大频道

练习题:在一个 50,000 名成员的频道中发布公告,其中 30,000 名成员在线,分布在 60 个网关的 45,000 个 socket 上。

  • 消息服务将消息写入一次,写入频道的分片,并获得其 ID。
  • 它向频道主题发布一次。所有 60 个网关都已订阅,因为每个网关都有一些成员。
  • 每个网关检查其本地哪些成员打开了该频道。假设总共 2,000 人。他们收到完整消息。
  • 其余 43,000 个 socket 收到一个小的 channel.activity 事件。它们的客户端将该频道加粗。
  • 通知服务发现有 12 个用户被点名提及,以及 @channel。对于大型频道,@channel 受工作区策略限制;它只向 12 个被提及且当前不活跃的用户发送推送。
  • 稍后打开频道的成员通过游标获取消息。

一次数据库写入、一次发布、60 个网关的扇出,以及约 45,000 次 socket 写入,其中大多数都很小。

什么能让答案脱颖而出:大受众机制

弱:每次加载都重新统计

每次都用查询逐频道统计未读消息。

好:每频道最新 ID

将最新消息 ID 与已读位置比较以确定加粗状态,并维护提及计数器。

强:走通大频道流程

展示一条公告的确切工作流程:一次写入、一次发布、每个网关的扇出、只有查看者收到完整消息,以及受限的广播提及。

变体

7.1 话题串(Threads)

话题串是对一条根消息的一组回复。将回复存储在同一频道分区中,使用

thread_root_id

,并让客户端按根消息加载话题串。通知话题串参与者,而不是整个频道。

7.2 搜索

将 Kafka 流中的消息索引到按工作区分区的搜索引擎中,并在查询时根据频道成员资格进行权限过滤。

7.3 公司之间的共享频道

由两个工作区共享的频道需要一个主工作区来存储,以及一个跨越双方的成员模型,各公司对自己的用户副本应用各自的保留策略。

7.4 用户规模扩大十倍时

在 2 亿日活用户规模下,高峰期消息投递量超过每秒 700 万条。网关数量达到数百个,pub/sub 层变为每个区域一个分片集群。最大的变化是区域化:工作区归属到某个区域,跨区域共享频道在区域消息服务之间转发消息。搜索和分析按区域消费 Kafka 流。

可迁移的模式

团队聊天是持久存储加自适应扇出。每个事件只存储一次,然后根据受众规模和关注度选择按接收者推送、按群组推送,或信号加拉取。同样的选择也出现在社交信息流(写时扇出与读时扇出)、通知和实时仪表盘中。

回顾:30 秒答案

  • 拆分服务。 网关、消息、成员关系、通知。
  • 先写入,再发布。 先持久化消息;用 pub/sub 提速;用游标修复缺口。
  • 自适应扇出。 小频道用按用户主题,大频道用按频道主题,最大的用信号加拉取。
  • 设备跟踪游标;用户跟踪已读状态。 消息只有一份副本;已读事件同步角标。
  • 每个键都带工作区。 从会话中获取,在存储层强制执行,大租户拥有主区域和专用分片。

测验

+为什么大频道要从按用户发布切换到按频道发布?

按用户发布每条消息要向每个成员发布一次,大频道中就是 5 万次发布。按频道发布只需一次发布,每个网关向自己的本地成员投递。

+为什么 pub/sub 层丢消息不会丢数据?

消息在发布之前已持久化写入,每台设备在重连或发现缺口时,会通过读取游标之后的消息来补齐。

+为什么不每台设备保存一份消息副本?

那会使存储量乘以设备数,并让已读状态难以保持一致。每个频道只存一份消息,再按设备跟踪游标就足够了。

+通知服务什么时候发送移动推送?

当消息是私信、提及,或所在频道设置为通知,且用户在任何设备上都不活跃,也不在用户的免打扰时段时。

+为什么把用户数据和消息数据放在不同的服务里?

它们的形态和负载不同。消息体量巨大且以追加写入为主;用户和成员关系数据量小,但扇出时需要持续读取。分开部署让各自可以独立扩容、缓存和容错。

+侧边栏如何在不发 400 条计数查询的情况下显示 400 个频道中哪些有未读消息?

它将每个频道的最新消息 ID 与用户最后已读消息 ID 进行比较,一次性批量获取。精确计数仅针对提及和私信保留。

+在一个 5 万成员的频道中,谁会立即收到完整消息?

只有当前打开该频道的成员。其他在线成员会收到一个小的活动事件,并在打开频道时通过游标获取消息。

参考资料与延伸阅读

NextNearby Places Search