Arka plan işlerinde idempotency: aynı işi iki kez yapmamak
Kuyruk sistemleri en az bir kez teslim garantisi verir, yani işiniz iki kez çalışacak. Veritabanı kısıtlarıyla tekrar teslimatı zararsız hale getirmenin pratik deseni.
Kuyruk sistemlerinin çoğu size en az bir kez teslim garantisi verir. En fazla bir kez değil, tam olarak bir kez hiç değil. Yani bir iş, siz hiçbir hata yapmasanız bile iki kez çalışacak. Worker mesajı işledi ama ack göndermeden önce öldü; görünürlük süresi doldu ve mesaj kuyruğa geri düştü; ağ kesildi, istemci aynı isteği tekrarladı; webhook sağlayıcısı 200 alamadığı için tekrar denedi. Bunların hepsi normal işletim koşulu.
Bu yüzden soru "işim iki kez çalışır mı" değil. Cevabı biliyorsunuz: çalışır. Soru şu: iki kez çalıştığında ne oluyor?
Yanlış çözüm: önce bak, sonra yap
En sık yazılan kod bu:
const varMi = await db.query(
'select 1 from fatura where siparis_id = $1', [siparisId]
)
if (varMi.rowCount > 0) return
await faturaOlustur(siparisId)
Bu kod tek worker'la test edildiğinde çalışır, üretimde iki worker aynı mesajı milisaniyeler arayla aldığında çalışmaz. İkisi de sorguyu yapar, ikisi de boş sonuç görür, ikisi de fatura keser. Kontrol ile eylem arasındaki boşluk yarış koşuludur ve uygulama kodunda kapatılamaz.
Kapatılabileceği tek yer veritabanı. Kontrolü siz yapmayın, benzersizlik kısıtı yapsın.
Talep etme deseni
create table is_kaydi (
anahtar text primary key,
durum text not null default 'calisiyor',
sonuc jsonb,
kiralama_son timestamptz not null,
guncelleme timestamptz not null default now()
);
async function birKezCalistir(anahtar, is) {
const talep = await db.query(
`insert into is_kaydi (anahtar, kiralama_son)
values ($1, now() + interval '5 minutes')
on conflict (anahtar) do update
set kiralama_son = excluded.kiralama_son,
guncelleme = now()
where is_kaydi.durum = 'calisiyor'
and is_kaydi.kiralama_son < now()
returning anahtar`,
[anahtar]
)
if (talep.rowCount === 0) {
const mevcut = await db.query(
'select durum, sonuc from is_kaydi where anahtar = $1', [anahtar]
)
if (mevcut.rows[0].durum === 'bitti') return mevcut.rows[0].sonuc
throw new Error('is baska bir worker tarafindan yurutuluyor')
}
const sonuc = await is()
await db.query(
`update is_kaydi set durum = 'bitti', sonuc = $2, guncelleme = now()
where anahtar = $1`, [anahtar, sonuc]
)
return sonuc
}
on conflict do nothing yerine koşullu do update yazmanın sebebi var. Sadece do nothing kullanırsanız, kilidi alıp işi bitiremeden çöken bir worker satırı sonsuza kadar kilitler; iş bir daha hiç çalışmaz. Kiralama süresi bu durumu çözer: süresi geçmiş bir kayıt yeniden talep edilebilir. Süreyi işin en kötü senaryodaki çalışma süresinden belirgin biçimde uzun seçin, yoksa hâlâ çalışan bir işin üstüne ikincisini salarsınız.
Anahtarı doğru seçmek, kodun kendisinden önemli
Anahtar rastgele üretilmiş bir UUID ise koruma yoktur. İstemci yeniden denerken yeni bir UUID üretiyorsa iki farklı iş kaydı oluşur ve iki fatura kesilir.
Anahtar, işin kimliğinden türemeli:
const anahtar = `fatura-olustur:${siparisId}`
Aynı sipariş için ikinci bir fatura kesilmesi anlamsızsa, anahtarda sipariş kimliği olmalı. Dışarıdan gelen bir idempotency başlığı kabul ediyorsanız, onu da istemcinin retry boyunca sabit tutması gerektiğini belgeleyin; sabit tutmayan istemci kendini korumamış olur.
Delta yazmayın, mutlak yazın
Bazı işler doğası gereği tekrar edilebilir, bazıları değil:
update hesap set bakiye = bakiye - 100 where id = 7; -- tekrar = ikinci tahsilat
update hesap set bakiye = 4200 where id = 7; -- tekrar = etkisiz
Para gibi biriken değerlerde en sağlam yol, bakiyeyi doğrudan güncellemek yerine hareket satırı yazmak ve o satıra benzersizlik kısıtı koymaktır. Bakiye hareketlerin toplamı olur; aynı hareketin ikinci kez yazılması kısıt tarafından reddedilir.
Dış dünyaya çıkan yan etkiler
Veritabanı içinde idempotency çözülebilir bir problem. Sorun, işin e-posta göndermek veya bir ödeme sağlayıcısını çağırmak gibi geri alınamaz bir adımı olduğunda başlar.
Üç kural işe yarıyor. Sağlayıcı bir idempotency anahtarı kabul ediyorsa kullanın, kendi anahtarınızı aynen geçirin. Kabul etmiyorsa, çağrıyı işin en sonuna alın ve öncesindeki bütün veritabanı yazmalarını tamamlayın; böylece tekrar durumunda kayıt üzerinden "bu zaten gönderildi" kararı verebilirsiniz. Üçüncüsü, gönderim kaydını çağrıdan önce mi sonra mı yazacağınıza bilinçli karar verin: önce yazmak en fazla bir kez, sonra yazmak en az bir kez davranışı üretir. İkisi aynı anda mümkün değil; hangisinin daha az zarar verdiği işe göre değişir.
Kuyruğa yazmakla veritabanına yazmak atomik değildir
Veritabanı işlemini commit ettikten sonra kuyruğa mesaj basıyorsanız, arada çöktüğünüzde mesaj hiç gitmez. Önce kuyruğa basıp sonra commit ediyorsanız, commit başarısız olduğunda olmayan bir kayıt için iş tetiklenir. Standart çözüm outbox: mesajı da aynı transaction içinde bir tabloya yazın, ayrı bir süreç o tabloyu okuyup kuyruğa taşısın. Taşıma adımı en az bir kez çalışır, bu yüzden tüketici tarafında yine idempotency gerekir. Zincir bir yerde kapanmıyor, sadece tek noktaya toplanıyor.
Bakım ve test
is_kaydi tablosu sonsuza kadar büyür. Tamamlanmış kayıtlar için bir saklama süresi belirleyin ve düzenli silin; anahtar sütununda birincil anahtar zaten var, silme için guncelleme üzerine indeks ekleyin.
Testte tek bir şey ölçün: aynı işi paralel olarak üç kez çağırın ve yan etkinin bir kez oluştuğunu doğrulayın. Sıralı iki çağrı testi, yukarıdaki yanlış çözümü de geçer; asıl ayrımı paralel test yapar.
Bir sonraki arka plan işinizi yazarken üç soruyu cevaplayın: anahtar ne, iş yarıda ölürse ne oluyor, dışarıya çıkan çağrı hangisi. Üçüne de cevabınız varsa tekrar teslimat bir olay olmaktan çıkar.
- idempotency
- kuyruk
- PostgreSQL
- arka plan işleri