本章將挑戰設計一款聊天應用程式 (chat app)。聊天應用程式極為普及,因此設計一個聊天 App 已成為行動系統設計面試 (mobile system design interview) 中非常常見的題目。圖 1 展示目前市場上最受歡迎的幾款聊天應用程式。

Figure 1: Most popular chat apps

聊天應用程式演化至今,已能滿足多元的溝通需求:

  • 作為一對一對話的平台
  • 協助朋友群保持聯繫
  • 讓家人分享生活點滴與照片
  • 甚至能讓玩家在線上對戰中協同作戰

由於功能多樣,清楚定義我們所要設計的聊天 App 規格至關重要。

Step 1:理解問題並確立設計範圍#

設計聊天應用程式時,首先必須與面試官達成共識。以下是一段典型的對話示範:

面試官: 歡迎來到今天的面試!我們要設計一個聊天應用的行動客戶端架構 (mobile client app architecture) 以及它與後端的通訊。

候選人: 謝謝,我很期待!在開始之前,我想釐清幾個關於功能面的問題。一般來說,聊天 App 允許使用者即時收發訊息、檢視對話列表,以及離線存取聊天歷史。我們要聚焦在一對一聊天、群組聊天,還是兩者皆包含?這會顯著影響我們的設計方向。

面試官: 我們先簡化範圍,專注在一對一對話,群組聊天先不納入。

候選人: 了解。關於後端角色,有些聊天系統(例如 WhatsApp)將後端作為臨時的訊息中繼 (message relay),僅在訊息送達客戶端前暫存,不做永久儲存。我們是採用這種做法嗎?

面試官: 沒錯,這正是我們要採用的方式。

候選人: 那我們是只支援文字訊息,還是也要考量附件與訊息回應 (reaction)?

面試官: 先簡化到只支援文字訊息。

候選人: 好的。關於常見的聊天功能,例如新訊息推播通知 (push notifications)、正在輸入指示 (typing indicators)、在線狀態 (online status) 更新,這些要納入設計嗎?

面試官: 是的,這些都包含在內。

候選人: 太好了。那麼應用程式的規模大概如何?這是新創等級,還是全球大規模的應用?

面試官: 我們目標很大,大約 3,000 萬名每日活躍使用者 (Daily Active Users, DAU),遍及全球。

候選人: 這是相當大的使用者基數。關於使用者註冊與驗證,可以假設使用者在使用這些功能時已完成驗證,而聯絡人清單也已在開啟 App 時預載完成嗎?

面試官: 這些假設都沒問題。

候選人: 最後是安全性。我們顯然會保護客戶端與後端之間的連線,那麼我們是否也需要考慮對儲存在裝置上的訊息加密,以防範潛在的資安事件?

面試官: 訊息在裝置端的加密目前先不納入範圍。

這樣的往返對話能幫助我們建立完整的設計需求圖像。

Pro tip! 面對熟悉的面試題目時,可以主動提出功能性與非功能性需求 (functional and non-functional requirements)。這種主動出擊的作法能展現你的領導力,也讓你的面試表現更突出。

需求 (Requirements)#

根據討論,我們要設計的聊天系統具備以下功能性需求

  • 使用者可依最新訊息時間戳排序檢視最近的對話
  • 使用者可即時收發文字訊息
  • 對話中可監看聯絡人的線上狀態、正在輸入指示、訊息送達狀態 (delivery status) 與已讀回條 (read receipts)
  • 使用者可在無網路情況下瀏覽聊天歷史,且透過分頁 (pagination) 有效率地載入
  • App 支援推播通知以提醒新訊息

非功能性需求則需要確保:

  • Scalability (擴展性):系統須能處理全球 3,000 萬 DAU,並在各種網路條件下提供可靠效能
  • Data integrity (資料完整性):App 須在離線使用時維持一致性,特別是儲存待連線後才送出的訊息
  • Durability (耐久性):客戶端資料須可靠,因為它是訊息的真實來源 (source of truth)

Pro tip! 詢問 DAU 不只是為了取得數字。使用者的規模與地理分佈會直接影響你的技術決策。高 DAU 通常意味著需要聚焦在:

  • 效能優化
  • 資源效率
  • 可擴展的架構模式
  • 正確的網路協定選擇

面試時務必將規模需求與你的技術選擇連結起來,以展現你理解商業需求如何塑造架構決策。

為了聚焦,以下功能不在設計範圍內

  • 使用者驗證與註冊
  • 群組聊天與媒體檔案分享
  • 訊息加密

系統規模會顯著影響設計決策。我們必須謹慎考量每個選擇如何影響我們高效管理大量使用者與高訊息吞吐量的能力。

UI 草稿#

圖 2 展示聊天 App 的基本版面:

  • 左邊為 Chat List screen,顯示最近的對話以及新開聊天的按鈕
  • 點擊某個對話會開啟 Conversation screen,顯示最近訊息、對方的在線與輸入狀態,以及可輸入新訊息的文字框

Figure 2: Basic sketch of the chat mobile app

