Memcached #

Memcached is a distributed caching system famous for its simplicity and speed. Unlike Redis, which offers many data types and features, Memcached focuses on one thing — storing and retrieving values by key as fast as possible. This simple design makes Memcached very easy to scale horizontally: add new nodes and the client automatically distributes data using consistent hashing. In Ruby, the dalli gem is the most mature Memcached client — thread-safe, supports the binary protocol for better performance, and integrates perfectly as a Rails cache_store. This article covers all aspects of Memcached, from basic operations to idiomatic caching patterns in Rails applications.

Memcached vs Redis — Which to Choose? #

Memcached:
  ✓ Simpler — key-value only, no complex data types
  ✓ Multi-threaded — natively utilizes all CPU cores
  ✓ More memory-efficient for pure cache workloads
  ✓ Very easy horizontal scaling — add nodes, the client distributes automatically
  ✗ No persistence — data is lost when the server restarts
  ✗ No built-in replication
  ✗ Strings only — no Lists, Sets, or Sorted Sets
  ✗ No pub/sub, Lua scripts, or Streams
  Best for: pure caching, session stores, fragment caching

Redis:
  ✓ Many data types — String, Hash, List, Set, Sorted Set, Stream
  ✓ Disk persistence (RDB/AOF)
  ✓ Built-in master-replica replication
  ✓ Pub/Sub, Lua scripts, distributed locks
  ✗ Single-threaded command processing (Redis 6+ has multi-IO)
  ✗ More complex to set up and maintain
  Best for: caching + messaging + sessions + job queues

If your application only needs pure caching without other features, Memcached is the lighter, more efficient choice. If you’re already using Redis for Sidekiq or ActionCable, use Redis for caching too — no need to maintain two systems.


Installation #

# Install the Memcached server
sudo apt install memcached   # Ubuntu/Debian
brew install memcached       # macOS

# Run Memcached
memcached -m 256 -p 11211 -u memcache -l 127.0.0.1 &
# -m: memory in MB
# -p: port
# -l: bind address

# Install the gem
gem install dalli
# Gemfile
gem 'dalli', '~> 3.2'

Connection and Configuration #

require 'dalli'

# Connect to a single server
cache = Dalli::Client.new(
  "localhost:11211",
  namespace:   "store_app",   # automatic prefix for all keys
  compress:    true,         # automatically compress values > 1KB
  serializer:  Marshal,      # serializer (default: Marshal)
  expires_in:  3600,         # default TTL in seconds
  failover:    true,         # use another server if one goes down
  socket_timeout:     1,     # connection timeout
  socket_failure_delay: 0.1  # delay before retrying after a failure
)

# Connect to multiple servers (cluster)
# Dalli automatically distributes keys using consistent hashing
cache = Dalli::Client.new(
  ["mc1.example.com:11211",
   "mc2.example.com:11211",
   "mc3.example.com:11211"],
  namespace:  "v1",
  compress:   true,
  expires_in: 3600
)

# Connect via environment variable (common in production)
cache = Dalli::Client.new(
  ENV.fetch("MEMCACHIER_SERVERS", "localhost:11211").split(","),
  username:   ENV["MEMCACHIER_USERNAME"],
  password:   ENV["MEMCACHIER_PASSWORD"],
  namespace:  "#{Rails.env}:v2",
  compress:   true,
  expires_in: 3600,
  failover:   true,
  socket_timeout: 1.5
)

# Check the connection
cache.alive!   # raises an exception if no server can be reached
puts cache.stats.keys.inspect   # list of connected servers

Thread Safety and Connection Pools #

# Dalli::Client is thread-safe by default
# but for intensive multi-threaded applications, use a connection pool

require 'connection_pool'

MEMCACHED = ConnectionPool.new(size: 10, timeout: 5) do
  Dalli::Client.new(
    ENV.fetch("MEMCACHIER_SERVERS", "localhost:11211").split(","),
    namespace:  Rails.env,
    compress:   true,
    expires_in: 3600
  )
