13 puan yazan GN⁺ 12 일 전 | 1 yorum | WhatsApp'ta paylaş
  • Postgres'te bir job queue çalıştırırken ortaya çıkan kronik sorunlar olan dead tuple birikimi ve buna bağlı table bloat ile performans düşüşünün nedenleri ve çözüm yolları özetleniyor
  • Kuyruk tablolarında satırların çoğu ekle-oku-sil döngüsünde kısa süreli dolaştığı için boyut sabit kalsa da kümülatif throughput çok yüksektir
  • Postgres'in MVCC yapısı gereği silinen satırlar hemen kaldırılmaz, dead tuple olarak kalır ve temizlenmeleri gerekir; bunu autovacuum yapar
  • Uzun süren transaction'lar veya çakışarak çalışan analitik sorgular MVCC horizon'ı sabitlerse autovacuum dead tuple'ları temizleyemez ve kuyruk performansı düşer
  • PlanetScale'in Traffic Control özelliği (Insights eklentisinin bir uzantısı), sorgu sınıfı bazında kaynak sınırlama ile bu soruna pratik bir çözüm olarak sunuluyor

Kuyruk iş yükünün özellikleri

  • Kuyruk tablolarının ayırt edici özelliği, satırların çoğunun geçici (transient) olmasıdır — eklenir, bir kez okunur ve silinir
  • Tablo boyutu neredeyse sabit kalır ama kümülatif throughput çok büyüktür
  • Job queue'yu Postgres içinde tutmanın başlıca avantajı, job durumunu diğer DB mantığıyla aynı transaction içinde senkronize edebilmek
    • job başarısız olursa tüm transaction rollback olur
    • harici bir kuyruk servisi kullanıldığında uygulamanın transaction durumu ile senkronizasyon daha karmaşık hale gelir

Örnek kuyruk tablosu ve worker davranışı

  • Metinde verilen temel şema
CREATE TABLE jobs (  
  id BIGSERIAL PRIMARY KEY,  
  run_at TIMESTAMPTZ DEFAULT now(),  
  status TEXT DEFAULT 'pending',  
  payload JSONB  
);  
CREATE INDEX idx_jobs_fetch ON jobs (run_at) WHERE status = 'pending';  
  • Worker bir transaction açar ve en eski pending jobFOR UPDATE SKIP LOCKED ile kilitleyerek çift işlemeyi önler
  • İş başarıyla tamamlanırsa DELETE ve ardından COMMIT yapılır; başarısız olursa rollback olur ve ilgili satır yeniden başka bir worker tarafından görülebilir
  • Bu transaction olabildiğince kısa tutulmalıdır — ne kadar uzun açık kalırsa vacuum'ı o kadar engeller (metindeki örnek, sub-milisaniye worker'lar temelindedir)

Sorun performans kapasitesinin kendisi değil

  • Postgres'in büyük ölçekli job queue işleyebildiği zaten belgelenmiştir; sorun kapasitenin kendisi değildir
  • Asıl sorun, aynı DB üzerinde rekabet eden diğer iş yükleriyle birlikte yaşamak
  • Kuyruk tablosunun sağlığı, yalnızca kendi ayarlarına değil aynı Postgres instance'ında çalışan tüm transaction'ların davranışına bağlıdır
  • Metin, primary üzerindeki rekabetçi sorgu trafiğine odaklanıyor (replica ve replication slot etkileri ayrı konu)

Asıl sorun: dead tuple temizliği

  • Postgres, MVCC ile aynı satırın birden fazla sürümünü tutar — silinen satırlar hemen kaldırılmaz, "silinmiş" olarak işaretlenir ve yeni transaction'lar için görünmez hale gelir
  • Bu şekilde kalan satırlar dead tuple olarak adlandırılır ve vacuum işlemi ile temizlenir
  • dead tuple'lar SELECT sonuçlarında görünmez ama yine de maliyet yaratır
    • Sequential scan: yürütücü heap page içindeki dead tuple'ı okur, görünürlüğünü kontrol eder ve atar
    • Index scan (kuyrukta ORDER BY run_at LIMIT 1 için kullanılan yöntem): B-tree indeks, dead tuple referanslarını biriktirir; böylece artık görünmeyen satırları işaret eden girdiler de taranır
  • Her dead index entry ek I/O üretir; uygulama bunu görmez ama dead tuple sayısı arttıkça maliyet ciddi biçimde yükselir
  • Temizlik periyodu autovacuum_naptime (varsayılan 1 dakika) ile belirlenir; çalışıp çalışmayacağı ise autovacuum_vacuum_threshold ve autovacuum_vacuum_scale_factor değerlerine bağlıdır

