Go Cachingベストプラクティス
Grace Collins
Solutions Engineer · Leapcell

Cachingは、APIアプリケーションを高速化するために不可欠であり、高いパフォーマンスが求められる場合は、設計段階でCachingを検討することが重要です。
設計段階でCachingを検討する際に最も重要なことは、どれくらいのメモリが必要になるかを予測することです。
まず、具体的にどのデータをキャッシュする必要があるのかを明確にする必要があります。
ユーザーベースが拡大し続けるアプリケーションでは、使用されるすべてのデータをキャッシュすることは現実的ではありません。
これは、アプリケーションのローカルメモリが単一マシンの物理リソースによって制限されるためです。データを無制限にキャッシュすると、最終的にはOOM(Out of Memory)につながり、アプリケーションが強制的に終了される可能性があります。
分散キャッシュを使用する場合でも、高価なハードウェアコストのために、トレードオフを迫られます。
もし物理リソースが無限にあれば、当然、すべてを最も高速な物理デバイスに保存するのが最善です。
しかし、現実のビジネスシナリオではこれが許されないため、データをホットデータとコールドデータに分類したり、コールドデータを適切にアーカイブおよび圧縮して、より経済的なメディアに保存したりする必要があります。
どのデータをローカルメモリに保存できるかを分析することが、効果的なローカルキャッシュを実装するための最初のステップです。
ステートフルアプリケーションとステートレスアプリケーションのバランス
データがローカルにアプリケーションに格納されると、アプリケーションは分散システムでステートレスではなくなります。
Webバックエンドアプリケーションを例にとると、バックエンドアプリケーションとして10個のPodをデプロイし、リクエストを処理するPodの1つにキャッシュを追加した場合、同じリクエストが別のPodに転送されると、対応するデータにアクセスできなくなります。
3つの解決策があります。
- Redisのような分散キャッシュを使用する
- 同じリクエストを同じPodに転送する
- すべてのPodに同じデータをキャッシュする
最初の方法は説明不要でしょう。これは基本的にストレージを集中化します。
2番目の方法は、ユーザーのuidなど、特定を識別できる情報を用いて特別な転送ロジックを実装する必要があり、対応できる利用場面が限られます。
3番目の方法は、より多くのストレージスペースを消費します。2番目の方法と比較すると、すべてのPodにデータを格納する必要があります。完全にステートレスとは言えませんが、キャッシュミスの可能性は2番目のアプローチよりも低くなります。これは、ゲートウェイが特定データを持つPodにリクエストを転送できなくても、他のPodがリクエストを正常に処理できるためです。
特効薬はありません。実際のシナリオに基づいて方法を選択してください。ただし、キャッシュがアプリケーションから離れるほど、アクセスに時間がかかります。
Goimはまた、メモリアライメントを通じてキャッシュヒットを最大化します。
CPUが計算を実行するとき、最初に必要なデータをL1で検索し、次にL2、そしてL3キャッシュで検索します。これらのキャッシュのいずれにもデータが見つからない場合、メインメモリからデータをフェッチする必要があります。 データが遠いほど、計算に時間がかかります。
削除ポリシー
キャッシュに対して厳密なメモリサイズ制御が必要な場合は、LRU(Least Recently Used)ポリシーを使用してメモリを管理できます。GoでのLRUキャッシュの実装を見てみましょう。
LRUキャッシュ
LRUキャッシュは、キャッシュサイズを制御し、使用頻度の低いアイテムを自動的に削除する必要があるシナリオに適しています。
たとえば、128個のキーと値のペアのみを格納したい場合、LRUキャッシュは制限に達するまで新しいエントリを追加し続けます。キャッシュされたアイテムにアクセスするか、新しい値が追加されるたびに、そのキーが先頭に移動され、削除されないようにします。
https://github.com/hashicorp/golang-lru は、GoでのLRUキャッシュの実装です。
LRUがどのように使用されるかを確認するために、テストの例を見てみましょう。
func TestLRU(t *testing.T) { l, _ := lru.New for i := 0; i < 256; i++ { l.Add(i, i+1) } // Value has not been evicted value, ok := l.Get(200) assert.Equal(t, true, ok) assert.Equal(t, 201, value.(int)) // Value has already been evicted value, ok = l.Get(1) assert.Equal(t, false, ok) assert.Equal(t, nil, value) }
ご覧のとおり、キー200は削除されていないため、引き続きアクセスできます。
ただし、キー1はキャッシュサイズ制限の128を超えているため、すでに削除されており、これ以上取得できません。
これは、格納するデータの量が多すぎる場合に役立ちます。最も頻繁に使用されるデータは常に先頭に移動され、キャッシュヒット率が向上します。
オープンソースパッケージの内部実装では、リンクリストを使用してキャッシュされたすべての要素を管理します。
Add
が呼び出されるたびに、キーがすでに存在する場合は、先頭に移動されます。
func (l *LruList[K, V]) move(e, at *Entry[K, V]) { if e == at { return } e.prev.next = e.next e.next.prev = e.prev e.prev = at e.next = at.next e.prev.next = e e.next.prev = e }
キーが存在しない場合は、insert
メソッドを使用して挿入されます。
func (l *LruList[K, V]) insert(e, at *Entry[K, V]) *Entry[K, V] { e.prev = at e.next = at.next e.prev.next = e e.next.prev = e e.list = l l.len++ return e }
キャッシュのサイズが超過した場合、リストの末尾にある要素(最も古く、最も使用されていない要素)が削除されます。
func (c *LRU[K, V]) removeOldest() { if ent := c.evictList.Back(); ent != nil { c.removeElement(ent) } } func (c *LRU[K, V]) removeElement(e *internal.Entry[K, V]) { c.evictList.Remove(e) delete(c.items, e.Key) // Callback after deleting key if c.onEvict != nil { c.onEvict(e.Key, e.Value) } } func (l *LruList[K, V]) Remove(e *Entry[K, V]) V { e.prev.next = e.next e.next.prev = e.prev // Prevent memory leak, set to nil e.next = nil e.prev = nil e.list = nil l.len-- return e.Value }
キャッシュの更新
分散システムでのタイムリーなキャッシュの更新は、データの一貫性の問題を軽減できます。
異なる方法が異なるシナリオに適しています。
キャッシュされたデータをフェッチするときは、さまざまな状況があります。たとえば、ユーザーに関係のない人気のあるランキングリストの場合、すべてのPodのローカルキャッシュでこのデータを維持する必要があります。書き込みまたは更新が発生した場合、すべてのPodにキャッシュを更新するように通知する必要があります。
データが各ユーザーに固有である場合は、固定されたPodでリクエストを処理し、ユーザー識別子(uid)を使用してリクエストを同じPodにルーティングすることをお勧めします。これにより、異なるPod間で複数のコピーを保存することを回避し、メモリ消費を削減します。
ほとんどの場合、アプリケーションをステートレス化したいと考えているため、キャッシュされたデータのこの部分はRedisに保存されます。
主な分散キャッシュの更新戦略として、cache-aside(バイパス)、write-through、write-backの3つがあります。
Cache-Aside(バイパス)戦略
cache-aside (バイパス) 戦略は、最も頻繁に使用する戦略の1つです。データを更新するときはまずキャッシュを削除し、データベースに書き込みます。次にデータを読み取り、キャッシュが見つからない場合は、データベースから取得してキャッシュを更新します。
この戦略は、読み取りQPSが非常に高い場合に不整合を引き起こす可能性があります。つまり、キャッシュが削除された後、データベースが更新される前に、読み取りリクエストが届き、古い値がキャッシュに再ロードされる可能性があるため、後続の読み取りでは引き続きデータベースから古い値を取得します。
実際にこれが発生する可能性は低いですが、シナリオを慎重に評価する必要があります。このような不整合がシステムにとって壊滅的な場合は、この戦略を使用できません。
このような状況が許容できるものの、不整合を最小限に抑えたい場合は、キャッシュの有効期限を設定できます。書き込み操作が発生しない場合、キャッシュは積極的に期限切れになり、キャッシュデータが更新されます。
Write-ThroughおよびWrite-Back戦略
Write-throughおよびwrite-back戦略は、どちらも最初にキャッシュを更新してからデータベースに書き込みます。違いは、更新が個別に行われるか、バッチで行われるかにあります。
これらの戦略の重大な欠点は、データが簡単に失われる可能性があることです。Redisはディスクへの書き込みなどの永続化戦略をサポートしていますが、QPSの高いアプリケーションでは、サーバーのクラッシュにより1秒のデータが失われただけでも、膨大な量になる可能性があります。したがって、ビジネスと実際のシナリオに基づいて決定する必要があります。
Redisではまだパフォーマンス要件を満たすことができない場合は、キャッシュされたコンテンツをアプリケーション変数(ローカルキャッシュ)に直接格納する必要があります。そのため、ユーザーリクエストはネットワークリクエストなしにメモリから直接配信されます。
以下では、分散シナリオでのローカルキャッシュの更新戦略について説明します。
アクティブ通知更新 (Cache-Aside戦略と同様)
分散システムでは、ETCDブロードキャストを使用して、次のクエリがデータを再ロードするのを待たずに、キャッシュの更新データを迅速に伝播できます。
ただし、このアプローチには問題があります。たとえば、時刻T1に、キャッシュ更新通知が送信されますが、ダウンストリームサービスはまだ更新を完了していません。時刻T2 = T1 + 1秒に、別のキャッシュ更新信号が送信されますが、T1での更新はまだ完了していません。
これにより、更新速度の違いにより、T2の新しい値がT1の古い値で上書きされる可能性があります。
これは、単調増加するバージョン番号を追加することで解決できます。データのT2バージョンが有効になると、T1のバージョンではキャッシュを更新できなくなるため、新しい値が古い値で上書きされるのを防ぐことができます。
アクティブ通知を使用すると、関連するキーを指定して、特定のキャッシュされたアイテムのみを更新できます。これにより、キャッシュされたすべてのデータを一度に更新することによって発生する高い負荷を回避できます。
この更新戦略は、分散キャッシュではなくローカルキャッシュを更新する点を除いて、cache-aside戦略に似ています。
キャッシュの有効期限を待つ
このアプローチは、厳密なデータ整合性が不要な場合に適しています。ローカルキャッシュの場合、更新をすべてのPodに伝播する場合は、メンテナンス戦略がより複雑になります。
Goのオープンソースパッケージhttps://github.com/patrickmn/go-cacheを使用して、独自のロジックを実装せずに、メモリ内のキャッシュの有効期限を処理できます。
go-cacheがローカルキャッシュをどのように実装しているか見てみましょう。
Go Cache
https://github.com/patrickmn/go-cacheは、Go用のオープンソースのローカルキャッシュパッケージです。
内部では、データをマップに格納します。
type Cache struct { *cache } type cache struct { defaultExpiration time.Duration items map[string]Item mu sync.RWMutex onEvicted func(string, interface{}) janitor *janitor }
items
フィールドは、関連するすべてのデータを格納します。
Set
またはGet
を実行するたびに、items
マップで操作します。
janitor
は、指定された間隔で期限切れのキーを定期的に削除します。
func (j *janitor) Run(c *cache) { ticker := time.NewTicker(j.Interval) for { select { case <-ticker.C: c.DeleteExpired() case <-j.stop: ticker.Stop() return } } }
Tickerを使用して信号をトリガーし、定期的にDeleteExpired
メソッドを呼び出して期限切れのキーを削除します。
func (c *cache) DeleteExpired() { // Key-value pairs to be evicted var evictedItems []keyAndValue now := time.Now().UnixNano() c.mu.Lock() // Find and delete expired keys for k, v := range c.items { if v.Expiration > 0 && now > v.Expiration { ov, evicted := c.delete(k) if evicted { evictedItems = append(evictedItems, keyAndValue{k, ov}) } } } c.mu.Unlock() // Callback after eviction, if any for _, v := range evictedItems { c.onEvicted(v.key, v.value) } }
コードからわかるように、キャッシュの有効期限は定期的な削除に依存しています。
では、期限切れになっているがまだ削除されていないキーを取得しようとするとどうなるでしょうか?
データをフェッチするときにも、キャッシュはキーが期限切れになっているかどうかを確認します。
func (c *cache) Get(k string) (interface{}, bool) { c.mu.RLock() // Return directly if not found item, found := c.items[k] if !found { c.mu.RUnlock() return nil, false } // If the item has expired, return nil and wait for periodic deletion if item.Expiration > 0 { if time.Now().UnixNano() > item.Expiration { c.mu.RUnlock() return nil, false } } c.mu.RUnlock() return item.Object, true }
値を取得するたびに有効期限がチェックされ、期限切れのキーと値のペアが返されないことを保証します。
キャッシュウォーミング
起動時にデータをプリロードする方法、起動前に初期化が完了するのを待つかどうか、セグメント化された起動を許可するかどうか、および同時ロードがミドルウェアに圧力をかけるかどうかは、起動時のキャッシュウォーミングで検討すべき問題です。
プリロードプロセスを開始する前にすべての初期化が完了するのを待つ場合、全体的なリソース消費量が高い場合は、初期化とプリロードを並行して実行できます。ただし、プリロード中にリソースが利用できなくなることを避けるために、特定のキーコンポーネント (データベース接続、ネットワークサービスなど) がすでに利用可能であることを確認する必要があります。
ロードが完了する前にリクエストが到着した場合は、通常どおり応答できるように適切なフォールバック戦略が必要です。
セグメント化されたロードの利点は、同時実行によって初期化時間を短縮できることですが、同時プリロードは効率を向上させると同時に、ミドルウェア (キャッシュサーバー、データベースなど) にも圧力をかけます。
コーディング中には、同時処理能力を評価し、合理的な同時実行制限を設定する必要があります。レート制限メカニズムを適用すると、同時実行の圧力を軽減し、ミドルウェアの過負荷を防ぐことができます。
Goでは、チャネルを使用して同時実行を制限することもできます。
キャッシュウォーミングは、実際のプロダクションシナリオで重要な役割を果たします。デプロイメント中、アプリケーションのローカルキャッシュは再起動後に消えます。ローリングアップデートの場合、少なくとも1つのPodがオリジンからデータをフェッチする必要があります。QPSが非常に高い場合、その単一のPodのピークQPSがデータベースを圧倒し、連鎖的な障害 (雪崩効果) を引き起こす可能性があります。
この状況に対処する方法は2つあります。1つは、トラフィックのピーク時にバージョンアップグレードを回避し、代わりにトラフィックの少ない時間にスケジュールすることです。これは、モニタリングダッシュボードから簡単に識別できます。
もう1つの方法は、起動時にデータをプリロードし、ロードが完了した後にのみサービスを提供することです。ただし、これにより起動時間が長くなる可能性があり、障害のあるリリースが原因でロールバックが必要になった場合に、すばやくロールバックすることが難しくなります。
どちらのアプローチにも長所と短所があります。実際のシナリオでは、特定のニーズに応じて選択する必要があります。最も重要なことは、特殊なケースへの依存を最小限に抑えることです。リリース中に依存関係が多いほど、問題が発生する可能性が高くなります。
We are Leapcell, your top choice for hosting Go projects.
Leapcell is the Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis:
Multi-Language Support
- Develop with Node.js, Python, Go, or Rust.
Deploy unlimited projects for free
- pay only for usage — no requests, no charges.
Unbeatable Cost Efficiency
- Pay-as-you-go with no idle charges.
- Example: $25 supports 6.94M requests at a 60ms average response time.
Streamlined Developer Experience
- Intuitive UI for effortless setup.
- Fully automated CI/CD pipelines and GitOps integration.
- Real-time metrics and logging for actionable insights.
Effortless Scalability and High Performance
- Auto-scaling to handle high concurrency with ease.
- Zero operational overhead — just focus on building.
Explore more in the Documentation!
Follow us on X: @LeapcellHQ