設計聊天系統(Chat System)

問題定義

設計一個支援一對一聊天和群組聊天的即時通訊系統,包含訊息傳遞、離線訊息、已讀回執等功能。

ℹ️核心技術選擇

即時通訊的關鍵是 WebSocket:建立持久的雙向連線,伺服器可以主動推送訊息。

三種方案的比較:

  • HTTP Polling:Client 每隔 N 秒發一次 GET 請求問「有新訊息嗎?」。問題:大量空請求浪費頻寬和伺服器資源,延遲最高可達 N 秒。只適合對即時性要求不高的場景(如 Email 通知)
  • Long Polling:Client 發請求,Server 有新訊息才回應,否則 Hold 住直到超時。問題:每次回應後需要重新建立連線,伺服器端 Hold 大量連線佔資源。比 Polling 好但仍有連線建立開銷
  • WebSocket:一次 HTTP Upgrade 後建立持久 TCP 連線,雙向隨時發資料。延遲最低(毫秒級)、最省資源。缺點:需要處理斷線重連、伺服器端需要維護大量長連線(每台伺服器約可維持 50,000-100,000 個 WebSocket 連線)

需求分析

注意事項

⚠️群組訊息的扇出問題

一個 500 人的群組,發一條訊息就要推送給 499 個人。如果每人都在線,就是 499 次 WebSocket 推送。當有多個大群組同時活躍時,推送量會爆炸。

兩種扇出策略

  • Write Fanout(寫擴散):發訊息時就把訊息寫入每個成員的 Inbox。優點:讀取快(每人只查自己的 Inbox)。缺點:寫入放大嚴重(500 人群組 = 寫 499 份)。適合:小群組(50 人以下)
  • Read Fanout(讀擴散):訊息只寫一份到群組的 Timeline。每個成員讀取時自己去拉。優點:寫入快。缺點:讀取時要查多個群組的 Timeline 再合併排序。適合:大群組(50 人以上)

實務上可以混合:小群組用 Write Fanout,大群組用 Read Fanout。微信的做法是小群組寫擴散、大群組讀擴散的混合模式。

設計流程

1

連線管理

用戶上線建立 WebSocket,伺服器維護連線映射表

2

訊息發送

發送者透過 WebSocket 發訊息到 Chat Service

3

訊息路由

Chat Service 查找接收者所在的伺服器,透過 MQ 轉發

4

訊息推送

接收者在線就透過 WebSocket 推送,離線就存入離線訊息表

5

訊息持久化

所有訊息寫入資料庫,支援歷史訊息查詢

6

已讀同步

接收者讀取訊息後,發送已讀回執更新狀態

架構設計

WebSocket 連線管理(TypeScript)

typescript
// 連線管理器:追蹤哪個用戶在哪台伺服器
class ConnectionManager {
  private connections = new Map<string, WebSocket>(); // userId → ws
 
  addConnection(userId: string, ws: WebSocket) {
    this.connections.set(userId, ws);
    // 在 Redis 中記錄:userId → serverId,TTL 300 秒
    // 為什麼要 TTL?防止伺服器異常關閉時殘留過期映射
    redis.set(`online:${userId}`, SERVER_ID, "EX", 300);
  }
 
  removeConnection(userId: string) {
    this.connections.delete(userId);
    redis.del(`online:${userId}`);
  }
 
  // 心跳機制:每 30 秒 Client 發 ping,Server 回 pong
  // 連續 3 次沒收到 ping 就判定斷線,清除連線
  startHeartbeat(userId: string, ws: WebSocket) {
    let missedPings = 0;
    const interval = setInterval(() => {
      if (missedPings >= 3) {
        ws.terminate();
        this.removeConnection(userId);
        clearInterval(interval);
        return;
      }
      missedPings++;
    }, 30_000);
 
    ws.on("pong", () => {
      missedPings = 0;
      // 刷新 Redis TTL
      redis.expire(`online:${userId}`, 300);
    });
  }
 
  async sendToUser(userId: string, message: ChatMessage) {
    const ws = this.connections.get(userId);
    if (ws?.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify(message));
      return true;
    }
    return false; // 用戶不在此伺服器或離線
  }
}

斷線重連機制:Client 端必須實作指數退避重連(Exponential Backoff)。第一次斷線 1 秒後重連,第二次 2 秒,第三次 4 秒,最大間隔 30 秒。重連成功後,用最後收到的 Sequence ID 向 Server 請求漏掉的訊息。

