高速なWebサーバアプリケーションを構築するための6つの経験則

この記事では、Webアプリケーション(特にバックエンド部分)を構築するときにハイレベルなパフォーマンスを達成しようとするなら考慮するべき、最も一般的な原則のいくつかを取り上げたいと思います。私は、自分自身の経験から、主にPHPの世界で使われるいくつかの例、設計パターン、慣例やツールについて書きますが、ここで説明する概念は、どんな言語やフレームワークにも必ず当てはまると思います。

手短に言うと、基本ルールは次の6つです。

  • ルール1. 時期尚早な最適化を回避する
  • ルール2. 最小限の作業で問題を解決する
  • ルール3. 今すぐやらなくてもいい作業は延期する
  • ルール4. 使えるときはキャッシュを使う
  • ルール5. リレーショナルデータベースのN+1問題を理解し、回避する
  • ルール6. 可能ならアプリケーションに水平スケーラビリティをもたせる

Your code should be fast as Flash Gordon

ルール1: 時期尚早な最適化を回避する

Donald Knuthの、最も有名な言葉の1つをご紹介します。

時期尚早な最適化は諸悪の根源だ

Knuthは、多くのソフトウェア開発者が、自分のコードの重要でない部分のパフォーマンスについて考えることで大量の時間を無駄にしていることに気づきました。その原因は、多くの場合、コードのどの部分が重要なのか、つまり、もっと最適化する必要のあるものは何かを開発者が実際には分かっていないので、「二重引用符で囲んだ文字列は単一引用符で囲んだ文字列より遅いのか? 」などという、くだらないことを心配し始めたりすることです。

時期尚早な最適化の罠に捕まらないために、コードの最初のバージョンは、パフォーマンスについてあまり気にせず書くべきです。その後で、プロファイラを使って、コードのどこがボトルネックになっているか調べることができます。こうすれば、本当に注意が必要な部分のみの改善に集中することができます。

注: ここで明確にしておきたいことは、Knuthの言葉は、最適化を全く考慮しなくていいという意味ではなく、粗悪なコードを書いては捨てることの言い訳でもないということです。この言葉は「賢い最適化」を学ぶことを奨励するために引用したもので、そういう意味に解釈してください。

PHPの世界で働いているなら、コードのプロファイリングに簡単に採用できるツールがたくさんあります。

  • xdebug: おそらく最も有名なPHPデバッガ&プロファイラです。PHP拡張としてインストールする必要があります。ほとんどのPHP開発環境に簡単に統合できます。
  • xhprof: PHP用の関数レベルの階層的プロファイラです。シンプルなHTMLベースのナビゲーションインターフェイスが付いていて、洗練されたdiff機能を使って異なるバージョンのコードのパフォーマンスを比較できます。
  • Symfonyプロファイラ: このプロファイラは、Symfonyフレームワークの最も優れた機能の1つです。リクエストごとの実行時間を精査して、コードのどの部分が最も時間を消費しているかが簡単に分かる、素敵なタイムラインを表示することができます。このプロファイラは”development”モードで自動的に有効になるので、PHP拡張をインストールする必要はありません。
  • Stopwatchコンポーネント: Symfonyプロファイラで使用される下位レベルのライブラリで、PHPコードの一部分の実行時間を測定します。どんなPHPプロジェクトにも簡単に統合でき、拡張は必要ありません。
  • Blackfire.io: PHP用に最適化されたプロファイラです。コードが何をしているのか、CPUがほとんどの時間をどこに費やしているのかを視覚的に理解することができる、素晴らしいWebインターフェイスがあります。
  • Tideways: Blackfireの代替として有望なプロファイラです。多くのグラフィカルツール(タイムライン、コールグラフなど)があり、実に簡単にボトルネックを見つけることができます。継続して実行するよう意図されています(プロダクションでも)。

最適化について詳しく知りたい方は、次の記事を読んでみてください。
* On optimization in PHP Anthony Ferrara
* The fallacy of premature optimization Randall Hyde
* Premature optimization Cunningham & Cunningham, Inc

ルール2: 必要なことだけをする

