Giden Kutusu Deseni: Veritabanı ile Kuyruğa Aynı Anda Yazmanın Doğru Yolu
Önce commit sonra publish neden olay kaybettirir, giden kutusu tablosu nasıl kurulur, yoklama mı CDC mi seçilmeli ve sıra numarası, tekrar eden mesaj, şişen tablo gibi tuzaklardan nasıl kaçınılır.
Bir sipariş ödendiğinde iki şey olması gerekir: veritabanındaki satır paid durumuna geçer ve başka servislerin haberdar olması için kuyruğa bir olay bırakılır. Kod çoğu zaman bunu art arda iki satırla yapar. Sorun şu ki bu iki yazma tek bir işlemin parçası değildir. Veritabanı işlemi tamamlanıp yayın başarısız olursa olay kaybolur; önce yayın yapılıp işlem geri alınırsa da dünya hiç gerçekleşmemiş bir ödemeyi duymuş olur. Bu yazı bu "çift yazma" probleminin neden kod disipliniyle çözülemediğini ve giden kutusu (transactional outbox) deseninin nasıl doğru kurulacağını anlatıyor.
Neden sıralamayı değiştirmek yetmez
İlk refleks sırayı ayarlamaktır: "önce commit, sonra publish, publish başarısız olursa tekrar dene." Bu, sürecin commit ile publish arasında hiç ölmeyeceğini varsayar. Oysa dağıtım sırasında kapatılan bir pod, bellek sınırına takılan bir süreç ya da broker'a giden bağlantının o an kopması tam bu aralıkta olur. Tekrar deneme mantığı bellekteyse süreçle birlikte ölür.
Ters sıra da kurtarmaz. Yayını işlemin içine alıp "broker onay verirse commit et" demek, broker'a giden ağ çağrısını veritabanı kilidi tutarken yapmak demektir. Broker yavaşladığında satır kilitleri uzar, bağlantı havuzu dolar ve bir mesajlaşma sorunu veritabanı kesintisine dönüşür.
İki farklı sisteme atomik yazmanın genel çözümü dağıtık işlemdir (iki aşamalı commit). Pratikte modern broker'ların çoğu buna katılmaz, katılanlarda da işletme maliyeti yüksektir. Giden kutusu deseni problemi başka yerden çözer: ikinci yazmayı ortadan kaldırır.
Desenin özü
Olayı broker'a değil, iş verisiyle aynı veritabanındaki bir tabloya yazarsınız. İki yazma artık aynı yerel işlemin içindedir, ya ikisi de olur ya hiçbiri. Ayrı bir aktarıcı (relay) bu tabloyu okuyup olayları broker'a taşır.
CREATE TABLE outbox (
id bigserial PRIMARY KEY,
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
CREATE INDEX outbox_pending ON outbox (id) WHERE published_at IS NULL;
Uygulama tarafında yazma şöyle görünür:
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 42;
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ('order', '42', 'OrderPaid', '{"order_id": 42, "amount": 1500}');
COMMIT;
Bu noktadan sonra garanti nettir: commit edilmiş her durum değişikliğinin kalıcı bir olay kaydı vardır. Kalan iş, bu kaydı en az bir kez broker'a ulaştırmaktır.
Aktarıcı için iki yol: yoklama ve değişiklik yakalama
Yoklama (polling). Bir süreç belli aralıkla yayımlanmamış satırları çeker, broker'a gönderir, onay gelince işaretler.
BEGIN;
SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- satırları broker'a gönder, onayı bekle
UPDATE outbox SET published_at = now() WHERE id = ANY($1);
COMMIT;
FOR UPDATE SKIP LOCKED, birden fazla aktarıcı örneğinin aynı satırları kapışmadan paylaşmasını sağlar. Gecikme yoklama aralığı kadardır; PostgreSQL'de LISTEN/NOTIFY ile aktarıcıyı uyandırıp aralığı uzun tutabilirsiniz, ama bildirim kaybolabileceği için yoklama yine de yedek olarak kalmalıdır.
Değişiklik yakalama (CDC). Aktarıcı tabloyu sorgulamaz, veritabanının işlem günlüğünü okur. PostgreSQL'de mantıksal çoğaltma, MySQL'de binlog üzerinden çalışan araçlar (Debezium bunların en yaygını ve giden kutusu tablosu için hazır bir yönlendirme dönüşümü sunar) her insert'i neredeyse anında yakalar. Sorgu yükü yoktur, gecikme düşüktür.
Hangisini seçmeli? Saniyede birkaç yüz olayın altındaysanız ve ekipte CDC işleten kimse yoksa yoklama ile başlayın. Yüz satırlık bir aktarıcı, anlaşılması ve hata ayıklaması kolay bir parçadır. CDC'nin gerçek bedeli kurulum değil işletmedir: çoğaltma yuvası, bağlayıcı kümesi, şema değişikliklerinde bozulan yapılandırma. Gecikme gereksiniminiz saniyenin altına iniyorsa ya da yoklama sorguları veritabanında görünür bir yük oluşturmaya başlıyorsa CDC'ye geçin.
Tuzaklar
Sıra numarası commit sırası değildir. Yoklamayı "son gördüğüm id'den büyükleri getir" diye yazmak cazip gelir. Ama bigserial değeri insert anında alınır, satırın görünür olması commit anındadır. 101 numaralı satırı yazan işlem, 102'yi yazandan sonra commit ederse aktarıcı 102'yi okuyup imleci ilerletir ve 101 sonsuza kadar atlanır. Bu yüzden imleç yerine published_at IS NULL koşulu kullanın; durum, satırın kendisinde dursun.
Aktarıcı en az bir kez gönderir, tam bir kez değil. Broker onay verdikten sonra, UPDATE commit edilmeden önce aktarıcı ölürse aynı olay bir daha gönderilir. Bu desenin doğal sonucudur ve giderilemez. Çözüm tüketici tarafındadır: her olayın benzersiz kimliği olsun, tüketici işlediği kimlikleri kendi veritabanında tutsun ve yan etkiyle aynı işlemde kaydetsin.
BEGIN;
INSERT INTO processed_events (event_id) VALUES ($1)
ON CONFLICT DO NOTHING;
-- etkilenen satır 0 ise olay daha önce işlenmiştir, işlemi bitir
-- değilse asıl işi burada yap
COMMIT;
Paralel aktarıcı sırayı bozar. SKIP LOCKED ile üç aktarıcı çalıştırdığınızda aynı siparişin OrderCreated ve OrderPaid olayları farklı örneklere düşüp ters sırada yayımlanabilir. Sıra önemliyse olayları aggregate_id üzerinden bölümleyin: her aktarıcı yalnız kendi bölümündeki kayıtları alsın, broker'da da aynı anahtar kullanılsın ki aynı varlığın olayları aynı bölüme gitsin.
Tablo sessizce şişer. Yayımlanan satırlar silinmezse giden kutusu en büyük tablonuz olur, UPDATE ve DELETE yoğunluğu yüzünden de PostgreSQL'de ölü satır birikir. Yayımlananları küçük partiler hâlinde, düzenli olarak silen bir iş kurun; çok yüksek hacimde zamana göre bölümlenmiş tablo kullanıp eski bölümleri komple düşürmek çok daha ucuzdur.
CDC yuvası diski doldurabilir. Mantıksal çoğaltma yuvası, tüketicisi okumadığı sürece işlem günlüğünü tutar. Bağlayıcı durursa ve kimse fark etmezse WAL diski doldurur ve veritabanı yazmayı keser. CDC kullanıyorsanız yuvanın geride kalma miktarına alarm koyun; PostgreSQL 13 ve sonrasında max_slot_wal_keep_size ile bir üst sınır tanımlayın.
Yük, sözleşmedir. Olay gövdesine iş tablosunun satırını olduğu gibi koymak kolaydır ama o andan itibaren tablo şemanız diğer ekiplerin sözleşmesi olur. Yükü ayrı tasarlayın, bir event_type ve sürüm alanı taşıyın. Aynı sebeple CDC'yi doğrudan iş tablolarına bağlamak yerine giden kutusu tablosuna bağlamak daha sağlıklıdır: iç şemayı serbestçe değiştirebilirsiniz.
Aktarıcının sağlığını ölçün. En önemli metrik, en eski yayımlanmamış satırın yaşıdır. Aktarıcı çökmüş olabilir ama uygulama hata vermez, çünkü yazma tarafı tamamen yereldir. Bu yüzden sorun kullanıcı tarafında "bildirim gelmedi" şikâyetiyle görünür hâle gelir. Yaş birkaç dakikayı geçtiğinde alarm verin.
Ne zaman kullanmamalı
Olayı tüketen kod aynı veritabanını kullanıyorsa giden kutusuna gerek yoktur; işi doğrudan aynı işlemde yapın ya da veritabanının kendi iş kuyruğunu kullanın. Olayın kaybolması kabul edilebilirse (analitik sayaçlar, önbellek ısıtma) basit bir "commit sonrası yayınla" yeterlidir ve ek parça işletmenin değeri yoktur.
Bir de alternatif desen var: olayı önce broker'a yazıp servisin kendi veritabanını da o olayı tüketerek güncellemesi. Bu, olay odaklı mimariye tamamen geçmiş sistemlerde temiz çalışır ama "yazdığım şeyi hemen okuyabilirim" garantisini kaybettirir. Çoğu CRUD ağırlıklı serviste giden kutusu daha az şaşırtıcı bir seçimdir.
Kısa kural: iş verisi ile dışarıya giden haber arasında tutarsızlık bir müşteriye ya da paraya dokunuyorsa giden kutusu kurun, aktarıcıyı yoklamayla başlatın, tüketicileri tekrar eden mesaja dayanıklı yazın ve en eski bekleyen olayın yaşını izleyin.