キャッシュはアプリケーション API の高速化には不可欠であり、設計初期段階で高いパフォーマンス要件がある場合は欠かせません。
設計段階でキャッシュを使用する必要がある場合、最も重要なのはどれだけのメモリを使用するかを見積もることです。
まず、自分がキャッシュする必要があるデータの内容を明確にする必要があります。
ユーザー数が増え続けるアプリケーションでは、すべての使用データをキャッシュするのは適切ではありません。
なぜなら、アプリケーションのローカルメモリは単一マシンの物理リソースに制約されており、無制限にデータをキャッシュすると最終的には OOM(メモリ不足)が発生し、アプリケーションが強制終了することになります。
分散型キャッシュの場合も、高価なハードウェアコストによってトレードオフが必要になります。
もし物理リソースに制限がなければ、すべてのデータを最速の物理デバイスに保存するのが当然最良です。
しかし、現実のビジネスシーンではそれは許されません。だからこそ、データをホットデータとコールドデータに分け、場合によってはコールドデータを適切にアーカイブ・圧縮して、より安価なメディアに保存する必要があります。
どのデータをローカルメモリに保存できるかを分析するのが、ローカルキャッシュをうまく設計する第一歩です。
ステートレス/ステートフルアプリケーションのバランス
データをローカルに保存するアプリケーションの場合、分散システム下ではアプリケーションはステートレスではなくなります。
Web バックエンドアプリケーションを例にとると、10 個の Pod をバックエンドアプリケーションとしてデプロイした場合、リクエストを処理する 1 つの Pod でキャッシュを追加すると、同じリクエストが別の Pod に転送された時に、該当データを取得できなくなります。
解決方法は 3 つあります:
- 分散型キャッシュ(Redis)を使用する
- 同じリクエストを同じ Pod に転送する
- すべての Pod で同じデータをキャッシュする
1 つ目は説明不要で、ストレージが集中化されるイメージです。
2 つ目は、特定のユーザー uid などの識別情報を用いて、特定のルーティングロジックを実装する必要があり、実際のシーンに依存します。
3 つ目は、より多くのストレージスペースを消費します。2 つ目に比べると、各 Pod にデータを保存するため、完全なステートレスとは言えませんが、2 つ目の方式に比べてキャッシュのミス率が低くなります。なぜなら、ゲートウェイの問題で特定データを持つ Pod にリクエストを転送できなくても、他の Pod でもリクエストを正常に処理できるからです。
どの方法も「銀の弾丸」ではありません。実際のシーンに応じて選択してください。ただし、キャッシュが遠くに行けば行くほど、時間もかかります。
Goim もメモリアラインメント方式により、できる限りキャッシュのヒットを高めています。
CPU が計算を実行する際、まず L1 で必要なデータを探し、次に L2、L3 と探し、最終的にどのキャッシュにもなければメインメモリに取りに行きます。距離が遠くなるほど、計算時間も長くなります。
削除(淘汰)ポリシー
キャッシュのメモリサイズを厳密にコントロールしたい場合、LRU方式でメモリ管理が可能です。以下、Go での LRU キャッシュ実装について見てみましょう。
LRU キャッシュ
キャッシュサイズをコントロールし、あまり使われないキャッシュを自動的に淘汰したい場合に適しています。
例えば 128 個の key-value だけを保存したい場合、LRU では保存が満たされていない場合はどんどん増え、中間でアクセスされたり新しい値が追加された場合は key が先頭に再配置され、淘汰を避けます。
https://github.com/hashicorp/golang-lru は、Go で実装された LRU キャッシュです。
Test コードで LRU の使い方を見てみましょう。
func TestLRU(t *testing.T) {
l, _ := lru.New
for i := 0; i < 256; i++ {
l.Add(i, i+1)
}
// 値が淘汰されていない
value, ok := l.Get(200)
assert.Equal(t, true, ok)
assert.Equal(t, 201, value.(int))
// 値が淘汰された
value, ok = l.Get(1)
assert.Equal(t, false, ok)
assert.Equal(t, nil, value)
}
200 という key はまだ淘汰されていないので取得できます。
1 の key は size=128 の制限を超えたため淘汰され、取得できません。
このような状況は、大量のデータを保存する際、よく使うデータが常に先頭に移動するため、キャッシュヒット率を向上させます。
オープンソースパッケージの内部実装は、すべてのキャッシュ要素を**リスト(linked list)**で管理しています。
Add 時、既に key が存在する場合は key を先頭に移動します。
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
}
key が存在しない場合は、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)
// 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
// メモリリーク防止のためnilに
e.next = nil
e.prev = nil
e.list = nil
l.len--
return e.Value
}
キャッシュの更新
分散システムでキャッシュを適時更新することで、データ不整合を減らせます。
方法によって適用シーンが異なります。
キャッシュデータ取得にはさまざまな状況があります。例えば、人気ランキングのようなものはユーザーとは無関係であり、複数の Pod がある場合は各ローカルキャッシュを保守し、書き込み更新時はすべての Pod に更新通知が必要です。
ユーザー固有データの場合は、できれば固定の Pod にユーザー識別(uid)でリクエストを安定的に振り分け、複数 Pod でデータを重複して持たないようにし、メモリ消費も削減できます。
多くの場合、アプリケーションをできる限りステートレスにしたいので、こうしたキャッシュデータは Redis に持たせます。
分散キャッシュの更新戦略は主に 3 つです:
- バイパス更新(サイド・バイ・サイド更新)
- キャッシュに書き込んだ後データベースにも書き込む
- 書き戻し戦略
バイパス更新戦略
最も一般的に使われている方法で、データ更新時にまずキャッシュを削除し、その後データベースに書き込みます。以降の読み取り時にキャッシュがなければデータベースから読み出してキャッシュを更新します。
この戦略は、読み取り QPS が非常に高い場合、一時的な不一致が発生します。なぜならキャッシュ削除後にまだデータベースが更新されていないうちに読み込みリクエストが来ると、旧値が再びキャッシュに書き込まれ、結果的にデータベースから取得したのも旧値になるためです。
実際にこのような状況が発生する確率は低いですが、システムにとって致命的なリスクがある場合はこの戦略は避けるべきです。
もしこの状況を許容でき、かつ不一致時間をできるだけ減らしたい場合、キャッシュに有効期限(TTL)を設けて、書き込み操作がなくてもキャッシュが自動的に期限切れし、データをリフレッシュできるようにします。
キャッシュ書き込み後データベース書き込み・書き戻し戦略
この 2 つの戦略はどちらも、まずキャッシュを更新してからデータベースを更新するという点で共通ですが、1 つずつ書き込むかバッチで書き戻すかという違いがあります。
明らかな欠点は、データ消失のリスクが高いことです。Redis などもディスク書き戻し機能がありますが、高 QPS なアプリケーションでは、マシン障害時に 1 秒間のデータが失われるだけでも莫大な量となることがあり、ビジネスや実際のシーンに合わせて採用可否を判断する必要があります。
もし Redis ですらパフォーマンス要件を満たせない場合は、キャッシュ内容をアプリケーションの変数(ローカルキャッシュ)に直接保存し、ユーザーアクセス時に即座に返すことでネットワークリクエストを省略できます。
主動的な通知による更新(バイパス更新戦略と同様)
分散環境下では、ETCD のブロードキャストを利用してキャッシュデータを迅速に広めることができ、次回の問い合わせまで待たずに済みます。
しかし、ここで 1 つ問題が発生します。例えば、T1 の時点でキャッシュ更新の通知が発生した場合、下流サービスはまだ完全に更新を終えていない可能性があります。その直後、T2=T1+1 秒で再度キャッシュ更新シグナルが発生し、T1 の時点の更新が完了していません。
この場合、更新スピードの違いにより、T2 で新しく更新した値が、T1 で古い値に上書きされるリスクが生じます。
この問題は、**単調増加のバージョン番号(version)**を追加することで解決できます。T2 バージョンのデータが有効になった後は、T1 の古いバージョンが T2 のキャッシュを上書きすることはできません。これにより、古い値で新しい値が消される問題を回避できます。
通知時には、該当する key 値を指定して、特定のキャッシュだけを更新できます。これにより、すべてのキャッシュデータを更新して負荷が高くなるのを避けられます。
この更新戦略は、バイパス更新と似ていますが、従来の「分散キャッシュの更新」が「ローカルキャッシュの更新」になる点が異なります。
キャッシュの自動期限切れを待つ
この方法は、データの一貫性要求があまり高くない場合に適しています。ローカルキャッシュを全 Pod に拡散する場合、その戦略の保守コストも高くなります。
Go のオープンソースパッケージ(例:https://github.com/patrickmn/go-cache )を利用することで、メモリ上の有効期限管理が簡単にできます。自分で実装する必要はありません。
Go Cache
https://github.com/patrickmn/go-cache は、Go のオープンソースなローカルキャッシュパッケージです。
内部的にはmapでデータを保存しています。
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 は、指定した時間間隔で有効期限が切れた key を削除する役割を担っています。具体的な間隔は設定可能です。
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 メソッドで期限切れの key を削除します。
func (c *cache) DeleteExpired() {
// 淘汰されたkv値
var evictedItems []keyAndValue
now := time.Now().UnixNano()
c.mu.Lock()
// 既に期限切れのkeyを探して削除
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()
// 淘汰後のコールバックがあれば実行
for _, v := range evictedItems {
c.onEvicted(v.key, v.value)
}
}
コードから分かるように、キャッシュの有効期限切れはループ処理による削除で実現されています。
もし取得しようとした key が既に期限切れだが、まだ Delete されていない場合はどうなるでしょう?
Get 時にも、key が期限切れかどうかチェックします。
func (c *cache) Get(k string) (interface{}, bool) {
c.mu.RLock()
// 見つからなければ即return
item, found := c.items[k]
if !found {
c.mu.RUnlock()
return nil, false
}
// 期限切れならnilを返し、ループで削除を待つ
if item.Expiration > 0 {
if time.Now().UnixNano() > item.Expiration {
c.mu.RUnlock()
return nil, false
}
}
c.mu.RUnlock()
return item.Object, true
}
つまり、値を取得するたびに必ず期限判定が行われるので、期限切れの kv が取得される心配はありません。
キャッシュ予熱
起動時にどのようにプリロード(事前ロード)するか、初期化が完了してから起動すべきか、段階的に起動できるか、並列処理によってミドルウェアへ負荷がかからないか等は、起動時のキャッシュ予熱で検討すべき問題です。
アプリケーションの起動時、全ての初期化が完了してからプリロード処理を開始すると、システム全体のリソース消費が大きくなる場合があります。この場合、初期化とプリロードを並行して行うこともできますが、データベース接続やネットワークサービスなど、重要なコンポーネントがすでに準備できていることを確認する必要があります。そうしないと、プリロード中にリソースが使えない状況になってしまうかもしれません。
もし、データの全プリロードが完了する前にすでにリクエストがアプリケーションに届いた場合は、正常に応答できるようなフォールバック戦略が必要です。
分割ロード(セグメントロード)のメリットは、並列処理で初期化時間を短縮できることです。しかし、並列でのロードは予熱効率を高める一方で、ミドルウェア(キャッシュサーバーやデータベースなど)に一時的な高負荷を与える可能性もあります。
コーディングの際には、システムの並列処理能力を評価し、適切な並列数制限を設定すべきです。レートリミット(制限)機構を採用することで、ミドルウェアの過負荷を回避できます。
Go 言語の場合、channelを用いて並列数の制限を実現することも可能です。
キャッシュ予熱は、実際のプロダクション現場で非常に重要な役割を果たします。デプロイ(リリース)中、アプリケーションのローカルキャッシュは再起動により消えてしまいます。ローリングアップデートの際、最低 1 つの Pod がバックエンドまでリクエストを取りに行く必要があり、QPS(リクエスト数)が非常に多い場合、その 1Pod のピーク QPS だけでデータベースがダウンしてしまい、雪崩(カスケード障害)が発生する可能性もあります。
このような場合の対処方法は 2 つあります。
1 つは、トラフィックのピーク時ではなく、オフピーク(深夜など)にバージョンアップを行うことです。これはモニタリングで簡単に判断できます。
もう 1 つは、起動時に事前に必要なデータを全てプリロードしてからサービスを外部に公開する方法です。しかしこの場合、もしリリースしたバージョンに問題がありロールバックが必要になった場合、サービスの起動時間が長くなり、迅速なロールバックが難しくなるリスクがあります。
どちらの方法にも長所・短所があり、実際の状況と要件に合わせて選択する必要があります。しかし最も重要なのは、特殊な状況への依存をできる限り減らすことです。依存が多いほど、リリース時にトラブルが発生しやすくなります。
私たちはLeapcell、Goプロジェクトのホスティングの最適解です。
Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:
複数言語サポート
- Node.js、Python、Go、Rustで開発できます。
無制限のプロジェクトデプロイ
- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。
比類のないコスト効率
- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。
洗練された開発者体験
- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。
簡単なスケーラビリティと高パフォーマンス
- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。
Xでフォローする:@LeapcellHQ