Go 併發控制:singleflight 詳解

江湖十年發表於2024-12-10

singleflightGo 官方擴充套件庫 x 中提供的擴充套件併發原語,能夠將多個併發請求合併為一個,降低服務端壓力。本文就來介紹下它的用法和實現原理。

請求合併

singleflight 主要用於抑制重複的併發呼叫,從而避免對同一資源進行重複操作,提升系統效能。

比如,當我們有多個 goroutine 併發呼叫一個同一個函式時,singleflight 能夠實現只讓一個 goroutine 發起呼叫,其他 goroutine 則阻塞等待,當發起呼叫的 goroutine 返回後,singleflight 將結果同時返回給所有 goroutine。這樣我們就減少了大量的併發呼叫,避免重複操作。

這也是 singleflight 提供的唯一能力——請求合併。

在 Go 後端開發中,我們很容易想到,高併發場景下快取失效時大量請求落到 DB 的場景,正是 singleflight 的用武之地。

如下圖所示:

image.png

左側圖(1)中,當大量請求過來讀取 Redis 快取時,它們同時發現快取失效,那麼所有請求都會繼續向下請求 MySQL 讀取資料。

右側圖(2)中,當所有請求都去 MySQL 讀取資料時,我們可以使用 singleflight 合併這些請求,只保留一個請求去呼叫 MySQL 讀取資料,然後將結果返回給所有請求。

這就是 singleflight 的典型應用場景。

現在,請你思考下 singleflightsync.Once 有什麼區別呢?我會在後文中揭曉答案。

NOTE:

如果你對 sync.Once 不熟悉,可以閱讀我的另一篇文章《Go 併發控制:sync.Once 詳解》

SingleFlight 使用示例

知道了 singleflight 作用,想必你已經躍躍欲試要動手實踐了。廢話不多說,咱們直接看效果。

singleflight 使用示例程式碼如下:

package main

import (
    "fmt"
    "strconv"
    "sync"
    "time"

    "golang.org/x/sync/singleflight"
)

var (
    cache        = make(map[string]*User) // 模擬快取
    mu           sync.RWMutex             // 保護快取
    requestGroup singleflight.Group       // SingleFlight 例項
)

type User struct {
    Id    int64
    Name  string
    Email string
}

// GetUserFromDB 模擬從資料庫獲取資料
func GetUserFromDB(username string) *User {
    fmt.Printf("Querying DB for key: %s\n", username)

    time.Sleep(1 * time.Second) // 模擬耗時操作

    id, _ := strconv.Atoi(username[len(username)-3:])
    fakeUser := &User{
        Id:    int64(id),
        Name:  username,
        Email: username + "@jianghushinian.cn",
    }
    return fakeUser
}

// GetUser 獲取資料,先從快取讀取,若沒有命中,則從資料庫查詢
func GetUser(key string) *User {
    // 先嚐試從快取獲取
    mu.RLock()
    val, ok := cache[key]
    mu.RUnlock()
    if ok {
        return val
    }

    fmt.Printf("User %s not in cache\n", key)

    // 快取未命中,使用 SingleFlight 防止重複查詢
    result, _, _ := requestGroup.Do(key, func() (interface{}, error) {
        // 模擬從資料庫獲取資料
        val := GetUserFromDB(key)

        // 存入快取
        mu.Lock()
        cache[key] = val
        mu.Unlock()

        return val, nil
    })

    return result.(*User)
}

func main() {
    var wg sync.WaitGroup
    keys := []string{"user_123", "user_123", "user_456"}

    // 第一輪併發查詢,快取中還沒有資料,使用 SingleFlight 減少 DB 查詢
    for _, key := range keys {
        wg.Add(1)
        go func(k string) {
            defer wg.Done()
            fmt.Printf("Get user for key: %s -> %+v\n", k, GetUser(k))
        }(key)
    }

    time.Sleep(2 * time.Second)
    fmt.Println("===================================")

    // 第二輪併發查詢,快取中有資料,直接讀取快取,不會查詢 DB
    for _, key := range keys {
        wg.Add(1)
        go func(k string) {
            defer wg.Done()
            fmt.Printf("Get user for key: %s -> %+v\n", k, GetUser(k))
        }(key)
    }

    wg.Wait()
}

