Memcached #
Memcached is a distributed in-memory caching system designed with an extreme simplicity principle — one data type (string/bytes), one main operation (get/set/delete), no persistence, no built-in replication. Precisely because of its simplicity, Memcached is very fast and easy to scale horizontally: add a new server, distribute keys using consistent hashing, done. Memcached is the right choice when you need lightweight pure caching without the overhead of extra features. Understanding its limitations — key/value size limits, no persistence, no complex data structures — is key to choosing when Memcached is the right tool.
Installation #
pip install pymemcache
To run Memcached locally:
# Docker
docker run -d --name memcached -p 11211:11211 memcached:latest
# With a 256MB memory limit
docker run -d --name memcached -p 11211:11211 memcached:latest memcached -m 256 -t 4
Creating a Connection #
pymemcache provides several client types: Client for a single server, PooledClient for multi-threading, and HashClient for distribution across several servers.
import json
import os
from pymemcache.client.base import Client, PooledClient
from pymemcache.client.hash import HashClient
MEMCACHED_HOST = os.getenv("MEMCACHED_HOST", "localhost")
MEMCACHED_PORT = int(os.getenv("MEMCACHED_PORT", "11211"))
# ANTI-PATTERN: a plain Client in a multi-threaded environment
client = Client(("localhost", 11211)) # ✗ -- not thread-safe without a pool
# CORRECT: PooledClient for web applications (thread-safe with a connection pool)
client = PooledClient(
(MEMCACHED_HOST, MEMCACHED_PORT),
max_pool_size=10, # number of connections in the pool
pool_idle_timeout=60, # close idle connections after 60 seconds
connect_timeout=5, # timeout when connecting
timeout=3, # timeout during operations
serde=None # we'll use a custom serde below
)
# Test the connection
try:
client.set("ping", "pong", expire=5)
assert client.get("ping") == b"pong"
print("Memcached connection successful.")
except Exception as e:
print(f"Connection failed: {e}")
JSON Serializer #
By default, pymemcache stores and returns bytes. To work with dicts and lists, implement a custom serializer.
import json
from pymemcache.client.base import PooledClient
class JSONSerde:
"""JSON serializer/deserializer for pymemcache."""
def serialize(self, key, value):
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False).encode("utf-8"), 2
if isinstance(value, str):
return value.encode("utf-8"), 1
return value, 0
def deserialize(self, key, value, flags):
if flags == 2:
return json.loads(value.decode("utf-8"))
if flags == 1:
return value.decode("utf-8")
return value
client = PooledClient(
(MEMCACHED_HOST, MEMCACHED_PORT),
max_pool_size=10,
connect_timeout=5,
timeout=3,
serde=JSONSerde() # enable the JSON serializer
)
# Now you can store and retrieve dicts transparently
client.set("pengguna:42", {"id": 42, "nama": "Budi", "email": "[email protected]"})
pengguna = client.get("pengguna:42")
print(type(pengguna)) # <class 'dict'>
print(pengguna["nama"]) # Budi
Basic Operations #
# Set with a TTL (expire in seconds)
client.set("kunci", "nilai", expire=3600) # expire in 1 hour
client.set("token:abc", "user-42", expire=300) # expire in 5 minutes
client.set("permanent", "data") # no TTL (until evicted)
# Get -- returns None if missing or expired
nilai = client.get("kunci")
print(nilai) # "nilai" (if using JSONSerde)
# Delete
client.delete("kunci")
# add() -- set only if the key doesn't exist (atomic, like SET NX in Redis)
berhasil = client.add("lock:proses", "1", expire=30)
print("Set successful:", berhasil) # True if new, False if it already exists
# replace() -- set only if the key already exists
berhasil = client.replace("kunci", "nilai_baru", expire=3600)
Cache-Aside Pattern #
from typing import Callable, Any
def get_atau_set(client, cache_key: str, fetch_fn: Callable, ttl_detik: int = 3600) -> Any:
"""
Cache-Aside: check cache → hit? return; miss? fetch, store, return.
"""
# Sanitize the key -- Memcached disallows spaces, max 250 characters
cache_key = cache_key.replace(" ", "_")[:250]
nilai = client.get(cache_key)
if nilai is not None:
print(f"Cache HIT: {cache_key}")
return nilai
print(f"Cache MISS: {cache_key}")
data = fetch_fn()
if data is not None:
client.set(cache_key, data, expire=ttl_detik)
return data
def ambil_produk_dari_db(produk_id: int) -> dict:
return {"id": produk_id, "nama": "Laptop Gaming", "harga": 18500000}
produk = get_atau_set(
client,
cache_key=f"produk:{101}",
fetch_fn=lambda: ambil_produk_dari_db(101),
ttl_detik=1800
)
Batch Operations #
# Set many keys at once
client.set_many({
"produk:101": {"id": 101, "nama": "Laptop Gaming", "harga": 18500000},
"produk:102": {"id": 102, "nama": "Samsung Galaxy S24", "harga": 15000000},
"produk:103": {"id": 103, "nama": "Nike Air Max", "harga": 1800000},
}, expire=1800)
# Get many keys at once -- more efficient than a get() loop
keys = ["produk:101", "produk:102", "produk:103", "produk:999"]
hasil = client.get_many(keys)
# hasil is a dict {key: value} -- only the found keys
for key in keys:
if key in hasil:
print(f"HIT: {key} → {hasil[key]['nama']}")
else:
print(f"MISS: {key}")
# Delete many keys at once
client.delete_many(["produk:101", "produk:102", "produk:103"])
Atomic Increment and Decrement #
# Page view counter
client.set("views:artikel:5", "0", expire=86400) # the value must be string/bytes for incr
client.incr("views:artikel:5", 1) # +1 (atomic)
client.incr("views:artikel:5", 10) # +10
nilai = client.get("views:artikel:5")
print(f"Views: {nilai}") # b'11'
# Simple rate limiting
def cek_rate_limit(client, user_id: str, maks: int = 100, window: int = 60) -> bool:
key = f"rl:{user_id}"
try:
jumlah = client.incr(key, 1)
if jumlah is None:
# Key doesn't exist -- set with a TTL
client.set(key, "1", expire=window)
return True
return int(jumlah) <= maks
except Exception:
return True # fail open if Memcached errors
for i in range(5):
diizinkan = cek_rate_limit(client, "user-42", maks=3, window=60)
print(f"Request {i+1}: {'✓ allowed' if diizinkan else '✗ rejected'}")
HashClient — Multi-Server Distribution #
HashClient distributes keys across several Memcached servers using consistent hashing — the standard way to scale Memcached horizontally.
from pymemcache.client.hash import HashClient
# Server list from an environment variable
servers_env = os.getenv("MEMCACHED_SERVERS", "localhost:11211")
servers_raw = [s.split(":") for s in servers_env.split(",")]
servers = [(host, int(port)) for host, port in servers_raw]
cluster = HashClient(
servers,
serde=JSONSerde(),
connect_timeout=5,
timeout=3,
use_pooling=True, # connection pooling per server
max_pool_size=5,
retry_attempts=2, # try another server if it doesn't respond
retry_timeout=0.1,
dead_timeout=30 # mark a server dead for 30 seconds
)
# Usage is identical to PooledClient
cluster.set("produk:200", {"id": 200, "nama": "MacBook Pro"}, expire=3600)
print(cluster.get("produk:200"))
When adding or removing servers from a Memcached cluster, some keys get rehashed to different servers, causing mass cache misses. Add new servers gradually and do cache warming before removing old servers to mitigate the impact.
Namespace Versioning for Bulk Invalidation #
Memcached doesn’t support pattern matching like Redis’s KEYS produk:*. To invalidate a group of keys at once, use a version counter per namespace.
def get_namespace_version(client, namespace: str) -> int:
versi = client.get(f"ns:{namespace}")
if versi is None:
client.set(f"ns:{namespace}", "1", expire=86400)
return 1
return int(versi)
def build_key(client, namespace: str, key: str) -> str:
versi = get_namespace_version(client, namespace)
return f"{namespace}:v{versi}:{key}"
def invalidasi_namespace(client, namespace: str) -> None:
"""Invalidate an entire group -- increment the version, all old keys become invalid."""
client.incr(f"ns:{namespace}", 1)
print(f"Namespace '{namespace}' invalidated.")
# Store with a namespace
key = build_key(client, "produk", "101")
client.set(key, {"id": 101, "nama": "Laptop"}, expire=3600)
# Retrieve with a namespace
nilai = client.get(build_key(client, "produk", "101"))
print(nilai)
# Invalidate all product caches at once (without deleting one by one)
invalidasi_namespace(client, "produk")
# All "produk:v1:..." keys will never be found again
# New keys automatically use "produk:v2:..."
Error Handling and Fallback #
from pymemcache.exceptions import MemcacheError
def set_cache_aman(client, key: str, nilai: Any, expire: int = 3600) -> bool:
try:
# Validate the size -- Memcached max 1MB per value
serialized = json.dumps(nilai, ensure_ascii=False)
if len(serialized.encode("utf-8")) > 900_000:
print(f"Value too large to cache ({len(serialized)} bytes)")
return False
client.set(key, nilai, expire=expire)
return True
except MemcacheError as e:
print(f"Memcached error: {e}")
return False
def ambil_data_dengan_fallback(client, key: str, fetch_fn: Callable) -> Any:
"""Get from the cache, fall back to the source if the cache is down."""
try:
cached = client.get(key)
if cached is not None:
return cached
except Exception:
pass # Memcached unavailable, go straight to the source
data = fetch_fn()
try:
if data is not None:
client.set(key, data, expire=3600)
except Exception:
pass # Can't store the cache, but the data is still returned
return data
Memcached vs Redis #
Choose Memcached when:
✓ You need simple, lightweight pure caching
✓ You want horizontal scaling with many servers (multi-threaded, efficient per core)
✓ The cached data is only simple strings/bytes
✓ A smaller memory footprint is a priority
Choose Redis when:
✓ You need data structures: Hash, List, Set, Sorted Set, Stream
✓ You need data persistence (RDB/AOF snapshots)
✓ You need Pub/Sub, Distributed Locks, or Lua scripting
✓ You need complex atomic operations (pipelines + WATCH)
✓ You want one tool for caching + messaging + queues
Memcached limitations to remember:
✗ Keys max 250 characters, no spaces allowed
✗ Values max 1MB per key
✗ No persistence -- data is lost on restart
✗ No built-in replication
✗ No pattern matching for bulk invalidation
Summary #
PooledClientnotClient— always use it in multi-threaded environments; a plainClientisn’t thread-safe without pooling.- Custom
JSONSerde— implement one so you can store and retrieve dicts/lists without manual serialize/deserialize every time.add()for atomic set-if-not-exists — use it for simple distributed locks or preventing overwrites; safer than a non-atomicset()+get().get_many()andset_many()— always use batch operations for multiple keys; one network round-trip for many keys.HashClientfor multi-server — distribute keys across several servers with consistent hashing; configureretry_attemptsanddead_timeoutfor failure tolerance.- Namespace versioning — since there’s no pattern matching, use a version counter per namespace to efficiently invalidate a group of keys at once.
- Validate size before storing — Memcached rejects values over 1MB; check the size before
set()to avoid runtime errors.- Sanitize keys — keys can’t contain spaces or control characters; replace spaces and cap at 250 characters.
- Fail open on errors — catch all exceptions and continue to the database; the cache is an optimization, not a single point of failure.
- Choose Redis for more than caching — if you need data structures, persistence, or Pub/Sub, Redis is the right choice; Memcached excels only at simple pure key-value caching.