Born Too Late

Yuya's old tech blog.

PHP のコードパスを解析する CodePathAnalyzer を作った

2012-11-11 17:55:50

レガシーコードと戦っていると、「このコードのどこをどう通ってこういう結果になってしまっているのか」がわからなくなることがあります。

初見でコードを理解する能力は、コードを読んできた経験が多ければ多いほど、向上するものだと思います。
とは言っても、構造化やモジュール化が適切でなく、スコープの長大なコードなどは、人間の限界を超えているものもあるでしょう。

ステップ実行のできる IDE などを使う、という選択もあると思いますが、僕は重厚な IDE を好みません。
もっと楽にできる方法で、コードパスを解析する方法があれば、ということで作ってみました。

yuya-takeyama/code_path_analyzer

元になっているのは、仕事の時にコード中にベタ書きした関数です。
Gist に公開したところはてブが 10 ぐらい付いたのと、次必要になったときにすぐ使えるようにしておきたかったので、ライブラリ化しました。

使い方

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

Composer を使っている場合は気にする必要が無いのですが、例えば PHP 5.2 などの環境で Composer やそのオートローディングの仕組みが使えない、という場合を想定して、ファイルツリーを適当に配置したら、./src/Yuyat/CodePathAnalyzer/Registrar.php 1 ファイルを読み込むだけで、他に必要な全てのファイルを読み込むようになっています。
パフォーマンスを気にするのであればオートローダを使いたいところですが、デバッグ時しか使われないので、それぐらいの妥協はいいんじゃないでしょうか。

解析対象のできるだけ先頭に以下のように記述すれば、そのファイルを解析することができます。

解析結果は以下のようなテキストファイルとして出力されます。

先頭に + (プラス) が付いているのが、実際に実行された行です。
これを見ることで、どのようにこのプログラムが実行されたのか、を把握しやすくなります。

仕組み

XDebug のコードカバレッジ解析の仕組みを利用しています。

Yuyat_CodePathAnalyzer_Registrar::registerDefault() の実行時に xdebug_start_code_coverage() を実行することで XDebug のカバレッジ解析を有効にするのと同時に、register_shutdown_function() で終了時に解析処理をフックするようにしています。
解析処理の中では xdebug_get_code_coverage() を実行することで収集した解析データを XDebug から取得し、CodePathAnalyzer の各コンポーネントが協調して結果の出力を行います。

出力形式の変更について

現在はテキストファイルへの出力のみに対応していますが、HTML 形式などに出力できたほうがより見やすいでしょう。

AnalysisHandlerInterface を実装したクラスを用意することでそういった拡張にも行えるようにしていますが、個人的にはテキストファイルだけで充分だったので、詳細は割愛します。
要望があればその辺の情報もちゃんとドキュメント化するかもしれません。 (めんどくさくてやらないかもしれません)

そもそもコードパスって...

完成してから気づきましたが、これを持ってコードパス解析、というのはどうかなー、という気がしています。
コードパスというのは「コードの経路」ですから、「この行は実行されたか」といった点の情報ではなく、「どのような順番で実行されたか」という線の情報だと思うので、これだとコードパスとは言えないんじゃないかなーと。

XDebug には IDE でのステップ実行のためのインターフェイスもあったと思うので、それを使って、例えば Socket.IO なんかでブラウザ上に表現できればよりかっこいい!なんていう気はするのですが、今のところそこまでのものは必要としていないので、多分作らないと思います。

まとめ

気軽にコードパス (?) を解析できる CodePathAnalyzer について紹介しました。
そんなに気の利いたツールではないですが、この程度情報があればちょっと楽になる、ぐらいのときに使っていただければ幸いです。

PHP/MySQL でレコードを N 件ずつバルクインサート

2012-11-04 22:59:02

昨日の ParallelHttp の話に引き続き、PHP でのバッチ処理のパフォーマンス改善の話です。
あと、昨日と同じく PHP 5.2 で使えるライブラリを開発した話でもあります。

バルクインサートとは

ひとことで言えば「複数のレコードをまとめてインサートすること」です。

例えば MySQL で言えば、

INSERT INTO `users` (`name`) VALUES ('foo');
INSERT INTO `users` (`name`) VALUES ('bar');
INSERT INTO `users` (`name`) VALUES ('baz');

ではなく

INSERT INTO `users` (`name`) VALUES ('foo'), ('bar'), ('baz');

というようにやることです。

これで何が嬉しいかというと、サーバにクエリを投げてその結果を受け取って、という往復が少なくて済むので、その分効率よくたくさんのレコードを INSERT でき、実行時間が節約できます。

