PostgreSQL'de View ve Materialized View: Updatable View, security_invoker, REFRESH CONCURRENTLY ve pg_cron ile Zamanlama
PostgreSQL'de materialized view sonucu diske yazar ve yalnızca REFRESH ile güncellenir; düz REFRESH okumaları kilitler, CONCURRENTLY kilitlemez ama unique index ister. View'ın SELECT * ve kolon değişikliği kısıtları, otomatik updatable view ve WITH CHECK OPTION, RLS'li tabloda security_invoker'ın farkı, pg_cron ile 10 dakikada bir refresh, EF Core'da keyless entity eşlemesi ve ne zaman ayrı read model tablosu.
Sürüm notu: Bu yazıdaki örnekler PostgreSQL 18.7 ve pg_cron 1.6.8 üzerinde (13. yazıdaki Dockerfile ile resmî postgres:18 image'ına PGDG (PostgreSQL topluluğunun resmî paket deposu) paketinden pg_cron eklenmiş container), Bookstore şeması yüklendikten hemen sonra çalıştırılmıştır; .NET örneği .NET 10, Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 ve Entity Framework Core (EF Core) 10 ile derlenip denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı), katalog ekranı için kitapları yazar adıyla birlikte döndüren bir view kullanıyor. Emre view'ı kısa yoldan b.* ile yazmış. Bir süre sonra books'a bir kolon ekleniyor, view'dan bir kolon çıkarılmak isteniyor ve title'ın tipi değiştiriliyor:
CREATE VIEW v_book_list AS
SELECT b.*, a.name AS author_name
FROM books AS b
JOIN authors AS a ON a.author_id = b.author_id;
ALTER TABLE books ADD COLUMN subtitle text;
SELECT * FROM v_book_list WHERE book_id = 1;
CREATE OR REPLACE VIEW v_book_list AS
SELECT b.book_id, b.isbn, b.title, a.name AS author_name
FROM books AS b
JOIN authors AS a ON a.author_id = b.author_id;
ALTER TABLE books ALTER COLUMN title TYPE varchar(300);
Yeni subtitle kolonu view'da görünür mü? CREATE OR REPLACE view'ı kolonları azaltılmış hâliyle değiştirir mi? Tip değişikliği geçer mi?
book_id | isbn | title | author_id | category | price | published_year | author_name
---------+---------------+---------+-----------+----------+-------+----------------+-------------
1 | 9780000000001 | Emma #1 | 1818 | Classic | 32.77 | 1885 | Ken Turing
ERROR: cannot drop columns from view
ERROR: cannot alter type of a column used by a view or rule
DETAIL: rule _RETURN on view v_book_list depends on column "title"
Üçünün cevabı da "hayır". *, view oluşturulurken o anki kolon listesine açılıyor ve view'a öyle kaydediliyor; sonradan eklenen subtitle view'a girmiyor. CREATE OR REPLACE VIEW yalnızca kolon eklemeye izin veriyor, var olan kolonu silemiyor, adını ya da tipini değiştiremiyor. Bir view'ın kullandığı kolonun tipi de view durdukça değiştirilemiyor. SQL Server'da da SELECT *'lı view'lar eski kolon listesinde kalır (sp_refreshview gerekir), ama tip değişikliği engellenmez; PostgreSQL ise bağımlılığı sıkı tutuyor. Bu yazıda view'ın bu kurallarını, updatable view'ları, view'ın güvenlikle ilişkisini, materialized view'ı ve onu pg_cron ile zamanlamayı görüyoruz.
View'ın Kısıtları: CREATE OR REPLACE ve Bağımlılıklar
View'ın gerçek tanımını ve tabloya bağlı view'ları catalog'dan okumak mümkün:
ALTER TABLE books DROP COLUMN subtitle;
SELECT pg_get_viewdef('v_book_list');
SELECT DISTINCT v.relname AS dependent_view
FROM pg_depend AS d
JOIN pg_rewrite AS r ON r.oid = d.objid
JOIN pg_class AS v ON v.oid = r.ev_class
WHERE d.refobjid = 'books'::regclass AND v.oid <> 'books'::regclass;
ALTER TABLE
pg_get_viewdef
--------------------------------------------------------
SELECT b.book_id, +
b.isbn, +
b.title, +
b.author_id, +
b.category, +
b.price, +
b.published_year, +
a.name AS author_name +
FROM (books b +
JOIN authors a ON ((a.author_id = b.author_id)));
dependent_view
----------------
v_book_list
View'ın kullanmadığı subtitle sorunsuz silindi. Kaydedilen tanımda * yok, yedi kolon tek tek yazılmış. PostgreSQL view'ı bir rewrite rule (_RETURN) olarak saklıyor ve pg_depend'de view ile kullandığı her kolon arasında bağımlılık tutuyor; hata mesajındaki "rule _RETURN" bu yüzden. Yukarıdaki sorgu, bir tabloya dokunmadan önce hangi view'ların etkileneceğini gösteriyor.
Pratikte kolon tipi değiştiren bir migration şu sırayla yazılıyor: bağımlı view'ları DROP VIEW ile sil, tabloyu değiştir, view'ları yeniden oluştur; hepsi tek transaction'da. PostgreSQL'de DDL (Data Definition Language; tablo ve view gibi yapıları değiştiren komutlar) transaction içinde çalıştığı için bu güvenli: bir adım hata verirse her şey geri alınıyor. DROP ... CASCADE kısa yol gibi görünüyor ama view'ların üstüne kurulmuş başka view'ları da sessizce siliyor; listeyi bilmeden kullanma. View'larda SELECT * yazmamak da bu yüzden iyi bir alışkanlık: kolon listesi açıkça yazılınca view'ın neye bağlı olduğu tanımdan okunuyor.
Updatable View ve WITH CHECK OPTION
Tek tablodan okuyan, GROUP BY, DISTINCT, aggregate, UNION gibi yapılar içermeyen bir view PostgreSQL'de otomatik olarak updatable: üzerine yazılan INSERT, UPDATE ve DELETE alttaki tabloya yönlendiriliyor. Bilim kurgu kitaplarını yöneten ekip için bir view:
CREATE VIEW v_scifi_books AS
SELECT book_id, isbn, title, author_id, category, price, published_year
FROM books
WHERE category = 'SciFi';
UPDATE v_scifi_books SET price = price + 1 WHERE book_id = 5 RETURNING book_id, title, price;
INSERT INTO v_scifi_books (isbn, title, author_id, category, price, published_year)
VALUES ('9780000040001', 'The Hobbit #40001', 1, 'Fantasy', 19.90, 1937)
RETURNING book_id;
SELECT count(*) AS visible_in_view FROM v_scifi_books WHERE book_id = 40001;
book_id | title | price
---------+---------------+-------
5 | Foundation #5 | 16.34
book_id
---------
40001
visible_in_view
-----------------
0
UPDATE beklendiği gibi çalıştı. Ama SciFi view'ı üzerinden bir Fantasy kitabı eklemek de hata vermedi: satır tabloya yazıldı ve view'dan hemen kayboldu. View bir yetki sınırı olarak kullanılıyorsa (ekip yalnızca view'a yazabiliyorsa) bu bir açık. WITH CHECK OPTION, view'ın WHERE koşulunu yazılan satıra da uyguluyor:
CREATE OR REPLACE VIEW v_scifi_books AS
SELECT book_id, isbn, title, author_id, category, price, published_year
FROM books
WHERE category = 'SciFi'
WITH LOCAL CHECK OPTION;
INSERT INTO v_scifi_books (isbn, title, author_id, category, price, published_year)
VALUES ('9780000040002', 'The Hobbit #40002', 1, 'Fantasy', 19.90, 1937);
UPDATE v_scifi_books SET category = 'Fantasy' WHERE book_id = 5;
CREATE VIEW v_category_stats AS
SELECT category, count(*) AS books, avg(price) AS avg_price FROM books GROUP BY category;
UPDATE v_category_stats SET books = 0 WHERE category = 'SciFi';
ERROR: new row violates check option for view "v_scifi_books"
DETAIL: Failing row contains (40002, 9780000040002, The Hobbit #40002, 1, Fantasy, 19.90, 1937).
ERROR: new row violates check option for view "v_scifi_books"
DETAIL: Failing row contains (5, 9780000000005, Foundation #5, 2061, Fantasy, 16.34, 1938).
ERROR: cannot update view "v_category_stats"
DETAIL: Views containing GROUP BY are not automatically updatable.
HINT: To enable updating the view, provide an INSTEAD OF UPDATE trigger or an unconditional ON UPDATE DO INSTEAD rule.
Artık hem view dışına satır eklemek hem de var olan satırı view'dan çıkaracak bir güncelleme reddediliyor. LOCAL yalnızca bu view'ın koşulunu, CASCADED (varsayılan) ise üstüne kurulduğu view'ların koşullarını da kontrol ediyor. GROUP BY'lı view otomatik updatable değil; hata mesajı iki yolu söylüyor: INSTEAD OF trigger ya da rule. INSTEAD OF trigger'ları 23. yazıda göreceğiz; rule sistemi ise eski ve sürprizli olduğu için yeni kodda önerilmiyor. Bir view'ın hangi kolonlarının yazılabilir olduğunu information_schema.columns'taki is_updatable gösteriyor.
View ve Güvenlik: security_invoker, security_barrier ve RLS
SQL Server'daki ownership chaining'e benzer biçimde, PostgreSQL'de view varsayılan olarak alttaki tablolara view'ın owner'ının yetkisiyle erişiyor. Bu, kullanıcıya tabloya değil yalnızca view'a yetki vermeyi mümkün kılıyor; ama RLS (Row-Level Security; satır bazında erişim kuralları, 14. yazı) ile birleşince sürpriz çıkarıyor. Müşteri notlarını tutan küçük bir tablo, uygulama rolü için bir policy ve iki view:
CREATE TABLE customer_notes
(
note_id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers (customer_id),
note text NOT NULL
);
INSERT INTO customer_notes (customer_id, note)
SELECT 1 + g % 5, 'note ' || g FROM generate_series(1, 50) AS g;
ALTER TABLE customer_notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY customer_notes_own ON customer_notes FOR SELECT TO bookstore_app
USING (customer_id = current_setting('app.customer_id')::int);
CREATE VIEW v_customer_notes AS SELECT customer_id, note FROM customer_notes;
CREATE VIEW v_customer_notes_invoker WITH (security_invoker = true) AS
SELECT customer_id, note FROM customer_notes;
Aynı sorguyu bookstore_app rolüyle, oturumda müşteri 3 seçiliyken tablodan ve iki view'dan çalıştırdık:
SET app.customer_id = '3';
SELECT 'table' AS source, count(*) AS rows, count(DISTINCT customer_id) AS customers FROM customer_notes
UNION ALL
SELECT 'view (owner)', count(*), count(DISTINCT customer_id) FROM v_customer_notes
UNION ALL
SELECT 'view (security_invoker)', count(*), count(DISTINCT customer_id) FROM v_customer_notes_invoker;
source | rows | customers
-------------------------+------+-----------
table | 10 | 1
view (owner) | 50 | 5
view (security_invoker) | 10 | 1
Tablo doğrudan sorgulanınca policy çalıştı: yalnızca müşteri 3'ün 10 notu. Varsayılan view ise beş müşterinin 50 notunun hepsini döndürdü: view tabloya owner'ın (bookstore_owner) yetkisiyle erişiyor, tablonun owner'ı da RLS'ye takılmıyor. PostgreSQL 15 ile gelen security_invoker = true view'ı sorgulayan kullanıcının yetkileri ve policy'leriyle çalıştırıyor; sonuç yine 10 satır. RLS kullanan bir şemada view yazıyorsan bu seçenek neredeyse her zaman doğru olan. RLS ile multi-tenancy tasarımının ayrıntıları Çözüm Desenleri serisinin 13. yazısında.
security_barrier başka bir sorunu çözüyor. View'ın WHERE'i satırları sınırlıyor olsa bile, planlayıcı kullanıcının sorgusundaki ucuz bir function'ı view'ın koşulundan önce çalıştırabilir; o function argümanını bir hata mesajına ya da RAISE NOTICE'a yazıyorsa, view'ın gizlemesi gereken satırlar sızar. security_barrier ile işaretlenmiş view'da, LEAKPROOF olarak işaretlenmemiş function'lar view'ın koşulundan sonra çalışıyor. Bedeli plan seçeneklerinin azalması; yalnızca view'ı gerçekten bir güvenlik sınırı olarak kullanıyorsan aç.
Materialized View: Sonucu Saklayan View
Normal view yalnızca bir sorgu tanımı; her okumada sorgu yeniden çalışıyor. Materialized view ise sonucu bir tablo gibi diske yazıyor ve ancak açıkça REFRESH edildiğinde güncelleniyor. SQL Server'daki indexed view'dan farkı burada: SQL Server indexed view'ı her INSERT/UPDATE'te eşzamanlı güncelliyor, PostgreSQL ise hiç güncellemiyor, tazeliği sana bırakıyor. Karşılığında kısıt yok: LIMIT, window function, outer join, istediğin her şey olabilir. Bookstore ana sayfasındaki "son 30 günün en çok satan 100 kitabı" için:
CREATE MATERIALIZED VIEW mv_top_books AS
WITH window_start AS (
SELECT max(created_at) - interval '30 days' AS since FROM orders
)
SELECT rank() OVER (ORDER BY sum(oi.quantity) DESC, oi.book_id) AS rank,
oi.book_id, b.title, b.category,
sum(oi.quantity) AS units,
sum(oi.quantity * oi.unit_price) AS revenue
FROM order_items AS oi
JOIN orders AS o ON o.order_id = oi.order_id
JOIN books AS b ON b.book_id = oi.book_id
WHERE o.created_at >= (SELECT since FROM window_start)
AND o.status <> 'Cancelled'
GROUP BY oi.book_id, b.title, b.category
ORDER BY units DESC, oi.book_id
LIMIT 100
WITH NO DATA;
SELECT * FROM mv_top_books LIMIT 3;
REFRESH MATERIALIZED VIEW mv_top_books;
SELECT rank, book_id, title, category, units, revenue FROM mv_top_books ORDER BY rank LIMIT 5;
SELECT pg_size_pretty(pg_total_relation_size('mv_top_books')) AS size, ispopulated
FROM pg_matviews WHERE matviewname = 'mv_top_books';
CREATE MATERIALIZED VIEW
Time: 4.025 ms
ERROR: materialized view "mv_top_books" has not been populated
HINT: Use the REFRESH MATERIALIZED VIEW command.
REFRESH MATERIALIZED VIEW
Time: 158.855 ms
rank | book_id | title | category | units | revenue
------+---------+-----------------+----------+-------+---------
1 | 1 | Emma #1 | Classic | 285 | 9339.45
2 | 2 | The Hobbit #2 | Fantasy | 123 | 4797.00
3 | 4 | Frankenstein #4 | Classic | 90 | 3852.00
4 | 3 | Middlemarch #3 | Classic | 83 | 2962.27
5 | 5 | Foundation #5 | SciFi | 76 | 1165.84
Time: 0.187 ms
size | ispopulated
-------+-------------
32 kB | t
Lab verisi 20 Ekim'de bittiği için "son 30 gün"ü now()'a değil en yeni siparişe göre hesapladık; gerçek sistemde now() - interval '30 days' yazılır. WITH NO DATA ile oluşturulan materialized view boş bir kabuk: okumak hata veriyor. Doldurmak 159 ms sürdü (bu lab'da yaklaşık 21.000 siparişin satırlarını tarayıp grupladı); okumak 0,2 ms. Ana sayfa her açıldığında grup sorgusu yerine 100 satırlık hazır bir tablo okunuyor.
REFRESH ve REFRESH CONCURRENTLY
Düz REFRESH sonucu baştan hesaplayıp materialized view'ın içeriğini tümden değiştiriyor ve bunu yaparken ACCESS EXCLUSIVE lock alıyor: refresh bitene kadar view'ı kimse okuyamıyor. CONCURRENTLY ise yeni sonucu ayrı hesaplayıp eskisiyle karşılaştırıyor ve yalnızca farkı uyguluyor; okumaları engellemiyor. Şartı bir unique index:
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_top_books;
CREATE UNIQUE INDEX ux_mv_top_books_book_id ON mv_top_books (book_id);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_top_books;
REFRESH MATERIALIZED VIEW mv_top_books;
ERROR: cannot refresh materialized view "public.mv_top_books" concurrently
HINT: Create a unique index with no WHERE clause on one or more columns of the materialized view.
Time: 0.100 ms
CREATE INDEX
Time: 0.466 ms
REFRESH MATERIALIZED VIEW
Time: 115.788 ms
REFRESH MATERIALIZED VIEW
Time: 100.569 ms
Unique index olmadan CONCURRENTLY reddedildi; index, eski ve yeni satırları eşleştirmenin anahtarı. Bu küçük sonuçta iki refresh'in süresi yakın; CONCURRENTLY fark hesabı yaptığı için çok satır değiştiğinde düz refresh'ten yavaş, az satır değiştiğinde ve okuma trafiği yüksekken daha iyi seçim. Farkı iki oturumla gördük. A oturumu refresh'i bir transaction içinde başlatıp dört saniye bekliyor, B oturumu bir saniye sonra hangi lock'ların tutulduğuna bakıp view'ı okumaya çalışıyor (lock_timeout = '2s'):
BEGIN;
REFRESH MATERIALIZED VIEW mv_top_books;
SELECT pg_sleep(4);
COMMIT;
SET lock_timeout = '2s';
SELECT l.mode, l.granted, a.query
FROM pg_locks AS l
JOIN pg_stat_activity AS a ON a.pid = l.pid
WHERE l.relation = 'mv_top_books'::regclass AND a.pid <> pg_backend_pid();
SELECT count(*) AS rows FROM mv_top_books;
Düz REFRESH sırasında B'nin gördüğü:
mode | granted | query
---------------------+---------+---------------------
AccessExclusiveLock | t | SELECT pg_sleep(4);
ExclusiveLock | t | SELECT pg_sleep(4);
ShareLock | t | SELECT pg_sleep(4);
ERROR: canceling statement due to lock timeout
LINE 1: SELECT count(*) AS rows FROM mv_top_books;
^
A'da REFRESH MATERIALIZED VIEW CONCURRENTLY ile aynı deneme:
mode | granted | query
------------------+---------+---------------------
ExclusiveLock | t | SELECT pg_sleep(4);
RowExclusiveLock | t | SELECT pg_sleep(4);
AccessShareLock | t | SELECT pg_sleep(4);
rows
------
100
Düz refresh'te okuma iki saniye bekleyip iptal edildi; gerçek bir uygulamada bu, refresh boyunca ana sayfanın askıda kalması demek. CONCURRENTLY'de A'nın tuttuğu en güçlü lock ExclusiveLock: başka bir refresh'i ve yazmayı engelliyor, okumayı engellemiyor; B 100 satırı hemen okudu. Lock modlarını ve pg_locks'u 09. yazıda ayrıntılı görmüştük.
pg_cron ile Periyodik Refresh
Materialized view'ı kimin, ne zaman tazeleyeceği senin kararın. Veritabanının içinde kalan bir çözüm 13. yazıda kurduğumuz pg_cron. Extension'ı postgres olarak bir kez kurup cron schema'sının kullanım yetkisini view'ın owner'ına verdik (GRANT USAGE ON SCHEMA cron TO bookstore_owner); job'u owner tanımlıyor, çünkü REFRESH'i materialized view'ın owner'ı (ya da PostgreSQL 17'den beri MAINTAIN yetkisi verilmiş bir rol) çalıştırabiliyor ve pg_cron job'u tanımlayan kullanıcıyla çalıştırıyor:
SELECT cron.schedule('refresh-top-books', '*/10 * * * *',
$$REFRESH MATERIALIZED VIEW CONCURRENTLY mv_top_books$$) AS jobid;
SELECT jobid, jobname, schedule, command, username, active FROM cron.job;
jobid
-------
1
jobid | jobname | schedule | command | username | active
-------+-------------------+--------------+-----------------------------------------------------+-----------------+--------
1 | refresh-top-books | */10 * * * * | REFRESH MATERIALIZED VIEW CONCURRENTLY mv_top_books | bookstore_owner | t
13'teki bookstore-cron container'ına bu kez 02'deki role'leri ve Bookstore şemasını da yükledik; adımlar snapshot'taki 21-cron.sql'in başında. Saat 00:30'u (UTC) geçince job'un çalışma kaydına baktık:
SELECT jobid, status, return_message, end_time - start_time AS duration
FROM cron.job_run_details
ORDER BY runid DESC
LIMIT 3;
jobid | status | return_message | duration
-------+-----------+---------------------------+-----------------
1 | succeeded | REFRESH MATERIALIZED VIEW | 00:00:00.464489
Job bir sonraki 10 dakikalık dilimde çalıştı ve yarım saniyenin altında başarıyla bitti. Refresh hata verseydi status failed, return_message da hata mesajı olurdu. cron.job_run_details her çalışmada bir satır büyüyor; 10 dakikalık bir job yılda 52.000 satır demek, bu tabloyu da eski kayıtları silen bir job ile temizle.
PostgreSQL materialized view'ın en son ne zaman refresh edildiğini kendisi kaydetmiyor; pg_matviews'ta böyle bir kolon yok. pg_cron kullanıyorsan cron.job_run_details bu bilginin kaynağı; başka bir zamanlayıcı kullanıyorsan refresh'ten sonra küçük bir tabloya zaman yazmak işini görüyor. "Veri en fazla 10 dakika eski" sözünü ekranda göstermek istiyorsan bu zamana ihtiyacın var. Managed servislerde pg_cron'un durumu 13. yazıda: Azure Database for PostgreSQL ve Amazon RDS (AWS'nin yönetilen veritabanı servisi) destekliyor, ama parametre grubunda preload gerekiyor. pg_cron'un olmadığı yerde aynı işi uygulamadaki bir background job yapıyor; Background Job'lar serisindeki zamanlayıcılarla tek fark, iş veritabanının dışında tanımlanmış oluyor.
EF Core'da View'ı Keyless Entity Olarak Map Etmek
Uygulama tarafında materialized view sıradan bir tablo gibi okunuyor. EF Core'da primary key'i olmayan, yalnızca okunan bu tür sonuçlar keyless entity olarak tanımlanıyor; 12. yazıdaki Npgsql provider'ı burada da aynı:
public sealed class TopBook
{
public long Rank { get; set; }
public int BookId { get; set; }
public string Title { get; set; } = "";
public string Category { get; set; } = "";
public long Units { get; set; }
public decimal Revenue { get; set; }
}
// ReportingContext.OnModelCreating içinde
modelBuilder.Entity<TopBook>().HasNoKey().ToView("mv_top_books");
IQueryable<TopBook> query = db.TopBooks
.Where(t => t.Category == "Fantasy")
.OrderBy(t => t.Rank)
.Take(3);
-- @p='3'
SELECT m.book_id, m.category, m.rank, m.revenue, m.title, m.units
FROM mv_top_books AS m
WHERE m.category = 'Fantasy'
ORDER BY m.rank
LIMIT @p
2 2 The Hobbit #2 123 4797.00
17 17 The Silmarillion #17 34 1333.82
19 42 The Hobbit #42 34 529.38
tracked: 0
saved: 0
Property tipleri SQL'in döndürdüğü tiplere uymalı: rank() ve smallint üzerindeki sum bigint döndürüyor, bu yüzden Rank ve Units long. Keyless entity change tracker'a girmiyor; ilk kitabın Units'ini değiştirip SaveChanges çağırdığımızda hiçbir şey yazılmadı. ToView EF Core migration'larına "bu bir tablo değil, oluşturma" diyor; materialized view'ın kendisini migration'da migrationBuilder.Sql(...) ile oluşturman gerekiyor. Lab'daki ReportingContext'te snake_case dönüşümünü elle yaptığımız için view eşlemesini dönüşüm döngüsünden önce tanımladık; 12'deki EFCore.NamingConventions paketi bunu kendisi hallediyor.
Ne Zaman Materialized View, Ne Zaman Read Model Tablosu?
Materialized view'ın sınırı tazelik modelinde: her refresh bütün sorguyu baştan çalıştırıyor. Sorgu 159 ms ise 10 dakikada bir refresh önemsiz bir yük; sorgu 10 dakika sürüyorsa refresh hiç bitmeden bir sonrakinin zamanı geliyor. PostgreSQL'de artımlı (yalnızca değişen satırları işleyen) materialized view çekirdekte yok; bunun için ya bir extension ya da kendi tablon gerekiyor.
Kendi read model tablon, değişiklik olduğunda (bir trigger, bir outbox mesajı ya da bir event ile) yalnızca etkilenen satırları güncelliyor; daha taze ve büyük veride daha ucuz, ama senkronizasyon kodu senin sorumluluğunda. CQRS (okuma ve yazma modellerini ayıran yaklaşım) ve Event Sourcing serisinin "Ayrı Read Model" yazısı bu seçimi ve eventual consistency ile yaşamayı anlatıyor. Kabaca: sonuç küçük, birkaç dakikalık gecikme kabul edilebilir ve sorgu hızlıysa materialized view; veri saniyeler içinde güncel olmalıysa ya da refresh pahalıysa read model tablosu.
Üretimde Dikkat
CONCURRENTLY refresh'i bir transaction içinde ve aynı view için aynı anda tek bir tane çalışacak şekilde planla. pg_cron bir job'un önceki çalışması bitmeden yenisini başlatmıyor, ama elle tetiklenen bir refresh ile çakışabilir; lock_timeout koymak beklemeyi sınırlar.
Refresh, materialized view'da da dead tuple bırakıyor; CONCURRENTLY farkı satır satır silip ekleyerek uyguladığı için özellikle. Autovacuum'un onu da temizlediğini pg_stat_user_tables'tan kontrol et (08. yazı).
Yetkiler: materialized view'ı uygulama rolünün okuyabilmesi için GRANT SELECT gerekiyor; 02'deki ALTER DEFAULT PRIVILEGES materialized view'ları da kapsıyor. Refresh'i ise owner ya da MAINTAIN yetkisi olan bir rol yapabiliyor; uygulama rolü denediğinde permission denied for materialized view mv_top_books aldı. Uygulama rolüne bu yetkiyi vermek yerine refresh'i owner'ın job'u yapsın.
Sık Düşülen Tuzaklar
CONCURRENTLY için unique index'i unutmak: refresh job'u her 10 dakikada bir hata veriyor ve kimse cron.job_run_details'a bakmıyorsa veri günlerce eski kalıyor. Job'un son durumunu izleme sistemine bağla.
View'a bağımlı object'ler yüzünden kolon değiştirememek: migration'ı yazarken bağımlı view'ları pg_depend'den bul, aynı transaction'da düşürüp yeniden oluştur. CASCADE'e güvenme.
Düz REFRESH ile okumaları kilitlemek: test ortamında fark edilmeyen birkaç saniyelik refresh, production'da her refresh'te ana sayfanın beklemesine dönüşüyor.
RLS'li tabloya varsayılan view yazmak: view owner'ın yetkisiyle çalıştığı için policy devre dışı kalıyor; security_invoker = true kullan.
Küçük Bir Deneme
★☆☆ v_book_list'e published_year'dan sonra yeni bir kolon eklemeyi ve mevcut iki kolonun yerini değiştirmeyi CREATE OR REPLACE VIEW ile ayrı ayrı dene. Hangisi kabul ediliyor?
★★☆ orders'a 2026-10-20 tarihli 500 yeni sipariş ve satırlarını ekle, sonra mv_top_books'u CONCURRENTLY ile tazele. Önce ve sonra pg_stat_user_tables'taki n_tup_ins, n_tup_upd ve n_tup_del değerlerini karşılaştır; kaç satır gerçekten değişti?
★★★ "En çok satan 100 kitap" için materialized view yerine top_books adlı bir read model tablosu ve onu güncelleyen bir job tasarla: yalnızca son 10 dakikada sipariş alan kitapların sayılarını yeniden hesaplasın. İki çözümün refresh süresini ve okuma tarafındaki farkı karşılaştır.
Bir sonraki yazıda function ve procedure'lara geçiyoruz: LANGUAGE sql ile PostgreSQL'in procedural dili (PL/pgSQL) arasındaki fark, RETURNS TABLE, volatility kategorileri, SECURITY DEFINER ve CALL ile transaction kontrolü.