end

# Use with a block
MEMCACHED.with do |cache|
  cache.set("key", "value")
  cache.get("key")
end

Basic CRUD Operations #

Set and Get #

cache = Dalli::Client.new("localhost:11211")

# SET — store a value with a TTL in seconds
cache.set("username", "Rina Wijaya")          # TTL from the default
cache.set("token:user:99", "abc123xyz", 1800) # 30 minute TTL
cache.set("config:tax", 0.11, 86400)          # 1 day TTL

# GET — fetch a value, nil if absent or expired
puts cache.get("username")   # => "Rina Wijaya"
puts cache.get("missing")    # => nil

# GET multi — fetch many keys at once (more efficient than a GET loop)
results = cache.get_multi("username", "config:tax", "missing")
puts results.inspect
# => {"username"=>"Rina Wijaya", "config:tax"=>0.11}
# missing keys don't appear in the result hash

# Store Ruby objects (auto-serialized with Marshal)
user = { id: 1, name: "Budi", email: "[email protected]", role: :admin }
cache.set("user:1", user, 3600)
puts cache.get("user:1").inspect   # => {:id=>1, :name=>"Budi", ...}

# Store arrays
cache.set("product:ids", [1, 2, 3, 4, 5], 600)
puts cache.get("product:ids").inspect   # => [1, 2, 3, 4, 5]

Add and Replace #

# ADD — set only if the key does NOT exist (returns true/false)
success = cache.add("unique_key", "initial_value", 300)
puts success   # => true if successful, false if it already exists

success2 = cache.add("unique_key", "other_value")
puts success2  # => false (already exists)

# REPLACE — set only if the key DOES exist
success = cache.replace("unique_key", "new_value", 300)
puts success   # => true if successful, false if it doesn't exist

# Useful for updates without race conditions:
# 1. add succeeds → you're the first to write
# 2. replace succeeds → you updated an existing value

Delete and Flush #

# DELETE — remove a single key
cache.delete("username")

# FLUSH — remove ALL keys from all servers
# CAREFUL: this wipes the entire cache!
# In production, use namespace versioning instead
cache.flush_all

# Flush with a delay (Memcached gradually marks keys as expired)
# Useful so the database isn't overwhelmed by a burst of cache misses at once
cache.flush_all(delay: 10)   # delete everything within the next 10 seconds

Increment and Decrement #

# INCR and DECR — atomic counters
cache.set("view_count:article:42", 0)
cache.incr("view_count:article:42")           # => 1
cache.incr("view_count:article:42", 5)        # => 6  (add 5)
cache.decr("view_count:article:42", 2)        # => 4  (subtract 2)

# Useful for simple rate limiting
def rate_limit_ok?(ip, max: 100, window: 60)
  key    = "rate:#{ip}:#{Time.now.to_i / window}"
  count  = cache.incr(key, 1, window, 1)   # (key, amount, ttl, initial)
  count <= max
end

CAS — Compare-And-Swap for Atomic Updates #

CAS prevents race conditions when many processes try to update the same value:

# CAS (Compare-And-Swap) — update only if the value hasn't changed since it was read
# Returns true on success, false on conflict

# Method 1: cas with a block — Dalli handles the CAS token automatically
success = cache.cas("balance:user:1") do |current_balance|
  current_balance ||= 0
  raise "Insufficient balance" if current_balance < 100_000
  current_balance - 100_000   # the new value to set
end

if success
  puts "Withdrawal successful"
else
  puts "Conflict — retrying"
  retry   # retry if there's a conflict
end

# Method 2: retry loop until success
loop do
  break if cache.cas("counter") { |n| (n || 0) + 1 }
  sleep 0.001   # brief pause before retrying
end

