《Yii1.1 Application Development Cookbook》

Chapter 11: Performance Tuning

In this chapter, we will cover:

  • Following best practices
  • Speeding up sessions handling
  • Using cache dependencies and chains
  • Profiling an application with Yii

Introduction

Yii is one of the fastest frameworks out there. Still, when developing and deploying an application, it is good to have some extra performance for free, as well as following best practices for the application itself. In this chapter, we will see how to configure Yii to gain extra performance. In addition, we will learn some best practices of developing an application that will run smoothly until we have very high loads.

Following best practices

In this recipe, we will see how to configure Yii for best performances and will see some additional principles of building responsive applications. These principles are both general and Yii-related. Therefore, we will be able to apply some of these even without using Yii.

Getting ready

  • Install APC (http://www.php.net/manual/en/apc.installation.php)
  • Generate a fresh Yii application using yiic webapp

How to do it...

Carry out the following steps:

  1. First, we need to turn off the debug mode. This can be done by editing index.php as follows:
defined('YII_DEBUG') or define('YII_DEBUG',false);
  1. The next step is to use yiilite.php. Again, we need to edit index.php and change
$yii=dirname(__FILE__).'/../framework/yii.php';

to the following:

 $yii=dirname(__FILE__).'/../framework/yiilite.php';
  1. Now we will move on to protected/config/main.php and replace it with the following:
<?php
 
// uncomment the following to define a path alias
// Yii::setPathOfAlias('local','path/to/local-folder');
// This is the main Web application configuration. Any writable
// CWebApplication properties can be configured here.
return array(
    'basePath'=>dirname(__FILE__).DIRECTORY_SEPARATOR.'..',
    'name'=>'My Web Application',
    
    // preloading 'log' component
    'preload'=>array('log'),
    
    // autoloading model and component classes
    'import'=>array(
        'application.models.*',
        'application.components.*',
    ),
    
    'modules'=>array(
        // uncomment the following to enable the Gii tool
        /*
        'gii'=>array(
            'class'=>'system.gii.GiiModule',
            'password'=>'Enter Your Password Here',
            // If removed, Gii defaults to localhost only. Edit carefully to taste.
            'ipFilters'=>array('127.0.0.1','::1'),
        ),
        */
    ),
    
    // application components
    'components'=>array(
        'user'=>array(
            // enable cookie-based authentication
            'allowAutoLogin'=>true,
        ),
        'urlManager'=>array(
            'urlFormat'=>'path',
            'rules'=>array(
                '<controller:\w+>/<id:\d+>'=>'<controller>/view',
                '<controller:\w+>/<action:\w+>/<id:\d+>'=>'<controller>/<action>',
                '<controller:\w+>/<action:\w+>'=>'<controller>/<action>',
            ),
        ),
        'db'=>array(
            'connectionString' => 'mysql:host=localhost;dbname=test',
            'username' => 'root',
            'password' => '',
            'charset' => 'utf8',
            'schemaCachingDuration' => 180,
        ),
        'errorHandler'=>array(
            // use 'site/error' action to display errors
            'errorAction'=>'site/error',
        ),
        'log'=>array(
            'class'=>'CLogRouter',
            'routes'=>array(
                array(
                    'class'=>'CFileLogRoute',
                    'levels'=>'error, warning',
                ),
                // uncomment the following to show log messages on web pages
                /*
                array(
                    'class'=>'CWebLogRoute',
                ),
                */
            ),
        ),
        'session' => array(
            'class' => 'CCacheHttpSession',
        ),
        'cache' => array(
            'class' => 'CApcCache',
        ),
    ),
    
    // application-level parameters that can be accessed
    // using Yii::app()->params['paramName']
    'params'=>array(
        // this is used in contact page
        'adminEmail'=>'webmaster@example.com',
    ),
);
  1. That is it. Now we don't have to worry about the overhead of Yii itself and can focus on our application.

How it works...

When YII_DEBUG is set to false, Yii turns off all the trace level logging, uses less error handling code, stops checking the code (for example, Yii checks for invalid regular expressions in router rules), and uses minified JavaScript libraries.

yiilite.php contains the most commonly executed Yii parts. By using it, we can avoid including the extra script and use less memory for APC cache.

Note that the benefit of using yiilite.php varies according to the server setup and sometimes, it is slower when using it. It is a good idea to measure the performance and choose what works faster for you.

Now we will review the additional component configuration which we performed:

'db'=>array(
    'connectionString' => 'mysql:host=localhost;dbname=test',
    'username' => 'root',
    'password' => '',
    'charset' => 'utf8',
    'schemaCachingDuration' => 180,
),

Setting schemaCachingDuration to a number of seconds allows caching the database schema used by Yii's Active Record. This is highly recommended for production servers and it significantly improves the Active Record performance. In order for it to work, you need to properly configure the cache component as follows:

'cache' => array(
    'class' => 'CApcCache',
),

The APC cache is one of the fastest cache solutions if you are using a single server. Enabling cache also has a positive effect on other Yii components. For example, Yii router or urlManager starts to cache routes in this case.

Finally, we configure the session component as follows:

'session' => array(
    'class' => 'CCacheHttpSession',
),

The preceding code enables storing sessions in APC which is significantly faster than the default file-based session handling.

There's more...

Of course, you can get into a situation where the preceding settings will not help to achieve sufficient performance level. In most cases, it means either that the application itself is a bottleneck or you need more hardware.

Server-side performance is just a part of the big picture

Server-side performance is only one of the things that affect the overall performance. By optimizing the client side such as serving CSS, images, and JavaScript files proper caching and minimizing the amount of HTTP-requests can give a good visual performance gain even without optimizing the PHP code.

Things to be done without using Yii

Some things are better to be done without Yii. For example, image resizing on the fly is better to be done in a separate PHP script in order to avoid the extra overhead.

Active record versus query builder and SQL

Use query builder or SQL in performance critical application parts. Generally, AR is most useful when adding and editing records, as it adds a convenient validation layer and is less useful when selecting records.

Always check for slow queries first

Database can become a bottleneck in a second if a developer accidentally forgets to add an index to a table that is being read often or vice versa, or adds too many indexes to a table we are writing to very often. The same goes for selecting unnecessary data and unneeded JOINs.

Cache or save results of "heavy" processes

If you can avoid running a "heavy" process in every page load, it is better to do so. For example, it is good practice to save or cache results of parsing the markdown text, purifying it (this is a very resource intensive process) once, and then using the ready to display HTML.

Handling too much processing

Sometimes there is too much processing to handle it immediately. It can be building of complex reports or just simple sending e-mails (if your project is heavily loaded). In this case, it is better to put it into a queue and process later by using cron or other specialized tools.

Further reading

For further information, refer to the following URL:

http://www.yiiframework.com/doc/guide/en/topics.performance

See also

  • The recipe named Speeding up sessions handling in this chapter
  • The recipe named Using cache dependencies and chains in this chapter
  • The recipe named Profiling an application with Yii in this chapter

Speeding up sessions handling

Native session handling in PHP is fine in most cases. There are at least two possible reasons why you will want to change the way sessions are handled:

  • When using multiple servers, you need to have a common session storage for both servers
  • Default PHP sessions use files, so the maximum performance possible is limited by disk I/O

In this recipe, we will see how to use an efficient storage for Yii sessions.

Getting ready

  • Generate a fresh Yii application using yiic webapp
  • You should have php_apc and php_memcache extensions installed, as well as memcached itself to follow this recipe

How to do it...

We will stress test the website by using the Apache ab tool. It is being distributed with Apache binaries, so if you are using Apache, you will find it inside the bin directory. Run the following command replacing your.website with the actual hostname you are using:

ab -n 1000 -c 5 http://your.website/index.php?r=site/contact

This will send 1,000 requests, five at a time, and will output stats as follows:

Z:\web\usr\local\apache\bin>ab -n 1000 -c 5 http://perf/index.php?r=site/contact
This is ApacheBench, Version 2.0.40-dev <$Revision: 1.146 $> apache-2.0
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Copyright 2006 The Apache Software Foundation, http://www.apache.org/
 
Benchmarking perf (be patient)
 
Server Software:        Apache/2.2.4
Server Hostname:        perf
Server Port:            80
Document Path:          /index.php?r=site/contact
Document Length:        6671 bytes
Concurrency Level:      5
Time taken for tests:   11.889185 seconds
Complete requests:      1000
Failed requests:        0
Write errors:           0
Total transferred:      7103000 bytes
HTML transferred:       6671000 bytes
Requests per second:    84.11 [#/sec] (mean)
Time per request:       59.446 [ms] (mean)
Time per request:       11.889 [ms] (mean, across all concurrent requests)
Transfer rate:          583.39 [Kbytes/sec] received
 
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0     0   1.5     0      15
Processing:    28    58  58.6    47     830
Waiting:       27    57  58.5    47     827
Total:         30    58  58.7    47     830
 
Percentage of the requests served within a certain time (ms)
  50%     47
  66%     52
  75%     57
  80%     60
  90%     70
  95%    100
  98%    205
  99%    457
 100%    830 (longest request)

We are interested in requests per second metric. The number means that the website can process 84.11 requests per second if there are five requests at a time.

Note that the debug is not turned off since we are interested in changes to session handling speed.

Now add the following to the protected/config/main.php, components section:

'session' => array(
    'class' =>'CCacheHttpSession',
    'cacheID' =>'sessionCache',
),
 
'sessionCache' => array(
    'class' => 'CApcCache',
),

In the preceding config section, we instruct Yii to use CCacheHttpSession as a session handler. With this component, we can delegate session handling to the cache component specified in cacheID. This time we are using CApcCache.

When using APC or memcached backend, you should take into account the fact that when using these solutions, the application user can possibly lose session if the maximum cache capacity is reached.

Note that when using a cache backend for a session, you cannot rely on a session as temporary data storage, since then there will be no memory to store more data in either APC or memcached. In such a case, these will just purge all data or delete some of it.

In the preceding tests, APC was the fastest backend but if you are using multiple servers, you cannot use it as there will be no way to share the session data between servers. In case of memcached, it is easy because it can be easily accessed from as many servers as you want.

Various cache backends provide different levels of stability. For example, it is a known fact that APC becomes unstable when it is filled up or when you try writing to a single key from multiple processes.

There's more...

In order to learn more about caching and sessions, refer to the following resources:

  • http://www.yiiframework.com/doc/api/CCache
  • http://www.yiiframework.com/doc/api/CHttpSession/
  • http://php.net/manual/en/book.apc.php
  • http://memcached.org/
  • http://stackoverflow.com/questions/930877/apc-vs-eaccelerator-vs-xcache

See also

  • The recipe named Following best practices in this chapter

Using cache dependencies and chains

Yii supports many cache backends, but what really makes Yii cache flexible is the dependency and dependency chaining support. There are situations when you cannot just simply cache data for an hour because the information cached can be changed at any time.

In this recipe, we will see how to cache a whole page and still always get fresh data when it is updated. The page will be dashboard-type and will show five latest articles added and a total calculated for an account. Note that an operation cannot be edited as it was added, but an article can.

Getting ready

  • Install APC (http://www.php.net/manual/en/apc.installation.php)
  • Generate a fresh Yii application by using yiic webapp
  • Set up a cache in the components section of protected/config/main.php as follows:
'cache' => array(
    'class' => 'CApcCache',
),
  • Set up and configure a fresh database
  • Execute the following SQL:
CREATE TABLE `account` (
    `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
    `amount` decimal(10,2) NOT NULL,
    PRIMARY KEY (`id`)
);
CREATE TABLE `article` (
    `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
    `title` varchar(255) NOT NULL,
    `text` text NOT NULL,
    PRIMARY KEY (`id`)
);
  • Generate models for the account and article tables using Gii
  • Configure the db and log application components through protected/config/ main.php, so we can see actual DB queries. In the end, the config for these components should look like the following:
'db'=>array(
    'connectionString' => 'mysql:host=localhost;dbname=test',
    'username' => 'root',
    'password' => '',
    'charset' => 'utf8',
    'schemaCachingDuration' => 180,
    'enableProfiling'=>true,
    'enableParamLogging' => true,
),
'log'=>array(
    'class'=>'CLogRouter',
    'routes'=>array(
        array(
            'class'=>'CProfileLogRoute',
        ),
    ),
),
  • Create protected/controllers/DashboardController.php as follows:
 <?php
class DashboardController extends CController
{
    public function actionIndex()
    {
        $db = Account::model()->getDbConnection();
        $total = $db->createCommand("SELECT SUM(amount) FROM account")->queryScalar();
        $criteria = new CDbCriteria();
        $criteria->order = "id DESC";
        $criteria->limit = 5;
        $articles = Article::model()->findAll($criteria);
        $this->render('index', array(
            'total' => $total,
            'articles' => $articles,
        ));
    }
    
    public function actionRandomOperation()
    {
        $rec = new Account();
        $rec->amount = rand(-1000, 1000);
        $rec->save();
        echo "OK";
    }
    
    public function actionRandomArticle()
    {
        $n = rand(0, 1000);
        $article = new Article();
        $article->title = "Title #".$n;
        $article->text = "Text #".$n;
        $article->save();
        echo "OK";
    }
}
  • Create protected/views/dashboard/index.php as follows:
<h2>Total: <?php echo $total?></h2>
<h2>5 latest articles:</h2>
<?php foreach($articles as $article):?>
    <h3><?php echo $article->title?></h3>
    <div><?php echo $article->text?></div>
<?php endforeach ?>
  • Run dashboard/randomOperation and dashboard/randomArticle several times. Then, run dashboard/index and you should see a screen similar to the one shown in the following screenshot:

How to do it..

Carry out the following steps:

  1. We need to modify the controller code as follows:
class DashboardController extends CController
{
    public function filters()
    {
        return array(
            array(
                'COutputCache +index',
                // will expire in a year
                'duration'=>24*3600*365,
                'dependency'=>array(
                    'class'=>'CChainedCacheDependency',
                    'dependencies'=>array(
                        new CGlobalStateCacheDependency('article'),
                        new CDbCacheDependency('SELECT id FROM account
                            ORDER BY id DESC LIMIT 1'),
                    ),
                ), 
            ),
        );
    }
    
    public function actionIndex()
    {
        $db = Account::model()->getDbConnection();
        $total = $db->createCommand("SELECT SUM(amount) FROM account")->queryScalar();
        $criteria = new CDbCriteria();
        $criteria->order = "id DESC";
        $criteria->limit = 5;
        $articles = Article::model()->findAll($criteria);
        $this->render('index', array(
            'total' => $total,
            'articles' => $articles,
        ));
    }
 
    public function actionRandomOperation()
    {
        $rec = new Account();
        $rec->amount = rand(-1000, 1000);
        $rec->save();
        echo "OK";
    }
    
    public function actionRandomArticle()
    {
        $n = rand(0, 1000);
        $article = new Article();
        $article->title = "Title #".$n;
 
        $article->text = "Text #".$n;
        $article->save();
        Yii::app()->setGlobalState('article', $article->id);
        echo "OK";
    }
}
  1. That is it. Now, after loading dashboard/index several times, you will get only one simple query, as shown in the following screenshot:

Also, try to run either dashboard/randomOperation or dashboard/randomArticle and refresh dashboard/index after that. The data should change.

How it works...

In order to achieve maximum performance while doing minimal code modification, we use a full-page cache by using a filter as follows:

public function filters()
{
    return array(
        array(
            'COutputCache +index',
            // will expire in a year
            'duration'=>24*3600*365,
            'dependency'=>array(
            'class'=>'CChainedCacheDependency',
                'dependencies'=>array(
                    new CGlobalStateCacheDependency('article'),
                    new CDbCacheDependency('SELECT id FROM account ORDER
                        BY id DESC LIMIT 1'),
                ),
            ),
        ),
    );
}

The preceding code means that we apply full-page cache to the index action. Page will be cached for a year and the cache will refresh if one of the dependency data changes. Therefore, in general, the dependency works as follows:

  • First run: Gets the fresh data as described in the dependency, saves for future reference, and updates cache.
  • Gets the fresh data as described in dependency, gets the saved data, and then compares the two.
  • If they are equal, uses the cached data.
  • If not, updates cache, uses the fresh data, and saves the fresh dependency data for future reference.

In our case, two dependency types are used: global state and DB. Global state dependency uses data from Yii::app()->getGlobalState() to decide if we need to invalidate cache while DB dependency uses the SQL query result for the same purpose.

The question that you have now is probably, "why have we used DB for one case and global state for another?" That is a good question!

The goal of using the DB dependency is to replace heavy calculations and select the light query that gets as little data as possible. The best thing about this type of dependency is that we don't need to embed any additional logic in the existing code. In our case, we can use this type of dependency for account operations, but cannot use it for articles as the article content can be changed. Therefore, for articles, we set a global state named article to the added article's ID which basically means that we are scheduling cache invalidation:

Yii::app()->setGlobalState('article', $article->id);

Note that if we edit the article 100 times in a row and view it only after that, the cache will be invalidated and updated only once.

There's more...

In order to learn more about caching and using cache dependencies, refer to the following URLs:

  • http://www.yiiframework.com/doc/guide/en/caching.data#cache-dependency
  • http://www.yiiframework.com/doc/guide/en/caching.page

See also

  • The recipe named Creating filters in Chapter 8, Extending Yii
  • The recipe named Using controller filters in Chapter 10, Security

Profiling an application with Yii

If all of the best practices for deploying a Yii application are applied and you still do not have the performance you want, then most probably, there are some bottlenecks with the application itself. The main principle while dealing with these bottlenecks is that you should never assume anything and always test and profile the code before trying to optimize it.

In this recipe, we will try to find bottlenecks in the Yii blog demo application.

Getting ready

  • Download the latest Yii 1.1.x version from the following URL: http://www.yiiframework.com/download/
  • Unpack demos/blog in your webroot and framework, one level above it:
framework
www
    ...
    index.php
    ...
  • In index.php, correct the path to yii.php. It should be as follows:
$yii=dirname(__FILE__).'/../framework/yii.php';
  • In protected/yiic.php, correct the path to yiic.php. It should be:
$yiic=dirname(__FILE__).'/../../framework/yiic.php';
  • Change protected/config/console.php with the following:
return array(
    'basePath'=>dirname(__FILE__).DIRECTORY_SEPARATOR.'..',
    'name'=>'My Console Application',
    'import'=>array(
        'application.models.*',
        'application.components.*',
    ),
    'components'=>array(
        'db'=>array(
            'connectionString' => 'mysql:host=localhost;dbname=blog',
            'emulatePrepare' => true,
            'username' => 'root',
            'password' => '',
            'charset' => 'utf8',
            'tablePrefix' => 'tbl_',
        ),
    ),
);
  • In protected/config/main.php, comment the SQLite db settings and use MySQL:
/*'db'=>array(
    'connectionString' => 'sqlite:protected/data/blog.db',
    'tablePrefix' => 'tbl_',
),*/
// uncomment the following to use a MySQL database
'db'=>array(
    'connectionString' => 'mysql:host=localhost;dbname=blog',
    'emulatePrepare' => true,
    'username' => 'root',
    'password' => '',
    'charset' => 'utf8',
    'tablePrefix' => 'tbl_',
),
  • Create a new database in MySQL and import protected/data/schema.mysql. sql.
  • As there is not that much data, we need to generate more. Create protected/ commands/DataCommand.php as follows:
<?php
class DataCommand extends CConsoleComman
{
    public function actionIndex()
    {
        $db = Yii::app()->db;
        
        echo "Creating tags.\n";
        for($t=1; $t<=50; $t++)
        {
            $db->createCommand()->insert('tbl_tag', array(
                'name' => "tag $t",
                'frequency' => rand(1, 20),
            )); 
        }
        echo "Done.\n";
        
        for($i=1; $i<=1000; $i++)
        {
            $tags = array();
            for($rt=1; $rt<=10; $rt++)
            {
                $tags[] = "tag ".rand(1, 100);
            }
            
            $db->createCommand()->insert('tbl_post', array(
                'title' => "Post #$i",
                'content' => "<strong>Hello!</strong> This is the content #$i",
                'tags' => implode(", ", $tags),
                'status' => Post::STATUS_PUBLISHED,
                'create_time' => time(),
                'update_time' => time(),
                'author_id' => 1,
            ));
            
            $postId = $db->getLastInsertID();
            
            for($j=1; $j<=10; $j++)
            {
                $db->createCommand()->insert('tbl_comment', array(
                    'content' => "Comment text $j.",
                    'status' => Comment::STATUS_APPROVED,
                    'create_time' => time(),
                    'author' => "Commenter $j",
                    'email' => "commenter$j@example.com",
                    'url' => "http://example.com/",
                    'post_id' => $postId,
                ));
            }
            
            if($i%50==0)
                echo "\nAdded $i posts.\n";
        }
        
        echo "All done.\n";
    }
}
  • Run it by entering yiic data in console and have a cup of coffee.

How to do it...

We have a blog with lots of posts and comments and it works somehow but not fast enough. We want to check it page-by-page and get the bottlenecks for each one.

  1. We will start with using proper configuration for caching and turn on the SQL profiler. Your protected/config/main.php should look like the following:
...
return array(
...
    'components'=>array(
        ...
        'db'=>array(
            'connectionString' => 'mysql:host=localhost;dbname=blog',
            'username' => 'root',
            'password' => '',
            'charset' => 'utf8',
            'tablePrefix' => 'tbl_',
            'schemaCachingDuration' => 180,
            'enableProfiling'=>true,
            'enableParamLogging' => true,
        ),
        ...
        'log'=>array(
            'class'=>'CLogRouter',
            'routes'=>array(
                array(
                    'class' => 'CProfileLogRoute',
                ),
            ),
        ),
        'session' => array(
            'class' => 'CCacheHttpSession',
        ),
        'cache' => array(
            'class' => 'CApcCache',
        ),
    ),
    ...
);
  1. Now run the front page of the blog several times and check what the profiler screen shows us:
  1. The slowest query is the following:
SELECT `t`.`id` AS `t0_c0`, `t`.`content` AS `t0_c1`, `t`.`status`
AS `t0_c2`, `t`.`create_time` AS `t0_c3`, `t`.`author` AS `t0_
c4`, `t`.`email` AS `t0_c5`, `t`.`url` AS `t0_c6`, `t`.`post_
id` AS `t0_c7`, `post`.`id` AS `t1_c0`, `post`.`title` AS `t1_
c1`, `post`.`content` AS `t1_c2`, `post`.`tags` AS `t1_c3`,
`post`.`status` AS `t1_c4`, `post`.`create_time` AS `t1_c5`,
`post`.`update_time` AS `t1_c6`, `post`.`author_id` AS `t1_c7`
FROM `tbl_comment` `t`
LEFT OUTER JOIN `tbl_post` `post` ON (`t`.`post_id`=`post`.`id`)
WHERE (t.status=2)
ORDER BY t.create_time DESC
LIMIT 10
  1. Now we can add EXPLAIN in front of it and run it through the SQL console or any other SQL management tool. It will show us that there are no indexes used when filtering and sorting records. Therefore, if we add indexes for tbl_post.status and tbl_post.create_time, it will improve SELECT performance, as shown in the following screenshot:
  1. Next, we have 10 queries used to get the author of each post. Most probably, there is a way to combine these into a single one. As we are running post/index, we will check the PostController, actionIndex methods:
$criteria=new CDbCriteria(array(
    'condition'=>'status='.Post::STATUS_PUBLISHED,
    'order'=>'update_time DESC',
    'with'=>'commentCount',
));
if(isset($_GET['tag']))
    $criteria->addSearchCondition('tags',$_GET['tag']);
    
$dataProvider=new CActiveDataProvider('Post', array(
    'pagination'=>array(
        'pageSize'=>Yii::app()->params['postsPerPage'],
    ),
    'criteria'=>$criteria,
));
 
$this->render('index',array(
    'dataProvider'=>$dataProvider,
));
  1. When the data provider is getting posts, it uses criteria defined earlier. As we can see, a criterion allows us to get the count of comments by using the most efficient query possible. commentCount is a relation defined in the Post model and if we check its relations method, we will find that there is an author relation as well. By changing the with part of the criterion to
'with'=> array('commentCount', 'author'),

we have got rid of 10 additional queries. Instead, we have a single query that is performing very well:

  1. Now the SQL part works better. We can improve it further, but you have an idea and can do it as homework. Overall, it is still not perfect. We will add profiling markers to the controller code as follows:
Yii::beginProfile('preparing_data');
$criteria=new CDbCriteria(array(
    'condition'=>'status='.Post::STATUS_PUBLISHED,
    'order'=>'update_time DESC',
    'with'=> array('commentCount', 'author'),
));
if(isset($_GET['tag']))
    $criteria->addSearchCondition('tags',$_GET['tag']);
    
$dataProvider=new CActiveDataProvider('Post', array(
    'pagination'=>array(
        'pageSize'=>Yii::app()->params['postsPerPage'],
    ),
    'criteria'=>$criteria,
));
Yii::endProfile('preparing_data');
Yii::beginProfile('rendering_data');
$this->render('index',array(
    'dataProvider'=>$dataProvider,
));
Yii::endProfile('rendering_data');
  1. Now run the front page again and check the profiler:
  1. It looks like the rendering data part took most of the time. As rendering takes part in a view, let's check protected/views/post/index.php:
<?php if(!empty($_GET['tag'])): ?>
<h1>Posts Tagged with <i><?php echo CHtml::encode($_GET['tag']);?></i></h1>
<?php endif; ?>
 
<?php $this->widget('zii.widgets.CListView', array(
    'dataProvider'=>$dataProvider,
    'itemView'=>'_view',
    'template'=>"{items}\n{pager}",
)); ?>
  1. CListView uses _view to render each record. Let's add two more profiling markers to it as follows:
<?php Yii::beginProfile('_view')?>
<div class="post">
    ...
</div>
<?php Yii::endProfile('_view')?>
  1. Now run the application again and check the profiler:
  1. Something is obviously wrong with this view file. In order to determine what exactly, we are moving <?php Yii::beginProfile('_view')?> down while <?php Yii::endProfile('_view')?> goes up. Finally, we should stop around
<?php
    $this->beginWidget('CMarkdown', array('purifyOutput'=>true));
    echo $data->content;
    $this->endWidget();
?>

If you are the only author of the blog and don't care about someone entering malicious code, then you can just leave echo $data->content.

Other options will be caching the purified output or pre-processing it on saving a post.

  1. Let's assume it is our case. Remove the widget code and run the profiler one more time:
  1. A lot better. Finally, we have gotten down from 0.184 second to 0.078 second. That is about 42% less than the initial processing time.

We can achieve more by performing more profiling and fixing. Probably, you will say these values were acceptable from the beginning. That is true, until you will get more readers and the server will not be able to generate pages for all these people in parallel. What this performance gain really means is that if you have, for example, 10,000 readers in the beginning and performance is starting to drop, after optimizing the code, you can handle an additional 4,200 readers without buying new hardware.

How it works...

First, we configured the application cache and cached the DB schema to exclude these possible bottlenecks from profiling results. In a production environment, these will be cached for sure. Then, we turn on the profiling DB queries and run the application multiple times; in the first run, Yii will cache the schema and routes, and the second run will be clean.

As the typical web application bottleneck is a database, we start to look at the SQL query anomalies—the most time-consuming queries and same type queries repeating multiple times.

Long running queries are typically a bad DB design (wrong index placement or no indexes, too much normalization, and so on). Therefore, we can feed a query to MySQL adding EXPLAIN in front of the query and it will give back a query profile that tells us what to do.

When a same type query is executed multiple times, most probably it is something we are getting for each entity we are displaying. In our case, it was the author for each blog post. In most cases, we can get these in a single query or even in the same query, which selects entities themselves.

As for the non-SQL part, we divided the controller 50/50, then took the slowest part, and divided it again. We repeated that until a bottleneck was identified. Of course, we used some assumptions to do less the routine job of adding profiler marks, but sometimes it is better not to assume anything since a bottleneck can be hidden in a very innocent looking code.

There's more...

In order to learn more about profiling, refer to the following resources:

  • http://www.yiiframework.com/doc/guide/en/topics.logging#performance-profiling
  • http://www.yiiframework.com/doc/guide/en/topics.logging#profiling-sql-executions
  • http://www.xdebug.org/docs/profiler
  • http://pecl.php.net/package/xhprof

See also

  • The recipe named Using different log routes in Chapter 9, Error Handling, Debugging, and Logging
  • The recipe named Following best practices in this chapter
  • The recipe named Speeding up sessions handling in this chapter
评论 X

      友荐云推荐
      Copyright 2011-2014. YiiBook.com