このように手書きで SQL を書く分には何てこと無いですが、プログラムの中でやるとなると微妙に面倒だったりします。

バルクインサート専用ライブラリ Bulky

というわけで作りました。

yuya-takeyama/bulky

Bulky は DB へのバルクインサートだけを行うためのライブラリです。
SELECT や UPDATE といったことは一切できません。

Perl とかだとバルクインサートのできるライブラリがいくつか見つかるのですが、PHP だと今のところ見つけられてません。
皆さんどうやっているんでしょうね。

Bulky を使うと、以下のように一見バルクインサートを思わせないコードで、バルクインサートを実行することができます。
この例では、10 万件のレコードを 50 件ずつバルクインサートしています。

Bulky によるバルクインサートの仕組み

$queue->insert() メソッドを読んだ瞬間は実際の INSERT は実行されません。
とりあえずはキューに溜めて、設定した数 (ここでは 50) に達したタイミングで自動的に $queue->flush() メソッドが呼ばれて 50 件分のバルクインサートが実行されます。

50 件に達しなかった場合も、$queue->flush() を明示的に呼べばキューに溜まっている分だけのレコードをバルクインサートします。
仮に明示的に呼ばなかったとしても、デストラクタで $queue->flush() を実行するようになっているので、暗黙的に全てのレコードが INSERT されるようになっています。

Bulky でのエラーハンドリング

上記の例では、$queue->insert() 呼び出し時にエラーハンドリングを行っていません。
何故かというと、そもそもできないからです。
$queue->insert() は特に値を返しません。
(引数のレコードのカラム数が一致しなければ例外は投げます)

INSERT が成功したかどうかは実際にバルクインサートが実行されるまでわかりません。
なので、予めエラーハンドラをコールバックとしてセットしておき、INSERT に失敗したときにはそれが呼び出されるようになっています。

バルクインサートでは 1 つでも失敗すると、そのクエリ中の全てのレコードが失敗になります。
この例でいうと、1 度の失敗は同時に 50 レコード分の INSERT に失敗したことになります。

エラーハンドラではその 50 レコード全てを配列で引数に受けるので、どのレコードの INSERT に失敗したのかをログに残すなりアラートを飛ばすなりできるようになっています。

MySQL 以外への対応について

この記事のタイトルには MySQL としていますが、Bulky の設計上は特に MySQL には依存していません。

DB 操作それ自体には様々なライブラリを使用できるよう、GoF パターンでいうところの Adapter Pattern を用いて実装しています。
現在のところは PdoMysqlAdapter というアダプタだけが実装されていて、これを使えばデータベース操作は PDO によって行われます。

PDO は様々なデータベースの差異を吸収してくれる組込みライブラリですが、敢えてここで MySQL としているのは、僕が他の DBMS でのバルクインサートをやったことが無いからです。
DBMS によっては同じ形式のクエリでは動かないかもしれないし、動くかもしれない、ということでとりあえずは自分に必要な MySQL 以外については放置しています。

簡単なベンチマーク

Bulky を使うことでどれだけパフォーマンスに違いが見られるか、ということで簡単なベンチマークを行いました。

使用したサーバは仕事で使っている開発用サーバです。
開発用サーバとはいえ、そのサーバ内ではいろんなものが動いていて、無風状態とは言えない適当なベンチマークなので、その辺りはご了承ください。
マシンの詳細なスペックも省略します。

また、適当なので time コマンドを使って、PHP スクリプトの実行時間を計測しています。
スクリプトの実行前には予め対象テーブルを TRUNCATE しているので、一応は平等な条件のもとで計測しています、と言えるんでしょうか。

まずは通常の INSERT の計測です。
何てことは無い、PDO でベタ書きの INSERT 文を実行するだけのものです。

結果は 1m25.381s、約 1 分半もかかってしまいました。

以下は、最初に掲載したサンプルコードを元に、バルクインサートの単位だけ調節しつつ計測したものです。

同時インサート数 処理時間
1000 44.256sec
500 10.800sec
200 9.478sec
120 8.091sec
100 7.242sec
80 6.721sec
50 5.508sec
40 6.267sec
30 6.956sec
10 10.709sec
1 1m30.200sec

この環境では同時インサート数 50 前後でのバルクインサートが一番処理時間が短く、それより多くても少なくてもより多くの処理時間がかかってしまうことがわかります。

予想としては 500 や 1000 ぐらいが一番効率いいんじゃないか、とか思っていたんですが、意外と少ない方がいいようです。

