Skip to main content

Command Palette

Search for a command to run...

SingleFlight

Updated
3 min read
SingleFlight

Go Singleflight 實作全攻略:優化 API 消耗、並發控制與監控實務

在開發高併發應用程式(如股票分析機器人)時,我們常面臨 「驚群效應」(Thundering Herd):當快取失效或系統剛啟動時,大量請求同時湧入,導致昂貴的 API(如 Gemini)成本爆炸或資料庫崩潰。

singleflight 是 Go 官方擴充套件(golang.org/x/sync/singleflight)中的神兵利器,確保當多個請求同時要求同一個結果時,實際的運算只會執行一次


1. 為什麼需要 Singleflight?

想像 1,000 個用戶同時查詢「台積電 (2330)」的分析報告:

  • 無控制:1,000 次 Gemini API 呼叫(帳單飆升)、1,000 次資料庫連線(系統卡死)。
  • 有 Singleflight1 次 API 呼叫,其餘 999 人在記憶體中等待結果,隨後共享同一份數據。

2. 深入核心:Singleflight 原始碼拆解

理解 singleflight 的底層源碼結構,能幫助我們更精準地掌握並發控制。

核心結構定義

  1. Group: 代表一個命名空間,管理所有正在進行中的任務。
  2. m map[string]*call: 記錄目前有哪些 Key 正在「飛行中」(In-flight)。
  3. mu sync.Mutex: 保護 Map,防止高併發下的 Race Condition。
  1. call: 代表一個正在執行的具體任務。
  2. wg sync.WaitGroup: 最關鍵的同步元件。用來讓「後到」的請求等待「先到」的請求完成。
  3. val / err: 存放執行後的結果與錯誤。

運作流程圖解:Do(key, fn)


3. 核心實作範例:L1 快取 + Singleflight

我們在 AnalysisService 中構建兩層防護:記憶體快取 (L1)Singleflight

package analyzer

import (
    "context"
    "sync"

    "golang.org/x/sync/singleflight" 
    "github.com/nathan/stock_bot/internal/storage"
)

type AnalysisService struct {
    genai      *GenAIClient
    d1Client   *storage.D1Client
    stockCache map[string]*StockAnalysisResult
    mu         sync.RWMutex
    sf         singleflight.Group
}

func (s *AnalysisService) analyzeStock(ctx context.Context, code, name string) (*StockAnalysisResult, error) {
    // 1. 第一層防護:檢查記憶體快取 (L1 Cache)
    s.mu.RLock()
    if result, ok := s.stockCache[code]; ok {
        s.mu.RUnlock()
        return result, nil
    }
    s.mu.RUnlock()

    // 2. 第二層防護:Singleflight (請求合併)
    key := "stock:" + code
    v, err, _ := s.sf.Do(key, func() (interface{}, error) {
        // 3. 執行昂貴的邏輯 (DB + Gemini API)
        result, err := s.doAnalyzeStock(ctx, code, name) 
        if err != nil {
            return nil, err
        }

        // 4. 寫入快取 (務必在 singleflight 內部完成,防止下一波瞬間擊穿)
        s.mu.Lock()
        s.stockCache[code] = result
        s.mu.Unlock()

        return result, nil
    })

    if err != nil {
        return nil, err
    }
    return v.(*StockAnalysisResult), nil
}

4. 進階實戰:處理「巢狀 Context」與非同步操作

doAnalyzeStock 內部,如果我們有其他的非同步操作(例如同時查多個 API),我們必須正確傳遞並檢查 ctx.Err()。這樣做是為了確保:如果外部請求(領頭者)超時了,後續的耗時運算能立即停止,釋放資源。

func (s *AnalysisService) doAnalyzeStock(ctx context.Context, code, name string) (*StockAnalysisResult, error) {
    // 建立一個子 Context 用於內部的多個非同步任務
    g, ctx := errgroup.WithContext(ctx)

    var dbData string
    var aiResult string

    // 任務 1:查資料庫
    g.Go(func() error {
        // 隨時檢查 Context 是否已取消
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
            // 模擬資料庫查詢
            dbData = "Historical Data"
            return nil
        }
    })

    // 任務 2:呼叫 Gemini API
    g.Go(func() error {
        // 將 ctx 傳入 API 客戶端,讓它能跟隨整體的超時控制
        res, err := s.genai.Generate(ctx, "Analyze this: "+code)
        if err != nil {
            return err
        }
        aiResult = res
        return nil
    })

    // 等待所有任務完成或其中一個出錯
    if err := g.Wait(); err != nil {
        return nil, err
    }

    return &StockAnalysisResult{Data: dbData, Analysis: aiResult}, nil
}

5. 性能數據對比 (100 個併發請求)

指標無 Singleflight有 Singleflight優化率
Gemini API 呼叫次數100 次1 次99%
資料庫讀取壓力高 (100 併發)極低 (1 併發)99%
平均回應時間~2,500ms~2,100ms~16%

6. 監控與量化:加入 OpenTelemetry 追蹤

為了知道 Singleflight 幫我們省了多少錢,我們可以追蹤 shared 這個回傳值。sharedtrue 表示該請求是「搭便車」成功的。

