PostgreSQL ile çalışırken, özellikle yüksek eşzamanlılık gerektiren uygulamalarda, MVCC (Multi-Version Concurrency Control) kavramını doğru anlamak kritik önem taşır. Bu mekanizma, veritabanının aynı anda birçok işlemi sorunsuz bir şekilde yönetmesini sağlar. Ancak, MVCC’nin inceliklerini göz ardı etmek, üretim ortamında beklenmedik performans sorunlarına veya veri tutarsızlıklarına yol açabilir.
Bir üretim ERP’sinde çalışırken veya kendi yan ürünümün finansal hesaplayıcılarının backend’ini geliştirirken, MVCC’nin neden olduğu ya da MVCC’yi yanlış anlamaktan kaynaklanan birçok sorunla karşılaştım. Bu yazıda, uygulama geliştirme süreçlerinde MVCC kullanımıyla ilgili sıkça yapılan hataları ve bu hatalardan nasıl kaçınılacağını kendi deneyimlerimle anlatacağım.
MVCC Nedir ve Neden Hayati Önemdedir?
MVCC, PostgreSQL’in okuma ve yazma işlemlerini eş zamanlı olarak kilitleme ihtiyacı olmadan gerçekleştirmesini sağlayan temel bir concurrency modelidir. Geleneksel veritabanı sistemlerinde bir işlem veri okurken, başka bir işlem aynı veriyi yazmaya çalıştığında genellikle kilitlenmeler yaşanır. Bu kilitlenmeler, yüksek trafikli sistemlerde ciddi performans darboğazlarına neden olur.
MVCC sayesinde, her transaction kendi “snapshot”ını görür. Yani bir işlem başladığında, o anki veritabanının tutarlı bir görüntüsünü alır ve bu görüntü, işlem tamamlanana kadar değişmez. Diğer işlemler aynı veriyi değiştirse bile, ilk işlem kendi snapshot’ındaki veriyi okumaya devam eder. Bu durum, okuyucuların yazıcıları, yazıcıların da okuyucuları engellemesini önleyerek sistemin genel eşzamanlılığını artırır. Örneğin, bir üretim bandının anlık durumunu gösteren operatör ekranlarını tasarlarken, verilerin sürekli güncellenmesi ve aynı anda raporların alınması gerektiğinde MVCC’nin bu yapısı çok işime yaradı.
VACUUM’un Gizli Eli: Bloat ve Performans Sorunları
MVCC’nin veritabanı eşzamanlılığına sağladığı faydalar tartışılmaz, ancak bu modelin bir de bedeli var: dead tuple’lar. Yeni bir tuple oluşturulduğunda eski tuple fiziksel olarak silinmediği için, bu “ölü” tuple’lar diskte yer kaplamaya devam eder. Zamanla bu dead tuple’lar birikerek tablolarda ve index’lerde “bloat” (şişme) denilen durumu yaratır. Bloat, veritabanı dosyalarının gereğinden fazla yer kaplamasına ve sorgu performansının düşmesine neden olur.
Bir üretim ERP’sindeki sipariş tablosunun boyutunun kontrolsüz bir şekilde büyüdüğünü gördüğümde bu bloat sorunuyla yüzleştim. Her gün binlerce yeni sipariş, güncelleme ve silme işlemi oluyordu. pg_stat_user_tables ve pg_class gibi tabloları sorgulayarak n_dead_tup ve relpages değerlerini incelediğimde, tablonun önemli bir kısmının dead tuple’lardan oluştuğunu gördüm. Bu durum, disk I/O’sunu artırıyor ve basit SELECT sorgularının bile yavaşlamasına neden oluyordu.
SELECT
relname AS table_name,
pg_size_pretty(pg_table_size(C.oid)) AS total_size,
pg_size_pretty(pg_relation_size(C.oid)) AS table_size,
pg_size_pretty(pg_total_relation_size(C.oid) - pg_relation_size(C.oid)) AS index_size,
n_live_tup AS live_tuples,
n_dead_tup AS dead_tuples,
(n_dead_tup * 100 / (n_live_tup + n_dead_tup + 0.001))::numeric(5,2) AS dead_ratio,
last_autovacuum,
last_autoanalyze
FROM pg_class C
JOIN pg_namespace N ON (N.oid = C.relnamespace)
JOIN pg_stat_user_tables S ON (S.relid = C.oid)
WHERE nspname = 'public' AND C.relkind = 'r'
ORDER BY dead_ratio DESC
LIMIT 10;
Bu bloat sorununu çözmek için VACUUM ve ANALYZE işlemlerinin düzenli olarak çalışması gerekir. PostgreSQL’de autovacuum daemon’ı varsayılan olarak bu işi otomatik yapar, ancak bazen varsayılan ayarlar yetersiz kalabilir. Özellikle yüksek write trafiği olan tablolarda autovacuum_vacuum_scale_factor ve autovacuum_vacuum_threshold gibi parametrelerin ayarlanması gerekebilir. Benim durumumda, ERP sipariş tablosu için autovacuum_vacuum_scale_factor değerini %20’den %5’e çekip, autovacuum_vacuum_threshold değerini de düşürerek autovacuum’un daha sık tetiklenmesini sağladım. Bu sayede, tabloda biriken dead tuple miktarı önemli ölçüde azaldı ve sorgu süreleri kabul edilebilir seviyelere düştü.
Transaction Isolation Seviyeleri ve Yanılgılar
PostgreSQL’de dört farklı transaction isolation seviyesi bulunur: READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ ve SERIALIZABLE. Uygulama geliştiricilerin MVCC ile ilgili en sık yaptığı hatalardan biri, bu izolasyon seviyelerinin davranışlarını tam olarak anlamadan varsayılan READ COMMITTED seviyesini kullanmaktır.
READ COMMITTED seviyesinde, bir sorgu sadece kendisinden önce commit edilmiş verileri görür. Aynı transaction içindeki farklı sorgular, aradaki başka commit’ler nedeniyle farklı sonuçlar döndürebilir (non-repeatable read). Örneğin, bir finansal hesaplayıcı uygulamasında, bir kullanıcının bakiyesini önce kontrol edip sonra işlem yapıyordum. SELECT bakiye FROM hesaplar WHERE user_id = X sorgusunu çalıştırdıktan hemen sonra, başka bir işlem kullanıcının bakiyesini değiştirip commit ederse, benim UPDATE hesaplar SET bakiye = ... WHERE user_id = X sorgum farklı bir bakiye üzerinden çalışacaktı. Bu durum, veri tutarsızlıklarına yol açabilir ve sistemin mantığını bozabilir.
-- Transaction A
BEGIN;
SELECT bakiye FROM hesaplar WHERE user_id = 1; -- Diyelim 1000 TL
-- Birkaç saniye geçti, bu arada başka transaction bakiyeyi 900 TL'ye düşürdü.
UPDATE hesaplar SET bakiye = bakiye - 100 WHERE user_id = 1; -- 1000 TL üzerinden işlem yapma riski
COMMIT;
-- Transaction B (eş zamanlı)
BEGIN;
UPDATE hesaplar SET bakiye = bakiye - 50 WHERE user_id = 1;
COMMIT;
Bu tür durumlarda REPEATABLE READ veya SERIALIZABLE izolasyon seviyelerine geçmek gerekebilir. REPEATABLE READ seviyesinde, transaction başladığında alınan snapshot, transaction sonuna kadar geçerli kalır. Bu, aynı sorguyu tekrar çalıştırsanız bile aynı sonuçları alacağınız anlamına gelir. Ancak, bu seviyede “phantom read” (hayalet okuma) denilen durumlar yine de yaşanabilir, yani transaction sırasında yeni eklenen satırlar görülebilir. SERIALIZABLE ise en yüksek izolasyon seviyesidir ve transaction’ların sanki art arda çalışıyormuş gibi davranmasını sağlar. Bu seviyede performans maliyeti daha yüksek olabilir ve serialization_failure hataları meydana gelebilir.
Bir müşteri projesinde, karmaşık bir rezervasyon sisteminde REPEATABLE READ kullandığımda, aynı anda rezervasyon yapmaya çalışan iki kullanıcının çakışan slotları ayırmasını engellemek için SELECT ... FOR UPDATE ile explicit lock kullandım. Bu, MVCC’nin non-blocking doğasını belirli bir an için bypass ederek doğru veri tutarlılığını sağlamamı gerektirdi. [İlgili: Veritabanı Kilitleme Stratejileri] yazısında bu konuyu daha detaylı ele almıştım.
Uzun Süreli Transaction’lar ve Snapshot Bloat
MVCC’nin bir diğer sinsi yanı, uzun süreli transaction’lardır. Bildiğiniz gibi, bir VACUUM işlemi ancak hiçbir aktif transaction’ın o dead tuple’ı içeren snapshot’ı görmediğinden emin olduğunda dead tuple’ları temizleyebilir. Eğer bir transaction çok uzun süre açık kalırsa, bu transaction’ın tuttuğu snapshot, dead tuple’ların temizlenmesini engelleyebilir. Bu duruma “snapshot bloat” diyoruz.
Snapshot bloat, normal bloat’tan daha tehlikeli olabilir çünkü autovacuum’un işini yapmasını tamamen engeller. Bir ERP sisteminde büyük bir raporlama işlemi, bazen saatlerce süren bir transaction içinde çalışabiliyordu. Bu işlem açık kaldığı sürece, diğer tablolarda biriken dead tuple’lar temizlenemiyor, bu da pg_class.relfrozenxid değerinin yavaş yavaş ilerlemesine ve transaction ID wraparound riskini artırmasına neden oluyordu. Bu durum, disk doluluğunun tehlikeli seviyelere tırmandığı bir krizle sonuçlandı.
Uzun süreli transaction’ları tespit etmek için pg_stat_activity tablosunu kullanıyorum:
SELECT
pid,
usename,
datname,
client_addr,
backend_start,
xact_start,
state,
query,
age(backend_xid) AS xid_age,
age(query_start) AS query_age
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY xact_start ASC;
Bu sorgu, aktif transaction’ların başlangıç zamanlarını ve xid_age (transaction ID’lerinin yaşı) değerlerini gösterir. Yüksek xid_age değerleri, potansiyel snapshot bloat kaynakları olabilir. Bu tür senaryolarda, uzun süreli işlemleri daha küçük transaction’lara bölmek, raporlama işlemlerini read replica’lara yönlendirmek veya kritik olmayan raporları mesai dışı saatlere planlamak gibi çözümler uyguladım. Bazen de, uygulama tarafında transaction’ın yaşam döngüsünü daha iyi yönetmek (örneğin, gereksiz yere uzun süren BEGIN bloklarından kaçınmak) sorunu kökten çözebilir.
Index Bloat ve MVCC İlişkisi
MVCC, sadece tablolardaki dead tuple’ları değil, index’leri de etkiler. Bir satır güncellendiğinde, aslında yeni bir tuple oluşturulur ve bu yeni tuple için index’e yeni bir giriş yapılır. Eski tuple’ın index girişleri hemen silinmez; onlar da “dead” olarak işaretlenir ve VACUUM tarafından temizlenmeyi bekler. Bu durum, index’lerde de bloat’a yol açar.
Index bloat, index boyutunun gereksiz yere büyümesine neden olur. Bu da, index taramalarının daha fazla disk I/O’su yapmasına ve dolayısıyla sorgu performansının düşmesine yol açar. Özellikle sık güncellenen tabloların index’leri bu durumdan daha fazla etkilenir. Örneğin, bir üretim ERP’sinin stok hareketleri tablosundaki index’lerin boyutları, tablonun kendisinden daha hızlı büyüyordu. pg_indexes_size ve pg_relation_size fonksiyonlarını kullanarak bu durumu tespit ettim.
SELECT
t.relname AS table_name,
indexrelname AS index_name,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
pg_stat_get_numscans(i.indexrelid) AS index_scans,
pg_stat_get_tuples_returned(i.indexrelid) AS tuples_returned
FROM pg_class t
JOIN pg_index i ON t.oid = i.indrelid
JOIN pg_class idx ON idx.oid = i.indexrelid
WHERE t.relkind = 'r'
ORDER BY pg_relation_size(i.indexrelid) DESC
LIMIT 10;
Index bloat’ı azaltmak için VACUUM FULL veya REINDEX komutları kullanılabilir. Ancak VACUUM FULL, tabloyu tamamen kilitler ve uzun sürebilir; bu yüzden üretim ortamında dikkatli kullanılmalıdır. REINDEX ise daha iyi bir seçenek olabilir, ancak o da kısa süreli bir kilit gerektirebilir. Benim tecrübelerime göre, autovacuum ayarlarını tablonun update trafiğine göre optimize etmek, index bloat’ı önlemede en etkili yöntemdir. Ayrıca, index’lerin FILLFACTOR ayarı da bloat’ı etkileyebilir. Daha düşük bir FILLFACTOR (örneğin %70), index sayfalarında daha fazla boş yer bırakarak gelecekteki güncellemeler için alan açar ve bloat oluşumunu yavaşlatır, ancak index boyutunu hemen artırır. Bu, disk alanı ve performans arasında bir trade-off’tur. [İlgili: PostgreSQL Index Optimizasyonları] yazısında bu konuya daha derinlemesine değinmiştim.
Sorgu Planı Hijyeni: EXPLAIN ANALYZE ile Darboğazları Görmek
Buraya kadarki konular MVCC’nin doğrudan bedeliydi. Şimdi onunla iç içe geçen ama tam olarak MVCC’ye özgü olmayan bir alışkanlığa değineceğim: sorgu planı hijyeni. Index bloat’ı index’in boyutunu büyütürken, eksik ya da yanlış index doğrudan planlayıcının yanlış yol seçmesine neden olur. İkisi de aynı yerde toplanır: yavaş sorgular. Bir sorgunun neden yavaş çalıştığını anlamak için, PostgreSQL’in sorgu planlayıcısının (query planner) nasıl karar verdiğini görmek kritik önem taşır. EXPLAIN ANALYZE komutu, bir sorgunun nasıl çalıştırılacağını gösteren planı sadece göstermekle kalmaz, aynı zamanda bu planın gerçekte nasıl çalıştığını, her bir adımın ne kadar sürdüğünü ve ne kadar satır döndürdüğünü de raporlar.
Bu aracı kullanmaktan kaçınmak veya çıktısını yüzeysel geçmek, performans sorunlarını kör noktada bırakır. Bir üretim firmasının ERP’sinde, belirli bir raporun çekilmesi saatler sürüyordu. EXPLAIN ANALYZE çıktısını incelediğimde, tablonun tam tarama (Seq Scan) yaptığını ve pahalı bir Nested Loop join kullandığını gördüm. Oysa ki, doğru index’ler olsaydı Index Scan veya Bitmap Heap Scan ile çok daha hızlı bir çözüm mümkündü.
Bu durumda sorunun kökü, tablolarda eksik veya yanlış index’ler olmasıydı. EXPLAIN ANALYZE çıktısında görülen yüksek maliyetli Seq Scan’leri gidermek için, sorgu planında sıkça taranan kolonlara uygun B-tree index’leri ekledim. Seq Scan yerine Index Scan devreye girdiğinde, sorgu performansı belirgin şekilde arttı. Bu basit değişiklik, raporlama süresini kayda değer ölçüde kısalttı.
-- Örnek EXPLAIN ANALYZE çıktısı (Önceki Durum)
EXPLAIN ANALYZE
SELECT o.order_id, c.customer_name
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.order_date BETWEEN '2026-01-01' AND '2026-01-31';
-- Çıktıda dikkat edilecek satır: order_date üzerinde index yoksa tam tarama görülür
-- Seq Scan on orders
-- Filter: ((order_date >= '2026-01-01'::date) AND (order_date <= '2026-01-31'::date))
-- ... (join maliyeti)
-- İndeks oluşturulduktan sonra (örneğin index on orders(order_date))
-- Tam tarama yerine index taraması devreye girer:
-- Index Scan using orders_order_date_idx on orders o
-- Index Cond: ((order_date >= '2026-01-01'::date) AND (order_date <= '2026-01-31'::date))
-- ... (join maliyeti)
Benzer bir kalıp, join’lerde kullanılan foreign key kolonunda index olmamasıdır: customers tablosundaki customer_id kolonunda index yoksa, bu tablo her bir order satırı için yeniden taranır ve EXPLAIN ANALYZE çıktısı beklenenden çok daha fazla IO ve CPU gösterir. CREATE INDEX idx_customers_customer_id ON customers (customer_id); gibi basit bir index’le bu darboğaz genellikle ortadan kalkar. Bloat’ı izlerken planı izlemeyi de alışkanlık haline getirmek, bu iki dünyayı tek bir refleks altında toplar.
FOR UPDATE/SHARE ile Explicit Locking: MVCC’yi Bypass Etmek
MVCC genellikle kilitleme ihtiyacını ortadan kaldırsa da, bazı durumlarda explicit locking (açık kilitleme) kullanmak zorunlu hale gelir. Özellikle bir transaction içinde okuduğunuz veriyi hemen ardından güncellemeniz gerekiyorsa ve başka bir transaction’ın aynı veriyi değiştirmediğinden emin olmak istiyorsanız, SELECT ... FOR UPDATE veya SELECT ... FOR SHARE ifadelerini kullanabilirsiniz.
SELECT ... FOR UPDATE, seçilen satırları o anki transaction tamamlanana kadar diğer FOR UPDATE veya FOR SHARE isteklerine karşı kilitler. Bu, özellikle stok düşme, bakiye güncelleme gibi kritik iş mantıklarında veri tutarlılığını sağlamak için vazgeçilmezdir. Bir üretim ERP’sinde, aynı anda birden fazla kullanıcının aynı ürünün stoğunu düşürmeye çalıştığı senaryolarda FOR UPDATE kullanarak race condition’ları engelledim.
-- Transaction 1
BEGIN;
SELECT stok_miktari FROM urunler WHERE id = 123 FOR UPDATE; -- Satırı kilitler
-- Stok_miktari = 50
-- Bir süre sonra
UPDATE urunler SET stok_miktari = 49 WHERE id = 123;
COMMIT;
-- Transaction 2 (eş zamanlı, Transaction 1 bitene kadar bekler)
BEGIN;
SELECT stok_miktari FROM urunler WHERE id = 123 FOR UPDATE; -- Transaction 1 bitene kadar bekler
-- Transaction 1 commit ettikten sonra çalışır
-- Stok_miktari = 49
UPDATE urunler SET stok_miktari = 48 WHERE id = 123;
COMMIT;
SELECT ... FOR SHARE ise daha hafif bir kilitlemedir; satırları sadece FOR UPDATE isteklerine karşı kilitlerken, diğer FOR SHARE istekleriyle eş zamanlı çalışmaya izin verir. Bu, okuma öncelikli ama tutarlılığın önemli olduğu durumlarda kullanılabilir. Ancak, bu explicit locking mekanizmaları, MVCC’nin getirdiği eşzamanlılık faydalarını belirli ölçüde azaltır. Yanlış veya gereksiz yere kullanıldığında, deadlocks (kilitlenmeler) veya performans darboğazları yaratabilirler. Bu yüzden, ne zaman ve nerede kullanılacakları konusunda çok dikkatli olmak gerekir. Kullanımının performans üzerindeki etkisini mutlaka test edip, ölçüm alarak karar vermek, üretim ortamında baş ağrılarının önüne geçer.
Sonuç: MVCC’yi Anlamak ve Doğru Kullanmak
PostgreSQL’in MVCC mekanizması, modern, yüksek performanslı ve eşzamanlı uygulamalar geliştirmek için güçlü bir temel sunar. Ancak bu gücü tam olarak kullanabilmek ve olası tuzaklardan kaçınmak, MVCC’nin çalışma prensiplerini ve ilişkili kavramları iyi anlamayı gerektirir. VACUUM’un rolü, transaction izolasyon seviyelerinin incelikleri, uzun süreli transaction’ların etkileri ve explicit locking’in doğru kullanımı gibi konular, veritabanı performansını ve veri tutarlılığını doğrudan etkiler.
Kendi deneyimimde, bu konuları göz ardı etmek, geceleri gelen “disk doldu” alarmlarına veya anlamsızca yavaşlayan sorgulara neden oldu. Bu nedenle, bir PostgreSQL veritabanı üzerinde uygulama geliştirirken veya operasyonel olarak yönetirken, MVCC’nin derinliklerine inmek ve yukarıda bahsettiğim hatalardan kaçınmak, sağlam ve ölçeklenebilir sistemler kurmanın anahtarıdır. Sonraki adım: Kendi sistemlerinizdeki pg_stat_user_tables ve pg_stat_activity çıktılarını inceleyerek MVCC optimizasyonları için potansiyel alanları belirleyin.