Step 2:API 設計#

需求確立後,接著進行 API 設計。本節將涵蓋:

  • 客戶端與後端的預期行為
  • 伺服器主動發起 (server-initiated) 的連線
  • 聊天 App 中使用的網路協定
  • 端點與資料模型

客戶端與後端的預期行為#

在聊天系統中,客戶端彼此不直接通訊,而是透過後端作為中介:

  • 當客戶端送出訊息時,訊息先送到後端
  • 後端識別目標接收者並轉發訊息
  • 若接收者尚未上線,後端會暫存訊息直到對方連線

圖 3 說明客戶端與聊天後端服務之間的互動,展示訊息在系統中的流動方式。

Figure 3: Client and server interactions in a chat system

我們的客戶端與後端需要處理兩種不同類型的互動:

  1. 客戶端主動發起(例如送出訊息、同步待處理訊息):HTTP 的請求—回應 (request-response) 模式非常適合這類情境。
  2. 後端主動推送(例如即時訊息送達、線上狀態更新):接收者的 App 必須立即得知這些事件,但 HTTP 只允許客戶端發出請求,無法反向推送。

推播通知雖可在 App 未啟動時通知使用者,但對於活躍的聊天連線並不足夠。訊息送達、已讀回條與線上狀態等即時功能需要比推播更即時且穩定的機制,因為推播本身可能延遲且不穩定。

伺服器主動發起的連線#

歷年來,開發者創造了多種支援伺服器主動連線的技術,包括 Polling、Long polling、Server-Sent Events (SSE)、WebSockets,以及基於 HTTP/2 的方案(例如 gRPC)。這些主題經常出現在行動系統設計面試中,值得深入了解。

Polling#

Polling(或稱 short polling)是最基本的作法:客戶端不斷詢問伺服器「有沒有新訊息?」。如圖 4 所示,流程如下:

  1. 客戶端請求新資料
  2. 伺服器回覆有或沒有新資訊
  3. 客戶端短暫等待後再次詢問

Figure 4: Polling strategy

Polling 實作簡單但常造成浪費。在沒有新資料時仍會產生大量不必要的請求,對客戶端與伺服器造成負擔,並消耗額外的網路與運算資源。

Long polling#

Long polling 是為了改善傳統 polling 的缺點而誕生。如圖 5 所示:

Figure 5: Long polling strategy

  • 客戶端向伺服器請求更新並等待
  • 伺服器保持連線開啟直到有新資料或逾時 (timeout)
  • 客戶端收到回應後立即再次發起請求

Long polling 雖優於 short polling,但仍有問題:

  • 對訊息稀少的使用者而言仍顯浪費,因為多數請求最終仍會逾時
  • 在多伺服器環境中,持有新訊息的伺服器未必是連線到接收者的那一台
  • 伺服器難以察覺客戶端是否已斷線

Server-Sent Events#

Server-Sent Events (SSE) 讓伺服器能透過單一 HTTP 連線向客戶端推送更新。如圖 6 所示,這與傳統的 request-response 模型不同。

Figure 6: Server-Sent Events strategy

SSE 的核心是維持一個開啟的 HTTP 連線。為避免連線因無活動而逾時,伺服器可週期性發送以冒號開頭的註解行 (comment line) 作為保活機制。

SSE 的運作方式

  1. 客戶端向 SSE 伺服器端點發出 HTTP GET 請求,並將 Accept 標頭設為 text/event-stream
  2. 伺服器以 200 OK 回應,並將 Content-Type 設為 text/event-stream,然後保持連線開啟以便持續推送更新
  3. 伺服器以簡單文字格式持續向客戶端推送事件
  4. 若連線中斷,客戶端通常會自動重連。為避免遺漏事件,可在重連請求中加入 Last-Event-ID 標頭,讓伺服器只送出自上次之後的事件

SSE 比 polling 更有效率:單一持久連線 + 僅在必要時推送更新,可顯著降低網路流量與伺服器負載。在聊天 App 情境中,我們可用 SSE 接收伺服器主動事件,並用標準 HTTP 請求處理客戶端主動操作。不過還有另一個選擇:WebSockets。

WebSocket protocol#

WebSocket (WS) 是最常見的即時、雙向 (bidirectional) 通訊方案之一。運作方式如圖 7 所示。

Figure 7: WebSocket protocol in action

  • WebSocket 連線一開始是標準 HTTP 連線,透過明確定義的協定 “升級 (upgrade)” 為 WebSocket 連線
  • 使用與 HTTP/HTTPS 相同的連接埠(80 和 443),因此即使存在防火牆也能順利運作
  • 建立後即形成雙向通道,客戶端與伺服器可自由交換訊息
  • 對資料格式無限制,可承載純文字或二進位資料

實際應用中的訊息協定#

WebSockets 建立於 TCP (Transmission Control Protocol) 之上,提供可靠的資料傳輸:建立並維持連線,確保封包依序、無錯誤地送達,並處理錯誤檢查、確認與重送。