Joker meme I Just Do Things

多くの場合、コードは、期待される結果を生むのに必要なことよりも多くのことをします。コードに複雑なライブラリとフレームワークを使っている場合には、なおさらです。例えば、決して使わないクラスをロードするかも知れませんし、特定のリクエストへの出力を作成するためには必要のないリソースなのに、リクエストごとにデータベース接続を開いたり、ファイルを読んだりするかも知れません。

このような状況を回避してパフォーマンスを向上させるために役立つ、多くの設計パターンとテクニックがあります。

  • オートロード: あるクラスを使おうとするとき(インスタンス化、静的メソッド呼び出し、定数へのアクセスなど)だけに、そのクラスの定義を含むファイルを要求することが可能になるPHP機能です。こうすれば、スクリプト内でどのファイルをincludeするか悩まなくても、必要なクラスを使うだけでよいのです。残りの仕事はオートロードがやってくれます。これまで、オートロードの設定は少し複雑でした。その理由は、特に、ライブラリがそれぞれ独自の慣例を使用していたからです。しかし、現在は、PSR-0PSR-4の標準や、Composerのようなツールのおかげで、オートローディングは朝飯前の作業になりました。
  • 依存性の注入: Javaの世界ではとても一般的な設計パターンで、ここ数年は、PHPの世界でも人気を集めています。Symfony、Zend、Laravelなどのフレームワークで広く使われ、推奨されてきたおかげでもあります。基本的に、この設計パターンでは、コンストラクタまたはsetterメソッドを使ってコンポーネントを注入することができます。この設計パターンを使うと、開発者が依存性について考え、1つのことだけをきちんと行うことに集中した独立性のある小さなコンポーネントを作るようになるという効果があります
  • 遅延読み込み: もう1つの重要な設計パターンで、あるオブジェクトの初期化を、そのオブジェクトが必要になる時まで延期するために使用されます。多くの場合、データベース接続やファイルベースのデータソースを扱うオブジェクトに使用されます。I

ルール3: ママ、それは明日やるよ!

Tomorrow definition mystical land for human productivity

図訳: 明日(名詞): 人間の全ての生産性、モチベーションと達成のほぼ99%がストアされている未知の領域

あなたのWebアプリで、特定のイベント(例えばパスワードの変更や注文完了など)が起こされた後にユーザにメールを送らなくてはならなかったことはどの程度ありましたか? ユーザが画像をアップロードした後、それをリサイズしなくてはならなかったことは? 動作の完了を伝えるメッセージをユーザに送る前に「負荷の高い」処理をしなくてはならないというのはよくあることです。別の言い方をすれば、ユーザは出来る限り早くブラウザ上で何らかのメッセージを目にすることを期待しているので、付加的なタスク(メッセージを作成することに直接の関連性のないもの)は後回しにする必要があります。
これを実現するのに最も一般的な方法は、ジョブキューです。後回しにされたタスクを実行するのに必要な最低限のデータを、何らかのキュー(データベースやメッセージブローカなど)に保存すれば、そのタスクについては忘れてしまっていいのです。あなたは自分のメインタスク、すなわちユーザに対してアウトプットを生成する作業にすぐ取り掛かれます。ある種のワーカーが定期的にキューを読み込み、後回しにされたジョブ(例えばメールを送信、またはイメージのサムネイルを生成するなど)を実行してくれます。

シンプルなキューのシステムは、あらゆるデータストア(RedisやMongoDBがよく使用されます)や、RabbitMQ、ActiveMQのようなメッセージブローカによって簡単に実現できます。PHPの世界では既に多くが実装されています。

  • Resque: Redisをデータストアとして用いるPHPキュー。
  • Laravelキュー: そのままですぐ使えるLaravel/Lumenのソリューション。キューとワーカーを使ってジョブを後で行うようにすることが可能。様々なデータストアを使えるように設定できます。
  • Gearman: 幅広い多くの言語(特にPHP)をサポートする汎用のジョブサーバ。
  • Beanstalkd: (RubyやPHPなど)一般的な言語のクライアントライブラリを用いた高速なワークキュー。

