omeryanbas.com

Ömer Yanbaş

Genel Müdür, Ticofab Yazılım

VeriEntegrasyon

Idempotent işler: işi tam olarak bir kez yapmak

Kuyruklar ve webhook'lar en az bir kez teslim eder, her handler er geç iki kez çalışır. İkinci çalıştırmayı zararsız kılan şey: anahtar ve unique kısıt.

Kuyruk worker'ı aynı bildirimi iki kez gönderir. Kodda iki kez gönderen bir yer yoktur, log'da tek bir iş kabul edilmiştir ve iş açıkça bir kez çalışmıştır. Olan şey sıradan: worker işi bitirdi, onay broker'a ulaşmadan süreç öldü ve broker söz verdiği şeyi yaptı. Mesajı tekrar teslim etti.

Gerçekte ne oluyor

Kuyruklar, webhook gönderenler ve arka plan zamanlayıcıları neredeyse her zaman en az bir kez teslim garantisi verir. Gönderen, mesajı onaylanana kadar saklar; onay zamanında gelmezse mesajı yeniden gönderir. Bu bir kusur değil. Bir ödeme bildirimini kaybetmek, onu iki kez göndermekten kötüdür; o yüzden ciddi her sistem tekrarı tercih edecek şekilde kurulur.

Tekrar, çoğu kişinin sandığından daha çok durumda gelir:

  1. Worker yan etkiyi tamamladı ve onaylayamadan öldürüldü.
  2. Onay gönderildi ama dönüş yolunda kayboldu.
  3. İş, visibility timeout'tan uzun sürdü; broker işi kayıp saydı ve birincisi hâlâ çalışırken ikinci bir worker'a verdi.
  4. Bir arıza sonrası birileri bir batch'i elle yeniden gönderdi. En sık görüleni budur.
  5. Endpoint'iniz doğru cevap verdi ama yavaş cevap verdiği için gönderen tekrar denedi.

Üçüncü madde insanları şaşırtır, çünkü aynı işin iki kopyasının arka arkaya değil aynı anda çalışabileceği anlamına gelir. Önce kontrol edip sonra yazan her savunma orada çöker: iki worker da bakar, ikisi de bir şey görmez, ikisi de devam eder.

Nasıl görülür

Bunu genelde log'dan değil veriden fark edersiniz. Tekil olması gereken alana göre gruplayıp sayın:

SELECT external_event_id, count(*) AS runs
FROM notifications
WHERE created_at > now() - interval '7 days'
GROUP BY external_event_id
HAVING count(*) > 1
ORDER BY runs DESC
LIMIT 20;

Bu sorgu satır dönüyorsa işi zaten birden fazla kez yapıyorsunuz, sadece yeni fark ediyorsunuz. İki yer daha doğrulama verir. Broker her mesaj için teslim sayısı ya da yeniden teslim bayrağı sunar; bunu log'a yazmanın maliyeti yok:

logger.info('job received', {
  jobId: msg.id,
  attempt: msg.deliveryCount,
  eventId: msg.body.eventId,
});

Sonra tek bir event id'yi log'da aratın. Bir dakika arayla attempt: 1 ve attempt: 2 ile görünen, ikisi de aynı başarı satırına ulaşan bir iş, hatanın tamamını iki satırda gösterir. Event id'nin o işe ait her log satırında bulunmasının sebebi de bu; teslim raporunun tutunacak bir kayda ihtiyaç duyması gibi, bir tekrar da bağlandığı kayıt olmadan bir şey ifade etmez.

Çözüm

Kararı veritabanı versin. İki worker yarışa girdiğinde işleyen tek hakem unique kısıttır, o yüzden anahtar sonucuyla birlikte kendi tablosuna girer:

CREATE TABLE job_runs (
  key          text PRIMARY KEY,
  status       text NOT NULL,
  result       jsonb,
  attempts     int NOT NULL DEFAULT 1,
  created_at   timestamptz NOT NULL DEFAULT now(),
  completed_at timestamptz
);

