富山のホームページ製作会社・グリーク スタッフブログ - ブログ -
  1. グリークトップ
  2. PHP
  3. ブログ

Symfony2の現実的な高速化テクニック

feature-image

Symfony2は遅いフレームワークと言われることがよくあります。

確かに色々なサイトでPHPフレームワークのベンチマークが比較されていて、Symfonyは遅い方とされていることが多いです。
ただ気になるのが、これらのベンチマークがどれだけ現実のアプリケーションのユースケースを反映しているかです。テンプレートエンジンを使うと(または変更すると)どうなるか、ORMで大量のDBアクセスを行うとどうなるか、キャッシュメカニズムを有効にした場合にどうなるか…

これらを踏まえてもあえておすすめしたいのがSymfony2です。
理由としては高速化する余地が十分にある点です (とても簡単にできるものもあります!)
特に以下の点に注目していただきたいです。

  • 実は内部にリバースプロキシの実装を持っています
  • ESI (Edge Side Includes) が使える
  • なんでもキャッシュできる!

ここではSymfony2で開発したシステムやWebサイトの高速化についてご紹介します。
本来なら開発の早い段階から意識するものですが、既に完成しているアプリケーションに導入できるものもあります。
今日できるものは今日やりましょう!

簡単に実行できる順に紹介します!

  1. ComposerのOptimized Autoloaderを利用する
  2. ApcClassLoaderで更にオートローダーを高速に
  3. SncRedisBundleでDoctrineのメタデータをキャッシュする
  4. Doctrine(>=2.5)のSecond Level Cacheを利用する
  5. ORMが実行するクエリの数を削減する
  6. Query Cache / Result Cacheを利用する

ComposerのOptimize Autoloaderを利用する

最適化したオートローダーを生成します。
本番環境では必ず実行しましょう。(-o は –optimizeの省略形です)

$ composer dump-autoload -o

アップデート時に行う場合は

$ composer update -o

二度言いますが本番環境では必ず実行しましょう。デメリットはありません。

ApcClassLoaderで更にオートローダーを高速に

先ほどのOptimize Autoloaderで効率はよくなりますが、どうしてもクラスマップへのディスクIOが発生してしまいます。`web/app.php`を見ていただくと、コメントアウトされたコードがあります。

このようにコメントを解除して有効にします。

<?php
// Enable APC for autoloading to improve performance.
// You should change the ApcClassLoader first argument to a unique prefix
// in order to prevent cache key conflicts with other applications
// also using APC.
$apcLoader = new ApcClassLoader('gratuitojp', $loader);
$loader->unregister();
$apcLoader->register(true);

当然ですがAPCのインストールが必要です。PHP5.5からはAPCがなくなりAPCuになりましたので注意してください。

Ubuntuの場合はAPTで簡単にインストールできます。

$ sudo apt-get install php5-apcu

SncRedisBundleでDoctrineのメタデータをキャッシュする

ここからはRedisが登場します。
この記事で紹介するRedisの使い方としては「消えても問題ないデータ」を保存するために利用しています。
つまり、導入するリスクは低いと思いますので、まだ使ったことがない方もぜひお試しいただきたいです。決して難しくありませんので。

本題ですがおそらく大部分の方はSymfonyを使う際、Doctrine ORMもあわせて使っていると思います。
Doctrineによって「エンティティと実際のテーブルをマッピングするための情報」が生成されますが、これは通常`app/cache/$env`フォルダにキャッシュされています。
これらはリクエストの度に毎回読み込む必要がありますので、RedisにキャッシュしてディスクIOを減らしたいところです。

まずはRedisをインストールして起動しておく必要があります。
またPHPからRedisに接続するための拡張もインストールします。ちなみにPHPからRedisに接続する場合はphpredis(PHP拡張による実装)、Predis(PHPによる実装)の2つあります。

今回はより高速なphpredisを利用します。

Ubuntuの場合はAPTで簡単にインストールできます。

$ sudo apt-get install redis-server php5-redis

SncRedisBundleのインストール

Composerの依存関係に追加します。

$ composer requre snc/redis-bundle

カーネルに追加します

<?php
public function registerBundles()
{
    $bundles = array(
        // ...
        new Snc\RedisBundle\SncRedisBundle(),
        // ...
    );
    ...
}

