PHP(Laravel)におけるバッチ処理を高速化させる上でやったこと

こんにちは。投資戦略システム事業部サーバーエンジニアの澤口です。
8月中頃に中途入社させていただき、本記事が公開される頃にはちょうど3か月目となります。

入社してからのお仕事としては、裏側のとある管理画面の実装を担当したのち、最近はLaravelのコマンドで実装されているバッチ処理の速度改善をメインでやらせていただいているところです。

ネタとしては後者の速度改善の方が書いて面白いかなと思ったので、特に高度な技術を使っているなどということはないのですが、まとめとして書かせていただければと思います。

はじめに

まず、前提となるバッチ処理についてですが、これは「多数のファイルを処理し、ユーザー数やユーザーのアクション数に比例して処理時間が長くなる」といった類のバッチになります。

そして大変ありがたいことに、そのユーザー数が最近とても増えてきており、そろそろ処理を見直さないと終わってほしい時間内に終わらなくなってしまう可能性が生じてきた、というのが速度改善が必要となった経緯です。

実際にやったことを順を追って書いていこうかと思いますが、そのままのコードを書くわけにはいかないので、代わりに速度変化のわかる簡単なサンプルコードを添えてまとめていきたいと思います。

やったこと

速度計測をする環境を整える

まずは簡易的に速度計測用を行えるようなメソッドをクラスにまとめました。 最初はmicrotime関数の差分をとって表示する程度のシンプルなものだったのですが、他の方のコミットもいただきつつ、徐々に使いやすいように機能拡張していき、いまでは少しリッチにSlackに通知できる形になっています。

以下の画像はブログ用に出力した適当なデータですが、こういった集計値から、どこを優先的に改善すべきなのか、データ毎、日毎で処理時間の偏りがあるのか、などを調査・監視することができます。

計測ポイント毎の処理時間
計測ポイント毎の処理時間

処理ファイル毎の処理時間
処理ファイル毎の処理時間

日毎の処理時間
日毎の処理時間

実際に速度改善する

おおよそ、ここに時間がかかっているんだなーということがわかってきたので今度はそれに対する対処です。

※ 順に本番リリース・効果検証をしていますが、現時点(11/9)で未リリースなものも含んでいます。

バルクインサート

大量のインサートを行うときは、インサートする件数分クエリを投げるよりも、データをまとめて1クエリで投げられるバルクインサートというやり方のほうが速度が早くなります。

今回、調べてみるとインサートが大量に行われている箇所が比較的大きなボトルネックになっていることがわかったのでまずはここを対処しました。

サンプルコード

$SimpleSpeedDebugger = new SimpleSpeedDebugger(true);

////////////////////////////////////

for ($i=0; $i < 30000; $i++) {
    Test::insert(['name' => '*********']);
}
$SimpleSpeedDebugger->result('通常のインサート');

////////////////////////////////////

$params = [];
for ($i=0; $i < 30000; $i++) {
    $params[] = ['name' => '*********'];
}
$params_chunked = array_chunk($params, $chunk_size = 1000);
foreach ($params_chunked as $param_chunked) {
    Test::insert($param_chunked);
}
$SimpleSpeedDebugger->result('バルクインサート');

////////////////////////////////////

結果

local.INFO: <通常のインサート> Running Time: 47.992627859116 [s]
local.INFO: <バルクインサート> Running Time: 0.29006409645081 [s] 

圧倒的に速くなりますね。

ただし、バルクインサートではプライマリーキーが返ってこないため、これが必要な場合は少し厄介です。

基本的にはBinary型のUUIDをプライマリーキーにして、PHP側で生成したプライマリーキーをインサート前に指定してしまう方法が一般的なようですが、LaravelではBinary型をうまく扱えるようにできていないようなので、以下のライブラリを使って拡張していくのがよさそうです。

github.com github.com

スロークエリ分析 + インデックス最適化

インサートの部分はコードを見てここが重そうとすぐに分かったのですが、それ以外はどこが重くなっているのか、というのは直感的にわかりませんでした。 特に、「1回の実行は軽いけど何度も実行しているので結果として全体の処理時間の大半を占めている」といったクエリはコードを見ているだけだと見逃してしまうケースも多いです。

