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 #
| Feature | Dalli | memcached gem | redis gem |
|---|---|---|---|
| Protocol | Binary (default) + ASCII | ASCII | RESP |
| Thread safety | Yes | Yes | Yes |
| Compression | Built-in | Manual | Built-in |
| Connection pool | Built-in | No | Built-in (v5+) |
| Rails integration | Excellent | Good | Excellent |
| Actively developed | Yes | Rarely updated | Yes |
| Multi-server | Yes (consistent hash) | Yes | Yes (cluster) |
Summary #
- Dalli for all Memcached needs — thread-safe, supports the more efficient binary protocol, integrates perfectly with Rails, and is actively maintained.
namespacefor environment isolation — usenamespace: "#{Rails.env}:v1"so development caches don’t mix with production, and versioning for mass invalidation.get_multiis more efficient than agetloop — send one request to fetch many keys at once; greatly reduces latency for pages needing lots of cached data.addfor a simple mutex —addonly succeeds if the key doesn’t exist; use it to ensure only one process initializes a cached value.- CAS for conflict-free atomic updates —
cache.cas("key") { |value| value + 1 }ensures atomic updates even with many concurrent processes; retry on conflict.compress: trueis 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_allreplacement — 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_storeas 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.