İçeriğe atla

Dağıtık İş Zamanlayıcı Sistem Tasarımı: Çift Çalıştırma Olmadan Büyük Ölçekte Cron

Dağıtık bir iş zamanlayıcı tasarlayın: zamanlama deposu, SKIP LOCKED ile iş sahiplenme, lease'ler, idempotent çalıştırmalar, yeniden denemeler, misfire'lar ve yaz saati.

· Kurucu ve geliştirici

Yayımlandı:

Mimari diyagram. Job API, Postgres zamanlama deposuna yazar. Çerçevedeki üç zamanlayıcı worker'ı etcd koordinatöründen bölüm lease'leri alır, çalıştırmaları kuyruğa gönderir. Yürütücüler hedefleri çağırır, geçmişi kaydeder, kuyrukla yeniden dener ya da dead-letter'a yollar.

Ürününüz her müşteriye kendi saat diliminde saat 09:00'da bir kullanım raporu e-postası gönderiyor, CRM hesaplarını 15 dakikada bir eşitliyor ve deneme sürelerini gece yarısı sona erdiriyor. Tek bir sunucuda bunu cron halleder. İkinci bir sunucu ekleyin, rapor iki kez gider. Bir kilit ekleyin, kilidi tutan süreç bir deploy sırasında takılıp kalsın, bir sabah rapor hiç gitmez.

Bu yazı, milyonlarca işi zamanında başlatan ve bir kopyanın zarar vermesine izin vermeyen dağıtık bir iş zamanlayıcı tasarlıyor: zamanlama deposu ve lease'lerden yaz saati uygulamasına, misfire'lara, ölçeklemeye ve mevcut araçlara kadar.

Gereksinimler ve önemli sayılar

Çok kiracılı (multi-tenant) bir SaaS ürünü varsayın:

  • 10 milyon aktif zamanlama; çoğu saatlik ya da günlük.
  • Ortalama bir zamanlama günde 24 kez tetikleniyorsa günde 240 milyon çalıştırma: saniyede yaklaşık 2.800 (240.000.000 / 86.400).
  • Çalıştırmalar, zamanları geldikten sonraki birkaç saniye içinde başlar ve 30 dakikaya kadar sürer.
  • 30 günlük çalıştırma geçmişi: Satır başına yaklaşık 200 bayttan günde 240 milyon satır, günde 48 GB ve ayda 1,4 TB demektir.

İnsanlar 0 * * * * yazar; bu yüzden çalışma zamanları yuvarlak dakikalarda yığılır. 3 milyon saatlik zamanlama 0. dakikayı kullanıyorsa 3 milyon çalıştırmanın zamanı aynı anda gelir; bunları bir dakika içinde dağıtmak saniyede 50.000 çalıştırma, yani ortalamanın 18 katı demektir. Jenkins H cron sembolünü bunun için ekledi: İş adını hash'leyerek sabit bir dakikaya eşler. Dahili işleri de aynı şekilde yayın.

Veri modeli: işler ve çalıştırmalar

jobs her tanımı ve bir sonraki çalışma anını tutar; job_runs her gerçekleşme için bir satır tutar.

CREATE TABLE jobs (
  job_id       uuid PRIMARY KEY,
  cron_expr    text NOT NULL,          -- '0 9 * * *'
  time_zone    text NOT NULL,          -- 'America/New_York'
  misfire      text NOT NULL DEFAULT 'run_once',
  partition_id smallint NOT NULL,      -- hash(job_id) % 256
  next_run_at  timestamptz NOT NULL,   -- a UTC instant
  paused       boolean NOT NULL DEFAULT false
);
CREATE INDEX jobs_due ON jobs (partition_id, next_run_at) WHERE NOT paused;

CREATE TABLE job_runs (
  job_id       uuid NOT NULL,
  scheduled_at timestamptz NOT NULL,
  attempt      int NOT NULL DEFAULT 0,
  status       text NOT NULL,          -- queued, running, retrying, succeeded, dead
  lease_until  timestamptz,
  PRIMARY KEY (job_id, scheduled_at)   -- the run key
) PARTITION BY RANGE (scheduled_at);

(job_id, scheduled_at) çalıştırma anahtarıdır: Tek bir gerçekleşmeyi tanımlar ve sonraki her güvencenin dayanağıdır. Günlük bölümler sayesinde saklama süresini uygulamak bir bölümü silmekten ibarettir; PostgreSQL bu birincil anahtara izin verir, çünkü anahtar bölüm sütununu içerir.

