Memcached #

Before Redis dominated the in-memory cache world, Memcached was the top choice powering the scale of Facebook, Twitter, and Wikipedia. Its philosophy is very simple and hasn’t changed since its release in 2003: one thing, done very well — storing key-value pairs in memory as fast as possible. No complex data structures, no persistence, no pub/sub, no scripting. Just caching. This simplicity isn’t a shortcoming — it’s a design choice that produces a very lightweight implementation, memory efficient and easy to scale horizontally. In the Java context, there are two mature and widely used client libraries: Spymemcached from Couchbase and XMemcached, which offers a fuller feature set. Understanding Memcached isn’t just about learning another tool — it teaches you the caching foundations that underpin all modern caching systems.

Memcached Architecture #

Memcached has several architectural characteristics that are important to understand before using it.

The Slab Allocator #

One of Memcached’s most fundamental differences from an ordinary cache is how it manages memory. Instead of dynamically allocating memory per item (which causes fragmentation), Memcached uses a slab allocator — memory is divided into slab classes based on size, and each item is stored in the matching slab class.

Total Memory: 512 MB

Slab Class 1  (size 96 bytes)    ████████████████  → stores small values
Slab Class 2  (size 120 bytes)   ████████████      → stores medium values
Slab Class 3  (size 152 bytes)   ████████          → stores larger values
...
Slab Class N  (size 1 MB)        ██                → stores large values

The consequence: if you store a 100-byte value in a slab class sized at 120 bytes, 20 bytes are wasted (internal fragmentation). This is a deliberate tradeoff for constant-speed allocation — Memcached never needs to malloc when storing a new item.

Single-Threaded per Core vs Multi-Threaded #

Memcached uses a multi-threaded model with worker threads. Each TCP connection is handled by one worker thread, and operations within one connection are serial. This differs from Redis, which is single-threaded (until Redis 6.0) but uses an event loop.

Horizontal Scaling — Shared-Nothing #

Unlike Redis, which supports clustering with replication, Memcached has no built-in clustering. Scaling happens on the client side — the client decides which server a key goes to, usually using consistent hashing. This makes a Memcached cluster a “shared-nothing” system that’s very easy to scale: just add a server, and clients automatically redistribute keys.

flowchart LR
    APP["Java Application\nSpymemcached / XMemcached"]

    APP -->|hash(key) % 3 = 0| M1["Memcached\nServer 1\n192.168.1.1:11211"]
    APP -->|hash(key) % 3 = 1| M2["Memcached\nServer 2\n192.168.1.2:11211"]
    APP -->|hash(key) % 3 = 2| M3["Memcached\nServer 3\n192.168.1.3:11211"]
When a Memcached server is added to or removed from a cluster, consistent hashing minimizes the number of keys that need to move — only about 1/N of keys are affected (N = number of servers). Without consistent hashing (plain modulo), almost all keys move, causing a massive cache stampede.

Setting Up Dependencies #

Spymemcached #

Spymemcached is Couchbase’s asynchronous client — lightweight and easy to use:

<!-- Maven -->
<dependency>
    <groupId>net.spy</groupId>
    <artifactId>spymemcached</artifactId>
    <version>2.12.3</version>
</dependency>
// Gradle
implementation 'net.spy:spymemcached:2.12.3'

XMemcached #

XMemcached offers a fuller feature set, including per-server connection pools, the binary protocol, and namespace support:

<!-- Maven -->
<dependency>
    <groupId>com.googlecode.xmemcached</groupId>
    <artifactId>xmemcached</artifactId>
    <version>2.4.8</version>
</dependency>
// Gradle
implementation 'com.googlecode.xmemcached:xmemcached:2.4.8'

Run Memcached locally with Docker:

# Default Memcached — 64 MB memory, port 11211
docker run -d \
  --name memcached \
  -p 11211:11211 \
  memcached:1.6-alpine

# Memcached with more memory
docker run -d \
  --name memcached \
  -p 11211:11211 \
  memcached:1.6-alpine \
  memcached -m 512 -c 1024 -t 4
  # -m 512 → allocate 512 MB of memory
  # -c 1024 → max 1024 simultaneous connections
  # -t 4 → 4 worker threads