簡單解釋下這個示例程式,我們想要模擬的就是高併發場景下快取失效時大量請求落到 DB 的場景。

main 函式中,首先宣告瞭 sync.WaitGroup 變數來控制併發,keys 表示我們要併發查詢的使用者,這裡以 username 作為查詢的 key。接著遍歷這些 keys 並開啟新的 goroutine 來併發的查詢 User 資訊。

GetUser 會先嚐試從快取讀取資料,若沒有命中,再去資料庫中查詢。從資料庫獲取資料需要呼叫 GetUserFromDB 函式,不過 GetUser 中並沒有直接去呼叫它,而是使用 singleflight 例項物件 requestGroup.Do 方法來呼叫。Do 方法接收兩個引數,一個字串型別的 key 和一個函式 fn,對於同一個 key,在併發情況下,只有一個 fn 正在執行。而 requestGroup.Do 返回的 result 就是函式 fn 的第一個返回值。在函式 fn 內部呼叫了 GetUserFromDB 並將從 DB 查詢到的資料存入快取 cache 中。

我們在 main 函式中共發起了兩輪併發查詢使用者資訊的請求。第一輪時,快取 cache 為空,所以請求會落在 DB,第二輪時,快取 cache 中有資料,所以請求直接讀取快取,不會查詢 DB。

執行示例程式碼,得到如下輸出:

$ go run main.go
User user_456 not in cache
Querying DB for key: user_456
User user_123 not in cache
Querying DB for key: user_123
User user_123 not in cache
Get user for key: user_123 -> &{Id:123 Name:user_123 Email:user_123@jianghushinian.cn}
Get user for key: user_456 -> &{Id:456 Name:user_456 Email:user_456@jianghushinian.cn}
Get user for key: user_123 -> &{Id:123 Name:user_123 Email:user_123@jianghushinian.cn}
===================================
Get user for key: user_123 -> &{Id:123 Name:user_123 Email:user_123@jianghushinian.cn}
Get user for key: user_123 -> &{Id:123 Name:user_123 Email:user_123@jianghushinian.cn}
Get user for key: user_456 -> &{Id:456 Name:user_456 Email:user_456@jianghushinian.cn}

可以發現,第一輪併發請求中,fmt.Printf("User %s not in cache\n", key) 的日誌列印了 3 次,說明快取確實為空。fmt.Printf("Querying DB for key: %s\n", username) 日誌列印了 2 次,說明 singleflight 生效了,因為 3 個併發請求中,有 2 個 key 是一樣的 user_123,所以 singleflight 合併了請求。

第二輪併發請求發起時,快取中已經存在資料,所以只會列印 fmt.Printf("Get user for key: %s -> %+v\n", k, GetUser(k)) 的日誌資訊。

現在你應該對 singleflight 有一個比較直觀的認識了。不過,我在這裡講解的並不夠詳細,如果完全沒接觸過 singleflight 這個概念,可能會有一些疑惑。沒關係,接下來我將對 singleflight 原始碼進行講解,相信看過原始碼後,你心中的疑惑就都能解開了。畢竟,原始碼之下無秘密。

SingleFlight 原始碼解析

singleflight 原始碼中有兩個核心結構體:

// call is an in-flight or completed singleflight.Do call
type call struct {
    wg sync.WaitGroup // in-flight 併發控制

    // These fields are written once before the WaitGroup is done
    // and are only read after the WaitGroup is done.
    val interface{} // 記錄 fn 返回值
    err error       // 記錄 fn 返回的 error

    // These fields are read and written with the singleflight
    // mutex held before the WaitGroup is done, and are read but
    // not written after the WaitGroup is done.
    dups  int             // 記錄從快取中獲取 fn 返回值的次數
    chans []chan<- Result // 提供給 DoChan 方法用於傳遞 fn 的返回值
}

// Group represents a class of work and forms a namespace in
// which units of work can be executed with duplicate suppression.
type Group struct {
    mu sync.Mutex       // protects m
    m  map[string]*call // lazily initialized
}

