Partitioning pada chapter sebelumnya mengatur data ke samping: satu folder per nilai kolom. Namun ada satu dimensi lain yang sama pentingnya: ukuran setiap berkas di dalam folder. Job yang berjalan setiap lima menit, misalnya, bisa meninggalkan 288 berkas per hari — masing-masing hanya berisi ratusan baris. Pada skala itu, biaya yang dibayar bukan pada membaca datanya, melainkan pada mengurus berkasnya: listing, metadata, dan permintaan ke storage. Chapter ini membahas anatomi berkas Parquet, ukuran yang patut ditargetkan, dan cara merapikan berkas kecil lewat kompaksi.

Row Group: Satuan Baca Parquet #

Berkas Parquet tersusun dari row group: blok berisi sebagian baris, umumnya 128 MB per blok pada pengaturan bawaan engine besar. Setiap row group menyimpan statistik nilai minimum-maksimum per kolom, dan statistik inilah yang dipakai engine untuk melompati blok yang pasti tidak memuat hasil query. Row group yang terlalu kecil membuat statistiknya jarang berguna; row group yang terlalu besar memaksa satu tugas membaca banyak data sekaligus. Spesifikasi lengkapnya ada di dokumentasi Parquet .

Komprominya juga berlaku untuk ukuran berkas. Pedoman yang umum dipakai di industri: targetkan berkas Parquet antara 128 MB dan 1 GB. Di bawah 128 MB, jumlah berkas membengkak dan listing menjadi mahal; di atas 1 GB, satu berkas sulit dibaca paralel oleh beberapa worker sekaligus. Angka pada demo kita nanti sengaja diperkecil (5.000 baris per berkas) agar skrip berjalan cepat — angka itu adalah versi mini dari target produksi.

Masalah Small Files #

Small files membebani sistem lewat tiga jalur sekaligus. Pertama, setiap berkas Parquet membawa footer metadata sendiri; ribuan footer berarti ribuan bacaan kecil sebelum satu baris data pun tersentuh. Kedua, di object storage, operasi listing adalah panggilan jaringan — daftar berkas yang panjang membuat perencanaan query lambat bahkan sebelum eksekusi dimulai. Ketiga, setiap berkas menjadi satu unit tugas bagi scheduler; 10.000 berkas kecil menghasilkan 10.000 tugas yang masing-masing hanya bekerja sepersekian milidetik.

Perbandingan sederhana dari demo kita nanti: 50 berkas kecil totalnya 154.249 byte, sementara isi datanya jika ditulis ulang sebagai 2 berkas besar hanya 68.434 byte — kurang dari separuh. Sisanya adalah biaya overhead yang dibayar untuk setiap berkas, berulang kali, pada setiap pembacaan.

Praktik: Kompaksi 50 Berkas Menjadi 2 #

Kompaksi bekerja seperti membereskan lemari: seluruh berkas kecil dibaca, digabungkan dalam urutan stabil, lalu ditulis ulang dalam beberapa berkas besar. Skrip berikut mensimulasikan job streaming yang menulis 50 berkas kecil, mengukurnya, melakukan kompaksi, lalu memverifikasi bahwa tidak ada satu baris pun yang berubah:

python
import pathlib
import shutil

import pyarrow as pa
import pyarrow.dataset as ds
import pyarrow.parquet as pq

BASE = pathlib.Path(__file__).resolve().parent / "smallfiles"
shutil.rmtree(BASE, ignore_errors=True)
KECIL = BASE / "kecil" / "transaksi"
BESAR = BASE / "kompaksi" / "transaksi"
KECIL.mkdir(parents=True, exist_ok=True)

# --- Tahap 1: simulasi job streaming menulis 50 berkas kecil ---
acak_state = 777001
def harga_dan_produk(nomor: int) -> tuple[str, int]:
    katalog = [
        ("Kopi Gayo 250g", 45000), ("Kopi Toraja 200g", 38000),
        ("Teh Melati 100g", 16000), ("Cangkir Keramik", 55000),
    ]
    return katalog[nomor % len(katalog)]

for nomor in range(50):
    baris = []
    for i in range(200):
        acak_state = (acak_state * 1103515245 + 12345) % (2**31)
        produk, harga = harga_dan_produk((acak_state >> 7) % 4)
        jumlah = (acak_state >> 3) % 3 + 1
        baris.append({
            "id": nomor * 200 + i + 1,
            "produk": produk,
            "harga": harga,
            "jumlah": jumlah,
            "total": jumlah * harga,
        })
    tabel = pa.Table.from_pylist(baris, schema=pa.schema([
        ("id", pa.int64()), ("produk", pa.string()), ("harga", pa.int64()),
        ("jumlah", pa.int64()), ("total", pa.int64())]))
    pq.write_table(tabel, KECIL / f"batch-{nomor:03d}.parquet")