相對地,UDP (User Datagram Protocol) 不建立連線即發送資料,無法保證送達、順序或正確性,但負擔較小、速度更快。

多年來,開發者針對不同使用情境建立了多種即時訊息協定:

  • WebSocket:在單一 TCP 連線上進行全雙工通訊
    • Slack 使用 WebSocket 作即時通訊;Uber 過去也曾使用
  • XMPP (Extensible Messaging and Presence Protocol):基於 XML 的協定,支援即時訊息與狀態資訊
    • WhatsApp 過去以 XMPP 為核心;Kik Messenger 也採用
  • MQTT (Message Queuing Telemetry Transport):運行於 TCP 上的輕量 publish/subscribe 協定
    • Facebook Messenger 最初實作採用 MQTT
  • WebRTC (Web Real-Time Communication):主要使用 UDP(TCP 作後備),支援點對點音訊、視訊與資料串流
    • Discord 使用 WebRTC 處理語音與視訊
  • IRC (Internet Relay Chat):運行於 TCP,主要用於頻道群組通訊,也支援私訊
    • Mozilla 曾以 IRC 作為社群主要通訊工具

聊天 App 的網路協定選擇#

表 1 彙整了各種可用於伺服器主動連線、滿足即時需求的網路協定比較。

選項優點缺點
Short polling實作簡單;與標準 HTTP 基礎設施相容,跨平台支援度高請求過多、效率低;無新資料時仍消耗大量頻寬
Long polling比 short polling 更接近即時,請求較少;適合非關鍵更新逾時處理複雜;客戶端多時伺服器資源消耗大、擴展性差
Server-Sent Events (SSE)單一 HTTP 連線;內建重連支援,與 HTTP/2 搭配良好單向(僅伺服器到客戶端),仍需另一機制處理客戶端到伺服器;連線數多時耗資源
WebSocket真正的雙向通訊;低延遲且無重複 request/response 的額外負擔實作較複雜,需要額外基礎設施(如 load balancer)支援;持久連線擴展具挑戰性

表 1:選擇即時功能網路協定的取捨

評估後,聊天 App 採用混合架構 (hybrid approach)

  1. HTTP + REST APIs 處理客戶端主動操作(例如送出訊息)
  2. WebSocket 接收伺服器推送的即時訊息

實作兩種協定雖增加些許複雜度,但隨 App 成長可維持更易維護的系統。透過限制 WebSocket 僅用於即時功能(而非所有通訊),可避免生產環境常見的擴展性問題。

生產環境擴展 WebSocket 服務需謹慎考量。主要挑戰包括:

  • 維護持久連線消耗大量伺服器資源
  • WebSocket 的 load balancing 比標準 HTTP 更複雜
  • 連線恢復與狀態管理需完整處理
  • 監控與除錯 WebSocket 服務需要專用工具

將 WebSocket 限縮在即時功能而非所有通訊,能更好地管理上述挑戰。

我們選擇 WebSocket 而非 SSE 的主要原因在於聊天情境的效能表現:

  • 延遲更低、header overhead 更小,對頻繁交換小訊息(例如輸入指示、已讀回條)至關重要
  • 跨行動平台支援一致,工具鏈成熟
  • 內建重連策略與 keep-alive 機制

此混合架構已在業界廣泛採用。例如 Slack 透過 Web API 處理大多數寫入操作,並以 Real-Time Messaging (RTM) API 的 WebSocket 處理即時事件;Discord 也採用類似模式。

客戶端在 App 啟動時建立 WebSocket 連線,並持續保持直到任一方終止連線。圖 8 展示系統中 HTTP 與 WebSocket 的互動方式。

Figure 8: Client and server interactions in the chat system

已做決策:

  • 以 HTTP + REST APIs 處理送出訊息等客戶端主動操作
  • 以 WebSocket 處理伺服器需推送更新的即時功能
  • 嚴格限縮 WebSocket 使用範圍以控管擴展複雜度

端點與資料模型#

客戶端主動請求#

送出訊息的端點定義如下:

Authentication: Bearer <token>
POST /v1/messages
  Body: NewMessageRequest
  Response: 201 Created. Payload of type NewMessageResponse
KotlinSwift
data class NewMessageRequest
requestId: Long
toUserId: Long
content: String
createdAt: String
struct NewMessageRequest
requestId: Int64
toUserId: Int64
content: String
createdAt: String
data class NewMessageResponse
message: Message
fromMessageRequestId: Long?
struct NewMessageResponse
message: Message
fromMessageRequestId: Int64?
  • NewMessageRequest 中的 requestId 作為 idempotency key(冪等鍵),讓後端能去重複 (de-duplicate) 意外重複的請求
  • NewMessageResponse 中的 fromMessageRequestId 用於將 Message 對應回原先的 NewMessageRequest

另一個用於 App 初始化時同步新訊息的端點:

Authentication: Bearer <token>
GET /v1/messages?lastSyncedMessage={messageId}&limit={limit}
  Body: empty
  Response: 200 OK. Payload of type SyncMessagesResponse