其中 Group 代表 singleflight 物件,它有兩個欄位,mu 是一個互斥鎖,用於保護 m 的併發訪問。m 是一個 map,會被延遲初始化,m 的鍵就是呼叫 singleflight.Do 時傳遞的第一個引數 keym 的值是一個 *call 物件。

call 代表一個正在執行(in-flight)或已完成(completed)的 fn 函式的呼叫,也就是說,它會記錄我們在呼叫 singleflight.Do 時傳遞的第二個引數 fn 的完整生命週期。

Group 物件提供了三個公有方法,簽名如下:

func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool)
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result
func (g *Group) Forget(key string)
  • Do 方法我們見過了,它接收一個 key 和一個函式 fn,對於同一個 key,在併發情況下,只有一個 fn 正在執行,其他請求會阻塞等待。函式 fn 無引數,有兩個返回值 valueerror。當 fn 執行完成並返回,Do 方法會返回 fn 的執行結果 valueerror,即 Do 方法返回值的前兩個。而 Do 方法的最後一個返回值 shared,則表示返回值 v 是否共享給了多給呼叫方,即在 fn 執行時,有其他併發請求過來,不過它們並沒有真正執行,而是等待這個 fn 的返回結果。
  • DoChan 方法其實和 Do 方法類似,只不過返回值變成了一個 channel。併發情況下對 DoChan 的呼叫不會阻塞等待第一個 fn 執行完成,而是直接返回 channel,等 fn 執行完成後,會將結果 Result 透過這個 channel 返回。
  • Forget 告知 Group 忘記一個 key,在呼叫 Forget 之後,再次呼叫 Do/DoChan 方法將不再等待前一個未完成的 fn 執行結果,而是當作一個新的請求來處理。

DoChan 方法的返回值中的 Result 型別,其實就是對 Do 方法返回的三個值的封裝,方便在 channel 中傳遞。

Result 型別定義如下:

// Result holds the results of Do, so they can be passed
// on a channel.
type Result struct {
    Val    interface{}
    Err    error
    Shared bool
}

現在我們對 Group 物件提供的三個方法原始碼依次進行講解。

singleflight.Do

我們先看 Do 方法的實現:

func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
    g.mu.Lock() // 加鎖,保證併發安全
    if g.m == nil {
        g.m = make(map[string]*call) // 延遲初始化 m
    }
    if c, ok := g.m[key]; ok { // 如果 key 已經在 map 中,即非第一個請求會進入到這個程式碼塊
        c.dups++
        g.mu.Unlock()
        c.wg.Wait()

        if e, ok := c.err.(*panicError); ok {
            panic(e)
        } else if c.err == errGoexit {
            runtime.Goexit()
        }
        return c.val, c.err, true
    }
    c := new(call) // 當前 key 對應的第一個請求會建立一個 call 物件
    c.wg.Add(1)
    g.m[key] = c
    g.mu.Unlock()

    g.doCall(c, key, fn) // 真正去執行 fn 的方法
    return c.val, c.err, c.dups > 0
}

Do 方法內部首先會進行加鎖操作,保證所有對 m 的操作併發安全。

Group 物件的 m 屬性延遲到呼叫 Do 方法時才被初始化,所以 Group 物件其實無需例項化即可直接使用。

如果 key 不在 m 中,說明是這個 key 的第一個請求,會為其建立一個 call 物件,並儲存到 m 中。然後就交給 Group.doCall 來處理 fn 的呼叫了。並且 call 物件使用了 sync.WaitGroup 來控制併發呼叫。

如果 keym 中,則說明不是這個 key 的第一個請求,那麼就可以呼叫 c.wg.Wait() 等待第一個請求完成,然後直接從 call 物件的 valerr 屬性中拿到 fn 的返回值。在這裡並沒執行當前請求的 fncall 物件上的結果是當前 key 的第一個請求返回的,所以就實現了類似“快取”的效果,有效合併了多次請求呼叫。

此外,在這裡有兩處錯誤型別判斷,c.err.(*panicError)c.err == errGoexit

其中 panicError 定義如下:

type panicError struct {
    value interface{} // 記錄 fn 函式的 panic 資訊
    stack []byte      // 記錄發生 panic 時的異常堆疊資訊
}

