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:

sql buat-tabel.sql
-- 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:

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

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

  1. CREATE MATERIALIZED VIEW ringkasan_penjual AS — sintaksnya meniru CREATE VIEW; yang berubah hanya kata kunci MATERIALIZED. PostgreSQL menjalankan query di bawahnya satu kali, lalu menulis empat baris hasilnya ke relasi fisik baru bernama ringkasan_penjual (perhatikan tag output SELECT 4, bukan CREATE VIEW — server sungguh-sungguh mengeksekusi query-nya).
  2. Daftar SELECT dengan alias — kolom hasil materialized view ditentukan oleh alias di query definisi, sama seperti pada view: penjual, transaksi, total_item, total_nilai.
  3. Dua SELECT penutup — 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:

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

AspekView biasaMaterialized view
Yang disimpandefinisi query sajadefinisi query + hasil eksekusi terakhir
Penyimpanan fisik sendiritidak ada (0 byte)ada, seperti tabel
Saat dibacaquery dieksekusi ulang setiap kalisalinan hasil dibaca langsung
Kebaruan dataselalu mengikuti tabel dasarstale sejak tabel dasar berubah
Cocok untukquery ringan, data harus segarlaporan agregat berat yang dibaca sering
Index sendiritidak bisabisa, 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:

sql data-stale.sql
-- 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:

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

sql indeks-unik.sql
-- 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:

text
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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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;