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 #

AspectMemcachedRedis
Data typesStrings/serialized onlyString, 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 overheadLowerSlightly higher
Best use casePure cache, high throughputCache + 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 Memcached extension (not Memcache) 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.

← Previous: Redis   Next: Articles & Resources →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact