十一. Go併發編程--singleflight

一.前言

1.1 爲什麼需要Singleflight?

很多程序員可能還是第一次聽說,本人第一次聽說這個的時候以爲翻譯過來就是程序設計中被稱爲的是 "單例模式"。 google之後二者天壤之別。

一般情況下我們在寫一寫對外的服務的時候都會有一層 cache 作爲緩存,用來減少底層數據庫的壓力,但是在遇到例如 redis 抖動或者其他情況可能會導致大量的 cache miss 出現。

1.2 使用場景

如下圖所示,可能存在來自桌面端和移動端的用戶有 1000 的併發請求,他們都訪問的獲取文章列表的接口,獲取前 20 條信息,如果這個時候我們服務直接去訪問 redis 出現 cache miss 那麼我們就會去請求 1000 次數據庫,這時可能會給數據庫帶來較大的壓力(這裏的 1000 只是一個例子,實際上可能遠大於這個值)導致我們的服務異常或者超時。

這時候就可以使用singleflight 庫了,直譯過來就是 單飛.

這個庫的主要作用就是: 將一組相同的請求合併成一個請求,實際上只會去請求一次,然後對所有的請求返回相同的結果。 如下圖所示

二. slingleFligh 庫(使用教程)

2.1 函數簽名

主要是一個Group結構體, 結構體包含三個方法,相見代碼註釋

type Group
    // Do 執行函數, 對同一個 key 多次調用的時候,在第一次調用沒有執行完的時候
	// 只會執行一次 fn 其他的調用會阻塞住等待這次調用返回
	// v, err 是傳入的 fn 的返回值
	// shared 表示是否真正執行了 fn 返回的結果,還是返回的共享的結果
    func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool)

	// DoChan 和 Do 類似,只是 DoChan 返回一個 channel,也就是同步與異步的區別
	func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result

    // Forget 用於通知 Group 刪除某個 key 這樣後面繼續這個 key 的調用的時候就不會在阻塞等待了
	func (g *Group) Forget(key string)

2.2 使用示例

先使用一個普通的例子,這時一個獲取文章詳情的函數,我們在函數裏面使用一個 count 模擬不同併發下的耗時的不同,併發越多請求耗時越多。

func getArticle(id int) (article string, err error) {
	// 假設這裏會對數據庫進行調用, 模擬不同併發下耗時不同
	// 使用原子操作保證併發安全
	atomic.AddInt32(&count, 1)
	time.Sleep(time.Duration(count) * time.Millisecond)

	return fmt.Sprintf("article: %d", id), nil
}

使用 singlefight 的時候就只需要 new(singleflight.Group) 然後調用以下相對應的Do方法

func singleflightGetArticle(sg *singleflight.Group, id int) (string, error) {
	
	v, err, _ := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
		return getArticle(id)
	})

	return v.(string), err
}

3. 測試

寫一個簡單的測試代碼,模擬啓動1000個goroutine 去併發調用這兩個方法

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"

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

var count int32

func getArticle(id int) (article string, err error) {
	// 假設這裏會對數據庫進行調用, 模擬不同併發下耗時不同
	atomic.AddInt32(&count, 1)
	time.Sleep(time.Duration(count) * time.Millisecond)

	return fmt.Sprintf("article: %d", id), nil
}

func singleFlightGetArticle(sg *singleflight.Group, id int) (string, error) {
	v, err, _ := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
		return getArticle(id)
	})

	return v.(string), err
}

func main() {
	// 使用一個定時器,
	time.AfterFunc(1*time.Second, func() {
		atomic.AddInt32(&count, -count)
	})

	var (
		wg  sync.WaitGroup
		now = time.Now()
		n   = 1000
		sg  = &singleflight.Group{}
	)

	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			res, _ := singleFlightGetArticle(sg, 1)
			// res, _ := getArticle(1)
			if res != "article: 1" {
				panic("err")
			}
			wg.Done()
		}()
	}

	wg.Wait()
	fmt.Printf("同時發起 %d 次請求,耗時: %s", n, time.Since(now))
}

調用 getArticle 方法的耗時,花費了 1s 多

go run demo.go
同時發起 1000 次請求,耗時: 1.0157721s

切換到singleflight方法測試發現花費20ms

go run demo.go
同時發起 1000 次請求,耗時: 21.1962ms

測試發現,使用singleflight這種方式的確在這種場景下能減少執行執行,提高效率。

3.實現原理

這是非內置庫,倉庫golang.org/x/sync/singleflight 源碼解析如下

Group
是一個結構體

type Group struct {
	mu sync.Mutex       // protects m
	m  map[string]*call // lazily initialized
}

Group 結構體由一個互斥鎖和一個 map 組成,可以看到註釋 map 是懶加載的,所以 Group 只要聲明就可以使用,不用進行額外的初始化零值就可以直接使用。call 保存了當前調用對應的信息,map 的鍵就是我們調用 Do 方法傳入的 key

call 也是一個結構體,結構體如下