def statistik(folder: pathlib.Path, label: str) -> None:
    berkas = sorted(folder.glob("*.parquet"))
    total = sum(b.stat().st_size for b in berkas)
    rata = total // len(berkas) if berkas else 0
    baris = ds.dataset(folder, format="parquet").count_rows()
    print(f"{label}: {len(berkas)} file, {baris} baris, "
          f"total {total} byte, rata-rata {rata} byte/file")

statistik(KECIL, "sebelum kompaksi")

# --- Tahap 2: kompaksi - gabungkan 50 berkas kecil jadi berkas besar ---
# Baca berkas kecil dalam urutan nama agar hasil kompaksi deterministik.
berkas_urut = pa.concat_tables(
    pq.read_table(b) for b in sorted(KECIL.glob("*.parquet")))
ds.write_dataset(
    berkas_urut, BESAR, format="parquet",
    max_rows_per_file=5000,
    min_rows_per_group=5000,
    max_rows_per_group=5000,
)
statistik(BESAR, "setelah kompaksi")

# --- Tahap 3: verifikasi isi dan anatomi berkas ---
for berkas in sorted(BESAR.glob("*.parquet")):
    meta = pq.ParquetFile(berkas).metadata
    print(f"{berkas.relative_to(BASE)}: {meta.num_rows} baris, "
          f"{meta.num_row_groups} row group, {berkas.stat().st_size} byte")

tabel_cek = ds.dataset(BESAR, format="parquet").to_table()
asli = ds.dataset(KECIL, format="parquet").to_table()
print("jumlah baris sama:", tabel_cek.num_rows == asli.num_rows)
print("total transaksi identik:",
      sorted(tabel_cek.column("total").to_pylist()) == sorted(asli.column("total").to_pylist()))
print("contoh baris:", tabel_cek.slice(0, 1).to_pylist()[0])

Output yang Dihasilkan:

text
sebelum kompaksi: 50 file, 10000 baris, total 154249 byte, rata-rata 3084 byte/file
setelah kompaksi: 2 file, 10000 baris, total 68434 byte, rata-rata 34217 byte/file
kompaksi/transaksi/part-0.parquet: 5000 baris, 1 row group, 34218 byte
kompaksi/transaksi/part-1.parquet: 5000 baris, 1 row group, 34216 byte
jumlah baris sama: True
total transaksi identik: True
contoh baris: {'id': 1, 'produk': 'Kopi Toraja 200g', 'harga': 38000, 'jumlah': 1, 'total': 38000}

Mari kita telaah baris-baris kode di atas:

  1. Baris 15 dan 26 (acak_state): generator angka deterministik sederhana (linear congruential) mengisi data contoh tanpa memanggil library eksternal; hasilnya sama pada setiap eksekusi.
  2. Baris 23-39 (loop 50): simulasi job streaming — setiap iterasi menulis satu berkas batch-NNN.parquet berisi 200 baris, persis seperti job yang berjalan sekali per lima menit sepanjang hari.
  3. Baris 41-47 (statistik): fungsi pengukur yang dipanggil sebelum dan sesudah kompaksi; ds.dataset(...).count_rows() menghitung baris lewat metadata tanpa memuat seluruh data ke memori.
  4. Baris 53-54 (berkas_urut): berkas kecil dibaca dalam urutan nama lalu digabungkan; urutan tetap penting supaya hasil kompaksi bisa direproduksi bit demi bit.
  5. Baris 55-60 (write_dataset): tiga parameter mengatur layout hasil — max_rows_per_file=5000 membagi 10.000 baris menjadi dua berkas, sementara min_rows_per_group dan max_rows_per_group memaksa satu row group berisi 5.000 baris per berkas. Pada data produksi, angka ini diganti setara 128 MB.
  6. Baris 71-73 (verifikasi): jumlah baris dan daftar nilai kolom total dibandingkan berurutan — bukti bahwa kompaksi hanya menata ulang berkas, tidak mengubah satu transaksi pun.

Ada satu kebutuhan yang senantiasa muncul dari pola “tulis berkas baru, hapus berkas lama”: siapa yang mengatur versi, dan bagaimana dua job tidak saling menimpa? Pertanyaan itu membawa kita ke lapisan paling atas dari lakehouse — table formats seperti Apache Iceberg, Delta Lake, dan Hudi, pembahasan chapter berikutnya.