// Error implements error interface.
func (p *panicError) Error() string {
    return fmt.Sprintf("%v\n\n%s", p.value, p.stack)
}

func (p *panicError) Unwrap() error {
    err, ok := p.value.(error)
    if !ok {
        return nil
    }

    return err
}

當同一個 key 的第一個請求函式 fn 呼叫發生了 panic,就會在 c.err 中儲存一個 *panicError 物件,那麼後續的併發請求過來,也要重新觸發 panic

另一個錯誤 errGoexit 定義如下:

var errGoexit = errors.New("runtime.Goexit was called")

這是一個典型的 Sentinel error,用於標記在使用者提供的 fn 函式內部呼叫了 runtime.Goexit() 來退出 goroutine,後續的併發請求過來,也要重新呼叫 runtime.Goexit()

NOTE:

runtime.Goexit用於終止當前 goroutine(其他正在執行的協程不受影響,程式繼續正常執行),不會繼續執行後續程式碼。並且在退出前會執行當前 goroutine 的所有 defer 語句,確保資源被正確釋放。此外 runtime.Goexit() 不會引發 panic,因此無法透過 recover 捕獲。

那麼現在 Do 方法的工作流程就清晰了:

  1. 請求 Do(key, fn) (v, err, shared) 被呼叫

    • 如果 key 不存在:建立一個新的 call,執行使用者函式 fn
    • 如果 key 已存在:等待現有操作 fn 呼叫完成,複用其結果。
  2. fn 函式完成後

    • 直接返回 fn 的執行結果。
    • 或者喚醒等待的重複請求,返回fn 的執行結果。

singleflight.DoChan

接下來再看 DoChan 方法的實現:

func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
    ch := make(chan Result, 1) // 構造一個 channel 用於傳遞 fn 的執行結果
    g.mu.Lock()                // 加鎖,保證併發安全
    if g.m == nil {
        g.m = make(map[string]*call) // 延遲初始化 m
    }
    if c, ok := g.m[key]; ok { // 如果 key 已經在 map 中
        c.dups++
        c.chans = append(c.chans, ch)
        g.mu.Unlock()
        return ch
    }
    c := &call{chans: []chan<- Result{ch}} // 建立一個 call 物件,並初始化 chans 欄位
    c.wg.Add(1)
    g.m[key] = c
    g.mu.Unlock()

    go g.doCall(c, key, fn) // 開啟新的 goroutine 來執行 fn

    return ch // 返回 channel 物件
}

可以發現,DoChan 方法的內部邏輯與 Do 方法類似,只不過它不會阻塞等待第一個請求執行完成,而是啟動新的 goroutine 呼叫 doCall 來執行 fn,並返回一個 channel 物件。

那麼也就是說,Do 方法和 DoChan 方法的核心邏輯其實都是在 doCall 方法中了。

singleflight.doCall

doCall 方法的實現:

func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
    normalReturn := false // fn 是否正常返回
    recovered := false    // fn 是否產生 panic

    // 使用 double-defer 來區分 panic 或 runtime.Goexit
    defer func() {
        // 如果條件成立,則說明給定的函式 fn 內部呼叫了 runtime.Goexit
        if !normalReturn && !recovered {
            c.err = errGoexit
        }

        g.mu.Lock()
        defer g.mu.Unlock()
        c.wg.Done()        // 通知阻塞等待的其他請求可以獲取 fn 執行結果了
        if g.m[key] == c { // fn 執行完成,從 m 中刪除 key 記錄
            delete(g.m, key)
        }

        if e, ok := c.err.(*panicError); ok {
            if len(c.chans) > 0 {
                go panic(e) // 為了防止等待 channel 的 goroutine 被永久阻塞,需要確保這個 panic 無法被 recover
                select {}   // 保持當前 goroutine 不退出
            } else {
                panic(e)
            }
        } else if c.err == errGoexit {
            // 當前 goroutine 正在執行 runtime.Goexit 退出流程,這裡無需特殊處理
        } else {
            // 進入此程式碼塊,說明 fn 正常返回
            for _, ch := range c.chans {
                ch <- Result{c.val, c.err, c.dups > 0}
            }
        }
    }()

    func() {
        defer func() {
            if !normalReturn {
                if r := recover(); r != nil { // 進入此程式碼塊,說明 fn 觸發了 panic
                    c.err = newPanicError(r)
                }
            }
        }()

        c.val, c.err = fn()
        normalReturn = true
    }()

    if !normalReturn {
        recovered = true
    }
}

