Tiga puluh dua baris fact pada gudang kita dijawab query mana pun dalam sekejap, sehingga keputusan desain Bagian 2 sampai 6 belum terbukti membantu performa. Pada bagian ini kita mengukurnya secara jujur: src/optimize_demo.py membangkitkan data uji satu juta baris di memori, menjalankan empat pasangan query naive versus hasil rancangan kita, lalu membedah rencana eksekusinya dengan EXPLAIN ANALYZE. Seluruh angka pada bagian ini adalah hasil eksekusi nyata, bukan perkiraan. Materi pendukung ada pada chapter Optimasi Query Dasar.
Menyiapkan Data Uji Satu Juta Baris #
Fungsi bangun_data_bench membangkitkan dua tabel yang isinya identik tetapi bentuknya berbeda: bench_fact memakai tipe terstruktur dan surrogate key persis seperti fact_pesanan, sedangkan bench_mentah meniru berkas mentah dengan tanggal teks dan kunci bisnis:
con.execute(
f"""
CREATE OR REPLACE TABLE bench_fact AS
SELECT 'ORD-' || lpad(((i % 50000) + 1)::VARCHAR, 5, '0') AS pesanan_id,
('2025-01-01'::DATE + (i % 181)::INTEGER) AS tanggal,
strftime('2025-01-01'::DATE + (i % 181)::INTEGER, '%Y%m%d')::INTEGER AS tanggal_sk,
(i % 10 + 1)::INTEGER AS pelanggan_sk,
(i % 12 + 1)::INTEGER AS produk_sk,
(i % 5 + 1)::INTEGER AS toko_sk,
(i % 3 + 1)::INTEGER AS jumlah,
((i % 90) * 1000 + 10000)::BIGINT AS harga_satuan,
((i % 9) * 2500)::BIGINT AS diskon,
((i % 3 + 1) * ((i % 90) * 1000 + 10000) - (i % 9) * 2500)::BIGINT AS total,
CASE WHEN i % 11 = 0 THEN 'dibatalkan'
WHEN i % 23 = 0 THEN 'dikembalikan'
ELSE 'selesai' END AS status
FROM range({n_baris}) AS t(i)
"""
)Distribusi status, rentang tanggal 181 hari, dan sepuluh pelanggan meniru bentuk data TokoKita dalam skala besar. Seluruhnya dibangun di koneksi :memory: sehingga demonstrasi tidak menyentuh berkas data/warehouse.duckdb.
Metode Pengukuran #
Satu kali pengukuran mudah tertipu oleh cache dan gangguan penjadwal, jadi setiap query diukur tujuh kali dan kita membaca mediannya:
def ukur(con, sql, ulangi=7) -> float:
"""Median waktu eksekusi query dalam milidetik, dengan satu pemanasan."""
con.execute(sql).fetchall()
waktu_ms = []
for _ in range(ulangi):
awal = time.perf_counter()
con.execute(sql).fetchall()
waktu_ms.append((time.perf_counter() - awal) * 1000)
return statistics.median(waktu_ms)Baris con.execute(sql).fetchall() pertama adalah pemanasan agar data sudah berada di memori sebelum pengukuran dimulai; statistics.median dipilih karena tahan terhadap satu dua lonjakan waktu. Untuk skenario yang membandingkan dua query setara, fungsi hasil_identik memastikan kedua query mengembalikan baris yang persis sama sehingga kita membandingkan buah yang sama, bukan cepat tetapi salah.
Empat Skenario Perbandingan #
| Skenario | Sisi naive | Sisi rancangan kita |
|---|---|---|
| menyaring bulan Maret | TRY_CAST tanggal teks lalu bandingkan | tanggal_sk BETWEEN smart key |
| GMV per kota pelanggan | join pada kunci bisnis teks | join pada surrogate key integer |
| pemangkasan kolom | agregasi 4 kolom | agregasi 1 kolom |
| penempatan agregasi | join dulu baru agregasi | pra-agregasi lalu join dimensi kecil |
Perintah python3 -m src.optimize_demo menjalankan seluruhnya:
Output yang Dihasilkan:
data uji: 1000000 baris fact, 181 tanggal, 10 pelanggan, 5 toko
skenario 1: menyaring bulan Maret
parse tanggal teks : 17.8 ms
smart key tanggal_sk : 2.9 ms
hasil identik : True
skenario 2: GMV per kota pelanggan (status selesai)
join kunci bisnis : 30.7 ms
join surrogate key : 11.4 ms
hasil identik : True
skenario 3: pemangkasan kolom pada tabel 1 juta baris
baca 4 kolom : 2.1 ms
baca 1 kolom : 0.9 ms
skenario 4: penempatan agregasi sebelum join
join lalu agregasi : 11.0 ms
agregasi lalu join : 8.7 ms
hasil identik : TrueTiga dari empat skenario mempertimbangkan dua hasil yang identik, jadi perbedaan waktu murni biaya eksekusi. Skenario 1 memperlihatkan smart key YYYYMMDD dari Bagian 2 menghindari penguraian satu juta string tanggal. Skenario 2 memperlihatkan join integer jauh lebih murah daripada join string, alasan utama surrogate key pada Bagian 3. Skenario 3 memperlihatkan sifat kolom DuckDB: membaca satu kolom jauh lebih murah daripada empat. Skenario 4 selisihnya paling tipis; optimizer DuckDB sendiri sudah memindahkan agregasi ke depan pada banyak pola, jadi menulis pra-agregasi secara eksplisit tetap aman tetapi jarang menjadi penyelamat utama.
Membaca EXPLAIN ANALYZE #
Angka milidetik menunjukkan berapa cepat; EXPLAIN ANALYZE menjelaskan mengapa. Skrip demo_rencana.py menjalankan EXPLAIN ANALYZE atas kedua query skenario 2. Output yang Dihasilkan (potongan rencana sisi naive; blok PROJECTION di tengah dipangkas agar fokus pada operator join dan pemindaian):
│ HASH_JOIN │
│ ──────────────────── │
│ Join Type: INNER │
│ │
│ Conditions: │
│pelanggan_id = pelanggan_id├──────────────┐
│ │ │
│ │ │
│ │ │
│ 869,564 rows │ │
│ 0.07s │ │
└─────────────┬─────────────┘ │
┌─────────────┴─────────────┐┌─────────────┴─────────────┐
│ TABLE_SCAN ││ TABLE_SCAN │
│ ──────────────────── ││ ──────────────────── │
│ Table: ││ Table: │
│ memory.main.bench_mentah ││ memory.main │
│ ││ .bench_dim_pelanggan │
│ Type: Sequential Scan ││ │
│ ││ Type: Sequential Scan │
│ Projections: ││ │
│ pelanggan_id ││ Projections: │
│ total ││ pelanggan_id │
│ ││ kota │
│ Filters: ││ │
│ status='selesai' ││ │Dan potongan yang sama pada sisi star schema:
Output yang Dihasilkan (potongan rencana sisi star, pemangkasan serupa):
│ HASH_JOIN │
│ ──────────────────── │
│ Join Type: INNER │
│ │
│ Conditions: │
│ CAST(pelanggan_sk AS │
│ BIGINT) = pelanggan_sk ├──────────────┐
│ │ │
│ │ │
│ │ │
│ 869,564 rows │ │
│ 0.01s │ │
└─────────────┬─────────────┘ │
┌─────────────┴─────────────┐┌─────────────┴─────────────┐
│ TABLE_SCAN ││ TABLE_SCAN │
│ ──────────────────── ││ ──────────────────── │
│ Table: ││ Table: │
│ memory.main.bench_fact ││ memory.main │
│ ││ .bench_dim_pelanggan │
│ Type: Sequential Scan ││ │
│ ││ Type: Sequential Scan │
│ Projections: ││ │
│ pelanggan_sk ││ Projections: │
│ total ││ pelanggan_sk │
│ ││ kota │
│ Filters: ││ │
│ status='selesai' ││ │Tiga bukti terbaca langsung. Pertama, Projections pada TABLE_SCAN hanya memuat dua kolom per tabel: DuckDB memang hanya membaca kolom yang dipakai query, dan memilih kolom secukupnya adalah optimasi gratis yang kita ukur pada skenario 3. Kedua, kondisi join berubah dari perbandingan string pelanggan_id menjadi perbandingan integer pelanggan_sk; total waktu eksekusi pada header rencana turun dari 0.0592s menjadi 0.0143s. Ketiga, kedua rencana memproses 869.564 baris hasil join yang sama, jadi perbedaan waktu murni berasal dari bentuk data, bukan jumlah baris. Dokumentasi EXPLAIN ANALYZE dan perilaku penyimpanan kolom DuckDB ada di https://duckdb.org/docs/
.
Desain kini terukur manfaatnya. Satu kewajiban terakhir menutup proyek: menjelaskan model ini kepada orang yang belum pernah membaca sembilan bagian sebelumnya, tugas Bagian 9.