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 #

AspekTerpadu (database klasik)Terpisah (lakehouse)
Skalastorage dan compute naik bersamakeduanya diskala sendiri-sendiri
Biaya idlecluster berjalan 24 jam meski tanpa querystorage murah per GB, compute dibayar saat dipakai
Konkurensiquery berebut resource satu enginebeberapa engine membaca data yang sama
Formatproprietary per vendorterbuka: 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:

python
"""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:

python
"""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:

text
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: True

Mari kita telaah baris-baris kode di atas:

  1. 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.
  2. Baris 28-38 (duckdb_n_thread): SET threads=n mengatur jumlah thread eksekusi DuckDB, sementara read_parquet('kota=*/data.parquet') membaca seluruh shard sekaligus dengan partisi hive.
  3. Baris 40-48 (ukur): setiap mode dijalankan tiga kali dan diambil waktu terbaiknya, mengurangi pengaruh gangguan scheduling.
  4. Baris 66-73 (loop MODE): hasil setiap mode dikumpulkan ke hasil_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:

KomponenSatuan bayarKisaran harga publik
Object storageper GB-bulansekitar $0,02 per GB (S3 Standard)
VM on-demandper instance-jamsekitar $0,02-$0,05 per vCPU-jam
Query serverlessper TB dipindaisekitar $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.