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

Koneksi MySQL & ukuran pool

Di sinilah kekhawatiran temanmu soal koneksi database sebenarnya berada — dan jawabannya bukan API, melainkan aritmetika sederhana yang jarang dilakukan orang.

Intisari

  • Satu Pool, satu instance Kysely, untuk seluruh proses. Jangan pernah membuatnya per request.
  • Rumusnya: connectionLimit × jumlah task maksimum harus di bawah max_connections RDS.
  • Autoscaling yang tidak memperhitungkan ini akan menjatuhkan database tepat saat trafik memuncak.
  • connectionLimit besar tidak membuat kueri lebih cepat — MySQL tetap memproses berurutan per koneksi.
  • Timeout wajib ada di tiap lapisan, dan harus mengecil ke dalam: Cloudflare > ALB > Astro > kueri.

Satu berkas, satu pool

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

const pool = createPool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT ?? 3306),
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,

  // Ukuran pool — lihat hitungannya di bawah
  connectionLimit: 10,
  queueLimit: 0,

  // Batas waktu
  connectTimeout: 5_000,

  // Skema CI3 sering menyimpan waktu sebagai DATETIME tanpa zona waktu.
  timezone: "Z",
  dateStrings: false,

  // Wajib untuk RDS: sertifikat AWS, verifikasi penuh
  ssl: { rejectUnauthorized: true },

  // Angka besar dari kolom BIGINT jangan diam-diam kehilangan presisi
  supportBigNumbers: true,
  bigNumberStrings: false,
});

export const db = new Kysely<DB>({
  dialect: new MysqlDialect({ pool }),
});

createPool dari mysql2, bukan mysql2/promise. MysqlDialect milik Kysely mengharapkan pool gaya callback dan membungkusnya sendiri. Memberikan versi promise menghasilkan error yang membingungkan saat kueri pertama, bukan saat impor — jadi terlihat seperti masalah kueri, padahal masalah konfigurasi.

Aritmetika yang menentukan

Inilah yang sebenarnya dikhawatirkan temanmu. Tiap proses Node punya pool sendiri. Jumlah koneksi total ke MySQL adalah:

total koneksi = connectionLimit × jumlah task ECS (MAKSIMUM, bukan yang sekarang)
              + koneksi dari sistem lain (CI3 lama, cron, admin, BI)
              + cadangan untuk DBA

Untuk RDS MySQL 8.0, max_connections default diturunkan dari memori instance. Contoh nyata:

Kelas instanceRAMmax_connections default (kira-kira)
db.t4g.medium4 GB~340
db.m6g.large8 GB~680
db.m6g.xlarge16 GB~1.365
db.r6g.2xlarge64 GB~5.000 (dibatasi ke 5.000)

Contoh perhitungan untuk portalmu di db.m6g.large (~680):

Astro:  connectionLimit 10 × maks 12 task  = 120
CI3 lama selama masa transisi (Fase 9)     = 150   ← jangan lupa ini
Cron & worker                              =  20
Admin, BI, koneksi manual                  =  30
                                             ────
                                             320   dari 680. Aman.

Kalau autoscaling dinaikkan ke 40 task:
Astro:  10 × 40                            = 400
+ sisanya                                  = 200
                                             ────
                                             600   dari 680. Mulai berbahaya.

Ini kegagalan yang paling menyakitkan karena terjadi tepat saat kamu paling tidak mampu menanganinya. Trafik memuncak → autoscaling menambah task → tiap task membuka pool baru → max_connections habis → semua task gagal terhubung, termasuk yang lama. Situsmu mati total di puncak trafik, dan penyebabnya adalah mekanisme yang seharusnya menyelamatkannya. Batas atas autoscaling harus dihitung dari max_connections, bukan dari CPU saja.

Kenapa connectionLimit besar tidak membantu

Godaannya menyetel connectionLimit: 100 supaya "tidak pernah kehabisan". Itu salah paham tentang apa yang dilakukan pool.

