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:

  1. Server-Side Cursor: Mengambil data dari database per potongan (chunk) tanpa menarik seluruh dataset ke buffer socket client.
  2. 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.
  3. 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.