キャッシュスタンピードを回避する方法

API7.ai

December 29, 2022

OpenResty (NGINX + Lua)

前回の記事では、shared dictlru cacheを使った高性能な最適化技術について学びました。しかし、今日の記事で取り上げるべき重要な問題が残されていました。それが「Cache Stampede(キャッシュスタンピード)」です。

キャッシュスタンピードとは何か?

次のようなシナリオを想像してみてください。

データソースはMySQLデータベースにあり、キャッシュデータはshared dictに保存され、タイムアウトは60秒です。キャッシュ内のデータが有効な60秒間、すべてのリクエストはMySQLではなくキャッシュからデータを取得します。しかし、60秒を過ぎるとキャッシュデータが失効します。もしその時点で大量の同時リクエストがある場合、キャッシュ内にデータが存在しないため、データソースのクエリ関数がトリガーされ、すべてのリクエストがMySQLデータベースに直接アクセスすることになります。これにより、データベースサーバーがブロックされたり、ダウンしたりする可能性があります。

この現象を「キャッシュスタンピード」と呼び、時にはDog-Pilingとも呼ばれます。前回のセクションで登場したキャッシュ関連のコードには、この問題に対する対応が含まれていませんでした。以下は、キャッシュスタンピードが発生する可能性のある疑似コードの例です。

local value = get_from_cache(key)
if not value then
    value = query_db(sql)
    set_to_cache(value, timeout = 60)
end
return value

この疑似コードは論理的には問題ないように見え、ユニットテストやエンドツーエンドテストではキャッシュスタンピードが発生しないかもしれません。しかし、長時間のストレステストを行うと問題が明らかになります。60秒ごとにデータベースへのクエリが定期的に急増します。ただし、ここでキャッシュの有効期限を長く設定すると、キャッシュスタンピードの問題が検出される可能性は低くなります。

どうやって回避するか?

いくつかの異なるケースに分けて議論しましょう。

1. キャッシュを積極的に更新する

上記の疑似コードでは、キャッシュは受動的に更新され、リクエストがあった際にキャッシュが失効している場合にのみデータベースに新しいデータをクエリします。そのため、キャッシュの更新方法を受動的から積極的に変更することで、キャッシュスタンピードの問題を回避できます。

OpenRestyでは、次のように実装できます。

まず、ngx.timer.everyを使用して、毎分実行されるタイマータスクを作成し、MySQLデータベースから最新のデータを取得して共有ディクショナリに保存します。

local function query_db(premature, sql)
    local value = query_db(sql)
    set_to_cache(value, timeout = 60)
end

local ok, err = ngx.timer.every(60, query_db, sql)

次に、リクエストを処理するコードのロジックでは、MySQLをクエリする部分を削除し、共有ディクショナリのキャッシュを取得する部分のみを残します。

local value = get_from_cache(key)
return value

上記の2つの疑似コードは、キャッシュスタンピードの問題を回避するのに役立ちます。ただし、このアプローチは完璧ではありません。各キャッシュは定期的なタスクに対応する必要があり(OpenRestyのタイマー数には上限があります)、キャッシュの有効期限とスケジュールタスクの周期が正確に対応する必要があります。この間に何かミスがあると、リクエストが空のデータを取得し続ける可能性があります。

そのため、実際のプロジェクトでは、通常はロックを使用してキャッシュスタンピードの問題を解決します。以下にいくつかの異なるロック方法を紹介します。必要に応じて選択してください。

2. lua-resty-lock

ロックを追加するとなると、難しく感じるかもしれません。重い操作であり、デッドロックが発生した場合に多くの例外を処理しなければならないと考えられるからです。

OpenRestyのlua-resty-lockライブラリを使用することで、この懸念を軽減できます。lua-resty-lockはOpenRestyのrestyライブラリで、共有ディクショナリに基づいて非ブロッキングなロックAPIを提供します。簡単な例を見てみましょう。

resty --shdict='locks 1m' -e 'local resty_lock = require "resty.lock"
                            local lock, err = resty_lock:new("locks")
                            local elapsed, err = lock:lock("my_key")
                            -- query db and update cache
                            local ok, err = lock:unlock()
                            ngx.say("unlock: ", ok)'

