Memcached

Memcached #

Memcached adalah in-memory cache yang sangat sederhana dan sangat cepat — dirancang dengan satu tujuan: menjadi cache key-value yang efisien untuk skala besar. Berbeda dari Redis yang kaya fitur dengan berbagai tipe data, Memcached hanya mendukung string (maksimal 1MB per nilai) dengan TTL. Kesederhanaan ini adalah kekuatannya: Memcached bisa discale secara horizontal dengan sangat mudah — tambahkan node baru dan client otomatis mendistribusikan data via consistent hashing tanpa perlu koordinasi antar node. Tidak ada package Dart yang matang untuk Memcached, sehingga artikel ini membangun client dari dasar menggunakan Socket — sekaligus menjelaskan cara kerja protokol Memcached yang berbasis teks ASCII.

Memcached vs Redis — Kapan Memilih Mana? #

flowchart TD
    A{Kebutuhan caching?} --> B{Butuh tipe data\nkompleks?}
    B -- Ya\nHash/List/Set/SortedSet --> C[Redis]
    B -- Tidak\nhanya key-value string --> D{Butuh persistensi\ndata?}
    D -- Ya\nsurvive restart --> C
    D -- Tidak\ndata boleh hilang --> E{Butuh horizontal\nscaling linear?}
    E -- Ya\nbanyak node cache --> F[Memcached]
    E -- Tidak\nsatu/beberapa node --> G{Butuh pub/sub\natau scripting?}
    G -- Ya --> C
    G -- Tidak --> H[Redis atau Memcached\nsama-sama oke]
Aspek Memcached Redis
Tipe data String saja String, Hash, List, Set, Sorted Set, Stream
Persistensi ✗ tidak ada ✓ RDB dan AOF
Pub/Sub
Scripting ✓ Lua
Clustering Linear scaling, shared nothing Cluster mode dengan koordinasi
Memory efficiency Lebih efisien untuk string Sedikit lebih boros
Multi-threading ✓ (multi-core) Single-threaded (I/O multiplex)
Batas nilai Max 1MB per item Sampai 512MB
Cocok untuk Cache murni, skalabilitas tinggi Cache + fitur tambahan

Protokol Memcached — ASCII Text Protocol #

Memcached menggunakan protokol teks ASCII yang sangat sederhana — perintah dikirim sebagai string teks melalui TCP:

SET:
  Request:  set <key> <flags> <exptime> <bytes>\r\n<value>\r\n
  Response: STORED\r\n

GET:
  Request:  get <key>\r\n
  Response: VALUE <key> <flags> <bytes>\r\n<value>\r\n
            END\r\n

DELETE:
  Request:  delete <key>\r\n
  Response: DELETED\r\n  atau  NOT_FOUND\r\n

Client Memcached dari Scratch #

Karena tidak ada package Dart yang matang, kita bangun client sederhana menggunakan dart:io Socket:

import 'dart:io';
import 'dart:convert';

class MemcachedClient {
  final String host;
  final int port;
  Socket? _socket;
  final _buffer = StringBuffer();

  MemcachedClient({this.host = 'localhost', this.port = 11211});

  Future<void> hubungkan() async {
    _socket = await Socket.connect(host, port);
    _socket!.encoding = utf8;
    print('Terhubung ke Memcached $host:$port');
  }

  Future<void> tutup() async {
    await _socket?.close();
    _socket = null;
  }

  // Kirim perintah dan tunggu respons
  Future<String> _kirimPerintah(String perintah) async {
    if (_socket == null) throw StateError('Belum terhubung ke Memcached');

    _socket!.write(perintah);
    await _socket!.flush();

    // Baca respons — tunggu hingga dapat baris lengkap
    final completer = Completer<String>();
    StreamSubscription<List<int>>? sub;

    sub = _socket!.listen((data) {
      final teks = utf8.decode(data);
      _buffer.write(teks);

      final respons = _buffer.toString();
      // Cek apakah respons sudah lengkap
      if (_responLengkap(respons)) {
        sub?.cancel();
        _buffer.clear();
        completer.complete(respons.trim());
      }
    });

    return completer.future.timeout(
      Duration(seconds: 5),
      onTimeout: () => throw TimeoutException('Memcached timeout'),
    );
  }

