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:
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
Seq Scan (periksa semua baris) jadi Index Scan (langsung ke baris yang cocok) — dampaknya scaling dengan ukuran tabel, makin terasa makin besar datanya.