II — Praktik
Artikel 5 dari 9

Praktik: Database Scaling

Migrasi dari SQLite ke Postgres, cara index mengubah Seq Scan jadi Index Scan, dan mengatur ukuran connection pool yang tepat — plus temuan mengejutkan tentang bottleneck yang tersembunyi di tempat lain.

10 Juli 2026

Di Bagian 3, kita menemukan bukti nyata: task-tracker-api versi SQLite collapse total di beban tinggi, dengan log yang jelas menunjuk ke satu penyebab — QueuePool limit of size 5 overflow 10 reached, connection timed out. Caching di Bagian 4 membantu menyerap beban baca, tapi tidak menyelesaikan akar masalah ini. Sekarang kita masuk ke jantungnya: migrasi ke Postgres, indexing, dan mengatur connection pool dengan benar.

Kenapa SQLite Harus Ditinggalkan untuk Sistem yang Scalable

SQLite itu database file tunggal — bagus untuk aplikasi kecil, prototyping, atau embedded system, tapi dari desainnya sendiri tidak dirancang untuk banyak proses yang mengakses secara bersamaan lewat jaringan. Dua masalah utamanya untuk kebutuhan kita:

  • Tidak bisa diakses lewat jaringan secara native — SQLite adalah file di disk, dibaca langsung oleh proses yang berjalan di mesin yang sama. Begitu kita punya banyak instance aplikasi (yang akan kita bangun di Bagian 7), masing-masing instance butuh akses ke database yang SAMA — dan itu cuma mungkin kalau database-nya bisa diakses lewat jaringan, seperti Postgres.
  • Locking yang lebih kasar saat concurrent write. SQLite mengunci di level yang cukup luas saat ada operasi tulis, yang membuatnya jadi bottleneck begitu ada banyak write bersamaan — persis salah satu poin di Bagian 1 tentang "resource yang dikira bisa dibagi, ternyata terkunci".
  • Postgres menyelesaikan keduanya: database server yang berjalan sebagai proses terpisah, diakses lewat jaringan (jadi bisa dipakai bersama oleh banyak instance aplikasi), dengan mekanisme locking yang jauh lebih granular.

    Migrasi ke Postgres

    Perubahan di kode kita sebenarnya kecil, karena SQLModel/SQLAlchemy sudah mengabstraksi banyak detail database-spesifik. Yang berubah cuma connection string dan driver:

    # app/database.py
    from sqlmodel import Session, SQLModel, create_engine
    from app.config import settings
    
    engine = create_engine(
        settings.database_url,     # "postgresql+psycopg2://user:pass@host:5432/tasks"
        pool_size=settings.db_pool_size,
        max_overflow=settings.db_max_overflow,
        pool_pre_ping=True,
    )
    

    pool_pre_ping=True menyuruh SQLAlchemy mengecek koneksi masih hidup sebelum dipakai — berguna karena koneksi jaringan ke Postgres (beda dengan file SQLite lokal) bisa saja putus di tengah jalan (network blip, database restart), dan ini mencegah kita memakai koneksi yang sudah mati.

    Untuk development lokal, jalankan Postgres lewat Docker (kita akan pakai docker-compose.yml yang sama persis di Bagian 7):

    docker compose up -d postgres redis
    

    Indexing — Membantu Database Menemukan Data Tanpa Memindai Semuanya

    Index adalah struktur data tambahan yang membantu database menemukan baris yang dicari tanpa harus memeriksa SETIAP baris di tabel — mirip daftar isi di buku, dibanding harus membaca dari halaman pertama untuk mencari satu topik.

    Model Task kita punya index di kolom done, karena ini yang paling sering dipakai untuk filter:

    class Task(SQLModel, table=True):
        id: Optional[int] = Field(default=None, primary_key=True)
        title: str
        done: bool = Field(default=False, index=True)   # <- index eksplisit
        priority: Priority = Priority.medium
        created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
    

    Untuk membuktikan bedanya, kita isi tabel dengan 205.000 baris (kondisi realistis aplikasi yang sudah dipakai lama), lalu bandingkan EXPLAIN ANALYZE untuk query yang mengenai kolom ber-index (done) vs kolom tanpa index (priority):

    EXPLAIN ANALYZE SELECT * FROM task WHERE done = true;
    
    Index Scan using ix_task_done on task  (cost=0.29..645.09 rows=4292 width=33)
                                            (actual time=0.031..2.134 rows=4065 loops=1)
      Index Cond: (done = true)
    Execution Time: 2.384 ms
    
    EXPLAIN ANALYZE SELECT * FROM task WHERE priority = 'high';
    
    Seq Scan on task  (cost=0.00..4261.12 rows=65650 width=33)
                       (actual time=0.312..17.104 rows=66514 loops=1)
      Filter: (priority = 'high'::priority)
      Rows Removed by Filter: 138536
    Execution Time: 19.159 ms
    

    Dua baris paling penting untuk dipahami di sini:

  • Index Scan vs Seq Scan — nama strateginya sendiri sudah menjelaskan: query pertama langsung "melompat" ke baris yang cocok lewat index. Query kedua melakukan Seq(uential) Scan — memeriksa SEMUA baris di tabel satu per satu.
  • Rows Removed by Filter: 138536 — ini bukti konkret: Postgres benar-benar memeriksa 138.536 baris yang TIDAK cocok, cuma untuk menemukan yang cocok. Di tabel 205 ribu baris ini bedanya "cuma" 2.3ms vs 19ms — belum dramatis. Tapi coba bayangkan tabel dengan 50 juta baris: Seq Scan akan scaling linear dengan ukuran tabel (makin besar tabelnya, makin lama), sementara Index Scan scaling jauh lebih landai. Masalah tanpa index ini biasanya baru terasa parah justru saat tabelnya sudah besar dan trafiknya sudah tinggi — kombinasi terburuk, dan sering kali sudah terlambat untuk pertama kali disadari.
  • Aturan praktis: index kolom-kolom yang sering dipakai di klausa WHERE, ORDER BY, atau JOIN. Tapi jangan index semua kolom asal-asalan — index mempercepat baca, tapi memperlambat tulis (setiap INSERT/UPDATE juga harus meng-update index-nya) dan memakan storage tambahan. Index itu trade-off, bukan "semakin banyak semakin baik".

    Connection Pooling — Mengatur Ukuran yang Tepat

    Sekarang kembali ke masalah yang bikin SQLite kita collapse di Bagian 3: connection pool. Ukurannya diatur eksplisit lewat environment variable:

    # app/config.py
    class Settings(BaseSettings):
        db_pool_size: int = 5
        db_max_overflow: int = 10
        ...
    

    pool_size adalah jumlah koneksi yang dijaga tetap terbuka (siap pakai kapan saja). max_overflow adalah tambahan koneksi sementara yang dibuka kalau pool_size sudah penuh, lalu ditutup lagi setelah tidak dipakai. Total maksimum koneksi bersamaan = pool_size + max_overflow.

    Mari kita tes: dengan dataset yang wajar (50 baris) dan default (pool_size=5, max_overflow=10 → maksimum 15 koneksi), kita kirim 300 request dengan 100 di antaranya bersamaan:

    Sukses / Gagal    : 300 / 0
    Throughput        : 52.6 req/detik
    Latency p95        : 5051.1 ms
    

    Lalu naikkan pool jadi pool_size=30, max_overflow=40 (maksimum 70 koneksi) dan ulangi tes yang sama:

    Sukses / Gagal    : 300 / 0
    Throughput        : 50.3 req/detik
    Latency p95        : 4861.6 ms
    

    Hampir tidak ada bedanya. Ini mengejutkan — bukankah connection pool seharusnya jadi bottleneck utama, persis seperti yang kita lihat hancur total di Bagian 3? Ternyata, di kasus ini, memperbesar pool TIDAK banyak membantu. Kenapa?

    Petunjuknya ada di query kita sendiri — dengan hanya 50 baris data, tiap query database selesai dalam hitungan milidetik. Koneksi dipakai sebentar lalu langsung dikembalikan ke pool, jadi pool jarang benar-benar penuh lama-lama, sekalipun ukurannya kecil. Berarti ada bottleneck LAIN yang membatasi seberapa banyak request bisa diproses bersamaan — sesuatu yang terjadi SEBELUM request itu sempat menyentuh connection pool sama sekali. Kita akan bongkar tuntas apa itu di artikel selanjutnya, karena jawabannya mengubah cara kita melihat semua benchmark yang sudah kita jalankan sejauh ini.

    Catatan penting soal SQLite di Bagian 3: di sana pool-nya benar-benar kolaps total (497 dari 500 gagal), jauh lebih parah dari yang kita lihat di sini. Bedanya bukan cuma ukuran pool (keduanya sama-sama 15 koneksi default), tapi SQLite menambahkan lapisan masalah lain — locking file yang membuat setiap operasi jauh lebih lambat dan gampang saling menunggu, memperparah antrian di pool. Postgres, dengan concurrency control yang jauh lebih baik, membuat pool yang "kekurangan" tidak langsung berujung collapse total — cuma jadi lebih lambat. Tetap saja, ukuran pool yang wajar untuk beban yang diharapkan itu penting, terutama nanti begitu kita punya banyak instance aplikasi yang berbagi satu database di Bagian 7.

    Rangkuman

  • SQLite tidak cocok untuk sistem multi-instance: tidak bisa diakses lewat jaringan bersama, dan locking-nya kasar saat concurrent write.
  • Migrasi ke Postgres di kode kita hanya butuh mengubah connection string dan driver — SQLModel/SQLAlchemy mengabstraksi sisanya.
  • Index mengubah Seq Scan (periksa semua baris) jadi Index Scan (langsung ke baris yang cocok) — dampaknya scaling dengan ukuran tabel, makin terasa makin besar datanya.
  • Connection pool yang tepat ukurannya penting, tapi ternyata bukan satu-satunya, bahkan bukan selalu bottleneck utama — ada faktor lain yang membatasi concurrency di lapisan yang berbeda.
  • Topik

    ScalabilityPostgreSQLDatabase