概要
Goにはsingleflightという重複した関数呼び出しを抑制する仕組みがあります。
例えばキャッシュが切れた時にOriginへのアクセスが並行して走ってしまう(Cache Stampede)ケースでサーバやDBがスローダウンする問題がありますが、こういった時にsingleflightを挟むと解決できます。
環境
- golang 1.12.5
簡単なサンプル
通常だと10回呼び出す処理を、singleflightで抑制してみます。
実装
package main import ( "fmt" "log" "sync" "time" "golang.org/x/sync/singleflight" ) var group singleflight.Group func doSomething(name string) { v, err, shared := group.Do(name, func() (interface{}, error) { time.Sleep(5 * time.Millisecond) return time.Now(), nil }) if err != nil { log.Fatal(err) } fmt.Printf("result: %s, shared: %t\n", v, shared) } func main() { var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() doSomething("hogehoge") }() time.Sleep(time.Millisecond) } wg.Wait() }
結果
$ go run main.go result: 2019-06-06 07:40:06.977784 +0900 JST m=+0.006349595, shared: true result: 2019-06-06 07:40:06.977784 +0900 JST m=+0.006349595, shared: true result: 2019-06-06 07:40:06.977784 +0900 JST m=+0.006349595, shared: true result: 2019-06-06 07:40:06.977784 +0900 JST m=+0.006349595, shared: true result: 2019-06-06 07:40:06.977784 +0900 JST m=+0.006349595, shared: true result: 2019-06-06 07:40:06.984543 +0900 JST m=+0.013108129, shared: true result: 2019-06-06 07:40:06.984543 +0900 JST m=+0.013108129, shared: true result: 2019-06-06 07:40:06.984543 +0900 JST m=+0.013108129, shared: true result: 2019-06-06 07:40:06.984543 +0900 JST m=+0.013108129, shared: true result: 2019-06-06 07:40:06.990454 +0900 JST m=+0.019019767, shared: false
このように重複したリクエストはshared: true
となり、結果が他のリクエストと一致(=時刻が同じになる)します。
今回だと
という結果で、合計10回呼び出しましたが、中身の処理は3回に収まっています。
今回はshared: false
がありますが、全処理に重複があって全てshared: true
になることももちろんあります。
具体的な実装
システム構成
以下のようなユーザ -> CloudFront -> 画像プロキシ -> S3みたいな構成を想定します。
基本的にCloudFrontのエッジがキャッシュを返すことで普段画像プロキシの負荷は低いです。
しかしCloudFrontのキャッシュがexpireしたり、invalidationで一時的にキャッシュが無効化された場合画像プロキシにアクセスが集中します。
S3バケットから画像を取得する処理は時間がかかる&メモリを喰うため、場合によっては負荷が高まりスローダウンする可能性があります。
これをsingleflightで解決します。
実装
var group singleflight.Group func download(path string) ([]byte, error) { log.Println("download") resp, err := http.Get(path) if err != nil { return nil, err } defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, err } return b, nil } func suppressDupCall(path string) ([]byte, error) { v, err, _ := group.Do(path, func() (interface{}, error) { return download(path) }) if err != nil { return nil, err } return v.([]byte), nil } func main() { log.SetFlags(log.LstdFlags | log.Lmicroseconds) path := "https://www.gstatic.com/webp/gallery3/1.png" var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() img, err := download(path) //img, err := suppressDupCall(path) fmt.Printf("result: %d, err: %v\n", len(img), err) }() } wg.Wait() }
結果
singleflightを挟まないケース
毎回download()
を呼ぶケースです。
img, err := download(path)
//img, err := suppressDupCall(path)
以下のように毎回ダウンロードが走ってしまいます。
$ go run main.go 2019/06/06 10:09:29.784576 download 2019/06/06 10:09:29.784576 download 2019/06/06 10:09:29.784580 download 2019/06/06 10:09:29.784581 download 2019/06/06 10:09:29.784576 download 2019/06/06 10:09:29.784576 download 2019/06/06 10:09:29.784589 download 2019/06/06 10:09:29.784591 download 2019/06/06 10:09:29.784593 download 2019/06/06 10:09:29.784673 download result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil>
singleflightを挟むケース
suppressDupCall()
で抑制するケースです。
//img, err := download(path)
img, err := suppressDupCall(path)
ダウンロードは最初の1回で済んでいます。
$ go run main.go 2019/06/06 10:11:18.562771 download result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil> result: 121363, err: <nil>
その他
Go 1.18のGenericsでキャストを安全に
Go 1.18からはGenericsが導入されました。
先程のinterface{}
を[]byte
にキャストするような部分はしばしばランタイムエラーの温床になりやすいので、
return v.([]byte), nil
以下のようにGenericsであらかじめ型を固定しておくことで安全に利用することができます。
type Group[T any] struct { group singleflight.Group } func (g *Group[T]) Do(key string, fn func() (T, error)) (T , error, bool) { v, err, shared := g.group.Do(key ,func() (any, error) { return fn() }) return v.(T), err, shared } var group Group[[]byte] func suppressDupCall(path string) ([]byte, error) { v, err, _ := group.Do(path, func() ([]byte, error) { return download(path) }) if err != nil { return nil, err } return v, nil }
今回は関数に閉じておりそこで戻り値の型チェックが入るためあまり恩恵を受けないですが、ある関数の途中で利用するなどキャストのチェックが入らないような実装の場合はメリットがあるでしょう。
まとめ
singleflightによる重複した関数呼び出しの抑制を紹介しました。
- キャッシュのOriginへのアクセスを抑制したい
- 重い関数だけど結果は一緒
というケースではsingleflightは非常に有効です。