なんかかきたい

プログラミングなどの個人的なメモやサークル「ゆきいろパラソル」の情報を載せてます

真剣にRedisを使ってみようという気持ちになったのでRedisについて知っていることを書く

年末ですね。更新がおろそかになっていたので、たまにはちゃんとした話をしたいなと思い、前々から書こうと思っていたRedisの話を書いてみました。

検証に使用したRedisは3.2ですので、新しくバージョンが変わると以下の話は変わってくるかもしれませんが今のところそのようなことはしばらくなさそうです。

以下長々とRedisを本当に使えるように設定したり調べたり検証したりした内容を書いていきます。

いまさらですが

Redisは構造データを持てるオンメモリデータベースサーバで、RDBとは違いますがHashやListなどのデータ構造を記録することができる高速なストレージサーバです。 オンメモリDBでHashを持つことができるので、KVSのように扱われることもあるのですが、memcachedと比較すると機能は多く、レプリケーションやAtomicなデータ処理なども可能です。

導入の前に

Redisに限ったことではありませんが、新しく投入するアプリケーションの性能限界は知っておきたいものです。 アプリケーションを動作させるのに十分な性能が出るかどうかで導入するかどうかを決定する必要があるでしょう。

Redisは構造化されたデータを扱えるオンメモリデータベースであり、様々な利用用途が考えられます。

一例をあげれば、単純なKVSとしてオンメモリキャッシュ、Queueとしての役割に注目したジョブキュー、Pub/Subを使ったメッセージングなどが浮かびます。

オンメモリキャッシュとしての利用であれば、秒間にSET/GETできる数ややり取りされるデータサイズを大まかに想定しておき、そのパフォーマンスが出るかどうか、 さらに多くのデータがやり取りされるようになった場合にどのような動作をするのかなどを考えておきたいものです。

パフォーマンス

ディスクへの書き込み頻度はスナップショットやAOF書き込みのタイミングに限られるため、基本的にCPU時間を大きく使う操作をしないのであれば比較的高速に動作します。

Redisはイベントドリブンな設計であり、1つのCPUコアを使います。

ただ、CPUボトルネックになることは基本的にはなく、多くの場合ネットワーク転送コストがボトルネックとなります。

ハードウェアを選ぶ際にマルチコアCPUを搭載したマシンを選ぶメリットは薄く、多くのCPUコアを使いたいことがあれば、 複数のプロセスを立ち上げる方法も考えられます。(通常CPUを使うような操作をRedisでやるべきではないとは思いますが。)

レプリケーションについて

Redisのレプリケーションは Master-Slave の形を取っており、1つのMasterが複数のSlaveに更新差分を転送するスタイルです。

Slaveが新しく参加する際に全データをコピーするフルsyncを実行します。

通信が途中で切れるなどデータの整合が取れなくなった場合には再度フルsyncが実行されます。

メモリについて

Redisはデータをヒープに格納しているため、格納するデータに比例してメモリ使用量も増えることとなります。

データを削除すると保持していたメモリも解放されます。

キャッシュとして使う場合、Expireに達したデータがメモリから消えるかどうかが問題となりますが、Expireを過ぎたデータは自動的にRedisのメモリから解放されます。

使用するメモリの上限を決める場合、maxmemory で指定できます。

メモリオーバー時の削除方法はいくつか選択できますが、 maxmemory 指定によるメモリ上限の指定はセーフガードのために用意しておくだけにしておきましょう。 特にメモリは余裕をもたせておき、格納するデータが増えた場合はメモリを増やせるようにメモリ使用量の監視をします。

メモリが不足する場合

オンメモリデータベースで気になるのはメモリ不足になったときの振る舞いでしょう。 私見ではありますが、Redisを使っていてメモリが不足するような状況の場合はデータの性質に合わせて以下の3つのスタイルで"縮小運用"できるのが理想的と考えています。

真にキャッシュとしての利用であり、やや低速とはいえマスターデータに確実にアクセスできる場合

この場合は、maxmemory の指定を行い、メモリ使用量の監視は緩やかに行います。乱暴にいえばメモリ不足はたいていの場合問題ではありません。 データ設定時には ttl を設定するようにし、maxmemoryに到達した時点でサンプリングしたデータを消します。パフォーマンスを優先し、サンプル数を増やす必要はないでしょう。

消えてはいけないデータとキャッシュの利用が併用されている場合

maxmemoryに到達した際に消えてはいけないデータが選択されると困ることもあるでしょう。その場合でも、maxmemoryは設定しておくのがよいという意見です。 消えてはいけないデータには ttl を設定しなければ削除対象に選択されることはありません。この利用方法で困ることはないでしょう。 監視項目としてはメモリ使用量ではなく、ttl が指定されているデータ数やデータサイズです。 ttlを指定しない(消えない)データが多く存在する場合は、メモリを増設しなければなりません。

