Redis Clusterとgo-redisの深刻な性能劣化を解決した話

さくらインターネット Advent Calendar 2020の23日目です。

現時点では最新版のRedis 6.0のRedis Clusterに対して、Go言語の代表的なRedisクライアントライブラリであるgo-redisからアクセスしたときに、性能が深刻なレベルで劣化しました。 この記事では、ミドルウェアを利用したGo言語アプリケーションの性能劣化に関する問題調査の事例として、この性能劣化を修正するまでの話をまとめました。

go-redisへのPull Requestはhttps://github.com/go-redis/redis/pull/1355です。


はじめに

半年ほど前の論文の締め切りに追われていたある日、評価実験のためにRedisを使った時系列データベースのプロトタイプを開発していました。 ベンチマークツールでプロトタイプの性能を測定したところ、単一インスタンスのRedisに対して想定した結果を得られました。

そこで、意気揚々と、スケールアウト性能を評価するために、複数のRedisインスタンスにデータを水平分割可能なRedis Clusterを利用して、スループットを計測しました。 ところが、信じがたいことに、単一インスタンスに対するスループットよりも、クラスタ化することでかえって、1/10倍ほどスループットが低下する結果となりました。

最初は、単一インスタンスとRedisクラスタとの処理の差分に着目しました。 実験では、GoアプリケーションとRedisプロセスのCPUリソースを全く使い切れていなかったため、ネットワークI/Oに関わるボトルネックにあたりをつけました。

Redisクラスタは、水平分割のために、コマンドが対象とするキーとノードを対応させる必要があります。 キーとノードの対応を直接管理するわけではなく、ノードの増減に対応しやすいように、ハッシュスロットという固定数のスロットが利用されます*1。 ノードが自身に割り当てられたハッシュスロットに対応しないキーを含むコマンドを受信したときに、クライアントへリダイレクトするように指示します。 なるべくリダイレクトさせないほうが望ましいため、go-redisはキーがどのハッシュスロットに属するかを計算した上で、適切なノードに最初からコマンドを送信します。

クライアントによるノード選択がうまく動かずに、コマンド発行のたびにリダイレクトしているのではないかとあたりをつけました。 しかしながら、go-redis内でprintデバッグしても、リダイレクトを観測できませんでした。

このように、その時点で持ってる知識から直感であたりをつけて問題調査をしても、試行錯誤の回数が増えて、結局遠回りなりました。 そこで、推測せずに、計測することにしました。 計測のために、Go言語標準のトレーシングツールであるExecution tracerを利用しました。 pprofで取得した結果をgo tool traceに食わせると、goroutineの実行状況、特にメソッドの呼び出し関係とメソッド単位の実行時間を可視化できます。

go tool traceの分析結果

go tool traceはView Trace、Goroutine Analysis、Network blocking profile、Synchronization blocking profile、Syscall blocking profile、Scheduler latency profileなどのトレースのビューがあります。 次の画像は、実際のプロトタイプの動作が可視化されたビューの種類ごとの結果です。 ビューによっては、みやすさのために、ビューの一部を拡大して表示しています。

View Traceは、Goroutine数、ヒープメモリ量、スレッド数、プロセスごとのGC回数やどのコードが実行されているかなどの時間変化を表示します。

Goroutine Analysisは、プロファイル期間に実行されたgoroutineの数を、goroutineが起動された関数ごと、かつ、全体の実行時間の割合の降順に表示します。

(Goroutine Analysis)

Network blocking profoleは、ネットワークI/O待ち時間をメソッドの呼び出し関係とともに表示します。

(Network blocking profole)

Synchornization blocking profileは、排他ロックなどの同期プリミティブにより待ちに入った時間をメソッドの呼び出し関係とともに表示します。

(Synchornization blocking profile)

Syscall blocking profileは、OSカーネルに対するシステムコールのブロック時間をメソッドの呼び出し関係とともに表示します。

(Syscall blocking profile)

Scheduler latency profileは、Goの処理系のスケジューラのスケジューリング遅延時間を表示します。

