RailsアプリケーションのホットスポットをJavaへリプレースする
Railsを使ったサーバーサイド開発は Rubyの記述力や標準ライブラリが優れていることもあいまって非常に速く、アジャイル的なイテレーションを伴う開発スタイルに適している。
その代わりに、実行性能はお世辞にも高いとは言えない。というかむしろ低い。しかしそれは高い生産性との正当なトレードオフである。
というわけで、求められる性能要件を満たすというミッションに対する RoRの典型的な解としては、「多くのハードウェアを投入して解決する」「節約できた開発時間を性能の向上に割り当てる」などが挙げられると思う。後者を選んだプロジェクトは地球に優しいエコロジーな意志決定に基づいており大変素晴らしい。よもや、まさか、万が一にも、金がないなどという理由ではないに違いない。なおTwitterなどは前者と後者のハイブリッドと思われる(後者寄り?)。
ここではエコロジーな方針を選択する前提で話を進める。一般に、プログラムの実行に必要な時間の8割は、2割のプログラムコードに費やされているとされる。最小の労力でソフトウェアの性能を大きく向上させたいのであれば、問題の2割となる部分を探し出して高速なコードに置き換えれば良い。
以下、事例
RoRと ActionWebServiceを使って実装したあるサービスでは、120件ほどのレコードを出力するオペレーションの実行におおよそ 1800ミリ秒かかった(※)。このサービスで最も多く呼び出されるばかりか、都度CPU時間を多く使うこのオペレーションの実装をより高速なものへ置き換えれば、労力に見合った成果を得られるだろう。
※普通いくら RoRが遅いと言っても 120件を表示するページの表示に 1.8秒もかからない。この事例は SOAPベースの RPCでの話であり、サーバ側・クライアント側双方におけるマーシャル・アンマーシャル処理および通信にかかる時間を含めた計測である。
とはいえ高速化すると言っても、この時既に DBMSの負荷は無視できる程度だということは確認済みであり、find_by_sqlでダイレクトに SQLを実行しているため ActiveRecordにおける O/Rマッピングのコストも少ないと予想されることから、CPUを使っている時間の殆どは SOAPの荷造りと荷解きに使われてしまっていることが明白だった。与えられた主な選択肢はふたつ。
ここでは、Railsと JEEサーバの両方を起動しておく必要があり運用の面倒はあるものの、JEEがシングルインスタンスで同時に複数リクエストの処理を行えるというオマケがあるため経済効果が将来的に大きいことや、クライアント側のコード変更量が少ないことを考慮し後者を選択した。(というわけで本日の題目になる)
手はずはこうだ。問題となっている重いオペレーションと全く同じ処理を Javaで記述しなおし、Apache CXFを使って SOAP化したものを Tomcat にデプロイする。クライアント側では起動時に WSDLを2種類(Rails側、Java側)ロードし、可能な場合は Java側をコールする。
大きな問題がひとつあった。このサービスではユーザを特定するのに必要な若干の情報をいわゆるセッション変数に保持しているのだ。Rails側で使われているCookieを JEE側でも読み出すことは可能だが(HTTPクライアントから見て同じ Webサーバに見えるようURLのマッピングを工夫すれば良い)、CookieはただのUIDでしかなく肝心のセッションデータ本体はデフォルトだと Rails配下のバイナリファイルに書き出されているためこれを読みに行かなければならない。
プレーンファイルを読みに行くよりもマシな方法がある。Railsではセッションデータストアを切り替えることが出来るのだが、それをファイルでなくデータベースにしてやれば Java側からも JDBCを使って読み出すことが出来るのだ。これだとアプリケーションのインスタンスをノードにまたがって複数起動したくなった場合にも対応出来るためスケーラビリティというオマケもつく。
(RDBのスループットが問題になるようなら代わりにmemcachedという選択肢もある)
設定変更は比較的簡単だ。environment.rbで
config.action_controller.session_store = :active_record_store
という行をコメントインし、
rake db:sessions:create
でセッションテーブル作成のマイグレーションを生成して rake db:migrate すれば良い。
データベースに保存されたセッションデータは
select data from sessions where session_id = ?
で読み込むことが出来る。ここで得られる data 列の内容は、RubyのHashをシリアライズしたデータを Base64でエンコードしたものである。
Java側でこれを解くには、commons-codecの Base64クラスを使って下記のようにデータベースから取得した data 列をデコードし、
byte[] rawData = Base64.decodeBase64(data.getBytes());
さらにデコードされたバイナリをデシリアライズしてやる必要がある。
Rubyでシリアライズされたデータを Javaでデシリアライズするためのライブラリが何かあれば教えて欲しい。私は上手く見つけられなかったので、Rubyリファレンスマニュアル - Marshalフォーマットを参照しながらサブセットのデシリアライザを自分で書き、RubyDeserializerという名前にした。
RubyDeserializerを使ってセッションデータ user_id (Integer) を取り出したコードを示す。
またこれは余所で役に立つ情報とはあまり思えないのであくまで個人的なメモのために書いておくが、CXFのAegis-Bindingでは構造体メンバの名前を制御するためのアノテーションを使うと Rubyのネーミングルールと互換を取ることが出来る。
上記のような工夫の末、Javaへリプレースされた問題のオペレーションは元の1800ms前後から 600ms前後まで高速化されると共に、(シングルインスタンスのアプリケーションサーバで)同時に複数のリクエストを処理できるようになった。
注意してほしいのは、サービスの機能全てを Javaで書き直したわけではないということだ。単に Rubyよりも Javaが速いという当たり前の話をここでしたいのではない。今回 Javaで書き直した部分以外の機能は依然として RoRで動作している。なぜか?性能上問題になっていないので、さしあたり書き直す必要がないからだ。言語やプラットフォームの性能論争は実にくだらない。トレードオフを考慮し、要件を満たすものを都度選択すれば良いだけである。制約上そうできないケースも当然ある(というか多くの場合そうだろう)が、それは案件個別の問題であって一般的な議論の中で持ち出しても何も生み出さない。
これにより以後 RailsとTomcatの両方を同時に運用していかなければならなくなったというデメリットは確かにあるが、必要ならサービスの仕様がある段階まで成熟してプロトコルの変更がほとんど無くなった時点でまとまったバジェットを用意して Javaへのフル・リライトを行い一本化することも可能だろう(これも運用性と予算とのトレードオフである)。
その時には入出力仕様が既に明確でかつFixされているのだから、つまらない Javaのコーディングはコストの安い単なる製造担当のエンジニアに任せることが出来る。アーキテクトは創造的な仕事を Ruby on Railsで行い、そうでない仕事を他人に任せることでより創造的な仕事に時間を割く(※)。そんな構図を夢に描いてみた。
※フルAjax, フルFlashといった完全に RPCベースのアーキテクチャを持つアプリケーションならUIとロジックの分離が真に実現しているわけなので実際に可能だと思う
■おまけ■
いわゆる WebサービスをSOAPで実装した時、HTTPペイロードの肥大化という問題がつきまとう。普通だと正味データ容量の3-4倍(それ以上?)といった長さのレスポンスボディが平気で返ってくるが、Apacheに deflateさせたら 1/16ほどに圧縮されたので、これならまあ許せると思う。
その代わりに、実行性能はお世辞にも高いとは言えない。というかむしろ低い。しかしそれは高い生産性との正当なトレードオフである。
というわけで、求められる性能要件を満たすというミッションに対する RoRの典型的な解としては、「多くのハードウェアを投入して解決する」「節約できた開発時間を性能の向上に割り当てる」などが挙げられると思う。後者を選んだプロジェクトは地球に優しいエコロジーな意志決定に基づいており大変素晴らしい。よもや、まさか、万が一にも、金がないなどという理由ではないに違いない。なおTwitterなどは前者と後者のハイブリッドと思われる(後者寄り?)。
ここではエコロジーな方針を選択する前提で話を進める。一般に、プログラムの実行に必要な時間の8割は、2割のプログラムコードに費やされているとされる。最小の労力でソフトウェアの性能を大きく向上させたいのであれば、問題の2割となる部分を探し出して高速なコードに置き換えれば良い。
以下、事例
RoRと ActionWebServiceを使って実装したあるサービスでは、120件ほどのレコードを出力するオペレーションの実行におおよそ 1800ミリ秒かかった(※)。このサービスで最も多く呼び出されるばかりか、都度CPU時間を多く使うこのオペレーションの実装をより高速なものへ置き換えれば、労力に見合った成果を得られるだろう。
※普通いくら RoRが遅いと言っても 120件を表示するページの表示に 1.8秒もかからない。この事例は SOAPベースの RPCでの話であり、サーバ側・クライアント側双方におけるマーシャル・アンマーシャル処理および通信にかかる時間を含めた計測である。
とはいえ高速化すると言っても、この時既に DBMSの負荷は無視できる程度だということは確認済みであり、find_by_sqlでダイレクトに SQLを実行しているため ActiveRecordにおける O/Rマッピングのコストも少ないと予想されることから、CPUを使っている時間の殆どは SOAPの荷造りと荷解きに使われてしまっていることが明白だった。与えられた主な選択肢はふたつ。
- このオペレーションだけ、SOAPをやめて生XMLやJSONのようなものを使ってプロトコルオーバーヘッドを軽減する
- このオペレーションだけ、Javaに置き換えることで根本的に高速化する
ここでは、Railsと JEEサーバの両方を起動しておく必要があり運用の面倒はあるものの、JEEがシングルインスタンスで同時に複数リクエストの処理を行えるというオマケがあるため経済効果が将来的に大きいことや、クライアント側のコード変更量が少ないことを考慮し後者を選択した。(というわけで本日の題目になる)
手はずはこうだ。問題となっている重いオペレーションと全く同じ処理を Javaで記述しなおし、Apache CXFを使って SOAP化したものを Tomcat にデプロイする。クライアント側では起動時に WSDLを2種類(Rails側、Java側)ロードし、可能な場合は Java側をコールする。
大きな問題がひとつあった。このサービスではユーザを特定するのに必要な若干の情報をいわゆるセッション変数に保持しているのだ。Rails側で使われているCookieを JEE側でも読み出すことは可能だが(HTTPクライアントから見て同じ Webサーバに見えるようURLのマッピングを工夫すれば良い)、CookieはただのUIDでしかなく肝心のセッションデータ本体はデフォルトだと Rails配下のバイナリファイルに書き出されているためこれを読みに行かなければならない。
プレーンファイルを読みに行くよりもマシな方法がある。Railsではセッションデータストアを切り替えることが出来るのだが、それをファイルでなくデータベースにしてやれば Java側からも JDBCを使って読み出すことが出来るのだ。これだとアプリケーションのインスタンスをノードにまたがって複数起動したくなった場合にも対応出来るためスケーラビリティというオマケもつく。
(RDBのスループットが問題になるようなら代わりにmemcachedという選択肢もある)
設定変更は比較的簡単だ。environment.rbで
config.action_controller.session_store = :active_record_store
という行をコメントインし、
rake db:sessions:create
でセッションテーブル作成のマイグレーションを生成して rake db:migrate すれば良い。
データベースに保存されたセッションデータは
select data from sessions where session_id = ?
で読み込むことが出来る。ここで得られる data 列の内容は、RubyのHashをシリアライズしたデータを Base64でエンコードしたものである。
Java側でこれを解くには、commons-codecの Base64クラスを使って下記のようにデータベースから取得した data 列をデコードし、
byte[] rawData = Base64.decodeBase64(data.getBytes());
さらにデコードされたバイナリをデシリアライズしてやる必要がある。
Rubyでシリアライズされたデータを Javaでデシリアライズするためのライブラリが何かあれば教えて欲しい。私は上手く見つけられなかったので、Rubyリファレンスマニュアル - Marshalフォーマットを参照しながらサブセットのデシリアライザを自分で書き、RubyDeserializerという名前にした。
RubyDeserializerを使ってセッションデータ user_id (Integer) を取り出したコードを示す。
RubyDeserializer rd = new RubyDeserializer(rawData);
Map sessionData = (Map)rd.deserialize();
if (sessionData == null) return null;
return (Integer)sessionData.get(new RubyDeserializer.Symbol("user_id"));
またこれは余所で役に立つ情報とはあまり思えないのであくまで個人的なメモのために書いておくが、CXFのAegis-Bindingでは構造体メンバの名前を制御するためのアノテーションを使うと Rubyのネーミングルールと互換を取ることが出来る。
@XmlElement(name="article_id") // Javaのルールだと articleId になる
public int getArticleId() {
return articleId;
}
public void setArticleId(int articleId) {
this.articleId = articleId;
}
上記のような工夫の末、Javaへリプレースされた問題のオペレーションは元の1800ms前後から 600ms前後まで高速化されると共に、(シングルインスタンスのアプリケーションサーバで)同時に複数のリクエストを処理できるようになった。
注意してほしいのは、サービスの機能全てを Javaで書き直したわけではないということだ。単に Rubyよりも Javaが速いという当たり前の話をここでしたいのではない。今回 Javaで書き直した部分以外の機能は依然として RoRで動作している。なぜか?性能上問題になっていないので、さしあたり書き直す必要がないからだ。言語やプラットフォームの性能論争は実にくだらない。トレードオフを考慮し、要件を満たすものを都度選択すれば良いだけである。制約上そうできないケースも当然ある(というか多くの場合そうだろう)が、それは案件個別の問題であって一般的な議論の中で持ち出しても何も生み出さない。
これにより以後 RailsとTomcatの両方を同時に運用していかなければならなくなったというデメリットは確かにあるが、必要ならサービスの仕様がある段階まで成熟してプロトコルの変更がほとんど無くなった時点でまとまったバジェットを用意して Javaへのフル・リライトを行い一本化することも可能だろう(これも運用性と予算とのトレードオフである)。
その時には入出力仕様が既に明確でかつFixされているのだから、つまらない Javaのコーディングはコストの安い単なる製造担当のエンジニアに任せることが出来る。アーキテクトは創造的な仕事を Ruby on Railsで行い、そうでない仕事を他人に任せることでより創造的な仕事に時間を割く(※)。そんな構図を夢に描いてみた。
※フルAjax, フルFlashといった完全に RPCベースのアーキテクチャを持つアプリケーションならUIとロジックの分離が真に実現しているわけなので実際に可能だと思う
■おまけ■
いわゆる WebサービスをSOAPで実装した時、HTTPペイロードの肥大化という問題がつきまとう。普通だと正味データ容量の3-4倍(それ以上?)といった長さのレスポンスボディが平気で返ってくるが、Apacheに deflateさせたら 1/16ほどに圧縮されたので、これならまあ許せると思う。

0 件のコメント:
コメントを投稿
<< ホーム