# Real-world example: safely updating a leaderboard
loop do
  ok = cache.cas("leaderboard:top10") do |list|
    list ||= []
    list.push({ name: "Rina", score: 1500 })
    list.sort_by { |e| -e[:score] }.first(10)
  end
  break if ok
end

Namespaces and Key Versioning #

Namespaces and versioning are strategies for mass cache invalidation without flushing everything:

# Namespace via Dalli — automatic prefix on all keys
cache_v1 = Dalli::Client.new("localhost:11211", namespace: "v1")
cache_v2 = Dalli::Client.new("localhost:11211", namespace: "v2")

cache_v1.set("product:1", old_data)
cache_v2.set("product:1", new_data)

# Switch the namespace version → all v1 caches are no longer accessed (expire naturally)
# No need to delete them one by one!

# Namespace with a runtime version — fetch the version from Memcached itself
class CacheNamespace
  def initialize(cache)
    @cache = cache
  end

  def namespace_version
    @cache.fetch("app:namespace:version") { SecureRandom.hex(4) }
  end

  def invalidate_all!
    @cache.set("app:namespace:version", SecureRandom.hex(4), 0)  # 0 = no expiry
  end

  def key(name)
    "#{namespace_version}:#{name}"
  end

  def get(name)
    @cache.get(key(name))
  end

  def set(name, value, ttl = 3600)
    @cache.set(key(name), value, ttl)
  end
end

ns_cache = CacheNamespace.new(cache)
ns_cache.set("product:list", product_list)
ns_cache.get("product:list")

# Invalidate everything at once with a single key update
ns_cache.invalidate_all!
# All old caches are unreachable because the version changed
# They expire naturally after their individual TTLs

Common Caching Patterns #

Cache-Aside (Lazy Loading) #

# The most common pattern: fetch from cache, query the database on a miss
def fetch_product(id)
  key = "product:#{id}"
  cached = cache.get(key)
  return cached if cached

  # Cache miss — query the database
  product = Product.find(id)
  cache.set(key, product, 3600)
  product
end

# With Dalli#fetch — more concise
def fetch_product(id)
  cache.fetch("product:#{id}", 3600) do
    Product.find(id)
  end
end

Write-Through — Updating the Cache Alongside the Database #

def update_product(id, attributes)
  product = Product.find(id)
  product.update!(attributes)

  # Update the cache alongside so it doesn't go stale
  cache.set("product:#{id}", product, 3600)
  product
end

Cache Stampede Prevention — Local Mutex #

On a cache miss, many requests can hit the database simultaneously. Prevent it with a mutex:

require 'monitor'

class CacheWithStampedeProtection
  def initialize(cache)
    @cache = cache
    @locks = Hash.new { |h, k| h[k] = Mutex.new }
  end

  def fetch(key, ttl = 3600, &block)
    # First check without a lock (fast path)
    value = @cache.get(key)
    return value if value

    # Cache miss — use a per-key lock so only one request queries the database
    @locks[key].synchronize do
      # Check again after acquiring the lock (another thread may have filled it)
      value = @cache.get(key)
      return value if value

      # A real miss — compute the value and store it
      value = block.call
      @cache.set(key, value, ttl)
      value
    end
  end
end

safe_cache = CacheWithStampedeProtection.new(cache)
product = safe_cache.fetch("product:#{id}", 3600) { Product.find(id) }

Rails Integration — Cache Store #

Rails uses Memcached as a cache store through the dalli adapter:

# Gemfile
# gem 'dalli', '~> 3.2'

# config/environments/production.rb
config.cache_store = :mem_cache_store,
  ENV.fetch("MEMCACHIER_SERVERS", "localhost:11211").split(","),
  {
    username:   ENV["MEMCACHIER_USERNAME"],
    password:   ENV["MEMCACHIER_PASSWORD"],
    failover:   true,
    socket_timeout:      1.5,
    socket_failure_delay: 0.2,
    value_max_bytes:     10_485_760,   # 10MB max per value
    compress:            true,
    namespace:           "#{Rails.env}:v#{ENV['CACHE_VERSION'] || 1}"
  }

