diary

日記です

redisのmaster-slave構成で考えるべきことの話

結論

  • Redisは2.6を使おう
  • master-slave構成を取る場合はclient-output-buffer-limitをちゃんと意識するべき

概要

redisはエッジトリガ型のnon-blocking I/Oを用いてシングルスレッドでソケットの読み書きをぶん回す構造で書かれています。 よってclientやslaveへ対してのreplyを行う際も、ソケット自体の送信バッファが溢れた際(EAGAINが帰った際)には一度イベントループに処理を戻し、またソケットが書き込み可能になってイベントループが自分を呼んでくれた時に続きをwriteします。 まあnon-blocking I/Oなんだから当たり前なんですが、送信処理を再入可能にするためにredisはアプリケーションレベルで出力バッファを持っています。

これは送信が終わり次第適宜解放されるものの、write自体が間に合わなくなると詰まって開放されなくなります。 これも当たり前で、書き込み自体が間に合わないなら諦めるか待ち続けるか後回しにするかしか無いよねっつー話です。

結局何が起こるかっつーと、masterから見てslaveのコネクションが高負荷なので応答不能になりかつコネクション自体が切断されない状況に陥った時(writeが間に合わなくなった時)、該当のコネクションへのレプリケーションの出力バッファは溜まり続けます。masterからのレプリケーションの出力バッファが溜まり続けるとどうなるかというと、(適切な設定をしていないと)当然メモリを無限に食い続けます。

よく考えたら当たり前なんですけど、まあそうなります。

フロントからのclient接続もまあおんなじ事情ではありますが、1コネクションで持続的にデータ送り続けかつ圧倒的なデータ量があるレプリケーション(あと使ってればpubsubもか)が一番これの被害を受けやすいと思います。

どうするか

client-output-buffer-limitという設定がRedis 2.6以降にはあり、slave/pubsub/client毎に出力バッファの最大サイズを定義してそれを超えたソケットをぶった切ることが出来るようになっています。使い方はググって下さい

client-output-buffer-limit自体は2.6を入れればデフォルトでredis.confには入ってます。逆に言うとそのまま使うとデフォルト設定のしきい値を超えて出力バッファが積まれると、いきなりスレーブはレプリケーションのコネクション切断されることになりやばいです。

というわけでまあ、Redisは2.6を使いつつ、こういう事情があることは意識はしときましょうという話でした。

boxenを導入した話

OSXの環境設定を自動化出来るツール。puppetベースだけどchefこねくり回すよりは個人で使うには手軽で良かったです。 XCode入れればとりあえず動かせるし、our-boxenベースにすればrbenvとかnvm(nodenvもいける)とかhomebrewがなんか勝手に入る。便利。

OSXアプリケーションのインストールもAppStoreのもの以外はだいたい全部自動化出来ました。便利。

便利です。

手順

  • XCode(& Command Line Tools)を入れる
  • boxen用にディレクトリ掘る

    sudo mkdir -p /opt/boxen sudo chown $USER:admin /opt/boxen

  • our-boxen(boxenテンプレート) mkdir ~/src && cd ~/src && git clone git://github.com/boxen/our-boxen.git our-boxen

  • Puppetfileに使いたいpuppetのgemをモリモリ並べる
  • ~/src/our-boxen/modules/people/manifests/$USER.ppを書く
  • ~/src/our-boxen/script/boxen を実行

設定

