Heartbeat 的核心目標是讓失效的長連線可以被發現並清理。Deadline 定義讀寫最多能停滯多久,ping/pong 在沒有業務訊息時確認連線仍然活著,unregister 流程負責釋放連線與訂閱狀態。

本章目標

學完本章後,你將能夠:

  1. 分辨 read deadline、write deadline、ping period、pong wait 的角色
  2. 在 read pump 設定 pong handler 與 read limit
  3. 在 write pump 用 ticker 統一送 ping
  4. 讓 heartbeat 失敗進入同一條 unregister 路徑
  5. 測試 timeout 設定與清理流程的邊界

【觀察】長連線可能在沒有錯誤訊息時失效

WebSocket 長連線的核心風險是失效不一定立刻表現成明確錯誤。Client 可能斷網、瀏覽器休眠、代理中斷、行動網路切換,server 的 read 或 write 可能長時間卡住。

沒有 heartbeat 的服務可能出現:

  • client 已離線,但 server 還保留 client。
  • 訂閱狀態沒有清理,broadcast 仍嘗試推送。
  • write pump 卡在慢或失效的 connection。
  • goroutine、send buffer、記憶體逐步累積。

Heartbeat 的目的是讓失敗可以在合理時間內被觀測並進入清理流程。

【判讀】四個時間參數負責不同邊界

Heartbeat 設計的核心是四個時間參數的關係。這些參數是讀寫生命週期的合約。

1const (
2    writeWait  = 10 * time.Second
3    pongWait   = 60 * time.Second
4    pingPeriod = 50 * time.Second
5    maxMessage = 1 << 20
6)
參數角色常見關係
writeWait單次寫入最多等待多久小於 pongWait
pongWait多久沒讀到資料就視為失效大於 pingPeriod
pingPeriod多久主動送一次 ping小於 pongWait
maxMessage單筆 client message 大小上限依協定需求設定

pingPeriod 應小於 pongWait,讓 server 有時間送 ping 並等待 client 回 pong。writeWait 保護每次寫入,避免 write pump 無限卡住。

【執行】read pump 設定 read deadline 與 pong handler

Read deadline 的核心語意是超過指定時間沒有讀取進展,下一次 read 會失敗。Pong handler 的核心責任是每次收到 pong 時延長 read deadline。

1func (c *Client) configureRead() {
2    c.conn.SetReadLimit(maxMessage)
3    _ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
4    c.conn.SetPongHandler(func(string) error {
5        return c.conn.SetReadDeadline(time.Now().Add(pongWait))
6    })
7}

Read pump 啟動時先設定:

 1func (c *Client) readPump(ctx context.Context, hub *Hub, router MessageRouter) {
 2    defer func() {
 3        hub.unregister <- c
 4    }()
 5
 6    c.configureRead()
 7
 8    for {
 9        var message ClientMessage
10        if err := c.conn.ReadJSON(&message); err != nil {
11            return
12        }
13        if err := router.Route(ctx, c, message); err != nil {
14            c.TrySend(errorMessage(err))
15        }
16    }
17}

ReadJSON 回錯時,read pump 不需要判斷每一種錯誤都如何清理;它只要退出並通知 hub。錯誤分類可以用於 log,但清理路徑應一致。

【執行】write pump 用 ticker 送 ping

Ping 的核心規則是由 write pump 送出,因為 ping 也是 WebSocket write。讓其他 goroutine 直接送 ping 會破壞「write pump 是唯一寫入者」的原則。

 1func (c *Client) writePump() {
 2    ticker := time.NewTicker(pingPeriod)
 3    defer ticker.Stop()
 4
 5    for {
 6        select {
 7        case message, ok := <-c.send:
 8            _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
 9            if !ok {
10                _ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
11                return
12            }
13            if err := c.conn.WriteJSON(message); err != nil {
14                return
15            }
16
17        case <-ticker.C:
18            _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
19            if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
20                return
21            }
22        }
23    }
24}

每次寫入前設定 write deadline。這包含正常訊息、ping、close message;只保護部分寫入會留下卡住路徑。

【判讀】heartbeat 失敗走共用清理流程

Heartbeat 失敗的核心語意是連線不可用。它應該進入和 read error、write error、client disconnect 相同的 unregister 流程,而不是在 ping 錯誤處重寫一套清理。

推薦流程:

 1read error / write error / ping error
 2 3 4read pump exits or write pump exits
 5 6 7hub unregisters client
 8 910close send, close conn, remove subscriptions

實作可以用 hub unregister channel、context cancellation 或 connection manager。重點是所有失效都收斂到同一個 owner。

【策略】read pump 和 write pump 都可能先失敗

連線失效的核心不確定性是 read pump 和 write pump 哪個先看到錯誤不可預測。讀不到 pong 可能讓 read pump 先退出;寫 ping 失敗可能讓 write pump 先退出。

因此 unregister 必須可重複呼叫而不出錯:

1func (h *Hub) unregisterClient(client *Client) {
2    if _, ok := h.clients[client]; !ok {
3        return
4    }
5
6    delete(h.clients, client)
7    close(client.send)
8    _ = client.conn.Close()
9}

clients map 判斷 client 是否仍註冊,可以避免重複 close send。這是 WebSocket cleanup 最容易漏掉的細節之一。

【策略】heartbeat 參數要符合部署環境

Heartbeat 參數的核心取捨是偵測速度與誤判風險。偵測太快會讓短暫網路抖動造成大量斷線;偵測太慢會讓失效連線保留太久。

調整時要考慮:

  • load balancer 或 proxy idle timeout
  • 行動網路與瀏覽器背景分頁行為
  • server 可接受的失效連線保留時間
  • ping 對大量連線造成的週期性流量
  • client 是否會自動重連

若基礎設施會在 60 秒 idle 後關閉連線,server 的 ping period 就不能長於這個時間。這是部署環境合約,不是單純 Go 程式碼問題。

【測試】把時間參數和清理邊界拆開測

Heartbeat 的測試核心是不要用真實分鐘級等待。時間參數可以測設定值關係,清理流程可以測 unregister 是否 idempotent。

1func TestHeartbeatDurations(t *testing.T) {
2    if pingPeriod >= pongWait {
3        t.Fatalf("pingPeriod must be smaller than pongWait")
4    }
5    if writeWait >= pongWait {
6        t.Fatalf("writeWait should be smaller than pongWait")
7    }
8}

Unregister 測試:

 1func TestUnregisterClientIsIdempotent(t *testing.T) {
 2    hub := NewHub()
 3    client := NewClient("c1", nil, 1)
 4    hub.clients[client] = struct{}{}
 5
 6    hub.unregisterClient(client)
 7    hub.unregisterClient(client)
 8
 9    if _, ok := hub.clients[client]; ok {
10        t.Fatalf("client should be removed")
11    }
12}

真實 ping/pong 行為適合放在 integration test。單元測試先保證時間合約與 cleanup owner 不會被破壞。

本章不處理

本章先處理單一 WebSocket 連線的存活偵測與 cleanup;client 重連與 load balancer 參數,會在下列章節延伸:

和 Go 教材的關係

這一章承接的是 read/write pump、time control 與 shutdown;如果你要先回看語言教材,可以讀:

小結

Heartbeat/deadline 的目的是讓失效連線在可預期時間內被發現並清理。Read deadline 搭配 pong handler 保護讀取端,write deadline 保護每次寫入,ping ticker 由 write pump 統一執行,所有錯誤最後都應進入同一個 unregister 流程。