Memcached #
Memcached is a distributed caching system that’s very simple and very fast — designed for a single purpose: storing data in memory so it can be retrieved far faster than from a database. Unlike Redis, which has many data structures and features, Memcached only stores simple key-value pairs. This simplicity is its strength in certain scenarios: very low latency, very high throughput, and easy horizontal scaling by adding nodes. PHP has two extensions for Memcached: Memcached (newer, more feature-rich, uses libmemcached) and Memcache (older, not recommended for new code). This article focuses on the Memcached extension, which is the modern choice.
Memcached vs Redis — When to Use Which #
| Aspect | Memcached | Redis |
|---|---|---|
| Data types | Strings/serialized only | String, Hash, List, Set, Sorted Set, etc. |
| Data persistence | ✗ None | ✓ RDB and AOF |
| Clustering | ✓ Client-side sharding | ✓ Native Redis Cluster |
| Pub/Sub | ✗ | ✓ |
| Lua scripting | ✗ | ✓ |
| Multi-threading | ✓ Native | ✓ (Redis 6+) |
| Memory overhead | Lower | Slightly higher |
| Best use case | Pure cache, high throughput | Cache + data structure features |
Choose Memcached if:
✓ You only need a simple key-value cache
✓ Very high throughput and very low latency are priorities
✓ You already have a running Memcached infrastructure
✓ You don't need data persistence at all
Choose Redis if:
✓ You need data structures beyond key-value (lists, sets, sorted sets)
✓ You need pub/sub, job queues, or rate limiting
✓ You want data to survive restarts
✓ Starting a new project — Redis is more versatile
Installation #
# Install the Memcached server
sudo apt install memcached
sudo systemctl enable memcached
sudo systemctl start memcached
# Install the PHP Memcached extension
sudo apt install php8.3-memcached
# Or via PECL
sudo apt install libmemcached-dev
sudo pecl install memcached
echo "extension=memcached.so" | sudo tee /etc/php/8.3/mods-available/memcached.ini
sudo phpenmod memcached
# Verify
php -m | grep memcached
memcached -h | head -5
Connecting and Server Pools #
Memcached’s strength is automatically distributing data across several servers (consistent hashing):
<?php
$mc = new Memcached();
// Add one server
$mc->addServer('127.0.0.1', 11211);
// Add several servers — Memcached distributes keys automatically
$mc->addServers([
['cache1.internal', 11211, 40], // host, port, weight
['cache2.internal', 11211, 30],
['cache3.internal', 11211, 30],
]);
// Keys are distributed based on weight:
// cache1 gets ~40% of keys, cache2 and cache3 each get ~30%
// Configure important options
$mc->setOption(Memcached::OPT_COMPRESSION, true); // compress large values
$mc->setOption(Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_PHP); // default serializer
$mc->setOption(Memcached::OPT_PREFIX_KEY, 'myapp:'); // prefix all keys
$mc->setOption(Memcached::OPT_CONNECT_TIMEOUT, 500); // connection timeout 500ms
$mc->setOption(Memcached::OPT_RECV_TIMEOUT, 1000); // receive timeout 1000ms
$mc->setOption(Memcached::OPT_SEND_TIMEOUT, 1000); // send timeout 1000ms
$mc->setOption(Memcached::OPT_LIBKETAMA_COMPATIBLE, true); // consistent hashing
$mc->setOption(Memcached::OPT_NO_BLOCK, true); // non-blocking I/O
// Check server status
$stats = $mc->getStats();
foreach ($stats as $server => $info) {
echo "$server: " . $info['bytes_used'] . " bytes used, " . $info['curr_items'] . " items\n";
}
// Persistent connection — connections are kept between requests
$mcPersistent = new Memcached('connection-pool-id');
// Only add servers if they're not there yet (persistent connections already have them)
if (!$mcPersistent->getServerList()) {
$mcPersistent->addServers([
['127.0.0.1', 11211],
]);
}
Basic Operations #
<?php
$mc = new Memcached();
$mc->addServer('127.0.0.1', 11211);
// SET — store a value
$mc->set('key', 'value', 3600); // key, value, TTL in seconds
$mc->set('user:42', ['name' => 'Budi', 'email' => '[email protected]'], 1800);
// TTL = 0 means never expires (but can still be evicted if Memcached runs out of memory)
$mc->set('config:app', ['debug' => false, 'version' => '2.0'], 0);
// GET — retrieve a value
$value = $mc->get('key');
if ($mc->getResultCode() === Memcached::RES_NOTFOUND) {
echo "Key not found\n";
// fetch from the database
}
$user = $mc->get('user:42');
if ($user !== false) {
echo $user['name'] . "\n"; // Budi
}
// ADD — only store if the key doesn't exist yet
$success = $mc->add('lock:process', '1', 30);
if (!$success) {
// the key already exists — another process is running
throw new \RuntimeException("Process is already running");
}
// REPLACE — only update if the key already exists
$success = $mc->replace('user:42', ['name' => 'Budi Santoso'], 1800);
if (!$success) {
// the key doesn't exist — need to set it first
$mc->set('user:42', ['name' => 'Budi Santoso'], 1800);
}
// DELETE — remove a key
$mc->delete('key');
$mc->delete('key', 0); // delete immediately (delay = 0 seconds)
// FLUSH — remove all keys (use very carefully in production!)
// $mc->flush(); // DANGER: deletes ALL cache on the server
// INCREMENT / DECREMENT
$mc->set('counter:views', 0, 0);
$mc->increment('counter:views'); // +1, returns the new value
$mc->increment('counter:views', 5); // +5
$mc->decrement('counter:views', 2); // -2
echo $mc->get('counter:views'); // 4
// increment / decrement with an initial value if the key doesn't exist
$mc->increment('counter:new', 1, 0, 3600); // +1, default 0, TTL 3600
// GETMULTI — fetch many keys at once
$values = $mc->getMulti(['key1', 'key2', 'user:42', 'missing:key']);
// ['key1' => 'value1', 'key2' => 'value2', 'user:42' => [...]]
// missing keys are absent from the result
// Detect misses in getMulti
$keys = ['product:1', 'product:2', 'product:3', 'product:999'];
$cached = $mc->getMulti($keys);
$missed = array_diff($keys, array_keys($cached ?? []));
// $missed contains the keys that weren't in the cache
// SETMULTI — store many keys at once
$mc->setMulti([
'a' => 'value-a',
'b' => 'value-b',
'c' => 'value-c',
], 3600);
// DELETEMULTI — delete many keys
$mc->deleteMulti(['a', 'b', 'c']);
CAS — Check and Set (Atomic Updates) #
CAS enables safe updates without race conditions — like optimistic locking:
<?php
// Scenario: two requests try to update the same counter concurrently
// Step 1: fetch the value WITH the CAS token
$casToken = null;
$value = $mc->get('counter:stock:42', null, $casToken);
// $casToken now holds a unique token for this version of the data
if ($value === false) {
// Not in the cache — set it from scratch
$mc->set('counter:stock:42', 10, 3600);
} else {
// Step 2: update ONLY if the data hasn't changed since it was read
$success = $mc->cas($casToken, 'counter:stock:42', $value - 1, 3600);
if (!$success) {
if ($mc->getResultCode() === Memcached::RES_DATA_EXISTS) {
// Data was changed by another request since we read it — retry
echo "CAS conflict — retrying\n";
// Implement a retry loop
}
}
}
// Example: update inventory with CAS and retries
function reduceStock(Memcached $mc, int $productId, int $amount, int $maxRetries = 3): bool
{
$key = "stock:$productId";
for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
$casToken = null;
$stock = $mc->get($key, null, $casToken);
if ($stock === false) {
return false; // stock not in the cache
}
if ($stock < $amount) {
throw new \DomainException("Insufficient stock: $stock < $amount");
}
$success = $mc->cas($casToken, $key, $stock - $amount, 3600);
if ($success) {
return true; // success!
}
if ($mc->getResultCode() !== Memcached::RES_DATA_EXISTS) {
return false; // some other error
}
// DATA_EXISTS — retry
usleep(rand(1000, 5000)); // wait a random 1-5ms before retrying
}
return false; // failed after maxRetries attempts
}
Namespaces and Group Key Invalidation #
Memcached doesn’t support wildcard deletes (like DEL product:*) — one of its biggest limitations. The common trick to work around this is namespace versioning:
<?php
class MemcachedNamespace
{
public function __construct(private Memcached $mc) {}
// Get the current namespace version
private function getVersion(string $namespace): int
{
$key = "ns_version:$namespace";
$version = $this->mc->get($key);
if ($version === false) {
$version = 1;
$this->mc->set($key, $version, 0); // never expires
}
return (int) $version;
}
// Build a versioned namespace key
public function key(string $namespace, string $key): string
{
$version = $this->getVersion($namespace);
return "{$namespace}:v{$version}:{$key}";
}
public function set(string $namespace, string $key, mixed $value, int $ttl = 3600): bool
{
return $this->mc->set($this->key($namespace, $key), $value, $ttl);
}
public function get(string $namespace, string $key): mixed
{
return $this->mc->get($this->key($namespace, $key));
}
// Invalidate an entire namespace — just increment the version!
// All old keys become automatically "invisible" because the version differs
public function invalidateNamespace(string $namespace): void
{
$key = "ns_version:$namespace";
// If it doesn't exist yet, set it first
if ($this->mc->get($key) === false) {
$this->mc->set($key, 2, 0);
} else {
$this->mc->increment($key); // increment the version
}
// Old keys are still in memory but will never be accessed again
// Memcached removes them via LRU eviction or TTL expiry
}
}
// Usage
$cache = new MemcachedNamespace($mc);
// Store product data in the 'products' namespace
$cache->set('products', '42', ['name' => 'Laptop', 'price' => 15000000]);
$cache->set('products', '43', ['name' => 'Monitor', 'price' => 5000000]);
// Read
$laptop = $cache->get('products', '42');
// Invalidate ALL product keys after a bulk update
$cache->invalidateNamespace('products');
// Now all product caches are "gone" — will be refreshed from the database
$laptop = $cache->get('products', '42'); // null — miss!
Proper Caching Patterns #
<?php
class CacheLayer
{
public function __construct(private Memcached $mc, private PDO $db) {}
// Cache-aside: read from the cache, on a miss fetch from the DB
public function getProduct(int $id): ?array
{
$key = "product:detail:$id";
$data = $this->mc->get($key);
if ($data !== false) {
return $data; // cache hit
}
// Cache miss — fetch from the database
$stmt = $this->db->prepare("SELECT * FROM products WHERE id = :id AND active = 1");
$stmt->execute([':id' => $id]);
$product = $stmt->fetch() ?: null;
if ($product !== null) {
// Store in the cache — 1-hour TTL with a little jitter to prevent stampedes
$ttl = 3600 + random_int(-300, 300); // 55-65 minutes
$this->mc->set($key, $product, $ttl);
}
return $product;
}
// Invalidate after an update
public function updateProduct(int $id, array $data): void
{
$stmt = $this->db->prepare("UPDATE products SET name = :name, price = :price WHERE id = :id");
$stmt->execute([':name' => $data['name'], ':price' => $data['price'], ':id' => $id]);
// Delete the cache — it will be refreshed on the next request
$this->mc->delete("product:detail:$id");
}
// Cache stampede prevention — dogpile lock
public function getProductAntiStampede(int $id): ?array
{
$key = "product:detail:$id";
$lockKey = "product:lock:$id";
$data = $this->mc->get($key);
if ($data !== false) {
return $data;
}
// Try to acquire the lock — only one request may hit the DB
$gotLock = $this->mc->add($lockKey, '1', 10); // 10-second lock
if (!$gotLock) {
// Another request is fetching — wait briefly and check the cache again
usleep(200_000); // 200ms
$data = $this->mc->get($key);
return $data !== false ? $data : null;
}
try {
$stmt = $this->db->prepare("SELECT * FROM products WHERE id = :id");
$stmt->execute([':id' => $id]);
$product = $stmt->fetch() ?: null;
if ($product !== null) {
$this->mc->set($key, $product, 3600);
}
return $product;
} finally {
$this->mc->delete($lockKey); // always release the lock
}
}
}
Common Memcached Anti-Patterns #
<?php
// ✗ Anti-pattern 1: storing too much data in one key
$mc->set('all:products', $db->query("SELECT * FROM products")->fetchAll()); // thousands of products!
// Memcached has a 1MB limit per item by default
// ✓ Cache per item or per page
$mc->set("products:page:1", $productsPage1, 300);
$mc->set("products:page:2", $productsPage2, 300);
// ✗ Anti-pattern 2: TTLs too long for frequently changing data
$mc->set('price:product:42', 15000000, 86400); // 24 hours — way too long for prices!
// ✓ TTLs matching the data's change frequency
$mc->set('price:product:42', 15000000, 300); // 5 minutes — more reasonable
// ✗ Anti-pattern 3: keys without TTLs for data that should expire
$mc->set('session:user:42', $sessionData, 0); // never expires!
// If Memcached restarts, data is lost; if it doesn't, data keeps piling up
// ✓ Always set a reasonable TTL
$mc->set('session:user:42', $sessionData, 1800); // 30 minutes
// ✗ Anti-pattern 4: not handling cache misses well (cache stampede)
// Many concurrent requests miss the cache and all hit the database!
// Use a dogpile lock like in the example above
// ✗ Anti-pattern 5: using Memcached for data that needs persistence
// Memcached has no persistence — when the server restarts, all data is lost
// Don't store critical data (like important sessions) ONLY in Memcached
Summary #
- The
Memcachedextension (notMemcache) is the modern choice — more feature-rich, uses libmemcached, supports CAS, consistent hashing, and server pools.- Distributed caching is Memcached’s main strength — add
addServers()with weights, and Memcached distributes keys automatically using consistent hashing.- CAS (Check and Set) for atomic updates without race conditions — read the value with a token, update only if the token is still valid. On
RES_DATA_EXISTS, retry the loop.- Memcached can’t do wildcard deletes — use namespace versioning: store the namespace version in a separate key and increment it to invalidate the entire group.
- TTLs with jitter (
3600 + random_int(-300, 300)) prevent cache stampedes — not all keys expire in the same second.- Dogpile locks use
add()(atomic: only succeeds if the key doesn’t exist) to ensure only one request hits the database during simultaneous cache misses.- Persistent connections (
new Memcached('pool-id')) — connections are kept between PHP requests in FPM, reducing TCP handshake overhead.- Choose Redis for new projects — more versatile, more complete features, and not much performance loss for regular caching use cases.