Zamanı gelen işleri bulmak

Her zamanlayıcı, sahip olduğu bölümleri yoklar:

SELECT job_id, next_run_at
FROM jobs
WHERE partition_id = ANY($1) AND NOT paused
  AND next_run_at <= now()
ORDER BY next_run_at
LIMIT 500
FOR UPDATE SKIP LOCKED;

Aynı işlem (transaction) içinde her iş için bir job_runs satırı ekler ve next_run_at değerini ileri alır. SKIP LOCKED, başka bir işlemin tuttuğu satırları beklemek yerine atlar; PostgreSQL bu kullanımı kuyruk benzeri tablolar için belgeler. Commit'ten sonra gelen yoklayıcılar satırı artık zamanı gelmiş olarak görmez: Her gerçekleşme bir kez dağıtılır.

Commit'ten sonra kuyruğa yayınlamak bir çift yazmadır (dual write); bu yüzden yeni çalıştırma satırını, transactional outbox deseninde olduğu gibi aynı zamanda bir outbox kaydı olarak kullanın. Polling'in alternatifleri de var:

Yaklaşım Zamanı gelen işi nasıl bulur Zayıf noktası
jobs tablosunu yoklamak SKIP LOCKED ile next_run_at üzerindeki bir dizin Her tetikleme dizinli bir sütunu günceller: HOT güncelleme yok, sürekli vacuum yükü
Zaman kovalı bölümler Geçerli dakikanın kovasını okuyarak Zaten okunmuş bir kovaya geç eklenen satırlar için ayrı bir tarama gerekir
Gecikme kuyruğu Çalıştırmaları zamanı gelince bırakan bir broker ya da sorted set Gecikme sınırları (Amazon SQS: 15 dakika); doğruluk kaynağı yine depodur

Lease'ler, heartbeat'ler ve idempotent çalıştırmalar

Kuyruktan itibaren teslimat en az bir kezdir: Bir tüketici onay vermeden çökebilir, bir aktarıcı da aynı mesajı iki kez yayınlayabilir. Bu yüzden yürütücü önce çalıştırmayı sahiplenir:

UPDATE job_runs
SET status = 'running', attempt = attempt + 1,
    lease_until = now() + interval '30 seconds'
WHERE job_id = $1 AND scheduled_at = $2
  AND (status IN ('queued', 'retrying')
       OR (status = 'running' AND lease_until < now()))
RETURNING attempt;

Hiç satır dönmemesi, başka bir yerde canlı bir lease olduğu ya da çalıştırmanın bittiği anlamına gelir: Mesajı atın. Yürütücü 10 saniyede bir heartbeat gönderir; böylece 30 saniyelik bir lease tek bir heartbeat kaybını atlatır. Yürütücü ölürse lease'in süresi dolar ve sahip zamanlayıcı çalıştırmayı yeniden yayınlar.

Duraklamış bir yürütücü, lease'inin süresi dolduktan sonra uyanıp yazmaya devam edebilir. Dönen attempt, Martin Kleppmann'ın dağıtık kilitler üzerine analizindeki gibi bir fencing token'dır: Sonuçlar WHERE attempt = $3 koşuluyla yazılır; böylece eski bir deneme daha yenisinin üzerine yazamaz. Veritabanınızın dışındaki hedefler çalıştırma anahtarını idempotency anahtarı olarak alır.

Dürüst garanti şudur: Çalıştırma anahtarı başına bir kez dağıtım, en az bir kez yürütme, yalnızca idempotent işlerde tek seferlik etki. Kubernetes CronJob belgeleri de aynısını söyler, çünkü bir CronJob iki Job oluşturabilir ya da hiç oluşturmayabilir; controller'ı her Job'a zamanlanmış saate göre ad bile verir, böylece yinelenen bir oluşturma isteği başarısız olur.