(Scheduler latency profile)

各ビューをみると、go-redis内のミューテックスロックと画像では見えづらいですがRedisのパイプライン処理内のネットワークI/O待ちがボトルネックとなっていることがわかります。 profile系のビューでは、上の画像では範囲外になっていますが、自分が書いているアプリケーションのコードのメソッドも表示されるので、ライブラリの内部的な処理と紐付けることができます。

ミューテックスロックの調査

このロックは何のためにどこで利用されているのでしょうか。 sync.Mutexの呼び出し元は、ClusterClient.mapCmdsByNode -> ClusterClient.cmdSlot -> ClusterClient.cmdInfo -> cmdsInfoCache.Get -> internal.Once.Do の順となっています。 mapCmdsByNodeは、パイプライニングのキーに関する制約にあたらないように、適切なノードにコマンドを振り分けるために、ノードとコマンドの対応を生成します。 その一連の処理の中で、コマンド引数の中のキーの位置を知る必要があります。 go-redisではCOMMANDコマンドを利用することにより、動的にコマンドとキーの位置の対応関係を取得できます。 コマンドとキーの位置の対応関係は、Redisのコード内で静的に決定されるため、Redisサーバにアクセスするたびに、取得する必要はありません。 そこで、COMMANDコマンドの結果をキャッシュするのが、cmdsInfoCacheです。 おそらくは、複数のgoroutineからCOMMANDコマンドがRedisサーバに殺到しないように、ミューテックスロックを獲得したのちに、COMMANDコマンドを送信しています。

このミューテックスロックの箇所がボトルネックということは、COMMANDコマンドをキャッシュできずに、毎回COMMANDコマンドを送信しているのではないか?と疑いました。 実際に、printデバッグすると、パイプラインを実行するたびに、たしかにCOMMANDコマンドを送信していました。 なぜこのようなことが起きているのかを探っていくと、COMMANDコマンドの結果を取得するメソッドがエラーとしてredis: got 7 elements in COMMAND reply, wanted 6を返していましたhttps://github.com/go-redis/redis/blob/v7.2.0/cluster.go/#L1534-L1540。 このエラーが上位の呼び出し元に伝搬する過程で、エラーが無視され、COMMANDコマンドの結果をnilとして返却していました。https://github.com/go-redis/redis/blob/v7.2.0/cluster.go/#L1546-L1549 この調査により、キャッシュできずに、毎回COMMANDコマンドを送信していたことが確定しました。

エラーの分析と修正

前述のエラーの生成元は、COMMANDコマンドの結果のパース処理でした。ここでエラーになるということはnが期待する6ではなかったことを指します。

func commandInfoParser(rd *proto.Reader, n int64) (interface{}, error) {
    if n != 6 {
        return nil, fmt.Errorf("redis: got %d elements in COMMAND reply, wanted 6", n)
    }
# https://github.com/go-redis/redis/blob/v7.2.0/command.go/#L1936-L1939

このnの値は、COMMANDコマンドが出力する、各コマンドごとのメタ情報の要素数を指しています。 そして、Redis 6.0からはこの要素数が6から7に増えていました。https://github.com/redis/redis/commit/c5e717c637cbb1c80e1259560ebf995fb7920628 このRedis本体の変更により、先程のエラーが生成されるようになっていました。

実際に、コマンドを叩いて確認してみます。 Redis 5.0.8は、次のように要素数6となりました。

127.0.0.1:6379> COMMAND INFO xadd
1) 1) "xadd"
   2) (integer) -5
   3) 1) write
      2) denyoom
      3) random
      4) fast
   4) (integer) 1
   5) (integer) 1
   6) (integer) 1

一方で、Redis 6.0.1では、次のように要素数が7となりました。

127.0.0.1:6379> COMMAND INFO xadd
1) 1) "xadd"
   2) (integer) -5
   3) 1) write
      2) denyoom
      3) random
      4) fast
   4) (integer) 1
   5) (integer) 1
   6) (integer) 1
   7) 1) @write
      2) @stream
      3) @fast

