Carpe Diem

備忘録

singleflight でキャッシュのOriginへのリクエストを抑制

概要

Goにはsingleflightという重複した関数呼び出しを抑制する仕組みがあります。
例えばキャッシュが切れた時にOriginへのアクセスが並行して走ってしまう(Cache Stampede)ケースでサーバやDBがスローダウンする問題がありますが、こういった時にsingleflightを挟むと解決できます。

環境

簡単なサンプル

通常だと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となり、結果が他のリクエストと一致(=時刻が同じになる)します。
今回だと

  • 07:40:06.977784
  • 07:40:06.984543
  • 07:40:06.990454
    • 重複なし

という結果で、合計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は非常に有効です。

ソース