dead tuple'ın iç mekanizması

  • Satır meta verisindeki 3 öğe kritiktir
    • ctid: heap içindeki tuple'ın fiziksel konumu (page, offset)
    • xmin: bu satırı ekleyen transaction ID'si (XID)
    • xmax: bu satırı silen/kilitleyen transaction ID'si; 0 ise silme işareti yoktur
  • 3 pending kayıt sorgulanırken bile, yürütücünün daha önce silinmiş 6 dead tuple'ın tamamını tarayıp ardından yalnızca 3 kayıt döndürdüğü bir durum oluşabilir
  • İndekste de leaf entry'nin dead heap tuple'ı göstermesi halinde, scan sırasında boşa giden iş birikir
  • DB'nin dead tuple üretme hızı, temizleme hızını aşarsa başarısızlık eğrisine girilir
  • İyi ayarlanmış bir Postgres cluster, saniyede on binlerce kuyruk işlemini kaldırabilir

autovacuum'ın etkisiz kaldığı durumlar

  • autovacuum'ın dead tuple temizliğinde başarısız olmasının başlıca nedenleri
    • belirli bir table lock'un cleanup'ı engellemesi
    • uygunsuz autovacuum ayarları
    • en yaygın olarak, aktif transaction'ların dead tuple geri kazanımını engellemesi
  • Postgres, aktif transaction'ların hâlâ görebileceği dead tuple'ları vacuum etmez
    • en eski aktif transaction cutoff noktasını belirler → MVCC horizon
    • bu transaction bitene kadar, onun snapshot'ından sonraki tüm dead tuple'lar tutulur
  • Tek bir 2 dakikalık transaction bile 2 dakika boyunca horizon'ı sabitler
  • Aynı başarısızlık kalıbı, üst üste binen orta uzunluktaki sorgularla da oluşabilir
    • örneğin 40 saniyelik 3 analitik sorgu, 20 saniye aralıklarla şaşırtmalı çalıştırılırsa; sorgular tek tek timeout'a düşmese bile her zaman biri aktif kalır ve horizon ilerleyemez
  • "Just use Postgres" yaklaşımıyla birden çok iş yükü tek DB'de tutulduğunda sorun, hızlı job işlemenin kendisi değil üst üste binen yavaş sorgular nedeniyle dead tuple temizliğinin geride kalmasıdır

Mevcut araçlar ve sınırları

  • autovacuum ayar seçenekleri: autovacuum_vacuum_cost_delay, autovacuum_vacuum_cost_limit
  • Uzun çalışan sorguları sınırlamak için timeout'lar
    • statement_timeout (Postgres 7.3): belirtilen süreyi aşan tekil SQL ifadesini sonlandırır
    • idle_in_transaction_session_timeout (9.6): transaction içinde boşta kalan oturumu sonlandırır
    • transaction_timeout (17.0): aktif ya da pasif transaction belirtilen süreyi aşarsa sonlandırır
  • Bu timeout'lar yalnızca tekil sorgu süresini hedefler; eşzamanlılığı veya yürütme maliyetini sınırlayamadıkları için MVCC horizon'ı sürekli sabitleyen iş yüklerini durdurmak için uygun değildir
  • Gereken şey, trafik sınıfları arasında ayrım yapabilmek; yüksek öncelikli iş yüklerini korurken yalnızca düşük öncelikli iş yüklerinin kaynak kullanımını ayarlayabilmektir

