Dağıtık iş zamanlayıcı (job scheduler)
Problem
"Şirketin servislerinin ortak kullanacağı dağıtık bir iş zamanlayıcı tasarla. İki tür iş var: tek seferlik gecikmeli işler ('ödenmeyen siparişi 30 dakika sonra iptal et', 'yarın 18:00'de hatırlatma gönder') ve tekrarlayan cron işleri ('her gece 02:00'de rapor üret'). İşler zamanında başlamalı, worker çökerse kaybolmamalı ve aynı iş iki kez çalışıp zarar vermemeli."
Bu soru, dağıtık sistemlerin en temel üç kavramını tek bir üründe birleştirir: kiralama (lease) ile iş sahipliği, en az bir kez çalıştırma ve idempotent işler, ve lider seçimi. Mülakatçı adayın "tam bir kez çalıştırma" iddiasına düşüp düşmediğine, iş sahipliğinin zamanla dolan bir kira olarak modellenip modellenmediğine, gece yarısı gibi yuvarlak saatlerdeki yığılmaya ve worker'ın "öldü sanılıp aslında yaşadığı" (zombi) senaryosuna bakar.
Netleştirme soruları ve varsayımlar
| Soru | Neden önemli | Varsayım |
|---|---|---|
| Kaç iş, ne kadar ileriye zamanlanıyor? | Depolama ve index | Günde 10 milyon iş; birkaç saniye ile birkaç ay ileri |
| İş ne kadar zamanında başlamalı? | Polling sıklığı, mimari | Planlanan zamandan sonra p99 birkaç saniye içinde |
| İşi zamanlayıcı mı çalıştırır, yoksa hedef servise mi haber verir? | Sorumluluk sınırı | Zamanlayıcı tetikler (Kafka mesajı ya da HTTP çağrısı); iş mantığı hedef serviste |
| İki kez çalışma ne kadar kötü? | Teslim semantiği | İşler idempotent yazılmalı; zamanlayıcı en az bir kez garanti eder |
| Kaçırılan çalışmalar (sistem kapalıyken) ne olsun? | Misfire politikası | İş bazında: hepsini çalıştır, bir kez çalıştır ya da atla |
| Saat dilimi? | Cron yorumu | İş bazında IANA saat dilimi |
| İş süresi? | Lease ve heartbeat | Saniyeler ile 1 saat arası |
| İptal ve güncelleme? | API | Henüz başlamamış iş iptal edilebilir, zamanı değiştirilebilir |
Kapasite tahmini
İş sayısı : 10M /gün ≈ 116 /s ortalama
Yuvarlak saat dalgası: 00:00 ve 09:00'da 1M iş; bir dakika içinde başlaması gerekirse
1.000.000 / 60 ≈ 16.700 iş/s
Bekleyen iş : aylar sonrasına zamanlanmış dahil ~50M satır
İş kaydı : ~1 KB (payload, durum, deneme bilgisi)
Depolama : 10M x 1 KB = 10 GB/gün; 30 gün geçmiş ≈ 300 GB + bekleyenler ~50 GB
Teşhis:
- Ortalama hız küçük (saniyede yüzün biraz üstü); tek bir PostgreSQL kümesi bunu rahat kaldırır.
- Asıl yük yuvarlak saatlerde yığılır: gece yarısı 1 milyon işin bir dakika içinde başlatılması bile ortalamanın ~140 katı bir dalga demektir (aynı saniyeye yığılırsa binlerce katı). Bu dalga hem zamanlayıcıyı hem de işlerin çağırdığı servisleri ezer. Tasarımın önemli bir kısmı bu yığılmayı yaymakla ilgilidir.
- Doğruluk problemi, ölçekten büyüktür: worker çökmesi, ağ bölünmesi ve GC duraklaması altında bir işin kaybolmaması ve iki kez zarar vermemesi.
API
POST /api/v1/jobs
Idempotency-Key: order-98213-auto-cancel
{ "type": "ORDER_AUTO_CANCEL",
"runAt": "2026-10-23T14:30:00Z",
"target": { "kind": "KAFKA", "topic": "order-commands", "key": "98213" },
"payload": { "orderId": 98213 },
"maxAttempts": 5, "timeoutSeconds": 60 }
-> 201 { "jobId": "job_01H...", "status": "SCHEDULED" }
POST /api/v1/cron-jobs
{ "name": "daily-settlement-report", "schedule": "0 2 * * *", "timeZone": "Europe/Istanbul",
"target": { "kind": "HTTP", "url": "https://reports.internal/run" },
"misfirePolicy": "RUN_ONCE", "concurrency": "FORBID", "jitterSeconds": 300 }
PATCH /api/v1/jobs/{jobId} { "runAt": "..." } # sadece SCHEDULED iken
DELETE /api/v1/jobs/{jobId} # iptal, sadece başlamamışsa
GET /api/v1/jobs/{jobId} # durum, deneme geçmişi
- Idempotency key iş oluşturmayı tekrarlara karşı korur: sipariş servisi "30 dakika sonra iptal et" işini iki kez kaydetmemeli.
- Hedef, işin iş mantığını değil, tetiklenecek yeri tanımlar. Zamanlayıcı genel amaçlıdır; "siparişi iptal etmek" sipariş servisinin işidir.
concurrency: FORBID, bir önceki çalışma bitmeden yenisinin başlamamasını ister (Kubernetes CronJob'dakiconcurrencyPolicyile aynı fikir).
Veri modeli
CREATE TABLE jobs (
id UUID PRIMARY KEY,
idempotency_key VARCHAR(200) NOT NULL,
owner_service VARCHAR(64) NOT NULL,
type VARCHAR(64) NOT NULL,
cron_id UUID, -- tekrarlayan işin bir çalışmasıysa
scheduled_for TIMESTAMPTZ NOT NULL, -- mantıksal zaman (cron'un planladığı an)
run_at TIMESTAMPTZ NOT NULL, -- jitter ve retry sonrası gerçek çalışma zamanı
status VARCHAR(16) NOT NULL, -- SCHEDULED, RUNNING, SUCCEEDED, FAILED, CANCELLED
attempt INT NOT NULL DEFAULT 0, -- her sahiplenmede artar (fencing token)
max_attempts INT NOT NULL,
locked_by VARCHAR(128),
lease_until TIMESTAMPTZ,
target JSONB NOT NULL,
payload JSONB NOT NULL,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT jobs_idem_uq UNIQUE (owner_service, idempotency_key),
CONSTRAINT jobs_cron_once_uq UNIQUE (cron_id, scheduled_for)
);
CREATE INDEX jobs_due_idx ON jobs (run_at) WHERE status = 'SCHEDULED';
CREATE INDEX jobs_lease_idx ON jobs (lease_until) WHERE status = 'RUNNING';
CREATE TABLE cron_jobs (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL UNIQUE,
schedule VARCHAR(64) NOT NULL,
time_zone VARCHAR(64) NOT NULL,
next_fire_at TIMESTAMPTZ NOT NULL,
misfire_policy VARCHAR(16) NOT NULL,
concurrency VARCHAR(16) NOT NULL,
jitter_seconds INT NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT true
);
- Partial index'ler (
WHERE status = 'SCHEDULED') sorguyu bekleyen işlerle sınırlar; tamamlanmış milyonlarca satır index'i şişirmez. UNIQUE (cron_id, scheduled_for), bir cron'un aynı mantıksal zaman için iki kez çalışma üretmesini veritabanı seviyesinde imkansız kılar. Bu, lider seçimindeki hataların zararsız kalmasını sağlayan kilit constraint'tir.- Tamamlanmış işler günlük partition'lara taşınır ve 30 gün sonra partition düşürülerek silinir.
Yüksek seviye tasarım
flowchart LR
SVC[İstemci servisler] -->|iş oluştur| API[Zamanlayıcı API]
API --> DB[(PostgreSQL jobs ve cron_jobs)]
CM[Cron materializer, tek lider] --> DB
W1[Worker 1] -->|SKIP LOCKED ile sahiplen| DB
W2[Worker 2] -->|SKIP LOCKED ile sahiplen| DB
W3[Worker N] -->|SKIP LOCKED ile sahiplen| DB
W1 -->|tetikle| K[(Kafka komut topic'leri)]
W2 -->|tetikle| HTTP[Hedef servis HTTP uç noktaları]
K --> TGT[Hedef servisler, idempotent handler]
REAP[Lease toplayıcı] --> DB
LE[Lider seçimi, lease] --> CM- API işleri kaydeder; idempotency key ile tekrarları eler.
- Worker'lar zamanı gelmiş işleri sahiplenir, hedefe tetikleme gönderir, sonucu yazar. Hepsi eşittir; aralarında koordinasyon veritabanı satır kilitleri üzerinden olur.
- Cron materializer tekrarlayan tanımlardan bir sonraki çalışmayı
jobstablosuna yazar. Tek bir lider olarak çalışır ama tasarım iki liderin aynı anda çalışmasına da dayanıklıdır. - Lease toplayıcı süresi dolmuş
RUNNINGişleri tekrarSCHEDULEDyapar (worker'lar bunu sahiplenme sorgusunda da yapabilir).
Derinlemesine
İşi sahiplenmek: SKIP LOCKED ve lease
Birden fazla worker aynı tabloyu tarar; her işin tek bir worker tarafından alınması gerekir. Worker kısa bir transaction'da işi sahiplenir, commit eder ve işi transaction dışında çalıştırır:
-- 1) Sahiplenme (kısa transaction)
WITH due AS (
SELECT id
FROM jobs
WHERE status = 'SCHEDULED' AND run_at <= now()
ORDER BY run_at
LIMIT 100
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'RUNNING', attempt = j.attempt + 1,
locked_by = :workerId, lease_until = now() + interval '2 minutes'
FROM due
WHERE j.id = due.id
RETURNING j.id, j.attempt, j.target, j.payload;
-- 2) İş çalıştırılır (transaction dışında, hedefe tetikleme gönderilir)
-- 3) Tamamlama: sadece hâlâ sahibi isek
UPDATE jobs SET status = 'SUCCEEDED', lease_until = NULL
WHERE id = :id AND status = 'RUNNING' AND attempt = :attempt;
Neden böyle:
FOR UPDATE SKIP LOCKED: Başka bir worker'ın o an kilitlediği satırlar beklenmeden atlanır; worker'lar birbirini bloklamadan farklı işleri alır. PostgreSQL dokümantasyonu bu seçeneğin tutarsız bir görünüm verdiğini, genel amaçlı sorgular için uygun olmadığını ama kuyruk benzeri tablolarda birden fazla tüketicinin çekişmesini önlemek için kullanılabileceğini söyler; bu tablo tam olarak böyle bir kuyruktur.- İş transaction dışında çalışır: İşi
FOR UPDATEkilidini tutarak çalıştırmak, dakikalarca açık transaction (bağlantı havuzu tükenir, VACUUM engellenir) demektir. Sahiplik kilitle değil, satırdaki durumla tutulur. - Lease: Sahiplik süresizdir değil,
lease_until'e kadardır. Worker çökerse lease dolar ve iş başka bir worker tarafından tekrar alınır. Uzun işler çalışırken periyodik olarak lease'i uzatır (heartbeat):UPDATE jobs SET lease_until = now() + interval '2 minutes' WHERE id = :id AND status = 'RUNNING' AND attempt = :attempt; 0 satır dönerse worker sahipliği kaybetmiştir ve durmalıdır. attemptbir fencing token'dır: Her sahiplenmede artar. Tamamlama ve heartbeatattempt = :attemptkoşuluyla yapılır. Eski sahip (lease'i dolmuş ama aslında yaşayan bir worker) geç kalmış tamamlamasını yazmaya çalışırsa 0 satır etkiler.
stateDiagram-v2
[*] --> SCHEDULED
SCHEDULED --> RUNNING : worker sahiplendi, attempt artar
RUNNING --> SUCCEEDED : tamamlandı
RUNNING --> SCHEDULED : lease doldu veya hata, retry zamanı
RUNNING --> FAILED : deneme hakkı bitti
SCHEDULED --> CANCELLED : iptal
SUCCEEDED --> [*]
FAILED --> [*]
CANCELLED --> [*]"Tam bir kez çalıştırma" yoktur: en az bir kez + idempotent iş
Şu senaryo kaçınılmazdır: worker işi tetikler (sipariş servisine "iptal et" komutu gider), sonucu veritabanına yazmadan önce GC duraklamasına girer ya da çöker. Lease dolar, başka bir worker işi tekrar alır ve komut ikinci kez gider. Zamanlayıcı ile hedef servis arasında ortak bir transaction yoktur; bu yüzden zamanlayıcının dürüst garantisi en az bir kez tetiklemedir.
Etkinin bir kez olması hedef tarafta sağlanır:
- Tetikleme, işin kimliğini ve mantıksal zamanını taşır:
jobIdvescheduledFor. Hedef servis bunu idempotency anahtarı olarak kullanır (ör.processed_jobstablosunda unique kayıt, iş verisiyle aynı transaction'da). - Birçok iş doğal olarak idempotenttir: "sipariş 98213 hâlâ
PENDING_PAYMENTise iptal et" komutu ikinci kez çalıştığında sipariş zaten iptal olduğu için etkisizdir. İşleri "durum X ise Y yap" biçiminde tasarlamak en ucuz idempotency'dir. - Google SRE kitabının dağıtık cron bölümü de aynı sonuca varır: işlerin ya güvenle tekrar edilebilir ya da durumlarının sorgulanabilir olması gerekir; ve bazı işler için çift çalıştırmak yerine atlamayı tercih etmek daha güvenlidir, çünkü atlanan bir çalışma telafi edilebilir ama çift çalışmanın etkisini geri almak zor ya da imkansız olabilir.
Kubernetes CronJob dokümantasyonu da benzer bir uyarı içerir: CronJob her zamanlama için yaklaşık bir kez Job oluşturur, bazı durumlarda iki Job oluşabilir ya da hiç oluşmayabilir; bu yüzden Job'ların idempotent olması gerekir.
Tekrarlayan işler ve lider seçimi
Cron tanımından bir sonraki çalışmayı üretmek (materialize etmek) basit bir döngüdür: next_fire_at <= now() + 1 dakika olan tanımlar için jobs tablosuna satır ekle ve next_fire_at'i bir sonraki zamana ilerlet. Bunu tek bir sürecin yapması istenir; aksi halde iki süreç aynı çalışmayı iki kez ekler.
Lider seçimi seçenekleri:
| Yöntem | Nasıl | Dikkat |
|---|---|---|
| Kubernetes Lease nesnesi | Pod'lar Lease'i periyodik yeniler; yenileyemeyen liderliği kaybeder | Zaman tabanlı; GC duraklamasında eski lider bir süre kendini lider sanabilir |
| PostgreSQL advisory lock | pg_try_advisory_lock(key) alan süreç lider | Session seviyesinde kilit oturum bitene kadar tutulur; transaction pooling yapan bir bağlantı havuzlayıcıda (PgBouncer gibi) oturum garantisi bozulur |
| ZooKeeper / etcd | Konsensüs tabanlı lease ve ephemeral düğüm | Ek altyapı; güçlü garantiler |
| Veritabanı satırı ile lease | UPDATE leader SET owner = :me, until = now() + interval '15 seconds' WHERE until < now() OR owner = :me | Basit; yine zaman tabanlı |
Hangisi seçilirse seçilsin, zaman tabanlı bir liderlik iki liderin kısa süre aynı anda çalışmasını tamamen engelleyemez: lider uzun bir GC duraklamasına girer, lease'i dolar, yeni lider seçilir, eski lider uyanır ve bir süre kendini lider sanır (Kitap Bölüm 11'deki Redlock tartışmasının aynısı). Çözüm liderliği doğruluğun dayanağı yapmamaktır: UNIQUE (cron_id, scheduled_for) sayesinde iki lider aynı çalışmayı eklemeye çalışırsa ikincisi ON CONFLICT DO NOTHING ile etkisiz kalır. Liderlik böylece sadece bir verimlilik mekanizmasıdır; doğruluk veritabanı constraint'indedir.
next_fire_at'in ilerletilmesi ve jobs satırının eklenmesi aynı transaction'dadır; materializer çökerse ya ikisi birden olmuştur ya hiçbiri.
Gece yarısı dalgası: thundering herd
Kullanıcılar ve ekipler cron'u yuvarlak saatlere yazar: 0 0 * * *. Bir milyon işin aynı saniyede başlaması hem zamanlayıcıyı hem hedef servisleri ezer. Önlemler:
- Jitter: Tanıma
jitterSecondseklenir; materializerrun_at'ischeduled_for + rastgele(0, jitter)olarak yazar. Rastgele yerine deterministik bir sapma daha iyidir:hash(cron_id) % jitter. Her iş her gün aynı saniyede çalışır (öngörülebilir), ama işler arasında yayılır. Google SRE kitabındaki dağıtık cron, crontab'a "herhangi bir değer olabilir" anlamına gelen bir soru işareti uzantısı ekleyip değeri cron yapılandırmasını belirli bir aralığa hash'leyerek seçtiğini anlatır. scheduled_forilerun_atayrımı: Mantıksal zaman (raporun ait olduğu gün) değişmez, sadece fiziksel başlama zamanı kayar. Tekilleştirme ve idempotency mantıksal zamanla yapılır.- Hedef bazında eşzamanlılık sınırı: Bir hedef servise aynı anda en fazla N tetikleme (worker tarafında hedef başına semafor veya token bucket). Zamanlayıcı, kendi müşterilerini koruyan bir geri basınç noktasıdır.
- Önceden yükleme: Dalgadan bir dakika önce worker'lar zamanı yaklaşan işleri bellekteki bir zamanlama yapısına (zaman çarkı, timing wheel) alıp lease'leyebilir; böylece 00:00:00'da veritabanına 16.000 sorgu/s yerine önceden dağıtılmış küçük sorgular gider.
Kaçırılan çalışmalar, retry ve zaman aşımı
- Misfire politikası: Zamanlayıcı bir saat kapalı kaldıysa saatlik bir cron'un kaçırdığı çalışmalar ne olur?
RUN_ALL(her biri çalışır, ör. saatlik faturalama),RUN_ONCE(sadece bir kez telafi, ör. önbellek ısıtma),SKIP(sadece bir sonraki zamanlamayı bekle, ör. "son 5 dakikanın metriklerini gönder"). Varsayılan iş tipine göre seçilir ve tanımda açıkça yazılır. - Retry: Hedef hata dönerse iş
SCHEDULED'a döner,run_at = now() + backoff(attempt)(exponential + jitter).max_attemptsdoluncaFAILEDolur ve sahibi servise olay/alarm gider. Bu, bildirim sistemindeki DLQ'nun karşılığıdır. - Zaman aşımı: Her işin bir
timeoutSeconds'ı vardır; lease süresi bunun üzerinde seçilir. Uzun işler heartbeat ile lease'i uzatır; heartbeat gönderemeyen iş başka worker'a geçebilir, bu yüzden uzun işlerin de idempotent ve tercihen kaldığı yerden devam edebilir (checkpoint) olması gerekir. concurrency: FORBID: Bir cron'un önceki çalışması hâlâRUNNINGiken yenisinin sahiplenilmemesi için sahiplenme sorgusuna "aynıcron_idile çalışan iş yok" koşulu eklenir; Kubernetes CronJob'dakiForbidpolitikası da önceki çalışma bitmemişse yeni çalışmayı atlar.
Saat dilimi
Cron ifadeleri bir saat dilimine göre yorumlanır ve tanımda IANA adıyla saklanır (Europe/Istanbul). Yaz saati uygulayan bölgelerde ileri alma gecesinde 02:30 hiç yaşanmaz, geri alma gecesinde iki kez yaşanır; her iki durumun davranışı (atla/çalıştır, bir kez/iki kez) tanımlı olmalıdır. Türkiye 2016'dan beri yaz saati uygulamıyor, ama global bir zamanlayıcı bunu tüm bölgeler için çözmek zorundadır. İç saklama UTC'dir (run_at TIMESTAMPTZ), yorum katmanı saat dilimini bilir. Kubernetes CronJob da .spec.timeZone alanıyla IANA saat dilimi kabul eder.
Ölçeklenme
- Tek PostgreSQL kümesiyle başla: Ortalama yük küçük, dalgalar jitter ile yayılır.
- Sorgu maliyeti: Partial index sayesinde sahiplenme sorgusu bekleyen işlerin küçük bir kısmına bakar. Tamamlanmış işler partition'lara taşınır.
- Sharding gerekirse: İşler
hash(job_id) % Nile N veritabanına bölünür, her worker grubu bir shard'ı tarar. Shard'lar arası koordinasyon gerekmez, çünkü işler birbirinden bağımsızdır. - Uzak gelecekteki işler: Aylar sonrasına zamanlanmış işler sıcak tabloda durmak zorunda değildir; "yakın gelecek" tablosu (ör. 24 saat) ve "uzak gelecek" tablosu ayrılabilir, periyodik bir iş uzaktakileri yakına taşır.
- Alternatifler: Redis sorted set ile gecikme kuyruğu (skor = çalışma zamanı) çok hızlıdır ama kalıcılık ve failover garantileri bir iş zamanlayıcının "iş kaybolmamalı" gereksinimine göre zayıftır; Quartz'ın JDBC tabanlı kümelenmiş modu ve ShedLock gibi kütüphaneler aynı veritabanı kilidi fikrini uygulama içinden kullanır; Kubernetes CronJob ise altyapı seviyesindeki periyodik işler için iyidir ama milyonlarca kullanıcı tanımlı işe uygun değildir.
Gözlemlenebilirlik
- Başlama gecikmesi:
başlama zamanı - run_at'in p50/p99'u; zamanlayıcının asıl SLI'ı. - Zamanı gelmiş ama sahiplenilmemiş iş sayısı (birikme), lease dolumu sayısı (worker çökmesi veya yetersiz lease), deneme ve
FAILEDoranı, hedef bazında hata oranı. - Cron'lar için "son başarılı çalışma zamanı" alarmı: "günlük mutabakat raporu 26 saattir başarıyla çalışmadı" tipindeki alarm, sessizce duran bir cron'un tek güvenilir tespitidir.
Darboğazlar, hatalar ve trade-off'lar
| Senaryo | Etki | Önlem |
|---|---|---|
| Worker iş ortasında çöker | İş takılı kalır | Lease dolunca başka worker alır; iş idempotent |
| Worker GC duraklamasından uyanır (zombi) | Eski sahip geç sonuç yazar | attempt fencing token'ı ile koşullu tamamlama |
| İki lider aynı anda | Cron çalışması iki kez üretilir | UNIQUE (cron_id, scheduled_for) + ON CONFLICT DO NOTHING |
| Gece yarısı dalgası | Zamanlayıcı ve hedefler ezilir | Deterministik jitter, hedef başına eşzamanlılık sınırı, önceden yükleme |
| Hedef servis düştü | İşler hata alır | Backoff ile retry, max_attempts, sahibe alarm |
| Zamanlayıcı bir saat kapalı | Kaçırılan çalışmalar | İş bazında misfire politikası |
| Uzun açık transaction | Bağlantı havuzu tükenir, VACUUM engellenir | Sahiplenme kısa transaction; iş transaction dışında |
| Bir cron sessizce durdu | Rapor üretilmedi, kimse fark etmedi | "Son başarılı çalışma" alarmı |
Trade-off'lar:
- En az bir kez: Kayıp olmaması için tekrarı kabul ettik; tekrarın zararını idempotent işlerle sıfırladık.
- Veritabanı tabanlı kuyruk: Kalıcılık, transaction ve basit işletim; bedeli çok yüksek hızlarda sharding gereksinimi.
- Zaman tabanlı liderlik: Basit; doğruluğu liderliğe değil constraint'e bağlayarak riskini sıfırladık.
- Jitter: Yükü yaydık; bedeli işlerin tam saatinde değil birkaç dakika içinde başlaması.
Üç seviyede cevap
Soru: "Birden fazla worker var. Aynı işin iki worker tarafından alınmasını nasıl engellersin?"
seviye: Orta etiketler: [locking, skip-locked] kaynak: Tahmini
İşi almadan önce durumunu kontrol ederim, SCHEDULED ise RUNNING yapıp çalıştırırım.
Kontrol ve güncelleme ayrı olursa iki worker aynı anda SCHEDULED görür. Zamanı gelmiş işleri SELECT ... FOR UPDATE SKIP LOCKED ile alırım; bir worker'ın kilitlediği satırları diğeri atlar. Aynı transaction'da durumu RUNNING yapıp commit ederim, sonra işi çalıştırırım.
Sahiplenmeyi kısa bir transaction'da FOR UPDATE SKIP LOCKED ile yaparım: worker'lar birbirini beklemeden farklı satırları alır, durum RUNNING olur, lease_until yazılır ve attempt artar; işi transaction dışında çalıştırırım ki kilit ve bağlantı dakikalarca tutulmasın. Ama "iki worker almasın" sorusu aslında yetmez: worker çökerse ya da GC'de uzun süre donarsa lease dolar ve iş bilerek ikinci bir worker'a verilir. Bu yüzden attempt'i fencing token olarak kullanırım; tamamlama ve heartbeat attempt koşuluyla yapılır, eski sahibin geç gelen yazması etkisiz kalır. Hedefe giden tetikleme ise yine de iki kez gitmiş olabilir; bu yüzden zamanlayıcının garantisi en az bir kez, işin kimliği ve mantıksal zamanı hedef serviste idempotency anahtarı olur.
Junior check-then-act yarışına düşüyor. Mid doğru veritabanı mekanizmasını biliyor. Senior sahipliği kilit değil lease olarak modelliyor, işi transaction dışına çıkarıyor, zombi worker'ı fencing token ile kapatıyor ve dış etkinin tekrarlanabileceğini kabul edip idempotency'yi hedefe taşıyor. "Tam bir kez" yanılgısına düşmemek bu sorunun asıl sınavıdır.
Takip sorusu: Lease süresini nasıl seçersin? 10 saniye ve 30 dakika seçmenin bedelleri neler?
Soru: "Cron işlerini kim tetikler? Lider düşerse ne olur?"
seviye: İleri etiketler: [leader-election, scheduling] kaynak: Tahmini
Bir pod'da @Scheduled ile çalışan bir metot cron'ları tetikler.
@Scheduled her pod'da çalışır; 5 pod varsa iş 5 kez tetiklenir. Ya ShedLock gibi bir kütüphaneyle veritabanı kilidi alırım ya da lider seçimi yaparım; sadece lider cron'ları tetikler. Lider düşerse kilit süresi dolar, başka pod lider olur.
Cron tanımlarını tek bir materializer'ın jobs tablosuna çalışma olarak yazmasını isterim; lideri Kubernetes Lease, advisory lock ya da etcd ile seçerim. Ama zaman tabanlı her liderlikte iki liderin kısa süre üst üste binmesi mümkün: eski lider GC'de donarken lease'i dolar, yeni lider seçilir, eskisi uyanıp bir tur daha yazar. Doğruluğu liderliğe bağlamam: UNIQUE (cron_id, scheduled_for) constraint'i aynı mantıksal çalışmanın iki kez eklenmesini imkansız kılar, ikinci ekleme ON CONFLICT DO NOTHING ile etkisiz kalır; next_fire_at'i ilerletmek ve çalışmayı eklemek aynı transaction'da. Böylece liderlik sadece verimlilik için. Lider düşerse yeni lider next_fire_at'e bakarak kaldığı yerden devam eder; aradaki kaçırılan çalışmaları cron'un misfire politikasına göre üretirim: hepsi, bir kez ya da atla. Advisory lock kullanırsam bağlantı havuzlayıcısının oturum davranışına dikkat ederim.
Junior çok pod'lu ortamda çoğalmayı görmüyor. Mid kilit veya lider seçimini biliyor. Senior zaman tabanlı liderliğin zayıflığını açıkça söylüyor ve doğruluğu bir veritabanı constraint'ine taşıyarak liderliği "verimlilik" seviyesine indiriyor; misfire ve bağlantı havuzlayıcı gibi işletim detaylarını da biliyor.
Takip sorusu: Advisory lock'u PgBouncer'ın transaction pooling modu arkasında kullanırsan tam olarak ne bozulur?
Soru: "Gece 00:00'a zamanlanmış bir milyon iş var. Ne olur, nasıl yönetirsin?"
seviye: İleri etiketler: [thundering-herd, scheduling] kaynak: Tahmini
Daha fazla worker çalıştırırım ki hepsi zamanında başlasın.
Bir milyon iş bir dakikada başlarsa saniyede 16 binden fazla sahiplenme ve tetikleme demek; hem veritabanı hem hedef servisler zorlanır. İşlere jitter eklerim, örneğin 5 dakikalık bir aralığa yayarım. Hedef servislere de rate limit koyarım.
Worker eklemek sorunu hedef servislere taşır; asıl çözüm yükü yaymak. Mantıksal zaman ile fiziksel çalışma zamanını ayırırım: scheduled_for 00:00 kalır, run_at hash(cron_id) % jitter kadar kayar. Rastgele değil deterministik sapma, çünkü her iş her gün aynı saniyede çalışır, davranış öngörülebilir olur. Google'ın dağıtık cron'u da cron yapılandırmasını hash'leyerek aynı şeyi yapıyor. Tekilleştirme ve idempotency mantıksal zamanla yapıldığı için kayma güvenli. Hedef başına eşzamanlılık sınırı koyarım; zamanlayıcı müşterilerini koruyan bir geri basınç noktası olur. Dalgadan önce worker'lar yaklaşan işleri sahiplenip bellekteki bir zaman çarkına alabilir ki 00:00'da veritabanına yığılma olmasın. Ve gerçekten saatinde çalışması gereken azınlık iş için jitter'ı sıfır tutarım; jitter iş bazında bir ayar.
Junior yükü sadece bir yerden diğerine taşıyor. Mid jitter ve rate limit'i biliyor. Senior mantıksal ve fiziksel zamanı ayırıyor, deterministik jitter'ı gerekçelendiriyor, zamanlayıcıyı aşağı akış için bir geri basınç noktası olarak tasarlıyor ve istisnaları iş bazında ayarlanabilir bırakıyor.
Takip sorusu: Jitter yüzünden 00:00'a zamanlanmış günlük rapor 00:04'te çalıştı ama 00:00-00:04 arasındaki verileri de içerdi. Bu bir hata mı, nasıl önlersin?
Mülakatçı için
- İş hacmini, zamanlama hassasiyetini, işlerin idempotent olup olmadığını ve misfire beklentisini netleştiriyor.
- Ortalama yükün küçük ama yuvarlak saatlerde yığılmanın büyük olduğunu hesaplıyor.
- Sahiplenmeyi atomik yapıyor (
SKIP LOCKEDveya eşdeğeri) ve check-then-act yarışını görüyor. - İşi uzun açık transaction içinde çalıştırmıyor; sahipliği lease olarak modelliyor.
- Worker çökmesini lease dolumu ile, zombi worker'ı fencing token ile ele alıyor.
- "Tam bir kez" yerine en az bir kez + idempotent hedef garantisini açıkça söylüyor.
- Cron tetiklemede çok pod problemini görüp lider seçimi veya kilit öneriyor.
- Zaman tabanlı liderliğin zayıflığını (iki liderin üst üste binmesi) söylüyor ve doğruluğu bir unique constraint gibi bir mekanizmaya bağlıyor.
- Thundering herd'e jitter ve hedef bazında eşzamanlılık sınırı ile çözüm veriyor.
- Retry, maksimum deneme, misfire politikası ve saat dilimini ele alıyor.
- Başlama gecikmesi ve "son başarılı çalışma" gibi gözlemlenebilirlik metriklerini öneriyor.
Takip soruları:
- Bir işin çalışması 3 saat sürüyor ve ortasında worker yeniden başlatılıyor. İşi baştan mı alırsın, kaldığı yerden mi?
- İş zamanlayıcıyı çok bölgeli yapmak istesek ne değişir?
- Kubernetes CronJob ile bu tasarım arasında ne zaman hangisini seçersin?
- Kullanıcıların kendi cron ifadelerini girdiği bir üründe kötü niyetli ya da hatalı ifadelere karşı ne önlem alırsın?
Seviye beklentisi: Mid seviyede SKIP LOCKED, lease, retry ve çok pod'da tekil tetikleme beklenir. Senior'dan fencing token, en az bir kez + idempotency'nin açıkça kurulması, liderliği doğruluktan ayırma, thundering herd yönetimi ve misfire/saat dilimi gibi işletim detayları beklenir.
Hızlı tekrar
S: Worker'lar aynı işi almadan nasıl paralel çalışır?
C: SELECT ... FOR UPDATE SKIP LOCKED ile kısa transaction'da sahiplenme: kilitli satırlar atlanır, durum RUNNING, lease_until yazılır, attempt artar.
S: İş neden kilidi tutan transaction içinde çalıştırılmaz?
C: Uzun açık transaction bağlantı havuzunu tüketir ve VACUUM'u engeller. Sahiplik satırdaki durum ve lease ile tutulur.
S: Lease ne işe yarar?
C: Sahiplik süre sınırlıdır; worker çökerse lease dolar ve iş başka worker'a geçer. Uzun işler heartbeat ile uzatır.
S: Fencing token zamanlayıcıda nasıl uygulanır?
C: Her sahiplenmede artan attempt; tamamlama ve heartbeat WHERE attempt = :attempt ile yapılır, lease'i dolmuş eski sahibin yazması etkisiz kalır.
S: Zamanlayıcının dürüst teslim garantisi nedir?
C: En az bir kez tetikleme. Etkinin bir kez olması, jobId + scheduledFor'u idempotency anahtarı olarak kullanan hedef serviste sağlanır.
S: İki cron liderinin aynı anda çalışması nasıl zararsız hale getirilir?
C: UNIQUE (cron_id, scheduled_for) ve ON CONFLICT DO NOTHING; liderlik verimlilik içindir, doğruluk constraint'tedir.
S: Gece yarısı dalgası nasıl yayılır?
C: Deterministik jitter (hash(cron_id) % jitter) ile run_at'i kaydırmak, scheduled_for'u sabit tutmak; hedef başına eşzamanlılık sınırı.
S: Misfire politikaları nelerdir?
C: Kaçırılan çalışmaların hepsini çalıştırmak, bir kez çalıştırmak ya da atlayıp bir sonraki zamanlamayı beklemek; iş tipine göre seçilir.
Kaynaklar
- Google, Site Reliability Engineering, "Distributed Periodic Scheduling with Cron": Atlamayı çift çalıştırmaya tercih etmek, Paxos tabanlı lider, başlangıç ve bitişin kaydedilmesi, idempotency ve thundering herd için hash tabanlı dağıtım.
- Kubernetes: CronJob:
concurrencyPolicy,startingDeadlineSeconds,timeZone, kaçırılan 100 zamanlama kuralı ve Job'ların idempotent olması gerekliliği. - PostgreSQL: SELECT, The Locking Clause:
SKIP LOCKEDve kuyruk benzeri tablolar. - PostgreSQL: Explicit Locking: Session ve transaction seviyesinde advisory lock'lar.
- Martin Kleppmann, Designing Data-Intensive Applications, Bölüm 8: Lease, süreç duraklamaları ve fencing token'lar.