Handler başka bir şey yapmadan önce anahtarı kapatır. Yarışı bir karara çeviren şey conflict cümlesidir:

async function handle(event) {
  const key = `notify:${event.id}`;

  const claimed = await db.query(
    `INSERT INTO job_runs (key, status)
     VALUES ($1, 'running')
     ON CONFLICT (key) DO NOTHING
     RETURNING key`,
    [key],
  );

  if (claimed.rowCount === 0) {
    const prior = await db.one(
      `SELECT status, result FROM job_runs WHERE key = $1`, [key],
    );
    if (prior.status === 'done') return prior.result;
    throw new RetryLater('bu anahtar için bir çalıştırma sürüyor');
  }

  const result = await doTheWork(event);

  await db.query(
    `UPDATE job_runs
     SET status = 'done', result = $2, completed_at = now()
     WHERE key = $1`,
    [key, result],
  );

  return result;
}

Burada üç özellik önemli. Insert ya başarılı olur ya da tek atomik adımda conflict bildirir, yani çalışma hakkını tam olarak bir worker alır. Başarıdan sonra gelen tekrar, saklanan sonucu döner; çağıran taraf yeni bir cevap değil aynı cevabı görür. İlk çalıştırma sürerken gelen tekrara ise paralel çalışma izni verilmez, sonra gelmesi söylenir.

Son duruma bir zaman sınırı gerekir. Worker anahtarı kapattıktan sonra bitiremeden ölürse satır running durumunda asılı kalır ve onu bir daha kimse denemez. Makul en uzun iş süresinden fazla running kalan satırları sıfırlayan bir süpürücü bunu çözer; sıfırlama attempts alanını artırmalı ki sürekli ölen bir iş sessizce dönmek yerine görünür olsun.

Geri alınamayan yan etkiler

Para göndermek ve mesaj göndermek transaction ile geri alınmaz. Bunlarda anahtarı çağrıdan sonra değil önce kapatın ve aynı anahtarı karşı sisteme de verin:

await claim(key);
await provider.charge({
  amount: order.total,
  idempotencyKey: key,
});
await markDone(key);

Süreç çağrı ile markDone arasında ölürse anahtar running kalır ve süpürücü işi tekrar dener. Tekrar denemede aynı idempotency anahtarı gider, karşı taraf anahtarı tanır ve ikinci bir çekim yapmak yerine ilk işlemi döner. Ödeme ve mesajlaşma API'lerinin çoğu böyle bir anahtar kabul eder; etmeyenlerde bile kendi referansınızla arama vardır, tekrar çağırmadan önce oradan bakabilirsiniz. Karşı taraftan hata değil hız sınırı dönüyorsa işi başarısız saymak yerine anahtarı tutup sonra deneyin; sebebi hız sınırını kalıcı hata saymanın bedeli yazısındakiyle aynı.

Kendi verinizi yazdığınız yerlerde daha basit biçim yeterli. Doğal anahtar üzerinde bir upsert, ayrı tabloya gerek kalmadan aynı işi görür:

INSERT INTO recipients (list_id, phone, name)
VALUES ($1, $2, $3)
ON CONFLICT (list_id, phone) DO UPDATE SET name = excluded.name;

Büyük bir içe aktarmayı hata sonrası yeniden çalıştırılabilir kılan şey budur; işin kendisi arka plan kuyruğuna alınmışsa ve herhangi bir noktadan devam edebiliyorsa bu özellik şart.

Çalıştığını nasıl doğrularsınız

Handler'ı aynı olayla iki kez çağırın ve işin bir kez yapıldığını doğrulayın:

const a = await handle({ id: 'evt_1', to: '900000000', body: 'test' });
const b = await handle({ id: 'evt_1', to: '900000000', body: 'test' });

assert.deepEqual(a, b);
assert.equal(await count('SELECT count(*) FROM notifications'), 1);

Sonra aynı şeyi eşzamanlı çalıştırın, çünkü sıralı test önce kontrol edip sonra yazan bir kurguda da geçer:

