高性能の鍵:`shared dict`と`lru`キャッシュ

API7.ai

December 22, 2022

OpenResty (NGINX + Lua)

前回の記事では、OpenRestyの最適化技術とパフォーマンスチューニングツールを紹介しました。これにはstringtableLua APILuaJITSystemTapフレームグラフなどが含まれます。

これらはシステム最適化の基盤であり、しっかりと習得する必要があります。しかし、これらを知っているだけでは実際のビジネスシナリオに対応するには不十分です。より複雑なビジネスシーンでは、高いパフォーマンスを維持するためには、コードやゲートウェイレベルの最適化だけでなく、データベース、ネットワーク、プロトコル、キャッシュ、ディスクなど、さまざまな側面を考慮する必要があります。これがアーキテクトの存在意義です。

今日の記事では、パフォーマンス最適化において非常に重要な役割を果たすコンポーネントであるキャッシュについて、OpenRestyでの使用方法と最適化方法を見ていきましょう。

キャッシュ

ハードウェアレベルでは、ほとんどのコンピュータハードウェアは速度を向上させるためにキャッシュを使用しています。例えば、CPUにはマルチレベルキャッシュがあり、RAIDカードには読み書きキャッシュがあります。ソフトウェアレベルでは、私たちが使用するデータベースはキャッシュ設計の非常に良い例です。SQL文の最適化、インデックス設計、ディスクの読み書きなど、キャッシュが存在します。

ここで、独自のキャッシュを設計する前に、MySQLのさまざまなキャッシュメカニズムについて学ぶことをお勧めします。私がお勧めする教材は、優れた書籍『High Performance MySQL: Optimization, Backups, and Replication』です。私がデータベースを担当していた頃、この本から多くの恩恵を受けましたし、後の他の最適化シナリオでもMySQLの設計を参考にしました。

キャッシュに戻ると、本番環境でのキャッシュシステムは、そのビジネスシナリオとシステムのボトルネックに基づいて最適なソリューションを見つける必要があります。これはバランスの芸術です。

一般的に、キャッシュには2つの原則があります。

  • 1つ目は、ユーザーのリクエストに近いほど良いということです。例えば、ローカルキャッシュを使用できる場合はHTTPリクエストを送信しない、CDNを使用できる場合はオリジンサイトに送信しない、OpenRestyキャッシュを使用できる場合はデータベースに送信しないなどです。
  • 2つ目は、このプロセスとローカルキャッシュを使用して解決することです。プロセス、マシン、さらにはサーバールームを跨ぐと、キャッシュのネットワークオーバーヘッドが非常に大きくなり、高並列シナリオではそれが顕著になります。

OpenRestyでは、キャッシュの設計と使用もこれらの原則に従っています。OpenRestyには2つのキャッシュコンポーネントがあります:shared dictキャッシュとlruキャッシュです。前者は文字列オブジェクトのみをキャッシュでき、キャッシュデータは1つしかなく、各ワーカーがアクセスできるため、ワーカー間のデータ通信によく使用されます。後者はすべてのLuaオブジェクトをキャッシュできますが、単一のワーカープロセス内でのみアクセス可能です。キャッシュデータはワーカーの数だけ存在します。

以下の2つの簡単な表は、shared dictlruキャッシュの違いを示しています:

キャッシュコンポーネント名アクセス範囲キャッシュデータタイプデータ構造期限切れデータを取得可能API数メモリ使用量
shared dict複数のワーカー間文字列オブジェクトdict,queueはい20+1つのデータ
lru cache単一のワーカー内すべてのLuaオブジェクトdictいいえ4N個のデータ(N = ワーカー数)

shared dictlruキャッシュはどちらが良い悪いではなく、シナリオに応じて一緒に使用する必要があります。

  • ワーカー間でデータを共有する必要がない場合、lruは配列や関数などの複雑なデータ型をキャッシュでき、最高のパフォーマンスを発揮するため、最初の選択肢となります。
  • しかし、ワーカー間でデータを共有する必要がある場合は、lruキャッシュにshared dictキャッシュを追加して、2段階のキャッシュアーキテクチャを形成できます。