ルール4: 全部キャッシュしちゃおう

Comic strip about the cloud and the cache

最近のWebアプリは非常に複雑なコードから成り立っています。全てのリクエストに対してレスポンスを生成する手段として多くの工程が必要となります。1つまたは複数のデータベースへの接続、外部APIの呼び出し、設定ファイルの読み込み、データの計算及び集計、パース可能なフォーマット(XmlやJsonなど)での結果のシリアライズ、またはテンプレートエンジンを用いての美しいHTMLページへのレンダリング、などです。
愚直なアプローチでも、全てのリクエストに対して上記の一連の工程は行えます。サーバは反復タスクに飽きることはありませんからね。

しかし、同じ結果を何度も計算することを避ける、反復タスクのためのスマートで(より速い)方法があります。それは、「キャッシュ」を使う方法です。

cacheは、「キャッシュ」と発音されます。(「キャッチ」や「キャシェイ」ではありません)。直近に使われた情報を保存しておき、後で迅速にアクセスできるようにしたものです。

キャッシュはコンピュータサイエンスの世界では広く用いられ、ほぼ、あらゆるエリアに存在します。例えば、RAMとは実行されているプログラムのコードをキャッシュし、時間をおいて何万回も(遅い)ハードディスクをCPUに読み込ませるのを避けるための1つの策です。

通常、Webプログラミングにおいては、いくつかのレベルのキャッシュに注目することができます。

  • バイトコードキャッシュ: 多くのインタプリタ型言語(PHP、Python、Rubyなど)に一般的な機能で、ソースコードの変更が無い場合には何度も再変換するのを回避することを可能にします。この機能がコアに組み込まれている言語(Python)もありますし、PHPなどは、これを拡張機能として備えていて、この機能用にいくつかの拡張モジュールがあります。例えばAPCeAccelaratorや、Xcacheなどで、PHP5.5からはコアに統合されたOpcache extensionが使用可能です。
  • アプリケーションキャッシュ: HTML5のアプリケーションキャッシュと混同しないように説明すると、これは特定のアプリケーションを監視するキャッシュロジックで、パフォーマンスの観点から言うとおそらく最も重要なものです。例えばアプリ上で、フィナボッチ数列で1264575番目の数値を何度も計算している場合、結果をキャッシュに書き込み、毎回再計算するのを避けることができます。もっと現実的な例としては、アプリのフロントページをレンダリングするために高コストなクエリをデータベースに対して何度も行っているような場合、結果(または可能ならば全ページのアウトプット)をクエリにキャッシュすることによって、ユーザリクエストの度にデータベースにアクセスするのを回避できます。このようなケースには MemcachedRedis、またはGibsonのようなキャッシュサーバを使うといいでしょう。
  • HTTPキャッシュ: ネットワーク上でのデータのフェッチには時間がかかります。クライアントとサーバの間で多くのラウンドトリップが必要となり、ブラウザがコンテンツを表示できるまで長い時間が無駄になります。既にダウンロードされたコンテンツを再利用するようブラウザに指示するよい方法はないでしょうか? これは、Etagcache-controlなどのようなHTTPキャッシュヘッダによって実現できます。サーバリソースに関してキャッシュを利用するのにはこれが最も安価な方法で(全てのものが既にブラウザにあり、サーバにはリクエストが全く行かないため)、注意するのは、リピートユーザが古いコンテンツを見ることがないよう、適切に使用することのみです。
  • プロキシキャッシュ: このテクニックは、全てのHTTPトラフィックを受け取る専用のサーバを利用する方法で(よくリバースプロキシと言われます)、ユーザにリクエストされたWebページのコピーが存在する可能性があります。この場合、アプリケーションサーバがリクエストを処理する必要がなく、ページのコピーを直接返します。通常、データのコピーをメモリ上に保存し、ネットワークのラウンドトリップの多発を避けるようにします。これはコンテンツがそれほど頻繁に変更されない、トラフィックの多いWebサイトを高速化するためのアプローチです。有名なプロキシサーバにはVarnishNginxSquidがあります。それと、Apacheもリバースプロキシとして振舞うように設定することができます。