const results = await Promise.allSettled(
  Array.from({ length: 5 }, () => handle(event)),
);

Biri başarılı olmalı, dördü ya saklanan sonucu dönmeli ya da sonra denemeyi istemeli, tabloda yine tek satır olmalı. Üretimde ise yazının başındaki tekrar sorgusunu zamanlanmış kontrol olarak bırakın. Eskiden tekrar üreten, şimdi üretmeyen bir handler tek geçerli kanıttır.

Nelere dikkat etmeli

  • O anki zamandan, rastgele bir değerden veya deneme sayısından üretilen şey anahtar değildir. Her tekrar yeni bir değer üretir ve kısıt hiç devreye girmez.
  • Null olabilen bir kolon üzerindeki unique index, çoğu veritabanında tekrarlanan null'ları engellemez. Anahtarın bir parçası boş kalabiliyorsa generated column ya da niyeti açık eden bir partial index kullanın.
  • Idempotent olmak sıralı olmak demek değildir. Farklı iki olay yine yanlış sırada gelebilir; durum yazan bir handler, yeni durumun üstüne eskisini yazmadığını da kontrol etmeli.
  • Insert ile dış çağrı aynı transaction'a giremez. Anahtarın var olduğu ama çağrının yapılmadığı bir aralığın bulunduğunu kabul edin ve süpürücüyü ona göre yazın; o aralık yokmuş gibi davranmak işe yaramaz.
  • Tablo, kimse silmedikçe sonsuza kadar büyür. Saklama süresini ilk günde belirleyin ve işin gerçekten çalıştığını kontrol edin.

Tekrar deneyen her sistem er geç aynı işi size iki kez verir, genelde de en uygunsuz anda. En ucuz sigorta üç parçadan oluşuyor: gönderenin tekrarladığı bir anahtar, kimin önce gideceğine karar veren bir unique kısıt ve ikinci çalıştırmanın hiçbir şey yapmadan cevap vermesini sağlayan saklanmış bir sonuç. Az kod, tek fonksiyon; araştırması can sıkıcı bir arıza sınıfı böylece zaten var olan bir satıra dönüşüyor.

Sorular ve cevaplar

En az bir kez teslim ne demek?
Gönderen taraf onay alana kadar tekrar dener ve mesajı kaybetmektense iki kez göndermeyi tercih eder. Onay dönüş yolunda kaybolursa, gönderen sizin mesajı hiç işlemediğinizle işleyip onaylayamadığınızı ayırt edemez, o yüzden tekrar gönderir. Tam olarak bir kez teslim ağın verebileceği bir garanti değildir; bu yüzden tekrarı zararsız kılmak alıcı tarafın işidir.
İyi bir idempotency anahtarı nasıl olur?
Gönderenin hesapladığı ve her denemede aynı gönderdiği bir değer olmalı: kaynak sistemdeki event id veya işlemi tanımlayan alanların hash'i. İçinde o anki zaman, rastgele bir değer veya deneme sayacı bulunamaz, çünkü bunlar denemeler arasında değişir ve her tekrar yeni bir kayıt gibi görünür. Gönderen size bir id veriyorsa olduğu gibi kullanın.
İşi yapmadan önce SELECT ile kontrol etmek neden yetmiyor?
Çünkü iki worker aynı anda o SELECT'i çalıştırıp ikisi de hiçbir şey görmeyebilir ve ikisi de devam eder. Kontrol ile yazma tek bir atomik işlem olmak zorunda; unique index artı insert tam olarak bunu verir. İki worker arasında güvenilir biçimde hakemlik edebilen tek bileşen veritabanıdır.
Idempotency anahtarları ne kadar saklanmalı?
En azından gönderenin tekrar deneyeceği süre kadar, üstüne geniş bir pay ekleyerek. Birkaç gün çoğu kuyruk politikasını, bir ay çoğu webhook gönderenini kapsar. Para ile ilgili her şeyde daha uzun tutun ve eski satırları zamanlanmış bir işle silin ki bu tablo veritabanının en büyüğü hâline gelmesin.