`lua-resty-*` のカプセル化により、開発者が多段階キャッシュから解放される
API7.ai
December 30, 2022
前回の2つの記事では、OpenRestyにおけるキャッシュとキャッシュスタンピード問題について学びましたが、これらは基本的な内容でした。実際のプロジェクト開発では、開発者は細部まで処理され隠蔽された、すぐに使えるライブラリを好み、直接ビジネスコードの開発に取り掛かることができます。
これは分業の利点であり、基本コンポーネントの開発者は柔軟なアーキテクチャ、良好なパフォーマンス、コードの安定性に焦点を当て、上位のビジネスロジックを気にする必要はありません。一方、アプリケーションエンジニアはビジネスの実装と迅速なイテレーションにより関心を持ち、下層の様々な技術的詳細に気を取られたくないと考えています。この間のギャップはラッパーライブラリによって埋めることができます。
OpenRestyにおけるキャッシュも同じ問題に直面しています。shared dict
とlru cache
は十分に安定して効率的ですが、扱うべき細部が多すぎます。有用なカプセル化なしでは、アプリケーション開発エンジニアにとっての「最後の1マイル」は困難を極めるでしょう。ここでコミュニティの重要性が浮き彫りになります。活発なコミュニティは積極的にギャップを見つけ、迅速に埋めるでしょう。
lua-resty-memcached-shdict
キャッシュのカプセル化に戻りましょう。lua-resty-memcached-shdict
は、OpenRestyの公式プロジェクトで、shared dict
を使用してmemcached
の上にカプセル化を行い、キャッシュスタンピードや期限切れデータなどの詳細を処理します。もしバックエンドのキャッシュデータがmemcached
に保存されている場合、このライブラリを試すことができます。
これはOpenRestyが開発した公式ライブラリですが、OpenRestyパッケージにはデフォルトで含まれていません。ローカルでテストしたい場合は、まずそのソースコードをローカルのOpenResty検索パスにダウンロードする必要があります。
このカプセル化ライブラリは、前回の記事で紹介した解決策と同じです。lua-resty-lock
を使用して相互排他を行い、キャッシュが失敗した場合に1つのリクエストだけがmemcached
にデータを取得しに行き、キャッシュストームを防ぎます。最新のデータが取得できない場合、古いデータがエンドポイントに返されます。
しかし、このlua-resty
ライブラリは、OpenRestyの公式プロジェクトであるにもかかわらず、完璧ではありません:
- まず、テストケースのカバレッジがなく、コード品質が一貫して保証されません。
- 次に、インターフェースパラメータが多すぎます。必須パラメータが11個、オプションパラメータが7個あります。
local memc_fetch, memc_store =
shdict_memc.gen_memc_methods{
tag = "my memcached server tag",
debug_logger = dlog,
warn_logger = warn,
error_logger = error_log,
locks_shdict_name = "some_lua_shared_dict_name",
shdict_set = meta_shdict_set,
shdict_get = meta_shdict_get,
disable_shdict = false, -- optional, default false
memc_host = "127.0.0.1",
memc_port = 11211,
memc_timeout = 200, -- in ms
memc_conn_pool_size = 5,
memc_fetch_retries = 2, -- optional, default 1
memc_fetch_retry_delay = 100, -- in ms, optional, default to 100 (ms)
memc_conn_max_idle_time = 10 * 1000, -- in ms, for in-pool connections,optional, default to nil
memc_store_retries = 2, -- optional, default to 1
memc_store_retry_delay = 100, -- in ms, optional, default to 100 (ms)
store_ttl = 1, -- in seconds, optional, default to 0 (i.e., never expires)
}
公開されているパラメータのほとんどは、「新しいmemcached
ハンドラを作成する」ことで簡略化できます。現在の方法では、すべてのパラメータをユーザーに投げつける形でカプセル化されており、ユーザーフレンドリーではありません。そのため、興味のある開発者がPRを提供して最適化することを歓迎します。
また、このカプセル化ライブラリのドキュメントでは、以下の方向性でさらなる最適化が提案されています。
lua-resty-lrucache
を使用して、Worker
レベルのキャッシュを増やし、server
レベルのshared dict
キャッシュだけに頼らないようにする。ngx.timer
を使用して非同期のキャッシュ更新操作を行う。
最初の方向性は非常に良い提案です。なぜなら、ワーカー内のキャッシュパフォーマンスはより良いからです。2番目の提案は、実際のシナリオに基づいて検討する必要があります。ただし、一般的には2番目の提案はお勧めしません。タイマーの数に制限があるだけでなく、ここでの更新ロジックが間違っていると、キャッシュが二度と更新されなくなる可能性があり、影響が大きいからです。
lua-resty-mlcache
次に、OpenRestyでよく使われるキャッシュのカプセル化を紹介します:lua-resty-mlcache
です。これはshared dict
とlua-resty-lrucache
を使用して、多層キャッシュメカニズムを実装しています。以下の2つのコード例で、このライブラリの使用方法を見てみましょう。
local mlcache = require "resty.mlcache"
local cache, err = mlcache.new("cache_name", "cache_dict", {
lru_size = 500, -- size of the L1 (Lua VM) cache
ttl = 3600, -- 1h ttl for hits
neg_ttl = 30, -- 30s ttl for misses
})
if not cache then
error("failed to create mlcache: " .. err)
end
最初のコードを見てみましょう。このコードの冒頭では、mlcache
ライブラリを導入し、初期化のためのパラメータを設定しています。通常、このコードはinit
フェーズに配置し、一度だけ実行します。
必須パラメータであるキャッシュ名と辞書名に加えて、3番目のパラメータは12のオプションを持つ辞書で、これらはオプションであり、入力されない場合はデフォルト値が使用されます。これはlua-resty-memcached-shdict
よりもはるかにエレガントです。もし私たちがインターフェースを設計するなら、mlcache
のアプローチを採用する方が良いでしょう。十分な柔軟性を保ちつつ、インターフェースを可能な限りシンプルに保つことが重要です。
以下は2番目のコードで、リクエストが処理される際のロジックコードです。
local function fetch_user(id)
return db:query_user(id)
end
local id = 123
local user , err = cache:get(id , nil , fetch_user , id)
if err then
ngx.log(ngx.ERR , "failed to fetch user: ", err)
return
end
if user then
print(user.id) -- 123
end
ご覧の通り、多層キャッシュは隠蔽されているため、mlcache
オブジェクトを使用してキャッシュを取得し、キャッシュが期限切れになった場合のコールバック関数を設定する必要があります。これにより、背後にある複雑なロジックを完全に隠すことができます。
このライブラリが内部的にどのように実装されているか気になるかもしれません。次に、このライブラリのアーキテクチャと実装をもう一度見てみましょう。以下の画像は、mlcache
の作者であるThibault CharbonnierがOpenResty Con 2018で行った講演のスライドです。
図からわかるように、mlcache
はデータを3つの層に分けています。L1
、L2
、L3
です。
L1
キャッシュはlua-resty-lrucache
で、各Worker
が独自のコピーを持ち、N
個のWorker
がある場合、N
個のデータコピーが存在するため、データの冗長性があります。単一のWorker
内でlrucache
を操作する場合、ロックが発生しないため、パフォーマンスが高く、第一レベルのキャッシュとして適しています。
L2
キャッシュはshared dict
です。すべてのWorker
が単一のキャッシュデータのコピーを共有し、L1
キャッシュがヒットしない場合にL2
キャッシュをクエリします。ngx.shared.DICT
はスピンロックを使用して操作の原子性を保証するAPIを提供しているため、ここでの競合状態を心配する必要はありません。
L3
は、L2
キャッシュもヒットしない場合で、コールバック関数を実行してデータソース(外部データベースなど)からデータを取得し、L2
にキャッシュする必要があります。ここでは、キャッシュストームを防ぐために、lua-resty-lock
を使用して、1つのWorker
だけがデータソースにデータを取得しに行くようにします。
リクエストの観点から見ると:
- まず、
Worker
内のL1キャッシュをクエリし、L1
がヒットした場合は直接返します。 L1
がヒットしないか、キャッシュが無効になった場合、Worker
間のL2
キャッシュをクエリします。L2
がヒットした場合、結果を返し、L1
にキャッシュします。L2
もヒットしないか、キャッシュが無効になった場合、コールバック関数を呼び出してデータソースからデータを取得し、L2
キャッシュに書き込みます。これがL3
データ層の機能です。
このプロセスからもわかるように、キャッシュの更新はエンドポイントリクエストによって受動的にトリガーされます。リクエストがキャッシュを取得できなくても、後続のリクエストが更新ロジックをトリガーできるため、キャッシュの安全性を最大化できます。
しかし、mlcache
は完璧に実装されているにもかかわらず、まだ1つの課題があります。それはデータのシリアライゼーションとデシリアライゼーションです。これはmlcache
の問題ではなく、lrucache
とshared dict
の違いによるものです。lrucache
では、table
を含む様々なLuaデータ型を保存できますが、shared dict
では文字列しか保存できません。
L1のlrucache
キャッシュはユーザーが触れるデータ層であり、string
、table
、cdata
など、あらゆる種類のデータをキャッシュしたいと考えています。問題は、L2
は文字列しか保存できず、データがL2
からL1
に昇格する際に、文字列からユーザーに直接提供できるデータ型に変換する必要があることです。
幸い、mlcache
はこの状況を考慮しており、new
とget
インターフェースにオプションの関数l1_serializer
を提供しています。これはL2
からL1
に昇格する際のデータ処理を特に扱うために設計されています。以下のサンプルコードは、私のテストケースセットから抽出したものです。
local mlcache = require "resty.mlcache"
local cache, err = mlcache.new("my_mlcache", "cache_shm", {
l1_serializer = function(i)
return i + 2
end,
})
local function callback()
return 123456
end
local data = assert(cache:get("number", nil, callback))
assert(data == 123458)
簡単に説明します。このケースでは、コールバック関数は数値123456
を返します。new
で設定したl1_serializer
関数は、渡された数値に2
を加えてからL1
キャッシュに設定し、123458
になります。このようなシリアライゼーション関数を使用することで、L1
とL2
の間でデータを変換する際に、より柔軟に対応できます。
まとめ
複数のキャッシュ層を使用することで、サーバーサイドのパフォーマンスを最大化し、多くの詳細が隠蔽されます。この時点で、安定して効率的なラッパーライブラリは私たちの労力を大幅に削減します。今日紹介した2つのラッパーライブラリが、キャッシュをよりよく理解するのに役立つことを願っています。
最後に、この質問を考えてみてください:キャッシュの共有辞書層は必要ですか?lrucache
だけを使用することは可能ですか?ぜひコメントを残して、あなたの意見を私と共有してください。また、この記事をより多くの人と共有して、コミュニケーションと進歩を共にすることも歓迎します。