Sembilan chapter di belakang kita membedah satu per satu: penyimpanan kolom, eksekusi vectorized, tiga engine dengan tiga arsitektur berbeda, optimizer, eksekusi terdistribusi, dan federasi. Pada titik ini pertanyaan praktis tinggal satu: ketika pekerjaan data baru datang ke meja kita, engine mana yang dibuka? Chapter penutup section Pemrosesan Data Skala Besar sudah menjawab separuh pertanyaan itu untuk Spark versus pandas dan DuckDB; sekarang kita lengkapi ruang keputusannya dengan Trino dan ClickHouse, diakhiri benchmark segar dan diagram yang bisa dipakai berulang.

Peta Kandidat #

KandidatModelKekuatan utamaPilih ketika
SQLite / PostgreSQLOLTP row-storetransaksi ACID, lookup cepataplikasi transaksional; sumber kebenaran operasional
DuckDBembedded OLAPnol instalasi, baca Parquet langsunganalisis interaktif dan pipeline batch di satu mesin
TrinoMPP federasisatu SQL lintas katalog dan klastergabung banyak sumber tanpa ETL; konkurensi tim
ClickHouseOLAP real-timeinsert cepat, agregasi milidetikdashboard live, event streaming, konkurensi tinggi
Sparkcluster ETLfault tolerance, ekosistem lakehousetransformasi berat multi-tahap di atas data raksasa

Perhatikan bahwa baris pertama bukan engine analitik — kehadirannya di tabel mengingatkan bahwa banyak pekerjaan data justru selesai di database operasional. Chapter Pemilihan Sistem Penyimpanan membahas sisi itu lebih detail. Untuk memperdalam tiap kandidat, dokumentasi resmi DuckDB , Trino , dan ClickHouse memuat panduan instalasi hingga panduan tuning masing-masing engine.

Benchmark Segar: Tiga Kandidat di Satu Mesin #

Tabel di atas menyebut karakter; angka membuktikannya. Skrip berikut menjalankan agregasi yang sama — total nilai pesanan per kota — pada satu juta baris identik di tiga wakil kandidat yang bisa dijalankan lokal. Trino, ClickHouse, dan Spark tidak diukur di sini karena membutuhkan server atau klaster; posisi ketiganya pada diagram keputusan merujuk ukuran dan temuan chapter-chapter sebelumnya:

python
"""Ch10: Pemilihan query engine — benchmark segar tiga kandidat lokal.

Satu juta pesanan TokoKita, satu agregasi yang sama (total nilai per kota):
- SQLite   : wakil database OLTP row-store (untuk aplikasi transaksional)
- DuckDB   : embedded analytical engine (columnar, atas Parquet)
- pandas   : DataFrame di memori (wakil alat analisis)
Angka Trino/ClickHouse/Spark tidak diukur di sini (butuh cluster/server);
posisinya dirujuk dari chapter sebelumnya di section ini.
"""
import os
import sqlite3
import time

import duckdb
import pandas as pd

N = 1_000_000
SQLITE_DB = "pilihan.db"
PARQUET = "pilihan.parquet"

for f in (SQLITE_DB, PARQUET):
    if os.path.exists(f):
        os.remove(f)

# --- data identik untuk ketiganya ---
con = duckdb.connect()
con.execute(f"""
CREATE OR REPLACE TABLE pesanan AS
SELECT
    i AS id,
    CASE (i - 1) % 8
        WHEN 0 THEN 'Bandung' WHEN 1 THEN 'Denpasar' WHEN 2 THEN 'Jakarta'
        WHEN 3 THEN 'Medan'   WHEN 4 THEN 'Palembang' WHEN 5 THEN 'Semarang'
        WHEN 6 THEN 'Surabaya' ELSE 'Yogyakarta' END AS kota,
    15000.0 + ((i % 500) * 250.0)::DOUBLE AS nilai
FROM range(1, {N + 1}) AS t(i)
""")
con.execute(f"COPY pesanan TO '{PARQUET}' (FORMAT parquet)")

oltp = sqlite3.connect(SQLITE_DB)
oltp.execute("CREATE TABLE pesanan (id INTEGER PRIMARY KEY, kota TEXT, nilai REAL)")
con.execute(f"ATTACH '{SQLITE_DB}' AS oltp (TYPE sqlite)")
con.execute("INSERT INTO oltp.pesanan SELECT * FROM pesanan")
oltp.commit()
df = con.execute("SELECT kota, nilai FROM pesanan").df()

Q = ("SELECT kota, count(*), round(sum(nilai), 0) AS total "
     "FROM {s} GROUP BY kota ORDER BY kota")

# --- SQLite: mesin OLTP ---
sql = Q.format(s="pesanan")
oltp.execute(sql).fetchall()  # pemanasan
t0 = time.perf_counter()
hasil_sqlite = oltp.execute(sql).fetchall()
durasi_sqlite = time.perf_counter() - t0
oltp.close()
print(f"SQLite  (row-store, aplikasi transaksional) : {durasi_sqlite:.3f} detik")