type call struct {
	wg sync.WaitGroup                          

	// 函數的返回值,在 wg 返回前只會寫入一次
	val interface{}
	err error

	// 使用調用了 Forgot 方法
	forgotten bool

    // 統計調用次數以及返回的 channel
	dups  int
	chans []chan<- Result
}

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)
	}

    // 會先去看 key 是否已經存在
	if c, ok := g.m[key]; ok {
       	// 如果存在就會解鎖
		c.dups++
		g.mu.Unlock()

        // 然後等待 WaitGroup 執行完畢,只要一執行完,所有的 wait 都會被喚醒
		c.wg.Wait()

        // 這裏區分 panic 錯誤和 runtime 的錯誤,避免出現死鎖,後面可以看到爲什麼這麼做
		if e, ok := c.err.(*panicError); ok {
			panic(e)
		} else if c.err == errGoexit {
			runtime.Goexit()
		}
		return c.val, c.err, true
	}

    // 如果我們沒有找到這個 key 就 new call
	c := new(call)

    // 然後調用 waitgroup 這裏只有第一次調用會 add 1,其他的都會調用 wait 阻塞掉
    // 所以這要這次調用返回,所有阻塞的調用都會被喚醒
	c.wg.Add(1)
	g.m[key] = c
	g.mu.Unlock()

    // 然後我們調用 doCall 去執行
	g.doCall(c, key, fn)
	return c.val, c.err, c.dups > 0
}

doCall
這個方法種有個技巧值得學習,使用了兩個 defer 巧妙的將 runtime 的錯誤和我們傳入 function 的 panic 區別開來避免了由於傳入的 function panic 導致的死鎖。

func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
	normalReturn := false
	recovered := false

    // 第一個 defer 檢查 runtime 錯誤
	defer func() {
        ...
	}()

    // 使用一個匿名函數來執行
	func() {
		defer func() {
			if !normalReturn {
                // 如果 panic 了我們就 recover 掉,然後 new 一個 panic 的錯誤
                // 後面在上層重新 panic
				if r := recover(); r != nil {
					c.err = newPanicError(r)
				}
			}
		}()

		c.val, c.err = fn()

        // 如果 fn 沒有 panic 就會執行到這一步,如果 panic 了就不會執行到這一步
        // 所以可以通過這個變量來判斷是否 panic 了
		normalReturn = true
	}()

    // 如果 normalReturn 爲 false 就表示,我們的 fn panic 了
    // 如果執行到了這一步,也說明我們的 fn  recover 住了,不是直接 runtime exit
	if !normalReturn {
		recovered = true
	}
}

第一個defer函數如下

defer func() {
	// 如果既沒有正常執行完畢,又沒有 recover 那就說明需要直接退出了
	if !normalReturn && !recovered {
		c.err = errGoexit
	}

	c.wg.Done()
	g.mu.Lock()
	defer g.mu.Unlock()

     // 如果已經 forgot 過了,就不要重複刪除這個 key 了
	if !c.forgotten {
		delete(g.m, key)
	}

	if e, ok := c.err.(*panicError); ok {
		// 如果返回的是 panic 錯誤,爲了避免 channel 死鎖,我們需要確保這個 panic 無法被恢復
		if len(c.chans) > 0 {
			go panic(e)
			select {} // Keep this goroutine around so that it will appear in the crash dump.
		} else {
			panic(e)
		}
	} else if c.err == errGoexit {
		// 已經準備退出了,也就不用做其他操作了
	} else {
		// 正常情況下向 channel 寫入數據
		for _, ch := range c.chans {
			ch <- Result{c.val, c.err, c.dups > 0}
		}
	}
}()

DoChan

Do chan 和 Do 類似,其實就是一個是同步等待,一個是異步返回,主要實現上就是,如果調用 DoChan 會給 call.chans 添加一個 channel 這樣等第一次調用執行完畢之後就會循環向這些 channel 寫入數據

func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
	ch := make(chan Result, 1)
	g.mu.Lock()
	if g.m == nil {
		g.m = make(map[string]*call)
	}
	if c, ok := g.m[key]; ok {
		c.dups++
		c.chans = append(c.chans, ch)
		g.mu.Unlock()
		return ch
	}
	c := &call{chans: []chan<- Result{ch}}
	c.wg.Add(1)
	g.m[key] = c
	g.mu.Unlock()

	go g.doCall(c, key, fn)

	return ch
}

Forget
forget 用於手動釋放某個 key 下次調用就不會阻塞等待了

func (g *Group) Forget(key string) {
	g.mu.Lock()
	if c, ok := g.m[key]; ok {
		c.forgotten = true
	}
	delete(g.m, key)
	g.mu.Unlock()
}

三.注意事項

3.1 一個阻塞,全員等待

使用 singleflight 我們比較常見的是直接使用 Do 方法,但是這個極端情況下會導致整個程序 hang 住,如果我們的代碼出點問題,有一個調用 hang 住了,那麼會導致所有的請求都 hang 住

還是之前的例子, 加入一個 select 模擬阻塞

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"

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

var count2 int32

func getArticle2(id int) (article string, err error) {
	// 假設這裏會對數據庫進行調用, 模擬不同併發下耗時不同
	atomic.AddInt32(&count2, 1)
	time.Sleep(time.Duration(count2) * time.Millisecond)

	return fmt.Sprintf("article: %d", id), nil
}