詳しい検証は全くできていませんが、同時インサート数が多いときは何がネックになっているんでしょう?
クエリの組み立てロジックが適当なので、そこら辺の文字列操作がネックになっているのかもしれません。
または、MySQL サーバ側でも、あまりクエリが大き過ぎるとかえって非効率になったりするんでしょうか?

ともかく、今回の場合は同時インサート 50 のときのパフォーマンスで充分だったので、とりあえずはこのまま実運用に投入する方向で検討しています。

Bulky を使ってみる

ParallelHttp と同様、Packagist に登録してあるので、Composer を利用してインストールできます。
Composer をよく知らないという方はこのあたりの資料をご覧ください。

まとめ

MySQL による大量の INSERT を高速化する方法と、それを手軽に実現するライブラリ Bulky について紹介しました。

仕事でコードを書く機会が減りつつある今日この頃ですが、休日にこういう便利で着実に効率の上がるライブラリを開発しつつ、日々の仕事にフィードバックしていくのが 20 台後半に差し掛かった自分なりの生存戦略かなぁと考えています。
あと、今回は要件が PHP 5.2 だったのでニッチな感じになってしまいましたが、チャンスがあれば割とイケイケな OSS 方面にももっと貢献していきたいと考えているので、皆様今後ともよろしくお願い致します。
(React の件いろいろ放置してて本当に申し訳ございません...)

PHP 5.2 で並列数を保ちつつ非同期 HTTP 処理

2012-11-03 17:24:55

最近は Admiral Sir Cloudesley Shovell というバンドの 1st フルアルバム Don't Hear It... Feat It をよく聴いています。
Sir Lord Baltimore のような、骨太なリフと、ブルージーでドラマ性もある楽曲が魅力のバンドですが、現在進行形で活動中のバンドです。
ストーナーロックとか好きな人は是非。

ここから本題です。
あるところに、Web API を数十万回単位で叩きまくる、PHP で書かれたバッチプログラムがあったとしましょう。
処理件数が増えるごとに処理時間が増大するので、いつしか 1 日経っても終わらないようになってしまいました。

そのプログラムは HTTP リクエストを直列に行っていたので、それを並列化させれば何とかなるのではないか、と考えて作ったのが以下のライブラリ。

yuya-takeyama/parallel_http

念のため書いておくと、全然プロダクションレベルにはなってないので、その辺りは自己責任でご利用ください。

試してみる

Packagist に登録しているので、Composer を使用してインストールすることができます。
Composer をよく知らないという方はこのあたりの資料を参考にすると良いのではないでしょうか。

composer install してやると EDPS (エディプス) というキラキラネームのライブラリも一緒にインストールされます。
これは Événement という PHP 5.3 または 5.4 以降で動作するイベントディスパッチャライブラリを 5.2 でも動くように移植したものです。

これを使えば、以下のように、Node.js ライクなコールバックスタイルで HTTP リクエストを行い、そのレスポンスを処理することができます。

PHP で並列 HTTP リクエストを実現する方法

PHP で並列に HTTP リクエスト、といえば curl_multi_* 系の関数がおなじみで、ちょっとググるだけでも以下のような記事が見つかります。

今回作った ParallelHttp も同様に curl_multi_* 系の関数を使用しての並列リクエストを行っています。

また、僕は以前にも HTTP_Parallel というライブラリを作ったことがあって、これも curl_multi_* 系の関数を使用しています。
上記の記事は curl_multi_* 系関数の紹介ということもあって、わりとゴリゴリと実装されていて再利用しづらいので、オブジェクト指向なインターフェイスにラップしよう、というモチベーションのもとに作りました。

既存のやり方がダメだった理由

先に紹介した記事の方法や、それを応用した HTTP_Parallel では、今回の問題に対応することができませんでした。

これらに共通するのは、「リクエストは並列に行うが、レスポンスの処理は最後に全部まとめてやる」というところです。
HTTP リクエストが 1 つでも残っている限りは他のタスクを一切行わず、最後のレスポンスが返ってくるまでただ待ち続けることになります。

これではリクエストを並列化させているだけで、非同期処理とは言えないでしょう。

そこで ParallelHttp は以下のようなフローで HTTP リクエストと、そのレスポンスの処理を行っています。

  1. イベントループにリクエスト情報とコールバックを登録 ($client->get($url, $cb))
  2. イベントループを開始 ($loop->run())
  3. 登録されたリクエストを同時に実行
  4. 終わったリクエストが無いかチェックし、終わったものから逐一コールバックで処理、まだ終わってないものは引き続きチェック
  5. 全てのリクエストが終了したらイベントループを抜ける

