こんにちは、名古屋スタジオでサーバエンジニアをしている山脇です。
前回執筆した記事では、Balckfireを用いたパフォーマンス改善の探し方というテーマで書かせていただきました。今回は前回から発展して実際どのようにパフォーマンス改善したか、開発言語をPHP、メインのデータストアをMySQLとしたサーバ構成で説明します。
データの件数が多い問題への対応
データが多いと、データストアからAPIサーバへのI/Oに時間がかかります。
また、データを受け取ったプログラムでもループ処理が多くなり、負荷が高くなります。
この問題の原因は「条件が不十分なため、件数が絞れていない」ためでした。
そのため、解決方法は簡単でMySQLのクエリに条件を追加する対応をとりました。
自分たちが対応した一例は、開始と終了日時で期間を持っているテーブル構造で開始日時しか見ておらず、終了しているデータも全て取るというクエリがあったので、終了日時も条件に加え件数を減らすことをしました。もちろん条件変更前にプログラムを確認して取得関数後に終了しているデータを除外しているかを確認する必要があります。
修正前
SELECT
*
FROM
`event_data`
WHERE
`start_date` <= NOW();
修正後
SELECT
*
FROM
`event_data`
WHERE
`start_date` <= NOW()
AND
`end_date` >= NOW();
これだけの変更で取得するデータの件数が数万件だったものを1/10くらいの数千件になりました。
この問題で厄介なところとしては、運営をしていくにつれ終了したデータが増えAPIの処理時間に影響が出てくるところです。また、機能を実装した当初にパフォーマンスチェックをしても終了しているデータが少なく問題になりにくいところです。
クエリの実行時間が長い問題への対応
MySQLのテーブル構造やクエリの内容が悪く処理に時間がかかるものでした。今回は「JOINによってインデックスが使われていなかった」と「インデックス列に対して否定形の演算子を使っていた」が問題になっていたので解決しました。
JOINによってインデックスが使われていなかった
SQLといえばという感じですが、インデックスの貼っていない検索条件に対して実行していました。
2つのテーブルをJOINしているがインデックスの貼ってある方のテーブルを指定していなかったため、インデックスが使われていませんでした。
なので、テーブルの向き先を変えてインデックスが効くように修正しました。
構造
CREATE TABLE `table_a` (
`id` int(10) unsigned NOT NULL,
`no` int(10) unsigned NOT NULL,
`name` text NOT NULL,
PRIMARY KEY (`id`,`no`),
);
CREATE TABLE `table_b` (
`id` int(10) unsigned NOT NULL,
`no` int(10) unsigned NOT NULL,
`val` int(10) unsigned NOT NULL,
PRIMARY KEY (`id`,`val`),
);
修正前
SELECT
*
FROM
TABLE_A AS ta
JOIN
TABLE_B AS tb ON ta.id = tb.id AND ta.no = tb.no
WHERE
(ta.id, tb.val) IN ((1, 1), (1, 2), (1, 3), (1, 4));
修正後
SELECT
*
FROM
TABLE_A AS ta
JOIN
TABLE_B AS tb ON ta.id = tb.id AND ta.no = tb.no
WHERE
(tb.id, tb.val) IN ((1, 1), (1, 2), (1, 3), (1, 4));
インデックス列に対して否定形の演算子を使っている
EnumでA,B,Cが定義されている列があり、C以外のレコードが必要でした。columnにはインデックスが貼られていたが否定形の演算子を使っていたためインデックスが使われておらず実行時間が長くなっていました。
解決方法は、A、Bである行を取るようにすることでインデックスを利用できるように修正しました。
修正前
WHERE `column` != 'C'
修正後
WHERE `column` IN ('A', 'B')
クエリの計算量が多い問題への対応
キャラやプレイヤーの情報で経験値とレベルを管理するテーブルで総経験値の計算でサブクエリを使っていました。
サブクエリで全レベル分計算しようとするとデータ件数の二乗分の計算していたため時間がかかっていました。
解決策はサブクエリを使わず並びだけをレベルの昇順で取得してPHPのプログラム側で総和を計算しつつ、対象行にデータを設定していくことでデータ件数分の計算量にすることで改善しました。
in_array関数の実行回数が多い問題への対応
PHPでよく言われるin_array関数は遅い問題です。よく使うパターンではデータスタアから取得したデータに特定IDを含むかどうかを調べる時に使うのでよく使う関数になります。
解決方法としては配列に対してarray_columnの第三引数を使用して前処理をしておき、isset関数を使うようにします。
修正前
$arr = [
['id' => 1, ...],
['id' => 2, ...],
['id' => 3, ...],
];
$ids = array_column($arr, 'id');
if (in_array(3, $arr)) {
// 配列にあった時にしたい処理
}
修正後
$arr = [
['id' => 1, ...],
['id' => 2, ...],
['id' => 3, ...],
];
$ids = array_column($arr, null, 'id');
if (isset($ids[3])) {
// 配列にあった時にしたい処理
}
型変換に時間がかかる問題への対応
日付intの変換
データストアから取得した際は日付データもフォーマットにそった文字列のため、strtotime関数にかけてint型で比較していました。
また同じように比較対象になる現在時刻を取得する時もdatetime型からint型に変換するため、timestamp関数で取得する処理をしていました。
型変換に時間がかかるのもありますが、データの件数が多い問題と合わさることで問題になりやすいです。
解決方法としては、決まったフォーマットであれば、文字列で比較してもほとんどの場合問題ないのでいちいちstrtotime関数をかけないように修正しました。
データストアのデータからオブジェクトに変換
フレームワークにはO/Rマッパーという仕組みがあり、データストアから取得したデータをオブジェクトにしてくれる便利なものがあります。ただし、この処理は、新しいオブジェクトを生成する、配列からデータを取得してメンバー変数に設定するなどの処理がされるため、それなりに時間がかかります。
そのため、連想配列のまま処理した方が早いのでオブジェクトへの変換をしない対応をしました。
O/Rマッパーの仕組みはフレームワークの他の機能を使うのにすごく便利な機能です。しかし、スピードを求める時には削れる機能なので今回は使わないように判断しました。
最後に
これまでに自分が改善してきた内容で目に見えて効果があったものをまとめてみました。
よく聞く改善方法も多々あったと思いますが、遅いと知らずに実装してしまうのと知っていて回避するのではプロジェクトの健全性はだいぶ違ってくると思います。
パフォーマンス改善に取り組み出した人の改善点の当たりをつける参考になったり、これから新規機能実装、機能改修をする人が遅い処理を使わず実装する手助けになれば幸いです。