  bool _responLengkap(String respons) {
    // Respons lengkap jika diakhiri dengan END, STORED, DELETED, dll.
    return respons.endsWith('END\r\n') ||
        respons.endsWith('STORED\r\n') ||
        respons.endsWith('NOT_STORED\r\n') ||
        respons.endsWith('DELETED\r\n') ||
        respons.endsWith('NOT_FOUND\r\n') ||
        respons.endsWith('ERROR\r\n') ||
        respons.endsWith('EXISTS\r\n');
  }

  // SET — simpan nilai dengan TTL
  // exptime: 0 = tidak expire, >0 = detik, >2592000 = Unix timestamp
  Future<bool> set(String key, String value, {int exptime = 0, int flags = 0}) async {
    final bytes = utf8.encode(value).length;
    final perintah = 'set $key $flags $exptime $bytes\r\n$value\r\n';
    final respons = await _kirimPerintah(perintah);
    return respons == 'STORED';
  }

  // GET — ambil nilai
  Future<String?> get(String key) async {
    final respons = await _kirimPerintah('get $key\r\n');

    if (respons.startsWith('END')) return null;  // tidak ada

    // Parse respons: VALUE <key> <flags> <bytes>\r\n<value>\r\nEND
    final baris = respons.split('\r\n');
    if (baris.length < 3) return null;

    return baris[1];  // baris kedua adalah nilai
  }

  // GETS — GET dengan CAS token (untuk check-and-set)
  Future<({String? value, String? casToken})> gets(String key) async {
    final respons = await _kirimPerintah('gets $key\r\n');

    if (respons.startsWith('END')) return (value: null, casToken: null);

    final baris = respons.split('\r\n');
    final header = baris[0].split(' '); // VALUE <key> <flags> <bytes> <cas>
    return (
      value: baris[1],
      casToken: header.length > 4 ? header[4] : null,
    );
  }

  // DELETE — hapus kunci
  Future<bool> delete(String key) async {
    final respons = await _kirimPerintah('delete $key\r\n');
    return respons == 'DELETED';
  }

  // ADD — simpan hanya jika belum ada
  Future<bool> add(String key, String value, {int exptime = 0}) async {
    final bytes = utf8.encode(value).length;
    final respons = await _kirimPerintah(
      'add $key 0 $exptime $bytes\r\n$value\r\n',
    );
    return respons == 'STORED';
  }

  // REPLACE — update hanya jika sudah ada
  Future<bool> replace(String key, String value, {int exptime = 0}) async {
    final bytes = utf8.encode(value).length;
    final respons = await _kirimPerintah(
      'replace $key 0 $exptime $bytes\r\n$value\r\n',
    );
    return respons == 'STORED';
  }

  // CAS — Check And Set (update hanya jika CAS token masih valid)
  Future<bool> cas(String key, String value, String casToken, {int exptime = 0}) async {
    final bytes = utf8.encode(value).length;
    final respons = await _kirimPerintah(
      'cas $key 0 $exptime $bytes $casToken\r\n$value\r\n',
    );
    return respons == 'STORED';
    // STORED = berhasil update
    // EXISTS = ada yang mengubah sebelum kita (conflict)
    // NOT_FOUND = kunci tidak ada lagi
  }

  // INCR / DECR — increment/decrement nilai numerik
  Future<int?> incr(String key, int delta) async {
    final respons = await _kirimPerintah('incr $key $delta\r\n');
    if (respons == 'NOT_FOUND') return null;
    return int.tryParse(respons);
  }

  Future<int?> decr(String key, int delta) async {
    final respons = await _kirimPerintah('decr $key $delta\r\n');
    if (respons == 'NOT_FOUND') return null;
    return int.tryParse(respons);
  }

  // STATS — info server Memcached
  Future<Map<String, String>> stats() async {
    final respons = await _kirimPerintah('stats\r\n');
    final map = <String, String>{};
    for (final baris in respons.split('\r\n')) {
      if (baris.startsWith('STAT ')) {
        final bagian = baris.split(' ');
        if (bagian.length >= 3) map[bagian[1]] = bagian[2];
      }
    }
    return map;
  }

  // FLUSH_ALL — hapus semua data (hati-hati di production!)
  Future<void> flushAll({int delay = 0}) async {
    await _kirimPerintah('flush_all $delay\r\n');
  }
}