func (s *AnalysisService) analyzeStockWithMetrics(ctx context.Context, code string) (*StockAnalysisResult, error) {
    key := "stock:" + code
    v, err, shared := s.sf.Do(key, func() (interface{}, error) {
        return s.doAnalyzeStock(ctx, code, "Name")
    })

    // 紀錄監控指標:分辨是「原始呼叫」還是「共享結果」
    status := "original"
    if shared {
        status = "shared"
    }

    s.sfCounter.Add(ctx, 1, metric.WithAttributes(
        attribute.String("stock_code", code),
        attribute.String("type", status),
    ))

    if err != nil {
        return nil, err
    }
    return v.(*StockAnalysisResult), nil
}

儀表板觀測指標 (Prometheus / Grafana) 透過這組指標,你可以在 Grafana 畫出以下圖表:

  1. Request Stacked Area Chart:

    • type="original": 實際產生的 API 呼叫次數(你的成本預算)。

    • type="shared": 被合併的請求次數(你省下的錢)。

  2. Saving Ratio (節省率):

    • 公式:sum(rate(shared)) / sum(rate(total))。

7. 總結

singleflight 的機制就像是:

  1. 進門先看櫃台 (Map):有沒有掛牌子 (Key)?
  2. 有牌子:代表有人處理了,我就站在旁邊等 (Wait),等他處理好直接拿結果。
  3. 沒牌子:我掛上牌子 (Add Map),開始處理 (Do Fn)。

開發者筆記:

  • 快取寫入:務必在 sf.Do 的 func 內部寫入快取。
  • Context 意識:傳遞並檢查 ctx.Err() 是確保系統在高壓下不會資源洩漏的關鍵。
  • 可觀測性:沒有監控,優化就只是傳說。加入 OTel 讓數據說話。

「多人請求,一人做事,結果共享」 —— 這不僅是效能優化,更是對昂貴資源與系統穩定性的極致追求。

172 views

More from this blog

Claude Code 監控秘錄:OpenTelemetry(OTel/OTLP)實戰指南

稟告主公:此乃司馬懿進呈之兵書,詳解如何以 OpenTelemetry 陣法,令臥龍神算之一舉一動盡在掌握,知糧草消耗、察兵器效能、辨戰報異常,使主公運籌帷幄於大帳之中。 為何需要斥候情報? 司馬懿稟告主公: 臥龍神算(Claude Code)乃當世利器,然若無斥候回報,主公便如蒙眼行軍——兵器耗損幾何、糧草消費幾許、哪路斥候出了差錯,一概不知。臣以為,此乃兵家大忌。 無情報之弊,有四: 軍

Feb 19, 202610 min read178
Claude Code 監控秘錄:OpenTelemetry(OTel/OTLP)實戰指南

工程師的 Claude Code 實戰指南:從零開始到高效開發

工程師的 Claude Code 實戰指南:從零開始到高效開發 本文整合 Anthropic 官方 Best Practices 與社群實戰 Tips,帶你由淺入深掌握 Claude Code。 什麼是 Claude Code?為什麼值得學? 如果你還在用「複製程式碼貼到 ChatGPT,再複製答案貼回去」的工作流程,Claude Code 會讓你大開眼界。 Claude Code 是 Anthropic 推出的命令列工具,它直接活在你的 terminal 裡,能夠讀懂你的整個 codeb...

Feb 18, 20265 min read77
工程師的 Claude Code 實戰指南:從零開始到高效開發

System Design Interview Ch 12 Digital Wallet

確立問題與設計範疇 角色對話內容 面試者我們應該只關注兩個數位錢包之間的餘額轉帳操作嗎?我們是否需要擔心其他功能? 面試官讓我們只關注餘額轉帳操作。 面試者該系統需要支援多少 TPS(每秒交易次數)? 面試官讓我們假設是 1,000,000 TPS (每秒 100 萬次交易)。 面試者數位錢包對正確性有嚴格的要求。我們可以假設事務保證 就足夠了嗎? 面試官聽起來不錯。 面試者我們需要證明正確性嗎? 面試官這是一個很好的問題。正確性(Correctness)通常只有在交...

Feb 2, 202610 min read229
System Design Interview Ch 12 Digital Wallet

Claude Code 利用 Event-Driven Hooks 打造自動化開發大腦

在現代 AI 輔助開發中,我們不僅需要 AI 寫程式,更需要它懂規則、記性好,並且能自動處理那些繁瑣的雜事。透過 Claude Code Hooks 機制,我們可以介入 AI 的思考與執行迴圈,實現真正的「人機協作自動化」。 一、 動機與痛點:為什麼你需要介入 AI 的生命週期? 在預設狀態下,Claude Code 雖然強大,但它是「被動」且「無狀態」的,這導致了開發者常遇到以下痛點: 記憶重置 (Session Amnesia): 痛點:每次重啟終端機,AI 就像失憶一樣。 解法:你...

Jan 24, 20266 min read537
Claude Code 利用 Event-Driven Hooks 打造自動化開發大腦
M

MicroFIRE

71 posts