Adam Innes · Blog

Putting memcached in Front of MySQL in Your PHP App

· 7 min · php, memcached, mysql, performance, lamp

There’s a point most growing PHP sites hit where the web servers are bored and MySQL is sweating. You look at what it’s doing and it’s the same handful of SELECTs, over and over: the front page article list, a user’s profile, the topics in a forum. The data barely changes between requests, but every page view pays for the query again. That’s the job memcached was built for.

A quick note so nobody mixes these up: an opcode cache like APC saves PHP from recompiling your scripts on every request. memcached is a different animal. It caches your data, the results of queries and anything else expensive to build, and it does it over the network so every web server shares the same cache.

What memcached is

The project describes itself as a high performance, distributed memory object caching system, generic in nature but aimed at speeding up dynamic web applications by taking load off the database. Danga Interactive wrote it for LiveJournal, and the current stable release is 1.2.8, which came out on April 10.

The model is deliberately simple. It’s a big key/value dictionary held in RAM. You store a value under a key with an optional expiration, and you ask for it back later. There’s no config file, just a few command line switches. When memory fills up, memcached throws out expired items first and then the least recently used ones. Your client library hashes each key to pick which server it lives on, so adding more boxes adds more cache.

A few limits from the protocol document are worth knowing before you write any code. Keys can be up to 250 characters and can’t contain whitespace or control characters. The project FAQ puts the maximum value size at 1 megabyte, so that giant serialized array might not fit.

The cache-aside pattern

The usual approach is called cache-aside, and it’s exactly what the memcached homepage suggests. Wherever your code runs a query, first ask memcached. On a hit, use it. On a miss, go to MySQL, then store the result so the next request gets the hit.

Here’s what that looks like with the older memcache extension and PDO:

<?php
$memcache = new Memcache;
$memcache->addServer('10.0.0.40', 11211);