Penggunaan Client #

import 'dart:convert';

Future<void> main() async {
  final client = MemcachedClient(host: 'localhost', port: 11211);
  await client.hubungkan();

  try {
    // SET dan GET dasar
    await client.set('greeting', 'Halo, Memcached!', exptime: 300);
    final nilai = await client.get('greeting');
    print(nilai); // 'Halo, Memcached!'

    // SET objek JSON
    final produk = {'id': 'P001', 'nama': 'Laptop', 'harga': 15000000};
    await client.set(
      'produk:P001',
      jsonEncode(produk),
      exptime: 3600,  // 1 jam
    );

    final cached = await client.get('produk:P001');
    if (cached != null) {
      final data = jsonDecode(cached) as Map<String, dynamic>;
      print('Produk: ${data['nama']}');
    }

    // ADD — hanya jika belum ada
    final sukses = await client.add('kunci_baru', 'nilai', exptime: 60);
    print('Add berhasil: $sukses');

    // INCR untuk counter
    await client.set('view_count', '0', exptime: 86400);
    final count1 = await client.incr('view_count', 1);
    final count2 = await client.incr('view_count', 1);
    print('View count: $count2'); // 2

    // DELETE
    final dihapus = await client.delete('greeting');
    print('Terhapus: $dihapus');

    // STATS server
    final info = await client.stats();
    print('Bytes tersedia: ${info['bytes']}');
    print('Items saat ini: ${info['curr_items']}');
    print('Total hit: ${info['get_hits']}');
    print('Total miss: ${info['get_misses']}');

  } finally {
    await client.tutup();
  }
}

Check-and-Set (CAS) — Operasi Atomic #

CAS memungkinkan update yang aman dalam lingkungan konkurensi — update hanya berhasil jika tidak ada yang mengubah nilai sejak kita membacanya:

// Skenario: dua proses mencoba update counter saldo yang sama
Future<bool> updateSaldoAman(
  MemcachedClient client,
  String kunci,
  double delta,
) async {
  const maxRetry = 5;

  for (int percobaan = 0; percobaan < maxRetry; percobaan++) {
    // 1. Baca nilai saat ini beserta CAS token
    final (:value, :casToken) = await client.gets(kunci);

    if (value == null || casToken == null) {
      // Kunci tidak ada — buat baru
      final nilaiAwal = delta.toString();
      return await client.add(kunci, nilaiAwal);
    }

    // 2. Hitung nilai baru
    final saldoLama = double.tryParse(value) ?? 0;
    final saldoBaru = (saldoLama + delta).toStringAsFixed(2);

    // 3. Update dengan CAS — hanya berhasil jika token masih valid
    final berhasil = await client.cas(kunci, saldoBaru, casToken);

    if (berhasil) {
      print('Saldo berhasil diupdate: $saldoLama$saldoBaru');
      return true;
    }

    // Token tidak valid — ada yang mengubah lebih dulu, coba lagi
    print('Konflik CAS, percobaan ${percobaan + 1}/$maxRetry');
    await Future.delayed(Duration(milliseconds: 10 * (percobaan + 1)));
  }

  throw StateError('Gagal update setelah $maxRetry percobaan');
}

Consistent Hashing untuk Cluster Memcached #

Memcached tidak memiliki built-in clustering — client bertanggung jawab mendistribusikan kunci ke node yang tepat menggunakan consistent hashing:

import 'dart:convert';

class MemcachedCluster {
  final List<MemcachedClient> _nodes;
  final List<_VirtualNode> _ring = [];
  static const int _virtualNodesPerServer = 150;

  MemcachedCluster(List<String> servers)
      : _nodes = servers.map((s) {
          final parts = s.split(':');
          return MemcachedClient(
            host: parts[0],
            port: int.parse(parts[1]),
          );
        }).toList() {
    _bangunRing();
  }

  void _bangunRing() {
    for (int i = 0; i < _nodes.length; i++) {
      for (int v = 0; v < _virtualNodesPerServer; v++) {
        final kunci = 'node$i:virtual$v';
        final hash = _hash(kunci);
        _ring.add(_VirtualNode(hash: hash, nodeIndex: i));
      }
    }
    _ring.sort((a, b) => a.hash.compareTo(b.hash));
  }