Backoff ile yeniden denemeler ve dead-letter durumu

  • Hataları sınıflandırın. Zaman aşımları, 5xx ve 429 yeniden denenebilir; doğrulama hataları doğrudan dead durumuna gider.
  • Tam jitter ile backoff uygulayın: delay = random(0, min(cap, base * 2^attempt)). 10 saniyelik bir tabanla dört yeniden deneme sırasıyla en fazla 20, 40, 80 ve 160 saniye bekler; beşinci başarısızlık kesindir. Hedefin tamamı çöktüyse yeniden deneme bütçeleri ve circuit breaker'lar yeniden denemelerin üst üste yığılmasını önler.
  • Dead durumunu işe yarar tutun: payload, son hata, bir alarm ve çalıştırma anahtarını yeniden kullanan bir yeniden oynatma (replay).
  • Kiracı başına eşzamanlılığı sınırlayın, tıpkı bir hız sınırlayıcının yaptığı gibi; böylece bir kiracının ani yük patlaması diğerlerini aç bırakamaz.

09:00 çalıştırması saat 10:00'da hâlâ yeniden deneniyorsa kararı bir çakışma politikası verir: atlamak, kuyruğa almak, ikisini de çalıştırmak ya da eskisini iptal etmek. Kubernetes buna concurrencyPolicy der (varsayılanı Allow); Temporal zamanlamalarında varsayılan Skip'tir.

Cron semantiği: saat dilimleri, yaz saati ve misfire'lar

Her cron ifadesini asla sabit bir ofsetle değil, America/New_York gibi bir IANA saat dilimiyle saklayın ve bir sonraki çalıştırmayı UTC'ye çevirmeden önce yerel saatte hesaplayın; 24 saat eklemek yılda iki kez kayar. Altyapı işleri için UTC'yi, insanların belirli bir saat beklediği yerlerde ise yerel saati kullanın; bir bildirim sistemindeki sessiz saatlerde olduğu gibi.

Yaz saati uygulaması yerel saati bozar: New York'ta 8 Mart 2026'da 02:00 ile 02:59 arası hiç yaşanmadı, 1 Kasım 2026'da ise 01:00 ile 01:59 arası iki kez yaşandı. Zamanlayıcılar bu iki durumda da farklı davranır:

Zamanlayıcı Var olmayan saat Tekrarlanan saat
Linux cron (cronie) Saat atladıktan hemen sonra çalışır Bir kez çalışır
Amazon EventBridge Scheduler Çalıştırmayı atlar Bir kez çalışır
timeZone ayarlı Kubernetes CronJob Çalıştırmayı atlar İki kez çalışır

Kubernetes satırı iki kaynağa dayanır: İleri alınan saatteki işlerin çalışmadığını belgeleyen cron kütüphanesi ve saat 02:00'de çalışan bir Berlin işinin iki kez çalıştığı 121594 numaralı issue. Bir kural seçin ve test edin.

Misfire, zamanlayıcı kapalı ya da aşırı yüklü olduğu için geç başlayan bir çalıştırmadır. Bir zamanlayıcı geç kalmış bir satırı sahiplendiğinde iş başına tanımlanan bir politika uygulanır:

Politika 3 saatlik kesintiden sonra saatlik bir iş Kullanım alanı Karşılıkları
Bir kez çalıştır Bir kez çalışır, sonra normal akışına döner Eşitlemeler, temizlikler Quartz smart policy, catchup kapalı Airflow (varsayılan)
Atla Bir sonraki zaman dilimini bekler Hatırlatmalar Quartz DO_NOTHING, kısa bir Temporal catchup penceresi
Telafi et Üçünü de en eskisinden başlayarak çalıştırır Saatlik faturalama Quartz IGNORE_MISFIRE_POLICY, Airflow catchup=True