Database Traffic Control™

  • PlanetScale'in geliştirdiği Insights eklentisinin bir parçası ve yalnızca PlanetScale Postgres'e özel bir özellik
  • Tek tek sorguların performansını ve kaynak kullanımını ayrıntılı biçimde kontrol etmek için kullanılır
  • Resource Budget ile hedef sorgulara kaynak limiti verilir → aşım durumunda engellenebilirler
  • Çözüm stratejisi, üst üste binen yavaş sorguların eşzamanlı çalışma sayısını ve sıklığını sınırlamak; böylece autovacuum'ın dead tuple'ları uygun hızda temizlemesine alan açmak
  • Engellenen sorgular kalıcı olarak reddedilmez; yeniden denenmeleri gerekir, bu yüzden uygulamanın retry mantığı şarttır
  • Yaklaşım, toplam iş miktarını korurken çalışma hızını daha dengeli hale getirmektir

Demo kurulumu ve arka plan

  • Bu yazının çıkış noktası, Brandur Leach'in 2015 tarihli "Postgres Job Queues & Failure By MVCC" blog yazısı
    • Postgres tabanlı job queue'ların kritik bir başarısızlık modunu kayıt altına alıyordu
    • Kapanmayan transaction'ların MVCC horizon'ı sabitleyip cleanup'ı engellediğini gösteren bir test bench içeriyordu
  • Orijinal test bench, brandur/que-degradation-test olarak yayımlanmış durumda