# Access via telnet for debugging
telnet localhost 11211

Connecting with Spymemcached #

Spymemcached uses an NIO-based asynchronous model — a single thread handles all I/O for all connections to the cluster.

import net.spy.memcached.AddrUtil;
import net.spy.memcached.MemcachedClient;
import net.spy.memcached.DefaultConnectionFactory;
import net.spy.memcached.ConnectionFactoryBuilder;
import net.spy.memcached.FailureMode;

import java.io.IOException;

public class SpymemcachedFactory {

    // Connect to a single server
    public static MemcachedClient createSingle() throws IOException {
        return new MemcachedClient(
            new java.net.InetSocketAddress("localhost", 11211)
        );
    }

    // Connect to multiple servers (cluster)
    // The client automatically uses consistent hashing for key distribution
    public static MemcachedClient createCluster() throws IOException {
        return new MemcachedClient(
            AddrUtil.getAddresses(
                "192.168.1.1:11211 192.168.1.2:11211 192.168.1.3:11211"
            )
        );
    }

    // Connect with advanced configuration
    public static MemcachedClient createWithConfig() throws IOException {
        ConnectionFactoryBuilder builder = new ConnectionFactoryBuilder();

        // Protocol: BINARY is more efficient than TEXT (default)
        builder.setProtocol(ConnectionFactoryBuilder.Protocol.BINARY);

        // Failure mode — what to do if a server can't be reached
        // FailureMode.Redistribute → send to another server (default, safe for caches)
        // FailureMode.Retry       → keep trying the same server
        // FailureMode.Cancel      → cancel the operation, throw an exception
        builder.setFailureMode(FailureMode.Redistribute);

        // Operation timeout
        builder.setOpTimeout(1000); // 1 second

        // Connections per server
        builder.setMaxReconnectDelay(30);

        return new MemcachedClient(
            builder.build(),
            AddrUtil.getAddresses("localhost:11211")
        );
    }
}

Connecting with XMemcached #

XMemcached provides a more intuitive synchronous API with per-server connection pool support:

import net.rubyeye.xmemcached.MemcachedClient;
import net.rubyeye.xmemcached.XMemcachedClientBuilder;
import net.rubyeye.xmemcached.algorithm.KetamaMemcachedSessionLocator;
import net.rubyeye.xmemcached.command.BinaryCommandFactory;
import net.rubyeye.xmemcached.utils.AddrUtil;

public class XMemcachedFactory {

    // Single server connection
    public static MemcachedClient createSingle() throws Exception {
        return new XMemcachedClientBuilder("localhost:11211").build();
    }

    // Cluster connection with full configuration
    public static MemcachedClient createCluster() throws Exception {
        XMemcachedClientBuilder builder = new XMemcachedClientBuilder(
            AddrUtil.getAddresses("192.168.1.1:11211 192.168.1.2:11211 192.168.1.3:11211")
        );

        // Consistent hashing (KetamaMemcachedSessionLocator)
        // Ketama is the consistent hashing algorithm widely used by memcached
        builder.setSessionLocator(new KetamaMemcachedSessionLocator());

        // Binary protocol — more efficient than the text protocol
        builder.setCommandFactory(new BinaryCommandFactory());

        // Connection pool — number of TCP connections per server
        // More than 1 allows parallel requests to the same server
        builder.setConnectionPoolSize(2);

        // Timeouts in milliseconds
        builder.setConnectTimeout(3000);
        builder.setOpTimeout(1000);

        // Health check — XMemcached automatically reconnects if the connection drops
        MemcachedClient client = builder.build();
        client.setEnableHeartBeat(true);

        return client;
    }
}

Basic Operations #

Memcached only supports one data type: string (binary-safe). All more complex data structures must be serialized by the client before storing.

Set, Get, Delete #

public class MemcachedBasicOps {