Quartz bir çalıştırmayı varsayılan olarak 60 saniye sonra misfire sayar. Kubernetes'te iki tuzak vardır: 10'un altındaki bir startingDeadlineSeconds değeri çalıştırmaların atlanmasına yol açabilir, çünkü controller 10 saniyede bir kontrol eder; ayrıca bir kesintiden sonra yalnızca kaçırılan en son çalıştırmayı başlatır, öncekiler kaybolur (100'den fazla kaçırmada ek olarak bir TooManyMissedTimes uyarısı kaydeder).

Zamanlayıcıyı ölçeklemek

Birden fazla zamanlayıcı çalıştırmanın üç yolu:

  1. Tek lider. Tek bir instance bir lease tutar (etcd, ZooKeeper, bir Kubernetes Lease) ve her şeyi yoklar. Basittir ama tek bir düğümle sınırlıdır ve failover lease'in dolmasını bekler: CronJob controller'ının da çalıştığı kube-controller-manager'da varsayılan olarak 15 saniye.
  2. Rekabet eden yoklayıcılar, tek bir tabloda SKIP LOCKED ile: Koordinatör gerekmez ama tek bir sıcak dizin oluşur. Quartz bir veritabanı kilidiyle küme oluşturur ve yaklaşık üç düğümün ötesinde performansın düştüğü konusunda uyarır.
  3. Bölüm sahipliği. İşleri zamanlayıcı sayısından çok daha fazla olan 256 mantıksal bölüme ayırın; her zamanlayıcı kendi bölümleri için etcd'den lease alır ve yalnızca onları yoklar. Tutarlı hashleme, bir düğüm katıldığında ya da öldüğünde yalnızca bir payı taşır; etcd de lease'leri Raft ile tutarlı tutar.

Sahiplik bir optimizasyondur, garanti değil: Duraklamış eski bir sahip devirden sonra bir kez daha yoklarsa satır kilidi, ileri alınmış next_run_at ve çalıştırma anahtarı onu durdurur. Bölümler aynı zamanda veritabanı sharding'inin birimi olur.

Mevcut araçlar ve her birinin güçlü yanı

Araç Güçlü olduğu alan Dikkat edilmesi gereken
Kubernetes CronJob Kendi işlettiğiniz bir kümede batch container'lar Zamanlama başına bir nesne, yaklaşık zamanlama
Quartz Bir JVM uygulamasının içinde, kendi veritabanınızda saklanan zamanlama Küme kilidi yatay ölçeklemeyi sınırlar
Temporal Schedules Kalıcı yeniden denemeleri olan çok adımlı işler Temporal'ı işletmek ya da bulut hizmetine ödeme yapmak
Apache Airflow Bağımlılıkları ve backfill'leri olan veri hatları Milyonlarca küçük kiracı işi için tasarlanmamıştır
Amazon EventBridge Scheduler AWS'de yönetilen tetikleyiciler Bu yazının yazıldığı tarihteki (Kasım 2026) varsayılan kotalar: bölge başına 10 milyon zamanlama, birincil bölgelerde saniyede 1.000 çağrı; ikisi de artırılabilir

Kendi zamanlayıcınızı yalnızca kiracıların tanımladığı milyonlarca zamanlama için yazın.

Dağıtık iş zamanlayıcı diyagramı

Mimari diyagram. Job API, Postgres zamanlama deposuna yazar. Çerçevedeki üç zamanlayıcı worker'ı etcd koordinatöründen bölüm lease'leri alır, çalıştırmaları kuyruğa gönderir. Yürütücüler hedefleri çağırır, geçmişi kaydeder, kuyrukla yeniden dener ya da dead-letter'a yollar.

18 bileşen · 9 bağlantı

Archboard'da aç
Diyagram içeriği
  • Job API
  • Schedule store (jobs)
  • Coordinator
  • Scheduler workers · 256 partitions
  • Scheduler 1
  • Scheduler 2
  • Scheduler 3
  • Run queue
  • Executor workers
  • Target services
  • Run history (job_runs)
  • Dead letter
  • POST /jobs
  • due jobs
  • partition leases
  • runs
  • deliver
  • call + Idempotency-Key
  • lease, result
  • after 5 attempts
  • retry: backoff + jitter
Dağıtık bir iş zamanlayıcı. Bölümlere sahip zamanlayıcılar zamanı gelen işleri Postgres'ten sahiplenip çalıştırmaları yayınlar; yürütücüler lease alır, hedefleri idempotency anahtarıyla çağırır, backoff ile yeniden dener, tükenenleri dead-letter kuyruğuna yollar.

Nasıl okunur:

  • Soldan sağa, bir çalıştırma yürütmeye doğru ilerler; yol Job API'den ve zamanlama deposundan başlar.
  • Çerçeve zamanlayıcı katmanıdır: Üç worker, etcd'den aldıkları lease'lerle 256 bölümü aralarında paylaşır ve zamanı gelen çalıştırmaları yayınlar.
  • Yürütücü worker'lar her çalıştırma için lease alır; lease çalıştırma geçmişinde tutulur, hedefler bir idempotency anahtarıyla çağrılır ve sonuç kaydedilir.
  • Kesikli bağlantılar hata yollarıdır: backoff ile yeniden denemeler ve beşinci başarısızlıktan sonra dead letter kuyruğu.
  • Markdown bloğu misfire politikalarını ve boyutlandırma sayılarını listeler.

İş zamanlayıcı diyagramlarını net çizmek

  • Çalıştırma anahtarını canvas'a yazın. (job_id, scheduled_at), kopyalara karşı korumayı her oktan daha iyi anlatır.
  • Sahipliği gösterin: bölümleri ve lease'lerin nereden geldiğini. Üzerinde yalnızca zamanlayıcı yazan tek bir kutu, işin en zor kısmını gizler.
  • Hata yollarını kesikli çizin ve tetikleyicilerini etiketleyin.
  • Süreleri canvas'a yazın: Lease, heartbeat ve yeniden deneme üst sınırı toparlanma süresini belirler.

Gereksinimleri bir tasarım diyagramına dönüştürmeyi anlatan yazı, aynı alışkanlıkları bir URL kısaltıcıya uygular.

Archboard'da oluşturun

  1. Google ya da GitHub ile giriş yapın ve bir diyagram oluşturun.
  2. Job API için C4 Modeli pack'inden bir Container, zamanlama deposu için de Veri pack'inden bir Postgres node'u ekleyin.
  3. Temel pack'inden, zamanlayıcılar için üç Container node'u içeren bir Çerçeve ekleyin; etcd koordinatörü için de çerçevenin üstüne bir Container koyun.
  4. Çalıştırmalar için Veri pack'inden bir Kuyruk, yürütücüler için bir Container, çalıştırma geçmişi için ikinci bir Postgres, dead letter'lar için bir Kuyruk ve hedef servisler için C4 Modeli pack'inden bir External System ekleyin.
  5. Etiketli bağlantılar çizin; yeniden deneme ve dead-letter yollarını kesikli yapın.
  6. Misfire politikalarını içeren, Temel pack'inden bir Markdown bloğu ve sahiplenme sorgusu ile çalıştırma anahtarı gibi kısa Metin notları ekleyin.

Akışın Mermaid ile yapılmış bir eskizi, Mermaid'den düzenlenebilir diyagrama aracıyla ilk taslağa dönüşür.

Örnek 13 diagram node kullanıyor ve Free planın sınırları içinde kalıyor.

SSS

Zamanlanmış bir işin iki kez çalışması nasıl önlenir? İş kimliğini ve zamanlanmış saati birlikte benzersiz bir çalıştırma anahtarı olarak kullanın. Zamanı gelen işleri sahiplenmeyi ve bir sonraki çalışma zamanlarını ileri almayı tek bir işlemde yapın, çalıştırmaları yürütmeden önce lease ile alın ve çalıştırma anahtarını hedeflere idempotency anahtarı olarak iletin.

FOR UPDATE SKIP LOCKED ne işe yarar? Dönen satırları kilitler ve başka işlemlerin tuttuğu satırları atlar; böylece birden fazla worker birbirini beklemeden aynı tabloyu yoklayabilir. PostgreSQL'de 9.5 sürümünden beri vardır.

Bir iş zamanlayıcı yaz saati uygulamasını nasıl ele almalı? Her zamanlamayla birlikte bir IANA saat dilimi saklayın, sonraki çalıştırma zamanlarını UTC olarak hesaplayın ve var olmayan ile tekrarlanan saatlerde ne olacağına karar verin, çünkü araçlar farklı davranır: Saatler geri alındığında bir Kubernetes CronJob iki kez bile çalışabilir.

İş zamanlamada misfire nedir? Başlama zamanını kaçırmış bir çalıştırmadır; genellikle zamanlayıcı kapalı ya da aşırı yüklü olduğu için olur. Misfire politikası, hemen bir kez çalıştırmaya, bir sonraki zaman dilimine atlamaya ya da kaçırılan her çalıştırmayı telafi etmeye karar verir.

Dağıtık bir zamanlayıcı için Kubernetes CronJob yeterli mi? Zaten işlettiğiniz bir kümede birkaç yüz batch iş için evet. Milyonlarca kiracı zamanlaması için hayır: Her zamanlama bir Kubernetes nesnesi, her çalıştırma Pod'ları olan bir Job'dır ve zamanlama yaklaşıktır.

Ücretsiz başlayın — tek kullanıcı, kredi kartı gerekmez.

İlgili yazılar

← Blog'a dön

Bir dakikada canvas açın

Google veya GitHub ile giriş yapın. Tek koltuk ücretsiz; ekip katılınca planı yükseltin.