次にconfig.ymlに設定を追加する必要がありますが、そのままコピペできるオススメ設定を貼りたいと思います。

app/config/parameters.yml & app/config/parameters.yml.dist
parameters:
    redis_dsn_default: redis://localhost
    redis_dsn_cache:   redis://localhost/1
app/config.yml
snc_redis:
    clients:
        default:
            type: phpredis # phpredis または predis
            alias: default
            dsn: "%redis_dsn_default%"
        cache:
            type: phpredis
            alias: cache
            dsn: "%redis_dsn_cache%"
    doctrine:
        metadata_cache:
            client: cache
            entity_manager: default
        result_cache:
            client: cache
            entity_manager: [default]
        query_cache:
            client: cache
            entity_manager: default

詳しい使い方はリファレンスを参照していただきたいのですが、このバンドルを使うとセッションデータも簡単にRedisに保管することができます。
セッションをRedisで管理することのメリットとして、セッションファイルのガベージコレクションが不要になる点(通常だと、/tmpフォルダに大量にセッションファイルがたまっているはずです。)
また、Webサーバーを複数台構成にした場合にセッション管理が容易になる点があげられます。

セッションもRedisで管理したい場合は、先ほどの設定ファイルを次のように変更します。

app/config/parameters.yml & app/config/parameters.yml.dist
parameters:
    redis_dsn_default: redis://localhost
    redis_dsn_cache:   redis://localhost/1
    # 以下を追加
    redis_dsn_session: redis://localhost/2
app/config.yml
snc_redis:
    clients:
        default:
            type: phpredis # phpredis または predis
            alias: default
            dsn: "%redis_dsn_default%"
        cache:
            type: phpredis
            alias: cache
            dsn: "%redis_dsn_cache%"
        
        # 以下を追加
        session:
            type: phpredis
            alias: session
            dsn: "%redis_dsn_session%"
    # 以下を追加
    session:
        client: session
        use_as_default: true
        ttl: 31536000
    doctrine:
        metadata_cache:
            client: cache
            entity_manager: default
        result_cache:
            client: cache
            entity_manager: [default]
        query_cache:
            client: cache
            entity_manager: default

セッションがうまくRedisに保存されていない場合は、config.ymlで他のセッションハンドラが登録されていないことを確認してください。

この状態ではデフォルトのセッションストレージが設定されますので、

freamework:
    session:
        handler_id: ~

コメントアウトしてください

freamework:
    session:
        #handler_id: ~

この状態で開発中のページにアクセスすると、WebプロファイラーにRedisの欄が追加されており、どのようなコマンドが実行されているか確認することができます。
おそらく劇的に速度が向上していることはないと思いますが、プログラム本体のコードを変える必要がないため、ぜひ導入をおすすめしたいと思います。

Doctrine(>=2.5)のSecond Level Cacheを利用する

Doctrineがバージョン2.5になり、新たにSecond Level Cacheが追加されました。
現時点(2016/01)ではExperimentalとなっていますが、既に実用的なレベルとなっています。
おそらく大部分の方はDoctrine2.4を使っていると思いますので、その場合composerの依存関係を少し修正する必要があります。

doctrine/ormのバージョンを`~2.5`に変更して、アップデートを実行してください。

{
    "require": {
        "doctrine/orm": "~2.5"
    }
}

Doctrineの更新が終わったら、Second Level Cacheを有効にしていきますが、その前に事前準備としてキャッシュドライバーを用意します。
今回は折角なので先ほどのSncRedisBundleを使って、Redisにキャッシュを保存します。

SncRedisBundleがDoctrine\Common\Cache\CacheProviderを継承したクラスを用意していますので、そのまま利用します。

app/config/config.yml
services:
    cache_driver_second_level:
        class: Snc\RedisBundle\Doctrine\Cache\RedisCache
        calls:
            - [setRedis, [@snc_redis.cache]]

次にDoctrineの設定を編集し、Second Level Cacheを指定します。
この時点ではまだキャッシュは有効になりません。

doctrine:
    orm:
        second_level_cache:
            region_cache_driver:
                type: service
                id: cache_driver_second_level
            region_lock_lifetime: 60
            log_enabled: true
            region_lifetime: 300
            enabled: true
            regions:
                default:
                    lifetime: 60
                    cache_driver:
                        type: service
                        id: cache_driver_second_level

