Redis and Memcached Concepts

Use Redis and Memcached as an application cache, session store, and queue backend; understand TTL semantics, Redis pub/sub and streams, and how to implement a distributed lock safely.

Redis and Memcached are the two dedicated, out-of-process caching servers most PHP applications reach for once an in-process cache like APCu isn't enough — typically because the cache needs to be shared across multiple web servers or worker processes rather than living inside a single PHP process's memory. Both are in-memory key-value stores accessed over a fast binary protocol, but they diverge in capability: Memcached is deliberately simple (strings in, strings out, multi-threaded for raw throughput), while Redis adds richer data structures (hashes, lists, sets, sorted sets, streams), optional persistence to disk, and features well beyond caching — sessions, queues, pub/sub, and distributed locks all build on the same server.

Redis and Memcached at a glance

Both servers solve the same core problem — a fast, shared, out-of-process key-value store — but make different trade-offs. Memcached is simpler by design: it stores strings (or byte blobs), is multi-threaded, and has no built-in persistence — if it restarts, the cache is empty and every key has to be recomputed. That simplicity makes it easy to reason about and scale horizontally, but limits it to being purely a cache.

Redis stores richer types natively (strings, hashes, lists, sets, sorted sets, streams) and can persist to disk (RDB snapshots and/or an append-only file), which lets it double as more than a cache — a session store, a lightweight queue, a pub/sub broker — often without adding another piece of infrastructure. Most new PHP projects reach for Redis by default precisely because of this versatility; Memcached remains a reasonable, lower-overhead choice when the only requirement is a pure cache with no need for the extra data structures.

• Memcached: simple string cache, multi-threaded, no persistence — purely a cache. • Redis: rich data structures (hash, list, set, sorted set, stream), optional persistence, doubles as sessions/queue/pub-sub/locks. • Redis's versatility is why most new projects default to it; Memcached is still a fine choice for a pure, low-overhead cache.

Using Redis/Memcached as an application cache, and TTL semantics

Both servers expose the caching operation as a set with an expiration. In Redis (via the phpredis extension), $redis->set($key, $value, ['EX' => 60]) sets a 60-second TTL, and the NX flag (set only if the key doesn't already exist) makes the same command double as the basis for locks, as shown further below. In Memcached (via the Memcached extension), $memcached->set($key, $value, 60) takes the TTL directly as the third argument.

TTL semantics are worth being precise about: in Redis, EX 60 means the key is deleted automatically 60 seconds from now regardless of how many times it's read in between (reads don't reset the TTL unless you explicitly re-set it), and PX sets the same thing in milliseconds for finer-grained control. A TTL of 0 or omitting it entirely means the key never expires on its own — which is rarely what you want for a cache entry, since it removes the self-healing safety net discussed in the previous lesson.

• Redis: $redis->set($key, $value, ['EX' => seconds]) or ['PX' => milliseconds] for sub-second TTLs. • Memcached: $memcached->set($key, $value, $ttlSeconds) — TTL is a plain third argument. • A TTL counts down from when the key is set, not from when it's last read — reading a key doesn't refresh its TTL by itself. • No TTL means the key lives forever (until evicted for memory pressure or explicitly deleted) — almost never the right default for a cache.

Session storage in Redis

PHP's default session handler stores each user's session data as a file on the local disk of whichever server handled that request — which breaks the moment an application runs behind a load balancer across multiple servers, because a user's next request might land on a server that never saw their session file. Pointing PHP's session handler at Redis instead (session.save_handler = redis and session.save_path pointing at the Redis server) makes session data available to every application server uniformly, since they all read and write the same shared Redis instance rather than their own local disk.

This requires no changes to application code that reads and writes $_SESSION — the switch is entirely in configuration — and comes with a useful side effect: Redis's own TTL mechanism can double as the session's expiration, automatically evicting sessions that have been idle past a configured lifetime without any separate cleanup job.

• Default file-based sessions don't work across multiple app servers without sticky sessions — Redis-backed sessions do. • session.save_handler=redis + session.save_path=<redis connection string> — no application code changes needed. • Redis's TTL can double as automatic session expiration, replacing a separate garbage-collection cron job.

Redis and Memcached as a queue backend

Redis's list type provides everything needed for a simple FIFO queue: LPUSH adds a job to one end of the list, and BRPOP (blocking right-pop) removes and returns a job from the other end, blocking efficiently until one is available rather than polling in a tight loop. This is a common, lightweight alternative to a dedicated message broker for straightforward job queues, and it's the mechanism underneath several popular PHP queue libraries' Redis driver. (The full architecture around this — retries, backoff, dead-letter queues, scheduling, and idempotency — is its own topic, covered in the "Background Queues and Job Processing" lesson.)

Memcached has no equivalent built-in queue primitive — it's a pure key-value cache — so it isn't used as a queue backend the way Redis is; applications that want Memcached for caching but need a queue typically pair it with Redis or a dedicated broker (RabbitMQ, Amazon SQS) for that separate concern.