以上により、エラーの修正内容は、要素数7のケースの対応を追加するだけだとわかりました。 修正の結果、プロトタイプにおいて、想定するスループットを得ることができました。

最終的に、この修正内容をgo-redisへPull Requestとして提案して、提案が取り込まれました。

参考

まとめ

Redis 6.0のクラスタにおいて、go-redisで接続したときの深刻な性能劣化に対して、pprofを利用して、ボトルネックを発見し、ソースコードを調査した上で、修正しました。 今回の問題は、Redis 6.0以降のクラスタに対して、go-redisから接続すると必ず発生するものでしたので、小さな修正のわりには、潜在的な影響範囲はそれなりに大きかったように思います。 それを修正できたので、自分としては満足感を得ました。

僕は、自分が開発したわけではない既存のOSSの、バグや改善点を発見することがどうも苦手なため、既存のOSSにパッチを投げることに対して苦手意識をもっています。 実際、Pull Requestを投げる頻度は、高々年に1,2回程度です。 OSSの問題を発見して修正パッチを息を吸うように投げられる人は、おそらくソフトウェアの問題を発見する目が鍛えられているのでしょう。

研究を進める上では、実験結果をよくするために、どうしても所定の性能がほしかったり、研究成果の一部を世の中で使ってもらうためには、既存のOSSを改善することが必要になります。 このような目的意識から駆動される、ある種の強制力をうまく利用して、苦手ながらもOSSコミュニティへ少しずつ貢献していきたいですね。

振り返ってみると、サーバ側のAPIの仕様変更にクライアントライブラリが追従できていなかった故に発生した問題ということになります。 仕様変更を異なる組織や個人が管理するクライアントライブラリまでに反映させるのは、コミュニケーションのレベルで、一般的に難しい問題です。 性能の問題と捉えると、CI/CDのプロセスに性能測定をして、異常があれば通知することも一つの手です。 しかし、どの程度遅くなれば、異常とみなすのか、性能値を評価するには、CI/CD環境で一貫したハードウェアリソースを利用しつづけなければならない、様々なアクセスパターンが考えられる中で、どの程度テストケースを網羅すべきかなど考えることはたくさんあります。

このように、ちょっとした工夫では解決しづらい問題を、研究開発で解決する問題と捉えると、おもしろいかもしれません。

*1:水平分割(パーティショニング)におけるノードのパーティション割り当て戦略について詳しくは、「Martin Kleppmann著,斉藤太郎 監訳,玉川竜司 訳, データ指向アプリケーションデザイン――信頼性、拡張性、保守性の高い分散システム設計の原理, 6.4.1.2 パーティション数の固定, オライリー・ジャパン, 2019」を参照してください。

Keepalivedのシンタックスチェッカ「gokc」を作った

Keepalivedのシンタックスチェッカ「gokc」をGo言語で書きました。

github.com

執筆時点でのKeepalived最新版であるバージョン1.2.19まで対応していることと、include文に対応していることがポイントです。

使い方

https://github.com/yuuki/gokc/releases からバイナリをダウンロードします。OSXでHomebrewを使っていれば、

$ brew tap yuuki/gokc
$ brew install gokc

でインストールできます。

gokcコマンドを提供しており、-f オプションで設定ファイルのパスを指定するだけです。

gokc -f /path/to/keepalived.conf
gokc: the configuration file /etc/keepalived/keepalived.conf syntax is ok

シンタックスエラーのある設定ファイルを読み込むと、以下のようなエラーで怒られて、exit code 1で終了します。

gokc -f /path/to/invalid_keepalived.conf
./keepalived/doc/samples/keepalived.conf.track_interface:7:14: syntax error: unexpected STRING

なぜ作ったか

KeepalivedはIPVSによるロードバランシングと、VRRPによる冗長化を実現するためのソフトウェアです。 KeepalivedはWeb業界で10年前から使われており、はてなでは定番のソフトウェアです。 社内の多くのシステムで導入されており、今なお現役で活躍しています。

