Database tradisional mengikat proses dan penyimpanan dalam satu mesin: data membesar, maka server harus diperbesar, dan query berat membuat ingestion ikut melambat. Arsitektur lakehouse memutus ikatan tersebut — data hidup sebagai berkas Parquet di object storage, sementara proses komputasi dijalankan engine query yang tidak menyimpan apa pun. Menambah kecepatan analitik TokoKita kini berarti menambah compute, bukan memindahkan data. Chapter ini membuktikan konsepnya lewat benchmark lokal: satu lake yang sama dibaca dengan empat ukuran compute berbeda.
Kenapa Compute dan Storage Dipisahkan #
| Aspek | Terpadu (database klasik) | Terpisah (lakehouse) |
|---|---|---|
| Skala | storage dan compute naik bersama | keduanya diskala sendiri-sendiri |
| Biaya idle | cluster berjalan 24 jam meski tanpa query | storage murah per GB, compute dibayar saat dipakai |
| Konkurensi | query berebut resource satu engine | beberapa engine membaca data yang sama |
| Format | proprietary per vendor | terbuka: Parquet di object storage |
Engine query seperti Trino, Spark, Amazon Athena, dan DuckDB bersifat stateless: seluruh keadaan sebuah query hidup di memori worker, sehingga worker bisa ditambah atau dihapus kapan saja tanpa memindahkan data. Bagi TokoKita, pola ini berarti dashboard, batch malam, dan analisis ad-hoc bisa membaca lake yang sama secara bersamaan tanpa saling menunggu. Pola ini juga menjadi dasar layanan serverless — Athena menagih per TB data yang dipindai tanpa cluster yang harus direawat, sebagaimana dijelaskan di https://docs.aws.amazon.com/athena/latest/ug/what-is.html .
Demo: Lake yang Sama, Compute Berbeda #
Kita bangun dulu lake lokal yang meniru produksi: 8 shard Parquet terpartisi per kota, masing-masing 4 juta baris pesanan — total 32 juta baris atau 327,4 MB:
"""Bangun lake parquet TokoKita: satu shard per kota, 4 juta baris per shard."""
import pathlib
import shutil
import numpy as np
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
LAKE = pathlib.Path(__file__).resolve().parent / "lake"
KOTA = ["Jakarta", "Bandung", "Surabaya", "Medan",
"Semarang", "Yogyakarta", "Denpasar", "Makassar"]
BARIS_PER_KOTA = 4_000_000
rng = np.random.default_rng(42)
shutil.rmtree(LAKE, ignore_errors=True)
for i, kota in enumerate(KOTA):
total = rng.integers(10_000, 5_000_000, size=BARIS_PER_KOTA)
hari = rng.integers(0, 20, size=BARIS_PER_KOTA)
df = pd.DataFrame({
"id_pesanan": np.arange(i * BARIS_PER_KOTA, (i + 1) * BARIS_PER_KOTA),
"kota": kota,
"tanggal": np.datetime64("2026-10-01") + hari,
"total": total,
})
folder = LAKE / f"kota={kota}"
folder.mkdir(parents=True)
pq.write_table(pa.Table.from_pandas(df, preserve_index=False),
folder / "data.parquet", compression="snappy")
ukuran = sum(f.stat().st_size for f in LAKE.rglob("*.parquet"))
print("{} shard, {:,} baris, {:.1f} MB di disk".format(
len(KOTA), len(KOTA) * BARIS_PER_KOTA, ukuran / 1_000_000))Selanjutnya, lake tersebut dibaca empat kali dengan ukuran compute berbeda: pandas dengan thread pool pyarrow 1 dan 2 thread, serta DuckDB dengan SET threads 1 dan 2:
"""Benchmark pemisahan compute dan storage: data yang sama, ukuran compute berbeda."""
import pathlib
import time
import duckdb
import pandas as pd
import pyarrow as pa
LAKE = pathlib.Path(__file__).resolve().parent / "lake"
KOTA = ["Jakarta", "Bandung", "Surabaya", "Medan",
"Semarang", "Yogyakarta", "Denpasar", "Makassar"]
def pandas_sekuensial():
"""Baca shard satu per satu; paralelisme dekripsi diatur thread pool pyarrow."""
jumlah, gmv = 0, 0
for kota in KOTA:
df = pd.read_parquet(LAKE / f"kota={kota}" / "data.parquet",
columns=["total"])
jumlah += len(df)
gmv += int(df["total"].sum())
return jumlah, gmv
def pandas_n_thread(n):
"""Atur jumlah thread pool global pyarrow, lalu baca sekuensial."""
pa.set_cpu_count(n)
return pandas_sekuensial()
def duckdb_n_thread(n):
"""Jalankan query agregasi dengan DuckDB memakai n thread."""
con = duckdb.connect()
con.execute(f"SET threads={n}")
baris = con.execute("""
SELECT kota, COUNT(*) AS jumlah, SUM(total) AS gmv
FROM read_parquet('{}', hive_partitioning=true)
GROUP BY kota ORDER BY gmv DESC
""".format(str(LAKE / "kota=*" / "data.parquet"))).fetchall()
con.close()
return sum(r[1] for r in baris), sum(r[2] for r in baris), baris
def ukur(fungsi, ulang=3):
"""Waktu terbaik dari beberapa percobaan + hasil terakhir."""
terbaik, hasil = None, None
for _ in range(ulang):
mulai = time.perf_counter()
hasil = fungsi()
detik = time.perf_counter() - mulai
terbaik = detik if terbaik is None else min(terbaik, detik)
return terbaik, hasil
# Pemanasan page cache supaya semua mode membaca dari memori yang sama.
for f in LAKE.rglob("*.parquet"):
f.read_bytes()
MODE = [
("pandas, pyarrow 1 thread", lambda: pandas_n_thread(1)),
("pandas, pyarrow 2 thread", lambda: pandas_n_thread(2)),
("duckdb 1 thread ", lambda: duckdb_n_thread(1)),
("duckdb 2 thread ", lambda: duckdb_n_thread(2)),
]
print("Query: jumlah pesanan dan GMV per kota")
print()
print("{:<28}{:>14}{:>18}".format("Mode", "Waktu (detik)", "Jumlah pesanan"))
hasil_semua = set()
baris_kota = None
for nama, fungsi in MODE:
detik, hasil = ukur(fungsi)
if len(hasil) == 3:
jumlah, gmv, baris_kota = hasil
else:
jumlah, gmv = hasil
hasil_semua.add((jumlah, gmv))
print("{:<28}{:>14.3f}{:>18,}".format(nama, detik, jumlah))
print()
print("GMV per kota (DuckDB 2 thread):")
for kota, jumlah, gmv_kota in baris_kota:
print(" {:<11}{:>10,} pesanan Rp{:,}".format(kota, jumlah, gmv_kota))
print()
print("GMV total : Rp{:,}".format(gmv))
print("Semua mode menghasilkan angka sama: {}".format(len(hasil_semua) == 1))Output yang Dihasilkan:
8 shard, 32,000,000 baris, 327.4 MB di disk
Query: jumlah pesanan dan GMV per kota
Mode Waktu (detik) Jumlah pesanan
pandas, pyarrow 1 thread 0.458 32,000,000
pandas, pyarrow 2 thread 0.328 32,000,000
duckdb 1 thread 0.323 32,000,000
duckdb 2 thread 0.219 32,000,000
GMV per kota (DuckDB 2 thread):
Medan 4,000,000 pesanan Rp10,024,790,225,125
Bandung 4,000,000 pesanan Rp10,021,192,344,592
Surabaya 4,000,000 pesanan Rp10,020,697,377,447
Jakarta 4,000,000 pesanan Rp10,020,229,074,652
Denpasar 4,000,000 pesanan Rp10,019,109,818,382
Semarang 4,000,000 pesanan Rp10,018,142,731,545
Makassar 4,000,000 pesanan Rp10,017,956,087,285
Yogyakarta 4,000,000 pesanan Rp10,016,998,650,000
GMV total : Rp80,159,116,309,028
Semua mode menghasilkan angka sama: TrueMari kita telaah baris-baris kode di atas:
- Baris 23-26 (
pandas_n_thread):pa.set_cpu_count(n)mengatur thread pool global pyarrow yang dipakai untuk dekripsi — inilah “ukuran compute” pada mode pandas. - Baris 28-38 (
duckdb_n_thread):SET threads=nmengatur jumlah thread eksekusi DuckDB, sementararead_parquet('kota=*/data.parquet')membaca seluruh shard sekaligus dengan partisi hive. - Baris 40-48 (
ukur): setiap mode dijalankan tiga kali dan diambil waktu terbaiknya, mengurangi pengaruh gangguan scheduling. - Baris 66-73 (loop
MODE): hasil setiap mode dikumpulkan kehasil_semua; satu angka GMV untuk semua mode berarti keempat ukuran compute membaca storage yang sama dengan hasil identik.
Menggandakan thread mempercepat pandas dari 0,458 ke 0,328 detik dan DuckDB dari 0,323 ke 0,219 detik, tanpa satu byte pun dari lake berubah. Kedua engine juga hanya membaca kolom total — format kolom Parquet membuat proyeksi semacam ini murah, sebagaimana dibahas pada chapter Parquet. Di cloud, pola yang sama berlaku pada skala ribuan worker: Spark menambah executor, Trino menambah worker, dan setiap worker memproses shard yang berbeda secara bersamaan — storage tetap menjadi satu sumber kebenaran yang dibaca bersama tanpa perlu disalin.
Model Biaya Compute dan Storage #
Setelah compute dan storage terpisah, keduanya ditagih dengan satuan berbeda:
| Komponen | Satuan bayar | Kisaran harga publik |
|---|---|---|
| Object storage | per GB-bulan | sekitar $0,02 per GB (S3 Standard) |
| VM on-demand | per instance-jam | sekitar $0,02-$0,05 per vCPU-jam |
| Query serverless | per TB dipindai | sekitar $5 per TB (Amazon Athena) |
Mari terapkan pada TokoKita: lake seukuran 750 GB (dari simulasi chapter sebelumnya) ditagih sekitar $17 per bulan untuk storage. Batch harian memindai 100 GB selama 30 hari — 3 TB per bulan — sehingga biaya query serverless sekitar $15. Total $32 per bulan tanpa satu pun server yang harus dijaga; skema cluster 24 jam justru lebih mahal untuk beban yang sporadis seperti ini. Referensi sintaks dan perilaku DuckDB yang dipakai demo tersedia di https://duckdb.org/docs/ .
Compute yang bisa diskala dan dipindahkan begitu bebas paling praktis dikemas sebagai image yang identik di semua mesin. Pengemasan itulah pekerjaan container — materi chapter Container dan Docker.