Chapter sebelumnya menunjukkan DuckDB mengalahkan SQLite dengan selisih besar pada agregasi yang sama. Selisih terbesarnya bukan berasal dari bahasa pemrograman atau keberuntungan penjadwal, melainkan dari cara data disusun di penyimpanan: per baris atau per kolom. Chapter Parquet sudah memperkenalkan format berkasnya; sekarang kita bedah sisi engine-nya — bagaimana layout kolom mengubah jumlah byte yang dibaca, diukur nyata pada dataset 500 ribu pesanan TokoKita dengan 12 kolom.
Row-Store dan Column-Store #
Row-store menyimpan satu baris lengkap sebagai satu kesatuan bersebelahan di disk: 1, Budi, Jakarta, ..., 12500000. Column-store memecah baris yang sama menjadi kolom-kolom terpisah: seluruh nilai kota berkelompok, seluruh nilai nilai berkelompok, dan seterusnya. Konsekuensinya simetris:
| Operasi | Row-store | Column-store |
|---|---|---|
| Baca 2 dari 12 kolom | tetap baca seluruh baris | hanya baca 2 kolom |
| Tulis 1 baris baru | satu tulis berurutan | 12 penulisan ke lokasi kolom berbeda |
| Kompresi kolom teks | rendah (data beragam per baris) | tinggi (nilai serupa berkelompok) |
UPDATE satu baris | murah | mahal (tulis ulang beberapa kolom) |
Query analitik hampir selalu memilih beberapa kolom dari banyak baris, sehingga layout kolom cocok untuk OLAP — dan sebaliknya untuk OLTP.
Layout kolom juga memberi bonus pada kompresi. Karena satu berkas kolom hanya memuat satu jenis data, nilai yang berulang — misalnya kota Bandung yang muncul 100 ribu kali — dapat disimpan sebagai kamus kecil plus indeks, atau sebagai rentang berurutan (run-length encoding). Pada row-store, teks Bandung terpisah-pisah di antara nilai kolom lain sehingga pola berulang sulit dimanfaatkan kompresor.
Eksperimen: Dua Belas Kolom, Dua Format #
Skrip berikut menulis dataset yang identik ke CSV dan Parquet, lalu mengukur query yang sama pada keduanya. Query analitiknya hanya membutuhkan kolom kota dan nilai — dua dari dua belas:
"""Ch2: Columnar storage — efek layout penyimpanan pada query analitik.
Data yang sama (500 ribu baris, 12 kolom) ditulis ke CSV dan Parquet,
lalu query yang sama dijalankan DuckDB pada kedua berkas. Query analitik
hanya butuh 2 dari 12 kolom.
"""
import os
import statistics
import time
import duckdb
N = 500_000
for f in ("lebar.csv", "lebar.parquet"):
if os.path.exists(f):
os.remove(f)
con = duckdb.connect()
con.execute(f"""
CREATE OR REPLACE TABLE pesanan AS
SELECT
i AS id,
'pelanggan-' || (i % 1000)::VARCHAR AS pelanggan,
CASE (i % 5) WHEN 0 THEN 'Bandung' WHEN 1 THEN 'Denpasar'
WHEN 2 THEN 'Jakarta' WHEN 3 THEN 'Medan'
ELSE 'Surabaya' END AS kota,
'x' AS isi_dummy_1,
CASE WHEN i % 97 = 0 THEN NULL
ELSE 'promo-' || (i % 40)::VARCHAR END AS kode_kupon,
'catatan panjang untuk pesanan nomor ' || i::VARCHAR AS catatan,
i % 5 AS produk_id,
1 + i % 3 AS qty,
15000.0 + (i % 500) * 250.0 AS nilai,
CASE i % 4 WHEN 0 THEN 'selesai' WHEN 1 THEN 'diproses'
WHEN 2 THEN 'dikirim' ELSE 'dibatalkan' END AS status,
CASE i % 4 WHEN 0 THEN 'kartu_kredit' WHEN 1 THEN 'e_wallet'
WHEN 2 THEN 'transfer' ELSE 'cod' END AS metode_bayar,
CASE i % 3 WHEN 0 THEN 'web' WHEN 1 THEN 'android'
ELSE 'ios' END AS channel
FROM range(1, {N + 1}) AS t(i)
""")
con.execute("COPY pesanan TO 'lebar.csv' (HEADER)")
con.execute("COPY pesanan TO 'lebar.parquet'")
print(f"kolom: 12, baris: {N:,}")
print(f"ukuran CSV : {os.path.getsize('lebar.csv'):>12,} byte")
print(f"ukuran Parquet : {os.path.getsize('lebar.parquet'):>12,} byte")
print()
# --- query analitik: hanya butuh kota + nilai (2 dari 12 kolom) ---
q_parquet = ("SELECT kota, count(*), round(avg(nilai), 2) AS rata_nilai "
"FROM read_parquet('lebar.parquet') GROUP BY kota ORDER BY kota")
q_csv = q_parquet.replace("read_parquet('lebar.parquet')",
"read_csv_auto('lebar.csv')")
q_semua = ("SELECT count(*), sum(qty), avg(nilai), min(id), max(id), "
"sum(length(pelanggan)), sum(length(kode_kupon)), "
"sum(length(catatan)), sum(length(status)), sum(length(metode_bayar)), "
"sum(length(channel)), sum(length(isi_dummy_1)) "
"FROM read_parquet('lebar.parquet')")
def ukur(query, ulang=3):
"""Median dari 3 run setelah 1 run pemanasan."""
con.execute(query).fetchall()
durasi = []
for _ in range(ulang):
t0 = time.perf_counter()
con.execute(query).fetchall()
durasi.append(time.perf_counter() - t0)
return statistics.median(durasi)
print("query: rata-rata nilai pesanan per kota (2 dari 12 kolom)")
print(f"CSV : {ukur(q_csv):.3f} detik (12 kolom diparse penuh)")
print(f"Parquet: {ukur(q_parquet):.3f} detik (2 kolom dibaca)")
print()
print("query: agregasi 12 kolom sekaligus")
print(f"Parquet: {ukur(q_semua):.3f} detik (12 kolom dibaca)")
print()
print(f"hasil CSV == Parquet : "
f"{con.execute(q_csv).fetchall() == con.execute(q_parquet).fetchall()}")
# --- bukti column pruning pada rencana eksekusi ---
plan = con.execute("EXPLAIN ANALYZE " + q_parquet).fetchall()[0][1]
lines = [l.replace("│", "").strip() for l in plan.splitlines()]
mulai = next(i for i, l in enumerate(lines) if "TABLE_SCAN" in l)
akhir = next(i for i, l in enumerate(lines) if "Filename(s):" in l)
print()
print("kutipan EXPLAIN ANALYZE (operator TABLE_SCAN):")
for l in lines[mulai:akhir + 2]:
print(l)Output yang Dihasilkan:
kolom: 12, baris: 500,000
ukuran CSV : 58,759,611 byte
ukuran Parquet : 5,433,856 byte
query: rata-rata nilai pesanan per kota (2 dari 12 kolom)
CSV : 0.128 detik (12 kolom diparse penuh)
Parquet: 0.007 detik (2 kolom dibaca)
query: agregasi 12 kolom sekaligus
Parquet: 0.035 detik (12 kolom dibaca)
hasil CSV == Parquet : True
kutipan EXPLAIN ANALYZE (operator TABLE_SCAN):
TABLE_SCAN
────────────────────
Function:
READ_PARQUET
Projections:
kota
nilai
Total Files Read: 1
Filename(s):
lebar.parquetMari kita telaah bagian-bagian penting skrip di atas:
- Baris 20-42 (pembangunan dataset): tabel 12 kolom dibangun dengan
range()danCASE; kolomcatatansengaja dibuat panjang agar bobot data nyata terasa.COPY ... TOmenulis dua berkas dengan isi identik. - Baris 51-61 (tiga query):
q_parquetdanq_csvadalah query yang sama di dua format;q_semuamenyentuh seluruh 12 kolom sebagai pembanding internal Parquet. - Baris 63-73 (fungsi
ukur): setiap query dijalankan sekali untuk pemanasan, lalu diukur tiga kali dan diambil mediannya — angka tunggal tanpa pemanasan mudah menyesatkan. - Baris 84-92 (kutipan rencana):
EXPLAIN ANALYZEditangkap sebagai teks, lalu hanya bagian operatorTABLE_SCANyang ditampilkan.
Membaca Hasil Eksperimen #
Tiga temuan bisa dibaca dari output. Pertama, urusan ukuran: Parquet 5,4 MB berhadapan dengan CSV 58,8 MB untuk data yang sama — rasio hampir 11 kali berkat kompresi nilai serupa dalam satu kolom, konsisten dengan temuan chapter Parquet. Kedua, urusan kecepatan: query dua kolom pada Parquet selesai 18 kali lebih cepat daripada di CSV, karena CSV memaksa parser membedah seluruh 12 kolom tiap baris padahal 10 di antaranya dibuang. Ketiga, pembanding internal: saat seluruh 12 kolom Parquet dibaca, waktunya naik dari 0,007 ke 0,035 detik — bukti bahwa kecepatan sebelumnya memang berasal dari column pruning, bukan kebetulan.
Angka-angka ini diambil dari median tiga run pada satu mesin yang saat pengukuran sedang tidak sepi beban; run lain di mesin yang sama pernah mencatat waktu CSV hingga 0,23 detik. Pola urutannya stabil (CSV paling lambat, Parquet dua kolom paling cepat), tetapi angka absolutnya selalu perlu diukur ulang di lingkungan masing-masing — kebiasaan yang akan sering kita ulang di section ini.
Baris Projections: kota, nilai pada rencana eksekusi adalah buktinya secara eksplisit: engine menyatakan hanya dua kolom yang diproyeksikan dari berkas. Sepuluh kolom lainnya tidak pernah disentuh sama sekali.
Penyimpanan kolom menekan jumlah byte yang dibaca, tetapi byte yang sudah berada di memori masih harus dihitung. Cara engine menghitungnya — baris per baris atau ribuan baris sekaligus — adalah topik chapter berikutnya, Vectorized Query Execution.