Performance Tuning PHP Apps on Windows/IIS with Output Caching

In this article, I’ll show you how to improve the performance of PHP applications on Windows/IIS by covering the What, When, and How of using the IIS Output Caching module. As background reading (or for the condensed version of my post), I suggest reading this: Configure IIS 7 Output Caching (and I strongly suggest reading the introduction to that article).

What is Output Caching?

The IIS output caching module allows you to configure IIS to cache dynamic pages generated by PHP. When a PHP page becomes “hot”, the content of the page is cached so that is served without executing the script that generates it. Obviously, this can have a significant impact on application performance, but it also means that you need to make sure that output caching is the right caching tool for your application (see When Should I Use Output Caching? below).

Note: Static content (such as plain HTML pages, images, style sheets, etc) is, by default, automatically cached by IIS.

With the IIS Output Caching module you can cache all pages generated by PHP, vary what is cached by query string parameter value, or vary what is cached by header value. Which of those options you choose to use will depend on your application, which brings up the next question…

When Should I Use Output Caching?

In figuring out when to  use output caching, it is important to keep one thing in mind: The output caching module caches entire pages and when a page is served from cache, none of its PHP code is executed. You can control which pages are cached first by page extension (e.g. .php), then by query string parameter and header type. With that in mind, here’s my high-level guidance about when to use output caching: Use output caching when you have entire pages whose content doesn’t change very often. (What is “very often” will depend on your application. For some applications, 10 minutes could be “very often”, while for others, 1 second could be.) Note that this includes pages that are requested in AJAX calls.

If you have a page with multiple data sources, these data sources may vary in how quickly they are updated, so using output caching might not be the best solution. However, if a page has lots of dynamic content that is be updated by separate AJAX calls, then some of the scripts being requested by the AJAX calls might be excellent candidates for output caching. If pages requested by AJAX calls have different query parameters, then you can definitely boost your application performance with output caching. Here’s why I narrow things down to this scenario: If you configure output caching to cache all pages with the .php  extension, then all (or nearly all) of your application pages may be cached (which is probably not what you want). If you then add that pages should be cached according to query string parameter or header value, then all PHP pages with the specified query string parameter or header value may be cached (again, probably not what you want). But, if a query string parameter is unique to a given page, then with this configuration you can be sure which pages will be cached. Hopefully, this will become clearer as I walk you through configuring output caching (think of my test script below as a script requested by an AJAX call).

How do I Configure Output Caching?

Configuring IIS to use output caching is fairly easy – you can either make some minor changes to your applications web.config file manually (which I’ll show you how to do here) or you can use the UI in IIS Manager to make the changes. However, to really understand how output caching works, I found it helpful to run some tests as I made changes to my configuration. In the sections below, I’ll walk you through the configuration changes I made and (in most cases) show you the results of the tests I ran so that you can get a feel for how output caching works. In all my tests, I used the following very simple PHP script to represent a page that generates “semi-dynamic” content.

 <?php
 $upperbound = isset($_GET['upperbound']) ? $_GET['upperbound'] : 100000000;
  
 for($i = 0; $i < $upperbound; $i++)
 {
     // loop to represent generation of "semi-dynamic" content
 }
 echo $upperbound; 
 ?>

Obviously, a “real” PHP page would be generating some interesting content, but this script will suffice for demonstrating output caching with IIS. (Think of this as a script invoked by an AJAX call.) If you are working along with me in this article, place the code above into a file in your wwwroot directory called dynamic_page.php.

Note: All page load times that I show in this article should be considered as examples only. I’ve generated page load times on my laptop, which is running Windows 7 (x64), IIS 7.5, and PHP 5.3.4. I’ve made no custom optimizations. YMMV.

I’ve organized things according to the major questions that came up for me…

When is a Page Cached?

By default, when you create a cache rule a page is cached when it is requested 2 times within a 10 second time period. These default values can be changed. However, I’ll explain how to change these settings in the context of an example. (You can skip ahead to the Changing When a Page is Cached section below if you can’t wait.)