資料模型#

主要的應用資料模型為 Message

KotlinSwift
data class Message
messageId: Long
fromUserId: Long
toUserId: Long
content: String
status: MessageStatus
createdAt: String
struct Message
messageId: Int64
fromUserId: Int64
toUserId: Int64
content: String
status: MessageStatus
createdAt: String
enum class MessageStatus { PENDING, SYNCED, DELIVERED, READ }enum MessageStatus { case pending, synced, delivered, read }

status 欄位會決定 UI 顯示的訊息指示器,其值含義如下:

  • PENDING:後端尚未收到訊息
  • SYNCED:後端已收到訊息
  • DELIVERED:後端已將訊息送達接收者
  • READ:接收者已讀取訊息

另一重要資料模型是 ConversationPreview,代表使用者在 Chat list 畫面看到的內容。以下與客戶端畫面相關的資料模型僅為客戶端所用,後端不關心客戶端如何顯示訊息。

KotlinSwift
data class ConversationPreview
contact: User
lastMessageSummary: String
lastMessageTimestamp: String
unreadCount: Int
struct ConversationPreview
contact: User
lastMessageSummary: String
lastMessageTimestamp: String
unreadCount: Int
data class User
id: Long
name: String
avatarUrl: String
lastConnectedAt: String
struct User
id: Int64
name: String
avatarUrl: String
lastConnectedAt: String

ConversationPreview 是輕量模型,只包含在 Chats 列表中顯示對話所需的資訊。由於每位使用者只有一個對話,User.id 即可作為對話的唯一識別。

使用者開啟 Conversation 畫面時,App 載入更完整的 Conversation 資料:

KotlinSwift
data class Conversation
contact: User
contactInfo: ContactInfo?
unreadCount: Int
messages: List<Message>
struct Conversation
contact: User
contactInfo: ContactInfo?
unreadCount: Int
messages: [Message]
data class ContactInfo
onlineStatus: String
typing: Boolean
struct ContactInfo
onlineStatus: String
typing: Bool

即時功能#

WebSocket 使用類似 HTTP URL 的 URI 格式。HTTP 以 http://https:// 開頭,而 WebSocket 使用 ws://(標準)或 wss://(加密)。我們的聊天後端 URI 為:wss://my-api.chat.com/v1/socket/updates

API 版本化的重要性

在 API 端點中加入版本號可帶來彈性:我們能在新版引入破壞性變更或主要新功能,同時保持舊版穩定,並有序地淘汰過時 API。深入討論請參考第 10 章:Mobile System Design Building Blocks。

透過 WebSocket 傳送資料時,使用以下 JSON 格式:

{
  "type": "message_type",
  "payload": {}
}

每個事件包含兩部分:

  • type:事件類型
  • payload:事件實際資料,為 JSON 物件,內容因事件類型而異

WebSocket 連線建立後,後端可推送以下即時更新:

  • new_message:給客戶端的新訊息
  • message_read:訊息已被接收者讀取
  • contact_status_update:聯絡人狀態更新,包括線上狀態、輸入指示等

Step 3:高階客戶端架構#

API 設計完成後,接著探討高階的客戶端架構 (high-level client architecture)。本節將涵蓋:

  • 外部伺服器端元件
  • UI 與資料層的客戶端架構元件
  • 資料儲存

圖 9 展示聊天系統的高階架構。

Figure 9: High-level mobile architecture of our chat system

此聊天系統所識別的元件與前一章 News Feed 設計相似,因此本節會快速帶過。如有疑問請回顧第 3 章 Design a News Feed App。

外部伺服器端元件#

系統仰賴 BackendCDNPush Notifications Provider 三個元件。

第三方的 Push Notifications Provider 讓我們即使在使用者未主動使用 App 時仍能觸及,負責處理可靠推播所需的複雜基礎設施。

客戶端架構#

延續前幾章的基礎,我們選擇分層架構 (layered architecture),並遵循:

  • Unidirectional Data Flow (UDF)
  • 反應式程式設計 (reactive programming)
  • 關注點分離 (separation of concerns) 等關鍵原則

以確保 App 強健、效能佳且可擴展。

資料層#

Message 是聊天 App 的核心應用資料。資料層包含:

  • Messages Repository:核心元件,管理所有讀寫操作
  • Users Repository:處理使用者相關資訊(名稱、頭像等)

Messages Repository 同時處理本地與遠端資料來源:

  • 客戶端主動請求透過 Messages Local DataSource 走傳統 HTTP 端點
  • Real time updates Remote DataSource 維持持久的 WebSocket 連線以接收即時更新

此雙軌作法讓系統同時對使用者動作與伺服器事件保持反應。

Push Notifications Client 元件則作為外部推播提供者的監聽者。

UI 層#

UI 層以 App 的兩個主要畫面為核心:Chat ListConversation。為最佳化效能:

  • 將圖片載入集中於單一 Image Loader 元件,由它從 CDN 取得資料
  • Message List 為關鍵元件,依時序顯示所有對話訊息,包括失敗或待送出的訊息;失敗訊息於 UI 上明確標示並提供重送選項

