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 dengantelnet 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 —
getsmendapat nilai beserta token,casmemastikan 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.
ADDsebagai 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.