PostgreSQL Kilit Mimarisi ve Bekleme Teşhisi

## PostgreSQL Kilit Mimarisi: Hangi Kilit Ne Zaman Alınır? PostgreSQL'de kilitler iki katmanda yaşar: nesne düzeyinde (tablo, şema, veritabanı) ve sa...

PostgreSQL Kilit Mimarisi ve Bekleme Teşhisi

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: SELECT alır. Yalnızca ACCESS EXCLUSIVE ile çakışır.
  • ROW SHARE: SELECT ... FOR UPDATE ve FOR SHARE alır.
  • ROW EXCLUSIVE: INSERT, UPDATE, DELETE alır. Kendi aralarında çakışmaz.
  • SHARE UPDATE EXCLUSIVE: VACUUM (FULL olmadan), ANALYZE, CREATE INDEX CONCURRENTLY, REINDEX CONCURRENTLY alır.
  • SHARE: CREATE INDEX (CONCURRENTLY olmadan) alır.
  • SHARE ROW EXCLUSIVE: Bazı ALTER TABLE biçimleri.
  • EXCLUSIVE: REFRESH MATERIALIZED VIEW CONCURRENTLY gibi 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şlem FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE veya FOR KEY SHARE ile kilitleyemez. Ayrıca bu satıra yönelik UPDATE/DELETE de 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. PostgreSQL UPDATE sırasında otomatik olarak bu kilidi alır.
  • FOR SHARE: Paylaşımlı okuma kilidi. Birden fazla işlem aynı satırı FOR SHARE ile 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). COMMIT veya ROLLBACK ile 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:

  1. CREATE INDEX yerine CREATE INDEX CONCURRENTLY: Normal CREATE INDEX tabloyu SHARE kilidiyle kilitler, yazma işlemleri bloke olur. CONCURRENTLY versiyonu daha uzun sürer ve başarısız olursa geçersiz bir indeks bırakır, ama yazma trafiğini engellemez. Geçersiz indeksi DROP INDEX CONCURRENTLY ile temizleyebilirsin.
  2. ALTER TABLE ile varsayılan değer ekleme: PostgreSQL 11 ve sonrasında ADD COLUMN ... DEFAULT <sabit> işlemi tabloyu yeniden yazmaz, ACCESS EXCLUSIVE kilidi çok kısa sürer. Ancak DEFAULT bir fonksiyon çağrısıysa (örn. now(), random()), tablo yeniden yazılır ve kilit uzun sürer. Bu ayrımı bilmek kritiktir.
  3. VACUUM FULL: Tabloyu tamamen yeniden yazar, ACCESS EXCLUSIVE alır. Üretimde yerine pg_repack gibi araçlar tercih edilir; ancak bunlar ek kurulum gerektirir.
  4. REINDEX: REINDEX CONCURRENTLY (PostgreSQL 12 ve sonrası) yazma trafiğini engellemez.
  5. TRUNCATE: ACCESS EXCLUSIVE alır ve geri alınamaz. Kısmi silme gerekiyorsa DELETE kullan, ama büyük tablolarda DELETE de 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 üzerinden granted = false sayısı: Anlık bekleyen kilit sayısı. Zaman içinde artıyorsa tıkanma var.
  • pg_stat_activity üzerinden state = 'idle in transaction' süresi: Uzun süren boşta işlemler, kilit tutuyor olabilir.
  • log_lock_waits = on ile 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: BEGIN açı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_timeout ayarlamamak: 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 UPDATE veya FOR 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_lock tercih 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_activity ve pg_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.

İLGİLİ İÇERİKLER

Bunları da Okumak İsteyebilirsiniz