`lua-resty-*` のカプセル化により、開発者が多段階キャッシュから解放される

API7.ai

December 30, 2022

OpenResty (NGINX + Lua)

前回の2つの記事では、OpenRestyにおけるキャッシュとキャッシュスタンピード問題について学びましたが、これらは基本的な内容でした。実際のプロジェクト開発では、開発者は細部まで処理され隠蔽された、すぐに使えるライブラリを好み、直接ビジネスコードの開発に取り掛かることができます。

これは分業の利点であり、基本コンポーネントの開発者は柔軟なアーキテクチャ、良好なパフォーマンス、コードの安定性に焦点を当て、上位のビジネスロジックを気にする必要はありません。一方、アプリケーションエンジニアはビジネスの実装と迅速なイテレーションにより関心を持ち、下層の様々な技術的詳細に気を取られたくないと考えています。この間のギャップはラッパーライブラリによって埋めることができます。

OpenRestyにおけるキャッシュも同じ問題に直面しています。shared dictlru 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の公式プロジェクトであるにもかかわらず、完璧ではありません:

  1. まず、テストケースのカバレッジがなく、コード品質が一貫して保証されません。
  2. 次に、インターフェースパラメータが多すぎます。必須パラメータが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を提供して最適化することを歓迎します。

また、このカプセル化ライブラリのドキュメントでは、以下の方向性でさらなる最適化が提案されています。

  1. lua-resty-lrucacheを使用して、Workerレベルのキャッシュを増やし、serverレベルのshared dictキャッシュだけに頼らないようにする。
  2. ngx.timerを使用して非同期のキャッシュ更新操作を行う。

最初の方向性は非常に良い提案です。なぜなら、ワーカー内のキャッシュパフォーマンスはより良いからです。2番目の提案は、実際のシナリオに基づいて検討する必要があります。ただし、一般的には2番目の提案はお勧めしません。タイマーの数に制限があるだけでなく、ここでの更新ロジックが間違っていると、キャッシュが二度と更新されなくなる可能性があり、影響が大きいからです。

lua-resty-mlcache

次に、OpenRestyでよく使われるキャッシュのカプセル化を紹介します:lua-resty-mlcacheです。これはshared dictlua-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 architecture

図からわかるように、mlcacheはデータを3つの層に分けています。L1L2L3です。

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の問題ではなく、lrucacheshared dictの違いによるものです。lrucacheでは、tableを含む様々なLuaデータ型を保存できますが、shared dictでは文字列しか保存できません。

L1のlrucacheキャッシュはユーザーが触れるデータ層であり、stringtablecdataなど、あらゆる種類のデータをキャッシュしたいと考えています。問題は、L2は文字列しか保存できず、データがL2からL1に昇格する際に、文字列からユーザーに直接提供できるデータ型に変換する必要があることです。

幸い、mlcacheはこの状況を考慮しており、newgetインターフェースにオプションの関数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になります。このようなシリアライゼーション関数を使用することで、L1L2の間でデータを変換する際に、より柔軟に対応できます。

まとめ

複数のキャッシュ層を使用することで、サーバーサイドのパフォーマンスを最大化し、多くの詳細が隠蔽されます。この時点で、安定して効率的なラッパーライブラリは私たちの労力を大幅に削減します。今日紹介した2つのラッパーライブラリが、キャッシュをよりよく理解するのに役立つことを願っています。

最後に、この質問を考えてみてください:キャッシュの共有辞書層は必要ですか?lrucacheだけを使用することは可能ですか?ぜひコメントを残して、あなたの意見を私と共有してください。また、この記事をより多くの人と共有して、コミュニケーションと進歩を共にすることも歓迎します。