Dilema Scaling Database: Scale-Up vs Scale-Out Read Replica

Beban kerja (workload) aplikasi web umumnya bersifat read-heavy dengan rasio pembacaan terhadap penulisan data sering kali melampaui 80:20. Ketika utilisasi CPU atau I/O database primer mendekati batas kapasitas, tim engineering dihadapkan pada dua pilihan: Scale-Up (Vertical Scaling) atau Scale-Out (Horizontal Read Replica).

Scale-up dilakukan dengan meningkatkan spesifikasi instance database primer (vCPU, RAM, IOPS disk). Keuntungan utamanya adalah kesederhanaan arsitektural: ACID compliance tetap trivial, tidak ada modifikasi kode ORM, dan konsistensi data absolut terjamin. Namun, pendekatan ini memiliki batas fisik (hardware ceiling) dan kurva biaya non-linear pada tier instance kelas atas.

Scale-out menggunakan asynchronous read replica memisahkan lalu lintas query SELECT ke satu atau lebih node sekunder, sementara mutasi (INSERT, UPDATE, DELETE) tetap ditangani node primary. Biaya komputasi per query menurun karena replica dapat menggunakan instance yang lebih terjangkau. Namun, keuntungan ini dibayar dengan kompromi konsistensi data dan kompleksitas pada layer ORM aplikasi.

Implementasi Custom Database Router

Django menyediakan routing database bawaan melalui konfigurasi DATABASE_ROUTERS. Router bertugas menentukan koneksi database yang digunakan untuk operasi baca, tulis, relasi foreign key, dan migrasi skema.

# settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'app_prod',
        'USER': 'db_user',
        'PASSWORD': 'secret',
        'HOST': 'primary.db.internal',
        'PORT': '5432',
    },
    'replica': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'app_prod',
        'USER': 'db_user',
        'PASSWORD': 'secret',
        'HOST': 'replica-1.db.internal',
        'PORT': '5432',
    },
}

DATABASE_ROUTERS = ['core.db_routers.PrimaryReplicaRouter']

Implementasi router dasar membutuhkan empat method utama: db_for_read, db_for_write, allow_relation, dan allow_migrate.

# core/db_routers.py
import random
from core.context import is_pinned_to_primary

class PrimaryReplicaRouter:
    def db_for_read(self, model, **hints):
        # Jika request sedang di-pin ke primary, bypass replica
        if is_pinned_to_primary():
            return 'default'
        return 'replica'

    def db_for_write(self, model, **hints):
        return 'default'

    def allow_relation(self, obj1, obj2, **hints):
        db_list = ('default', 'replica')
        if obj1._state.db in db_list and obj2._state.db in db_list:
            return True
        return None

    def allow_migrate(self, db, app_label, model_name=None, **hints):
        # Migrasi DDL hanya boleh dieksekusi pada primary node
        return db == 'default'

Anatomi Replication Lag dan Stale Read

Database relasional populer seperti PostgreSQL dan MySQL umumnya menggunakan asynchronous streaming replication (berbasis Write-Ahead Logging/WAL atau binary log) untuk menjaga throughput tulis tetap optimal pada primary. Ada jeda waktu (replication lag) antara saat transaksi di-commit pada primary hingga WAL diunduh dan diterapkan oleh engine replica.

Kondisi ini memicu race condition yang dikenal sebagai Read-After-Write Inconsistency. Alur insiden yang terjadi:

  1. Klien mengirim POST /profile/update/ untuk mengubah alamat. Data berhasil di-commit di default.
  2. Server merespons dengan HTTP 302 redirect ke GET /profile/.
  3. Browser langsung memicu request GET /profile/ (biasanya dalam rentang < 50ms).
  4. Django memetakan query SELECT ke replica melalui db_for_read.
  5. Replica masih mengalami lag 120ms dan belum memproses WAL perubahan tersebut.
  6. Klien menerima tampilan profil dengan data lama (stale read).