這個方法有點長,不過整體脈絡是清晰的,我們拆成幾個小的邏輯程式碼段來分析它。

函式在最開始處初始化兩個變數:

normalReturn := false
recovered := false

normalReturn 如果為 true,則說明 fn 正常返回。

recovered 如果為 true,則說明 fn 執行期間發生了 panic

然後是一大段延遲執行的 defer 語句,我們先跳過它,直接來看下面的匿名立即執行函式邏輯:

func() {
    defer func() {
        if !normalReturn {
            if r := recover(); r != nil {
                c.err = newPanicError(r)
            }
        }
    }()

    c.val, c.err = fn()
    normalReturn = true
}()

這裡之所以使用一個立即執行函式,是為了執行 defer 語句。函式內主要邏輯就是呼叫 fn 函式,並將其結果儲存到 *call 物件 c.valc.err 兩個屬性中。

fn 執行成功,則標記 normalReturntrue,表明 fn 正常返回,執行期間沒有發生 panic 或呼叫 runtime.Goexit()

如果 fn 內發生 panic,則會被 defer 中的 recover 捕獲到,並使用 panic 資訊建立一個 *panicError 物件儲存到 c.err 屬性中。

newPanicError 函式實現如下:

func newPanicError(v interface{}) error {
    stack := debug.Stack()

    // The first line of the stack trace is of the form "goroutine N [status]:"
    // but by the time the panic reaches Do the goroutine may no longer exist
    // and its status will have changed. Trim out the misleading line.
    if line := bytes.IndexByte(stack[:], '\n'); line >= 0 {
        stack = stack[line+1:]
    }
    return &panicError{value: v, stack: stack}
}

這裡程式碼很簡單,使用 debug.Stack() 獲取當前 goroutine 的呼叫棧資訊,然後截掉第一行 goroutine N [status]: 格式的堆疊內容,再構造一個 *panicError 物件並返回。

NOTE:

debug.Stack 是對 runtime.Stack 的一個高層次的封裝,直接返回當前 goroutine 的呼叫棧資訊。

回憶下在 Do 函式中有一個錯誤型別斷言 c.err.(*panicError),錯誤資訊就是在這裡透過呼叫 newPanicError 建立並賦值給 c.err 的。

匿名函式執行完成後,程式碼邏輯走到這裡:

if !normalReturn {
    recovered = true
}

如果此時 normalReturnfalse,則執行 fn 時必然出現了 panic,所以記錄 recovered 值為 true

這裡之所以能這樣斷定 fn 中出現 panic,是因為這段邏輯與匿名的立即執行函式在同一個 goroutine 中,如果 c.val, c.err = fn() 這行執行成功,內部肯定沒有發生 panic 或呼叫 runtime.Goexit(),那麼 normalReturn = true 也必然會執行成功。而如果 normalReturnfalse,則有可能發生 panic 或呼叫 runtime.Goexit()。但是如果呼叫 runtime.Goexit(),那麼當前 goroutine 會立即終止,所以程式碼根本就不會執行到此處。既然程式碼能夠執行到此處,且 normalReturnfalse,就只剩一種可能,fn 中發生了 panic

doCall 方法最後一行程式碼已經執行完成,接下來就要執行到頂部的 defer 函式中了:

