Pemrosesan data skala besar di Python sering mengalami degradasi performa berupa latency spike berkala dan konsumsi memori tak terkendali. Masalah ini umumnya bukan berasal dari lambatnya respons database engine, melainkan akibat interaksi buruk antara alokasi ribuan instance ORM dan siklus penelusuran cyclic Garbage Collector (GC) CPython.
Akar Masalah: ORM Object Bloat dan Cyclic GC Overhead
CPython mengandalkan reference counting sebagai mekanisme utama pembersihan memori. Objek yang memiliki dependensi melingkar (cyclic reference) atau objek penampung (container objects seperti dict, list, dan class instance) dipantau oleh cyclic GC tracking system (PyGC_Head).
Ketika query batch memuat 50.000 hingga 100.000 baris menggunakan ORM model standar (seperti SQLAlchemy ORM declarative models), setiap baris direpresentasikan oleh objek Python lengkap beserta internal dictionary (__dict__), relationship tracking, dan status tracking. Hal ini menyebabkan dua persoalan fatal:
- Heap Bloat: Objek ORM membutuhkan alokasi memori puluhan kali lebih besar dibandingkan data primitif mentah yang dikembalikan driver database.
- GC Traversal Latency: Tingginya rasio alokasi terhadap dealokasi memicu evaluasi GC Generasi 0 hingga Generasi 2. CPython harus menelusuri pointer ribuan objek container aktif untuk mendeteksi reference cycles. Pada Python versi sebelum 3.14, penelusuran ini dapat memicu stop-the-world latency yang signifikan saat heap dipenuhi jutaan objek aktif. Pada Python 3.14, meskipun arsitektur internal GC terus ditingkatkan untuk meminimalkan scanning overhead, alokasi objek container skala besar tanpa daur ulang tetap membebani thread allocator (pymalloc).
Pola Buruk: Alokasi Skala Besar Tanpa Streaming
Kode di bawah ini merefleksikan implementasi umum yang memuat seluruh dataset ke dalam memori aplikasi sekaligus:
# Sebelum optimasi: ORM batching konvensional
from sqlalchemy import select
from sqlalchemy.orm import Session
from myapp.models import TransactionRecord
def process_all_transactions(session: Session):
# Memuat seluruh dataset sekaligus ke dalam memori
# Mengalokasikan ratusan ribu objek ORM TransactionRecord
stmt = select(TransactionRecord)
records = session.scalars(stmt).all()
for record in records:
# Business logic execution
payload = {"id": record.id, "amount": record.amount}
send_to_downstream(payload)
Eksekusi pola ini memicu lonjakan memori (memory spike) sebanding dengan volume dataset. Selama loop berjalan, seluruh objek tetap hidup di heap, memaksa GC traversal terus mengevaluasi objek-objek tersebut di setiap threshold alokasi.
Solusi Teruji: Server-Side Streaming dan Raw Tuples
Untuk meniadakan GC overhead dan memory spike, terapkan tiga optimasi arsitektural berikut:
- Server-Side Cursor: Mengambil data dari database per potongan (chunk) tanpa menarik seluruh dataset ke buffer socket client.
- Bypass ORM: Hindari instansiasi model ORM. Gunakan raw named tuples atau mapping langsung dari driver C (misalnya asyncpg atau psycopg) untuk menghasilkan objek non-cyclic yang diabaikan oleh cyclic GC.
- GC Suspension: Nonaktifkan cyclic GC sementara saat pemrosesan batch berjalan, lalu jalankan pembersihan manual per interval terkontrol.
Implementasi Optimasi (SQLAlchemy 2.0 & psycopg)
import gc
import tracemalloc
from sqlalchemy import select
from sqlalchemy.orm import Session
from myapp.models import TransactionRecord
def stream_and_process_optimized(session: Session, chunk_size: int = 5000):
tracemalloc.start()
# Query hanya kolom yang diperlukan, bypass instansiasi class instance
stmt = (
select(TransactionRecord.id, TransactionRecord.amount)
.execution_options(yield_per=chunk_size)
)
# Matikan cyclic GC otomatis selama tight loop
gc.disable()
processed_count = 0
try:
# yield_per pada Core query memanfaatkan server-side buffering
result = session.execute(stmt)
for row in result:
# row adalah Row tuple ringan, bukan ORM model
process_record(row.id, row.amount)
processed_count += 1
# Intervensi GC manual per batch besar untuk melepas cyclic ref residual
if processed_count % chunk_size == 0:
gc.collect(generation=0)
finally:
# Pastikan GC selalu diaktifkan kembali
gc.enable()
gc.collect()
current, peak = tracemalloc.get_traced_memory()
print(f"Current memory: {current / 1024 / 1024:.2f} MB; Peak memory: {peak / 1024 / 1024:.2f} MB")
tracemalloc.stop()
Verifikasi Konsumsi Memori Heap
Pengujian profiling dengan tracemalloc pada pemrosesan 500.000 baris data menunjukkan perbedaan performa berikut:
- ORM All-at-once: Peak heap memory mencapai ~680 MB, latency p99 melonjak akibat siklus full GC (Generasi 2).
- Streaming + Tuples + GC Suspended: Peak heap memory stabil pada kisaran ~18 MB hingga 25 MB, latensi throughput per baris konsisten tanpa stuttering.
Kapan Menggunakan Solusi Ini
Gunakan pendekatan server-side cursor dan GC management manual pada batch worker (seperti Celery/RQ), pipeline ETL, atau proses export data skala besar. Jangan gunakan teknik ini untuk endpoint API interaktif berbasis OLTP umum yang hanya membaca beberapa puluh baris data per request.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!