次に、これらの2つのキャッシュ方法を詳しく見ていきましょう。

Shared dictキャッシュ

Luaの記事では、shared dictについて具体的に紹介しましたが、ここでその使用方法を簡単に復習します:

$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
                               dict:set("Tom", 56)
                               print(dict:get("Tom"))'

NGINX設定ファイルで事前にメモリゾーンdogsを宣言する必要があり、その後Luaコードで使用できます。使用中にdogsに割り当てられたスペースが不足していることがわかった場合、まずNGINX設定ファイルを変更し、NGINXをリロードして有効にする必要があります。実行時に拡張や縮小はできないためです。

次に、共有ディクショナリキャッシュにおけるいくつかのパフォーマンス関連の問題に焦点を当てます。

キャッシュデータのシリアライゼーション

最初の問題は、キャッシュデータのシリアライゼーションです。shared dictにはstringオブジェクトのみをキャッシュできるため、配列をキャッシュする場合は、設定時に1回シリアライズし、取得時に1回デシリアライズする必要があります:

resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
                        dict:set("Tom", require("cjson").encode({a=111}))
                        print(require("cjson").decode(dict:get("Tom")).a)'

しかし、このようなシリアライズとデシリアライズの操作は非常にCPUを消費します。これほど多くの操作がリクエストごとに行われる場合、フレームグラフでその消費を見ることができます。

では、共有ディクショナリでこの消費を避けるにはどうすればよいでしょうか?ここでは良い方法はありません。ビジネスレベルで配列を共有ディクショナリに置かないようにするか、自分で文字列をJSON形式に手動で結合するしかありません。もちろん、これも文字列結合のパフォーマンス消費をもたらし、より多くのバグを引き起こす可能性があります。

ほとんどのシリアライゼーションはビジネスレベルで分解できます。配列の内容を分解し、文字列として共有ディクショナリに保存することができます。それができない場合は、配列をlruにキャッシュし、プログラムの利便性とパフォーマンスのためにメモリスペースを交換することもできます。

また、キャッシュ内のキーはできるだけ短く意味のあるものにし、スペースを節約し、後続のデバッグを容易にします。

期限切れデータ

shared dictには、データを読み取るためのget_staleメソッドもあります。getメソッドと比較して、期限切れデータのための追加の戻り値があります:

resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
                            dict:set("Tom", 56, 0.01)
                            ngx.sleep(0.02)
                             local val, flags, stale = dict:get_stale("Tom")
                            print(val)'

上記の例では、データはshared dict0.01秒間のみキャッシュされ、設定後0.02秒後にデータがタイムアウトしました。この時点で、getインターフェースを通じてデータを取得することはできませんが、get_staleを通じて期限切れデータを取得できる場合があります。ここで「可能」という言葉を使用する理由は、期限切れデータが占有するスペースが他のデータのために再利用される可能性があるためです。これはLRUアルゴリズムです。

これを見て、疑問を持つかもしれません:期限切れデータを取得するのは何のためでしょうか?shared dictに保存されているのはキャッシュデータであることを忘れないでください。キャッシュデータが期限切れになっても、ソースデータが必ず更新されるわけではありません。

例えば、データソースがMySQLに保存されている場合、MySQLからデータを取得した後、shared dictに5秒のタイムアウトを設定します。その後、データが期限切れになった場合、2つの選択肢があります:

  • データが存在しない場合、再度MySQLにクエリを実行し、結果をキャッシュに保存します。
  • MySQLのデータが変更されたかどうかを判断します。変更がない場合、キャッシュ内の期限切れデータを読み取り、その有効期限を変更して、引き続き有効にします。

後者はより最適化されたソリューションであり、MySQLとのやり取りをできるだけ少なくし、すべてのクライアントリクエストが最速のキャッシュからデータを取得できるようにします。