When is a Page Expired From the Cache?

After a page is cached, you have three options in determining when a page is expired from the cache:

  1. Expire a cached page when a change is made to the source code for the page (Using file change notifications).
  2. Expire a cached page after a pre-determined time interval (At time intervals (hh:mm:ss) ).
  3. Never cache a page in the first place (Prevent all caching).

I’ll use user-mode caching and focus on expiring a cached page based on file change notifications and time intervals. Although I think expiring a cached page based on file changes or time intervals is fairly straightforward, I’ll walk through one example just to make the idea concrete. To do this, I’ll load my dynamic PHP page (dynamic_page.php) by executing the following test script from a command prompt:

 <?php
 function microtime_float() 
 { 
     list($usec, $sec) = explode(" ", microtime()); 
     return ((float)$usec + (float)$sec); 
 } 
  
 for($i = 1; $i <= 5; $i++)
 {
     $ch = curl_init();
     curl_setopt($ch, CURLOPT_URL, "https://localhost/dynamic_page.php");
     $start_time = microtime_float();
     curl_exec($ch);
     $end_time = microtime_float();
     $time = round($end_time - $start_time, 4); 
     echo " ".$time."\n";
     curl_close($ch);
 }
 ?>

When I run the script above without first creating a cache rule, here’s what I get:

image

Note: 100000000 is the output of the dynamic_page.php page and the times (load times for dynamic_page.php) are the output of the cache_test.php file.

Now let’s create a cache rule and rerun the test.

Creating a File Change Notification Cache Rule

To create a file change notification cache rule, open (or create) the web.config file in your site’s root directory. Then, add the <caching> element as a child element <system.webServer> element as shown here:

 <?xml version="1.0" encoding="UTF-8"?>
 <configuration>
     <system.webServer>
         <caching>
             <profiles>
                 <add extension=".php" 
                      policy="CacheUntilChange" />
             </profiles>
         </caching>
     </system.webServer>
 </configuration>

Note: You can create caching rules through a friendly UI in IIS Manager. For more information, see Add an Output Caching Rule (IIS 7).

Make sure you save your web.config file after you’ve edited it.

Now when I run my test, here’s what I get:

image

Notice that the “dynamic content” of the page was generated on the first two page loads, but after that, the page was served from the cache.

Note: By default, a page is only cached if it is requested at least 2 times within a 10 second time period. To learn how to change these default values, see the Change When a Page is Cached section below. Because the cache rule expires the cached page only after the source file has been changed, subsequent requests for the page will be served from the cache. To see this, re-run the test (all load times will be similar to the last three above).

Further note: File change notifications also have a time limit of approximately 4 minutes. In other words, even if you don’t change the source code for a page and the page is not requested for approximately 4 minutes, it will be removed from the cache (and then cached again when it is requested twice within 10 minutes). So, if you want a page cached for a longer period of time (and you don’t expect to change the source code), I would suggest using a time interval cache rule (see below).

To see that the page is not served from cache after the source file is changed, add a comment to dynamic_page.php, save it, and re-run the test.

Creating a Time Interval Cache Rule

If you’d rather that a page be expired from the cache at some time interval (instead of waiting for a change to the source file, as above), add a <caching> element to your web.config file as shown here:

 <?xml version="1.0" encoding="UTF-8"?>
 <configuration>
     <system.webServer>
         <caching>
             <profiles>
                 <add extension=".php" 
                      policy="CacheForTimePeriod" 
                      duration="00:00:30" />
             </profiles>
         </caching>
     </system.webServer>
 </configuration>

This cache policy will expire a page from the cache after 30 seconds (change the value of the duration attribute as you see fit). With this rule in effect, you will see test results similar to those above (with the file change rule), but if you rerun the test after 30 seconds, you will see that the page is not served from the cache. And, as with any cache rule, a page is cached only when it is requested 2 times within a 10 second time period by default.