すべてのデータが消えてはいけない場合

メモリが不足する場合でもデータを消してはいけない場合 maxmemory を指定せず運用することが考えられます。 当たり前ですが、メモリは有限ですのでデータを格納できる十分なメモリを確保しておく必要があります。 メモリ使用量の監視は非常に重要であり、データの増加に合わせてメモリを増設できなければなりません。

急なデータ増加に対応できずメモリが不足する場合に、どのように動作して欲しいかで以下の3つの運用が考えられます。

1. 諦めてOOM-Killerに殺される

個人的に許しがたいのですが、メモリが足りず多くのデータがswapし操作不能になり再起動するしかなくなるくらいなら、 OOM-Killerに処理してもらうというのも一つの手ではあります。 もちろん、この場合サービスは停止しますし、スレーブももちろんメモリ不足、 ダンプデータも多くのデータが存在し、古いデータを手で消すか、よりメモリの多いマシンを用意して復帰することになります。

2. データに ttl を指定し、maxmemoryも指定する

この方法はキャッシュではないデータではあるが、サービスが停止するよりは多少のデータ消去が許容できる場合には有効です。 まあ大抵このケースには当てはまらないとは思いますが、 データの急増が考えづらくメモリに余裕があり最悪のケースでもサービス停止よりはマシという場合もあるでしょう。 この場合基本的にはttlの古いデータが消えますが、サンプリングによっては新しいデータが選択されてしまうケースもあります。 (比較的新しいデータが多く存在する場合に起こりがちではありますが、これも考えづらくはあります) そもそも、TTLの長いデータであっても消えてしまうことがあるということは留意しておくべきでしょう。 (TTLを指定している時点でデータは消えてもよいものと考えるのが自然)

3. スワップを許し、パフォーマンスの低下を許容する

データが消えてはいけないが、サービスの停止も許容できない場合にパフォーマンスの低下を許容しスワップを許すということが考えられます。 Redisではこの設定を使うため、 vm.overcommit_memory = 1 を設定し、swapサイズを大きくする例を推奨しています。 (overcommitはメモリの割り当て時にはOOM-Killerにはならないけど、使うタイミングでは来るのでどれほどのおまじないなのか疑問なところはあるけれど。 と思ったらこれは BGSAVE などでforkするタイミング、いいかえればCopy-on-writeで有効に働くらしい。)

swapの増やし方はスワップファイルをSSDに割り当てればよいので割愛するとして、大きさは実メモリと同程度でもよいとされているようです。 (ディスクをどんどん使いたいなら大きくしてもいい気もするけど、何か違う気もする。)

maxmemory制限時の削除ポリシーについて

私見ではありますが、単に volatile-lruvolatile-ttl を使用しておけば問題ないと思います。 allkeys-lruallkeys-random はすべてのキーの中から削除対象を選びますが、このポリシーは使いづらいです。 その他のポリシーでは ttl の設定があるキーを対象としますので、通常はそちらを選ぶことになるでしょう。

volatile-* は新しいキーが設定される際に、サンプリングを行い削除対象を選択する。 lruはアクセスが、ttlexpireが古い順のものを選択しますが、サンプリングの為どちらも新しいキーが消されてしまうことがあります。

なお、maxmemory-policyはMasterに対する操作に適用されるものであり、MasterとSlaveの設定が異なっていても切り替えしないうちは特に問題はありません。 *-randomであっても、Masterで消去されたキーと同じキーがSlaveでも消去されるようになっているため、maxmemory-policyによってMaster-Slaveでデータの整合性が崩れることはありません。

そうは言っても信頼できない人もいると思いますのでテストスクリプトを用意しました。

github.com

require 'redis'
require 'securerandom'

redis = []
redis << Redis.new(port: 16379)
redis << Redis.new(port: 26379)
redis << Redis.new(port: 36379)

100.times do |i|
  redis[0].setex "foo_#{i}", 1000+i, SecureRandom.base64(1024*100)
end

keys = []
redis.size.times do |i|
  keys[i] = redis[i].keys.sort
end

p keys[0]-keys[1]
p keys[1]-keys[2]

いずれの場合でも Master-Slave間でキーのリストに差がないことがわかると思います。

バックアップについて

スナップショット作成のメモリ

Redisにはスナップショットと更新ログを記録するがありバックアップにはこれらの機能を使います。

スナップショットはメモリサイズ分のサイズになります。

スナップショットはforkされたバックグラウンドプロセスで動作し、LinuxではCopy-on-writeになるので、スナップショット作成中に変更されたメモリ分だけメモリを必要とします。

最悪ケースではメモリ使用量は2倍となりますが、秒間3000キーの書き換え、データサイズが1KBとしても秒間3MB、スナップショット作成に60秒かかるとして必要なメモリは180MB程度と想定できます。