実際にキャッシュを行いたいEntityに対して`@ORM\Cache`アノテーションを設定することで、キャッシュが有効になります。

<?php

namespace Acme\DemoBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table()
 * @ORM\Entity()
 * @ORM\Cache(usage="NONSTRICT_READ_WRITE", region="default")
 */
class Province
{
    //...
}

まずは更新の少ないテーブルから徐々に範囲を広げていくことをおすすめします。

ORMが実行するクエリの数を削減する

これ以降はおそらく目に見える効果があるかと思いますが、方向性としてはSQLの実行数を抑えるものです。
普段Symfony Profilerをご覧になる方はお気づきだと思いますが、プロファイラーから実行されたSQLを確認したり、MySQLの場合は実行計画(Explain)を確認したりすることができます。

ORMを使っているとあまりSQLについて意識せずにプログラムを作れますので、時には非効率なクエリとなることがあります。まずは具体例をお見せしたいと思います。

最小限の例としてシンプルなブログを考えます。Post=記事とCategory=カテゴリーがManyToOneの関係であるとします。Entityのコードとしては次の通りです。

Category (Owning side)

<?php

namespace AppBundle\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table(name="Category")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\CategoryRepository")
 */
class Category
{

    /**
     * @var integer ID
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    public $id;

    /**
     * @var string カテゴリー名
     * @ORM\Column(name="name", type="string", length=255)
     */
    public $name;

    /**
     * @var Post[]|ArrayCollection このカテゴリーの記事
     * @ORM\OneToMany(targetEntity="Post", mappedBy="category")
     */
    public $posts;

}

Post (Inverse side)

<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table(name="Post")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\PostRepository")
 */
class Post
{

    /**
     * @var integer ID
     * @ORM\Column(name="id", type="bigint")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    public $id;

    /**
     * @var Category カテゴリー
     * @ORM\ManyToOne(targetEntity="Category", inversedBy="posts")
     * @ORM\JoinColumn(name="category_id", referencedColumnName="id")
     */
    public $category;

    /**
     * @var string 記事のタイトル
     * @ORM\Column(name="title", type="string", length=255)
     */
    public $title;

}

この2つの定義でテーブルを作成し、カテゴリを3件、記事を3件登録します。

phpstorm-category phpstorm-post

これをコントローラーで単純に取り出し、TwigでHTMLに書き出します。

この程度であればfindBy()でも取り出しできますが、次の例に必要なためあえてQueryBuilderを使っています。

<?php

namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class DefaultController extends Controller
{
    /**
     * @Route("/", name="homepage")
     */
    public function indexAction()
    {
        $posts = $this->getDoctrine()->getRepository('AppBundle:Post')
            ->createQueryBuilder('p')
            ->setMaxResults(10)
            ->getQuery()
            ->getResult();

        return $this->render('AppBundle:Default:index.html.twig', [
            'posts' => $posts
        ]);
    }
}

ビュー上では記事タイトルとカテゴリー名のみを表示するようにします。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
</head>
<body>
  {% for post in posts %}
    <h1>{{ post.title }}</h1>
    <h2>{{ post.category.name }}</h2>
  {% endfor %}
</body>
</html>

このコントローラーにアクセスすると、次のように単純な画面が表示されます。

2016-01-15 17-21-10

ここで注目いただきたいのはクエリの実行数です。コントローラー上では記事データを取得しているだけのように見えますが、実際には4本のクエリが実行されています。

2016-01-15 17-21-40

ポイントはリレーションで、テンプレートの`{{ post.category.name }}`が評価された時点でCategoryテーブルへのアクセスが発生しています。これはプロファイラーでタイムラインを確認すると一目瞭然です。

2016-01-15 17-28

これは必要なタイミングでORMが自動的にクエリを実行してくれるためで、ORMを使うメリットでもあります。
言い換えると想定外にクエリの実行数が増えてしまうということでもあります。

今回の例では当然1回のSQL実行でできますし、その方法も2つあります。

方法1. QueryBuilderでの明示的なJOIN

JOINすることで1本のSQLとなることは容易に想像できると思いますが、ポイントは`addSelect()`です。
ORMが基本的に遅延読み込みであることは変わりありませんが、`addSelect()`を入れておくことでPostオブジェクトからCategoryを参照した際、予めJOINで取得したデータが使われます。