Changing When a Page is Cached

By default, when you create a cache rule a page is cached when it is requested 2 times within a 10 second time period. You can change these default values by first making a change to your applicationHost.config file and then updating your web.config file. Your applicationHost.config file is typically in the c:\Windows\System32\inetsrv\config directory and is most easily opened with Notepad from a command prompt with administrator privileges. In that file, find the following entry (a child of the <sectionGroup name="system.webServer"> element): <section name="serverRuntime" overrideModeDefault="Deny" />. Change Deny to Allow and save the file.

 <sectionGroup name="system.webServer">
  
     <!-- other elements here -->
  
     <section name="serverRuntime" overrideModeDefault="Allow" />
  
     <!-- more elements here -->
  
 </sectionGroup>

Now you can add the following element as a child of the <system.webServer> element in your web.config file: <serverRuntime frequentHitThreshold="3" frequentHitTimePeriod="00:00:20" />.

 <?xml version="1.0" encoding="UTF-8"?>
 <configuration>
     <system.webServer>
         <serverRuntime frequentHitThreshold="3" frequentHitTimePeriod="00:00:20" />
         <caching>
             <profiles>
                 <add extension=".php" 
                      policy="CacheUntilChange"  
                      duration="00:00:30" />
             </profiles>
         </caching>
     </system.webServer>
 </configuration>

This will cache pages after they have been requested 3 times in a 20 second period. Obviously, change those values as you see fit. (The element applies to both file change and time interval caching.)

If you want to be sure that a page is never cached, add the following <caching> element to your web.config file:

 <?xml version="1.0" encoding="UTF-8"?>
 <configuration>
     <system.webServer>
         <caching>
             <profiles>
                 <add extension=".php" 
                      policy="DisableCache" />
             </profiles>
         </caching>
     </system.webServer>
 </configuration>

Caching Based on Query String Variables vs. Headers

Often times, you might want to cache a page based on what query string variables or headers are sent in the request. For example, when creating a cache rule for my dynamic_page.php page, I want a request for https://localhost/dynamic_page.php?upperbound=25000000 to cached separately from a request for https://localhost/dynamic_page.php?50000000.  In the examples above, these requests are treated as the same request (because I didn’t specify that I wanted caching based on query string values), so the wrong page could be served from cache. We can see this by altering our test (cache_test.php) to send requests with different query string values (note that the page requests now have a different query string value each time through the loop):

 <?php
 function microtime_float() 
 { 
     list($usec, $sec) = explode(" ", microtime()); 
     return ((float)$usec + (float)$sec); 
 } 
  
 for($i = 1; $i &lt;= 5; $i++)
 {
     $upperbound = 5000000*$i;
     $ch = curl_init();
     curl_setopt($ch, CURLOPT_URL, "https://localhost/dynamic_page.php?upperbound=$upperbound");
     $start_time = microtime_float();
     curl_exec($ch);
     $end_time = microtime_float();
     $time = round($end_time - $start_time, 4); 
     echo " ".$time."\n";
     curl_close($ch);
 }
 ?>

When I run this test, here’s what I get:

image

As you can see, the query string variables are ignored and the wrong page is served from cache. Fortunately, it’s easy to fix this.

Creating a Cache Rule Based on Query String Variables

To make sure that pages are cached based on query string variables, follow the same steps outlined above for creating a cache rule (either file change notification rule or time interval rule) and add one attribute/value pair to the <add> element: varyByQueryString="variable_name". So, in my case, I have a web.config file like this:

 <?xml version="1.0" encoding="UTF-8"?>
 <configuration>
     <system.webServer>
         <caching>
             <profiles>
                 <add extension=".php" 
                      policy="CacheForTimePeriod"  
                      duration="00:00:30" 
                      varyByQueryString="upperbound" />
             </profiles>
         </caching>
     </system.webServer>
 </configuration>

Now, when I run my test, I see this:

