← Semua pembelajaran / Astro Nol → Portal Berita
Fase 2 · MySQL dengan Kysely

Read replica — memisahkan baca dari tulis

Portalmu 95% membaca. Read replica adalah cara termurah menambah kapasitas baca — asalkan kamu tahu kueri mana yang tidak boleh ke sana.

Intisari

  • Replica RDS MySQL bersifat asinkron. Ia tertinggal beberapa milidetik sampai beberapa detik.
  • Konsekuensinya: membaca dari replica tepat setelah menulis bisa tidak menemukan yang baru saja kamu tulis.
  • Aturan: setelah operasi tulis dalam satu alur pengguna, baca dari writer sampai alur itu selesai.
  • Dua pool terpisah: satu ke endpoint writer, satu ke endpoint reader. Dua instance Kysely.
  • Pantau ReplicaLag. Lag yang melonjak biasanya berarti ada transaksi tulis besar di writer.

Bentuknya di RDS

                ┌──── tulis ────►  portal.abc.rds.amazonaws.com   (writer)
Astro (ECS)  ───┤                        │ replikasi asinkron
                └──── baca ─────►  portal-ro.abc.rds.amazonaws.com (reader)

Replica RDS MySQL adalah instance terpisah yang memutar ulang binlog dari writer. Ia punya endpoint sendiri, kelas instance sendiri, dan biaya sendiri. Kamu bisa punya lebih dari satu, dan menaruhnya di Availability Zone berbeda.

Ini bukan failover. Read replica adalah untuk kapasitas baca, bukan ketersediaan. Untuk failover otomatis kamu butuh Multi-AZ, yang merupakan fitur berbeda dan bisa dipakai bersamaan. Banyak orang memasang replica lalu mengira mereka sudah punya HA — tidak.

Dua pool, dua instance Kysely

// src/lib/db.ts
import { Kysely, MysqlDialect } from "kysely";
import { createPool, type PoolOptions } from "mysql2";
import type { DB } from "./db-types";

const dasar: PoolOptions = {
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  connectTimeout: 5_000,
  timezone: "Z",
  ssl: { rejectUnauthorized: true },
};

function buat(host: string, limit: number) {
  const pool = createPool({ ...dasar, host, connectionLimit: limit });
  pool.on("connection", (c) => {
    c.query("SET SESSION MAX_EXECUTION_TIME = 10000");
    c.query("SET SESSION time_zone = '+00:00'");
  });
  return new Kysely<DB>({ dialect: new MysqlDialect({ pool }) });
}

// Baca jauh lebih banyak, jadi pool-nya lebih besar.
export const dbBaca  = buat(process.env.DB_HOST_RO!, 12);
export const dbTulis = buat(process.env.DB_HOST_RW!, 4);

Ingat aritmetika koneksi dari materi sebelumnya — sekarang ada dua pool per task. 12 + 4 = 16 koneksi per task, bukan 10. Dengan 12 task itu 192 koneksi, tapi terbagi ke dua instance database: 144 ke reader, 48 ke writer. Hitung batas keduanya secara terpisah, karena max_connections-nya diturunkan dari memori masing-masing instance dan reader-mu boleh berkelas lebih kecil.

Kelas bug yang hanya muncul setelah replica ada

// Member baru saja berlangganan lewat Midtrans.
await dbTulis.updateTable("langganan")
  .set({ status: "aktif" })
  .where("id", "=", idLangganan)
  .execute();

// Redirect ke halaman artikel; server island membaca status langganan.
const member = await dbBaca
  .selectFrom("langganan")
  .select("status")
  .where("member_id", "=", memberId)
  .executeTakeFirst();

// status masih "menunggu" — replica belum menyusul.
// Member yang BARU SAJA MEMBAYAR melihat paywall.

Ini keluhan pelanggan yang paling merusak kepercayaan, dan paling sulit direproduksi: di laptopmu lag replica nol, di produksi ia 300 ms — cukup untuk kalah dari redirect browser.

Perbaikan: tandai alur yang baru saja menulis

// src/lib/db.ts
const KUNCI_SEGAR = "baca-writer";

export function tandaiBaruMenulis(cookies: AstroCookies) {
  cookies.set(KUNCI_SEGAR, "1", {
    maxAge: 15,            // cukup untuk melampaui lag normal
    httpOnly: true,
    sameSite: "lax",
    secure: true,
    path: "/",
  });
}

export function pilihDb(cookies: AstroCookies) {
  return cookies.has(KUNCI_SEGAR) ? dbTulis : dbBaca;
}
// Setelah pembayaran dikonfirmasi:
await aktifkanLangganan(idLangganan);
tandaiBaruMenulis(Astro.cookies);
return Astro.redirect("/akun?status=aktif");

Selama 15 detik berikutnya, pembaca itu membaca dari writer dan pasti melihat data terbarunya. Setelah cookie kedaluwarsa, ia kembali ke replica. Bebannya dapat diabaikan — hanya pembaca yang baru saja menulis yang terkena, dan mereka segelintir.

Alternatif yang lebih kuat, kalau kamu butuh: MySQL 8.0 punya GTID-based consistent reads — catat GTID setelah menulis, lalu suruh replica menunggu sampai ia mencapai GTID itu sebelum menjawab. Lebih presisi, tapi jauh lebih rumit dan menambah latensi ke pembacaan yang menunggu. Untuk portal berita, cookie 15 detik hampir selalu cukup.

Kueri mana yang ke mana

KueriKeAlasan
Daftar artikel, halaman artikel, kategoriReaderBasi beberapa detik tidak masalah
Data perusahaan, statistikReaderDiperbarui harian
PencarianReaderMahal, dan jangan mengganggu writer
Cek status langganan di paywallReader, kecuali baru menulisPola cookie di atas
Login & pembuatan sesiWriterMenulis, dan langsung dibaca sesudahnya
Webhook MidtransWriterMenulis, dan idempotensinya bergantung pada baca-tulis yang konsisten
Panel admin & redaksiWriterEditor harus melihat hasil suntingannya seketika

Memantau lag

-- Dari sisi replica
SHOW REPLICA STATUS\G
-- Seconds_Behind_Source: 0 itu sehat

Di CloudWatch, metrik yang dipantau adalah ReplicaLag. Pasang alarm di 5 detik. Lonjakan lag hampir selalu berarti salah satu dari tiga hal:

Sabuk pengaman yang layak dipasang: kalau ReplicaLag melewati ambang, alihkan sementara pembacaan ke writer. Lebih baik writer bekerja lebih keras selama beberapa menit daripada pembaca melihat artikel yang hilang dari daftar karena replica tertinggal. Bacalah metriknya sekali per menit, bukan per request.

Kapan replica belum perlu

Jujur: kalau cache Cloudflare-mu sudah bekerja seperti yang direncanakan di Fase 8, beban baca yang sampai ke database akan turun 5–6 kali lipat. Ada kemungkinan nyata kamu tidak butuh replica sama sekali setelah migrasi — dan menghemat biaya satu instance RDS penuh.

Urutan yang benar: perbaiki cache dulu, ukur, baru putuskan. Menambah replica untuk menutupi cache yang buruk adalah membayar bulanan untuk masalah yang bisa diselesaikan sekali.

Latihan: pisahkan src/lib/db.ts jadi dbBaca dan dbTulis, lalu telusuri seluruh fungsi di src/lib/ dan tandai tiap satu harus memakai yang mana. Buat daftarnya. Untuk yang jawabannya "tergantung", tuliskan kenapa — daftar itu adalah spesifikasi pola cookie segar untuk portalmu.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.