  // Consistent hash — MD5-like hash sederhana
  int _hash(String kunci) {
    int hash = 0;
    for (final char in kunci.codeUnits) {
      hash = (hash * 31 + char) & 0x7fffffff;
    }
    return hash;
  }

  // Tentukan node berdasarkan kunci (consistent hashing)
  MemcachedClient _pilihNode(String kunci) {
    if (_nodes.length == 1) return _nodes[0];

    final hash = _hash(kunci);

    // Cari virtual node pertama yang hashnya >= hash kunci
    for (final vNode in _ring) {
      if (vNode.hash >= hash) {
        return _nodes[vNode.nodeIndex];
      }
    }

    // Wrap-around: gunakan node pertama di ring
    return _nodes[_ring.first.nodeIndex];
  }

  Future<void> inisialisasi() async {
    for (final node in _nodes) {
      await node.hubungkan();
    }
    print('Cluster Memcached: ${_nodes.length} node terhubung');
  }

  // Operasi otomatis ke node yang tepat
  Future<bool> set(String key, String value, {int exptime = 0}) {
    return _pilihNode(key).set(key, value, exptime: exptime);
  }

  Future<String?> get(String key) {
    return _pilihNode(key).get(key);
  }

  Future<bool> delete(String key) {
    return _pilihNode(key).delete(key);
  }

  Future<void> tutup() async {
    for (final node in _nodes) await node.tutup();
  }
}

class _VirtualNode {
  final int hash;
  final int nodeIndex;
  const _VirtualNode({required this.hash, required this.nodeIndex});
}

// Penggunaan cluster
Future<void> main() async {
  final cluster = MemcachedCluster([
    'memcached-1:11211',
    'memcached-2:11211',
    'memcached-3:11211',
  ]);

  await cluster.inisialisasi();

  // Distribusi otomatis ke node berdasarkan kunci
  await cluster.set('user:1001', '{"nama": "Budi"}', exptime: 3600);
  await cluster.set('user:1002', '{"nama": "Siti"}', exptime: 3600);

  final user = await cluster.get('user:1001');
  print(user);

  await cluster.tutup();
}

Pola Caching dengan Memcached #

Cache-Aside Pattern #

Future<Map<String, dynamic>?> ambilProduk(
  MemcachedClient mc,
  String id,
  Future<Map<String, dynamic>?> Function(String) dariDB,
) async {
  final kunci = 'produk:$id';

  // 1. Cek cache
  final cached = await mc.get(kunci);
  if (cached != null) {
    return jsonDecode(cached) as Map<String, dynamic>;
  }

  // 2. Ambil dari database
  final produk = await dariDB(id);
  if (produk == null) {
    // Cache negatif — cegah thundering herd ke database
    // Simpan penanda "tidak ada" selama 60 detik
    await mc.set(kunci, '__not_found__', exptime: 60);
    return null;
  }

  // 3. Simpan ke cache
  await mc.set(kunci, jsonEncode(produk), exptime: 3600);
  return produk;
}

Cache Stampede Prevention #

// Thundering herd problem: banyak request bersamaan ke database
// saat cache expired

Future<Map<String, dynamic>?> ambilDenganLock(
  MemcachedClient mc,
  String id,
  Future<Map<String, dynamic>?> Function(String) dariDB,
) async {
  final kunci = 'produk:$id';
  final kunciLock = 'lock:produk:$id';

  // Cek cache
  final cached = await mc.get(kunci);
  if (cached != null && cached != '__loading__') {
    return jsonDecode(cached) as Map<String, dynamic>;
  }

  // Gunakan ADD sebagai lock — hanya satu yang berhasil
  final dapatLock = await mc.add(kunciLock, '1', exptime: 10);

  if (!dapatLock) {
    // Ada yang lain sedang load — tunggu sebentar dan coba lagi
    await Future.delayed(Duration(milliseconds: 100));
    return ambilDenganLock(mc, id, dariDB); // retry
  }

  try {
    // Kita yang load data
    final produk = await dariDB(id);
    if (produk != null) {
      await mc.set(kunci, jsonEncode(produk), exptime: 3600);
    }
    return produk;
  } finally {
    await mc.delete(kunciLock);
  }
}

Anti-Pattern Memcached #