<?php

namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class DefaultController extends Controller
{
    /**
     * @Route("/", name="homepage")
     */
    public function indexAction()
    {
        $posts = $this->getDoctrine()->getRepository('AppBundle:Post')
            ->createQueryBuilder('p')
            ->leftJoin('p.category', 'c')
            ->addSelect('c')
            ->setMaxResults(10)
            ->getQuery()
            ->getResult();

        return $this->render('AppBundle:Default:index.html.twig', [
            'posts' => $posts
        ]);
    }
}

2016-01-15 17-36-45

ご覧の通り、クエリは1回のみ実行されるようになりました。

方法2. FetchModeの変更

先ほど述べた通りORMは基本的に遅延読み込みでデータを取得します(= Lazy)が、Entityの設定でリレーション先の取得をより積極的に(= Eager)行うこともできます。
今回の例ではPostからみたCategoryの取得が該当します。

`@ORM\ManyToOne`のオプションに`fetch=”EAGER”`を追加します。尚、コントローラーは始めの状態に戻してください。(leftJoin,addSelectを消す)

<?php
    /**
     * @var Category
     * @ORM\ManyToOne(targetEntity="Category", inversedBy="posts", fetch="EAGER")
     * @ORM\JoinColumn(name="category_id", referencedColumnName="id")
     */
    public $category;

この状態で先ほどのページにアクセスすると、次のようなSQLが実行されます。

2016-01-15 17-43-26

クエリの実行数が4本から2本に減りました。EAGERモードは少し特殊な動きをするのと、副作用があるため「方法1」をまずはおすすめします。ただ「方法2」をうまく使えばEntityを少し触るだけでクエリの実行数を減らせることもあるため、うまく使い分けていただきたいのですが、慣れが必要かもしれません。

Query Cache / Result Cacheを利用する

先ほどご説明したものはクエリ実行数を減らすために、ORMが効率良く動くよう調整するものでした。
次にご説明するものはクエリを減らす目的に変わりありませんが、手段としてキャッシュを使おうというものです。

具体例として、記事の件数をバッヂかなにかで常にページのどこかに表示するような状況を想定します。
私の場合、そのようなコードはTwig Extensionを用意してビューから直接呼び出したりしますが、コントローラーに書く場合でも基本的に変わりありません。

おそらく次のようなコードになると思います。

<?php
$count = $this->getDoctrine()->getRepository('AppBundle:Post')
    ->createQueryBuilder('p')
    ->select('count(p.id) as _count')
    ->getQuery()
    ->getSingleScalarResult();

このコードを全ページに埋め込む場合、リクエストのたびにこのクエリが実行されますが、更新頻度がそれほどないと想定してキャッシュしてみたいと思います。

先ほどのコードを次のように変更します。

<?php
$cacheKey = 'post_count';
$count = $this->getDoctrine()->getRepository('AppBundle:Post')
    ->createQueryBuilder('p')
    ->select('count(p.id) as _count')
    ->getQuery()
    ->useQueryCache(true) // クエリキャッシュを有効に
    ->useResultCache(true, 60, $cacheKey) // さらに結果もキャッシュ
    ->getSingleScalarResult();

この状態で実行すると、実行結果を60秒間キャッシュします。その間のアクセスではSQLは実行されません。
`useResultCache()`の3番目の引数はキャッシュのキーです。これはログインユーザーによって結果が変わる場合などには、プライマリーキーを含めるなど、ユニークになるよう注意して設定してください。

このままだとキャッシュが有効な間は正しい記事数が表示されないため、記事が追加されたタイミングでキャッシュを破棄することもできます。コントローラー上で行う場合は次のようなコードになります。

<?php
$cacheKey = 'post_count';
$mamager = $this->getDoctrine()->getManager();
$mamager->getConfiguration()->getResultCacheImpl()->delete($cacheKey);

さいごに

今回は簡単にできて効果のあるパフォーマンス改善方法をご紹介しました。

次はより進んだ方法として、HTTPキャッシュやESIについてご紹介したいと思います。

この記事を書いたスタッフ
Kazuyuki Hayashi
Symfony / Node.js / React.js / Swift を中心にテクノロジー系の記事を書きます。趣味はコーヒーです。