資料儲存#

雖然行動平台提供簡易的儲存選項(Android 的 SharedPreferences、iOS 的 UserDefaults),但這些 key-value 儲存並不適合我們的需求:

  • 適合儲存使用者偏好等小量資料
  • 不擅於處理不斷成長的訊息歷史,以及訊息、對話、使用者之間的複雜關係
  • 訊息量增加後會明顯變慢,且難以有效搜尋

關聯式資料庫 (relational database) 是較佳選擇。訊息本質上是結構化資料:每則訊息屬於某個對話、有作者,並遵循固定格式。

若要深入比較不同儲存方案,請參考第 10 章:Mobile System Design Building Blocks 的 Data Storage 章節。

關聯式資料庫的三項核心優勢:

  1. 高效處理複雜查詢:使用者搜尋歷史訊息或篩選對話時,資料庫能快速返回結果,即使歷史資料龐大也不受影響
  2. 資料一致性與 ACID 特性 (Atomicity, Consistency, Isolation, Durability):在大量訊息同時收發時仍能維持資料完整性、避免衝突
  3. 支援未來成長:新增訊息回應、串列回覆等功能時,可以擴充 schema 而不破壞現有訊息

汰除策略 (Eviction policy)#

由於所有對話都儲存在裝置上,管理本地儲存非常關鍵。雖然可以透過汰除策略或時效刪除自動管理,但讓使用者掌控通常提供更好的體驗。業界做法:

  • WhatsApp:儲存空間不足時提醒使用者並引導清理
  • Slack:免費版自動移除 90 天或 1 年以前的訊息

可能的作法包含:

  • 依訊息日期或附件大小等條件汰除訊息與檔案
  • 超過定時後自動刪除
  • 讓使用者檢視使用量與清理選項,自行管理

Pro tip! 設計行動系統時,像是「記憶體受限下如何處理訊息儲存」這類決策常涉及產品團隊。面試中可以提出不同作法與取捨,但在投入實作細節前,先與面試官確認這類產品層級決策是否在範圍內

若面試官允許,我們提議以使用者為中心的策略:

  1. 定期檢查可用的裝置儲存空間
  2. 空間不足時主動提醒,並提供清楚選項:刪除舊訊息、移除整段對話,或清理其他 App 資料

已做決策:

  • 選用關聯式資料庫持久化訊息資料
  • 儲存空間不足時主動提醒使用者

Step 4:設計深入探討#

完成客戶端與後端通訊協定及客戶端架構後,接著深入幾個關鍵領域:

  • 畫面上的訊息排序
  • 送出訊息
  • 資料庫實作細節
  • 推播通知

畫面上的訊息排序#

訊息排序是聊天 App 的基礎。順序錯誤會讓使用者迷惑與沮喪。雖然「依時序顯示訊息」聽起來簡單,但行動裝置上會遇到不少邊界案例:

  • 網路延遲可能造成訊息亂序抵達
  • 離線訊息需在重新連線後整合
  • 不同裝置時鐘可能不一致
  • 過去訊息與新進更新都須維持一致排序

訊息生命週期#

圖 10 說明訊息如何流經系統:

  1. Messages repository 立即建立並儲存新訊息,UI 馬上顯示以提供即時回饋
  2. Repository 將訊息轉發給 Messages remote data source,後者將其封裝為 NewMessageRequest 並透過 HTTP 送到後端
  3. 後端處理請求,為訊息產生唯一的 messageId
  4. 後端接著執行兩個並行動作:
    • (4.1) 以 NewMessageResponse 回傳 201 Created 給寄件者
    • (4.2) 通知接收者(透過 WebSocket,若其在線;否則發推播通知)
  5. 寄件者的客戶端收到 HTTP 回應後,以新的 messageId 更新本地儲存,UI 更新顯示送達成功

Figure 10: Message lifecycle in the chat system

訊息排序#

在圖 10 的步驟 1 和 5 中,客戶端需要在畫面上顯示訊息。為了實作可靠的排序,必須決定:該用哪個欄位排序?

Message 模型提供兩個候選欄位:

  • messageId:後端處理時指派
  • createdAt:客戶端建立時的時間戳

表 2 列出取捨:

排序依據優點缺點
messageId伺服器產生的 ID 確保各客戶端一致排序;後端是真實來源客戶端在後端產生前沒有 ID;無法有效支援離線訊息
createdAt 時間戳低延遲,無須等待後端即可顯示;支援離線訊息易受裝置時鐘差異影響;容易被客戶端操弄
createdAt 搭配客戶端—後端時鐘同步結合時間戳優點並改善可靠性;可透過 WebSocket 事件或 Meta 的 Simple Precision Time Protocol 等減少排序問題裝置間仍可能時鐘漂移;跨網路精準實作複雜

表 2:客戶端訊息排序的取捨

