Allocation 分析的核心目標是區分必要的安全複製與可優化的重複配置。Go 服務中很多配置來自 slice 成長、map/list 複製、JSON marshal、buffer 建立與 WebSocket payload;優化前要先確認配置是否位於熱路徑,且不能破壞狀態邊界。

本章目標

學完本章後,你將能夠:

  1. 理解 allocation 如何增加 GC 壓力
  2. 分辨必要 copy boundary 與不必要重複配置
  3. 用預配置降低 slice 成長成本
  4. 判斷 JSON marshal 與 WebSocket payload 的重用邊界
  5. 用 pprof 的 alloc_spaceinuse_space 決定優化方向

【觀察】allocation 壓力會放大 GC 成本

Allocation 的核心影響是增加 heap 成長速度,進而增加 GC 工作量。即使物件很快被回收,大量短命配置仍可能造成 CPU 與 latency 壓力。

常見熱路徑:

  • 每次 WebSocket broadcast 都對每個 client 重新 marshal。
  • 每次 API list 都建立大型 slice。
  • 每次 repository 查詢都 copy 大型 map。
  • 每次 log 都組大量臨時欄位。
  • 每次 encode 都建立新的 bytes.Buffer

不是所有 allocation 都要消除。診斷重點是找出高頻、可避免、且不破壞邊界的配置。

【判讀】預配置解決的是成長成本

Slice 預配置的核心用途是讓底層 array 成長符合預期。若結果長度可預估,應用 make 設定容量。

未預配置:

1func BuildNames(users []User) []string {
2    var names []string
3    for _, user := range users {
4        names = append(names, user.Name)
5    }
6    return names
7}

預配置:

1func BuildNames(users []User) []string {
2    names := make([]string, 0, len(users))
3    for _, user := range users {
4        names = append(names, user.Name)
5    }
6    return names
7}

這不是微優化。若這段程式在高頻 list API、background projection 或 broadcast path 中執行,預配置可以減少反覆擴容與 copy。

【判讀】copy boundary 是必要成本

安全複製的核心目的是保護內部可變狀態。Repository 回傳資料時 copy slice 或 map,會增加 allocation,但能避免外部突變與 data race。

 1func (r *UserRepository) ListUsers(ctx context.Context) ([]User, error) {
 2    r.mu.RLock()
 3    defer r.mu.RUnlock()
 4
 5    users := make([]User, 0, len(r.users))
 6    for _, user := range r.users {
 7        users = append(users, user)
 8    }
 9    return users, nil
10}

這個 allocation 是狀態邊界的成本。優化前要先確認它是否真的是瓶頸,不能只因為 profile 看到配置就移除 copy。

若列表很大且讀取頻繁,應考慮分頁、projection、snapshot cache 或只回傳必要欄位。不要為了省配置而直接暴露內部 map。

【策略】大型 list 優先改資料形狀

大型 list allocation 的核心問題常常是 API 一次回太多資料。若每次請求都複製整個 repository,配置與延遲都會隨資料量線性成長。

可選策略:

策略適用情境代價
分頁使用者只需要部分資料API 需要 cursor 或 offset
projection只需要摘要欄位要維護讀取模型
snapshot cache讀多寫少要處理快取失效
incremental updateWebSocket 推送最新變化client 要能合併狀態

優化資料形狀通常比取消 copy 更安全。Copy boundary 保護正確性,資料形狀決定每次 copy 的成本。

【執行】JSON marshal 是 WebSocket 常見配置來源

JSON 序列化的核心成本是把 Go 資料結構轉成 bytes。高頻 WebSocket 推送若對每個 client 反覆 marshal 同一份 message,會造成大量短命配置。

反模式:

1for _, client := range clients {
2    payload, err := json.Marshal(message)
3    if err != nil {
4        return err
5    }
6    client.SendBytes(payload)
7}

同一份 message 可以先 marshal 一次:

1payload, err := json.Marshal(message)
2if err != nil {
3    return err
4}
5
6for _, client := range clients {
7    client.SendBytes(payload)
8}

這個優化的前提是 payload 被視為只讀。Send path 不應修改傳入的 bytes;若某個 client 需要修改,就應在該 client 邊界 copy,而不是讓共享 payload 被改動。

【判讀】bytes 重用要先定義所有權

Bytes 重用的核心風險是共享 slice 被修改。[]byte 是可變資料,傳給多個 client 時要明確規定它只讀。

可以用型別或註解表達語意:

1type EncodedMessage []byte
2
3func (c *Client) SendEncoded(message EncodedMessage) bool {
4    return c.TrySend(ServerMessage{
5        Encoded: message,
6    })
7}

