Redis互換サービスのGarnetにおけるメモリ消費とチェックポイント作成の注意点

# はじめに

プロトコルにRedis互換のものを使用したキャッシュストアサーバーとして、[Garnet](https://microsoft.github.io/garnet/)というプロダクトがMicrosoftからリリースされた。

使い方や性能評価はひとまず[公式ドキュメント](https://microsoft.github.io/garnet/docs)を参照してもらうものとして、試してみていくつか注意した方が良い設定というものがあったのでここに書く。

なお、Garnetではストレージ部分に[FASTER](https://microsoft.github.io/FASTER/)をフォークしたTsavorite(元ネタは鉱石の一種らしい)を使っているので、ストレージ部分で何かわかりにくい所があったらFASTERのドキュメントも合わせてみると良い。

# データ量の見積もり

Garnetに限らずキャッシュストアでは、いくらメモリを消費するかという見積もりをするのはかなり重要になってくる。
以下ではインデックスと実際のデータのサイズについて書く。
なお、Garnetではバイトデータ等プリミティブ領域を格納するための領域(Store)と、型付データを格納するための領域(ObjectStore)の二つが存在するが、データ量の見積もりに関しては大体同じなので一緒に書く。

この辺りの情報は[FASTERのチューニングドキュメント](https://microsoft.github.io/FASTER/docs/fasterkv-tuning/)が詳しいので、そちらも合わせて読んで欲しい。

## インデックスサイズの見積もり

FASTERではデータ領域と異なり、インデックス領域は必ずオンメモリにする必要がある。
インデックス領域に必要なサイズの見積もりは、元となったFASTERのドキュメントが詳しい。
https://microsoft.github.io/FASTER/docs/fasterkv-tuning/#managing-hash-index-size
大体 `IndexSize=最大キー数*8` という具合で見積もればOK(多分)。
例えば8MBに設定した場合、最大で約100万程度のキーを登録できる。ただし、Garnetは50%程度("**IndexResizeThreshold**"で調整可能)のインデックス領域が埋まると、インデックス領域を最大"**IndexMaxSize**"まで2倍ずつ拡大しようとするので、実際は16MB程度のメモリを見積もった方が、動作に支障が無いだろう。

## メインデータサイズの見積もり

メインデータで一番領域を占めるのは実際のデータであることは勿論だが、その他にメタデータ領域として24バイトを消費することになる。そして、一つのレコードデータは必ずページ単位に収まる必要がある。
ページの大きさは"**PageSize**"で決まり、GarnetはPage単位でメモリを確保したりする。ページサイズのデフォルトは32MBとなる。
なお、PageSizeは必ずMemorySizeよりも小さい値でなければならず、不適切な値を入れるとサービス起動時にエラーを起こす。

# レコードの保全

Garnetのデータはキーのインデックス領域と、そのインデックスが指し示すデータ領域とに分けられる。
インデックス部分は全てオンメモリとなるが、データ部分に関しては扱いは以下の三つに分かれる

* ミュータブル領域
* リードオンリー領域

キーが追加された時はまずミュータブル領域(最新から"**MutablePercent**"の割合)に入る。そして、キーが更に追加されていくと、古いデータはリードオンリー領域に近づいていく。
キーの更新時、リードオンリー領域に入ったレコードは新しいバージョンのレコードを作ってデータ領域に追記するという挙動になっている。
このデータ部分の最大サイズは"**MemorySize**"で設定できる。デフォルトは32GB。
さて、ここで問題になるのが、MemorySizeを超えたデータはどうなるのかというところだが、Garnetでは設定によってこの時の挙動を調節できる。

## メモリオンリーで使用していた場合

"**EnableStorageTier**"をfalseにした場合、メモリオンリーで運用することになるが、サイズオーバーが発生した場合、古くなったデータから切り捨てられる。
よって、更新がほとんどないキーはそのうち消える場合があるので、アプリケーション側でその場合のフォールバックもきちんと考える必要がある。
読み込み頻度が十分であれば、"**CopyReadsToTail**"フラグを設定すれば、読み込みのタイミングである程度危険な領域にレコードが存在していると判断した場合、最新の場所に自動的にコピーしてくれるようになる。
ただし、当然のことながら読み込み頻度も低い場合は消える可能性もあるので注意すること。

今までの話はメモリオンリーで運用していた場合の話だが、Garnetの特徴であるハイブリッドログ形式を使うことで、メモリの制限を緩和することができる。

## ハイブリッドログを使用する場合

Garnetでは、メモリオンリーで運用するやり方もあるが、メモリサイズ以上のデータを取り扱うための"ハイブリッドログ"という仕組みが存在し、"**EnableStorageTier**"をtrueにすることで有効になる。
これは、メモリ領域の他に、古くなったデータの書き出しをファイルないし永続ストレージに対して行うというものである。
これにより、更新があまりないキーの読み込みには影響が出るが、少ないメモリでもより多くのレコードが格納できるということになる。
基本的にはデータはメモリ内に収めるのが理想だが、データサイズの見積もりが難しい場合は重宝することになるだろう。

デフォルトではカレントディレクトリに"Store"(と"ObjectStore")というディレクトリを作成し、その下に書き込んでいく。この設定は"**LogDir**"で任意の場所に変更することが可能。
ハイブリッドログを使う場合、メモリとは異なり古いファイルは自動的には消えない。
なので、**特に何もしなければ際限なくファイルが増えていくことになってしまう**。
この時に古いバージョンのレコードをガベージコレクションのような形で削除する必要があり、その処理を**Compaction**と呼ぶ。
この時の圧縮処理のパターンがいくつか用意されているが、それを決めるのが"**CompactionType**"設定となる。

# 圧縮処理

Compactionでは、`MemorySize+(SegmentSize*CompactionMaxSegments)`からはみ出た古い領域を削ることになるが、やり方に関しては下記のように複数の方式が存在する。

* None(デフォルト)
* Shift
* ShiftForced
* Lookup
* Scan

## None

文字通り何もしないという意味となる。
これを選択すると、ハイブリッドログが際限なく増えていくことになるので、長期的な運用には向かないので基本的にはこれ以外を選択することを推奨する。

## Shift,ShiftForced

古いが生きているログがいても考慮せずに切り詰めを実行する。この時、古い部分にあるデータは無効となる。
ShiftはCompaction実行時にはみ出ていた部分は論理的には読み込めない領域として処理されるようになるが、この時点で物理的には消されない。
そして、チェックポイント作成("SAVE"あるいは"BGSAVE"実行)時に正式にファイルが消され、ディスクが空くという挙動になる。
この辺りの挙動はチェックポイント作成中にCompactionが起きても不整合が出ないようにするためではないかと思われる。

ShiftForcedは、チェックポイント作成を待たずに古いファイルを消すという挙動になる。
チェックポイント作成は割と重い処理なので、これが省略できるということは大きい。
**しかし、ShiftForcedを選んだ場合、チェックポイントは作れなくなる、つまり、再起動やクラッシュ時などにリカバリができなくなるので注意すること**。

## Lookup

以下の順に処理を行う

1. 古い領域の走査を行い、生きているものがあれば先頭にコピーする
    * この時、より新しい値があればコピーは行わない
2. 領域の切り詰めを行う

この方式でも古いファイルは物理的に消されないので、定期的なチェックポイント作成は必要なことに注意。
更新を頻繁にしているような環境の場合、コピー試行が発生しやすいのでその分の負荷は大きい。

## Scan

以下の順に処理を行う。

1. メモリ上に一時的なTsavoriteストレージを作成
2. 古い領域にあるレコードを走査して、生きているものがあれば一時領域へUpsertを行う
3. ディスク領域にあるが、Compaction範囲外にあるレコードを走査し、一時領域に存在していれば消す
4. 一時領域にあるデータを元の領域へ挿入する
5. 領域の切り詰めを行う

この方式でも他の方式同様、定期的なチェックポイント作成は必要なことに注意。
一時領域を作るのでメモリ負荷は一時的に大きくなるが、更新を頻繁にしているような環境ではディスク領域にあるレコードの移動が抑えられるため、ディスクIOを低く抑えられる場合がある。

# チェックポイントの作成

Redisでは定期的に"SAVE"または"BGSAVE"コマンドを実行することにより、スナップショットを作成することが可能だが、Garnetでもこの機能をサポートしている。
チェックポイント出力ディレクトリ(デフォルトではカレントディレクトリ)に"Store"と"ObjectStore"というディレクトリを作成し、そこにインデックス情報とデータ情報を格納する。
"Store"が通常のデータ領域で、"ObjectStore"が、Garnetの型付データを格納する場所となる。
ただ、注意した方が良い点もあるため記載しておく。

## チェックポイントに必要なディスク領域

チェックポイント作成では、大体"現在のインデックスサイズ(IndexSize+ObjectStoreIndexSize)"+"現在のデータ領域のサイズ"だけかかると思えば良い。
ここで注意が必要なのが、登録キー数が少ない場合でも、ディスクに出力されるサイズは最低でもIndexSize+ObjectStoreIndexSizeとなるので、デフォルト設定では16GBのファイルが出力されてしまうことになる。
お試しでチェックポイントを作成しようとしたら多分驚くことになるかもしれない。
データ部分は大体現在のデータ量程度のサイズが出力される。

## チェックポイント作成時にメモリが肥大化する

まず、チェックポイントの作成を実行すると、Garnetは現在のインデックス領域+オブジェクトストアのインデックス領域分だけメモリを確保する。
この時の領域というのは、実際使われている領域の他、予約されている領域も含まれる。
デフォルト値がそれぞれIndexSize=8GとObjectStoreIndexSize=8Gなので、デフォルト設定ではスナップショット作成時に16GBもの領域を一気に確保しようとするということになる。
また、チェックポイント作成が成功した場合、出力されるインデックスファイルのサイズはIndexSize+ObjectStoreIndexSizeの値になり、大き目のインデックスサイズを指定している場合は大きくディスクを消費してしまうことになる。
そのため、スナップショットを作成するような運用を考える場合は、必ずIndexSizeとObjectStoreIndexSizeの値は適切な値に調整しておくこと。

## インクリメンタルなチェックポイントの作成

何も設定しなければ、通常はチェックポイントの作成はデータとインデックスのフルダンプを行う。
ただし、フルダンプは通常多大な時間を要するので、ハイブリッドログのクリーンアップを目的としている場合、頻繁な実行は大きな負荷となってしまう。
これを回避するために、チェックポイントの作成は差分だけをとるような方式も使える。これは"**EnableIncrementalSnapshots**"をtrueにすれば有効となる。
ただし、インクリメンタルといっても初回は確実にフルダンプが行われ、更に差分データ量の累積が"**IncrementalSnapshotLogSizeLimit**"(デフォルト1GB)を超えるとフルダンプが行われることに注意。

# まとめ

* **IndexSizeとObjectStoreIndexSizeだけはちゃんと見積もりを取ろう**
* ハイブリッドログを使う場合、CompactionType設定の検討をしておこう
* ハイブリッドログを使う場合は定期的なスナップショットの作成を行おう
* 何かわからないことがあったらFASTERのドキュメントも見よう

# 終わりに

メモリ以上のデータ量を扱えるというGarnetだが、そのために色々と考慮事項の多い運用は必要なので、Redisからの置き換えを検討している人は注意が必要。
今回の記事では書いてないが、ハイブリッドログ運用時にReadCacheも設定できるなど、チューニングできる項目はまだ結構ある。
また、[`KEYS`コマンドで大量にメモリ消費する問題](https://github.com/microsoft/garnet/issues/143)もあるが、記事の都合上これは宿題とする。
後、お試しでスナップショットして驚いたのは他でもない筆者である。