Memcached #
Memcached adalah distributed caching system yang terkenal dengan kesederhanaan dan kecepatannya. Berbeda dari Redis yang menawarkan banyak tipe data dan fitur, Memcached berfokus pada satu hal saja — menyimpan dan mengambil nilai berdasarkan kunci secepat mungkin. Desain yang sederhana ini membuat Memcached sangat mudah di-scale horizontal: tambahkan node baru dan client secara otomatis mendistribusikan data menggunakan consistent hashing. Di Ruby, gem dalli adalah client Memcached yang paling matang — thread-safe, mendukung protokol binary untuk performa lebih baik, dan terintegrasi sempurna sebagai cache_store Rails. Artikel ini membahas semua aspek Memcached dari operasi dasar hingga pola caching yang idiomatik di aplikasi Rails.
Memcached vs Redis — Pilih yang Mana? #
Memcached:
✓ Lebih sederhana — hanya key-value, tidak ada tipe data kompleks
✓ Multi-threaded — memanfaatkan semua CPU core secara native
✓ Memory lebih efisien untuk pure cache workload
✓ Scale horizontal sangat mudah — tambah node, client otomatis distribusi
✗ Tidak ada persistence — data hilang saat server restart
✗ Tidak ada replikasi bawaan
✗ Hanya string — tidak ada List, Set, Sorted Set
✗ Tidak ada pub/sub, Lua script, atau Stream
Terbaik untuk: pure caching, session store, fragment cache
Redis:
✓ Banyak tipe data — String, Hash, List, Set, Sorted Set, Stream
✓ Persistence ke disk (RDB/AOF)
✓ Replikasi master-replica bawaan
✓ Pub/Sub, Lua script, distributed lock
✗ Single-threaded untuk command processing (Redis 6+ multi-IO)
✗ Lebih kompleks untuk di-setup dan di-maintain
Terbaik untuk: caching + messaging + session + job queue
Jika aplikasimu hanya butuh cache murni tanpa fitur lain, Memcached adalah pilihan yang lebih ringan dan efisien. Jika sudah menggunakan Redis untuk Sidekiq atau ActionCable, gunakan Redis untuk cache juga — tidak perlu maintain dua sistem.
Instalasi #
# Install Memcached server
sudo apt install memcached # Ubuntu/Debian
brew install memcached # macOS
# Jalankan Memcached
memcached -m 256 -p 11211 -u memcache -l 127.0.0.1 &
# -m: memori dalam MB
# -p: port
# -l: bind address
# Install gem
gem install dalli
# Gemfile
gem 'dalli', '~> 3.2'
Koneksi dan Konfigurasi #
require 'dalli'
# Koneksi ke satu server
cache = Dalli::Client.new(
"localhost:11211",
namespace: "toko_app", # prefix otomatis untuk semua key
compress: true, # kompres nilai > 1KB secara otomatis
serializer: Marshal, # serializer (default: Marshal)
expires_in: 3600, # TTL default dalam detik
failover: true, # gunakan server lain jika satu down
socket_timeout: 1, # timeout koneksi
socket_failure_delay: 0.1 # jeda sebelum retry setelah gagal
)
# Koneksi ke banyak server (cluster)
# Dalli otomatis mendistribusikan key menggunakan 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
)
# Koneksi via environment variable (umum di 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
)
# Cek koneksi
cache.alive! # raise exception jika tidak ada server yang bisa dihubungi
puts cache.stats.keys.inspect # daftar server yang terhubung
Thread Safety dan Connection Pool #
# Dalli::Client sudah thread-safe secara default
# tapi untuk aplikasi multi-thread yang intensif, gunakan 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
# Gunakan dengan blok
MEMCACHED.with do |cache|
cache.set("kunci", "nilai")
cache.get("kunci")
end
Operasi Dasar CRUD #
Set dan Get #
cache = Dalli::Client.new("localhost:11211")
# SET — simpan nilai dengan TTL dalam detik
cache.set("nama_pengguna", "Rina Wijaya") # TTL dari default
cache.set("token:user:99", "abc123xyz", 1800) # TTL 30 menit
cache.set("config:pajak", 0.11, 86400) # TTL 1 hari
# GET — ambil nilai, nil jika tidak ada atau expired
puts cache.get("nama_pengguna") # => "Rina Wijaya"
puts cache.get("tidak_ada") # => nil
# GET multi — ambil banyak key sekaligus (lebih efisien dari loop GET)
hasil = cache.get_multi("nama_pengguna", "config:pajak", "tidak_ada")
puts hasil.inspect
# => {"nama_pengguna"=>"Rina Wijaya", "config:pajak"=>0.11}
# key yang tidak ada tidak muncul di hash hasil
# Simpan objek Ruby (di-serialize otomatis dengan Marshal)
user = { id: 1, nama: "Budi", email: "[email protected]", role: :admin }
cache.set("user:1", user, 3600)
puts cache.get("user:1").inspect # => {:id=>1, :nama=>"Budi", ...}
# Simpan array
cache.set("produk:ids", [1, 2, 3, 4, 5], 600)
puts cache.get("produk:ids").inspect # => [1, 2, 3, 4, 5]
Add dan Replace #
# ADD — set hanya jika key BELUM ada (return true/false)
berhasil = cache.add("kunci_unik", "nilai_awal", 300)
puts berhasil # => true jika berhasil, false jika sudah ada
berhasil2 = cache.add("kunci_unik", "nilai_lain")
puts berhasil2 # => false (sudah ada)
# REPLACE — set hanya jika key SUDAH ada
berhasil = cache.replace("kunci_unik", "nilai_baru", 300)
puts berhasil # => true jika berhasil, false jika tidak ada
# Berguna untuk update tanpa race condition:
# 1. add berhasil → kamu yang pertama menulis
# 2. replace berhasil → kamu update nilai yang sudah ada
Delete dan Flush #
# DELETE — hapus satu key
cache.delete("nama_pengguna")
# FLUSH — hapus SEMUA key dari semua server
# HATI-HATI: ini menghapus seluruh cache!
# Di production, gunakan namespace versioning sebagai gantinya
cache.flush_all
# Flush dengan delay (Memcached akan menandai key sebagai expired secara bertahap)
# Berguna agar tidak membebani database dengan banyak cache miss sekaligus
cache.flush_all(delay: 10) # hapus semua dalam 10 detik ke depan
Increment dan Decrement #
# INCR dan DECR — atomic counter
cache.set("view_count:artikel:42", 0)
cache.incr("view_count:artikel:42") # => 1
cache.incr("view_count:artikel:42", 5) # => 6 (tambah 5)
cache.decr("view_count:artikel:42", 2) # => 4 (kurangi 2)
# Berguna untuk rate limiting sederhana
def rate_limit_ok?(ip, max: 100, window: 60)
kunci = "rate:#{ip}:#{Time.now.to_i / window}"
jumlah = cache.incr(kunci, 1, window, 1) # (key, amount, ttl, initial)
jumlah <= max
end
CAS — Compare-And-Swap untuk Atomic Update #
CAS mencegah race condition saat banyak proses mencoba memperbarui nilai yang sama:
# CAS (Compare-And-Swap) — update hanya jika nilai belum berubah sejak dibaca
# Mengembalikan true jika berhasil, false jika ada konflik
# Cara 1: cas dengan blok — Dalli handle CAS token secara otomatis
berhasil = cache.cas("saldo:pengguna:1") do |saldo_saat_ini|
saldo_saat_ini ||= 0
raise "Saldo tidak cukup" if saldo_saat_ini < 100_000
saldo_saat_ini - 100_000 # nilai baru yang akan di-set
end
if berhasil
puts "Penarikan berhasil"
else
puts "Konflik — coba ulang"
retry # coba ulang jika ada konflik
end
# Cara 2: loop retry sampai berhasil
loop do
break if cache.cas("counter") { |n| (n || 0) + 1 }
sleep 0.001 # jeda singkat sebelum retry
end
# Contoh nyata: update leaderboard secara safe
loop do
sukses = cache.cas("leaderboard:top10") do |list|
list ||= []
list.push({ nama: "Rina", skor: 1500 })
list.sort_by { |e| -e[:skor] }.first(10)
end
break if sukses
end
Namespace dan Key Versioning #
Namespace dan versioning adalah strategi untuk invalidasi cache massal tanpa harus flush semua:
# Namespace via Dalli — prefix otomatis ke semua key
cache_v1 = Dalli::Client.new("localhost:11211", namespace: "v1")
cache_v2 = Dalli::Client.new("localhost:11211", namespace: "v2")
cache_v1.set("produk:1", data_lama)
cache_v2.set("produk:1", data_baru)
# Ganti versi namespace → semua cache v1 tidak diakses lagi (expired sendiri)
# Tidak perlu delete satu per satu!
# Namespace dengan runtime version — ambil versi dari Memcached itu sendiri
class CacheNamespace
def initialize(cache)
@cache = cache
end
def versi_namespace
@cache.fetch("app:namespace:version") { SecureRandom.hex(4) }
end
def invalidasi_semua!
@cache.set("app:namespace:version", SecureRandom.hex(4), 0) # 0 = tidak expire
end
def kunci(nama)
"#{versi_namespace}:#{nama}"
end
def get(nama)
@cache.get(kunci(nama))
end
def set(nama, nilai, ttl = 3600)
@cache.set(kunci(nama), nilai, ttl)
end
end
ns_cache = CacheNamespace.new(cache)
ns_cache.set("produk:list", produk_list)
ns_cache.get("produk:list")
# Invalidasi semua sekaligus hanya dengan update satu key
ns_cache.invalidasi_semua!
# Semua cache lama tidak bisa diakses karena versi berubah
# Expired sendiri setelah TTL masing-masing habis
Pola Caching Umum #
Cache-Aside (Lazy Loading) #
# Pola paling umum: ambil dari cache, jika miss baru query database
def ambil_produk(id)
kunci = "produk:#{id}"
cached = cache.get(kunci)
return cached if cached
# Cache miss — query database
produk = Produk.find(id)
cache.set(kunci, produk, 3600)
produk
end
# Dengan Dalli#fetch — lebih ringkas
def ambil_produk(id)
cache.fetch("produk:#{id}", 3600) do
Produk.find(id)
end
end
Write-Through — Update Cache Bersamaan dengan Database #
def perbarui_produk(id, atribut)
produk = Produk.find(id)
produk.update!(atribut)
# Update cache bersamaan agar tidak stale
cache.set("produk:#{id}", produk, 3600)
produk
end
Cache Stampede Prevention — Mutex Lokal #
Saat cache miss, banyak request bisa serentak menghantam database. Cegah dengan mutex:
require 'monitor'
class CacheWithStampedeProtection
def initialize(cache)
@cache = cache
@mutex = MonitorMixin.new.extend(MonitorMixin)
@locks = Hash.new { |h, k| h[k] = Mutex.new }
end
def fetch(kunci, ttl = 3600, &blok)
# Pertama cek tanpa lock (fast path)
nilai = @cache.get(kunci)
return nilai if nilai
# Cache miss — gunakan lock per kunci agar hanya satu request yang query database
@locks[kunci].synchronize do
# Cek lagi setelah dapat lock (mungkin sudah diisi thread lain)
nilai = @cache.get(kunci)
return nilai if nilai
# Benar-benar miss — hitung nilai dan simpan
nilai = blok.call
@cache.set(kunci, nilai, ttl)
nilai
end
end
end
cache_aman = CacheWithStampedeProtection.new(cache)
produk = cache_aman.fetch("produk:#{id}", 3600) { Produk.find(id) }
Integrasi Rails — Cache Store #
Rails menggunakan Memcached sebagai cache store melalui adapter dalli:
# 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 di View #
# Aktifkan caching di config/development.rb untuk testing
# config.action_controller.perform_caching = true
# app/views/produk/index.html.erb
<% cache ["produk_list_v1", @produk.maximum(:updated_at)] do %>
<% @produk.each do |produk| %>
<%= render "produk/card", produk: produk %>
<% end %>
<% end %>
# Cache per-item (Russian Doll Caching)
<% @produk.each do |produk| %>
<% cache produk do %>
<!-- Cache digest berdasarkan produk.id + updated_at -->
<%= render "produk/card", produk: produk %>
<% end %>
<% end %>
Low-Level Caching di Controller dan Model #
# Di controller
class ProdukController < ApplicationController
def index
@produk = Rails.cache.fetch("produk:semua:aktif", expires_in: 10.minutes) do
Produk.aktif.includes(:kategori).to_a
end
end
def show
@produk = Rails.cache.fetch("produk:#{params[:id]}", expires_in: 1.hour) do
Produk.find(params[:id])
end
end
end
# Di model — cache yang otomatis terinvalidasi
class Produk < ApplicationRecord
after_commit :invalidasi_cache
def self.cari_dengan_cache(id)
Rails.cache.fetch("produk:#{id}", expires_in: 1.hour) { find(id) }
end
private
def invalidasi_cache
Rails.cache.delete("produk:#{id}")
Rails.cache.delete("produk:semua:aktif")
end
end
# Operasi cache langsung
Rails.cache.write("setting:pajak", 0.11, expires_in: 1.day)
Rails.cache.read("setting:pajak") # => 0.11
Rails.cache.exist?("setting:pajak") # => true
Rails.cache.delete("setting:pajak")
# Increment/decrement via Rails cache
Rails.cache.increment("view:artikel:42")
Rails.cache.decrement("stok:produk:1")
# Multi-read
Rails.cache.read_multi("user:1", "user:2", "user:3")
# => {"user:1"=>..., "user:2"=>...} (key yang tidak ada tidak muncul)
Session Store dengan 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: "_toko_session",
expire_after: 2.hours,
secure: Rails.env.production?,
httponly: true,
same_site: :lax,
# Opsi Dalli
username: ENV["MEMCACHIER_USERNAME"],
password: ENV["MEMCACHIER_PASSWORD"],
compress: true,
failover: true
Kompresi Data Besar #
Memcached punya batas 1MB per item secara default. Untuk data lebih besar, kompres:
require 'zlib'
# Kompresi manual dengan Zlib
def set_dengan_kompresi(cache, kunci, nilai, ttl = 3600)
data = Marshal.dump(nilai)
terkompresi = Zlib::Deflate.deflate(data)
cache.set(kunci, terkompresi, ttl)
puts "Ukuran asli: #{data.bytesize} bytes"
puts "Terkompresi: #{terkompresi.bytesize} bytes (#{(terkompresi.bytesize.to_f / data.bytesize * 100).round}%)"
end
def get_dengan_decompresi(cache, kunci)
terkompresi = cache.get(kunci)
return nil unless terkompresi
data = Zlib::Inflate.inflate(terkompresi)
Marshal.load(data)
end
# Atau biarkan Dalli handle kompresi otomatis (compress: true)
cache = Dalli::Client.new("localhost:11211",
compress: true, # kompres jika nilai > compress_threshold
# compress_threshold: 1024 # kompres jika > 1KB (default)
)
Monitoring dan Statistik #
# Statistik per server
stats = cache.stats
stats.each do |server, data|
puts "=== #{server} ==="
puts "Versi: #{data['version']}"
puts "Uptime: #{data['uptime'].to_i / 3600} jam"
puts "Memori terpakai: #{(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 "Item tersimpan: #{data['curr_items']}"
puts "Evicted: #{data['evictions']}"
puts "Koneksi aktif: #{data['curr_connections']}"
end
# Alert jika eviction rate tinggi (memori tidak cukup)
stats.each do |server, data|
evictions = data['evictions'].to_i
if evictions > 1000
puts "PERINGATAN: #{server} sudah melakukan #{evictions} evictions — tambah memori!"
Monitoring.alert("Memcached eviction tinggi", server: server, evictions: evictions)
end
end
Strategi Eviction LRU #
Memcached menggunakan Least Recently Used (LRU) untuk menghapus item saat memori penuh. Memahami ini penting untuk desain key yang baik:
flowchart LR
A["Request masuk\nkey: produk:1"] --> B{"Ada di cache?"}
B -- Hit --> C["Kembalikan nilai\nPerbarui posisi\ndi LRU list"]
B -- Miss --> D["Query database"]
D --> E{"Memori penuh?"}
E -- Ya --> F["Hapus item LRU\n(paling lama tidak diakses)"]
E -- Tidak --> G["Simpan ke cache"]
F --> G
G --> H["Kembalikan nilai"]Best practice untuk menghindari eviction prematur:
✓ Set TTL yang tepat — jangan terlalu panjang untuk data yang berubah
✓ Hindari menyimpan data yang sangat besar (kompres jika perlu)
✓ Monitor eviction rate — jika tinggi, tambah memori atau kurangi data
✓ Gunakan namespace versioning daripada menyimpan terlalu banyak key lama
✓ Pertimbangkan Memcached slab allocation untuk menghindari fragmentasi
✗ Jangan simpan data yang tidak akan pernah di-read kembali
✗ Jangan set TTL 0 (tidak expire) kecuali untuk data yang benar-benar permanen
Perbandingan Dalli vs Lain #
| Fitur | Dalli | memcached gem | redis gem |
|---|---|---|---|
| Protokol | Binary (default) + ASCII | ASCII | RESP |
| Thread safety | Ya | Ya | Ya |
| Compression | Bawaan | Manual | Bawaan |
| Connection pool | Built-in | Tidak | Built-in (v5+) |
| Rails integration | Excellent | Baik | Excellent |
| Aktif dikembangkan | Ya | Jarang update | Ya |
| Multi-server | Ya (consistent hash) | Ya | Ya (cluster) |
Ringkasan #
- **Dalli untuk semua kebutuhan Memcached ** — thread-safe, mendukung protokol binary yang lebih efisien, terintegrasi sempurna dengan Rails, dan di-maintain aktif.
namespaceuntuk isolasi environment — gunakannamespace: "#{Rails.env}:v1"agar cache development tidak bercampur dengan production, dan versioning untuk invalidasi massal.get_multilebih efisien dari loopget— kirim satu request untuk mengambil banyak key sekaligus; sangat mengurangi latency untuk halaman yang membutuhkan banyak data dari cache.adduntuk mutex sederhana —addhanya berhasil jika key belum ada; gunakan untuk memastikan hanya satu proses yang melakukan inisialisasi nilai cache.- CAS untuk update atomic tanpa konflik —
cache.cas("kunci") { |nilai| nilai + 1 }memastikan update atomic meski ada banyak proses bersamaan; retry jika konflik.- Kompresi
compress: truewajib untuk data besar — Memcached punya batas 1MB per item; kompresi otomatis Dalli mengurangi ukuran dan menghemat bandwidth.- Namespace versioning sebagai pengganti
flush_all— update satu key versi namespace → semua cache lama tidak diakses lagi tanpa harus delete satu per satu.- Monitor eviction rate — eviction berarti Memcached tidak punya cukup memori; tambah memori atau kurangi TTL untuk data yang kurang penting.
- Session store yang tepat — Memcached sangat cocok untuk session store karena session tidak perlu persistent; gunakan
dalli_storesebagai session adapter Rails.- Memcached untuk pure cache, Redis untuk segalanya — jika sudah menggunakan Redis untuk Sidekiq atau ActionCable, gunakan Redis untuk cache juga agar tidak perlu maintain dua sistem.