PostgreSQL Kilit Mimarisi: Hangi Kilit Ne Zaman Alınır?
PostgreSQL'de kilitler iki katmanda yaşar: nesne düzeyinde (tablo, şema, veritabanı) ve satır düzeyinde. Bu ayrımı netleştirmeden ileri düzey yönetimden bahsetmek mümkün değil.
Tablo düzeyindeki kilitler pg_locks görünümünde locktype = 'relation' olarak listelenir. En sık karşılaşacağın modlar:
ACCESS SHARE:SELECTalır. YalnızcaACCESS EXCLUSIVEile çakışır.ROW SHARE:SELECT ... FOR UPDATEveFOR SHAREalır.ROW EXCLUSIVE:INSERT,UPDATE,DELETEalır. Kendi aralarında çakışmaz.SHARE UPDATE EXCLUSIVE:VACUUM(FULL olmadan),ANALYZE,CREATE INDEX CONCURRENTLY,REINDEX CONCURRENTLYalır.SHARE:CREATE INDEX(CONCURRENTLY olmadan) alır.SHARE ROW EXCLUSIVE: BazıALTER TABLEbiçimleri.EXCLUSIVE:REFRESH MATERIALIZED VIEW CONCURRENTLYgibi işlemler.ACCESS EXCLUSIVE:ALTER TABLE,DROP TABLE,TRUNCATE,REINDEX(CONCURRENTLY olmadan),CLUSTER,VACUUM FULL. Her şeyle çakışır.
Satır düzeyinde ise iki ayrı mekanizma var: satır kilitleri (FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE) ve MVCC çoklu sürüm yönetimi. UPDATE ve DELETE satırları değiştirirken örtük olarak FOR UPDATE benzeri kilit alır, ancak bu kilitler pg_locks'ta satır başına görünmez; yalnızca pg_stat_activity üzerinden bekleyen sorguları görebilirsin.
Kilit Beklemesini Teşhis Etme
Kilit sorununu çözmek için önce kimin kimi beklediğini görmen gerekir. pg_locks ve pg_stat_activity birleşimi temel araçtır:
SELECT
blocked.pid AS blocked_pid,
blocked.usename AS blocked_user,
blocking.pid AS blocking_pid,
blocking.usename AS blocking_user,
blocked.query AS blocked_query,
blocking.query AS blocking_query,
blocked.wait_event_type,
blocked.wait_event
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;
pg_blocking_pids() fonksiyonu, verilen PID'i engelleyen tüm PID'leri dizge olarak döner. Paralel sorgu işçileri, kilit grubu (lock group) liderleri gibi durumları da hesaba kattığı için elle pg_locks join'i yazmaktan daha güvenilirdir.
Bekleyen kilitlerin modunu görmek için:
SELECT
a.pid,
a.query,
l.locktype,
l.mode,
l.granted,
l.relation::regclass AS relation_name
FROM pg_locks AS l
JOIN pg_stat_activity AS a ON a.pid = l.pid
WHERE NOT l.granted
ORDER BY a.pid;
granted = false olan satırlar, kilidi bekleyen işlemleri gösterir. Aynı PID için birden fazla bekleyen kilit olabilir; bu, işlemin birden fazla kaynağı sırayla beklediği anlamına gelir.
lock_timeout ve statement_timeout: Ölümcül Beklemeyi Önleme
Kilit yönetiminin en pratik aracı zaman aşımlarıdır. İkisinin farkı kritik:
lock_timeout: Bir kilidi almaya çalışırken geçen süreyi sınırlar. Kilit alındıktan sonraki çalışma süresini etkilemez.statement_timeout: Sorgunun toplam çalışma süresini sınırlar; kilit beklemesi de bu süreye dahildir.
Örneğin şema değişikliği yapan bir migration'da:
SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN note text;
ALTER TABLE ACCESS EXCLUSIVE kilit ister. Uzun süren bir SELECT varsa ALTER TABLE kuyruğa girer ve kuyruğa giren ACCESS EXCLUSIVE isteği arkasındaki tüm yeni sorguları da bloke eder. Bu, "kuyruk tıkanması" olarak bilinen ve üretimde sık yaşanan bir durumdur. lock_timeout bu durumda migration'ı iptal eder, uygulamanın geri kalanı çalışmaya devam eder.
Bu değerleri postgresql.conf üzerinden genel ayar olarak da tanımlayabilirsin:
lock_timeout = '5s'
statement_timeout = '30s'
idle_in_transaction_session_timeout = '60s'
idle_in_transaction_session_timeout özellikle önemlidir: BEGIN açıp işi bitirmeden bağlantıyı boşta bırakan uygulama hataları, ACCESS EXCLUSIVE kilitleri saatlerce tutabilir. Bu ayar, boşta bekleyen işlemi otomatik sonlandırır.
FOR UPDATE ve Varyantları
Satır kilidi almanın dört biçimi vardır ve hangisini seçtiğin, eşzamanlı yük altında ne kadar çakışma yaşayacağını belirler:
FOR UPDATE: En güçlü satır kilidi. Aynı satırı başka bir işlemFOR UPDATE,FOR NO KEY UPDATE,FOR SHAREveyaFOR KEY SHAREile kilitleyemez. Ayrıca bu satıra yönelikUPDATE/DELETEde bloke olur.FOR NO KEY UPDATE:FOR UPDATE'ten daha zayıftır; birincil anahtar veya benzersiz anahtarı değiştirmeyen güncellemelere izin verir. PostgreSQLUPDATEsırasında otomatik olarak bu kilidi alır.FOR SHARE: Paylaşımlı okuma kilidi. Birden fazla işlem aynı satırıFOR SHAREile tutabilir, ancak güncelleyemez.FOR KEY SHARE: En zayıf satır kilidi. Yalnızca anahtar sütunlarını değiştirmeyi engeller; anahtar dışı sütun güncellemelerine izin verir. Yabancı anahtar denetimleri bu kilidi kullanır.
Pratik kural: Yalnızca anahtar olmayan sütunları güncelleyeceksen FOR NO KEY UPDATE yeterlidir ve FOR UPDATE'e göre daha az blokaj üretir. Yalnızca satırın var olduğunu ve değişmediğini doğrulamak istiyorsan FOR KEY SHARE çoğu zaman yeterlidir.
NOWAIT ve SKIP LOCKED seçenekleri kilit beklemesini tamamen ortadan kaldırır:
-- Beklemek yerine hata al
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE NOWAIT;
-- Kilitli satırları atla, diğerlerinden iş al
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
SKIP LOCKED, iş kuyruğu (job queue) desenlerinde yaygın kullanılır: birden fazla worker aynı anda farklı satırları kilitler, hiçbiri diğerini beklemez. Ancak dikkat: SKIP LOCKED sonuç kümesini tutarlı yapmaz, çünkü atlanan satırlar sonuçta görünmez. Bunu yalnızca "bir işi kap" semantiği istediğinde kullan; analitik sorgularda kullanma.
Ölümcül Kilitlenme (Deadlock) ve Önlenmesi
PostgreSQL ölümcül kilitlenmeleri otomatik algılar ve bir işlemi iptal eder. Ancak doğru tasarım, kilitlenmenin hiç oluşmamasını sağlar.
Klasik senaryo: İki işlem aynı iki satırı ters sırayla günceller.
İşlem A: UPDATE accounts SET balance = balance - 100 WHERE id = 1;
İşlem B: UPDATE accounts SET balance = balance - 50 WHERE id = 2;
İşlem A: UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- bekler
İşlem B: UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- bekler
İki taraf da bekler, PostgreSQL birini iptal eder. Çözüm: kaynakları her zaman aynı sırada kilitle. Örneğin hesapları her zaman id artan sırayla güncelle:
BEGIN;
SELECT id FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
ORDER BY id ile FOR UPDATE birleşimi, kilitlerin her zaman aynı sırayla alınmasını garantiler. Bu, uygulama katmanında tutarlı bir kural olarak uygulanmalıdır.
Ölümcül kilitlenmeleri izlemek için:
SELECT deadlocks FROM pg_stat_database WHERE datname = current_database();
Bu sayaç artıyorsa, kilit sırası tutarsızlığı vardır. log_lock_waits = on ayarı ile deadlock_timeout (varsayılan 1 saniye) eşiğini aşan kilit beklemeleri log'a düşer.
pg_advisory_lock: Uygulama Düzeyinde Koordinasyon
Bazı durumlarda tablo veya satır kilidi yeterli olmaz: birden fazla uygulama örneğinin aynı anda çalışmasını istemediğin bir cron işi, önbellek yenileme, ya da dış sistemle senkronizasyon gibi. Bu durumda danışma kilitleri (advisory locks) devreye girer.
İki biçim vardır:
- Oturum düzeyi:
pg_advisory_lock(key)/pg_advisory_unlock(key). Bağlantı kapanana kadar tutulur. - İşlem düzeyi:
pg_advisory_xact_lock(key).COMMITveyaROLLBACKile otomatik serbest bırakılır.
İşlem düzeyi neredeyse her zaman tercih edilir, çünkü bağlantı havuzu kullanıyorsan oturum düzeyi kilit bağlantıyla birlikte havuzda asılı kalır:
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('daily-report-generator'));
-- ... rapor üret ...
COMMIT;
hashtext() ile metin anahtarını 32 bitlik tam sayıya çevirebilirsin. Anahtar çakışması olasılığı var ama pratikte nadirdir. Daha güvenli yol, iki argümanlı biçimi kullanmaktır: pg_advisory_xact_lock(bigint, int).
Beklemek istemiyorsan pg_try_advisory_xact_lock kullan:
BEGIN;
SELECT pg_try_advisory_xact_lock(hashtext('daily-report-generator'))
AS acquired \gset
\if :acquired
-- işi yap
\else
-- başka bir örnek çalışıyor, çık
\endif
COMMIT;
Danışma kilitleri herhangi bir tabloyu korumaz. Uygulama kodunun, ilgili işi yapmadan önce bu kilidi aldığından emin olması gerekir. Veritabanı senin yerine bunu yapmaz.
Uzun Süren ACCESS EXCLUSIVE İşlemlerinden Kaçınma
Üretimde en çok kesintiye yol açan durumlar şunlardır:
CREATE INDEXyerineCREATE INDEX CONCURRENTLY: NormalCREATE INDEXtabloyuSHAREkilidiyle kilitler, yazma işlemleri bloke olur.CONCURRENTLYversiyonu daha uzun sürer ve başarısız olursa geçersiz bir indeks bırakır, ama yazma trafiğini engellemez. Geçersiz indeksiDROP INDEX CONCURRENTLYile temizleyebilirsin.ALTER TABLEile varsayılan değer ekleme: PostgreSQL 11 ve sonrasındaADD COLUMN ... DEFAULT <sabit>işlemi tabloyu yeniden yazmaz,ACCESS EXCLUSIVEkilidi çok kısa sürer. AncakDEFAULTbir fonksiyon çağrısıysa (örn.now(),random()), tablo yeniden yazılır ve kilit uzun sürer. Bu ayrımı bilmek kritiktir.VACUUM FULL: Tabloyu tamamen yeniden yazar,ACCESS EXCLUSIVEalır. Üretimde yerinepg_repackgibi araçlar tercih edilir; ancak bunlar ek kurulum gerektirir.REINDEX:REINDEX CONCURRENTLY(PostgreSQL 12 ve sonrası) yazma trafiğini engellemez.TRUNCATE:ACCESS EXCLUSIVEalır ve geri alınamaz. Kısmi silme gerekiyorsaDELETEkullan, ama büyük tablolardaDELETEde uzun sürer; partisyonlama daha uygun olabilir.
Migration araçları (Flyway, Liquibase, Alembic, Rails migrations) çoğu zaman bu ayrımları otomatik yapmaz. Üretim migration'larında lock_timeout ayarlamak ve uzun süren işlemleri ayrı adımlara bölmek geliştiricinin sorumluluğundadır.
İzleme ve Uyarı
Kilit sorunlarını erken yakalamak için şu metrikleri izle:
pg_stat_database.deadlocks: Ölümcül kilitlenme sayısı. Sıfırdan farklı ve artıyorsa tasarım sorunu var.pg_locksüzerindengranted = falsesayısı: Anlık bekleyen kilit sayısı. Zaman içinde artıyorsa tıkanma var.pg_stat_activityüzerindenstate = 'idle in transaction'süresi: Uzun süren boşta işlemler, kilit tutuyor olabilir.log_lock_waits = onile log'lanan kilit beklemeleri.
Bu metrikleri Prometheus'a aktarmak için postgres_exporter gibi araçlar kullanılır; ancak kurulum ve yapılandırma ayrı bir konudur.
Sık Yapılan Hatalar
- Uzun işlemleri açık tutmak:
BEGINaçıp uygulama katmanında ağ çağrısı, dosya okuma veya kullanıcı girdisi beklemek, kilitleri gereksiz yere uzatır. İşlemi mümkün olduğunca kısa tut. lock_timeoutayarlamamak: Kilit kuyruğu tıkanmasına yol açar. Özellikle şema değiştiren migration'larda bu ayar zorunlu kabul edilmelidir.FOR UPDATE'i gereksiz yere kullanmak: Yalnızca okuma yapıyorsan ve tutarlılık gerekmiyorsa, kilit almak yükü artırır.FOR NO KEY UPDATEveyaFOR KEY SHAREçoğu zaman yeterlidir.- Danışma kilidini işlem düzeyi yerine oturum düzeyi almak: Bağlantı havuzu kullanıyorsan kilit havuzda asılı kalır.
pg_advisory_xact_locktercih et. - Ölümcül kilitlenmeyi uygulama hatası sanmak: Deadlock, tasarım sorununun belirtisidir; yeniden deneme (retry) bir yama olsa da, kilit sırasını düzeltmek asıl çözümdür.
pg_locks'u tek başına okumak: Satır kilitleri burada görünmez;pg_stat_activityvepg_blocking_pids()ile birlikte kullan.
Kapanış
PostgreSQL kilit yönetimi, "hangi kilidi ne zaman aldığını bilmek" ile başlar. Tablo düzeyinde ACCESS EXCLUSIVE'ten kaçınmak, satır düzeyinde doğru modu seçmek, lock_timeout ile kuyruk tıkanmasını önlemek ve kaynakları tutarlı sırayla kilitleyerek deadlock'u tasarımdan elemek, üretimde en çok işe yarayan dört pratik kuraldır. Danışma kilitleri ise uygulama düzeyinde koordinasyon gerektiğinde devreye girer; ancak veritabanı onları senin için uygulamaz, bunu kodda sen garanti etmelisin.