    // ===== SPYMEMCACHED =====
    public void demoSpy(MemcachedClient client) throws Exception {

        // SET — store a value with a TTL (in seconds)
        // TTL 0 → never expires (until eviction or server restart)
        // Max TTL → 30 days (2592000 seconds). Beyond that it's treated as a Unix timestamp
        client.set("key", 300, "string-value");

        // GET — fetch the value (synchronous — blocks until a response)
        Object value = client.get("key");
        System.out.println("Value: " + value);

        // GET with a cast
        String str = (String) client.get("key");

        // DELETE
        client.delete("key");

        // ADD — set only if the key does NOT exist (doesn't overwrite)
        client.add("new-key", 300, "value");

        // REPLACE — set only if the key ALREADY exists (doesn't create)
        client.replace("new-key", 600, "new-value");

        // APPEND / PREPEND — append a string to the end of a value (requires the binary protocol)
        // Only for string values, no-op if the key doesn't exist
        client.append(0, "new-key", "-suffix");
        client.prepend(0, "new-key", "prefix-");

        // INCR / DECR — atomic operations on numeric values
        // The initial value must be a numeric string: "0", "100", etc.
        client.set("counter", 0, "0");
        client.incr("counter", 1);   // "1"
        client.incr("counter", 10);  // "11"
        client.decr("counter", 3);   // "8"

        // Async GET — non-blocking, returns a Future
        net.spy.memcached.internal.GetFuture<Object> future = client.asyncGet("new-key");
        Object asyncResult = future.get(1000, java.util.concurrent.TimeUnit.MILLISECONDS);
    }

    // ===== XMEMCACHED =====
    public void demoX(MemcachedClient client) throws Exception {

        // The XMemcached API is more straightforward — all synchronous with timeouts
        client.set("key", 300, "value");

        String value = client.get("key");
        System.out.println("Value: " + value);

        client.delete("key");

        boolean added = client.add("new-key", 300, "value");
        System.out.println("Added successfully (key didn't exist): " + added);

        boolean replaced = client.replace("new-key", 600, "updated-value");
        System.out.println("Replaced successfully (key existed): " + replaced);

        // INCR / DECR with an initial value if the key doesn't exist
        long result = client.incr("counter", 1, 0); // delta=1, defaultValue=0
        System.out.println("Counter: " + result);
    }
}

Java Object Serialization #

Memcached stores byte arrays. Spymemcached and XMemcached automatically serialize Java objects using built-in Java Serialization. But Java Serialization is slow and produces large output — in production, use a more efficient serialization.

The Problem with Default Java Serialization #

// ✗ ANTI-PATTERN: storing Java objects with default serialization
// Problems: slow, large output, not portable across different JVM versions
public class UserProfile implements java.io.Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
    private String email;
    private int age;
    // getters/setters...
}

// This WORKS but isn't efficient
client.set("user:1001", 300, new UserProfile("Budi", "[email protected]", 30));
UserProfile user = (UserProfile) client.get("user:1001");

Manual Serialization with JSON #

import com.fasterxml.jackson.databind.ObjectMapper;
import net.spy.memcached.MemcachedClient;

public class JsonMemcachedCache {

    private final MemcachedClient client;
    private final ObjectMapper objectMapper;

    public JsonMemcachedCache(MemcachedClient client) {
        this.client = client;
        this.objectMapper = new ObjectMapper();
    }

    // Store an object as a JSON string
    public <T> void set(String key, int ttlSeconds, T value) throws Exception {
        String json = objectMapper.writeValueAsString(value);
        client.set(key, ttlSeconds, json);
    }

    // Fetch and deserialize an object from a JSON string
    public <T> T get(String key, Class<T> type) throws Exception {
        String json = (String) client.get(key);
        if (json == null) return null;
        return objectMapper.readValue(json, type);
    }

    // Usage example
    public static void example(MemcachedClient client) throws Exception {
        JsonMemcachedCache cache = new JsonMemcachedCache(client);

        // Store
        UserProfile user = new UserProfile("Sari", "[email protected]", 25);
        cache.set("user:1002", 300, user);

        // Fetch
        UserProfile cached = cache.get("user:1002", UserProfile.class);
        System.out.println("Name: " + cached.getName());
    }
}

// The object doesn't need to be Serializable with JSON
public class UserProfile {
    private String name;
    private String email;
    private int age;

    public UserProfile() {} // Jackson needs a no-arg constructor

    public UserProfile(String name, String email, int age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }

    public String getName() { return name; }
    public String getEmail() { return email; }
    public int getAge() { return age; }
    public void setName(String name) { this.name = name; }
    public void setEmail(String email) { this.email = email; }
    public void setAge(int age) { this.age = age; }
}

Custom Transcoder in Spymemcached #

Spymemcached supports Transcoder — an interface for controlling how objects are serialized before being sent to Memcached:

import net.spy.memcached.transcoders.Transcoder;
import net.spy.memcached.CachedData;
import com.fasterxml.jackson.databind.ObjectMapper;

public class JacksonTranscoder<T> implements Transcoder<T> {

    private static final int FLAGS = 0;
    private final ObjectMapper objectMapper = new ObjectMapper();
    private final Class<T> type;

    public JacksonTranscoder(Class<T> type) {
        this.type = type;
    }

    @Override
    public boolean asyncDecode(CachedData d) {
        return false;
    }

    @Override
    public CachedData encode(T o) {
        try {
            byte[] bytes = objectMapper.writeValueAsBytes(o);
            return new CachedData(FLAGS, bytes, CachedData.MAX_SIZE);
        } catch (Exception e) {
            throw new RuntimeException("Failed to serialize: " + e.getMessage(), e);
        }
    }

    @Override
    public T decode(CachedData d) {
        try {
            return objectMapper.readValue(d.getData(), type);
        } catch (Exception e) {
            throw new RuntimeException("Failed to deserialize: " + e.getMessage(), e);
        }
    }

    @Override
    public int getMaxSize() {
        return CachedData.MAX_SIZE;
    }
}

// How to use a custom transcoder
public class TranscoderDemo {

    public static void demo(MemcachedClient client) throws Exception {
        JacksonTranscoder<UserProfile> transcoder = new JacksonTranscoder<>(UserProfile.class);

        // Set with a transcoder
        client.set("user:1003", 300, new UserProfile("Dani", "[email protected]", 32), transcoder);

        // Get with a transcoder — get the right type directly without casting
        UserProfile user = client.get("user:1003", transcoder);
        System.out.println("Name: " + user.getName());
    }
}

Multi-Get — Batch Read Efficiency #

One of Memcached’s advantages for read-heavy workloads is its very efficient multi-get support. With one request, you can retrieve hundreds of values at once.

import java.util.Arrays;
import java.util.List;
import java.util.Map;

public class MemcachedMultiGet {

    // ✗ ANTI-PATTERN: fetching one by one in a loop
    // Every get() = 1 round trip to the server
    public Map<String, String> getOneByOne(MemcachedClient client,
                                            List<String> keys) throws Exception {
        Map<String, String> results = new java.util.HashMap<>();
        for (String key : keys) {
            String value = (String) client.get(key);
            if (value != null) results.put(key, value);
        }
        return results; // N round trips for N keys
    }

    // ===== SPYMEMCACHED =====
    // ✓ CORRECT: getBulk — fetch everything at once in 1-2 round trips
    public void bulkGetSpy(MemcachedClient client) throws Exception {
        // Prepare the keys
        List<String> keys = Arrays.asList(
            "user:1001", "user:1002", "user:1003",
            "user:1004", "user:1005"
        );

        // getBulk sends requests to all relevant servers at once
        // and waits for all responses before returning
        Map<String, Object> results = client.getBulk(keys);

        for (Map.Entry<String, Object> entry : results.entrySet()) {
            System.out.printf("Key: %s | Value: %s%n", entry.getKey(), entry.getValue());
        }

        // Keys not in the cache don't appear in the results
        System.out.printf("Requested %d, found %d%n", keys.size(), results.size());

        // Async bulk get — non-blocking
        net.spy.memcached.internal.BulkFuture<Map<String, Object>> future =
            client.asyncGetBulk(keys);
        Map<String, Object> asyncResults = future.get(2000, java.util.concurrent.TimeUnit.MILLISECONDS);
    }

    // ===== XMEMCACHED =====
    public void bulkGetX(net.rubyeye.xmemcached.MemcachedClient client) throws Exception {
        List<String> keys = Arrays.asList("product:A", "product:B", "product:C");

        // getByKeys — multi-get with type inference
        Map<String, String> results = client.get(keys);
        results.forEach((k, v) -> System.out.printf("Key: %s | Value: %s%n", k, v));
    }
}