KeepalivedはNginxやHAProxy同様に、独自の構文を用いた設定ファイルをもちます。

  • しかし、Keepalived本体は構文チェックをうまくやってくれず、誤った構文で設定をreloadさせると、正常に動作しなくなることがありました。

そのため、これまでHaskellで書かれた kc というツールを使って、シンタックスチェックしていました。 initスクリプトのreloadで、kcによるシンタックスチェックに失敗するとreloadは即中断されるようになっています。

ところが、Haskellを書けるメンバーがいないので、メンテナンスができず、Keepalivedの新機能に対応できていないという問題がありました。 (Haskell自体がこのようなものを書くのに向いているとは理解しているつもりです。) さらに、kcについてはビルドを成功させるのが難しいというのもありました。Re: keepalived.confのシンタックスチェックツール「keepalived-check」「haskell-keepalived 」が凄い! - maoeのブログ

さすがに、Keepalivedの新しい機能を使うためだけに、Haskellを学ぶモチベーションがわかなかったので、Go言語とyaccで新規にgokcを作りました。 Go言語はインフラエンジニアにとって馴染みやすい言語だと思っています。 yaccは構文解析の伝統的なツールなので、情報系の大学で習っていたりすることもあります(僕は習わなかったけど、概念は習った)。

ちなみに、C言語+flex+yacc版のシンタックスチェッカである ftp://ftp.artech.se/pub/keepalived/ というものがあります。 新しい構文には対応しているのですが、include未対応だったり、動いてない部分が結構あるので、参考にしつつも一から作りました。

実装

シンタックスのチェックだけであれば、コンパイラのフェーズのうち、字句解析と簡単な構文解析だけで済みました。 「簡単な」と言ったのは、構文解析フェーズで、抽象構文木を作らなくて済んだということです。

一般に字句解析器は、自分で書くか、Flexのような字句解析器の自動生成ツールを使います。 後者の実装として、自分の知る限り、Go言語にはgolexnex があります。

ただし、include文のような字句解析をそこそこ複雑にする構文があるため、柔軟に書けたほうがよかろうということで自分で書くことにしました。 といっても、スキャナ部分はGo言語自体のスキャナであるtext/scannerを流用しました。 Go言語用のスキャナですが、多少カスタマイズできる柔軟性があるので、ユーザ定義の言語の字句解析器として利用できます。 Rational Number Calculator in Go を参照。

構文解析にはパーサジェネレータであるyaccを使いました。 yaccのGo版は標準でgo tool yaccがあります。 goyaccについて詳しくは、goyaccで構文解析を行う - Qiitaを参照してください。

多少面倒だったのはinclude文の対応です。 include対応とはつまり、字句解析器において、別の設定ファイルを開いて、また元の設定ファイルに戻るというコンテキストの切り替えをしつつ、トークンを呼び出し元の構文解析器に返すことが求められます。

字句解析器から構文解析器へトークンを渡す構造をどうするかが問題でした。 逐次的にトークンを構文解析器へ返すのを諦めて、一旦末尾まで字句解析した結果をメモリにすべてのせて、構文解析器から順に読ませるみたいなこともできました。

それでもよかったんですが、Rob Pikeの Lexical Scanning in Go の資料に、goroutineとchannelを利用して、字句解析器を作る方法が書かれており、この手法を部分的に真似てみました。

具体的には、字句解析を行うgoroutineと、構文解析を行うgoroutine(メインのgo routine)が2つがあり、字句解析goroutineが構文解析goroutineにemitetr channelを通じて、トークンを受け渡すという構造にして解決しました。 channelをキューとして扱うようなイメージです。

include文のもつ複雑さに対して、そこそこシンプルに書けたような気はしています。

参考

Go言語によるCLIツール開発とUNIX哲学について

この記事ははてなエンジニアアドベントカレンダー2014の8日目です。

