27
2019

Laravelで負荷対策としてやっとくこと

CATEGORYPHP
お仕事で作ってるLaravelアプリがついに負荷試験も終わったので、やったこととか調べたこととかを、忘れないうちに改めて基礎からまとめてみる。Laravelのバージョンは6.x。
なお「Laravelで」と題しているが、「Laravelのここを設定しろ」みたいな話じゃなくて、インデックス貼れだのLaravelに限らずWebアプリなら当然やっとけみたいな話が中心のまとめなのでご注意を。
あと個別の手法の詳細も書いてない。それは必要ならリンク先見たりググったりしてくださいm(__)m

インデックス貼れ

LaravelというかDB使うアプリ全部の大前提。テーブルにきちんとインデックスを貼る&インデックス使いましょう。どれだけアプリを頑張っても、インデックス無しのDBを全検索してたらそれ以前の問題なので、まずは再確認。

インデックスの作成は、Laravelの場合はマイグレーションのテーブル定義で出来る。
主キーとかuniqueキーとかも設定できるので、ちゃんと一通り設定しましょう。

あと、設定したインデックスは使わなかったら当然意味はないので、whereやorderByの条件として忘れず設定しましょう。

ページングしろ

同じくアプリ共通の話。大量のデータを一度に取得するのはどう頑張っても時間がかかるので、データ件数が多いor不定の場合は、基本的にページングするようにしましょう。

Laravelの場合は、paginate() とかを使えば非常に簡単に出来るので、それ使いましょう。
自前で似たようなことやる場合は、クエリでskip/takeとかoffset/limitとか使えばOK。

大量のデータを一度に扱うと死ぬから細かく分ける、というのは以後も頻繁に出てくる基本なので覚えておこう。

with使え

ORM共通の話題。Eloquentはリレーションも自動で取ってきてくれて便利だけど、何も考えずにループ内でリレーションにアクセスすると容易くN+1問題を起こすので、必要に応じてwithとかでEagerロードしましょう。

ネストしたリレーションもできるし、既に取得済みのモデルでもコレクションまとめてloadとか出来るから、ループで使うときは忘れずに。

cursorかchunk使え

画面系でデータを小分けにするのはさっきのページングで大体足りるけど、バッチで全データチェックするとか、CSVで全データエクスポートするとか要件によっては使えない。
そういうときは、cursorやchunkを使えば、データを一度に1件~数十件とかの単位で小分けにして読み込んで処理することができる。
例えバッチであっても、allで10万件とかしたら普通にメモリ不足で即死なので、小分けにしてメモリ節約しましょう。

なお、cursorとchunkの差は、cursorがよりSQL寄りな機能で、chunkがLaravelでそれをラップしてる感じ?
cursorだと1件単位なので処理はシンプルになるけど、withとかが効かない。普通に使う際はchunkの方がよさそう。

※ cursorとchunkの違いは追加記事にまとめました。

streamDownload使え

CSVエクスポートの処理とか、chunk使うようにしてこれでめでたしめでたし…と思いきやそんなことは無い。
それを普通に配列にしてレスポンスを返したら、結局メモリ不足で死んでしまう。
なのでそういうときはstreamDownloadを用いましょう。

公式ドキュメントだとファイルダウンロードの説明のところに出てくるけど、別に↓みたいにContent-Type指定してやればJSONでも何でも返せる。
こうやって返せば、量が多いデータでもメモリ不足になることは無い。
return response()->streamDownload(function () use ($iterator) {
echo '[';
$first = true;
foreach ($iterator as $value) {
if (!$first) {
echo ',';
}
echo json_encode($value);
$first = false;
}
echo ']';
}, null, ['Content-Type' => 'application/json']);
なお、streamDownloadでDBデータとか返す時は一点注意点が。
クロージャの中でエラーが起きても、streamDownloadを呼んだ時点でもうヘッダーとかが返っているのでエラーレスポンスが返せない。
エラーが起きると、不完全なレスポンスが返ってきて悩むことになるのでご注意を。

バイナリはFileそのままかstreamDownload使え

Laravelからファイル(例えば画像だのPDFだの)を返す時は、それがサーバー上にあるファイルならFileレスポンスFileダウンロードを、DBとかにあるデータならさっきのstreamDownloadで返しましょう。
わざわざfile_get_contentsで読み込んだりする必要はないし、デカいファイルの場合に危険。

静的ファイルはアプリ外に出せ

Fileレスポンスで返しましょうとか言ったあとに何だけど、そもそもLaravelから静的ファイルを返すのは筋が悪い。可能であれば、ファイルはNginx/Apache下のパスに置いて、彼らに返させるようにしましょう。アクセスが多いようであれば、CDNを使って高速化することもできる。