Sorunun yeniden üretilmesi (Postgres 18 temelinde)

  • Orijinal test Ruby + Que gem v0.x + Postgres 9.4 tabanlıydı
  • Yazar, SQL düzeyindeki davranışı izole biçimde doğrulamak için bunu TypeScript + Bun ile yeniden yazdı
  • Que ile aynı recursive CTE kalıbı, aynı schema, producer rate, work duration, worker sayısı ve long-runner kalıbı korundu
  • Testler PlanetScale PS-5 cluster üzerinde çalıştırıldı (aylık 5 dolardan başlıyor)
  • Sonuç: gözle görülür ama yönetilebilir düzeyde performans bozulması
    • orijinal test 15 dakika içinde DB'yi death spiral'e sokarken, PS-5 üzerinde 15 dakika boyunca worker queue neredeyse 0 seviyesinde kaldı
    • ancak dead tuple'lar doğrusal olarak arttı; daha uzun sürseydi aynı sorunun geri döneceğine dair işaret vardı
    • B-tree indeks temizliğindeki iyileştirmeler (sürüm churn'ü için bottom-up deletion, scan tabanlı dead index tuple kaldırma vb.) sayesinde etki azalmış olsa da tamamen ortadan kalkmış değil

İyileştirme denemesi: SKIP LOCKED + batch işleme

  • 2015'te olmayan 2 modern iyileştirme
    1. FOR UPDATE SKIP LOCKED — recursive CTE'nin tamamını tek bir SELECT ile değiştirdi; başka worker'ların kilitlediği satırlar atlanıyor
    2. Batch processing (transaction başına 10 job) — tek bir lock acquisition ile 10 iş işleniyor, böylece indeks tarama maliyeti yayılıyor
  • Koşullar aynı: 8 worker, producer 50 jobs/sec, iş süresi 10ms, long-runner 45. saniyede başlıyor
  • Başlıca sonuçlar
Gösterge original (recursive CTE) enhanced (SKIP LOCKED + batch)
Baseline lock time 2–3ms 1.3–3.0ms
End lock time (typical) 10–34ms 9–29ms
Worst spike 84.5ms (dead tuple 33k) 180ms (dead tuple 24k)
Queue depth 0–100 (oscillating) 0 (çoğunlukla)
Dead tuples at end 42,400 42,450
Throughput ~89/s ~50/s
  • degradation eğrisi neredeyse aynı — çünkü her iki yaklaşım da aynı B-tree indeksi tarıyor ve aynı dead tuple'larla karşılaşıyor
  • Throughput farkı, lock stratejisinden değil test tasarımından kaynaklanıyor (CTE worker, producer'dan daha hızlı job alıyor; batch worker ise kuyruğu boşaltıp backoff sleep yapıyor)
  • Sonuç: 10 yıl önce 15 dakikada DB'yi öldüren kuyruk tasarımı artık daha uzun dayanıyor ama temel sorun hâlâ duruyor — hız 500 jobs/sec seviyesine çıkarılırsa sorun daha hızlı yeniden üretiliyor

Traffic Control ile çözüm

  • Resource Budget'ın sunduğu kontrol araçları
    • Server share & burst limit: sunucu kaynak oranı ve tüketim hızı
    • Per-query limit: sunucu kullanımı temelinde sorgunun saniye cinsinden çalışma süresi
    • Maximum concurrent workers: kullanılabilir worker process'lere göre oran
  • Hedef sorgular çoğunlukla SQLCommenter etiketlerindeki metadata ile belirleniyor (ör. action=analytics)
  • Boştaki transaction timeout'ına takılan tek bir long-runner yerine, daha gerçekçi bir senaryo olan aktif çalışan ve üst üste binen analitik sorgular ile bozulma tetikleniyor (oturum timeout'larının yakalayamadığı durum)
  • action=analytics sorgularının Maximum concurrent workers değeri 1 worker ile sınırlandı (max_worker_processes'in %25'i) → aynı anda yalnızca 1 analitik sorgu çalışabiliyor
  • 15 dakikalık pencerede death spiral oluşturabilmek için producer hızı 800 jobs/sec'e çıkarıldı
  • EC2 üzerinde, aynı PlanetScale DB'ye karşı "enhanced" iş yükü 2 kez çalıştırıldı
    • 800 jobs/sec
    • 120 saniyelik 3 analitik sorgu aynı anda başlatıldı ve sürekli çakışacak şekilde kaydırıldı
    • 15 dakika boyunca sürdürüldü
  • Sonuç karşılaştırması
Gösterge Traffic Control kapalı Traffic Control açık
Queue backlog 155,000 jobs 0 jobs
Lock time 300ms+ 2ms
Dead tuples at end 383,000 0–23,000 (cycling)
Analytics queries 3 concurrent, overlapping 1 at a time, 2 retrying
VACUUM effectiveness Blocked (horizon sabit) Normal (sorgular arasında temizlik penceresi var)
Outcome Death spiral Completely stable
  • Traffic Control, belirli iş yüklerinin eşzamanlılığını doğrudan sınırlıyor; autovacuum ayarı veya timeout'larla mümkün olmayan bir denetim sağlıyor
  • Analitik raporlar, kapasitenin izin verdiği ölçüde çalışmaya devam etti ve 15 dakika içinde 15 tanesi tamamlandı; kuyruk ise tüm süre boyunca sağlıklı kaldı

Özet

  • Postgres tabanlı kuyruklarda görülen MVCC dead tuple sorunu, 2015'ten kalma bir kalıntı değil
  • Modern Postgres, B-tree iyileştirmeleri ve SKIP LOCKED ile önemli ölçüde esneklik sağlıyor ama temel mekanizma aynı
    • VACUUM dead tuple'ları temizleyemezse birikiyorlar
    • Uzun çalışan veya üst üste binen transaction'lar MVCC horizon'ı sabitlerse VACUUM temizlik yapamıyor
  • "Just use Postgres" yaklaşımıyla kuyruk, analitik ve uygulama mantığının tek DB'de toplandığı ortamlarda bu, teorik bir risk değil günlük operasyon koşulu
  • Tehlikeli olan şey dramatik bir çöküş değil, sessizce bozulan bir denge durumu — lock time yavaş yavaş artıyor, job'lar yavaşlıyor ve alarm çalmıyor
  • Postgres'in timeout araçları, iş yükü sınıflarını ayıramıyor veya eşzamanlılık sınırı koyamıyor
  • Kuyruğu diğer iş yükleriyle birlikte çalıştırıyorsanız, en etkili önlem VACUUM'ın yetişebileceğini garanti etmektir; Traffic Control bunu basitleştiriyor

1 yorum

 
GN⁺ 12 일 전
Hacker News yorumları
  • Postgres hâlâ vacuum horizon sorununa sahip. Bu, uzun süren sorguların hızlı değişen tabloların vacuum işlemini engellemesi anlamına geliyor. Bu sorun zaten 2015'ten beri iyi biliniyor. Varsayılan Postgres'te bunu çözmek için iyi araçlar yok, ancak yazının yazarının şirketinin özel sürümünde bunu çözen bir özellik var. Sonuç olarak, OLAP tarzı uzun işler ile kuyruk tarzı hızlı işleri aynı Postgres instance'ında karıştırmak hâlâ akıllıca değil. Gereksinimlere bağlı olarak 0MQ veya RMQ gibi bir mesaj kuyruğu kullanmak daha kolay bir çözüm olabilir

    • Veritabanı cache ya da buffer pool'u ince ayarlı şekilde kontrol etmiyorsa, bu kadar farklı yükleri karıştırmak her zaman kötü bir seçimdir. Çünkü analitik tabloların tüm cache'i kirletmesini engellemenin bir yolu yoktur
  • Yazı fena değildi ama belirtilecek birkaç nokta var.

    1. MVCC horizon açıklaması kendi içinde çelişkili görünüyor. İşlemler farklı zamanlarda başlarsa snapshot'lar farklı olur ve ilk işlem bittiğinde vacuum'un ilerleyebilmesi gerekir
    2. SELECT * FROM jobs WHERE status='pending' ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED; sorgusunun performans sorunu gerçekten var, ancak monoton artan bir sütun ekleyip indekslerseniz hafifletilebilir. Böylece dead tuple'ları hesaba katmak gerekmez ve yalnızca alan israf edilir; okuma performansı daha az düşer. Ancak eşzamanlı yazma durumunda monoton artışı nasıl garanti edeceğiniz uygulama tasarımına bağlıdır
    3. Özetle ders şu: “Postgres'te çok uzun transaction'lar ile çok yüksek transaction sıklığını birlikte kullanmayın”
    • İndeks ekleseniz bile Postgres, dead tuple'ları tamamen temizleyene kadar onları tutmak zorunda kalır. Bir miktar hızlanma olabilir ama sonunda disk dolar ve diğer tabloların vacuum işlemi de engellenebilir
  • Yazar benim. Sorunuz varsa istediğiniz zaman sorun

    • Blogdaki kuyruk uygulaması, iş yürütülürken transaction'ı açık tutuyor gibi görünüyor. Mümkün olduğunca kısa tutma tavsiyesini anlıyorum, ancak durum sütununu “processing” olarak güncelleyip uzun transaction'ın kendisinden tamamen kaçınmak mümkün değil mi diye merak ediyorum
    • Kuyruk tablosunun fillfactor değerini 100'den küçük ayarlayıp test ettiniz mi diye merak ediyorum. HOT update kullanılırsa durum değişiminde yeni indeks girdileri oluşturmadan ölü alan yeniden kullanılabilir, bu da sorunun ortaya çıkış zamanını geciktirebilir gibi geliyor
    • Bu çözümün pg_squeeze kullanmaya göre farkı nedir diye merak ediyorum. Biz de kuyruk sistemimizde aynı sorunu yaşıyoruz ve pg_squeeze'i test ediyoruz; oldukça iyi çalışıyor gibi görünüyor
  • Reklam gibi görünüyor ama teknik çözüm yöntemine dair az da olsa bir açıklama olsaydı iyi olurdu

  • Postgres gerçekten çok şey yapabiliyor. İnsanlar Kafka ya da SQS seçiyor ama aslında Graphile Worker ile de rahatlıkla halledilebilecek çok iş var

    • “Her şeyi Postgres ile yapalım” felsefesi küçük ya da orta ölçek için sadeliği korumakta iyidir, ancak ölçek büyüdüğünde SQL veritabanına mümkün olduğunca az iş vermek daha iyidir. Çünkü çoğu sistemde darboğaz olan kısım orasıdır
    • SQS çok basit ve AWS ortamında kolay entegre oluyor. Kafka çok daha karmaşık ama ihtiyaç duyulan özellikler varsa harika bir seçim. Yalnız operasyon yükü büyük ve özgeçmiş için başlatılmış proje de çok. Buna karşılık SQS pratik bir araç olarak kullanılıyor. Ancak AWS'den çıkmak istiyorsanız bağımlılık sorun yaratabilir
  • Postgres'te satırları güncellediğinizde sorun çok daha ciddi hâle geliyor. Yalnızca insert ve delete kullanırsanız oldukça uzun süre idare edebilirsiniz

    • Postgres'te UPDATE aslında INSERT ile DELETE'in birleşimidir