Memcached

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 #

FiturDallimemcached gemredis gem
ProtokolBinary (default) + ASCIIASCIIRESP
Thread safetyYaYaYa
CompressionBawaanManualBawaan
Connection poolBuilt-inTidakBuilt-in (v5+)
Rails integrationExcellentBaikExcellent
Aktif dikembangkanYaJarang updateYa
Multi-serverYa (consistent hash)YaYa (cluster)

Ringkasan #

  • **Dalli untuk semua kebutuhan Memcached ** — thread-safe, mendukung protokol binary yang lebih efisien, terintegrasi sempurna dengan Rails, dan di-maintain aktif.
  • namespace untuk isolasi environment — gunakan namespace: "#{Rails.env}:v1" agar cache development tidak bercampur dengan production, dan versioning untuk invalidasi massal.
  • get_multi lebih efisien dari loop get — kirim satu request untuk mengambil banyak key sekaligus; sangat mengurangi latency untuk halaman yang membutuhkan banyak data dari cache.
  • add untuk mutex sederhanaadd hanya berhasil jika key belum ada; gunakan untuk memastikan hanya satu proses yang melakukan inisialisasi nilai cache.
  • CAS untuk update atomic tanpa konflikcache.cas("kunci") { |nilai| nilai + 1 } memastikan update atomic meski ada banyak proses bersamaan; retry jika konflik.
  • Kompresi compress: true wajib 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_store sebagai 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.

← Sebelumnya: Redis   Berikutnya: Ruby on Rails →

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