# config/environments/development.rb
config.cache_store = :mem_cache_store, "localhost:11211",
  { namespace: "dev", compress: false }

Fragment Caching in Views #

# Enable caching in config/development.rb for testing
# config.action_controller.perform_caching = true

# app/views/products/index.html.erb
<% cache  ["product_list_v1", @products.maximum(:updated_at)] do %>
  <% @products.each do |product| %>
    <%= render "products/card", product: product %>
  <% end %>
<% end %>

# Per-item caching (Russian Doll Caching)
<% @products.each do |product| %>
  <% cache product do %>
    <!-- Cache digest based on product.id + updated_at -->
    <%= render "products/card", product: product %>
  <% end %>
<% end  %>

Low-Level Caching in Controllers and Models #

# In a controller
class ProductsController < ApplicationController
  def index
    @products = Rails.cache.fetch("products:all:active", expires_in: 10.minutes) do
      Product.active.includes(:category).to_a
    end
  end

  def show
    @product = Rails.cache.fetch("product:#{params[:id]}", expires_in: 1.hour) do
      Product.find(params[:id])
    end
  end
end

# In a model — cache that invalidates automatically
class Product < ApplicationRecord
  after_commit :invalidate_cache

  def self.find_with_cache(id)
    Rails.cache.fetch("product:#{id}", expires_in: 1.hour) { find(id) }
  end

  private

  def invalidate_cache
    Rails.cache.delete("product:#{id}")
    Rails.cache.delete("products:all:active")
  end
end

# Direct cache operations
Rails.cache.write("setting:tax", 0.11, expires_in: 1.day)
Rails.cache.read("setting:tax")         # => 0.11
Rails.cache.exist?("setting:tax")       # => true
Rails.cache.delete("setting:tax")

# Increment/decrement via the Rails cache
Rails.cache.increment("view:article:42")
Rails.cache.decrement("stock:product:1")

# Multi-read
Rails.cache.read_multi("user:1", "user:2", "user:3")
# => {"user:1"=>..., "user:2"=>...}  (missing keys don't appear)

Session Store with Memcached #

# config/initializers/session_store.rb
Rails.application.config.session_store :dalli_store,
  memcache_server: ENV.fetch("MEMCACHIER_SERVERS", "localhost:11211").split(","),
  namespace:       "sessions",
  key:             "_store_session",
  expire_after:    2.hours,
  secure:          Rails.env.production?,
  httponly:        true,
  same_site:       :lax,
  # Dalli options
  username:        ENV["MEMCACHIER_USERNAME"],
  password:        ENV["MEMCACHIER_PASSWORD"],
  compress:        true,
  failover:        true

Compressing Large Data #

Memcached has a 1MB limit per item by default. For larger data, compress:

require 'zlib'

# Manual compression with Zlib
def set_with_compression(cache, key, value, ttl = 3600)
  data        = Marshal.dump(value)
  compressed  = Zlib::Deflate.deflate(data)

  cache.set(key, compressed, ttl)
  puts "Original size: #{data.bytesize} bytes"
  puts "Compressed:    #{compressed.bytesize} bytes (#{(compressed.bytesize.to_f / data.bytesize * 100).round}%)"
end

def get_with_decompression(cache, key)
  compressed = cache.get(key)
  return nil unless compressed

  data = Zlib::Inflate.inflate(compressed)
  Marshal.load(data)
end

# Or let Dalli handle compression automatically (compress: true)
cache = Dalli::Client.new("localhost:11211",
  compress:   true,          # compress if the value > compress_threshold
  # compress_threshold: 1024   # compress if > 1KB (default)
)

Monitoring and Statistics #