ただ、例えば会員限定のPDFとか、うっかり見えてしまうと不味いものは難しい。やる時は仕様とも相談しましょう。

キャッシュしろ

これも別にLaravelに限った話じゃないけど、DBアクセスは遅いし、一番ボトルネックになりやすいので適度にキャッシュしましょう。
キャッシュの保存先は、個人的にはRedisがお気に入り。早いしシンプルだし。
システムの規模によっては、そこまでしなくてもファイルキャッシュとかで足りるかもしれない。

自分は標準のCacheの他に、laravel-model-cachingとかlaravel-responsecacheとかのOSSライブラリも使ってキャッシュした。使えるならこういうの使うと楽。

ただし、キャッシュはよく考えずに使うと「データが更新されない」「他人のデータが表示された」とかの致命的なバグを生み出す恐れがあるので、ご利用は計画的に。
性能要件が厳しくないなら、マスタデータやお知らせとかの、誰に対しても同じで、更新タイミングが分かりやすいもの、だけ使うのをお勧め。

ブラウザにキャッシュさせろ

Webアプリのブラウザキャッシュの話。サーバーでのキャッシュも有効だけど、そもそもブラウザ側でキャッシュしてくれてアクセスしてくれなければ最高なので、可能であればそれを使うようにしましょう。
具体的には、Laravelはデフォルトではそもそもno-cacheヘッダーとかを返しているので、SetCacheHeadersミドルウェアを有効にしてキャッシュ可なヘッダーが返るようにしましょう。

で、さらに可能であればレスポンスヘッダーにExpiresとかを指定して、ブラウザに明示的にキャッシュを使うように指示しましょう。
(キャッシュ指定には他にもETagとかあるけど、アプリ的に設定しやすいのはExpiresだと思う。)

ただし、これも当然さっきのサーバー側キャッシュと同じ問題を起こしえるので、扱いには注意。キャッシュされても問題ないページやAPIに留めましょう。

なお、クライアントがブラウザじゃなくてアプリの場合は、せっかくキャッシュ可を指定してもキャッシュしてくれるとは限らない。そういうときは、アプリ側にキャッシュするよう依頼しましょう。
(第三者が作ったアプリがアクセスしてくるときはお手上げ。)

デカいJSONのバリデーションは重いぞ

これはLaravelアプリの負荷試験してて気付いた話。
Laravelのバリデーションは簡単に書けて便利なんだけど、巨大JSON (100KB程度) を毎秒数件とか送りつけてみたら、CPU負荷が急上昇してしまった。
で、xhprofで調べてみると、単純(必須チェックや文字列長、数値や日付か)なバリデーションしかしてないのにも関わらず、バリデーション周りが処理時間の大半を占めていたという。
モデル配列みたいなデータを大量にバリデーションするのは、LaravelではCPUに厳しいようだ。。。

自分のケースでは、クライアント側に無駄なデータを送らないようにしてもらって何とかなったけど、後から問題に気付いてもどうにもならない場合もあるのでご注意を。
(追記、単にLaravelのバリデーションのオーバーヘッドが大きいだけっぽいので、アプリ側でissetとかべた書きして自前でやるのも効果的でした。)

なお、existsとかのDBアクセスするようなバリデーションはそもそも重いので、データ量多い場面では迂闊に使わないように。

Eloquentモデルはメモリ喰うぞ

大量データでの試験をしていて気づいた話。
LaravelのEloquentで普通にDB検索するとモデルクラスのインスタンスが返ってくるわけだけど、これが結構メモリ喰う。
うっかり2万件とか取ってしまう処理があったのだが、一発でメモリ超過で死んでしまった。

上述のようにページングやらchunkやらにすべきなのは置いといて、Eloquentのインスタンスでは内部的にいろんな情報を保持しているようで、2万件で200MBとか確保されていた。
これが、単純にtoArray()でデータだけを連想配列にしてやると、20MBで済んだ。
一度に大量のデータを扱う場合は、インスタンスで処理しないことも考えましょう。

SQL頑張れ

ORM使ってるとSQLを意識しなくなりがちだけど、性能出すためにはちゃんと活用しましょうという話。
単一テーブルをCRUDすれば済むレベルの話ならともかく、複数テーブルを結合する場合はjoinも考えるべきだし、特に集計的な処理を行う場合はPHP側ではなくSQLで行わなければまともな性能でないので、ちゃんと活用しましょう。
SQLこわくない。

BULK更新しろ