• Redis LPUSH/BRPOP gives a simple, efficient blocking FIFO queue with no extra infrastructure beyond Redis itself. • This is the mechanism several PHP queue libraries use under their Redis driver. • Memcached has no built-in queue primitive — it's cache-only. • Full queue architecture (retries, backoff, DLQs, idempotency) is covered separately in the queues lesson.

Pub/sub and streams

Redis's publish/subscribe lets one process PUBLISH a message to a named channel while any number of other processes that have SUBSCRIBEd to that channel receive it in real time — useful for broadcasting events (a cache-invalidation signal, a live notification) to multiple listeners without them polling. Pub/sub is fire-and-forget, though: a message published while no one is subscribed is simply lost, and a subscriber that briefly disconnects misses whatever was published during that gap.

Redis Streams (the XADD/XREAD/XREADGROUP family of commands) solve exactly that durability gap: a stream is an append-only log of entries that persists in Redis and can be replayed, with consumer groups allowing multiple worker processes to split up reading a stream (each entry delivered to only one consumer in the group) and acknowledge entries once processed, so a worker that crashes mid-read doesn't silently lose messages the way a pub/sub subscriber would.

• PUBLISH/SUBSCRIBE: real-time, fire-and-forget broadcast to any currently-connected subscribers. • A pub/sub message published with no subscribers, or missed during a disconnect, is gone for good. • Streams (XADD/XREAD/XREADGROUP): a durable, replayable, append-only log with consumer-group support. • Choose streams over pub/sub whenever a message must not be lost even if a consumer is briefly offline.

Distributed locks

