Soon after we launched TotalFilm.com we had a problem. We had far more visitors than we were expecting! It's a nice problem to have but our server was working too hard. It just couldn't cope with PHP rendering the pages using CakePHP's built in view caching. We needed to come up with a solution quickly.
We benchmarked CakePHP's view caching. It didn't seem especially slow considering the functionality it provides inside cached views. We thought that we needed certain dynamic aspects (e.g. user login for commenting) but our pages simply didn't need to be custom built for each user. We could use a cookie and a bit of Javascript to display when a user was logged in. We only needed to check if users were logged in when they actually did something. We could cache most pages in their entirety and didn't need to use PHP but unfortunately CakePHP's view caching required PHP.
Your website could well need that level of dynamism or you might not want to implement some functionality solely in Javascript. That's fine but don't press your back button in disgust just yet! You can probably use the solution described below as an emergency level of caching reserved for when you experience extreme peak loads.
We decided to use a Memcache to create our quick fix caching engine. We used the URL as the cache key as this would enable us to use the NginxHttpMemcachedModule to serve cached pages directly from Memcache completely bypassing PHP.
The following instructions assume you have already installed memcached and the PHP Memcache extension.
Unfortunately CakePHP's built in cache engine for Memcache translates slashes into underscores - presumably as they are not allowed in filenames. That was bad news as URLs have slashes in them! We also need to be able to specify different cache timeouts for different types of page. An article page which only needs to be updated in the cache when that article changes can have a very long timeout but the homepage will change frequently and requires a much shorter timeout. We therefore needed a custom Memcache cache engine. This needs to go in cake/libs/cache/viewmemcache.php.
/**
* ViewMemcache storage engine for cache
*
* This is a very cut down rewrite of the memcache cache engine that comes with
* CakePHP. This cache engine only does the bare minimum to support chucking
* views into memcache.
*/
/**
* ViewMemcache storage engine for cache
*
* @package cake
* @subpackage cake.cake.libs.cache
*/
class ViewMemcacheEngine extends CacheEngine {
/**
* Memcache wrapper.
*
* @var Memcache
* @access private
*/
var $__Memcache = null;
/**
* Settings
*
* - servers = string or array of memcache servers, default => 127.0.0.1. If an
* array MemcacheEngine will use them as a pool.
* - compress = boolean, default => false
*
* @var array
* @access public
*/
var $settings = array();
/**
* Initialize the Cache Engine
*
* Called automatically by the cache frontend
* To reinitialize the settings call Cache::engine('EngineName', [optional] settings = array());
*
* @param array $setting array of setting for the engine
* @return boolean True if the engine has been successfully initialized, false if not
* @access public
*/
function init($settings = array()) {
if (!class_exists('Memcache')) {
return false;
}
parent::init(array_merge(array(
'engine'=> 'ViewMemcache',
'prefix' => Inflector::slug(APP_DIR) . '_',
'servers' => array('127.0.0.1'),
'compress'=> false
), $settings)
);
if ($this->settings['compress']) {
$this->settings['compress'] = MEMCACHE_COMPRESSED;
}
if (!is_array($this->settings['servers'])) {
$this->settings['servers'] = array($this->settings['servers']);
}
if (!isset($this->__Memcache)) {
$return = false;
$this->__Memcache =& new Memcache();
foreach ($this->settings['servers'] as $server) {
$parts = explode(':', $server);
$host = $parts[0];
$port = 11211;
if (isset($parts[1])) {
$port = $parts[1];
}
if ($this->__Memcache->addServer($host, $port)) {
$return = true;
}
}
return $return;
}
return true;
}
/**
* Write data for key into cache
*
* @param string $key Identifier for the data
* @param mixed $value Data to be cached
* @param integer $duration How long to cache the data, in seconds
* @return boolean True if the data was succesfully cached, false on failure
* @access public
*/
function write($key, &$value, $duration) {
$expires = time() + $value['timeout'];
return $this->__Memcache->set($key, $value['data'], $this->settings['compress'], $expires);
}
/**
* Read a key from the cache
*
* @param string $key Identifier for the data
* @return mixed The cached data, or false if the data doesn't exist, has expired, or if there was an error fetching it
* @access public
*/
function read($key) {
return $this->__Memcache->get($key);
}
/**
* Delete a key from the cache
*
* @param string $key Identifier for the data
* @return boolean True if the value was succesfully deleted, false if it didn't exist or couldn't be removed
* @access public
*/
function delete($key) {
return $this->__Memcache->delete($key);
}
/**
* Delete all keys from the cache
*
* @return boolean True if the cache was succesfully cleared, false otherwise
* @access public
*/
function clear() {
return $this->__Memcache->flush();
}
/**
* Connects to a server in connection pool
*
* @param string $host host ip address or name
* @param integer $port Server port
* @return boolean True if memcache server was connected
* @access public
*/
function connect($host, $port = 11211) {
if ($this->__Memcache->getServerStatus($host, $port) === 0) {
if ($this->__Memcache->connect($host, $port)) {
return true;
}
return false;
}
return true;
}
/**
* Use the key requested rather than doing any conversion on it
*
* @param string $key key
* @access public
*/
function key($key) {
return $key;
}
}
You will obviously need to change configuration parameters if your Memcache server is not running on the default port on localhost.
Cache::config('view', array(
'engine' => 'ViewMemcache',
'duration'=> 3600,
'prefix' => '',
'servers' => array(
'127.0.0.1:11211'
),
'compress' => false,
));
Configure::write('ViewMemcache.timeout', 3600);
The Memcache helper dumps rendered views into Memcache for us. This goes in app/views/helpers/memcache.php.
class MemcacheHelper extends Helper
{
function afterLayout()
{
$view = & ClassRegistry::getObject('view');
if (is_object($view) && array_key_exists('docache', $view->viewVars) && $view->viewVars['docache'] === true) {
$timeout = Configure::read('ViewMemcache.timeout');
if (array_key_exists('docachetimeout', $view->viewVars)) {
$timeout = $view->viewVars['docachetimeout'];
}
if (!array_key_exists('nocachefooter', $view->viewVars)) {
$view->output .= "\n';
}
Cache::write($view->here, array( 'data' => $view->output, 'timeout' => $timeout), 'view');
}
return true;
}
}
If you aren't using Nginx you probably should be. Nginx greatly outperforms Apache HTTP Server, maybe even when serving PHP these days. I won't cover how to setup Nginx - as there are so many different configurations you can use and it's different on each platform - but the extra configuration you need is as simple as:
server {
listen 80;
server_name superfastwebsite;
access_log /opt/local/var/log/nginx/access.log;
error_log /opt/local/var/log/nginx/error.log;
rewrite_log on;
# check memcache for page existence using uri as key
location /checkmemcache {
internal;
set $memcached_key $request_uri;
memcached_connect_timeout 2000;
memcached_read_timeout 2000;
memcached_pass 127.0.0.1:11212;
default_type text/html;
# if memcache throws one of the following errors head off to the php-cgi
error_page 404 502 = /edpagegeneration;
}
# Pass the PHP scripts to FastCGI server
# listening on 127.0.0.1:9000
location /edpagegeneration {
internal;
root /data/superfastwebsite/trunk/webroot;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_intercept_errors on; # to support 404s for PHP files not found
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location / {
root /data/superfastwebsite/trunk/webroot;
index index.php index.html;
# all non-existing file or directory requests to index.php
if (!-e $request_filename) {
# redirect directly to the main php processing
# rewrite ^(.*)$ /index.php last;
# redirect to memcache page checking first which will
# then fall through to the php-cgi
rewrite ^(.*)$ /checkmemcache last;
}
}
# Not found this on disk?
# Feed to CakePHP for further processing!
if (!-e $request_filename) {
rewrite ^/(.+)$ /index.php?url=$1 last;
break;
}
# Pass the PHP scripts to FastCGI server
# listening on 127.0.0.1:9000
location ~ \.php$ {
root /data/superfastwebsite/trunk/webroot;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_intercept_errors on; # to support 404s for PHP files not found
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Static files.
# Set expire headers, Turn off access log
location ~* \favicon.ico$ {
access_log off;
expires 1d;
add_header Cache-Control public;
}
location ~ ^/(img|js|css)/ {
access_log off;
expires 7d;
add_header Cache-Control public;
}
# Deny access to .htaccess files,
# git & svn repositories, etc
location ~ /(\.ht|\.git|\.svn) {
deny all;
}
}
Add our Memcache helper into your controller helper array:
var $helpers = array('Memcache');
To make a page cache, add the following to the controller method:
$this->set('docache', true);
To change the timeout from the default (in seconds):
$this->set('docachetimeout', 3432434);
To stop the cache comment appearing (e.g. for a JSON view):
$this->set('nocachefooter', true);
To remove something from the cache:
Cache::delete($key, 'view');
/before-you-implement-caching.html
For now, to clear the cache simply delete the relevant cache keys - i.e. the urls - when things are updated. I will discuss cache clearing and update strategies in a future article or come and see my talk at CakeFest 2010!
Thanks to Jon Bennett for going through this tutorial and spotting a few bugs and thanks for Ed Kennedy at Future Publishing for the Nginx configuration.