image

Each request was treated as a different request, so none of the pages were served from cache.  To see that pages are actually cached, run the test again within 30 seconds (since that’s the time interval for my cache rule):

image

Note that each page was served from cache.

Creating a Cache Rule Based on Headers

Just as you may want pages cached according to their query string values, you may want them cached according to header values. And, just as with query string variables, this doesn’t happen by default. Unless a cache rule specifies that page values should be cached according to header values, then header values will not be considered when a page is cached. Again, the fix is easy…

To make sure that pages are cached by header value, follow the same steps outlined above for creating a cache rule (either file change notification rule or time interval rule) and add one attribute/value pair to the <add> element: varyByHeaders="header _name". So, if you want to vary caching based on the value of the Accept-Charset header, your web.config file should look like this:

 <?xml version="1.0" encoding="UTF-8"?>
 <configuration>
     <system.webServer>
         <caching>
             <profiles>
                 <add extension=".php" 
                      policy="CacheForTimePeriod" 
                      duration="00:00:30" 
                      varyByHeaders="Accept-Charset" />
             </profiles>
         </caching>
     </system.webServer>
 </configuration>

To demonstrate this rule in action, I’ll change my test so that page requests are sent with different values for the Accept-Charset header on each request (note that I’m keeping the query string value constant with each request):

 <?php
 function microtime_float() 
 { 
     list($usec, $sec) = explode(" ", microtime()); 
     return ((float)$usec + (float)$sec); 
 } 
  
 $charset = array('Accept-Charset: ISO-8859-1', 
                  "Accept-Charset: US-ASCII", 
                  "Accept-Charset: q=0.8", 
                  "Accept-Charset: UTF-8", 
                  "Accept-Charset: ISO-10646-UCS-2");
  
 for($i = 1; $i <= 5; $i++)
 {
     $ch = curl_init();
     curl_setopt($ch, CURLOPT_URL, "https://localhost/dynamic_page.php?upperbound=25000000");
     curl_setopt($ch, CURLOPT_HTTPHEADER, array($charset[$i - 1]));
     $start_time = microtime_float();
     curl_exec($ch);
     $end_time = microtime_float();
     $time = round($end_time - $start_time, 4); 
     echo " ".$time."\n";
     curl_close($ch);
 }
 ?>

Running the test above shows that pages are not cached initially because the header values are different:

image

Each request was treated as a different request, so none of the pages were served from cache. To see that pages are actually cached, run the test again within 30 seconds (since that’s the time interval for my cache rule):

image

Note that each page is served from the cache.

Caching Based on Query String Variables AND Headers

Of course, pages can be cached based on query string values and header values. For example (using my dynamic_page.php script), if you wanted the combination of a query string value (upperbound) and a header value (Accept-Charset) to determine whether a page is cached, your web.config file would look something like this:

 <?xml version="1.0" encoding="UTF-8"?>
 <configuration>
     <system.webServer>
         <caching>
             <profiles>
                 <add extension=".php" 
                      policy="CacheForTimePeriod"  
                      duration="00:00:30" 
                      varyByHeaders="Accept-Charset" 
                      varyByQueryString="upperbound" />
             </profiles>
         </caching>
     </system.webServer>
 </configuration>

Now, a page will be cached only when it is requested with the same query string value and header value twice within 10 seconds (unless you’ve changed the frequentHitThreshold and frequentHitTimePeriod values as described in the Changing When a Page is Cached section above).

Summary

As you can see, using output caching can greatly improve how PHP quickly content is is served by IIS and therefore improve the performance of your PHP application on Windows/IIS. Of course, as I mentioned early on, you need to be sure that output caching is right for your application. If it isn’t, you will need a caching tool that gives you finer control over what is cached (e.g. WinCache, which I’ll cover soon).

For a complete IIS <caching> element reference, see https://www.iis.net/ConfigReference/system.webServer/caching.

Thanks.

-Brian

Share this on Twitter