// 使用 double-defer 來區分 panic 或 runtime.Goexit
defer func() {
    // 如果條件成立,則說明給定的函式 fn 內部呼叫了 runtime.Goexit
    if !normalReturn && !recovered {
        c.err = errGoexit
    }

    g.mu.Lock()
    defer g.mu.Unlock()
    c.wg.Done()        // 通知阻塞等待的其他請求可以獲取 fn 執行結果了
    if g.m[key] == c { // fn 執行完成,從 m 中刪除 key 記錄
        delete(g.m, key)
    }

    if e, ok := c.err.(*panicError); ok {
        if len(c.chans) > 0 {
            go panic(e) // 為了防止等待 channel 的 goroutine 被永久阻塞,需要確保這個 panic 無法被 recover
            select {}   // 保持當前 goroutine 不退出
        } else {
            panic(e)
        }
    } else if c.err == errGoexit {
        // 當前 goroutine 正在執行 runtime.Goexit 退出流程,這裡無需特殊處理
    } else {
        // 進入此程式碼塊,說明 fn 正常返回
        for _, ch := range c.chans {
            ch <- Result{c.val, c.err, c.dups > 0}
        }
    }
}()

defer 函式中首先對 fn 函式的執行結果進行了判斷,如果沒有正常退出,且未發生 panic,則說明一定是呼叫了 runtime.Goexit()

所以,這也是為什麼 doCall 方法中共計使用了兩個 defer 語句,就是為了對 fn 的三種可能執行結果進行判別。

c.wg.Done() 通知阻塞等待的其他請求可以獲取 fn 函式的執行結果了。

fn 執行完成,立即從 Group.m 中刪除 fn 函式所對應的 key。所以,singleflight 只保證併發情況下,合併多個請求。如果這一輪併發結束,下次相同 key 發來的請求,fn 函式會依然會執行。所以看到此處,我想你應該能 Get 到 singleflightsync.Once 的不同之處了。

接下來的邏輯就有點意思了,如果 c.err 中記錄的 error*panicError 型別,則說明 fn 函式發生了 panic。那麼此時需要重新觸發 panic,讓呼叫方感知到。這又分兩種情況,如果 len(c.chans) > 0 成立,則說明使用者呼叫了 DoChan 方法,此時為了防止呼叫方用來等待 channel 的 goroutine 被永久阻塞,需要確保這個 panic 不能被 recover,所以啟動了一個新的 goroutine 來執行 panic(e)select {} 則是用來保持當前 goroutine 不被退出。另一種情況則是使用者呼叫了 Do 方法,那麼直接執行 panic(e) 即可。

NOTE:

recover 只能捕獲當前 goroutine 中的 panic,我在另一篇文章《Go 錯誤處理指北:Defer、Panic、Recover 三劍客》中進行了詳細講解。

如果 c.err == errGoexit 成立,則說明 fn 函式內容呼叫了 runtime.Goexit(),那麼無需特殊處理,當前 goroutine 會繼續執行退出操作。

最終程式碼進入 else 邏輯,說明 fn 正常返回,如果使用者呼叫了 DoChan 方法,則 c.chans 有值,將 fn 執行結果包裝成 Result 並透過 channel 通知給所有等待者。

至此,singleflight 最核心的方法 doCall 就執行完成了。

我們來梳理下 doCall 方法的工作流程:

  1. 呼叫 fn 函式,執行 fn 的邏輯包裹在巢狀的匿名函式中,並處理可能產生的 panicruntime.Goexit
  2. 處理返回結果,在 defer 方法中,區分了 fn 函式的正常返回、panicruntime.Goexit 三種可能執行結果,並設定對應的狀態和錯誤資訊。
  3. 分發 fn 函式的執行結果或錯誤資訊,如果使用者呼叫了 Do 方法,可以從 *call 物件的 c.valc.err 兩個屬性中拿到結果,如果使用者呼叫了 DoChan 方法,最終會將結果廣播到所有等待的 channel

