こんにちは、Gunosy Tech Labの片木(@jkatagi)です(普段はGunosyデータ分析ブログの方に生息しています)。 本記事では社内のとあるAPI(Go製)の改修をしていたときに遭遇したインメモリキャッシュの落とし穴について共有します。 普段からGoを書いている人にとっては当たり前のことかもしれませんが、アンチパターンとして共有できれば幸いです。
要約
長くなるので最初に要約しますと
- 当該APIでインメモリキャッシュとして構造体のスライスを格納していた
- キャッシュしている値を変えてしまうとキャッシュ元も変わってしまう
という事態が発生しました。 ですのでインメモリキャッシュを使う時は対象の値が変更されないことを開発者が保証する、あるいはインメモリキャッシュを取り扱っているレイヤーで値が変更されないことを保証する必要があります。
簡単な再現コードはこちらです(当該APIではpatrickmn/go-cacheを使っているのでそれを元に再現) play.golang.org
上記コードを実行してみると分かるように、go-cacheでは内部的にmapを保持しています。 一方でmapはポインタのように振る舞うため、元を置き換えてしまうと同様にキャッシュしていたものも置き換わってしまいます。
すなわち上記コードはgo-cacheを使わずに以下のように書き換えることができます play.golang.org
以下では詳細について述べます。
APIのアーキテクチャについて
該当APIは多層キャッシュを採用しており、ローカルのインメモリキャッシュと、リモートのマネージドキャッシュの2層構成でキャッシュしています。 多層キャッシュについては過去にブログ記事がありますので、そちらをご参照ください:
この図のように、APIにアクセスした時に①インメモリキャッシュにアクセスし、キャッシュにない場合は②リモートキャッシュにアクセスし、それでもない場合は③データストアにアクセスする、という流れになっています。
なにが起きたのか
APIにリクエストを送った時に、時々正しい結果が返ってきて、時々正しくない結果が返ってくるという現象が発生しました。 これはリモートキャッシュでキャッシュヒットする時は正しい結果が返ってきて、インメモリキャッシュでキャッシュヒットする時は正しくない結果が返ってくる、ということが裏で起きていました。
なぜ起こったのか
直接の原因はインメモリキャッシュに書き込んだ後に、別の箇所でインメモリキャッシュが参照している構造体のスライスを置き換えてしまっていたからでした。 (要約でも述べましたが)インメモリキャッシュとして使用しているgo-cacheでは内部的にmapを保持しており、mapはポインタのように振る舞います。 そのため元を置き換えてしまうと同様にキャッシュしていたものも置き換わってしまいます。
単体テストでは気づけなかったのか
ドメイン層のテストではキャッシュをモックしてたため、インフラストラクチャ層の問題に気づけませんでした。 またインフラストラクチャ層ではインメモリキャッシュのテストを実施していましたが、以下2点のテストをカバーできていませんでした:
- キャッシュの参照元を置き換えても置き換え前のものが返ってくるか
- Getしたものを置き換えても置き換え前のものがGetできるか
すなわち、インフラストラクチャ層でキャッシュの値が変更されないことを保証する場合は以下のようなテストが必要でした(簡略化のため一つのソースコードにまとめています)。
なお、以下では構造体のスライス([]Hoge
)を使用していますが、構造体のポインタのスライス([]*Hoge
)でも同様の事象は発生します。
package localcache import ( "context" "time" "fmt" "github.com/patrickmn/go-cache" "github.com/example/example-api/domian/repository/hoge" "github.com/stretchr/testify/assert" ) type Hoge struct { ID uint64 Name string } type HogeCacheRepository interface { GetHoges(ctx context.Context)([]Hoge, error) SetHoges(ctx context.Context, hoges []Hoge) error } type hogeCacheRepository struct { cache *cache.Cache } func NewHogeCacheRepository() HogeCacheRepository { return &hogeCacheRepository{ cache: cache.New(60*time.Second, 60*time.Second) // 例なので適当な時間 } } func TestHogeCacheRepository_GetHoges(t *testing.T) { r := NewHogeCacheRepository() expected := []entity.Hoge{ {ID: 1, Name: "piyo"}, } t.Run(fmt.Sprint("1. キャッシュの参照元を置き換えても置き換え前のものが返ってくるか"), func(t *testing.T) { hoges := []entity.Hoge{ {ID: 1, Name: "piyo"}, } err := r.SetHoges(context.Background(), hoges) if err != nil { t.Fatal(err) } // 参照元を書き換える for i := range hoges { hoges[i].Name = "replaced" } actual, _ := r.GetHoges(context.Background()) assert.Equal(t, expected, actual) }) t.Run(fmt.Sprint("2. Getしたものを置き換えても置き換え前のものがGetできるか"), func(t *testing.T) { hoges := []Hoge{ {ID: 1, Name "piyo"}, } err := r.SetHoges(context.Background(), hoges) if err != nil { t.Fatal(err) } got, _ := r.GetHoges(context.Background()) // Getしたものを書き換える for i := range got { got[i].Name = "replaced" } // 再度Get actual, _ := r.GetHoges(context.Background()) assert.Equal(t, expected, actual) }) }
どう解決したか
解決策は2つ考えられます*1:
- インメモリキャッシュが対象とする値が変更されないことを開発者が保証する
- インメモリキャッシュを取り扱っているレイヤーで値が変更されないことを保証する
aの場合はキャッシュの対象となる値に対し、常に値が変更されないように開発者が注意しなければなりません。 そのため今回はbの方法を取り、開発者の負担にならないようにしました。
package localcache import ( "context" "errors" "fmt" "github.com/patrickmn/go-cache" ) // 例示のため再掲 type Hoge struct { ID uint64 Name string } const ( cacheKey = "piyo" // 本来は可変だが例示のため適当な文字列に ErrCacheNotFound = errors.New("not found") ) func (c *hogeCacheRepository) GetHoges(ctx context.Context) ([]Hoges, error) { if res, ok := c.cache.Get(cacheKey); ok { hoges := res.([]Hoge) // コピーを作成し、コピーを返す rets := make([]Hoge, 0, len(hoges)) for i := range hoges { hoges = append(hoges, hoges[i]) } return hoges, nil } return []Hoge{}, ErrCacheNotFound } func (c *hogeCacheRepository) SetHoges(ctx context.Context, hoges []Hoge) error { // 新たに構造体を定義し、それをsetする sets := make([]Hoge, 0, len(hoges)) for i := range hoges { sets = append(sets, hoges[i]) } c.cache.Set(cacheKey, sets, time.Second*time.Duration(60)) return nil }
こうすることにより、 インメモリキャッシュを取り扱っているレイヤーで値が変更されないことを保証することができました。
おわりに
今回はGoでインメモリキャッシュを取り扱うときの落とし穴について紹介しました。 実際にこの現象に遭遇した時、リモートキャッシュが正しく機能していると正常な結果が返ってきてしまうため、なかなかバグの原因に気がつけませんでした。 原因に気づいてくださった上司の小澤さんには感謝です。
*1:インメモリキャッシュを使わないという選択肢もあるので3つですね