Sertaç Yıldırımsaha notları

Ana sayfa → Teknik

Worker Sharding: İşi Bölmenin Doğru Yolu

Dört worker çalışıyor. Kuyrukta 200 bin iş var. Grafiklere bakıyorsun: biri %100 CPU’da, üçü neredeyse boşta. Worker eklemek bu tabloyu düzeltmiyor.

Özet
  • Kilit çakışmayı yönetir, sharding çakışmayı yok eder. Yapabiliyorsan ikincisini seç.
  • Modulo en kolayı ve en kırılganı. Worker sayısı değişince anahtarların çoğu yer değiştirir.
  • Dağılım verinin şekline bağlı. Tek bir müşteri toplam yükün %40’ıysa hiçbir hash seni kurtarmaz.
  • Yeniden dağıtım anı, çift işlemenin doğduğu andır. İşler idempotent olmak zorunda.

Önce: kuyruk yetmiyor mu?

Çoğu durumda yetiyor ve gereksiz yere sharding yazılıyor. Ayrım basit:

Kuyruk yeter
  • İşler birbirinden bağımsız
  • Sıra önemli değil
  • Worker durum tutmuyor

Örnek: rapor PDF’i üretmek, görsel küçültmek.

Sharding gerekiyor
  • Aynı varlığın işleri sırayla işlenmeli
  • Worker bellekte durum tutuyor (önbellek, oturum)
  • Aynı varlığa paralel dokunmak veri bozuyor

Örnek: bir cüzdanın hareketleri, bir siparişin durum geçişleri.

Kritik cümle şu: sharding’in asıl amacı hız değil, çakışmayı ortadan kaldırmak. Kilit yaklaşımında herkes aynı kapıya koşup sırayla bekliyor; sharding’de zaten farklı kapılardan giriyorlar.

Yöntem 1: Modulo (en kolay, en kırılgan)

Klasik
shard = hash(kullanici_id) % WORKER_SAYISI

# worker 2 sadece sunu isler:
SELECT * FROM isler
 WHERE durum = 'bekliyor'
   AND MOD(hashtext(kullanici_id::text), 4) = 2
 ORDER BY olusma
 LIMIT 100;

Çalışıyor, kilit yok, kod kısa. Sorun ölçek değiştirdiğinde başlıyor.

4’ten 5’e çıkınca ne oluyor

% 4 yerine % 5 dediğin anda anahtarların yaklaşık %80’i başka bir worker’a taşınır. Bunun iki sonucu var:

  • Worker’ların bellekteki önbellekleri komple çöpe gider; sistem geçici olarak yavaşlar.
  • Geçiş anında eski worker hâlâ eski dağılımla çalışıyorsa aynı iş iki worker tarafından alınabilir.

En sinsi kısmı: % N değerini her worker kendi ortam değişkeninden okuyor ve dağıtım kademeli oluyorsa, bir süre boyunca bazıları 4 bazıları 5 sanıyor.

Yöntem 2: Sabit sayıda mantıksal shard (pratikte en iyisi)

En sevdiğim çözüm ve şaşırtıcı derecede basit: shard sayısını worker sayısından ayır. Sabit ve büyük bir sayı seç — örneğin 64 — ve worker’lara shard numaralarını dağıt.

64 shard, 4 worker
SHARD_SAYISI = 64          # asla degismez
shard = hash(kullanici_id) % 64

# 4 worker varsa:
worker 0 -> shard 0..15
worker 1 -> shard 16..31
worker 2 -> shard 32..47
worker 3 -> shard 48..63

# 5. worker eklendiginde shard'lar yeniden bolusulur,
# ama bir anahtarin SHARD'i degismez. Sadece sahibi degisir.

Fark ince ama önemli: anahtar → shard eşlemesi sabit kalıyor, sadece shard → worker eşlemesi değişiyor. Bu sayede shard başına tutulan durum, önbellek ve ilerleme bilgisi taşınabilir hale geliyor. Kafka’nın partition mantığı da aynı fikir.

Shard sayısını seçerken tek kural: ulaşmayı hayal ettiğin worker sayısının birkaç katı olsun. 64 veya 128 çoğu sistem için fazlasıyla yeterli.

Yöntem 3: Claim tablosu (dinamik ve gözle görülür)

Worker’lar sabit shard almak yerine, işi kendileri “sahipleniyor”. Tek bir atomik güncellemeyle:

Atomik sahiplenme
UPDATE isler
   SET sahip = :worker_id,
       sahiplik_bitis = now() + interval '5 minutes',
       durum = 'isleniyor'
 WHERE id IN (
       SELECT id FROM isler
        WHERE durum = 'bekliyor'
           OR (durum = 'isleniyor' AND sahiplik_bitis < now())  -- olen worker'in isi
        ORDER BY olusma
        LIMIT 50
        FOR UPDATE SKIP LOCKED     -- ← anahtar satir
 )
RETURNING *;

SKIP LOCKED burada işin sırrı: başka bir worker’ın o an kilitlediği satırları beklemeden atlıyor. Böylece worker’lar birbirini hiç beklemeden farklı işleri alıyor.

İkinci güzelliği: sahiplik_bitis sayesinde ölen bir worker’ın işleri beş dakika sonra kendiliğinden serbest kalıyor. Ayrı bir sağlık kontrolü yazmana gerek kalmıyor.

Bunun bedeli, sıra garantisinin olmaması: aynı kullanıcının iki işi farklı worker’lara düşebilir. Sıra önemliyse bu yöntem uygun değil.

