今更 Redisを勉強した


今までちゃんと調査してこなかった Redis について使い方から簡単なチューニングまで調査をしたのでその結果をまとめておきます。使い方については飛ばして自分が調査する前に疑問に思っていたところを中心にまとめています。適当に書いてるので間違ってたらマサカリください(予防線)。

Redis とは

インメモリデータストアです。データが全てメモリに収められているのでめちゃくちゃ高速に読み書きができます。

データ型

単なるKVSではなくVにさらにHashListを置くことができます。その下にさらにHashをおいたりはできません。型定義で表現してみると

Hash<String,String|Hash|List|Set|SortedSet>

みたいな感じです。

データの永続化

インメモリということは再起動するとデータが消えるのか?それは設定によります。デフォルトでは1分に一度ディスクに保存されるようになっています。BGSAVEコマンドを発行することで今すぐ保存することもできます。

この保存がどのように動いているかという、1分ごとのインターバルでプロセスをforkします。このフォークしたプロセスがメモリの内容を新しいデータファイルに書き込んでいき、全て書き込みが終わったら既存のデータファイルを置き換えます。CoWが有効なのでfork直後は余分なメモリを消費しません。ファイルにダンプしている間に元プロセスのメモリの内容がガンガン書き換えられるとその分のメモリが余分に必要になります。なので極端に書き込みの激しいサービスでは必要メモリ量の2倍のメモリを用意しておくのが安全でしょう。またダンプは新旧2つが同時に存在するのでその分のディスク容量は確保しておく必要があります。

この間 Redisはディスク書き込みに高い負荷を書けるので他のサービスとと同じサーバに同居させるなどは絶対にやめましょう。

Redis が落ちたりすると最大1分間程度のデータが失われます。なので大事なデータの一時ストアとしては使ってはいけません。

メモリが足りなくなると?

それ以上の書き込みが拒否されるようになります。実質サービスダウンとなるのでメモリが不足していないかどうかはかなり気をつけてモニタリングする必要があります。

データの内部表現

全てのデータは文字列として保存されます。文字コードという概念はありません。長いデータを格納するとその分余計にメモリが使われます。特に見落としがちなのはHashのキーです。無駄に長いキー名を使うとその分余計にメモリを消費しますので、大量のデータを使う用途では出来る限り短い名前を使うことが肝要となります。

例えば1000万のキーを持つHashを作ったとして、キーの長さが10文字だとすると10byte*1000万=100MBがキーのために消費されます。

チューニング

ziplist, zipmapを使う

これが最もインパクトが大きいのですが、redis にデータを突っ込むときは要素数 500 ほどのHashを沢山作るのが最もメモリ効率が良くなります。

例えば100万人のユーザに対して数値型の user_id をキーに以下のようなデータを格納したいとしましょう。

{
  'session_id': 'abcdeabcdeabcdeabcdeabcde',
  'fail_count': 1,
  'locked': 'true'
}

小さな沢山のHash

この実現方法として Hash<user_id, Hash> という型を使うという方法があります。

hmset session12345 session_id abcdeabcdeabcdeabcdeabcde fail_count 1 locked true

session12345 はこのユーザのHashの名前です。これは最も効率の悪い方法です。なぜかというとこれだと100万ユーザ分の小さなHashができるのですがHashを作るにはそれなりのデータ構造のオーバーヘッドが必要になり、データ量に比してオーバーヘッドの容量がかなり大きくなります。

単一の大きな Hash

じゃあそのオーバーヘッドを削ろうとして次に思いつく方法は大きな単一のHashを作ることです。これだとオーバーヘッドは無視できますね。そしてこの大きなHashに対してJSON文字列を突っ込みます。

hset sessions 12345 "{'session_id':'abcdeabcdeabcdeabcdeabcde','fail_count':'1','locked':'true'}"

この方法だと上の方法に比べてメモリ消費量が半分くらいになります。それだけオーバーヘッドは大きいということですね。

zipmapを使う

実は上記よりもはるかに効率の良いやり方があります。それが zipmap を使う方法です。RedisでHashやListを使った場合、その要素数に応じてデータ構造の実装が切り替わります。デフォルトでは要素数が512以下だと zipmap という効率の良いデータ構造が使われます。この実装の詳細についてはよく知りませんが、単なるリストとして格納しているようなことをどこかで読んだ記憶があります。

この方式を利用するために、user_idを500ごとシャーディングしましょう。単にuser_idを500で割った商と余りに分割するだけです。user_id 12345 の場合は商が24余りが345となりますから、