雖然同步時鐘可行,但會大幅增加系統複雜度與成本。因此採用混合策略

  • 已成功送達並被後端確認的訊息:依 messageId 排序
  • 仍在等待或送出中的訊息:依 createdAt 時間戳排序,並永遠置於訊息列表末端

多數熱門聊天 App 使用伺服器分配的 ID 進行排序。有些公開詳細文件,例如 Discord 的 Snowflake ID;有些則較模糊,如 WhatsApp 的 id、Facebook Messenger 的 mid、Slack 的 ts。待處理訊息如何排序則是產品決策。

已做決策: Conversation 畫面使用混合策略排序訊息:

  • 已成功送達並被伺服器確認的訊息以 messageId 排序
  • 仍在等待或進行中的訊息以客戶端 createdAt 時間戳排序,並永遠位於列表末端

messageId 的責任#

messageId 在系統中扮演關鍵角色,它必須可依時間排序,確保新訊息的 ID 永遠大於舊訊息。後端作為 ID 產生的真實來源以確保資料完整性、可靠送達與一致排序。

雖然看似直接,在大規模系統中正確產生 ID 並不容易,因此這責任通常交給後端:伺服器更適合為數以百萬計的訊息快速且準確地產生唯一、時序性的 ID。

送出訊息#

在行動 App 中可靠送出訊息並不容易。網路問題可能導致訊息無法送達目的地。為了應對,我們需要追蹤不同訊息狀態:尚未送出、傳送中、送出失敗。

資料層以下列 MessageRequest 模型描述這些狀態。送出前,remote data source 會將其映射為 NewMessageRequest

KotlinSwift
data class MessageRequest
requestId: Long
toUserId: Long
content: String
status: MessageRequestStatus
lastModifiedAt: String
lastSentAt: String?
failCount: Int?
struct MessageRequest
requestId: Int64
toUserId: Int64
content: String
status: MessageRequestStatus
lastModifiedAt: String
lastSentAt: String?
failCount: Int?
enum class MessageRequestStatus { DRAFT, PENDING, SENT, FAILED }enum MessageRequestStatus { case draft, pending, sent, failed }

status 欄位追蹤訊息的目前狀態:

  • DRAFT:訊息已建立但客戶端尚未送出
  • PENDING:已排隊待送到後端
  • SENT:客戶端已透過 send_messages 事件送到後端,但尚未收到送達確認
  • FAILED:伺服器回報失敗,或請求逾時

你可能注意到 MessageRequest 模型中沒有 SUCCEEDED 狀態,這是刻意設計。

當伺服器確認訊息送達時,我們不只是更新狀態,而是完全移除 MessageRequest,並建立一個帶 RECEIVED 狀態的新 MessageNewMessageResponse 含有此轉換所需的資訊,包括後端產生的 messageIdfromMessageRequestId

同理,本地資料源中不會出現 PENDING 狀態的 MessagePENDING 狀態是 MessageRequest 專屬。UI 顯示時則會合併兩者。

送出訊息的資料流#

圖 11 展示訊息成功送達時的詳細資料流:

  1. Messages Remote DataSource 收到後端成功回應
  2. Remote DataSource 將資訊傳給 Messages Repository
  3. Messages Repository 執行兩個資料庫操作:
    • 從儲存中移除暫時的 message request
    • 以後端確認的 ID 建立新的 Message 項目
  4. 這些資料庫變動自動觸發更新流向 App 各層,讓使用者看到訊息已成功送達(步驟 4–7)

Figure 11: Client receiving message confirmation data flow

已讀回條的流程類似,當對方讀取訊息:

  1. 後端透過 WebSocket 發送 read_messages 事件
  2. 客戶端的 Real-time Updates Remote DataSource 接收事件
  3. 通知 Messages Repository 狀態變更
  4. Repository 更新本地資料庫的訊息狀態
  5. 變更流向 UI,更新已讀指示器

訊息送達失敗#

實際情境中並非一切順利。有時我們可能永遠收不到 NewMessageRequest 的回應,原因可能是網路不穩或其他非預期因素。

請求失敗或逾時時,我們不立即放棄,而是:

  • MessageRequest 狀態更新為 FAILED
  • 增加 failCount 計數

此追蹤讓我們決定是否繼續重試(使用指數退避 (exponential backoff))或在失敗次數達上限後停止重試。

指數退避 (Exponential Backoff) 是一種重試策略,兩次嘗試之間的延遲會逐次增加。例如初次等 1 秒,下一次 2 秒,再下一次 4 秒…。這能避免我們以高頻率重試壓垮系統,也讓網路有時間恢復。

為避免無限重試,設定最大重試次數 (maximum retry limit)。超過上限後自動停止,標記為永久失敗並通知使用者,提供手動重試或刪除訊息的選項。

已做決策: 使用指數退避演算法重試失敗訊息。

資料層設計更新#

新增的失敗處理、重連與訊息請求相關功能對客戶端架構有顯著影響。圖 12 展示更新後的資料層設計。

Figure 12: Data layer design update