Sahadan iki olay

1. Tek müşteri, dört worker, hiç işe yaramayan hash

Bildirim gönderim sistemi musteri_id % 4 ile bölünmüştü. Kurumsal müşterilerden biri tek başına günlük bildirimlerin yaklaşık %40’ını üretiyordu.

Sonuç: o müşterinin düştüğü worker sürekli %100 CPU, diğer üçü boşta. Worker sayısını 8’e çıkardık — hiçbir şey değişmedi, çünkü o müşteri hâlâ tek bir worker’a düşüyordu.

Çözüm: anahtarı inceltmek
-- once
shard = hash(musteri_id) % 64

-- sonra
shard = hash(musteri_id + ":" + bildirim_id) % 64

Artık aynı müşterinin bildirimleri bütün shard’lara yayılıyor. Bunu yapabildik çünkü bildirimler arasında sıra garantisi gerekmiyordu. Gerekseydi bu çözüm mümkün olmazdı; o durumda o müşteriye özel, daha güçlü bir worker ayırmak gerekirdi.

2. Sıranın bozulduğu gün

Sipariş durum güncellemeleri kuyruğa yazılıyordu: hazırlanıyor → kargoda → teslim edildi. Yük artınca worker sayısı çoğaltıldı ve bir hafta sonra destek ekibi tuhaf bir şey bildirdi: bazı siparişler “teslim edildi”den “kargoda”ya geri dönüyordu.

Sebep basitti: aynı siparişin iki mesajı iki farklı worker’a düşmüştü ve “kargoda” mesajı, ondan önce üretilen “teslim edildi” mesajından sonra işlendi.

İki kademeli düzelttik:

  1. Sipariş kimliğine göre shard. Aynı siparişin bütün mesajları aynı worker’a gidiyor, dolayısıyla sırayla işleniyor.
  2. Durum geçişine koşul. Sharding’e rağmen bir gün yeniden dağıtım sırasında sıra bozulabilir; o yüzden veritabanı da kendini korusun: UPDATE ... WHERE durum_sirasi < :yeni_sira. Geç gelen eski mesaj yazamıyor.

İkinci madde, fencing token ile aynı fikir: doğruluğu zamanlamaya değil, sıraya emanet etmek.

Sharding sırayı çoğu zaman korur. “Çoğu zaman” yeterli değilse, korunan verinin kendisi de eski yazmayı reddetmeli.

Yeniden dağıtım: en tehlikeli an

Worker eklerken veya çıkarırken shard’lar el değiştiriyor. Tam o saniyede iki worker aynı shard’ı kendine ait sanabilir. Bu, sharding’in en kritik anı ve en az test edilen kısmı.

Üç savunma
  1. Devretmeden önce durdur. Eski sahip “shard 17 artık bende değil” dediğini onaylamadan yeni sahip başlamasın. Kubernetes’te bunun için kapanış sinyaline uyum ve makul bir bekleme süresi gerekiyor.
  2. İşleri idempotent yaz. Bu madde pazarlık konusu değil. Yeniden dağıtım sırasında bir işin iki kez işlenmesi olacaktır; sistem bundan zarar görmemeli.
  3. Dağıtımı tek yerden yönet. Her worker kendi payını kendi hesaplarsa, geçici olarak farklı sonuçlara varırlar. Dağılımı tek bir yerde tut (bir tablo, bir koordinatör, bir lease) ve worker’lar oradan okusun.

Ölçülmesi gereken üç şey

ÖlçüNeyi gösterirKötü sinyal
Shard başına bekleyen iş sayısı Dağılım dengeli mi Bir shard diğerlerinin 5 katıysa hot key var
Worker başına CPU Yük gerçekten bölünmüş mü Biri dolu, diğerleri boş
İşin kuyrukta bekleme süresi Gecikme nerede birikiyor Ortalama iyi ama %95’lik dilim kötüyse tek shard tıkalı

Üçüncü satır önemli: ortalamaya bakma. Dört worker’dan üçü boştaysa ortalama gecikme harika görünür; sıkışan shard’daki müşteri ise bildirimlerini yarım saat sonra alır.

Kontrol listesi

Sharding kurarken
  • Gerçekten sharding mi lazım, düz kuyruk yetmiyor mu?
  • Shard sayısı worker sayısından bağımsız mı?
  • Anahtar seçimi sıra garantisini koruyor mu? (Aynı varlık aynı shard’a mı?)
  • Verideki en büyük anahtar toplam yükün yüzde kaçı? (Ölçtün mü?)
  • Bir worker ölünce shard’ı ne kadar sonra devralınıyor?
  • İşler idempotent mi? (Yeniden dağıtımda çift işleme olacak.)
  • Dağılımı tek bir yer mi belirliyor, yoksa her worker kendi mi hesaplıyor?
  • Shard başına metrik var mı, yoksa sadece toplam mı görünüyor?

Sonuç

Sharding, kilitten daha zarif bir çözüm: çakışmayı yönetmek yerine ortadan kaldırıyor. Ama bedava değil — ölçek değiştirmeyi, ölü worker’ı ve dengesiz veriyi düşünmek zorundasın.

Baştaki tabloya dönersek: o üç boş worker’ın sebebi kapasite değil, anahtar seçimiydi. Sharding’de zor olan kodu yazmak değil, doğru anahtarı seçmek — ve doğru anahtar her zaman verinin gerçek dağılımına bakılarak seçiliyor, tahminle değil.