うちで使ってるのは以下みたいな感じです

  • ~/src/our-boxen/modules/people/manifests/trapezoid.pp

      class people::trapezoid {
        #includes
        include skype
        include iterm2::stable
        include chrome
        include firefox
        include virtualbox
        include flux
        include rubymine
        include sourcetree
        include dropbox
        include sublime_text_2
        include ctags
        include java
    
        class { 'intellij':
          edition => 'ultimate',
        }
        #include osx
    
        package {
          [
            'tmux',
            'tig',
          ]:
        }
    
        package {
          'ForkLift':
            source   => "http://download.binarynights.com/ForkLift2.5.4.zip",
            provider => compressed_app;
          'Mou':
            source   => "http://mouapp.com/download/Mou.zip",
            provider => compressed_app;
          'GoogleJapaneseInput':
            source => "http://dl.google.com/japanese-ime/latest/GoogleJapaneseInput.dmg",
            provider => pkgdmg;
          'RemoteDesktopConnectionClient':
            source => "http://download.microsoft.com/download/C/F/0/CF0AE39A-3307-4D39-9D50-58E699C91B2F/RDC_2.1.1_ALL.dmg",
            provider => pkgdmg;
        }
    
        package {
          'zsh':
            install_options => [
              '--disable-etcdir'
            ]
        }
    
        file_line { 'add zsh to /etc/shells':
          path    => '/etc/shells',
          line    => "${boxen::config::homebrewdir}/bin/zsh",
          require => Package['zsh'],
          before  => Osx_chsh[$::luser];
        }
    
        osx_chsh { $::luser:
          shell   => "${boxen::config::homebrewdir}/bin/zsh";
        }
    
        $home     = "/Users/${::luser}"
        $src      = "${home}/src"
        $dotfiles = "${src}/dotfiles"
        $oh_my_zsh = "${home}/.oh-my-zsh"
        $oh_my_zsh_custom = "${home}/.oh-my-zsh-custom"
        $dust     = "${home}/.dust"
        file {$dust:
          ensure => directory
        }
        $dust_vim     = "${dust}/vim"
        file {$dust_vim:
          ensure => directory
        }
        $dust_vim_swap     = "${dust_vim}/swap"
        file {$dust_vim_swap:
          ensure => directory
        }
    
        $dust_vim_backup   = "${dust_vim}/backup"
        file {$dust_vim_backup:
          ensure => directory
        }
    
        repository { $dotfiles:
          source  => "trapezoid/dotfiles",
          require => File[$src]
        }
    
        exec { "ruby ${dotfiles}/symlink.rb":
          cwd => $dotfiles,
          creates => "${home}/.zshrc",
          require => Repository[$dotfiles],
        }
    
        repository { $oh_my_zsh_custom:
          source  => "trapezoid/oh-my-zsh-custom",
          require => File[$src]
        }
    
        $sublimetext2_packages = "${src}/sublimetext2-packages"
        repository { $sublimetext2_packages:
          source  => "trapezoid/sublimetext2_packages",
          require => File[$src]
        }
    
        exec { "ruby ${sublimetext2_packages}/symlink.rb":
          cwd => $sublimetext2_packages,
          creates => "${home}/Library/Application Support/Sublime Text 2/Installed Packages/Package Control.sublime-package",
          require => Repository[$sublimetext2_packages],
        }
      }
    
  • Puppetfile

      # ..省略
    
      # Optional/custom modules. There are tons available at
      # https://github.com/boxen.
    
    
      github "chrome",   "1.1.0"
      github "rubymine", "1.0.1"
      github "iterm2",   "1.0.2"
      github "firefox",  "1.0.5"
      github "skype",    "1.0.2"
      github "intellij", "1.1.3"
      github "vlc",      "1.0.1"
      github "flux",     "0.0.1"
      github "osx",      "1.0.0"
      github "ctags",    "1.0.0"
      github "dropbox",  "1.1.0"
      github "java",     "1.0.6"
      github "virtualbox",    "1.0.2"
      github "sourcetree",    "0.0.2"
      github "sublime_text_2","1.1.0"
    

redis 2.2を複数slave構成かつevictedありで運用してるとマジヤバイ話

結論

Redis 2.2使ってる奴はさっさとアップデートしろ

タイトルの条件で使用メモリがmaxmemoryに達するとキーがまるごと全部消し飛ぶ(evictedする)可能性があります。2.4ではとっく対策されているので、さっさと2.4以降、というかもうstableは2.6だし2.6にしろ

概要

わかりやすい全容(ぶっちゃけこっち見たほうが多分速い)。

だいたい下記のような感じのことが書いてあります

  • Redis 2.2でMaster-Slave構成(特に多数のslaveを接続している)を取っており
  • maxmemory-policyにnoeviction以外を指定している状態で
  • メモリ使用量がmaxmemoryに到達してキーのevictが発生し、
  • 格納されているキー1つあたりのの平均サイズが小さい (2013/04/23 21:47 紛らわしいので追記)

上記の条件で、maxmemory時のキー削除ロジックが暴走して、(ほぼ)すべてのキーがmasterから削除される

説明

まず前提として、

  • redisはキーを消すときにslaveに対して出力バッファ、AOF用のバッファにDELコマンドを書き足す
  • maxmemory到達時の押し出しに関しても、同様にふるまう

という事実があり、Redis 2.2ではmaxmemory到達時のキー削除に関しては色々省略するとまあだいたいこんなかんじで実装されていて、何らかのコマンドが走る度にチェックされる。

while (server.maxmemory && zmalloc_used_memory() > server.maxmemory) {

    /* 
     * maxmemoryポリシーに従って消すべきbestkeyを探すコードが
     * モリモリ書いてあったりするけど今回は全く関係ないオブジイヤーなので中略
     */

    /* Finally remove the selected key. */
    if (bestkey) {
        robj *keyobj = createStringObject(bestkey,sdslen(bestkey));
        propagteExpire(db,keyobj);
        dbDelete(db,keyobj);
        server.stat_evictedkeys++;
        decrRefCount(keyobj);
        freed++;
    }

    /* だるいので省略 */
}