# --- pandas: DataFrame di memori ---
df.groupby("kota", as_index=False).agg(
    jumlah=("kota", "size"), total=("nilai", "sum"))  # pemanasan
t0 = time.perf_counter()
hasil_pd = (df.groupby("kota", as_index=False)
              .agg(jumlah=("kota", "size"), total=("nilai", "sum"))
              .sort_values("kota"))
durasi_pd = time.perf_counter() - t0
print(f"pandas  (DataFrame di memori)                : {durasi_pd:.3f} detik")

# --- DuckDB: embedded OLAP atas Parquet ---
dsql = Q.format(s=f"read_parquet('{PARQUET}')")
con.execute(dsql).fetchall()  # pemanasan
t0 = time.perf_counter()
hasil_duck = con.execute(dsql).fetchall()
durasi_duck = time.perf_counter() - t0
print(f"DuckDB  (columnar atas Parquet)              : {durasi_duck:.3f} detik")

print()
print(f"jumlah kota: SQLite={len(hasil_sqlite)}, pandas={len(hasil_pd)}, "
      f"DuckDB={len(hasil_duck)}")
print(f"total semua kota identik: "
      f"{sum(r[2] for r in hasil_sqlite) == hasil_pd['total'].sum() == sum(r[2] for r in hasil_duck)}")
print(f"ukuran SQLite db : {os.path.getsize(SQLITE_DB):,} byte")
print(f"ukuran Parquet   : {os.path.getsize(PARQUET):,} byte")

Output yang Dihasilkan:

text
SQLite  (row-store, aplikasi transaksional) : 0.386 detik
pandas  (DataFrame di memori)                : 0.058 detik
DuckDB  (columnar atas Parquet)              : 0.008 detik

jumlah kota: SQLite=8, pandas=8, DuckDB=8
total semua kota identik: True
ukuran SQLite db : 20,742,144 byte
ukuran Parquet   : 4,172,671 byte

Mari kita telaah bagian-bagian penting skrip di atas:

  1. Baris 25-45 (persiapan): satu sumber data ditulis ke tiga wadah — SQLite lewat ATTACH (teknik chapter Federated Query), Parquet lewat COPY, dan DataFrame pandas lewat .df(). Ketiganya memuat baris yang persis sama.
  2. Baris 47-57 (SQLite): agregasi di mesin OLTP harus memindai seluruh baris — kota dan nilai berdampingan dengan kolom lain — menghasilkan waktu terlama, 0,386 detik.
  3. Baris 59-68 (pandas): data sudah di memori sebagai array NumPy, jadi groupby hanya membayar biaya pengelompokan; 0,058 detik.
  4. Baris 69-76 (DuckDB): columnar plus vectorized execution dari Parquet menutup benchmark dengan 0,008 detik — 48 kali lebih cepat dari SQLite dan 7 kali dari pandas, dengan jejak disk lima kali lebih kecil.

Angka ini konsisten dengan tiga pengukuran independen sebelumnya di section ini: agregasi 500 ribu baris di chapter Konsep Analytical Query Engine (0,173 vs 0,008 detik), eksperimen dua kolom dari dua belas di Columnar Storage, dan benchmark satu juta baris di Vectorized Query Execution.

Diagram Keputusan #

Urutan pertanyaan di diagram ini disengaja: kebutuhan akses ditanya lebih dulu, ukuran data menyusul, karena kebutuhan akses menentukan kelas engine sedangkan ukuran hanya menentukan skala dalam kelasnya:

Cabang bawah diagram juga mengakui kenyataan sepele yang sering dilupakan: bila data sudah berada di database OLTP dan pertanyaannya bisa dijawab SQL langsung, Trino bisa meng-query-nya tanpa memindahkan apa pun.

Prinsip Memilih #

Empat prinsip merangkum seluruh section:

  • Mulai dari yang paling ringan. DuckDB membaca berkas yang sama dengan yang dibaca Spark dan Trino; naik kelas hanya ketika pengukuran — bukan keinginan — yang menyuruh.
  • Bedakan kelas dari merek. Row-store OLTP, embedded OLAP, MPP federasi, OLAP real-time, dan cluster ETL adalah lima kelas kebutuhan; merek hanya implementasi.
  • Format lebih awet daripada engine. Data yang tersimpan sebagai Parquet terpartisi bisa dibuka semua engine di tabel ini — keputusan paling tahan zaman adalah keputusan tentang format, bukan engine.
  • Ukur di lingkungan sendiri. Semua angka di section ini jujur diukur di mesin kecil yang sedang sibuk; mesin Anda punya angkanya sendiri.

Dengan query engine yang tepat, data TokoKita bisa dijawab cepat — tetapi jawaban hanya berguna bila datanya bisa dipercaya. Berapa banyak baris kosong? Apakah schema berubah tanpa kabar? Section course berikutnya, Data Quality dan Observability, membangun pertahanan untuk pertanyaan-pertanyaan itu.