そこでローカル環境でバッチ実行時のクエリをすべてログに取り、mysqldumpslowで累計実行時間でソートしてみました。

まず、以下のようにmysqlの設定をします。

mysql> set global slow_query_log_file = '/tmp/mysql-slow.log'; // 任意のパス
mysql> set global long_query_time = 0;
mysql> set global slow_query_log = ON;

設定をしたら該当のバッチ処理を実行したのち、ソートです。

$ php artisan <バッチ処理を実行するコマンド>
$ mysqldumpslow -s t /tmp/mysql-slow.log > result.txt

これでresult.txtに分析結果が出てくるので、さらに上から順にMySQLの実行計画を見ていきました。

結果として、インデックスの観点ではほとんどが適切に張られていて、一部テーブルフルスキャンをしているようなクエリが見つかったものの、使用頻度が低いかつレコード数の少ないテーブルであったのでそれほど改善にはつながりませんでした。

一方、クエリ実行回数と累計実行時間の観点では、いままでそれほど注目していなかったクエリに比較的多くのコストを割いていることがわかりました。 ここについてはバッチ処理外の仕様も大きく絡むためまだ未対応ですが、現在調整中です。

メモ化

forループ内でとあるマスターデータをRedisから参照しているコードがありました。

Redisはオンメモリデータベースなので比較的処理が早いのですが、アクセス回数が多くなってくるとやはりそれなりにボトルネックになってきます。

そこで取得済みデータを連想配列にキャッシュして複数回取らないようにするメモ化を利用してみました。

以下のサンプルコードでは100回目以降は値がキャッシュされ変数参照となるようになっています。

サンプルコード

$SimpleSpeedDebugger = new SimpleSpeedDebugger(true);
for ($i=0; $i < 100; $i++) {
    Redis::set('test_' . $i, '*********', 'EX', 86400);
}

////////////////////////////////////

$answers = [];
for ($i=0; $i < 10000; $i++) {
    $key = 'test_' . ($i % 100);

    $answers[] = Redis::get($key);
}
$SimpleSpeedDebugger->result('メモ化前');

////////////////////////////////////

$answers = [];
$redisData = [];
for ($i=0; $i < 10000; $i++) {
    $key = 'test_' . ($i % 100);

    $answers[] = $redisData[$key] ?? $redisData[$key] = Redis::get($key);
}
$SimpleSpeedDebugger->result('メモ化後');

////////////////////////////////////

結果

local.INFO: <メモ化前> Running Time: 0.84238600730896 [s]
local.INFO: <メモ化後> Running Time: 0.021033048629761 [s]

こちらもかなり大きく差が出ました。

実際のところはキャッシュヒット率がどれくらいかで大きくされるかと思いますが、今回のバッチ処理も今後データが増えていくにつれてヒット率が上がるような構造だったので、有効な対策にはなりそうです。

呼び出し最適化

Laravelではさまざまなライブラリが用意されており、とても便利であるのですが、大きなforループの中では塵積って意外と重い処理となってしまっていることも多いようです。

処理内容によっては使えない場合もありますが、速度が重視される個所では、あらかじめ変数に格納したり、ネイティブ関数に置き換えるだけで結構速度も変わってきます。

例として今回のバッチ処理で呼び出し回数の多かったCarbonによる日付系の処理について見てみます。

サンプルコード

$SimpleSpeedDebugger = new SimpleSpeedDebugger(true);

////////////////////////////////////

$answers = [];
for ($i=0; $i < 10000; $i++) {
    $answers[] = Carbon::now()->format('Y-m-d H:i:s');
}
$SimpleSpeedDebugger->result('Carbonで直接値を参照');

////////////////////////////////////

$answers = [];
$now = Carbon::now()->format('Y-m-d H:i:s');
for ($i=0; $i < 10000; $i++) {
    $answers[] = $now;
}
$SimpleSpeedDebugger->result('Carbonで変数から参照');

////////////////////////////////////

$answers = [];
for ($i=0; $i < 10000; $i++) {
    $answers[] = date('Y-m-d H:i:s');
}
$SimpleSpeedDebugger->result('ネイティブ関数で直接値を参照');

////////////////////////////////////