為簡化 Messages remote data source 的職責,我們新增數個元件:

  • Messages Scheduler:從背景執行緒協調訊息送出,適當時批次送出,並與 Messages Timeout Controller 協作處理失敗重試
  • Message Timeout Controller:監控等待後端確認的訊息逾時
  • Pending Messages Provider:從本地資料來源取得待送或失敗的訊息請求;依循依賴注入 (dependency injection) 最佳實務,實際實作會依賴 Messages Local Data Source

透過導入更多元件、降低 Messages Remote Data Source 的複雜度,我們提升系統整體可讀性與可維護性。

業界見解:

  • Airbnb 開發了訊息同步機制,減少網路請求與資料不一致,使收件匣載入更快,特別改善慢速網路下的體驗
  • Facebook Messenger 客戶端透過 orchestrated sync service 處理離線訊息。在 Messenger「Lightspeed」重構中,Facebook 引入名為 MSYS (Messenger SYnc System) 的模組管理本地資料與任務

資料庫實作細節#

SQLite 是行動裝置上極佳的選擇:輕量但強大、可嵌入行動作業系統,且支援複雜查詢與交易,非常符合聊天 App 的需求。

平台實作細節

  • iOS:Apple 的 CoreData 可用 SQLite 作為底層;也可考慮開源方案如 SQLite.swift 或 FMDB
  • Android:最熱門的選擇是 Google 的 Jetpack Room 與 Square 的 SQLDelight

為最大化效能,我們需要仔細設計資料模型,使其符合 SQLite 的運作方式。我們將一般訊息存於 Messages 資料表,未同步的訊息請求存於 MessageRequests 資料表。這樣的分離很重要,因為未同步的請求尚未擁有 Message id。

SQLite 建表語法#

展開 CREATE TABLE 語法
CREATE TABLE Users (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  avatar_url TEXT,
  last_connected_at TEXT,
)
CREATE TABLE Messages (
  id INTEGER PRIMARY KEY,
  from_user_id INTEGER NOT NULL,
  to_user_id INTEGER NOT NULL,
  content TEXT NOT NULL,
  status TEXT NOT NULL,
  created_at TEXT NOT NULL,
  FOREIGN KEY (from_user_id) REFERENCES Users(id),
  FOREIGN KEY (to_user_id) REFERENCES Users(id)
);
CREATE TABLE MessageRequests (
  request_id INTEGER PRIMARY KEY,
  to_user_id INTEGER NOT NULL,
  content TEXT NOT NULL,
  status TEXT NOT NULL,
  last_modified_at TEXT NOT NULL,
  last_sent_at TEXT,
  fail_count INTEGER,
  FOREIGN KEY (to_user_id) REFERENCES Users(id)
)

雖然資料模型中的 ID 在 Kotlin 是 Long、Swift 是 Int64,但在 SQLite 中以 INTEGER 表示。

SQLite 採用以 type affinity (類型親和性) 為基礎的動態類型系統:依宣告的資料型別為欄位關聯對應的儲存類別。INTEGER 為帶符號整數,以 1、2、3、4、6 或 8 bytes 儲存。任何包含 “INT” 的型別名稱(如 INTINTEGERBIGINTSMALLINT)都會有 INTEGER affinity。

資料層更新#

圖 13 展示資料層設計的更新。新增的三個資料表以獨立 DAO (Data Access Object) 元件呈現。為分離關注點,我們將不同類型的應用資料(訊息與使用者資訊)切分到各自的 repository。

Figure 13: Data layer design update—this only shows repositories

雖然訊息儲存拆為不同資料表,但這不影響整體資料流:repository 將 MessagesMessageRequests 合併後再提供給上層,讓更高層的 App 看見統一的對話列表,不必知道拆分細節。

挑戰#

本地儲存訊息雖看似簡單,卻有不少關卡。

資料表相依與 schema 初始化#

多個 SQL 陳述式的執行順序很重要:Users 必須先於其他依賴它的資料表建立。將所有 CREATE TABLE 語句保存在同一份 SQL 腳本能避免順序問題,使資料庫初始化更可靠。

為簡化、一致與資料完整性,我們把三個資料表 (UsersMessagesMessageRequests) 全部放在同一個 SQLite 資料庫檔 chats.db 中。

若將 Users 拆到獨立資料庫雖可提升模組化或便於跨 App 重用,但會產生多個複雜性:

  • 跨資料庫檔的外鍵約束無法強制執行,必須在應用層手動維護參照完整性
  • 同一 App 管理多個資料庫需小心協調,尤其在不同環境(開發、測試、生產)
  • 獨立資料庫增加執行順序錯誤的可能性,導致 schema 設定失敗,且難以除錯

已做決策:

  • UsersMessagesMessageRequests 三個資料表放在同一個 chats.db 資料庫中
  • CREATE TABLE 陳述式保持在同一份 SQL 腳本中
大小與效能#