hset session24 345 "{'session_id':'abcdeabcdeabcdeabcdeabcde','fail_count':'1','locked':'true'}"

とします。このようにすることで500人ごとにひとつのHashを作る形となってオーバーヘッドは少なく、かつzipmapが使われるのでメモリが最大で10分の1程度に抑えることができます。すごい。

JSONをやめる

上で全てのデータは文字列として保存されると書きました。となると、スキーマレスなフォーマットであるJSONを使うのはメモリ効率上著しく不利になります。なぜかというと100万個全てのデータが同じフィールド名を重複して格納するからです。これは非常に地球に厳しい。こんな小さいデータでは gzip で圧縮するのもうまく行きませんので素直にフィールド名を削りましょう。

最も簡単なやり方は値をセパレータで単純に区切るというものです。上記の例だと

hset session24 345 "abcdeabcdeabcdeabcdeabcde|1|t"

となります。データ量半分以下になってますね。もちろんデータにセパレータが絶対入ってこないという前提です。JSONにあくまでも拘りたければフィールド名を1文字にしてしまうとかもありだと思います。

そんなやり方はかっこ良くないというオシャレな方は ProtocolBuffer を使うこともできます。データの大半が大きめの数値な場合は数値がバイナリ表現されることによるデータ量削減の恩恵を受けることができるでしょう。小さな数字が沢山ある場合はバイナリにした結果データが逆に大きくなる可能性もありますので int8 などの型がある MessagePack を使うか自分でバイナリ変換コードを書くのも良いでしょう。MessagePackはJSONと同様スキーマレスでデータにフィールド名が含まれるのでフィールド名を短くしましょう。いずれにせよ実際のデータを使って何が最適なフォーマット化実験するのが良いでしょう。メモリはRedisにおいて最も重要なリソースなのでこれくらいはやる価値はあると個人的に思います。

ProtocolBufferや自己エンコーダを使う場合、スキーマが固定されるのでスキーマ変更には注意してください。

sorted set

sorted set にも zipmap のようなものがあるのかどうかはよく知りませんが、sorted set はデータ構造のオーバーヘッドがかなりでかいので(1レコードあたり64byte)sorted setを大量に作る場合はメモリ消費量が簡単に膨らみ、チューニングもろくにできないので気をつけましょう。

expire

Redisにはトップレベルのキーに対して有効期限を設定できます。これを使って不要データの削除を試みるのがよくある使い方だと思います。ただし少し気をつける必要があり、redisの古いデータは semi-lazy な方法で削除されます。これはつまり即座に領域が開放されることは無いということです。

より具体的には以下の2つのロジックで不要データが削除されます
* 有効期限が切れた後にそのキーに再度読み込みアクセスがあるとキーが削除される
* 毎秒10回、ランダムにいくつかキーを選んで期限切れチェックを行う

なので非常に短い期限のデータを大量につくるといったユースケースでは思うようにはメモリは開放されません。

kernel チューニング

Redisで大きなデータを運用する際の最重要オプションが vm.overcommit_memory = 1 です。

上でデータ保存時にforkして保存プロセスを起動すると書きました。システムが32GBのメモリを持っているとしてRedisが24GB消費していたとしましょう。ここで Redis がおもむろにforkすると Linux Kernel としてはいきなり24GBのプロセスが増えるので「おいおいこいつ何する気やねん」となるわけです。おれ8GBしかメモリ残ってないのにこのforkを許して大丈夫なんか?と考えるわけです。Redisとしては「ふっそれはただの残像だ」という感じなのですがLinuxとしてはやばいのが二匹に増えたとビビる感じになります。結果、デフォルトの設定だとこういう場合にLinuxはいやいや無理無理、無理だって、メモリちょっとしか無いし、とforkを拒否してしまいます。

この慎重な Linux君を「楽勝でいけるっしょ」というイケイケモードにしてくれるのが vm.overcommit_memory = 1 オプションです。このお注射をすることでメモリがちょっとしかなくてもforkをバンバン許可するようになります。もちろんその結果メモリが足りなくなると OOM Killer の封印が解除されて生け贄が必要になったりスワップが発生してパフォーマンスが極端に悪化してしまうハイリスクなオプションですのであまりギリギリを攻めるのではなくメモリには余裕を持っておきましょう。実際どのくらいメモリの余裕が必要かはログで確認できます。

Redisは1分に一度しかデータが保存されない関係上、OOM killerによるダメージのほうがSWAPによる速度低下よりもダメージがでかいケースもあります。スワップを設定しないと問答無用で OOM killer が発動しますので目的に応じて適切にスワップを設定しましょう。