Pattern: Cache Warming with Multi-Set #

public class CacheWarmer {

    private final net.rubyeye.xmemcached.MemcachedClient client;

    public CacheWarmer(net.rubyeye.xmemcached.MemcachedClient client) {
        this.client = client;
    }

    // Pre-populate the cache with frequently accessed data
    public void warmUpProductCache(List<Product> products) throws Exception {
        for (Product product : products) {
            String key = "cache:product:" + product.id();
            String json = new com.fasterxml.jackson.databind.ObjectMapper()
                .writeValueAsString(product);
            client.set(key, 3600, json); // cache for 1 hour
        }
        System.out.println("Cache warmed up for " + products.size() + " products");
    }

    record Product(String id, String name, double price) {}
}

CAS — Check-And-Set for Concurrency #

CAS (Check-And-Set, or Compare-And-Swap) is Memcached’s mechanism for avoiding race conditions when multiple clients update the same key simultaneously. Every item in Memcached has a CAS token — a number that changes every time the item is updated.

sequenceDiagram
    participant A as Client A
    participant B as Client B
    participant M as Memcached

    A->>M: gets("counter")
    M-->>A: value="10", cas=42

    B->>M: gets("counter")
    M-->>B: value="10", cas=42

    A->>M: cas("counter", cas=42, "11")
    M-->>A: STORED ✓ (cas matches, value updated, new cas=43)

    B->>M: cas("counter", cas=42, "11")
    M-->>B: EXISTS ✗ (cas doesn't match — A already changed the value)

    Note over B: B needs to gets() again and retry
import net.spy.memcached.CASValue;
import net.spy.memcached.CASResponse;

public class MemcachedCASDemo {

    // ✗ ANTI-PATTERN: GET then SET without CAS — race condition!
    public void updateWithoutCAS(MemcachedClient client, String key) throws Exception {
        String value = (String) client.get(key);   // Clients A and B both GET "10"
        int counter = Integer.parseInt(value) + 1;
        client.set(key, 300, String.valueOf(counter)); // Both SET "11" — one update lost!
    }

    // ✓ CORRECT: GETS then CAS — atomic update
    public boolean incrementWithCAS(MemcachedClient client,
                                     String key, int maxRetries) throws Exception {
        for (int attempt = 0; attempt < maxRetries; attempt++) {
            // GETS — like GET but also returns the CAS token
            CASValue<Object> casValue = client.gets(key);

            if (casValue == null) {
                // Key doesn't exist — initialize with ADD (atomic)
                client.add(key, 300, "0");
                continue;
            }

            String oldValue = (String) casValue.getValue();
            long casToken = casValue.getCas();
            String newValue = String.valueOf(Integer.parseInt(oldValue) + 1);

            // CAS — set only if the CAS token is still the same
            CASResponse response = client.cas(key, casToken, 300, newValue);

            switch (response) {
                case OK:
                    System.out.println("Update successful: " + oldValue + " → " + newValue);
                    return true;

                case EXISTS:
                    // The value was already changed by another client — try again
                    System.out.println("CAS conflict, retrying... (attempt " + (attempt + 1) + ")");
                    Thread.sleep(10 + (long)(Math.random() * 20)); // small jitter
                    break;

                case NOT_FOUND:
                    // The key was deleted during the operation
                    System.out.println("Key not found during CAS");
                    return false;
            }
        }

        System.err.println("Failed to update after " + maxRetries + " attempts");
        return false;
    }

    // ===== XMEMCACHED — CAS is cleaner with GetsResponse =====
    public void casWithXMemcached(net.rubyeye.xmemcached.MemcachedClient client,
                                   String key) throws Exception {
        net.rubyeye.xmemcached.GetsResponse<String> getsResponse = client.gets(key);

        if (getsResponse == null) {
            client.add(key, 300, "0");
            return;
        }

        String oldValue = getsResponse.getValue();
        long cas = getsResponse.getCas();
        String newValue = String.valueOf(Integer.parseInt(oldValue) + 1);

        boolean success = client.cas(key, 300, newValue, cas);
        System.out.println("CAS " + (success ? "succeeded" : "failed — value changed"));
    }
}

Caching Patterns #

Cache-Aside with Memcached #

public class MemcachedCacheAside {

    private final net.rubyeye.xmemcached.MemcachedClient cache;
    private final ArticleRepository repository;
    private final com.fasterxml.jackson.databind.ObjectMapper mapper;

    public MemcachedCacheAside(net.rubyeye.xmemcached.MemcachedClient cache,
                                ArticleRepository repository) {
        this.cache = cache;
        this.repository = repository;
        this.mapper = new com.fasterxml.jackson.databind.ObjectMapper();
    }

    public Article getArticle(long articleId) throws Exception {
        String cacheKey = "article:" + articleId;

        // 1. Try the cache
        String cached = cache.get(cacheKey);
        if (cached != null) {
            System.out.println("Cache HIT: " + cacheKey);
            return mapper.readValue(cached, Article.class);
        }

        // 2. Cache MISS — fetch from the database
        System.out.println("Cache MISS: " + cacheKey);
        Article article = repository.findById(articleId);

        if (article != null) {
            // 3. Store in the cache
            cache.set(cacheKey, 600, mapper.writeValueAsString(article)); // 10 minutes
        }

        return article;
    }

    public void updateArticle(long articleId, Article updated) throws Exception {
        // Update the database
        repository.update(articleId, updated);

        // Invalidate the cache
        cache.delete("article:" + articleId);
        System.out.println("Cache invalidated: article:" + articleId);
    }

    // Multi-get for an article list (list/index pages)
    public List<Article> getArticleBatch(List<Long> ids) throws Exception {
        List<String> keys = ids.stream()
            .map(id -> "article:" + id)
            .toList();

        // Fetch everything in the cache at once
        Map<String, String> cached = cache.get(keys);

        List<Article> results = new java.util.ArrayList<>();
        List<Long> missedIds = new java.util.ArrayList<>();

        for (Long id : ids) {
            String key = "article:" + id;
            if (cached.containsKey(key)) {
                results.add(mapper.readValue(cached.get(key), Article.class));
            } else {
                missedIds.add(id);
            }
        }

        // Fetch the misses from the database
        if (!missedIds.isEmpty()) {
            List<Article> fromDb = repository.findByIds(missedIds);
            for (Article article : fromDb) {
                // Store in the cache
                cache.set("article:" + article.id(), 600,
                    mapper.writeValueAsString(article));
                results.add(article);
            }
        }

        System.out.printf("Batch: %d from cache, %d from DB%n",
            ids.size() - missedIds.size(), missedIds.size());
        return results;
    }

    interface ArticleRepository {
        Article findById(long id);
        void update(long id, Article article);
        List<Article> findByIds(List<Long> ids);
    }

    record Article(long id, String title, String content) {}
}

Namespaces and Mass Key Invalidation #

Memcached doesn’t support prefix or pattern-based deletion (unlike Redis’s SCAN + DEL). A common trick for mass invalidation is using a namespace key that stores a version:

public class MemcachedNamespace {

    private final net.rubyeye.xmemcached.MemcachedClient client;

    public MemcachedNamespace(net.rubyeye.xmemcached.MemcachedClient client) {
        this.client = client;
    }

    // Get the current namespace version
    private long getNamespaceVersion(String namespace) throws Exception {
        Long version = client.get(namespace + ":version");
        if (version == null) {
            client.set(namespace + ":version", 0, 1L);
            return 1L;
        }
        return version;
    }

    // Build a key that includes the namespace version
    private String buildKey(String namespace, String key) throws Exception {
        long version = getNamespaceVersion(namespace);
        return namespace + ":" + version + ":" + key;
    }

    public void set(String namespace, String key, int ttl, String value) throws Exception {
        client.set(buildKey(namespace, key), ttl, value);
    }

    public String get(String namespace, String key) throws Exception {
        return client.get(buildKey(namespace, key));
    }

    // Invalidate all keys in a namespace — just increment the version
    // All old keys become automatically "invisible" because their keys have old versions
    // Old keys expire naturally per their own TTLs
    public void invalidateNamespace(String namespace) throws Exception {
        client.incr(namespace + ":version", 1, 1L);
        System.out.println("Namespace '" + namespace + "' invalidated.");
    }

    // Usage example
    public static void example(net.rubyeye.xmemcached.MemcachedClient client) throws Exception {
        MemcachedNamespace ns = new MemcachedNamespace(client);

        ns.set("products", "123", 3600, "{\"name\":\"Laptop\"}");
        ns.set("products", "456", 3600, "{\"name\":\"Mouse\"}");

        String laptop = ns.get("products", "123");
        System.out.println("Laptop: " + laptop);

        // Invalidate all product caches at once
        ns.invalidateNamespace("products");

        // Now get("products", "123") returns null (the old-version key isn't found)
        String laptopAfter = ns.get("products", "123");
        System.out.println("Laptop after invalidation: " + laptopAfter); // null
    }
}

Memcached vs Redis — Which to Choose #

This is the most frequently asked question when choosing a caching layer. Both are good for caching, but have different sweet spots.

AspectMemcachedRedis
Data structuresOnly string/binaryString, Hash, List, Set, Sorted Set, etc.
Memory efficiencyMore efficient for simple valuesLarger per-key overhead
PersistenceNone — data lost on restartRDB snapshots + AOF log
ReplicationNot built-inBuilt-in primary-replica
ClusteringClient-side (consistent hashing)Built-in Redis Cluster
Multi-threadingYes — several worker threadsSingle-threaded (I/O), multi-threaded (Redis 6+)
Atomic operationsINCR/DECR, CASMuch richer — Lua scripts, transactions
Pub/SubNot availableYes
Max value size1 MB512 MB
List/set operationsNot availableLRANGE, ZADD, SINTER, etc.
CHOOSE MEMCACHED WHEN:
  ✓ You only need simple caching (string key-value)
  ✓ Memory efficiency is critical — small values, large volume
  ✓ You need very easy horizontal scale-out (add nodes without coordination)
  ✓ Multi-threading matters for using all CPU cores
  ✓ You don't need persistence — an empty cache on restart is fine
  ✓ You already have a running Memcached infrastructure

CHOOSE REDIS WHEN:
  ✓ You need data structures beyond simple strings
  ✓ You need persistence (data must survive restarts)
  ✓ You need distributed locks, pub/sub, or Lua scripting
  ✓ You need built-in replication and high availability
  ✓ You need leaderboards (Sorted Sets), sessions (Hashes), or queues (Lists)
  ✓ The team isn't familiar with either — Redis has a richer ecosystem
flowchart TD
    A{"Need data structures\nbeyond strings?"} -- Yes --> REDIS[Redis]
    A -- No --> B{"Need persistence\nor replication?"}
    B -- Yes --> REDIS
    B -- No --> C{"Is memory efficiency\ncritical?"}
    C -- Yes --> D{"Does multi-threading\nmatter?"}
    C -- No --> REDIS
    D -- Yes --> MEMCACHED[Memcached]
    D -- No --> REDIS

Summary #

  • Memcached is a pure cache — no persistence, no complex data structures, no built-in clustering. This simplicity is its strength: very fast and memory efficient.
  • The slab allocator eliminates memory fragmentation at the cost of a little space (internal fragmentation). This results in constant, predictable memory allocation.
  • Horizontal scaling happens on the client side using consistent hashing (Ketama). Use KetamaMemcachedSessionLocator in XMemcached or make sure KetamaConnectionFactory is used in Spymemcached for a stable cluster.
  • Avoid default Java Serialization — slow and wasteful. Use JSON (Jackson) or more efficient binary serializers (Protobuf, Kryo) to store objects.
  • Multi-get (getBulk) is the performance key for read-heavy workloads — one request for hundreds of keys is far more efficient than a one-by-one GET loop.
  • CAS (Check-And-Set) prevents race conditions when multiple clients update the same key. Always use gets() + cas() for updates that need atomicity, not get() + set().
  • Namespace versioning is the technique for mass invalidation without per-key DELETEs — just increment the namespace version, and all old keys become automatically invisible.
  • Choose Memcached for pure caching with high memory efficiency and simple horizontal scaling. Choose Redis if you need rich data structures, persistence, or extra features like Pub/Sub and distributed locks.

← Previous: Redis   Next: Spring Boot →

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