要するに消したいものを探して、メモリ消費量がmaxmemoryを下回るまで消しまくる。

中で何をしているかというと、実際にdbからキーを消してるのはdbDeleteなんですが、消す前にpropagteExpireの呼び出しによって各DELコマンドの発行とそれの接続中の各スレーブへのレプリケーション、そしてAOFへの追記のための出力バッファへの書き出しが行われています。

この時、あくまで出力バッファにそれぞれ書き出されるだけで、その場では実際にAOFへの追記やslaveへのレプリケーションは行われない。また、接続中の各slave毎に出力バッファは確保されます。

このため、

  • propagteExpireはメモリ解放ロジック内において(少なくとも、イベントループ側に処理を返すまで)は、出力バッファ分のメモリを専有する
  • 専有する出力バッファは、接続中のslaveの数に比例して増大する

加えて、出力バッファもdbと同じくzmalloc(redisの厨二ネーミングなmalloc。名前に反して確保した容量を管理する程度のことしか自らはしていない、薄いwrapper)で確保している為、

  • 出力バッファの分のメモリ消費も上記コードのzmalloc_used_memoryの返り値に加算される

すると、

  • dbDeleteで開放される容量 < replication,aof用の出力バッファが専有する量

となった時、zmalloc_used_memoryの返り値は解放前後で減るどころか増えるようになり、この解放ロジックはキーを全て捨て去るまで止まらない勢いで走り続けるようになります。

でかい容量を食っているキーが偶然削除されれば、運が良ければそこで止まるかもしれないですが、まあ大体ダメであろうと思われます。大人しく2.6を使え

対策は実行時に確保されてる出力バッファぶんをmaxmemoryから差っ引いた上で、dbDeleteで実際に確保されるメモリ量が目標解放量に達するまでキーを削除し続ける、というロジックに変更することでなされたみたいです。

最後に

なんか成り行きで調べてたらちょっと面白くなってきたのでまとめてみましたが、もう解決してるので2.2使わなきゃ微塵も問題ないです。

とりあえず2.6使っときましょう

Readable Git

gitのブランチ名には実は日本語が使えます

   
➜  ~ git:(develop) ✗ git branch feature/びっくりするほどウルトラ凄い機能 develop 
➜  ~ git:(develop) ✗ git branch
  feature/びっくりするほどウルトラ凄い機能
* develop
  master

これでどんな各トピックブランチがどんな機能なのか一目瞭然な気がしますね。

使える事を知ったはいいけどいかにも面倒臭い問題起きそうで運用してないので、誰か試してみて下さい。

master-slave構成のredisでttl(expire)を持つキーを使う方法

いろいろあってめんどい。

  1. ttlを指定したキーの実削除は、キーの参照があった際又は100ms毎に行われるttlを持つ全キーからのランダムルックアップによる検査により行われる
  2. 故に、短いttlを持つキーが多数存在する場合には、(実際に参照しない限り)実削除が間に合わなくなる(意図した時間に揮発しない)事がある

これだけならまあ参照すればいいんですが

  1. master-slave構成時、slaveから見たキーの削除はmaster側からの削除命令が無ければ行わず、ttl < 0となったキーに関してもこれは同様に扱われる
  2. 故に、揮発されるべきキーがslaveから参照されても、実削除は行われず、(master側の定期実削除が間に合っていない場合)slaveはそのまま揮発されているべきキーの値を返してしまう

対応策は

  1. masterに対してgetを投げる
    master-slaveとは何だったのか…
  2. redis.hのREDIS_EXPIRELOOKUPS_PER_CRONを増やす
    100msごとにルックアップするキーの数を増やす。
    揮発されるまでの時間は短くなるがCPU負荷は上がる。
    redisの再コンパイルも必要だし、そもそも根本的な解決にはならない
  3. slaveからのキー参照時に、必ずttlコマンドによる生存時間確認を行う
    実削除はされなくても、該当キーのttlは(unixtimeで時間を持っている為)正常にカウントダウンされる。
    このため、この現象が起こってしまったキーに対してttlコマンドを打つと0-1を返す。
    これを利用して論理的にそのキーがexpireしたことを確認する。
    ttlを使わないキーもttl=0ttl=-1であることを留意しないと辛いことになる。
  4. ttl使うキーを減らすか無くす、又はそのような用途にredisを使わない

まああんまりttlに期待しない設計にするのが一番よいです。memcachedのslab程あんまり断片化に気を使わないでもいいしそこそこ速いしレプリケーション出来るしmemcached代替として使っちゃいたくなるけどまあ落とし穴はありますよ、と。

redisはコード平易で読みやすくていいですねというお話でした(?)

2013/3/13 訂正:

正しくはttl=0じゃなくて-1です。ただ、expireしたのもttlを持たないのも両方-1返すんで、区別出来ないのには変わりなし