Koneksi database bukan pekerja — ia saluran. MySQL memproses kueri di saluran itu satu per satu. Kalau database-mu sanggup melayani 200 kueri per detik dan tiap kueri makan 5 ms, sepuluh koneksi sudah cukup untuk menjenuhkannya. Koneksi ke-sebelas tidak menambah kecepatan; ia hanya membuat antrean berpindah dari aplikasimu ke database — tempat yang jauh lebih buruk untuk mengantre, karena di sana ia memakan memori dan membuat semua kueri melambat bersamaan.

GejalaArtinyaTindakan
Kueri menunggu pool, database santaiconnectionLimit terlalu kecilNaikkan sedikit, ukur lagi
Kueri lambat, database sibukKuerinya yang lambatEXPLAIN, perbaiki index
Too many connectionsTotal pool melebihi max_connectionsTurunkan limit atau batas autoscaling
Latensi p95 melonjak saat scale-outTask baru membuka koneksi bersamaanPerlambat laju scaling

Titik awal yang masuk akal untuk portalmu: connectionLimit: 10 per task. Ukur waktu tunggu pool sebelum mengubahnya.

Timeout yang mengecil ke dalam

Cloudflare proxy      100 detik  (batas platform)
  └─ ALB idle timeout  60 detik
      └─ Astro/Node    30 detik
          └─ kueri     10 detik   ← paling ketat

Kalau lapisan dalam lebih longgar dari lapisan luar, klien menyerah lebih dulu sementara kuerinya tetap berjalan dan tetap memegang koneksi. Pembaca melihat error; database-mu tetap bekerja untuk tidak ada siapa-siapa. Kalikan dengan trafik puncak dan pool-mu habis oleh pekerjaan yang tidak ada penontonnya.

// Batas waktu per kueri, memakai AbortSignal
export async function ambilArtikelPublik(slug: string) {
  return db
    .selectFrom("artikel")
    .selectAll()
    .where("slug", "=", slug)
    .where("status", "=", "terbit")
    .executeTakeFirst();
}

MySQL 8.0 punya pagar tingkat server yang layak dipasang: SET SESSION MAX_EXECUTION_TIME = 10000; membatalkan SELECT yang lewat 10 detik di sisi database. Pasang ini di event connection pool-mu supaya berlaku untuk semua kueri baca. Ia hanya berlaku untuk SELECT, tapi di portal berita itu 95% bebanmu.

pool.on("connection", (conn) => {
  conn.query("SET SESSION MAX_EXECUTION_TIME = 10000");
  conn.query("SET SESSION time_zone = '+00:00'");
});

Menutup pool saat container dimatikan

// src/lib/db.ts
async function tutup() {
  await db.destroy();
}

process.once("SIGTERM", tutup);
process.once("SIGINT", tutup);

ECS mengirim SIGTERM lalu menunggu sebelum SIGKILL. Tanpa penutupan yang rapi, koneksi ditinggalkan menggantung dan MySQL butuh waktu untuk membersihkannya — yang berarti hitungan koneksimu meleset persis saat deploy, ketika task lama dan baru hidup bersamaan.

Verifikasi, jangan asumsi

-- Berapa koneksi sekarang, dan dari mana
SELECT user, host, COUNT(*) AS jumlah
FROM information_schema.processlist
GROUP BY user, host
ORDER BY jumlah DESC;

-- Batas dan puncak yang pernah tercapai
SHOW VARIABLES LIKE 'max_connections';
SHOW STATUS LIKE 'Max_used_connections';

Max_used_connections adalah angka yang paling jujur: ia mencatat puncak sejak server terakhir restart. Kalau ia sudah mendekati max_connections, kamu sudah pernah nyaris tumbang tanpa menyadarinya.

Latihan: hitung anggaran koneksimu di atas kertas: max_connections RDS-mu yang sebenarnya, dikurangi koneksi CI3 saat ini (jalankan kueri processlist di atas untuk angka nyatanya), dibagi jumlah task ECS maksimum yang kamu rencanakan. Tetapkan connectionLimit dari hasilnya. Lalu buat src/lib/db.ts, hubungkan ke salinan database-mu, dan jalankan satu SELECT 1 dari halaman Astro.

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