より高速な書き換えが行われる想定であればスナップショット作成に必要なメモリはより多めに見積もっておく方が良いでしょう。

スナップショット作成用インスタンス

私見ではありますが、Master, Slaveとされているサーバでスナップショットの作成は最小限でよく、実際のところ不要でもあると考えています。

スナップショットの作成が不要というわけではなく、スナップショットの作成はIOに最適化した特別なSlaveとして参加させるのがよいという考えです。

単に性能の良いSlaveを用意できるのであればそれでも構いませんが、バックアップは専用のインスタンスを用意する方が良いと考えます。

設定例

daemonize yes
pidfile /var/run/redis_6379.pid
port 6379
bind 0.0.0.0
unixsocket /tmp/redis.sock
unixsocketperm 777

timeout 0
tcp-keepalive 0

loglevel notice
logfile /var/log/redis.log

databases 16

# Disable snapshot on master and slave
#save ""
#dbfilename ""
#stop-writes-on-bgsave-error yes
dir /var/redis

slave-serve-stale-data yes
# >>> DO NOT SET "SLAVE-READ-ONLY YES" <<<
slave-read-only no

repl-disable-tcp-nodelay no
slave-priority 100

# REPLICATION
repl-backlog-size 128mb
client-output-buffer-limit slave 1gb 1gb 0 # (*1)
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit pubsub 32mb 8mb 60

################################### LIMITS ####################################

maxclients 10000

maxmemory 16gb
maxmemory-policy volatile-lru
maxmemory-samples 20

# Disable AOF mode
appendonly no
lua-time-limit 5000

slowlog-log-slower-than 10000
slowlog-max-len 128

############################### ADVANCED CONFIG ###############################

hash-max-ziplist-entries 512
hash-max-ziplist-value 64

list-max-ziplist-entries 512
list-max-ziplist-value 64

set-max-intset-entries 512

zset-max-ziplist-entries 128
zset-max-ziplist-value 64

activerehashing yes

client-output-buffer-limit

出力バッファ制限、指定サイズに到達した場合クライアントとの接続が切断される。

大きなデータを転送する際にバッファを消費しすぎる場合へのセーフガードになる。

ただ、この条件でたびたび切断される場合そもそも機能していない感じはある。

(レプリケーションが切断されるとデータのフルsyncが走るため、そもそも適切な状態ではないでしょう。)

timeout

クライアントとの接続で使われるタイムアウト値。

ESTABLISHEDなコネクションで占有され続けるリスクを避けたい場合には設定すると便利だけど、遅いリクエストを切断することを考えるなら他の方法を考えたほうがいいのではないかという気もする。

maxclients

同時接続数の上限、接続数に合わせて適切な値を設定する。

多めに設定しておいても構わないが、client-output-buffer-limit の設定なども考えると詰まったときにリソースを使い切らないように考えたほうがよい。

設定の反映

Redisの設定は動的に変更でき、maxmemoryやmaxclientsなども例外ではない。

CONFIG SET maxmemory 28gb
CONFIG SET maxclients 30000

maxmemoryはデータを消し始める閾値の設定なのでわかるとして、maxclientsは変更すると limits の値も変更される。

$ cat /proc/$PID/limits
...
Max open files            30032                30032                files

CONFIG SET "normal 0 0 0 slave 268435456 67108864 60 pubsub 33554432 8388608 60"

max-ziplist

Redisではhash,list,set,zsetでの操作時に小さなデータを扱う際に特別なエンコーディングを使用します。

これにより小さなデータを扱う際にメモリの効率化を図っていますが、この操作にはCPU時間を使います。

それぞれのmax値を超えるエントリーや値の数を超える場合、特別なエンコーディングは行われなくなります。 いいかえれば大きなデータを扱う場合にCPU時間を大きく使うのを避けるように設定されています。

CPUパワーがあり、メモリが少ないマシンで使用する場合、このmax値をデフォルトより大きくすることで、より効率良くメモリを使うことができるかもしれません。

activerehashing

ハッシュテーブルのアクティブな再ハッシュを行うかどうか。0.1秒のCPU時間を消費するかわりにメモリの高速解放を行えます。 デフォルトで有効であり、再ハッシュのためわずかな遅延が発生します。

終わる

他にもいくつか書いておかないといけないことがあった気もするけど、書くことが多くなりすぎてまとまりがなくなってきたので一旦閉めます。偉そうなこと書いたのにすいません。

もうちょっと綺麗に情報が整理できたらまた書くかもしれませんが、もともと自分用メモに書き始めたものなので自分が見づらくなければ書かないかもしれません。

具体的にはSentinelとかMaster-Slave切り替え周りとか、あとTwemproxyとか監視とか・・・いややっぱりまた書こう。個人的にはSentinelよりKeepalivedなどでばーんと切り替えるというのもいいかなと思ってます。