# Per-server statistics
stats = cache.stats
stats.each do |server, data|
  puts "=== #{server} ==="
  puts "Version:         #{data['version']}"
  puts "Uptime:          #{data['uptime'].to_i / 3600} hours"
  puts "Memory used:     #{(data['bytes'].to_i / 1_048_576.0).round(1)} MB / #{data['limit_maxbytes'].to_i / 1_048_576} MB"
  puts "Hit rate:        #{(data['get_hits'].to_f / (data['get_hits'].to_i + data['get_misses'].to_i) * 100).round(1)}%"
  puts "Items stored:    #{data['curr_items']}"
  puts "Evicted:         #{data['evictions']}"
  puts "Active conns:    #{data['curr_connections']}"
end

# Alert if the eviction rate is high (insufficient memory)
stats.each do |server, data|
  evictions = data['evictions'].to_i
  if evictions > 1000
    puts "WARNING: #{server} has performed #{evictions} evictions — add more memory!"
    Monitoring.alert("High Memcached evictions", server: server, evictions: evictions)
  end
end

LRU Eviction Strategy #

Memcached uses Least Recently Used (LRU) to remove items when memory is full. Understanding this is important for good key design:

flowchart LR
    A["Request arrives\nkey: product:1"] --> B{"In cache?"}
    B -- Hit --> C["Return the value\nUpdate position\nin the LRU list"]
    B -- Miss --> D["Query the database"]
    D --> E{"Memory full?"}
    E -- Yes --> F["Remove the LRU item\n(least recently accessed)"]
    E -- No --> G["Store in cache"]
    F --> G
    G --> H["Return the value"]
Best practices to avoid premature evictions:
  ✓ Set appropriate TTLs — don't make them too long for changing data
  ✓ Avoid storing very large data (compress if needed)
  ✓ Monitor the eviction rate — if high, add memory or reduce data
  ✓ Use namespace versioning instead of storing too many old keys
  ✓ Consider Memcached slab allocation to avoid fragmentation
  ✗ Don't store data that will never be read again
  ✗ Don't set TTL 0 (no expiry) except for truly permanent data

Dalli vs Other Clients #

FeatureDallimemcached gemredis gem
ProtocolBinary (default) + ASCIIASCIIRESP
Thread safetyYesYesYes
CompressionBuilt-inManualBuilt-in
Connection poolBuilt-inNoBuilt-in (v5+)
Rails integrationExcellentGoodExcellent
Actively developedYesRarely updatedYes
Multi-serverYes (consistent hash)YesYes (cluster)

Summary #

  • Dalli for all Memcached needs — thread-safe, supports the more efficient binary protocol, integrates perfectly with Rails, and is actively maintained.
  • namespace for environment isolation — use namespace: "#{Rails.env}:v1" so development caches don’t mix with production, and versioning for mass invalidation.
  • get_multi is more efficient than a get loop — send one request to fetch many keys at once; greatly reduces latency for pages needing lots of cached data.
  • add for a simple mutexadd only succeeds if the key doesn’t exist; use it to ensure only one process initializes a cached value.
  • CAS for conflict-free atomic updatescache.cas("key") { |value| value + 1 } ensures atomic updates even with many concurrent processes; retry on conflict.
  • compress: true is mandatory for large data — Memcached has a 1MB per-item limit; Dalli’s automatic compression reduces size and saves bandwidth.
  • Namespace versioning as a flush_all replacement — update one namespace version key → all old caches become unreachable without deleting them one by one.
  • Monitor the eviction rate — evictions mean Memcached doesn’t have enough memory; add memory or reduce TTLs for less important data.
  • The right session store — Memcached is great for session stores because sessions don’t need to be persistent; use dalli_store as the Rails session adapter.
  • Memcached for pure caching, Redis for everything else — if you’re already using Redis for Sidekiq or ActionCable, use Redis for caching too so you don’t maintain two systems.

← Previous: Redis   Next: Ruby on Rails →

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