這不能從型別上完全禁止修改,但能讓 API 語意更清楚。真正保護仍靠 ownership 規則、測試與 code review。

若無法保證下游不修改,就必須 copy:

1func CloneBytes(input []byte) []byte {
2    output := make([]byte, len(input))
3    copy(output, input)
4    return output
5}

效能優化不能建立在模糊的可變資料共享上。

【策略】sync.Pool 只適合已證明的熱路徑

sync.Pool 的核心用途是複用高頻、短命、可重建的暫存物件。它可以降低配置,但會增加所有權複雜度。

 1var bufferPool = sync.Pool{
 2    New: func() any {
 3        return new(bytes.Buffer)
 4    },
 5}
 6
 7func Encode(value any) ([]byte, error) {
 8    buf := bufferPool.Get().(*bytes.Buffer)
 9    defer bufferPool.Put(buf)
10    buf.Reset()
11
12    if err := json.NewEncoder(buf).Encode(value); err != nil {
13        return nil, err
14    }
15
16    output := append([]byte(nil), buf.Bytes()...)
17    return output, nil
18}

這裡仍然 copy 出 output,因為 buf 會被放回 pool。若直接回傳 buf.Bytes(),呼叫端拿到的 slice 可能在 pool 重用後被覆寫。

不要一開始就使用 sync.Pool。先用 pprof 證明配置是瓶頸,再評估 pool 是否值得承擔額外複雜度。

【判讀】inuse 與 alloc 回答不同問題

Heap profile 的核心判讀是分清 inuse_spacealloc_space

1go tool pprof http://localhost:8080/debug/pprof/heap
2go tool pprof -alloc_space http://localhost:8080/debug/pprof/heap
指標問題常見修正
inuse_space現在誰保留記憶體cache 淘汰、釋放引用、限制 buffer
alloc_space誰累積配置最多預配置、重用、減少 marshal、改資料形狀

alloc_space 高但 inuse_space 不高,代表配置很多但大多被回收,問題可能是 GC 壓力。若 inuse_space 持續上升,代表資料被長期保留,應檢查 cache、map、slice、goroutine reference 或 send buffer。

【策略】allocation 優化要保留正確性邊界

Allocation 優化的核心底線是不能破壞狀態安全。以下做法通常不可接受:

  • 為了省 copy,直接回傳 repository 內部 map。
  • 為了省 bytes,讓多個 client 共享可修改 payload。
  • 為了省 allocation,把 buffer 放回 pool 後仍回傳其底層 slice。
  • 為了少建立 struct,把 request DTO 和 domain state 混用。

較安全的優化順序:

  1. 用 pprof 確認熱點。
  2. 預配置已知大小的 slice/map。
  3. 減少重複 marshal。
  4. 改 API 資料形狀,例如分頁或 projection。
  5. 最後才考慮 sync.Pool

這個順序先處理低風險、高可讀性的改動,再處理高複雜度工具。

【測試】優化後要補邊界測試

Allocation 優化的測試核心是確保共享資料沒有被外部修改。若你重用 bytes、snapshot 或 pooled buffer,要補測試保護 ownership。

例如 repository list 仍要回傳 copy:

 1func TestListUsersReturnsCopy(t *testing.T) {
 2    repo := NewUserRepository()
 3    ctx := context.Background()
 4    _ = repo.Save(ctx, User{ID: "user_1"})
 5
 6    users, err := repo.ListUsers(ctx)
 7    if err != nil {
 8        t.Fatalf("list users: %v", err)
 9    }
10    users[0].ID = "changed"
11
12    again, err := repo.ListUsers(ctx)
13    if err != nil {
14        t.Fatalf("list users again: %v", err)
15    }
16    if again[0].ID != "user_1" {
17        t.Fatalf("repository data was modified through returned slice")
18    }
19}

這種測試能防止未來為了省 allocation 而移除必要 copy。

本章不處理

本章先處理熱路徑上的配置與資料形狀;更大範圍的序列化與 payload 策略,會在下列章節再往外延伸:

和 Go 教材的關係

這一章承接的是 copy boundary、JSON 與 runtime profile;如果你要先回看語言教材,可以讀:

小結

Allocation 優化要先判斷配置是否必要。保護狀態的 copy 是合理成本,高頻熱路徑的重複配置才是優先目標。JSON marshal、slice 成長、map/list 複製與 buffer 建立都是常見來源;用 pprof 區分 inuse_spacealloc_space 後,再決定預配置、分頁、projection、payload 重用或 sync.Pool