リクエストを待つ間に、先に処理できるレスポンスを処理することで、待ち時間を有効に活用することができます。
数 10 件程度のリクエストなら大した差は出ないと思いますが、今回のように数 10 万規模のリクエストを行うとなると、かなりの差が出ることが予想されます。

処理件数によるさらなる問題

レスポンスの処理を逐一行うことで、処理効率を上げられそうな感じがしてきました。
が、問題はその手前にありました。

前のセクションに書いた通り、イベントループを開始すると、登録されたリクエストを同時に実行することになるので、素直に実装するとものすごい数のリクエストが全て同時に行われてしまいます。

そうなると以下のような問題が発生します。

  1. ものすごい数の処理を同時に実行するので、CPU 等のマシンリソースを大量に消費する
  2. 対象のサーバが単一または小数だった場合には DoS 攻撃になってしまう
  3. それ以前に全てのリクエスト情報とそのコールバックの分のメモリを確保する必要がある

これらについても解決しないと、今回の問題に ParallelHttp を使うことはできませんでした。

対策 1. 並列数を保てるようにする

同時に行うリクエストの数を設定として持たせ、最大でもその数までしか並列化させなければ、ローカルマシンにもリモートマシンにも優しくなります。

ParallelHttp ではイベントループの内部にキューを持っており、並列数が最大になるまでは curl_multi リソースにリクエスト情報を登録していきます。
curl_multi に登録したリクエスト情報が最大に達すると、その後のリクエストはとりあえずキューの中にためておいて、1 つでもレスポンスが返ってきたらそれを処理しつつ、キューから次のリクエスト情報を curl_multi リソースに登録する、という方法を採りました。

イベントループの生成時に以下のようなオプションを指定することで、並列数を指定することができるようになります。

デフォルトでは 10 になっているので、必要に応じて調節することで、さらなるパフォーマンスを得たり、マシンリソースに気をつけて処理を行ったりすることができます。

これで、前のセクションで示した問題のうち最初の 2 つが解決されました。

対策 2. リクエスト情報の登録を少しずつにする

最後に残るのは、リクエスト情報とコールバックのために、大量のメモリを確保する必要がある、という点です。

結論からいうと、これは ParallelHttp の実装だけで解決することはできませんでした。
そこで、ParallelHttp の使い方を工夫することにしました。

例えば、処理対象の URL がデータベースに 30 万件入っていて、それらの全てにリクエストを行う場合を考えてみましょう。

memory_limit の設定値等にもよりますが、普通は $loop->run() の手前で、メモリ使用量が上限を超えて強制終了になるでしょう。
URL 30 万件程度ならメモリを多めに確保しておけば何とかなりますが、ParallelHttp の内部で生成しているリクエスト情報を保持したオブジェクトであったり、コールバックに使う無名関数であったりでメモリを大量に消費します。

コールバックに使う関数が単一でよければループの手前で変数にいれておき、それをコールバックとして登録するようにすれば、コールバック分のメモリは節約できます。
ですが、クロージャを使用する場合等はリクエストごとにユニークなコールバックを生成することになるので、そういうわけにもいきません。

URL を一度にまとめて取得し、その全てをイベントループに登録するのではなく、少しずつの URL をイベントループに登録し、そのリクエストが全て終わった段階で新たに URL を登録していく... というのを延々繰り返すようにしてみます。

これは擬似的な例ですが、実際にこういうのを作ってみてとりあえず 1 週間はまともに動かすことができました。

ここで UrlIteratorIterator としているものについて、具体的に説明すると、外側のイテレータはデータベースから URL の一覧を LIMIT 1001 で取得し、1000 件分の URL を内側のイテレータとして返します。
LIMIT 1001 としたのはまだデータベース内に次の値があるかどうかをチェックするためで、1001 番目の要素に値があれば、オフセットを適当に指定しつつ次の 1001 件を取得する、といったことを繰り返しています。

まとめ

PHP 5.2 で動作する非同期 HTTP 処理ライブラリ ParallelHttp について紹介しつつ、その中で直面した問題と、その解決について説明しました。

高いパフォーマンスを得る上で非同期処理は大変魅力的ではありますが、それはそれで別の問題が生まれるので、一筋縄では行きません。
とはいえ、筋のいいライブラリであったり、実装パターンを使用することである程度は緩和することができるでしょう。

今回作った ParallelHttp については ReactNode.js の標準ライブラリのコードを大いに参考にさせていただきました。
また、前にブログで紹介した AsyncMysql を実装した経験も大いに活かすことができました。

いろんな実装パターンを学んで、フレームワークや言語にとらわれないプログラミングスキルを身につけて行きたいと感じました。