いずれにせよ、キャッシュの概念を理解すれば、適用するのはとても簡単です。ただし、何かの変更があってキャッシュされたデータが最新ではなくなっているかどうかを判断する必要がある場合は、問題になります。その場合、キャッシュのデータを削除し、次にリクエストがあった時に正しく再計算されるようにしなくてはなりません。このプロセスは「キャッシュの無効化」と呼ばれ、開発者の悩みのタネで、これに関する有名な引用があるほどです。

コンピュータサイエンスにおいて難しいことは2つだけ。キャッシュの無効化とネーミングだ。
–Phil Karlton

ソフトウェア開発の業界に身を置いているなら、おそらく目にしたことがあるのではないでしょうか。

キャッシュの無効化を簡単にする特効薬はありません。あなたのコードのアーキテクチャと、アプリケーションの必要条件によって異なるからです。通常は、キャッシュレイヤが少ないほどいいということになっています。複雑性を増やすことはいつでも避けたいものですよね。

ご参考までに、Webアプリケーションのキャッシュについての記事を下記にリストしておきます。

ルール5: やっかいなN+1問題を回避する

「N+1問題」とは、起こすつもりがなくても起こってしまう、まさによくあるアンチパターンです。特にリレーショナルデータベースを扱う時に良く発生します。この問題の原理は、データベースからN個のレコードを読み込む際に、N+1個のクエリ(n個分のIDを読み取る動作に1つ、そして各データに対して1つずつ)が発生してしまうということです。実際の(正確には、ほぼ実際の)ケースでコードを見てみましょう。

<?php

function getUsers() {  
  //... retrieve the users from the database (1 query)
  return $users;
}

function loadLastLoginsForUsers($users) {  
  foreach ($users as $user) {
    $lastLogins = ... // load the last logins for the user (1 query, executed n times)
    $user->setLastLogins($lastLogins);
  }

  return $users;
}

$users = getUsers();
loadLastLoginsForUsers($users);  

このコードは、まずユーザリストを読み込み、それから全てのユーザの最終ログイン時間をそれぞれデータベースから読み込みます。まさに次のようなN+1個のクエリを発生させています。

SELECT id FROM Users; -- ids: 1, 2, 3, 4, 5, 6...  
SELECT * FROM Logins WHERE user_id = 1;  
SELECT * FROM Logins WHERE user_id = 2;  
SELECT * FROM Logins WHERE user_id = 3;  
SELECT * FROM Logins WHERE user_id = 4;  
SELECT * FROM Logins WHERE user_id = 5;  
SELECT * FROM Logins WHERE user_id = 6;  
-- ...

明らかに非効率ですね。この問題は、データベースに「1対多」関係がある場合によく発生します。中でも特に、ある種の魔法のようなORMを使っていて、中では何が起こっているのかよく分からない(もしかしたら正しく設定していない)データベースを扱う時に良く見受けられます。

一般的にはこの問題は、次のようなクエリを作ることで解決できます。

SELECT id FROM Users; -- ids: 1, 2, 3, 4, 5, 6...  
SELECT * FROM Logins WHERE user_id IN (1, 2, 3, 4, 5, 6, ...);  

もしくは、JOINシンタックスを使ってみて下さい。

この問題は、自分のSQLクエリをコントロール出来ている時か、もしくは(利用しているなら)使っているORMライブラリの中身をきちんと理解している時だけ対処出来ます。とにかく、こうした問題があるということを覚えておいて下さい。特に大規模なデータセットを扱う時には、くれぐれもN+1個のクエリという罠にはまらないよう気を付けて下さい。ページリクエストごとに、発生するクエリを点検できる機能を持つPHPプロファイラも多くありますので、ぜひ活用してみて下さい。N+1問題がきちんと回避出来ているか、一緒に確認できるでしょう。

データベースに関して補足しておきます。データ元に対しては、1本の接続を常に開いておくよう心がけましょう。クエリごとに再接続してはいけません。

ここに挙げた方法だけで、全てのケースが解決できるとも思わないで下さい。この問題は大変幅広く、分析に値する重要なケースが他にもたくさんあります。より詳しく知りたい場合には、次の記事や書籍をご確認下さい。