今回は、Go言語でサーバ管理ツール Mackerel のコマンドラインツールmkr を作るときに調べたこと、考えたこと、やったことについて紹介します。(mkr は現時点では開発版での提供になります。)

コマンドラインツールについて

コマンドラインツールを作るにあたって、@ さんの YAPC Asia 2014 での発表資料が非常に参考になります。 書籍 UNIXという考え方ーその思想と哲学 の内容をベースに、コマンドラインツールはどうあるべきかということが丁寧に説明されています。

続きを読む

GoとMySQLを用いたジョブキューシステムを作るときに考えたこと

【追記】2023年3月21日 YAPC::Kyoto 2023で、ジョブキューシステムFireworqの設計と運用実績も含めて発表されました。id:tarao ++

【加筆修正】 2020年2月16日 執筆時から6年も経過していますが、たまたまこの記事を振り返る機会があったので、日本語がおかしいところを一部修正したり、一緒に取り組んだ方々の名前が書かれていなかったところを修正しました。

【追記】2017年12年24日 このエントリのジョブキュー実装がFireworqという名でOSSとして公開されました。id:tarao ++

github.com

この記事ははてなエンジニアアドベントカレンダー2014の4日目です。 前回は Mackerelで採用している技術一覧とその紹介 - Hatena Developer Blog でした。

社内の開発合宿で、 id:taraoさん、id:hakobe932さん、 id:shiba_yu36さんとともに、新たなジョブキューシステムを設計・実装しました。この記事では、筆者が担当した主にサーバ運用視点でのアーキテクチャ設計について紹介します。 ジョブキュー(メッセージキュー)は、単なる非同期処理をしたいというだけでなく、今年流行したマイクロサービスアーキテクチャにおけるサービス間の連携などにもよく用いられているという点で、今後ホットになる話題だと思います。

続きを読む

Go言語の便利情報

ここ1年ぐらい収集した便利 Go 言語情報を並べただけです。

オフィシャル

言語機能解説を中心にピックアップ。

続きを読む

#Gokyoto

Go勉強会 そうだ京都、行こう on Zusaar

Gokyoto 行ってきた。 Go、チュートリアルやったり、Docker のコード読んだりくらいしかしてなかったので、集中して Go 勉強できるちょうどいい機会だった。

A Tour of Go で Go に入門した - ゆううきブログ

Go

@Jxck_ さんの当日の資料、コンパクトにまとまっててよかった。 A Tour of Go さっとみたあとにみるとよい感じする。

Go Kyoto(Go勉強会 そうだ京都、行こう) のハンズオン資料 (http://www.zusaar.com/event/4367004)

新しい言語を覚えようとすると、言語仕様はもちろんある程度覚える必要があるけど、開発補助ツールとか標準的な書き方とかデバッグ方法とか実装に向いたアプリケーションの性質などの文化を知る必要がある。 Perl とか歴史が長い言語ほど困る。cpanm を知らずに過ごしてしまうこともありうる。 新しい言語だと情報がその分少ないのでそれも困る。 その辺を自分でウォッチするのは結構手間がかかるので、たった4時間でそういうのを知れたのはすごくよかった。

具体的に得られた知見は以下の様な感じ。

  • http://godoc.org 相当のアプリケーションをローカルで起動できる。インターネットなくても安心。あと、自分で書いたモジュールのドキュメントもみれる。
godoc -http=:4000

当日書いたコードは、https://github.com/y-uuki/gotask においた。

感想

おこられたので、モジュール分割とテストも書いてって言われて書いた。 チュートリアルしかやってない人間、どう考えても初心者だと思う。

懇親会、なんか Go とか Eralng とか JS とか HTTP2.0 とか Immutable Infrastructure とか話してた気がするけど何も思い出せない。 @Jxck_ さんが最高のエイプリルフールネタ思いついたって喜んでおられたからだいたいよかったと思う。

イベント準備・運営していただいた @shokiri さん、はるばる東京から講師としてきていただいた @Jxck_ さんありがとうございました。

見つかった。