同じくSQLにはBULK INSERTという複数レコードをまとめて登録する構文があり、大量にデータを登録する場合、1レコードずつ登録すると比べものにならないぐらい性能差がでるので、これも活用しましょう。
Laravelの場合、クエリビルダのinsertに配列を渡せば勝手にBULK INSERTしてくれるので、使いましょう。
(ただし、Eloquentインスタンスそのままは使えないので、使い勝手は悪い。)

なお、DBによってはBULK UPSERT的なもの(あればUPDATE、なければINSERT)もあるが、それはLaravel標準では対応していない模様。
自分でSQL文を組み立ててDB::statementで流せば使えるので、必要なら頑張りましょう。

gzip圧縮しろ

今度はインフラ寄りの話。Webアプリでは通信量の削減も大きな課題なので、NginxやApacheを設定して、gzip圧縮が効くようにしましょう。
これは基本的にアプリ側では実装しない。NginxやApache側の設定を弄ってやればそれだけで圧縮されるようになるので、忘れずにやっときましょう。

なお、クライアントがUnityだったりすると、標準ではgzip圧縮に対応していないため、せっかく設定しても使ってくれません。
が、自力で Accept-Encoding: gzip ヘッダー送って、GZipStreamで展開すれば簡単に使えるので、対応して貰いましょう。

本番用のデプロイしろ

Laravelのデプロイの説明読んでデプロイしてたらそうなってる筈だけど一応。
手順通りにやるとオートローダー最適化と設定ローディングの最適化をやる事になるのでやっておきましょう。
(ただし、どの程度性能に影響するのかは同環境で試してないので未確認。)

あと、性能には関係ないかもしれないが、APP_DEBUGをfalseにしたり、APP_ENVをproductionにしたりするのも忘れずに。

バッチのトランザクションは短く切れ

バッチ処理共通の話題。バッチ処理でDBのトランザクションを切る場合は、バッチの全体を囲まずに、1件とか10件とかそういう小さな単位でトランザクション分けましょう。
間違っても、10万件処理するバッチでトランザクションはプログラム全部とかやってはいけない。
その間は中途半端にロックされたデータが残り続けるし、メモリも圧迫するし。

なお、トランザクションを細かく分ける場合は、エラーで中断した場合に備えて、再実行の手順を考えておく必要がある。
処理済みのデータはスキップや上書きするのか、それとも1万件目からスタートのように再開位置を指定できるのか、そういう仕組みとセットじゃないと障害時に悩むことになるので注意。

datetimeプロパティは重い?

これは負荷試験してて気付いた奴。Eloquentモデルはミューテタをちゃんと設定すると日時データをCarbonインスタンスで返してくれて便利!ということで設定してたんだけど、負荷試験してみたら、ここもCPUを食ってたという。
と言っても、CPU負荷が低い状況や、テーブルに2個や3個ある分にはどうってこと無いんだけど。
CPU負荷が高い状況で、日時がテーブルに10個とかあるデータを数百件単位で取得したら、今度はそこが処理時間の大半を占めてしまったという。

今回は、JSONに変換した後のものをキャッシュすることでとりあえず回避できたけど、これも状況によってはハマる可能性があるので注意を。

Lumen使う?

Laravelは機能が豊富な一方、フレームワーク自体が重いので、それを削って高速化したLumenを使うべきか?というのもちょっと検討した。
(ただし、今回は使わなかったので、実際のところどうなのかまでは確認していない。)

Lumenはベンチマーク等を見るにかなり軽量な様子。一方で、Laravelの便利な機能がデフォルトでは無効化されているらしい。
(FacadesやEloquentも。ONには出来るがそうやってるとどんどん重くなりそう。)

今回はそこまで性能重視では無かったのと、一般的にボトルネックになるのはDB周りでWebサーバーじゃないし、いざとなれば台数を増やせばいいやという事で、Laravelにした。
後は、Laravelの魅力は豊富な機能や情報だと思ってるので、それを半分捨ててLumenにするのにあまりメリットを感じなかったのも。
それ捨ててまで高速化目指すなら、PHPよりもGoとかもっと速い言語使った方が良いのではとも思った。

とはいえ、用途によってはLumenが良いこともあると思うので、検討はしてみていいと思う。

補足1) 負荷が高いときによくある現象

まとめは一旦以上で、ここからは補足。補足の最初は、負荷試験でよく見るエラーやらの解説と対処法について。

Allowed memory size of xxx bytes exhausted

PHPが使用可能なメモリ上限を超えてしまったときに出る奴。エラーが出た瞬間に処理が打ち切られるので、Laravelのエラーハンドラーも呼ばれず、ただ500が返されるだけなので最初戸惑う。
(ApacheやNginxのエラーログ見るとちゃんと出てるはず。)

メモリの割り当てを増やすという最終手段もあるけど、プログラム側の問題の事が多いので、まずそれを調べましょう。
(前述のchunkやstreamDownloadなんかで大体解決できる。)

