Memcached #
Memcached is a very simple, very fast distributed in-memory caching system. It was born before Redis and designed with one philosophy: do one thing extremely well — store key-value pairs in memory with minimal latency. Memcached has no complex data types like Redis, no persistence, no pub/sub, no Lua scripting. But its very simplicity makes it very lightweight and easy to scale horizontally — add a new Memcached server, and clients automatically distribute data using consistent hashing. In Kotlin, the two most common libraries are Spymemcached (an older library still widely used) and XMemcached (more modern, supporting async operations and NIO).
Memcached vs Redis — When to Choose #
flowchart TD
A{Caching Needs?} --> B{"Need complex\ndata types?"}
B -- Yes --> C["Redis\nHash, List, Set, Sorted Set"]
B -- No --> D{"Need persistence\nor replication?"}
D -- Yes --> C
D -- No --> E{"Need easy horizontal\nscaling?"}
E -- Yes --> F["Memcached\nSimple, scalable, lightweight"]
E -- No --> G{"Team familiar\nwith Redis?"}
G -- Yes --> C
G -- No --> F| Aspect | Memcached | Redis |
|---|---|---|
| Data types | Key-value (String only) | String, Hash, List, Set, ZSet, etc. |
| Persistence | ✗ None | ✓ RDB and AOF |
| Replication | ✗ Not native | ✓ Master-Replica |
| Clustering | ✓ Easy horizontal scaling | ✓ Redis Cluster |
| Threading | Multi-thread | Single-thread (Redis 6+ multi-IO) |
| Memory overhead | Very low | Slightly higher |
| Pub/Sub | ✗ None | ✓ Available |
| Atomic operations | CAS, append | INCR, DECR, GETSET, and many more |
| Best for | Pure caching, simple key-value | Caching + additional features |
CHOOSE Memcached if:
✓ You only need pure caching, no other features
✓ The team is familiar with Memcached and the setup already exists
✓ You need native multi-threading (Memcached is more CPU-efficient)
✓ Very easy horizontal scaling — add a node = more capacity
✓ Simple cache data (strings, serialized objects)
CHOOSE Redis if:
✓ You need rich data types (Hash, List, Sorted Set)
✓ You need persistence (data survives restarts)
✓ You need replication and high availability
✓ You need additional features: pub/sub, Lua, distributed locks
Setup and Dependencies #
// build.gradle.kts
dependencies {
// Spymemcached — a mature, widely used library
implementation("net.spy:spymemcached:2.12.3")
// XMemcached — a modern NIO alternative
// implementation("com.googlecode.xmemcached:xmemcached:2.4.7")
// kotlinx.serialization for object serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}
Connecting with Spymemcached #
import net.spy.memcached.MemcachedClient
import net.spy.memcached.AddrUtil
import net.spy.memcached.ConnectionFactoryBuilder
import net.spy.memcached.DefaultHashAlgorithm
import net.spy.memcached.FailureMode
import java.net.InetSocketAddress
object MemcachedConnection {
// Connection to one server
val client: MemcachedClient by lazy {
val host = System.getenv("MEMCACHED_HOST") ?: "localhost"
val port = System.getenv("MEMCACHED_PORT")?.toInt() ?: 11211
MemcachedClient(InetSocketAddress(host, port))
}
// Connection to many servers (distributed)
fun createDistributedClient(servers: List<String>): MemcachedClient {
// Server format: "host1:11211 host2:11211 host3:11211"
val serverAddresses = servers.joinToString(" ")
return MemcachedClient(AddrUtil.getAddresses(serverAddresses))
}
// Connection with full configuration
fun createCustomClient(): MemcachedClient {
val factory = ConnectionFactoryBuilder()
.setProtocol(ConnectionFactoryBuilder.Protocol.BINARY) // binary protocol is more efficient
.setHashAlg(DefaultHashAlgorithm.KETAMA_HASH) // consistent hashing
.setFailureMode(FailureMode.Redistribute) // redistribute if a server dies
.setOpTimeout(5000) // 5 second operation timeout
.setTimeoutExceptionThreshold(1998)
.build()
return MemcachedClient(factory, AddrUtil.getAddresses(
System.getenv("MEMCACHED_SERVERS") ?: "localhost:11211"
))
}
fun close() {
client.shutdown()
}
}
fun main() {
val client = MemcachedConnection.client
client.set("test", 60, "Hello Memcached!")
println(client.get("test")) // Hello Memcached!
client.shutdown()
}
Basic Operations #
Memcached only supports simple operations — that’s its strength:
fun basicOperations() {
val client = MemcachedConnection.client
val TTL = 300 // 5 minutes in seconds
// SET — store a value (overwrites if it exists)
client.set("nama", TTL, "Budi Santoso")
// GET — retrieve a value
val name = client.get("nama") as String?
println(name) // Budi Santoso
// DELETE — remove a key
client.delete("nama")
println(client.get("nama")) // null
// ADD — store only if the key DOESN'T EXIST
val success1 = client.add("kunci.unik", TTL, "nilai pertama").get() // true
val success2 = client.add("kunci.unik", TTL, "nilai kedua").get() // false (already exists)
println("success1: $success1, success2: $success2")
// REPLACE — update only if the key ALREADY EXISTS
client.set("kunci.ada", TTL, "lama")
val updated = client.replace("kunci.ada", TTL, "baru").get() // true
val notFound = client.replace("kunci.tidak.ada", TTL, "nilai").get() // false
println("updated: $updated, notFound: $notFound")
// APPEND and PREPEND — add text (without a new TTL)
client.set("log", TTL, "awal")
client.append(0, "log", " | tengah") // 0 = CAS not used
client.prepend(0, "log", "prefix | ")
println(client.get("log") as String) // prefix | awal | tengah
// INCR and DECR — for numeric values (the value must be a numeric string)
client.set("pengunjung", TTL, "0")
client.incr("pengunjung", 1) // 1
client.incr("pengunjung", 5) // 6
client.decr("pengunjung", 2) // 4
println(client.get("pengunjung")) // 4
// FLUSH ALL — delete all data (be careful in production!)
// client.flush()
}
Object Serialization #
Memcached only stores byte arrays, but Spymemcached can automatically store Java objects that are Serializable. For Kotlin data classes, use manual serialization:
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.Serializable as JavaSerializable
// Way 1: implement java.io.Serializable
data class User(
val id: Long,
val name: String,
val email: String,
val active: Boolean = true
) : JavaSerializable
// Way 2: manual serialization with kotlinx.serialization (more flexible)
@kotlinx.serialization.Serializable
data class Product(
val id: Int,
val name: String,
val price: Double,
val stock: Int
)
class ProductCache {
private val client = MemcachedConnection.client
private val json = Json { ignoreUnknownKeys = true }
private val TTL = 300
fun save(product: Product) {
val key = "produk:${product.id}"
val value = json.encodeToString(Product.serializer(), product)
client.set(key, TTL, value)
}
fun get(id: Int): Product? {
val key = "produk:$id"
val value = client.get(key) as String? ?: return null
return runCatching {
json.decodeFromString(Product.serializer(), value)
}.getOrNull()
}
fun delete(id: Int) {
client.delete("produk:$id")
}
// Cache many products — one by one (Memcached has no MSET)
fun saveMany(products: List<Product>) {
products.forEach { p ->
save(p)
}
println("${products.size} products cached")
}
}
Multi-Get — Fetching Many Keys at Once #
Multi-get is one of Memcached’s most efficient operations — one network round-trip for many keys:
fun multiGetExample() {
val client = MemcachedConnection.client
// Store several products
val products = mapOf(
"produk:1" to """{"id":1,"nama":"Laptop","harga":15000000.0,"stok":10}""",
"produk:2" to """{"id":2,"nama":"Mouse","harga":250000.0,"stok":50}""",
"produk:3" to """{"id":3,"nama":"Keyboard","harga":500000.0,"stok":25}"""
)
products.forEach { (key, value) -> client.set(key, 300, value) }
// Multi-get: fetch all in one request
val keys = listOf("produk:1", "produk:2", "produk:3", "produk:99")
val result = client.getBulk(keys)
println("Found ${result.size} of ${keys.size} keys:")
result.forEach { (key, value) ->
println(" $key = ${(value as String).take(40)}...")
}
// produk:99 is not in the result because it's not in the cache
}
CAS — Check-And-Set (Optimistic Locking) #
CAS prevents race conditions by ensuring the value hasn’t changed between GET and SET:
import net.spy.memcached.CASResponse
import net.spy.memcached.CASValue
fun casExample() {
val client = MemcachedConnection.client
val key = "stok:produk:1"
client.set(key, 300, "100")
// GETS — fetch the value along with its CAS token
val casValue: CASValue<Any> = client.gets(key)
val currentStock = (casValue.value as String).toInt()
val casToken = casValue.cas
println("Stock: $currentStock, CAS: $casToken")
// Simulate: calculate the new stock
val newStock = currentStock - 5
// CAS — update only if the value hasn't changed since GETS
val response = client.cas(key, casToken, 300, newStock.toString())
when (response) {
CASResponse.OK -> println("Stock updated to $newStock")
CASResponse.EXISTS -> println("Failed: the value changed since it was read (race condition)")
CASResponse.NOT_FOUND -> println("Key not found")
else -> println("Unknown response: $response")
}
}
// CAS with retries to handle conflicts
fun safelyDecreaseStock(key: String, quantity: Int, maxAttempts: Int = 3): Boolean {
val client = MemcachedConnection.client
repeat(maxAttempts) { attempt ->
val casValue = client.gets(key) ?: return false
val currentStock = (casValue.value as String).toIntOrNull() ?: return false
if (currentStock < quantity) {
println("Insufficient stock: $currentStock < $quantity")
return false
}
val newStock = currentStock - quantity
val response = client.cas(key, casValue.cas, 300, newStock.toString())
if (response == CASResponse.OK) {
println("Attempt ${attempt + 1}: stock decreased to $newStock")
return true
}
println("Attempt ${attempt + 1}: CAS conflict, retrying...")
Thread.sleep(10) // wait briefly before retrying
}
println("Failed after $maxAttempts attempts")
return false
}
Caching Patterns with Memcached #
The Cache-Aside Pattern #
class DataServiceWithCache(private val database: Database) {
private val client = MemcachedConnection.client
private val json = Json { ignoreUnknownKeys = true }
private val TTL = 600 // 10 minutes
fun getUser(id: Long): User? {
val key = "pengguna:$id"
// 1. Check the cache
val cached = client.get(key) as String?
if (cached != null) {
return json.decodeFromString(User.serializer(), cached)
}
// 2. Cache miss — get from the DB
val user = database.findUserById(id) ?: return null
// 3. Store in the cache
client.set(key, TTL, json.encodeToString(User.serializer(), user))
return user
}
fun invalidateUser(id: Long) {
client.delete("pengguna:$id")
}
// The namespace pattern — invalidate a group of keys
fun buildKeyWithNamespace(namespace: String, id: Any): String {
// Get the namespace version from Memcached
val versionKey = "namespace:$namespace"
val version = (client.get(versionKey) as String?)?.toInt() ?: run {
client.add(versionKey, 0, "1") // TTL 0 = never expires
1
}
return "$namespace:v$version:$id"
}
fun invalidateNamespace(namespace: String) {
// Incrementing the version = all old keys are automatically invalid
client.incr("namespace:$namespace", 1)
println("Namespace '$namespace' invalidated — all old keys are invalid")
}
}
Connecting with XMemcached #
XMemcached is a more modern alternative with NIO and async operation support:
// build.gradle.kts
// implementation("com.googlecode.xmemcached:xmemcached:2.4.7")
import net.rubyeye.xmemcached.MemcachedClient
import net.rubyeye.xmemcached.XMemcachedClientBuilder
import net.rubyeye.xmemcached.utils.AddrUtil
import net.rubyeye.xmemcached.command.BinaryCommandFactory
fun createXMemcachedClient(): MemcachedClient {
val builder = XMemcachedClientBuilder(
AddrUtil.getAddresses("localhost:11211")
).apply {
commandFactory = BinaryCommandFactory() // binary protocol
connectionPoolSize = 2 // connection pool per server
setOpTimeout(5000) // 5 second timeout
}
return builder.build()
}
fun xmemcachedExample() {
val client = createXMemcachedClient()
// Basic operations are the same as Spymemcached
client.set("key", 300, "nilai")
val value = client.get<String>("key")
println(value) // nilai
// Multi-get is more type-safe
client.set("a", 300, "nilai A")
client.set("b", 300, "nilai B")
val many = client.get<String>(listOf("a", "b", "c"))
println(many) // {a=nilai A, b=nilai B}
// Counters
client.set("counter", 300, 0.toString())
client.incr("counter", 1)
client.incr("counter", 1)
println(client.get<String>("counter")) // 2
client.shutdown()
}
Distributed Caching — Consistent Hashing #
Memcached distributes data across many servers using consistent hashing. When servers are added or removed, only a small fraction of keys need to move:
// All operations are the same — the client decides which server stores a key
fun distributedExample() {
// Create a client with 3 servers
val client = MemcachedConnection.createDistributedClient(
listOf("server1:11211", "server2:11211", "server3:11211")
)
// The client automatically distributes to the right server based on the key hash
client.set("pengguna:1", 300, "Budi") // → server1 (for example)
client.set("pengguna:2", 300, "Sari") // → server3 (for example)
client.set("pengguna:3", 300, "Ahmad") // → server2 (for example)
// GETs are also automatically routed to the right server
println(client.get("pengguna:1")) // Budi
println(client.get("pengguna:2")) // Sari
client.shutdown()
}
Consistent hashing visualization:
flowchart TD
S1["Server 1"] --- S2["Server 2"]
S2 --- S4["●"]
S4 --- S3["Server 3"]
S3 --- S1Keys are hashed to a position on the ring, then placed on the nearest server clockwise.
When a server is added/removed → only ~K/N keys need to move (K = number of keys, N = number of servers)
Spymemcached vs XMemcached #
| Aspect | Spymemcached | XMemcached |
|---|---|---|
| Status | Maintenance mode | Actively developed |
| Threading | Async, one I/O thread | NIO, more scalable |
| API | Future for async | Sync and async |
| Protocol | Text and Binary | Text and Binary |
| Popularity | Very widespread | More popular in Asia |
| Spring integration | ✓ Spring Cache | ✓ Spring Cache |
| Documentation | Good | Good |
Production Tips #
// 1. Don't store large Java objects — Memcached max 1MB per item by default
// Compress if needed:
import java.util.zip.GZIPOutputStream
import java.util.zip.GZIPInputStream
import java.io.ByteArrayOutputStream
import java.io.ByteArrayInputStream
fun compress(data: String): ByteArray {
val bos = ByteArrayOutputStream()
GZIPOutputStream(bos).use { it.write(data.toByteArray()) }
return bos.toByteArray()
}
fun decompress(data: ByteArray): String {
return GZIPInputStream(ByteArrayInputStream(data)).bufferedReader().readText()
}
// 2. Be consistent in key naming:
// Format: {entity}:{id}:{optional_field}
// Examples: pengguna:1001, produk:5:harga, sesi:tok_abc123
// 3. Avoid overly long keys — Memcached max 250 characters per key
// If the key is long, hash it with MD5/SHA
// 4. Set appropriate TTLs:
// - Infrequently changing data: long TTLs (hours - days)
// - Frequently changing data: short TTLs (seconds - minutes)
// - Never use TTL = 0 for cached data
// 5. Monitor the hit rate — target above 80%
// The stats command in Memcached:
// telnet localhost 11211
// > stats
// cmd_get, get_hits, get_misses → calculate hit rate = get_hits / cmd_get
Summary #
- Memcached for simple, pure caching — if your need is only storing and retrieving data by key, Memcached is a lighter choice than Redis and scales horizontally very easily.
- Consistent hashing for distribution — use
KETAMA_HASHin Spymemcached for even distribution and minimal redistribution when nodes change. The client handles routing; you don’t need to do anything.- CAS for safe concurrency —
gets()+cas()is the atomic way to update values that may be accessed concurrently. On conflict (the value changed since it was read), retry with backoff.- Multi-get for efficiency —
getBulk(keys)sends one request for many keys, far more efficient than loopingget()one by one.- TTL is always needed — unlike Redis which can have keys without expiry, Memcached uses LRU eviction. Set sensible TTLs for all keys so old data doesn’t consume memory.
- Namespace versioning for mass invalidation — instead of deleting hundreds of keys one by one, use the namespace pattern with version increments. All old-version keys become automatically “invalid” without being actually deleted.
- Spymemcached for compatibility, XMemcached for performance — Spymemcached still dominates in many legacy projects. XMemcached is more actively developed and more efficient for high throughput.
- Compress large data — Memcached limits item size to 1MB by default. For larger payloads, compress with GZIP before storing and decompress when retrieving.