訊息發送流程(C#)

csharp
public class ChatService
{
    private readonly IMessageQueue _queue;
    private readonly IMessageRepository _repo;
    private readonly IConnectionRegistry _registry;
 
    public async Task SendMessage(ChatMessage msg)
    {
        // 1. 產生訊息 ID 和時間戳
        msg.Id = Guid.NewGuid().ToString();
        msg.Timestamp = DateTimeOffset.UtcNow;
 
        // 2. 持久化訊息
        await _repo.SaveAsync(msg);
 
        // 3. 取得接收者清單
        var recipients = msg.IsGroup
            ? await _repo.GetGroupMembersAsync(msg.GroupId!)
            : new[] { msg.RecipientId! };
 
        // 4. 逐一推送(透過 MQ 非同步)
        foreach (var recipientId in recipients)
        {
            if (recipientId == msg.SenderId) continue;
            await _queue.PublishAsync("chat.deliver", new DeliveryTask
            {
                MessageId = msg.Id,
                RecipientId = recipientId
            });
        }
    }
}
 
// MQ Consumer:負責實際推送
public class DeliveryWorker
{
    public async Task HandleDelivery(DeliveryTask task)
    {
        var serverId = await _registry.GetServerIdAsync(task.RecipientId);
        if (serverId != null)
        {
            // 用戶在線:透過該伺服器的 WebSocket 推送
            await _registry.PushToServerAsync(serverId, task);
        }
        else
        {
            // 用戶離線:存入離線訊息表
            await _offlineStore.SaveAsync(task);
        }
    }
}

架構圖

Sender (App)
WebSocket
WebSocket Server 1
send message
WebSocket Server 2
WebSocket
Chat Service
persist
Message Queue
lookup server
Redis (Online Status)
Message DB
Receiver (App)

架構圖解讀:Sender 透過 WebSocket 連到 Server 1,發送訊息到 Chat Service。Chat Service 先將訊息持久化到 Message DB,然後 publish 到 Message Queue。Queue 的消費者查 Redis 找到 Receiver 在 Server 2 上線,透過 Server 2 的 WebSocket 推送給 Receiver。如果 Receiver 離線,訊息存入離線訊息表,等 Receiver 上線後拉取。

實戰補充

💡訊息排序的挑戰

分散式系統中,訊息的絕對順序很難保證(不同伺服器的時鐘有微小偏差)。實務做法:

  1. 單聊:用伺服器端時間戳排序,精度到微秒基本夠用。如果時間戳相同,用訊息 ID(UUID v7 包含時間戳)做次要排序
  2. 群聊:用遞增的 Sequence ID(每個群組獨立的計數器),由 Chat Service 統一分配。Redis 的 INCR 命令保證原子遞增,每個群組一個 Key 如 seq:group:123
  3. 跨裝置:用戶的多裝置間用 Sync Protocol — 每個裝置記錄最後同步的 Sequence ID,上線後發送 sync(last_seq_id) 拉取後續所有訊息

面試常見問題與參考答案

Q:已讀回執怎麼實作? A:接收者讀取訊息後,Client 發送 ack(message_id, timestamp) 到 Server。Server 更新該訊息的 read_at 欄位,然後透過 WebSocket 推送已讀狀態給發送者。群聊的已讀比較複雜 — 通常只顯示「N 人已讀」而不逐一追蹤(減少寫入量),或者只有 50 人以下的群組才顯示已讀名單。

Q:如何保證訊息 At-least-once delivery? A:發送者發送訊息後等待 Server 的 ACK。如果 3 秒內沒收到 ACK,重發(帶相同的 client_message_id)。Server 端用 client_message_id 做冪等去重,避免重複儲存。

Q:訊息儲存用什麼資料庫? A:聊天訊息是典型的 time-series 寫入(append-only、按時間排序查詢),適合用寬列儲存(如 HBase、Cassandra)。Row Key 設計為 chat_id:bucket(bucket 按天或按千條分桶),支援按對話和時間範圍高效查詢。

理解測驗

🤔 為什麼即時聊天系統選擇 WebSocket 而不是 HTTP Long Polling?

🤔 群組聊天中,一條訊息發送給 500 人的群組,最大的挑戰是什麼?

🤔 用戶離線後重新上線,如何確保收到所有漏掉的訊息?

重點整理

💡一句話記住

WebSocket 推、MQ 削峰、離線存、上線拉。 記憶口訣:「推削存拉」— 在線用 WebSocket 推送、用 MQ 處理扇出、離線時存起來、上線後拉取補齊。

| 概念 | 說明 | |------|------| | WebSocket | 全雙工持久連線,即時通訊的基礎 | | 連線映射 | Redis 記錄 userId → serverId,知道推送到哪台伺服器 | | Message Queue | 非同步處理訊息推送,削峰填谷 | | 離線訊息 | 用戶不在線時暫存訊息,上線後拉取 | | Sequence ID | 每個對話/群組的遞增序號,確保訊息順序 |

你可能也想看

設計 Key-Value Store設計動態消息

按 ← → 鍵切換課程