Solusi Konkret: Primary Pinning via Middleware

Untuk meniadakan stale read pasca-mutasi tanpa mengorbankan isolasi replica untuk traffic murni read-only, implementasikan replication lag pinning. Mekanisme ini memaksakan routing pembacaan kembali ke primary node selama durasi window tertentu (misal: 3-5 detik) khusus untuk sesi pengguna yang baru saja mengeksekusi mutasi.

1. Layer Context Variable

# core/context.py
import contextvars

_pinned = contextvars.ContextVar('pinned_to_primary', default=False)

def set_pinned_to_primary(status: bool):
    _pinned.set(status)

def is_pinned_to_primary() -> bool:
    return _pinned.get()

2. Middleware Pinning

# core/middleware.py
import time
from core.context import set_pinned_to_primary

PIN_WINDOW_SECONDS = 5.0
WRITE_TIMESTAMP_SESSION_KEY = '_last_db_write_ts'
STATE_CHANGING_METHODS = {'POST', 'PUT', 'PATCH', 'DELETE'}

class ReplicationLagPinningMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        now = time.time()
        should_pin = False

        # 1. Evaluasi session untuk request GET pasca-mutasi
        if hasattr(request, 'session'):
            last_write = request.session.get(WRITE_TIMESTAMP_SESSION_KEY, 0)
            if now - last_write < PIN_WINDOW_SECONDS:
                should_pin = True

        # 2. Mutasi langsung selalu pin ke primary
        if request.method in STATE_CHANGING_METHODS:
            should_pin = True

        # Terapkan state ke ContextVar per thread/task
        set_pinned_to_primary(should_pin)

        response = self.get_response(request)

        # 3. Simpan timestamp jika method memodifikasi data
        if request.method in STATE_CHANGING_METHODS and hasattr(request, 'session'):
            # Pastikan hanya mencatat jika status HTTP sukses/redirect
            if 200 <= response.status_code < 400:
                request.session[WRITE_TIMESTAMP_SESSION_KEY] = time.time()

        return response

Catatan: Penggunaan contextvars menjamin thread-safety baik pada runtime WSGI (Gunicorn/uWSGI) maupun ASGI (Uvicorn), mencegah status pinning bocor ke request pengguna lain di thread yang sama.

Batasan Arsitektur dan Operasional

1. Relasi Lintas Database (allow_relation)

Secara default, Django ORM memvalidasi integritas relasi antar model instance. Jika Model A diarahkan ke primary dan Model B dibaca dari replica, pemanggilan a.b_set.all() dapat memicu IntegrityError jika router mengembalikan False atau None pada allow_relation. Pastikan method allow_relation secara eksplisit mengizinkan instance yang berada di dalam set database cluster yang sama.

2. Batasan Transaksi Atomik (transaction.atomic)

Blok transaction.atomic() mengikat transaksi ke koneksi database tertentu via parameter using (default: 'default'). Jika Anda mengeksekusi query baca di dalam blok atomik tanpa routing yang tepat, query tersebut berpotensi lari ke replica dan gagal melihat snapshot uncommitted dari transaksi yang sedang berjalan pada primary. Disarankan untuk memaksakan pinning ke primary secara otomatis saat berada dalam konteks transaksi atomik.

3. Connection Pooling (PgBouncer)

Ketika beralih ke arsitektur multi-node, beban jumlah koneksi terbuka ke database meningkat dua kali lipat pada sisi aplikasi (Django membuka koneksi terpisah untuk tiap entry di DATABASES per worker process). Solusinya:

  • Tempatkan PgBouncer di depan cluster database.
  • Gunakan dua host/port pool terpisah: satu pool mengarah ke primary (port 6432) dan satu pool mengarah ke replica pool (port 6433).
  • Gunakan Transaction Pooling Mode untuk replica guna efisiensi koneksi tertinggi, karena query replica murni stateless tanpa dependensi session variable atau prepared statement antar-transaksi.