Setting up Memcached for web application caching
Memcached is a distributed in-memory cache with a minimal model: key, value, TTL. No transactions, persistence, or pub/sub. This simplicity makes it faster than Redis in read-heavy caching scenarios — cache hit latency is 0.1–0.5 ms versus 1–3 ms for Redis with AOF persistence.
Used where you need to cache large volumes of homogeneous data: SQL query results, serialized objects, HTML fragments, API responses.
Installation and basic configuration
# Ubuntu/Debian
apt install memcached libmemcached-dev
# Edit /etc/memcached.conf
-d # daemon mode
-m 2048 # 2GB RAM
-p 11211 # port
-u memcache # user
-l 127.0.0.1 # localhost only (don't expose!)
-c 2048 # max connections
-t 8 # threads (= CPU cores)
-I 10m # max item size (default 1MB, increase to 10MB)
-o modern # modern slab allocator options
Restart and verify:
systemctl restart memcached
echo "stats" | nc 127.0.0.1 11211 | grep -E "curr_items|bytes|hit_rate|evictions"
PHP integration via php-memcached
pecl install memcached
echo "extension=memcached.so" > /etc/php/8.2/mods-available/memcached.ini
phpenmod memcached
Basic usage:
$mc = new Memcached();
$mc->addServer('127.0.0.1', 11211);
// Client settings
$mc->setOptions([
Memcached::OPT_CONNECT_TIMEOUT => 50, // ms
Memcached::OPT_RETRY_TIMEOUT => 300,
Memcached::OPT_SEND_TIMEOUT => 100,
Memcached::OPT_RECV_TIMEOUT => 100,
Memcached::OPT_POLL_TIMEOUT => 100,
Memcached::OPT_COMPRESSION => true,
Memcached::OPT_SERIALIZER => Memcached::SERIALIZER_IGBINARY,
Memcached::OPT_TCP_NODELAY => true,
Memcached::OPT_NO_BLOCK => true, // async I/O
]);
Caching SQL queries
The cache-aside pattern is the most common:
class ProductRepository
{
private Memcached $cache;
private PDO $db;
private int $defaultTtl = 300; // 5 minutes
public function findById(int $id): ?array
{
$key = "product:v2:{$id}";
$product = $this->cache->get($key);
if ($this->cache->getResultCode() === Memcached::RES_SUCCESS) {
return $product;
}
$stmt = $this->db->prepare('SELECT * FROM products WHERE id = ? AND active = 1');
$stmt->execute([$id]);
$product = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
if ($product !== null) {
$this->cache->set($key, $product, $this->defaultTtl);
}
return $product;
}
public function findByCategoryWithPagination(int $categoryId, int $page, int $perPage): array
{
$offset = ($page - 1) * $perPage;
$key = "products:cat:{$categoryId}:p{$page}:pp{$perPage}";
$result = $this->cache->get($key);
if ($this->cache->getResultCode() === Memcached::RES_SUCCESS) {
return $result;
}
$stmt = $this->db->prepare('
SELECT p.*, c.name as category_name
FROM products p
JOIN categories c ON c.id = p.category_id
WHERE p.category_id = ? AND p.active = 1
ORDER BY p.created_at DESC
LIMIT ? OFFSET ?
');
$stmt->execute([$categoryId, $perPage, $offset]);
$result = [
'items' => $stmt->fetchAll(PDO::FETCH_ASSOC),
'page' => $page,
];
$this->cache->set($key, $result, 120);
return $result;
}
public function invalidateProduct(int $id): void
{
$this->cache->delete("product:v2:{$id}");
// Invalidate pagination by category — via tag pattern
}
}
Tag-based invalidation (emulation)
Memcached doesn't support tags natively. Standard approach — versioned namespaces:
class CacheTagManager
{
private Memcached $mc;
public function getTagVersion(string $tag): int
{
$version = $this->mc->get("tag_version:{$tag}");
if ($this->mc->getResultCode() !== Memcached::RES_SUCCESS) {
$version = time();
$this->mc->set("tag_version:{$tag}", $version, 0); // no expiry
}
return (int)$version;
}
public function buildKey(string $base, array $tags): string
{
$versions = array_map(
fn($tag) => $this->getTagVersion($tag),
$tags
);
return $base . ':' . implode(':', $versions);
}
public function invalidateTag(string $tag): bool
{
return $this->mc->increment("tag_version:{$tag}", 1, time()) !== false;
}
}
// Usage
$tagManager = new CacheTagManager($mc);
// Key depends on category tag version
$key = $tagManager->buildKey("products:cat:5:p1", ['category:5', 'products']);
$data = $mc->get($key);
// When category changes — all dependent caches become "non-existent"
$tagManager->invalidateTag('category:5');
Distributed cache — consistent hashing
With multiple servers, it's critical to use consistent hashing to minimize key invalidation when adding/removing nodes:
$mc = new Memcached('persistent_pool'); // persistent connection pool
$mc->addServers([
['memcached-1.internal', 11211, 40], // weight 40
['memcached-2.internal', 11211, 40],
['memcached-3.internal', 11211, 20], // lower weight — less traffic
]);
$mc->setOption(Memcached::OPT_DISTRIBUTION, Memcached::DISTRIBUTION_CONSISTENT);
$mc->setOption(Memcached::OPT_LIBKETAMA_COMPATIBLE, true);
$mc->setOption(Memcached::OPT_REMOVE_FAILED_SERVERS, true);
$mc->setOption(Memcached::OPT_SERVER_FAILURE_LIMIT, 3);
$mc->setOption(Memcached::OPT_RETRY_TIMEOUT, 2);
Monitoring and diagnostics
# Server statistics
echo "stats" | nc 127.0.0.1 11211
# Important metrics:
# get_hits / (get_hits + get_misses) = hit rate (target > 90%)
# evictions > 0 = insufficient memory, need to increase -m
# curr_connections — current connections
# View all keys (debug only, never in production)
echo "stats cachedump 1 100" | nc 127.0.0.1 11211
Prometheus + memcached_exporter:
docker run -d --name memcached-exporter \
-p 9150:9150 \
prom/memcached-exporter:latest \
--memcached.address=127.0.0.1:11211
Grafana dashboard id: 7603 — ready-made dashboard for Memcached.
Typical timeline
Day 1 — installation, memory size configuration and thread count, firewall setup (port 11211 must be closed externally).
Day 2 — application integration, cache-aside implementation for heavy SQL queries, invalidation on writes.
Day 3 — check hit rate, monitoring setup, TTL tuning by data types. If hit rate is below 80% — analyze misses and fix caching strategy.