lua-resty-lockは共有ディクショナリを使用して実装されているため、まずshdictの名前とサイズを宣言し、newメソッドを使用して新しいlockオブジェクトを作成する必要があります。上記のコードスニペットでは、最初のパラメータであるshdictの名前のみを渡しています。newメソッドには2番目のパラメータがあり、ロックの有効期限やタイムアウト時間など多くのパラメータを指定できます。ここではデフォルト値を保持します。これらのパラメータは、デッドロックやその他の例外を回避するために使用されます。

次に、lockメソッドを呼び出してロックを取得しようとします。ロックの取得に成功した場合、同時刻にデータソースにアクセスしてデータを更新するリクエストは1つだけであることが保証されます。ただし、ロックが競合やタイムアウトなどで失敗した場合、古いキャッシュからデータを取得してリクエスト元に返します。これにより、前回のレッスンで紹介したget_stale APIが役立ちます。

local elapsed, err = lock:lock("my_key")
# elapsedがnilの場合、ロックが失敗したことを意味します。errの戻り値はtimeoutまたはlockedのいずれかです。
if not elapsed and err then
    dict:get_stale("my_key")
end

lockが成功した場合、データベースをクエリして結果をキャッシュに更新し、最後にunlockインターフェースを呼び出してロックを解放します。

lua-resty-lockget_staleを組み合わせることで、キャッシュスタンピードの問題を完璧に解決できます。lua-resty-lockのドキュメントには、これを処理するための非常に完全なコードが記載されています。興味があれば、こちらで確認できます。

さらに深く掘り下げて、lockインターフェースがどのようにロックを実装しているかを見てみましょう。興味深い実装に出会ったとき、そのソースコードを見ることはオープンソースの利点の1つです。

local ok, err = dict:add(key, true, exptime)
if ok then
    cdata.key_id = ref_obj(key)
    self.key = key
    return 0
end

共有ディクショナリの記事で述べたように、共有ディクショナリのすべてのAPIはアトミック操作であり、競合を心配する必要はありません。そのため、共有ディクショナリを使用してロックの状態をマークするのは良いアイデアです。

上記のlockの実装では、dict:addを使用してキーを設定しようとします。もし共有メモリ内にキーが存在しない場合、addは成功を返し、ロックが成功したことを示します。他の同時リクエストはdict:addの行のコードに到達すると失敗を返し、その後、コードは直接返すか、返されたerr情報に基づいて複数回再試行するかを選択できます。

3. lua-resty-shcache

上記のlua-resty-lockの実装では、ロック、アンロック、期限切れデータの取得、再試行、例外処理などを手動で処理する必要があり、まだかなり面倒です。

ここで、簡単なラッパーを紹介します:lua-resty-shcacheです。これはCloudflareのlua-restyライブラリで、共有ディクショナリと外部ストレージの上に1層のカプセル化を提供し、シリアライゼーションとデシリアライゼーションの追加機能を提供するため、上記の詳細を気にする必要はありません。

local shcache = require("shcache")

local my_cache_table = shcache:new(
        ngx.shared.cache_dict
        { external_lookup = lookup,
          encode = cmsgpack.pack,
          decode = cmsgpack.decode,
        },
        { positive_ttl = 10,           -- 良いデータを10秒間キャッシュ
          negative_ttl = 3,            -- 失敗したルックアップを3秒間キャッシュ
          name = 'my_cache',     -- デバッグ/レポート用に「名前付き」キャッシュ
        }
    )

local my_table, from_cache = my_cache_table:load(key)

このサンプルコードは公式の例から抽出され、すべての詳細が隠されています。このキャッシュカプセル化ライブラリは最良の選択肢ではありませんが、初心者にとっては良い学習資料です。次の記事では、他のより優れたかつ一般的に使用されるカプセル化をいくつか紹介します。

4. NGINXディレクティブ

OpenRestyのlua-restyライブラリを使用していない場合、NGINXの設定ディレクティブを使用してロックと期限切れデータの取得を行うこともできます:proxy_cache_lockproxy_cache_use_staleです。ただし、ここではNGINXディレクティブの使用は推奨しません。柔軟性が低く、Luaコードほど性能が良くないからです。

まとめ

キャッシュスタンピードは、以前から繰り返し述べてきた競合問題と同様に、コードレビューやテストでは検出が難しい問題です。これを解決する最良の方法は、コーディングを改善するか、カプセル化ライブラリを使用することです。

最後に1つ質問です:あなたが慣れ親しんでいる言語やプラットフォームでは、キャッシュスタンピードやそのような問題をどのように処理していますか?OpenRestyよりも良い方法はありますか?コメントでぜひ教えてください。