The pattern shown in the previous lesson for stampede protection generalizes into a distributed lock: SET key uniqueToken NX PX <ttl> atomically acquires a lock only if no one else currently holds it, with the TTL guaranteeing the lock is released automatically even if the process holding it crashes before releasing it explicitly. The unique token matters: releasing a lock must check that the token being deleted still matches the one you set (usually via a small Lua script executed atomically with EVAL, since "check then delete" as two separate commands isn't itself atomic) — otherwise a slow process could delete a lock that a different process has since legitimately acquired after the first one's TTL expired.

This single-instance lock pattern is good enough for most PHP applications, but it has an edge case: if the single Redis instance holding the lock fails over to a replica before the lock replicates, two processes could briefly both believe they hold the lock. The Redlock algorithm addresses this by acquiring the same lock across a majority of several independent Redis instances before considering it held, trading implementation complexity for correctness guarantees that matter when a lock failure would be genuinely costly (e.g. preventing double-processing of a financial transaction) rather than merely inconvenient.

• SET key token NX PX ttl acquires a lock atomically, with the TTL as a safety net against a crashed holder. • Release must verify the token still matches before deleting (atomically, via a Lua script) — a naive DEL can delete someone else's lock. • A single-Redis-instance lock has a small failover edge case; Redlock (majority across several instances) closes that gap for high-stakes locks. • For most application-level locking (e.g. cache stampede protection), a single-instance SET NX PX lock is a reasonable, simple choice.

Code Examples

Both clients expose the same core operation — set a value with a TTL — but with different call shapes: Redis takes options as an array (['EX' => 300] for a 300-second expiry) and works with strings, so structured data needs json_encode/json_decode; Memcached's client natively serializes PHP values and takes the TTL as a plain integer argument.

$redis->set('weather:london', json_encode(['tempC' => 14]), ['EX' => 300]);

publish() sends a message to any currently-connected subscribers of the 'order.shipped' channel; subscribe() blocks the subscriber process, invoking the callback once per message received. This is real-time and fire-and-forget — if the subscriber process wasn't running when publish() was called, it never sees that message, which is the trade-off pub/sub makes for its simplicity.

$subscriber->subscribe(['order.shipped'], function (Redis $redis, string $channel, string $message) {

Common mistake: Relying on pub/sub for something that must never be lost (billing events, audit logs) is a mistake — a subscriber that's restarting for a deploy at the exact moment a message is published will simply never see it. Use a Redis Stream (XADD/XREADGROUP) instead when durability matters.

The unique token identifies who holds the lock. Acquiring uses SET ... NX PX for an atomic 'set only if absent, with an auto-expiring TTL as a safety net'. Releasing runs a small Lua script through EVAL so the 'check the value matches, then delete' sequence happens as a single atomic operation on the Redis server — eliminating the race where a naive GET-then-DEL from PHP could delete a lock that a different process legitimately acquired in between.

if redis.call("GET", KEYS[1]) == ARGV[1] then Common mistake: Releasing a lock with a plain $redis->del($lockKey) — without checking the token first — means that if your process was slow enough for the TTL to expire and another process to legitimately acquire the same lock, your late DEL deletes that other process's active lock, letting a third process acquire it too. Two processes now both believe they hold an exclusive lock at once.

<?php
declare(strict_types=1);

// --- Redis (phpredis extension) ---
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$redis->set('weather:london', json_encode(['tempC' => 14]), ['EX' => 300]);
$cached = $redis->get('weather:london');

// --- Memcached (Memcached extension) ---
$memcached = new Memcached();
$memcached->addServer('127.0.0.1', 11211);

$memcached->set('weather:london', ['tempC' => 14], 300); // TTL as 3rd arg
$cachedFromMemcached = $memcached->get('weather:london');
<?php
declare(strict_types=1);

// Publisher (e.g. run after an order ships)
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->publish('order.shipped', json_encode(['orderId' => 4821]));

// Subscriber (a long-running listener process)
$subscriber = new Redis();
$subscriber->connect('127.0.0.1', 6379);
$subscriber->subscribe(['order.shipped'], function (Redis $redis, string $channel, string $message) {
    $event = json_decode($message, true);
    error_log("Order {$event['orderId']} shipped — sending notification");
});
<?php
declare(strict_types=1);

function withLock(Redis $redis, string $lockKey, int $ttlMs, callable $work): mixed
{
    $token = bin2hex(random_bytes(16)); // unique per acquisition attempt

    $acquired = $redis->set($lockKey, $token, ['NX', 'PX' => $ttlMs]);
    if (!$acquired) {
        throw new RuntimeException("Could not acquire lock: {$lockKey}");
    }

    try {
        return $work();
    } finally {
        // Atomically: delete the key only if its value still matches our
        // token — a plain DEL could remove a lock someone else now holds.
        $releaseScript = <<<'LUA'
            if redis.call("GET", KEYS[1]) == ARGV[1] then
                return redis.call("DEL", KEYS[1])
            end
            return 0
        LUA;
        $redis->eval($releaseScript, [$lockKey, $token], 1);
    }
}

Why this matters in production

Redis earning a place in nearly every modern PHP stack isn't an accident of popularity — a single Redis instance genuinely replaces what would otherwise be three or four separate pieces of infrastructure (a cache, a session store, a simple queue, a pub/sub broker) for a large share of applications, which is a real reduction in operational surface area. The place teams most often get burned is treating Redis locks casually: a 'quick SETNX' without a unique token and atomic release looks correct in testing under low concurrency and then causes a genuinely hard-to-reproduce double-processing bug (a job run twice, a report generated twice) the first time a real production incident causes one process to run unusually slowly.

finally { $redis->del($lockKey); } — unconditionally delete the lock key when work() finishes.

Release only if the stored value still matches the unique token this process set when it acquired the lock, via an atomic Lua script.

If work() runs longer than the lock's TTL, Redis will have already expired and possibly reassigned the key to a different process by the time the unconditional DEL runs — deleting that other process's legitimate lock and defeating the entire point of using a lock for mutual exclusion.

What capability does Redis have that Memcached does not, which lets Redis double as a queue backend? A. Redis is faster at storing plain strings than Memcached B. Redis supports richer data structures like lists (with LPUSH/BRPOP), which Memcached's pure key-value string model has no equivalent for C. Memcached cannot store any data larger than 1 KB D. Redis requires no network connection, unlike Memcached

B. Redis supports richer data structures like lists (with LPUSH/BRPOP), which Memcached's pure key-value string model has no equivalent for — Memcached is a pure string cache with no built-in list, set, or stream types, so it has nothing equivalent to Redis's LPUSH/BRPOP for building a simple FIFO queue directly on top of the data store.

Why is Redis pub/sub unsuitable for messages that must never be lost, such as billing events? A. Pub/sub messages are limited to 100 bytes B. Pub/sub is fire-and-forget: a message published while no subscriber is connected, or during a subscriber's brief disconnect, is simply gone and cannot be replayed C. Pub/sub messages expire after exactly one second D. Redis pub/sub only works with Memcached, not standalone Redis

B. Pub/sub is fire-and-forget: a message published while no subscriber is connected, or during a subscriber's brief disconnect, is simply gone and cannot be replayed — Pub/sub delivers only to subscribers connected at the moment of publish; there's no persistence or replay, which is exactly the gap Redis Streams (XADD/XREADGROUP) close for cases where durability matters.

When releasing a Redis-based distributed lock, why must the release check that a unique token still matches before deleting the key? A. Because Redis requires a token argument on every DEL command B. To avoid deleting a lock that a different process has since legitimately acquired, after this process's own lock TTL already expired C. Because tokens make the lock acquire faster D. Tokens are only needed when using the Redlock algorithm, never for a single-instance lock

B. To avoid deleting a lock that a different process has since legitimately acquired, after this process's own lock TTL already expired — If the original holder's work takes longer than the lock's TTL, the key may already have expired and been re-acquired by another process; an unconditional delete would remove that other process's active lock, so the release must verify (atomically) that it's still deleting its own lock.

Design a Redis-based rate limiter that allows at most 5 requests per user per 10-second window, using only INCR and EXPIRE. Then explain what would go wrong if you used INCR without ever calling EXPIRE.

$key = "ratelimit:{$userId}"; $count = $redis->incr($key); if ($count === 1) { $redis->expire($key, 10); } if ($count > 5) { throw new RuntimeException('Rate limit exceeded'); } Without EXPIRE, the key would live forever once created, so the count would keep climbing across every future window instead of resetting — after the first 5 requests ever made, that user would be rate-limited permanently.

INCR on a key that doesn't exist initializes it to 0 then increments to 1, returning the new value. Set the expiry only the first time the key is created (when the returned count equals 1), so the window resets exactly 10 seconds after the first request in that window.