Sejak chapter pertama section ini, view yang kita buat menyimpan definisi query: setiap kali view dibaca, PostgreSQL menjalankan ulang query di baliknya terhadap tabel dasar. Pola itu menyegarkan data secara otomatis, tetapi menjadi beban ketika query-nya adalah laporan agregat berat — pengelompokan jutaan baris penjualan dijalankan ulang setiap kali dashboard membuka halaman yang sama. Materialized view mengambil jalur yang berbeda: saat dibuat, query dijalankan satu kali dan hasilnya ditulis ke penyimpanan fisik seperti tabel biasa. Pembacaan berikutnya cukup memindai salinan kecil yang sudah jadi, bukan menghitung ulang agregasi dari nol. Imbalannya ada, dan babak konsekuensinya besar: hasil yang disimpan tidak berubah sendiri ketika data dasar berubah — data menjadi stale sampai diperbarui secara eksplisit dengan REFRESH MATERIALIZED VIEW, yang menjadi chapter berikutnya.
Query yang Dibekukan Menjadi Tabel #
Mekanisme materialized view paling mudah dipahami dengan membandingkannya dengan dua saudaranya. CREATE TABLE ... AS query menjalankan query sekali lalu menyimpan hasilnya — tetapi hubungan dengan query aslinya putus: tidak ada definisi yang tersimpan, tidak ada cara menyegarkan. View biasa menyimpan definisi query — tetapi tidak pernah menyimpan hasilnya. CREATE MATERIALIZED VIEW menggabungkan keduanya: definisi query disimpan di catalog seperti view, dan hasil eksekusi pertamanya ditulis ke penyimpanan fisik seperti tabel. Karena memiliki isi fisik, materialized view bisa di-query, di-index, dan dihitung ukurannya; karena definisinya tersimpan, isi itu bisa dibangkitkan ulang lewat perintah refresh.
Sisa chapter ini membuktikan tiap klaim tersebut dengan satu kasus berkelanjutan: laporan ringkasan penjualan per penjual di Toko Maju Jaya.
Tabel Latihan: penjualan #
Data latihan kita adalah sembilan transaksi sepanjang Januari 2026 — cukup kecil untuk dibaca sekali duduk, cukup bervariasi untuk menghasilkan ringkasan yang berarti:
-- Tabel latihan chapter Materialized View:
-- transaksi penjualan Toko Maju Jaya sepanjang Januari 2026.
CREATE TABLE penjualan (
id integer PRIMARY KEY,
tanggal date NOT NULL,
penjual text NOT NULL,
produk text NOT NULL,
jumlah integer NOT NULL, -- unit terjual
harga numeric(10,2) NOT NULL -- harga satuan, rupiah
);
INSERT INTO penjualan (id, tanggal, penjual, produk, jumlah, harga) VALUES
( 1, DATE '2026-01-05', 'Budi', 'Kopi Gayo', 10, 85000.00),
( 2, DATE '2026-01-08', 'Siti', 'Teh Melati', 25, 35000.00),
( 3, DATE '2026-01-12', 'Andi', 'Gula Merah', 40, 15000.00),
( 4, DATE '2026-01-15', 'Budi', 'Kopi Toraja', 8, 90000.00),
( 5, DATE '2026-01-20', 'Dewi', 'Kopi Gayo', 12, 85000.00),
( 6, DATE '2026-01-22', 'Siti', 'Teh Hijau', 15, 40000.00),
( 7, DATE '2026-01-25', 'Andi', 'Gula Merah', 30, 15000.00),
( 8, DATE '2026-01-28', 'Dewi', 'Kopi Gayo', 6, 85000.00),
( 9, DATE '2026-01-30', 'Budi', 'Teh Melati', 20, 35000.00);
SELECT id, tanggal, penjual, produk, jumlah, harga
FROM penjualan
ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 9
id | tanggal | penjual | produk | jumlah | harga
----+------------+---------+-------------+--------+----------
1 | 2026-01-05 | Budi | Kopi Gayo | 10 | 85000.00
2 | 2026-01-08 | Siti | Teh Melati | 25 | 35000.00
3 | 2026-01-12 | Andi | Gula Merah | 40 | 15000.00
4 | 2026-01-15 | Budi | Kopi Toraja | 8 | 90000.00
5 | 2026-01-20 | Dewi | Kopi Gayo | 12 | 85000.00
6 | 2026-01-22 | Siti | Teh Hijau | 15 | 40000.00
7 | 2026-01-25 | Andi | Gula Merah | 30 | 15000.00
8 | 2026-01-28 | Dewi | Kopi Gayo | 6 | 85000.00
9 | 2026-01-30 | Budi | Teh Melati | 20 | 35000.00
(9 rows)Membuat Materialized View Pertama #
Laporan yang ingin dibekukan adalah ringkasan per penjual: jumlah transaksi, total item, dan total nilai rupiah. Agar perbedaannya kelihatan jelas, kita buat dua objek dengan query inti yang persis sama — satu view biasa dan satu materialized view:
-- Dua objek dengan query inti yang persis sama:
-- view biasa hanya menyimpan definisi query,
-- materialized view menyimpan definisi DAN hasil eksekusinya.
CREATE VIEW ringkasan_penjual_view AS
SELECT penjual,
count(*) AS transaksi,
sum(jumlah) AS total_item,
sum(jumlah * harga) AS total_nilai
FROM penjualan
GROUP BY penjual;
CREATE MATERIALIZED VIEW ringkasan_penjual AS
SELECT penjual,
count(*) AS transaksi,
sum(jumlah) AS total_item,
sum(jumlah * harga) AS total_nilai
FROM penjualan
GROUP BY penjual;
-- Saat ini hasil keduanya identik: materialized view baru saja
-- menyalin hasil eksekusi pertamanya ke penyimpanan fisik.
SELECT penjual, transaksi, total_item, total_nilai
FROM ringkasan_penjual_view
ORDER BY penjual;
SELECT penjual, transaksi, total_item, total_nilai
FROM ringkasan_penjual
ORDER BY penjual;Output yang Dihasilkan:
CREATE VIEW
SELECT 4
penjual | transaksi | total_item | total_nilai
---------+-----------+------------+-------------
Andi | 2 | 70 | 1050000.00
Budi | 3 | 38 | 2270000.00
Dewi | 2 | 18 | 1530000.00
Siti | 2 | 40 | 1475000.00
(4 rows)
penjual | transaksi | total_item | total_nilai
---------+-----------+------------+-------------
Andi | 2 | 70 | 1050000.00
Budi | 3 | 38 | 2270000.00
Dewi | 2 | 18 | 1530000.00
Siti | 2 | 40 | 1475000.00
(4 rows)Mari kita bedah perintah pembuatannya baris per baris:
CREATE MATERIALIZED VIEW ringkasan_penjual AS— sintaksnya meniruCREATE VIEW; yang berubah hanya kata kunciMATERIALIZED. PostgreSQL menjalankan query di bawahnya satu kali, lalu menulis empat baris hasilnya ke relasi fisik baru bernamaringkasan_penjual(perhatikan tag outputSELECT 4, bukanCREATE VIEW— server sungguh-sungguh mengeksekusi query-nya).- Daftar
SELECTdengan alias — kolom hasil materialized view ditentukan oleh alias di query definisi, sama seperti pada view:penjual,transaksi,total_item,total_nilai. - Dua
SELECTpenutup — keduanya mengembalikan hasil identik karena materialized view baru selesai menyalin keadaan tabel saat penciptaan. Kecocokan ini sementara; paragraf berikutnya memperlihatkan kapan jalurnya berpisah.
View Biasa vs Materialized View #
Karena perbedaan keduanya berada di balik layar, bukti terbaiknya justru di catalog dan fungsi ukuran. Query berikut membaca pg_class — catalog yang mencatat setiap relasi — dan mengukur penyimpanan fisik tiap objek dengan pg_relation_size():
-- pg_class mencatat jenis setiap relasi (kolom relkind)
-- dan ukuran fisiknya di penyimpanan:
-- r = tabel biasa, v = view, m = materialized view.
SELECT c.relname,
CASE c.relkind
WHEN 'r' THEN 'tabel'
WHEN 'v' THEN 'view'
WHEN 'm' THEN 'materialized view'
END AS jenis,
pg_relation_size(c.oid) AS ukuran_byte,
pg_size_pretty(pg_relation_size(c.oid)) AS ukuran
FROM pg_class c
WHERE c.relname IN ('penjualan', 'ringkasan_penjual_view', 'ringkasan_penjual')
ORDER BY c.relname;Output yang Dihasilkan:
relname | jenis | ukuran_byte | ukuran
------------------------+-------------------+-------------+------------
penjualan | tabel | 8192 | 8192 bytes
ringkasan_penjual | materialized view | 8192 | 8192 bytes
ringkasan_penjual_view | view | 0 | 0 bytes
(3 rows)Hasilnya lugas: view biasa berukuran 0 byte — tidak punya penyimpanan sendiri, hanya definisi — sedangkan materialized view menempati 8192 byte, satu halaman penuh, sama seperti tabel penjualan yang menjadi sumbernya. Nilai relkind-nya pun berbeda: v untuk view, m untuk materialized view, r untuk tabel biasa. Perbandingan lengkapnya rangkum dalam tabel berikut:
| Aspek | View biasa | Materialized view |
|---|---|---|
| Yang disimpan | definisi query saja | definisi query + hasil eksekusi terakhir |
| Penyimpanan fisik sendiri | tidak ada (0 byte) | ada, seperti tabel |
| Saat dibaca | query dieksekusi ulang setiap kali | salinan hasil dibaca langsung |
| Kebaruan data | selalu mengikuti tabel dasar | stale sejak tabel dasar berubah |
| Cocok untuk | query ringan, data harus segar | laporan agregat berat yang dibaca sering |
| Index sendiri | tidak bisa | bisa, termasuk index unik |
Salinan Lama Terus Tampil Sampai Di-refresh #
Inilah sisi yang harus diinternalisasi sebelum memakai materialized view di sistem nyata. Transaksi baru masuk ke tabel penjualan, lalu kita baca kedua objek ringkasan secara berurutan:
-- Satu transaksi baru masuk ke tabel dasar...
INSERT INTO penjualan (id, tanggal, penjual, produk, jumlah, harga) VALUES
(10, DATE '2026-01-31', 'Siti', 'Teh Melati', 10, 35000.00);
-- ...dan view biasa langsung melihatnya: agregat Siti berubah
-- menjadi 3 transaksi, 50 item, 1825000.00 rupiah.
SELECT penjual, transaksi, total_item, total_nilai
FROM ringkasan_penjual_view
ORDER BY penjual;
-- Materialized view TIDAK berubah: masih menampilkan salinan lama
-- (Siti 2 transaksi, 40 item) karena hasilnya tersimpan saat dibuat,
-- bukan dihitung ulang saat query dijalankan.
SELECT penjual, transaksi, total_item, total_nilai
FROM ringkasan_penjual
ORDER BY penjual;Output yang Dihasilkan:
INSERT 0 1
penjual | transaksi | total_item | total_nilai
---------+-----------+------------+-------------
Andi | 2 | 70 | 1050000.00
Budi | 3 | 38 | 2270000.00
Dewi | 2 | 18 | 1530000.00
Siti | 3 | 50 | 1825000.00
(4 rows)
penjual | transaksi | total_item | total_nilai
---------+-----------+------------+-------------
Andi | 2 | 70 | 1050000.00
Budi | 3 | 38 | 2270000.00
Dewi | 2 | 18 | 1530000.00
Siti | 2 | 40 | 1475000.00
(4 rows)Pada hasil eksekusi, agregat Siti di view biasa naik menjadi 3 transaksi dan 50 item, sedangkan di materialized view angkanya bertahan di 2 transaksi dan 40 item — padahal keduanya menjalankan query definisi yang sama. View biasa selalu segar karena setiap bacaan adalah eksekusi ulang; materialized view membaca salinan yang dibekukan pada saat terakhir diisi. Tidak ada mekanisme otomatis yang menyinkronkannya — tidak ada trigger tersembunyi, tidak ada deteksi perubahan.
Index di Atas Materialized View #
Karena materialized view punya penyimpanan fisik, PostgreSQL mengizinkannya memiliki index sendiri — hak yang tidak dimiliki view biasa, yang index-nya selalu milik tabel dasar. Kita buat satu index unik di atas kolom penjual:
-- Materialized view boleh memiliki index sendiri -- sesuatu yang
-- tidak dimiliki view biasa. Index unik pada kolom kelompok juga
-- menjadi syarat REFRESH MATERIALIZED VIEW ... CONCURRENTLY,
-- yang dibahas di chapter berikutnya.
CREATE UNIQUE INDEX idx_unik_penjual ON ringkasan_penjual (penjual);
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'ringkasan_penjual'
ORDER BY indexname;Output yang Dihasilkan:
CREATE INDEX
indexname | indexdef
------------------+----------------------------------------------------------------------------------------
idx_unik_penjual | CREATE UNIQUE INDEX idx_unik_penjual ON public.ringkasan_penjual USING btree (penjual)
(1 row)Index unik ini bekerja persis seperti index unik pada tabel biasa: memaksa nilai penjual unik di dalam salinan (dijamin oleh GROUP BY), mempercepat filter dan join atas ringkasan, dan — ini yang akan segera relevan — menjadi prasyarat bagi varian refresh yang tidak mengunci pembaca. Penampilan indexdef di pg_indexes juga menegaskan statusnya: CREATE UNIQUE INDEX ... ON public.ringkasan_penjual, persis seperti index tabel.
Materialized view kini lengkap gambarnya: definisi tersimpan, hasil dibekukan di penyimpanan fisik, index milik sendiri terpasang. Satu pertanyaan tersisa — bagaimana caranya memperbarui salinan yang mulai kedaluwarsa tanpa membongkar dan membuat ulang objeknya? Jawabannya adalah perintah REFRESH MATERIALIZED VIEW beserta dua variannya, yang dibahas tuntas di chapter Refresh Materialized View
.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-matview.sql
Unduh berkas-- Dua objek dengan query inti yang persis sama:
-- view biasa hanya menyimpan definisi query,
-- materialized view menyimpan definisi DAN hasil eksekusinya.
CREATE VIEW ringkasan_penjual_view AS
SELECT penjual,
count(*) AS transaksi,
sum(jumlah) AS total_item,
sum(jumlah * harga) AS total_nilai
FROM penjualan
GROUP BY penjual;
CREATE MATERIALIZED VIEW ringkasan_penjual AS
SELECT penjual,
count(*) AS transaksi,
sum(jumlah) AS total_item,
sum(jumlah * harga) AS total_nilai
FROM penjualan
GROUP BY penjual;
-- Saat ini hasil keduanya identik: materialized view baru saja
-- menyalin hasil eksekusi pertamanya ke penyimpanan fisik.
SELECT penjual, transaksi, total_item, total_nilai
FROM ringkasan_penjual_view
ORDER BY penjual;
SELECT penjual, transaksi, total_item, total_nilai
FROM ringkasan_penjual
ORDER BY penjual;
buat-tabel.sql
Unduh berkas-- Tabel latihan chapter Materialized View:
-- transaksi penjualan Toko Maju Jaya sepanjang Januari 2026.
CREATE TABLE penjualan (
id integer PRIMARY KEY,
tanggal date NOT NULL,
penjual text NOT NULL,
produk text NOT NULL,
jumlah integer NOT NULL, -- unit terjual
harga numeric(10,2) NOT NULL -- harga satuan, rupiah
);
INSERT INTO penjualan (id, tanggal, penjual, produk, jumlah, harga) VALUES
( 1, DATE '2026-01-05', 'Budi', 'Kopi Gayo', 10, 85000.00),
( 2, DATE '2026-01-08', 'Siti', 'Teh Melati', 25, 35000.00),
( 3, DATE '2026-01-12', 'Andi', 'Gula Merah', 40, 15000.00),
( 4, DATE '2026-01-15', 'Budi', 'Kopi Toraja', 8, 90000.00),
( 5, DATE '2026-01-20', 'Dewi', 'Kopi Gayo', 12, 85000.00),
( 6, DATE '2026-01-22', 'Siti', 'Teh Hijau', 15, 40000.00),
( 7, DATE '2026-01-25', 'Andi', 'Gula Merah', 30, 15000.00),
( 8, DATE '2026-01-28', 'Dewi', 'Kopi Gayo', 6, 85000.00),
( 9, DATE '2026-01-30', 'Budi', 'Teh Melati', 20, 35000.00);
SELECT id, tanggal, penjual, produk, jumlah, harga
FROM penjualan
ORDER BY id;
data-stale.sql
Unduh berkas-- Satu transaksi baru masuk ke tabel dasar...
INSERT INTO penjualan (id, tanggal, penjual, produk, jumlah, harga) VALUES
(10, DATE '2026-01-31', 'Siti', 'Teh Melati', 10, 35000.00);
-- ...dan view biasa langsung melihatnya: agregat Siti berubah
-- menjadi 3 transaksi, 50 item, 1825000.00 rupiah.
SELECT penjual, transaksi, total_item, total_nilai
FROM ringkasan_penjual_view
ORDER BY penjual;
-- Materialized view TIDAK berubah: masih menampilkan salinan lama
-- (Siti 2 transaksi, 40 item) karena hasilnya tersimpan saat dibuat,
-- bukan dihitung ulang saat query dijalankan.
SELECT penjual, transaksi, total_item, total_nilai
FROM ringkasan_penjual
ORDER BY penjual;
indeks-unik.sql
Unduh berkas-- Materialized view boleh memiliki index sendiri -- sesuatu yang
-- tidak dimiliki view biasa. Index unik pada kolom kelompok juga
-- menjadi syarat REFRESH MATERIALIZED VIEW ... CONCURRENTLY,
-- yang dibahas di chapter berikutnya.
CREATE UNIQUE INDEX idx_unik_penjual ON ringkasan_penjual (penjual);
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'ringkasan_penjual'
ORDER BY indexname;
ukuran-fisik.sql
Unduh berkas-- pg_class mencatat jenis setiap relasi (kolom relkind)
-- dan ukuran fisiknya di penyimpanan:
-- r = tabel biasa, v = view, m = materialized view.
SELECT c.relname,
CASE c.relkind
WHEN 'r' THEN 'tabel'
WHEN 'v' THEN 'view'
WHEN 'm' THEN 'materialized view'
END AS jenis,
pg_relation_size(c.oid) AS ukuran_byte,
pg_size_pretty(pg_relation_size(c.oid)) AS ukuran
FROM pg_class c
WHERE c.relname IN ('penjualan', 'ringkasan_penjual_view', 'ringkasan_penjual')
ORDER BY c.relname;