この時、データソース内のデータが変更されたかどうかを判断する方法が、私たちが考慮し解決する必要がある問題になります。次に、lruキャッシュを例として、実際のプロジェクトでこの問題をどのように解決するかを見てみましょう。

lruキャッシュ

lruキャッシュには、newsetgetdeleteflush_allの5つのインターフェースしかありません。上記の問題に関連するのはgetインターフェースだけです。まず、このインターフェースの使用方法を理解しましょう:

resty -e 'local lrucache = require "resty.lrucache"
local cache, err = lrucache.new(200)
cache:set("dog", 32, 0.01)
ngx.sleep(0.02)
local data, stale_data = cache:get("dog")
print(stale_data)'

lruキャッシュでは、getインターフェースの2番目の戻り値は直接stale_dataであり、shared dictのようにgetget_staleの2つの異なるAPIに分かれていません。このようなインターフェースのカプセル化は、期限切れデータの使用により親しみやすいです。

実際のプロジェクトでは、通常、バージョン番号を使用して異なるデータを区別することをお勧めします。これにより、データが変更された後もそのバージョン番号が変更されます。例えば、etcd内の変更されたインデックスをバージョン番号として使用し、データが変更されたかどうかをマークできます。バージョン番号の概念を使用すると、lruキャッシュの簡単な二次カプセル化を行うことができます。例えば、以下の疑似コードを見てください。これはlrucacheから取られています。

local function (key, version, create_obj_fun, ...)
    local obj, stale_obj = lru_obj:get(key)
    -- データが期限切れでなく、バージョンが変更されていない場合、キャッシュされたデータを直接返す
    if obj and obj._cache_ver == version then
        return obj
    end

    -- データが期限切れだが、まだ取得可能で、バージョンが変更されていない場合、キャッシュ内の期限切れデータを直接返す
    if stale_obj and stale_obj._cache_ver == version then
        lru_obj:set(key, obj, item_ttl)
        return stale_obj
    end

    -- 期限切れデータが見つからない場合、またはバージョン番号が変更された場合、データソースからデータを取得する
    local obj, err = create_obj_fun(...)
    obj._cache_ver = version
    lru_obj:set(key, obj, item_ttl)
    return obj, err
end

このコードから、バージョン番号の概念を導入することで、期限切れデータを完全に活用し、データソースへの圧力を軽減し、バージョン番号が変更されない場合に最適なパフォーマンスを達成することがわかります。

さらに、上記のソリューションでは、キーとバージョン番号を分離し、バージョン番号を値の属性として使用するという大きな最適化があります。

私たちが知っているより一般的なアプローチは、バージョン番号をキーに書き込むことです。例えば、キーの値はkey_1234です。この方法は非常に一般的ですが、OpenResty環境ではこれは無駄です。なぜそう言えるのでしょうか?

例を挙げると理解できます。バージョン番号が毎分変更される場合、1分後にはkey_1234key_1235になり、1時間で60の異なるキーと60の値が生成されます。これは、Lua GCが59のキーと値のペアの背後にあるLuaオブジェクトを回収する必要があることを意味します。オブジェクトの作成とGCは、更新頻度が高いほど多くのリソースを消費します。

もちろん、これらの消費は、バージョン番号をキーから値に移動するだけで回避できます。キーがどれだけ頻繁に更新されても、2つの固定されたLuaオブジェクトしか存在しません。このような最適化技術は非常に巧妙です。しかし、単純で巧妙な技術の背後には、OpenRestyのAPIとキャッシュメカニズムを深く理解する必要があります。

まとめ

OpenRestyのドキュメントは比較的詳細ですが、ビジネスと組み合わせて最大の最適化効果を生み出す方法を体験し、理解する必要があります。多くの場合、ドキュメントには1、2文しか記載されていませんが、例えば期限切れデータなど、それが大きなパフォーマンスの違いをもたらすことがあります。

では、OpenRestyを使用する際に同様の経験をしたことはありますか?ぜひコメントを残して共有してください。また、この記事を共有して、一緒に学び、進歩しましょう。