隨著 App 成長,資料庫可能變得龐大進而拖慢 App 效能。Android 與 iOS 在資料儲存與存取的效率上皆有上限。可採取以下策略:

  • 拆分大表:例如依 user id 拆分 MessagesMessageRequests,每位登入使用者的訊息存於獨立資料庫檔(例如 chats-currentUserId.db),有助管理資料庫大小與查詢效能
  • 為常搜尋的欄位加索引 (例如 to_user_id):但要注意索引雖可加速搜尋,卻可能拖慢寫入
  • 批次交易 (batch transactions):將多個寫入群組進同一個 transaction 中,減少磁碟 I/O、提升吞吐量
  • 妥善的錯誤處理搭配重試機制,尤其在高負載期間
  • 使用連線池 (connection pool) 管理並發存取,避免瓶頸、提升資源使用效率

行動客戶端的所有資料庫操作都應在背景執行緒上執行,以避免阻塞 UI 執行緒,維持流暢使用者體驗。

推播通知 (Push Notifications)#

我們的需求包含透過推播通知在使用者離線時提醒新訊息。圖 14 展示推播系統相關元件。

Figure 14: Push notifications–related components in the chat system design

後端發送推播#

當訊息寄給無活躍 WebSocket 連線的使用者時,後端會觸發推播。為了送達通知,後端向外部 Push Notifications Provider 發 HTTPS 請求:

  • Android:Firebase Cloud Messaging (FCM)
  • iOS:Apple Push Notification Service (APNs)

為了正確路由,後端維護使用者與裝置之間的映射。此映射在 App 安裝流程中建立:每個裝置在背景註冊推播,後端儲存每台裝置的唯一 token,以便定向投遞。

推播類型 從後端可以發送多種推播:

  • Alert notifications:立即提示使用者
  • Silent notifications:背景處理,不打擾使用者
  • Rich media notifications:可含圖片、影片或其他媒體

在本案中,我們採用 alert notifications 告知使用者未讀數量與訊息內容。

接收推播#

Android 與 iOS 分別對 FCM 與 APNs 伺服器維持一條低功耗的持久 TLS 加密 TCP 連線,避免每個 App 自行管理連線造成資源浪費。通知抵達時,裝置會喚醒對應 App 或直接顯示通知。

平台實作細節

  • Android:透過 Google Play Services 管理連線;開發者通常建立繼承 FirebaseMessagingService 的服務
  • iOS:由背景服務 push notification daemon 處理;開發者使用 UNUserNotificationCenterDelegate 協定,通常整合至 UIApplicationDelegate

依 App 狀態處理方式不同:

  • App 在前景:Push Notifications Client 直接處理通知,並呼叫 Messages Repository 同步訊息
  • App 在背景或已終止:系統於通知列顯示;使用者點擊後,系統將 App 帶到前景,Push Notifications Client 可存取通知 payload

推播的挑戰#

推播雖強大,但有不少挑戰:

  • FCM、APNs 或裝置本身可能限縮通知,尤其在省電模式下系統可能延遲、限制網路存取或批次化請求以節能
  • 必須取得使用者同意;若使用者拒絕或事後關閉,App 將失去此通訊管道
  • Android 與 iOS 使用不同 API 與流程,需要平台專屬程式碼,且功能支援與行為可能不一致
  • 推播常需將使用者導向特定畫面,需實作 URL scheme 或 universal links;尤其 App 原本未在執行時,深度連結回復狀態可能棘手
  • 確保跨裝置與 OS 版本相容是挑戰;推播的非同步特性讓送達問題除錯特別複雜

因此我們的聊天 App 把推播當作輕量警示機制,僅用於提醒新訊息。

業界見解: Slack 的後端基礎設施決定何時與何物該推送。每則貼出的訊息都流經 Slack 的事件管線,並排入一個背景工作處理潛在通知。Slack 的 job queue 每日處理超過十億筆工作,包含推播 fan-out。

Step 5:總結#

本章設計了一個聊天系統:

  • HTTP 處理客戶端主動請求
  • WebSocket 處理即時更新,包含已讀回條與使用者線上狀態

我們規劃了客戶端架構、定義後端的 HTTP 與 WebSocket 端點、建立必要的資料模型,也深入討論了畫面上的訊息排序、客戶端如何送訊息、資料庫實作細節以及推播通知。

延伸議題 若面試中有多餘時間或想挑戰不同需求,可考慮以下主題:

  • Online status / Presence 資訊:WebSocket 斷線如何影響使用者在線狀態,以及 TCP half-open 在其中扮演的角色
  • 群組聊天功能:設計群組情境下的訊息建立與投遞;思考 role-based permissions 與使用者 @mention 功能
  • 強化安全性:實作資料庫加密;進一步探索端到端加密 (end-to-end encryption) 確保只有寄件者與收件者能讀取訊息
  • 富媒體支援:擴充對話以支援圖片、影片、地理位置等附件;處理大檔的壓縮與縮圖生成
  • 訊息管理:設計編輯與刪除訊息的機制;考量編輯歷史顯示與選擇性刪除(僅自己或所有參與者)
  • 搜尋能力:開發單一對話內搜尋與跨所有聊天的全域搜尋