Menjalankan Nuxt 3 di lingkungan multi-instance (seperti Kubernetes Pods atau PM2 cluster) sering memicu masalah stale data jika server menggunakan caching bertingkat. Nuxt 3 memanfaatkan unstorage bawaan Nitro engine untuk mengelola caching. Ketika menggunakan driver in-memory (L1) untuk kecepatan akses lokal dan Redis (L2) untuk penyimpanan terdistribusi, mutasi data pada Node A hanya membersihkan cache lokal Node A. Node B dan Node C tetap menyajikan data usang sampai masa berlaku (TTL) berakhir.

Akar Masalah: Asinkronitas Cache Multi-Instance

Pola caching multi-tier membagi penyimpanan menjadi dua lapisan:

  • L1 (In-Memory): Menggunakan memori proses Node.js lokal via driver memory. Latensi <1ms, tetapi terisolasi per instance.
  • L2 (Distributed Cache): Menggunakan Redis terpusat via driver redis. Latensi 2-10ms, konsisten antar instance, tetapi membebani I/O jaringan jika diakses pada setiap request.

Ketika mutasi data terjadi melalui request ke Node A, Node A mengeksekusi useStorage().removeItem(). Operasi ini menghapus cache L1 lokal Node A dan cache L2 Redis. Namun, Node B dan instance lain tidak mengetahui perubahan tersebut. Selama L1 pada Node B masih valid, client yang diarahkan oleh load balancer ke Node B akan terus menerima data usang.

Arsitektur Solusi: Invalidasi Terdistribusi via Redis Pub/Sub

Solusi deterministik untuk masalah ini adalah pola Cache Invalidation Broadcast. Setiap instance Nuxt 3 menjalankan subscriber Redis yang mendengarkan channel invalidasi khusus. Saat mutasi data terjadi pada sembarang node:

  1. Node pengeksekusi memperbarui database utama.
  2. Node pengeksekusi menghapus kunci terkait di storage L2 (Redis).
  3. Node pengeksekusi mempublikasikan pesan purge berisi nama kunci ke channel Redis Pub/Sub.
  4. Semua instance yang berlangganan (termasuk instance lain) menerima pesan tersebut dan mengeksekusi pembersihan L1 lokal secara bersamaan.

Implementasi Teknis

Berikut adalah implementasi menggunakan ioredis dan Nitro plugin pada Nuxt 3.

1. Konfigurasi Client Redis dan Pub/Sub

Pisahkan koneksi Redis biasa dengan koneksi subscriber, karena client yang masuk ke mode subscriber tidak dapat menjalankan perintah data standar.

// server/utils/redis.ts
import Redis from 'ioredis';

const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';

// Client untuk operasi reguler / publish
export const redisPublisher = new Redis(redisUrl, {
  maxRetriesPerRequest: 3,
  retryStrategy(times) {
    return Math.min(times * 200, 2000);
  },
});

// Client terdedikasi untuk subscription (blokir mode pub/sub)
export const redisSubscriber = new Redis(redisUrl, {
  retryStrategy(times) {
    return Math.min(times * 200, 2000);
  },
});

export const CACHE_INVALIDATION_CHANNEL = 'nuxt:cache:invalidate';

2. Nitro Plugin Subscriber

Daftarkan plugin Nitro untuk mendengarkan broadcast saat server instance booting. Plugin ini langsung mengeksekusi eviksi L1 di unstorage.

// server/plugins/cache-sync.ts
import { defineNitroPlugin } from 'nitropack/runtime';
import { redisSubscriber, CACHE_INVALIDATION_CHANNEL } from '../utils/redis';

export default defineNitroPlugin(async (nitroApp) => {
  const storage = useStorage('memory'); // Mount point L1

  try {
    await redisSubscriber.subscribe(CACHE_INVALIDATION_CHANNEL);
    console.info(`[CacheSync] Subscribed to ${CACHE_INVALIDATION_CHANNEL}`);

    redisSubscriber.on('message', async (channel, message) => {
      if (channel !== CACHE_INVALIDATION_CHANNEL) return;

      try {
        const payload = JSON.parse(message);
        if (payload.action === 'purge' && payload.key) {
          // Hapus key dari storage in-memory lokal
          await storage.removeItem(payload.key);
          console.debug(`[CacheSync] L1 evicted locally for key: ${payload.key}`);
        }
      } catch (err) {
        console.error('[CacheSync] Failed to process invalidate message:', err);
      }
    });

    redisSubscriber.on('error', (err) => {
      console.error('[CacheSync] Redis Subscriber connection error:', err);
    });
  } catch (err) {
    console.error('[CacheSync] Failed to initialize Redis subscription:', err);
  }

  nitroApp.hooks.hook('close', async () => {
    await redisSubscriber.unsubscribe(CACHE_INVALIDATION_CHANNEL);
    await redisSubscriber.quit();
  });
});

3. Server Handler Mutasi dan Broadcast Purge

Pada route handler mutasi data (misalnya API update produk), kirimkan sinyal invalidasi setelah pembaruan data berhasil disimpan.

// server/api/products/[id].put.ts
import { defineEventHandler, readBody } from 'h3';
import { redisPublisher, CACHE_INVALIDATION_CHANNEL } from '~/server/utils/redis';

export default defineEventHandler(async (event) => {
  const id = event.context.params?.id;
  const body = await readBody(event);
  const cacheKey = `product:${id}`;

  // 1. Mutasi ke Database utama
  // await db.products.update(id, body);

  // 2. Hapus L1 lokal pengeksekusi dan L2 terpusat
  const storage = useStorage();
  await storage.removeItem(`memory:${cacheKey}`);
  await storage.removeItem(`redis:${cacheKey}`);

  // 3. Broadcast ke seluruh instance lain via Pub/Sub
  const message = JSON.stringify({
    action: 'purge',
    key: cacheKey,
    sourceNode: process.env.HOSTNAME || 'unknown',
  });

  await redisPublisher.publish(CACHE_INVALIDATION_CHANNEL, message);

  return { success: true, id };
});

Mitigasi Fallback TTL dan Eventual Consistency

Redis Pub/Sub bekerja dengan skema fire-and-forget. Pesan tidak di-buffer. Jika sebuah node mengalami gangguan koneksi jaringan sesaat ketika pesan dipublikasikan, node tersebut akan melewatkan instruksi eviksi.

Untuk mengatasi keterbatasan ini:

  • Terapkan TTL Pendek pada L1: Selalu batasi masa aktif L1 in-memory secara agresif (misalnya 30 hingga 60 detik). Jika pesan invalidasi terlewat akibat paket hilang, durasi inkonsistensi data tidak akan melebihi sisa waktu TTL.
  • Reconnection Resubscribe: ioredis secara otomatis berlangganan ulang ke channel terdaftar saat koneksi pulih setelah terputus (fitur default autoResubscribe: true).
  • Hindari Invalidation Storm: Jika melakukan operasi batch (contoh: import massal), jangan mempublikasikan ratusan pesan per item. Buat protokol invalidasi berbasis prefix (misalnya key product:*) untuk membersihkan seluruh namespace terkait dalam satu payload.

Catatan Produksi: Jika aplikasi memiliki tingkat mutasi yang sangat masif dan membutuhkan garansi 100% konsistensi tanpa celah paket hilang, pertimbangkan beralih dari Redis Pub/Sub murni ke Redis Streams dengan Consumer Groups, atau hilangkan lapisan L1 memory dan gunakan koneksi L2 Redis teroptimasi via connection pooling.