func singleFlightGetArticle2(sg *singleflight.Group, id int) (string, error) {
	v, err, _ := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
		// 模擬阻塞
		select {}
		return getArticle2(id)
	})

	return v.(string), err
}

func main() {
	// 使用一個定時器,
	time.AfterFunc(1*time.Second, func() {
		atomic.AddInt32(&count2, -count2)
	})

	var (
		wg  sync.WaitGroup
		now = time.Now()
		n   = 1000
		sg  = &singleflight.Group{}
	)

	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			res, _ := singleFlightGetArticle2(sg, 1)
			if res != "article: 1" {
				panic("err")
			}
			wg.Done()
		}()
	}

	wg.Wait()
	fmt.Printf("同時發起 %d 次請求,耗時: %s", n, time.Since(now))
}

執行就會發現死鎖

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [select (no cases)]:

這時候DoChan就能派上用場了,結合 select 做超時控制

func singleflightGetArticle(ctx context.Context, sg *singleflight.Group, id int) (string, error) {
	result := sg.DoChan(fmt.Sprintf("%d", id), func() (interface{}, error) {
		// 模擬出現問題,hang 住
		select {}
		return getArticle(id)
	})

	select {
	case r := <-result:
		return r.Val.(string), r.Err
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

調用的時候傳入一個含 超時的 context 即可

func main() {
	// 使用一個定時器,
	time.AfterFunc(1*time.Second, func() {
		atomic.AddInt32(&count2, -count2)
	})

	var (
		wg     sync.WaitGroup
		now    = time.Now()
		n      = 1000
		sg     = &singleflight.Group{}
		// 超時控制
		ctx, _ = context.WithTimeout(context.Background(), 1*time.Second)
	)

	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			res, _ := singleFlightGetArticle2(ctx, sg, 1)
			if res != "article: 1" {
				panic("err")
			}
			wg.Done()
		}()

	}

	wg.Wait()
	fmt.Printf("同時發起 %d 次請求,耗時: %s", n, time.Since(now))
}

執行時就會返回超時錯誤.

❯ go run demo2.go
panic: context deadline exceeded

3.2 一個出錯,全部出錯

本身不是什麼問題,因爲singleflight就是這麼設計的。 但是實際使用的時候,可能並不是想要這樣的效果。 比如 如果一次調用要1s. 我們的數據庫請求或者下游服務服務可以支撐10rps(即每秒可以支持10次的請求)的請求的時候會導致我們的錯誤閾值提高。 因爲我們可以1s嘗試10次,但是用了singleflight之後只能嘗試一次。只要出錯這段時間內的所有請求都會收到影響。 那這種情況該如何解決呢?

我們可以啓動一個Goroutine定時forget一下,相當於將rps 從1rps提高到10rps

go func() {
       time.Sleep(100 * time.Millisecond)
       // logging
       g.Forget(key)
   }()

四. 使用場景

如開頭場景所講, singleflige 可以有效解決在使用 Redis 對數據庫中的數據進行緩存,發生緩存擊穿時,大量的流量都會打到數據庫上進而影響服務的尾延時。

使用singleflight能有效解決這個問題,限制對同一個鍵值對的多次重複請求,減少對下游的瞬時流量

通過一段代碼可以查看如何解決這種緩存擊穿的問題


type service struct {
    requestGroup singleflight.Group
}

func (s *service) handleRequest(ctx context.Context, request Request) (Response, error) {
    // request.Hash就是傳入的建,請求的哈希在業務上一般表示相同的請求,所以上述代碼使用它作爲請求的鍵
    v, err, _ := requestGroup.Do(request.Hash(), func() (interface{}, error) {
        rows, err := // select * from tables
        if err != nil {
            return nil, err
        }
        return rows, nil
    })
    if err != nil {
        return nil, err
    }
    return Response{
        rows: rows,
    }, nil
}

五. 總結

golang/sync/singleflight.Group.Dogolang/sync/singleflight.Group.DoChan 分別提供了同步和異步的調用方式,這讓我們使用起來也更加靈活。

當我們需要減少對下游的相同請求時,可以使用 golang/sync/singleflight.Group 來增加吞吐量和服務質量,不過在使用的過程中我們也需要注意以下的幾個問題:

  • golang/sync/singleflight.Group.Dogolang/sync/singleflight.Group.DoChan 一個用於同步阻塞調用傳入的函數,一個用於異步調用傳入的參數並通過 Channel 接收函數的返回值;
  • golang/sync/singleflight.Group.Forget 可以通知 golang/sync/singleflight.Group 在持有的映射表中刪除某個鍵,接下來對該鍵的調用就不會等待前面的函數返回了;
  • 一旦調用的函數返回了錯誤,所有在等待的 Goroutine 也都會接收到同樣的錯誤;

六.參考

  1. https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-sync-primitives/#singleflight
  2. https://lailin.xyz/post/go-training-week5-singleflight.html
  3. https://pkg.go.dev/golang.org/x/sync/singleflight
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章