Star schema adalah cara menyusun tabel di warehouse supaya pertanyaan bisnis gampang dijawab: satu fact table di tengah yang mencatat kejadian beserta angkanya, dikelilingi dimension table yang menjelaskan siapa, apa, dan kapan. Bayangin gulungan struk kasir. Tiap baris struk cuma menulis kode barang, kode member, tanggal, jumlah, dan harga. Nama barang dan penulisnya ada di katalog, nama dan kota member ada di daftar member, dan "hari apa, minggu ke berapa" ada di kalender. Gulungan struk itu fact table; katalog, daftar member, dan kalender itu dimension table. Kalau digambar, fact di tengah dengan dimensi di sekelilingnya, bentuknya seperti bintang.
🧪 Tebakan cepat: Sabtu 10 Oktober, harga Bumi Manusia turun dari 125.000 jadi 119.000. Tiap malam pipeline membangun ulang
fact_penjualan, dan harganya diambil daridim_buku. Omzet minggu 28 September tadinya Rp 1.370.000. Setelah pipeline jalan lagi, jadi berapa? Tebak dulu, jawabannya ada di bagian "Slowly changing dimension: harga yang berubah".
Grain dulu: satu baris itu apa?
Sebelum membuat tabel apa pun, tentukan grain: satu baris fact table mewakili apa. Untuk Toko Buku, grain-nya satu buku dalam satu pesanan, alias satu baris pesanan (order line). Datanya yang sudah bersih: 18 baris, 21 buku, Rp 2.214.000.
Kenapa gak satu baris per pesanan? Pesanan 2 berisi Bumi Manusia dan Cantik Itu Luka. Kalau dilipat jadi satu baris, judulnya hilang, dan pertanyaan pemilik ("buku apa paling laku per kota per minggu?") gak bisa dijawab lagi. Pilih grain paling halus yang ada di sumber: yang halus bisa dijumlah jadi kasar, sebaliknya gak bisa.
Fact table: kunci dan angka
Fact table berisi dua macam kolom: kunci ke tiap dimensi, dan measure, angka yang bisa dijumlah.
CREATE TABLE fact_penjualan (
order_id INT, -- nomor pesanan, gak punya tabel dimensi sendiri
tanggal_key INT, -- ke dim_tanggal, misalnya 20261001
buku_key INT, -- ke dim_buku
pelanggan_key INT, -- ke dim_pelanggan
qty INT, -- measure: jumlah buku
harga INT, -- measure: harga per buku saat itu
subtotal INT -- measure: qty * harga
);
Diisi dari tabel aplikasi, sambil menerapkan aturan bersih dari pelajaran sebelumnya:
INSERT INTO fact_penjualan
SELECT i.order_id,
CAST(strftime('%Y%m%d', o.created_at) AS INT),
b.buku_key,
p.pelanggan_key,
i.qty,
b.harga,
i.qty * b.harga
FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY order_id, book_id ORDER BY id) AS ke
FROM order_items) i
JOIN orders o ON o.id = i.order_id
JOIN dim_pelanggan p ON p.customer_id = o.customer_id
JOIN dim_buku b ON b.book_id = i.book_id
AND date(o.created_at) >= b.valid_from
AND date(o.created_at) < b.valid_to
WHERE i.ke = 1;
ROW_NUMBER() ... WHERE i.ke = 1membuang item 9, baris dobel dari job yang di-retry.b.hargamengambil harga dari katalog, jadi item 16 dapat 95.000, bukan 950.000. Toko Buku gak kasih diskon, jadi harga katalog hari itu = harga yang dibayar.valid_fromdanvalid_tomemilih versi buku yang berlaku di tanggal pesanan. Gunanya kelihatan di bagian SCD.
| order_id | tanggal_key | buku_key | pelanggan_key | qty | harga | subtotal |
|---|---|---|---|---|---|---|
| 1 | 20260928 | 1 | 1 | 1 | 89000 | 89000 |
| 2 | 20260928 | 2 | 2 | 1 | 125000 | 125000 |
| 2 | 20260928 | 3 | 2 | 1 | 110000 | 110000 |
| 6 | 20261001 | 2 | 1 | 2 | 125000 | 250000 |
Isinya cuma kunci dan angka. Itu sebabnya fact table yang menyimpan metrik yang bisa diagregasi (SUM(qty), SUM(subtotal)), sedangkan teks penjelasnya ada di dimensi.
Dimension table: konteks yang dibaca manusia
Tiga dimensi Toko Buku, satu baris per "benda":
| Tabel | Satu baris = | Kolom |
|---|---|---|
dim_buku |
satu versi buku | buku_key, book_id, judul, penulis, harga, valid_from, valid_to, is_current |
dim_tanggal |
satu hari | tanggal_key (20261001), tanggal, hari (Kamis), minggu (Senin awal minggunya), akhir_pekan |
dim_pelanggan |
satu pelanggan | pelanggan_key, customer_id, nama, kota (Maya: Tidak diketahui) |
buku_keydanpelanggan_keyadalah surrogate key: nomor buatan warehouse, terpisah dariidaplikasi (natural key, disimpan sebagaibook_iddancustomer_id). Awalnya angkanya kebetulan sama. Begitu satu buku punya dua versi, satubook_idpunya duabuku_key.dim_tanggaldibuat duluan untuk semua hari (28 September sampai 11 Oktober: 14 baris), termasuk hari tanpa penjualan seperti 3 Oktober. Hari dan minggu dihitung sekali di sini, bukan di tiap query.penulisdisalin langsung kedim_buku, dankotakedim_pelanggan. Ini denormalisasi, dan disengaja.
Pertanyaan pemilik di star schema
WITH per_judul AS (
SELECT t.minggu, p.kota, b.judul, SUM(f.qty) AS terjual
FROM fact_penjualan f
JOIN dim_tanggal t ON t.tanggal_key = f.tanggal_key
JOIN dim_pelanggan p ON p.pelanggan_key = f.pelanggan_key
JOIN dim_buku b ON b.buku_key = f.buku_key
GROUP BY t.minggu, p.kota, b.judul
),
peringkat AS (
SELECT *, RANK() OVER (PARTITION BY minggu, kota ORDER BY terjual DESC) AS posisi
FROM per_judul
)
SELECT minggu, kota, judul, terjual
FROM peringkat
WHERE posisi = 1
ORDER BY minggu, kota, judul;
Hasilnya 9 baris yang sama dengan pelajaran SQL: Jakarta minggu 28 September Laskar Pelangi 3, Surabaya minggu 5 Oktober seri. Bedanya, pembersihan, kalender, dan nama kota sudah dikerjakan sekali waktu tabelnya dibangun. Query-nya tinggal fact plus satu JOIN per dimensi.
Star atau snowflake
Kalau dimensinya dinormalisasi lagi, penulis pindah ke dim_penulis dan kota ke tabel kota sendiri, bentuknya bercabang seperti kepingan salju: snowflake schema. Pertanyaan "buku terjual per penulis per kota" butuh 2 JOIN di star schema (dim_buku, dim_pelanggan) dan 4 JOIN di snowflake.
Di database aplikasi (OLTP), normalisasi penuh (3NF) itu benar: satu fakta disimpan sekali, jadi update gak bikin data saling bertentangan. Warehouse beda kebutuhannya: dibaca jauh lebih sering daripada diubah, dan yang mengubah cuma pipeline. Keuntungan star schema dibanding 3NF: query lebih sederhana dan JOIN lebih cepat karena dimensinya didenormalisasi. Harganya, teks seperti "Pramoedya Ananta Toer" ditulis berulang. Warehouse menyimpan data per kolom dan memampatkan nilai yang berulang, jadi biayanya kecil.
Slowly changing dimension: harga yang berubah
Dimensi jarang berubah, tapi bisa. Sabtu 10 Oktober, harga Bumi Manusia turun jadi 119.000, dan pagi itu Siti membeli satu (pesanan 15, pukul 10:15:30). Cara warehouse menangani perubahan ini disebut SCD (slowly changing dimension).
SCD tipe 1: timpa.
UPDATE dim_buku SET harga = 119000 WHERE book_id = 2;
Malamnya pipeline membangun ulang fact, dan semua baris Bumi Manusia, termasuk pesanan 28 September, sekarang 119.000. Omzet minggu 28 September berubah sendiri dari Rp 1.370.000 jadi Rp 1.346.000 (4 buku × 6.000 hilang). Laporan minggu lalu berubah padahal gak ada yang terjadi minggu lalu. Itu jawaban tebakannya, untuk tipe 1. Tipe 1 cocok untuk membetulkan salah ketik, bukan untuk perubahan yang punya sejarah.
SCD tipe 2: tambah baris versi baru.
UPDATE dim_buku
SET valid_to = '2026-10-10', is_current = FALSE
WHERE book_id = 2 AND is_current;
INSERT INTO dim_buku
VALUES (5, 2, 'Bumi Manusia', 'Pramoedya Ananta Toer', 119000, '2026-10-10', '9999-12-31', TRUE);
| buku_key | book_id | harga | valid_from | valid_to | is_current |
|---|---|---|---|---|---|
| 2 | 2 | 125000 | 2026-09-01 | 2026-10-10 | 0 |
| 5 | 2 | 119000 | 2026-10-10 | 9999-12-31 | 1 |
- Versi lama ditutup:
valid_todiisi tanggal perubahan,is_currentjadiFALSE.9999-12-31artinya "masih berlaku". - Fact load tadi memilih versi dengan
tanggal >= valid_from AND tanggal < valid_to. Pesanan sebelum 10 Oktober dapatbuku_key2 (125.000), pesanan 15 dapatbuku_key5 (119.000). - Omzet minggu 28 September tetap Rp 1.370.000. Minggu 5 Oktober jadi Rp 963.000 (844.000 ditambah pesanan 15).
Tipe 3 (simpan nilai sebelumnya di kolom tambahan, misalnya harga_sebelumnya) cuma ingat satu perubahan terakhir, jadi jarang dipakai.
⚠️ Jebakan yang sering ketemu
- Grain campur. Menaruh ongkos kirim per pesanan di baris per buku bikin ongkirnya terjumlah sekali per buku. Satu fact table, satu grain.
- JOIN ke dimensi tipe 2 tanpa rentang tanggal.
JOIN dim_buku b ON b.book_id = i.book_idsaja membuat tiap baris Bumi Manusia cocok dengan dua versi: fact jadi 25 baris dan 29 buku, bukan 19 dan 22. - GROUP BY surrogate key di laporan. Setelah tipe 2,
GROUP BY buku_keymemecah Bumi Manusia jadi dua baris. Kelompokkan perjudulataubook_id. - Memakai id aplikasi sebagai kunci dimensi. Satu
book_idbisa punya banyak versi, jadi id aplikasi gak bisa jadi primary key dimensi.
TL;DR: Star schema = satu fact table di tengah (kunci ke dimensi + measure yang bisa dijumlah: qty, harga, subtotal) dan dimension table di sekelilingnya (teks penjelas: dim_buku, dim_tanggal, dim_pelanggan). Tentukan grain dulu; Toko Buku: satu buku dalam satu pesanan, 18 baris. Dimensi punya surrogate key sendiri (buku_key) terpisah dari natural key (book_id). Dimensi didenormalisasi (penulis ikut di dim_buku), jadi query cuma butuh satu JOIN per dimensi: lebih sederhana dan lebih cepat daripada snowflake atau 3NF. Kalau dimensi berubah: SCD tipe 1 menimpa dan menulis ulang sejarah (minggu 28 September jadi Rp 1.346.000), SCD tipe 2 menambah versi dengan valid_from, valid_to, is_current sehingga pesanan lama tetap memakai harga lamanya (tetap Rp 1.370.000).