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
AspectMemcachedRedis
Data typesKey-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
ThreadingMulti-threadSingle-thread (Redis 6+ multi-IO)
Memory overheadVery lowSlightly higher
Pub/Sub✗ None✓ Available
Atomic operationsCAS, appendINCR, DECR, GETSET, and many more
Best forPure caching, simple key-valueCaching + 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 --- S1

Keys 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 #

AspectSpymemcachedXMemcached
StatusMaintenance modeActively developed
ThreadingAsync, one I/O threadNIO, more scalable
APIFuture for asyncSync and async
ProtocolText and BinaryText and Binary
PopularityVery widespreadMore popular in Asia
Spring integration✓ Spring Cache✓ Spring Cache
DocumentationGoodGood

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_HASH in Spymemcached for even distribution and minimal redistribution when nodes change. The client handles routing; you don’t need to do anything.
  • CAS for safe concurrencygets() + 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 efficiencygetBulk(keys) sends one request for many keys, far more efficient than looping get() 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.

← Previous: Redis   Next: Quarkus →

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