function get_forum_topics(PDO $db, Memcache $memcache, $forum_id)
{
    $key = 'forum_topics:' . (int) $forum_id;

    $topics = $memcache->get($key);
    if ($topics !== false) {
        return $topics;
    }

    $stmt = $db->prepare('SELECT id, title, updated FROM topics
                          WHERE forum_id = ? ORDER BY updated DESC LIMIT 50');
    $stmt->execute(array((int) $forum_id));
    $topics = $stmt->fetchAll(PDO::FETCH_ASSOC);

    // flag 0 (no compression), expire in 5 minutes
    $memcache->set($key, $topics, 0, 300);
    return $topics;
}

Arrays and objects get serialized for you, so you can cache the finished PHP structure rather than raw rows. That’s a real advantage over MySQL’s own query cache, which caches result sets but not whatever work you do on them afterward, and which throws away everything cached for a table whenever that table changes.

One gotcha: Memcache::get() returns false when a key isn’t found, so if the thing you cache can legitimately be false, you can’t tell a hit from a miss. Wrap it in an array or store something else. The newer extension has a cleaner answer, which I’ll get to.

Picking keys and expirations

Good keys are boring and predictable. Prefix them with the type of thing (user:, forum_topics:) and include the ID. That makes them easy to rebuild in the write path, which you’ll need for invalidation. The FAQ also shows caching a whole result by using md5() of the SQL string as the key, which keeps you safely under the length limit and away from spaces, but you can’t easily delete those later because you have to reconstruct the exact query.

For expirations, the rule that trips people up is that a number of seconds only works up to 30 days (2,592,000). Anything larger gets treated as a Unix timestamp, so passing 60 days in seconds gives you a date back in 1970 and an item that’s effectively already expired. The PHP manual spells this out on the expiration times page. Zero means no expiry, though the item can still be evicted when memory runs low.

How long to cache is a judgment call. I’d set an expiration on almost everything, even when you also invalidate on write. It’s a safety net: if an invalidation gets missed somewhere, stale data fixes itself in minutes instead of living forever.

Invalidating on write

When data changes, delete the cached copy right after the database write succeeds:

<?php
$stmt = $db->prepare('INSERT INTO topics (forum_id, title, updated) VALUES (?, ?, NOW())');
$stmt->execute(array((int) $forum_id, $title));

$memcache->delete('forum_topics:' . (int) $forum_id);

The next reader misses, hits MySQL, and repopulates. Deleting rather than rewriting keeps the write path simple, because you don’t need to rebuild the exact cached structure there.

There’s a small race to be aware of. A reader can fetch old rows from MySQL just before your write, then store them just after your delete, and now the stale version is cached until it expires. The memcached homepage suggests using add (store only if the key doesn’t exist) when populating from a read and set when updating, so a slow reader can’t clobber a fresh value that an update just wrote. With delete based invalidation, that expiration is your backstop.

Sometimes one write should wipe a whole group of keys, and memcached has no wildcard delete. The FAQ’s trick is to keep a version number in its own key, include that number in every key of the group, and bump it with increment() when you want them all gone. The old keys simply stop being asked for and age out.

memcache or memcached?

This is where PHP gets confusing, because there are two PECL extensions with nearly the same name.

The memcache extension is the established one. It has both an object and a procedural API, and its current stable release is 2.2.5 from February 27, with a 3.0 line still in beta.

The memcached extension is new. Its first PECL release was 0.1.0 on January 29, and the latest is 0.1.5 beta from March 31. It’s built on the libmemcached C library, so you need that installed before pecl install will build it. In return you get a richer API: multi-get and multi-set, CAS tokens, options like consistent hashing and key prefixes, and getResultCode(), so you can check for Memcached::RES_NOTFOUND instead of guessing what a false means.

If you want something marked stable today, that’s memcache 2.2.5. If you’re starting fresh, can install libmemcached, and are comfortable running a beta, memcached is the one to watch. Either way, I’d pick one per app rather than mixing the two against the same cached data.

Sessions in memcached

Both extensions ship a session handler, which is handy once you have more than one web server and file based sessions stop working. With memcache, the runtime configuration page takes URLs:

session.save_handler = memcache
session.save_path = "tcp://10.0.0.40:11211"

With memcached, the sessions page uses plain host:port pairs, and session keys get stored under a memc.sess.key. prefix:

session.save_handler = memcached
session.save_path = "10.0.0.40:11211"

Reading the 0.1.5 source, the memcached handler also takes a lock on each session while a request uses it, and sets the expiry from session.gc_maxlifetime. For memcache, session locking only shows up in the changelog for the 3.0.4 beta.

Now the tradeoff. memcached is a cache, and the FAQ is blunt that it isn’t redundant. Restart the daemon and every session on it is gone. Fill the memory and the least recently used sessions get evicted to make room, which your users experience as being logged out at random. For a site where losing a session means someone logs in again, that can be fine. For a shopping cart or a long form, think hard about it, or keep sessions somewhere durable and use memcached only for things you can rebuild.

Keep port 11211 off the internet

memcached has no authentication. The FAQ says so directly, and suggests a firewall if you want to restrict access. That means anyone who can open a connection can read, overwrite, or delete what’s in the cache, or flush the whole thing. The only protection is controlling who can reach it.

By default the daemon listens on every interface, on TCP port 11211 and UDP port 11211. The memcached man page calls the -l option important because there’s no other way to secure the installation, and suggests binding to an internal or firewalled interface. So bind it explicitly:

memcached -d -u nobody -m 256 -l 10.0.0.40 -p 11211 -U 0

That binds to a private address, gives it 256 MB, drops to an unprivileged user when started as root, and turns off UDP if you aren’t using it. If memcached and PHP are on the same box, bind to 127.0.0.1. Then firewall 11211 so only your web servers can reach it, and check from outside your network that the port really is closed. The FAQ also mentions listening on a Unix domain socket (-s) instead, which works when PHP and memcached share a machine.

Don’t put anything in the cache you’d be horrified to see leak, either. Treat it like any other internal service that trusts its network.

Where to start

Turn on MySQL’s slow query log or just watch your busiest pages, pick the one query that runs most often, and wrap it in cache-aside with a short expiration and a delete on write. Watch your database load drop, then move to the next one. memcached won’t fix a bad query, but for the reads you repeat thousands of times an hour, it’s about the cheapest capacity you can buy.

← all posts