メモリを浪費してる処理を探すのは、古典的だけど、ソースを見て辺りを付けてからmemory_get_usageで使用量をログに仕込むといいぞ。

Maximum execution time of 30 seconds exceeded

PHPの処理が制限時間内に終わらなかった場合に出る奴。これもエラーが出た瞬間処理が打ち切られるので戸惑う。

同じく制限を延ばすこともできるが、こっちの場合は単純に負荷が捌ききれてないのが原因なので、処理を見直すか、マシンスペック上げるとかしましょう。
(制限時間を延ばして意味があるのは、Webからバッチを実行するとかの場合だけ。)

キーの重複

Duplicate entryだの、DBのINSERTが競合した時に出る奴。DBにUNIQUE制約が貼って合って、複数人が同時に同じレコードを登録しようとしたときに出る。
初めから複数のユーザーが同じデータを扱わない設計にすればいいんだけど、仕様上どうしても扱わざる得ないことがある。
(例えば、ソシャゲのレイドバトルとかギルドバトルとか。)

そういうときは、DB設計をどうにか工夫して頑張ったり(正規化を崩したり、INSERTの代わりにUPDATEでインクリメントしたり)、いっそ時間をずらしてリトライする実装にしたりするけど、完全には対応できないケースもあるのでなかなか難しい。
(最悪、1日にx件のエラーなら見なかったことにするとかも。。。)

デッドロック

DBの更新処理とかが競合した時に出る奴。デッドロックは、処理Aが更新してるデータを処理Bが待ってるけど、処理AはBが終わるのを待ってる…みたいなときに出るのがメイン。
タイミングがシビアなこともあり、調べるのに苦労することが多いが、プログラムの処理順を統一したりすれば直せることが多い。

ただ普通のもの以外にも、MySQLなんかだとGap LocksやNext-Key Locksといってロックを広くとる仕組みがあったりして、そういうのでも起きたりする模様。事前に知ってないとハマることになるので注意。
(実際に多発してかなりハマった。設定緩めに変えて対処した。)

補足2) 負荷やボトルネックの調査の仕方

補足二つ目は、負荷の掛け方やボトルネックの調査法について。

負荷の掛け方

これは負荷試験ツールというものがたくさんあるので、それを使えばOK。
今回は、最初はLocustというPythonのツールで、次いでお客さんの指定でJMeterというJavaのツールに移行した。
JMeterの方がGUIから操作できる代わりに機能がシンプルで、LocustはPythonスクリプト必須な代わりに多機能で何でも出来るイメージ。
とはいえ、JMeterもGroovyスクリプトとかを使えたので、リクエストのJSON組み立てたり、ランダムに値を変えたりと、いろいろできた。

ただ、いずれもスクリプトがPythonやGroovyなので、PHPを書きながら脳みそを切り替えるのは面倒くさいです…。

ボトルネックの調査方法

ボトルネックの調査には、最近だとNew Relicが人気(?)だけど有料で予算が無かったので、昔からあるxhprofを使った。
xhprofはPHP7だと公式のものが動作しなくて戸惑ったけど、Fork版を見つけてそれでならすんなり動いた。xhprof-htmlもFork版がおすすめ。

補足3) さらに改善するには?

補足三つ目は、さらに性能を上げていくためにはどうすればいいのかについて。
これまで上げたのは主にプログラミングレベルの話だったり、アプリの工夫でどうにかできる程度の話だったけど、さらに上を目指す場合はもっと設計レベルでの考慮や、お金を掛けてどうにかする必要が出てくる。
今回はそこまでする必要がなくてやらなかったけど、一応ざっと思い浮かんだ方法をリストアップしておく。
だいたい上からやってく。マシンスペックアップで賄えなくなったら後は台数を増やすしかないが、台数を増やすにはそれを考慮した設計/実装が必要になるので、随時その改修をしていく感じ。
水平分割辺りまでくると、もう性能のためにいろいろ犠牲にした感じで、普通にフレームワークの機能で開発とはいかなくなる。
その先は、台数増やしてはボトルネックがあれば調べて都度解消みたいな雰囲気…?


以上、だいぶ長くなったけど負荷対策としてやっとくことはこんな感じでした。まあデータ量やアクセス数が少なければインデックスとページングだけで済んだりするだろうけど、真面目に負荷を考えるならこれぐらいはということで。


2019/11/30 cursor/chunkについて追加記事を書いたのでその旨追記。
2019/12/04 バリデーション遅い件の対処法を追記。
スポンサーサイト



Tag: Laravel PHP プログラミング 性能問題

0 Comments

Leave a comment