ルール6: スケール – 水平方向

Horizontal scalability is hard

「スケーラビリティ」は、もちろん厳密に言えば「パフォーマンス」と同義ではありませんが、この2つは密接に絡み合っています。個人的には「スケーラビリティ」とは、ユーザ(もしくはリクエスト)数が増加した場合でも、パフォーマンスに関する著しい問題を生じることなく、状況に適応し機能的であり続けるシステムの能力、と定義しています。

これは大変複雑で幅広いトピックですので、このブログで詳細を論じるつもりはありません。ただパフォーマンス向上のためには、いくつかの簡単な事実については理解し覚えておく価値はあるでしょう。そうすれば、皆さんのアプリでも簡単かつ確実に水平方向のスケールができるようになります。 水平スケールとは、アプリが配備されているクラスタに更にマシンを追加する拡張手法のことです。この手法を使えば、負荷は全てのマシンに分散されるので、同時に大量のリクエストが発生するような場面でも、システム全体のパフォーマンスは保たれることになります。

水平スケールを検討する際には、考慮しなければならない重要な点が2つあります。ユーザセッションと、ユーザファイルの整合性に関する問題です。

  • セッションに関する問題 – (PHPアプリケーションには特に多くみられますが)ユーザのセッションデータは、アプリが配備されているローカルファイルシステム内に保存されることがかなり多いです。この状況で水平スケールを実行した場合、動きは遅くなります。更に、2つのマシーン(1つはセッションデータの保存用、もう片方はその他のデータ保存用)でリクエストを処理する場合には、水平スケールは機能しません。特定のデータベースにユーザセッションデータを保存すれば、この問題は解消されるでしょう。ほとんどのフレームワークで、ほんの数行コンフィグを加えるだけで、データベースの利用が可能となります。アプリがまだ小規模であまり知られていないような初期段階では、Webサーバとしてお使いのマシーンそのものに、お気に入りのセッション保管用プラットフォームをインストールすることが可能です。実際にアーキテクチャを拡張する必要が出てきた時には簡単に、セッションが保管されているストレージを別のマシンに移し、それから全てのWebマシンをこのマシンに繋げばよいでしょう。

  • ユーザファイルの整合性に関する問題 – ユーザがアプリ内にファイルを保存することが出来る場合にも、セッションに関する同じような問題が起こります。こうしたケースの場合皆さんは、Webサーバがどんなものであろうと、最後には確実にファイルを見つけることが出来るようにしておく必要があります。そのためには、ファイルは専用のストレージ(Amazon S3Rackspace Cloudfiles等)に保存しましょう。マシン上でローカルに保管しても良いのですが、その場合はファイルがクラスタ内の全てのマシン上で常に同期した状態を保てる方法を確認して下さい。NFSGlusterFSを使って共有ファイルシステムを作成しても良いでしょう。

拡張性のあるWebアプリについては、他にも詳しい興味深い記事や書籍が出ていますので、リストを挙げておきます。

終わりに

この長い投稿が、皆さんにとって役に立つものであったことを願っています。アプリを書き始める時、パフォーマンスとは必ず考慮しなければならないものです。その際に通常気に留めておいた方が良い点について、私の考えを共有したかった次第です。最初のルールでお話したように、時期尚早な最適化という罠にははまらないようにして下さい。正しく動作をする正しいコードを書くことにだけ専念しましょう。この点を明確にしておけば、アプリの初期バージョンの段階からほとんど自動的に、パフォーマンスや拡張性を良い状態で実行できる素晴らしい策を考えることが出来るでしょう。まさに最初の時点からスマートなアーキテクチャを作ることだって可能でしょう。

経験豊富なWeb開発者の皆さん、もし私が何か重要なルールを忘れているようでしたらご指摘下さい。また、既にいつもここにあげたルールを考慮している場合、どのようにそれぞれお考えか教えて下さい。

皆さんのコメントから、更に重要な議論が始まることを期待しています。

この記事を読んで下さったことに感謝します。

次の投稿もお楽しみに。