$answers = [];
for ($i=0; $i < 10000; $i++) {
    $answers[] = date('Y-m-d H:i:s');
}
$SimpleSpeedDebugger->result('ネイティブ関数で変数から参照');

////////////////////////////////////

結果

local.INFO: <Carbonで直接値を参照> Running Time: 0.18904900550842 [s]
local.INFO: <Carbonで変数から参照> Running Time: 0.011945009231567 [s] 
local.INFO: <ネイティブ関数で直接値を参照> Running Time: 0.0097908973693848 [s]
local.INFO: <ネイティブ関数で変数から参照> Running Time: 0.0093591213226318 [s] 

変数を格納しておいて使いまわすほうが早いのは当たり前ではありますが、直感的にそれほど重くなさそうに感じる Carbon::now() が意外と遅いのは気を付けなくてはいけないところかもしれません。

マルチスレッド並列化

こちらはまだ検証中なのですが、Laravelのコマンドで実装していたバッチ処理をジョブとして実装しなおして、本番環境のCPUのスレッド数を最大とした並列処理を行うように修正を進めています。

今回の実装で気を付けなければいけない点としては、「並列処理をするが、同一ユーザーについては処理するファイルの日付が前後してはならない」という制約がある点です。

そのため、普通にLaravel Horizonで負荷に応じたジョブキューのバランシングなどを行うと、同一キューレーンに同一ユーザーの処理を必ず割り当てる、といったようなことはできず、未来日付の処理が過去日付の処理を追い越してしまう可能性もあり得る状態となってしまうという懸念が生じてしまいます。

そこで現時点では、以下のようなメソッドで同じユーザーID(文字列UUID)であれば同じ数字になるという条件を満たしつつ、それなりに均等に番号を割り当てるようにし、その番号のキューに登録するという仕組みで検討しています。 また、登録されたキューは、各キューを指定したキューワーカーがsupervisorによってマルチスレッドで起動し、並列処理されていくイメージです。

/**
 * 文字列を決定的変換で指定範囲内の数字に変換する
 *
 * @param  String  $str   変換する文字列
 * @param  Integer $range 出力する数字の範囲
 * @return Integer
 */
public function convertDeterministicallyFromStrToInt($str, $range = 10)
{
    return hexdec(bin2hex(mhash(MHASH_ADLER32, $str))) % $range;
}

※ より良い方法がありましたらご教示いただけますと幸いです。

DBフラグメンテーションの定期解消

バルクインサートのところで「インサートが重い」というお話が出たかと思うのですが、こちらでは別の切り口でも見てみました。

処理を追っていくと全体的にレコードのDELETEが頻繁に発生する個所が多いようでした。

DELETEを頻繁に行うようなテーブルでは内部で断片化が起こるようになってきます。

断片化が進んでくると、データ容量増加やインサート速度低下だけでなく、適切な実行計画が払い出されなくなってきて、SELECTやUPDATEまでも速度低下してしまうというような可能性もあるようです(↓以下参照)。

techblog.yahoo.co.jp

そうなってくると、問題はかなり大きくなってしまうのですが、対処方法は簡単で、OPTIMIZE TABLE をSQLで実行してあげるだけで解決することできます。

mysql> OPTIMIZE TABLE;

なお、上記リンク先ではテーブルロックについての懸念も書かれていますが、MySQL 5.6.17移行であればオンラインDDLで実行されるため、比較的短時間のテーブルロックで済むそうです。(とはいえ、テーブルアクセスの少ない時間帯を狙うようにはすべきですね。)

dev.mysql.com

こちらについてはまだ未リリースですが、一定閾値以上の断片化が生じた場合のみ OPTIMIZE TABLE を実行するというようなバッチを作成しました。

さいごに

長くなってしまいましたが、ここ最近の速度改善にトライした内容をまとめてみました。 今後については、そもそものデータを保存する形式や、データをやり取りする形式自体を見直していくような改善であったり、アルゴリズムベースでコードを見直していく、より高度な高速化について取り組んでいければと考えています。

まだ、私も入社したばかりの身ではありますが、このようなお仕事の一端を見ていただき、テコテックに興味を持っていただけた方は、ぜひ下記リンクも覗いてみていただけましたら幸いです。

www.tecotec.co.jp