Menyimpan Nilai Lebih dari 1MB #

// ANTI-PATTERN: Memcached menolak nilai > 1MB (default)
final dataBesar = List.generate(100000, (i) => {'id': i, 'data': 'xxx'});
await client.set('data_besar', jsonEncode(dataBesar));
// ✗ SERVER_ERROR object too large for cache

// BENAR: pecah menjadi chunk atau gunakan Redis yang lebih fleksibel
final chunks = <String>[];
final json = jsonEncode(dataBesar);
for (int i = 0; i < json.length; i += 900000) {
  chunks.add(json.substring(i, (i + 900000).clamp(0, json.length)));
}

// Simpan jumlah chunk dan setiap chunk
await client.set('data:chunks', chunks.length.toString());
for (int i = 0; i < chunks.length; i++) {
  await client.set('data:chunk:$i', chunks[i]);
}

Key yang Terlalu Panjang #

// ANTI-PATTERN: key > 250 karakter tidak didukung Memcached
final keyPanjang = 'produk:kategori:elektronik:laptop:gaming:asus:rog:2024';
// Mungkin masih oke, tapi...
final keyTerlalu = 'a' * 300; // ✗ error: key terlalu panjang

// BENAR: hash key yang panjang menjadi key yang pendek
import 'dart:convert';
String hashKey(String panjang) {
  // MD5 atau SHA256 untuk key yang konsisten dan pendek
  final bytes = utf8.encode(panjang);
  // Gunakan package crypto untuk hash yang proper
  return bytes.fold(0, (a, b) => a ^ b).toRadixString(16).padLeft(8, '0');
}

Tidak Menangani Cache Miss dengan Baik #

// ANTI-PATTERN: asumsi cache selalu ada
final nilai = await client.get('produk:P001');
final harga = jsonDecode(nilai!)['harga']; // ✗ crash jika cache miss (null)!

// BENAR: selalu tangani cache miss
final nilai = await client.get('produk:P001');
if (nilai == null) {
  // Cache miss — ambil dari database
  final produk = await database.ambilProduk('P001');
  if (produk != null) {
    await client.set('produk:P001', jsonEncode(produk), exptime: 3600);
  }
  return produk;
}
return jsonDecode(nilai) as Map<String, dynamic>;

Ringkasan #

  • Memcached adalah cache murni — tidak ada persistensi, pub/sub, atau tipe data kompleks. Pilih Memcached jika hanya butuh caching string dengan TTL dan skalabilitas horizontal yang mudah.
  • Protokol ASCII text — perintah dikirim sebagai string teks melalui TCP: set key flags exptime bytes\r\nvalue\r\n. Ini memudahkan debugging dengan telnet localhost 11211.
  • Maksimal 1MB per nilai — untuk data yang lebih besar, pecah menjadi beberapa chunk atau gunakan Redis yang tidak memiliki batasan ini secara default.
  • Maksimal 250 karakter per kunci — hash kunci yang panjang menggunakan MD5 atau SHA256 untuk menjaga kunci tetap pendek dan valid.
  • CAS (Check-and-Set) memungkinkan update concurrent yang aman — gets mendapat nilai beserta token, cas memastikan update hanya berhasil jika tidak ada yang mengubah di antara read dan write.
  • Consistent hashing di sisi client mendistribusikan kunci ke node yang tepat tanpa koordinasi antar node — tambah atau hapus node hanya memindahkan sebagian kunci, tidak seluruhnya.
  • ADD sebagai distributed lock — ADD hanya berhasil jika kunci belum ada, menjadikannya primitive yang berguna untuk lock dan pencegahan thundering herd.
  • Cache negatif — simpan penanda “tidak ditemukan” (misalnya __not_found__) dengan TTL pendek untuk mencegah terus-menerus query ke database untuk data yang memang tidak ada.
  • STATS memberikan metrik operasional penting: get_hits, get_misses, evictions, curr_items, bytes — monitor ini untuk menentukan ukuran memory yang tepat dan hit rate.
  • Untuk fitur beyond caching (pub/sub, session dengan struktur, rate limiting kompleks, sorted set) — pilih Redis. Untuk cache murni yang perlu scale banyak node, Memcached tetap pilihan yang solid.

← Sebelumnya: Redis   Berikutnya: Flutter →

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