doCall 方法程式碼量不大,不過其中中有兩處關鍵點值得注意:

  1. 雙層 defer 設計(double-defer

    • 第一層 defer 用於捕獲 panic
    • 第二層 defer 則用於處理 runtime.Goexit 和資源釋放。
  2. 對於 panic 的處理

    • *panicError 中包含了錯誤值和堆疊資訊,便於除錯。
    • 透過 goroutine 執行 panic(e) 保證不會阻塞等待 channel 的呼叫者。

singleflight.Forget

現在還剩下最後方法沒有分析了,Forget 方法原始碼如下:

func (g *Group) Forget(key string) {
    g.mu.Lock()
    delete(g.m, key)
    g.mu.Unlock()
}

一目瞭然,Forget 方法用於呼叫方主動告知 Group 忘記一個 key

Forget 方法適用場景如下:

  • 長時間未完成的呼叫,比如某個函式執行時間過長,但業務上已經不再需要結果,此時可以透過 Forget 主動移除 key
  • 錯誤請求的清理,如果某次呼叫由於邏輯錯誤進入了無效狀態,直接 Forget 該呼叫,可以避免fn 執行結果後續被誤用。
  • 重試機制,在某些場景下,你希望對同一個 key 發起新的呼叫,而不是複用之前的結果。

不過,還是建議慎使用 Forget,有需要時再使用。因為如果呼叫時間較短且結果重要,頻繁使用 Forget 可能導致資源浪費,singleflight 也就失去了意義。

SingleFlight 適用場景

現在我們對 singleflight 的原始碼進行了解析,那麼 singleflight 的適用場景也就清晰了。

singleflight 典型使用場景如下:

  1. 快取擊穿

    • 問題: 快取中的某個熱點鍵過期,導致大量請求同時訪問後端資料庫,增加系統壓力。
    • 解決: 使用 singleflight 確保在快取重建過程中,只有一個請求會訪問資料庫,其他請求等待結果返回。
  2. 遠端服務呼叫

    • 問題: 多個併發請求訪問同一個遠端服務時,可能造成不必要的重複呼叫,浪費頻寬和計算資源。
    • 解決: 使用 singleflight 使相同的請求合併為一次。
  3. 定時任務去重

    • 問題: 在分散式系統中,多個節點可能同時執行定時任務,導致重複任務執行。
    • 解決: 使用 singleflight 確保只有一個節點執行任務,其他節點共享結果。
  4. 訊息去重

    • 問題: 訊息佇列中可能存在重複訊息的消費問題。
    • 解決: 在消費端使用 singleflight,確保對相同訊息的處理只執行一次。
  5. 分散式鎖最佳化

    • 問題: 多個節點同時搶鎖時,可能會發起大量重複的加鎖嘗試。
    • 解決: 使用 singleflight 降低對分散式鎖的訪問壓力,只允許一個請求實際去嘗試加鎖。

SingleFlight 的核心作用是抑制重複的併發呼叫,在併發場景中,多次相同請求(由同一個 key 標識)過來時,讓它們共享第一個呼叫的結果,而不是重複執行。這在讀操作中尤其常見,而對於寫操作,合併的需求和行為需要更慎重的對待。

關於 SingleFlight 你認為還有那些使用場景可以分享出來,大家一起探討學習。

總結

singleflight 主要用於抑制重複的併發呼叫,從而避免對同一資源進行重複操作,提升系統效能。所以 singleflight 適用於可以合併請求的操作。

singleflight 提供了三個公有方法 DoDoChanForgetDoDoChan 兩個方法作用相同都用來合併請求,二者的核心邏輯在 doCall 方法中。Forget 方法則用於呼叫方主動告知 Group 忘記一個 key

singleflight 典型使用場景有快取擊穿、遠端服務呼叫、任務去重、訊息去重、分散式鎖最佳化等。

我在前文中留過一個思考題,singleflightsync.Once 有什麼區別,現在你有答案了嗎?

singleflight 只用在併發場景下,同時有多個重複的請求,才能夠合併請求。而當請求結束,就會執行 delete(g.m, key) 刪除 key,下一次請求過來 fn 依然被重新執行。

sync.Once 則始終保證函式 f 只被呼叫一次。

二者雖然看起來功能類似,但它們的實現原理和適用場景各不相同。

此外,其實在 Go 原始碼中的 internal 包下,也有一個 SingleFlight實現,與擴充套件庫 x 中的實現思路相同,程式碼更加簡單,感興趣的讀者可以跳轉過去檢視其原始碼實現。

本文示例原始碼我都放在了 GitHub 中,歡迎點選檢視。

希望此文能對你有所啟發。

聯絡我

  • 公眾號:Go程式設計世界
  • 微信:jianghushinian
  • 郵箱:jianghushinian007@outlook.com
  • 部落格:https://jianghushinian.cn
  • GitHub:https://github.com/jianghushinian

相關文章