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
  • İki yöntem var: Sabit Shard (static partitioning) ya da Ring Hashing (consistent hashing). Modulo ikisi de değil; herkesin başladığı, kimsenin kalmaması gereken yer.
  • Sabit shard’ın bir coordinator’a ihtiyacı var ve lease dolunca iki sahip olur — fencing token olmadan çalışmaz.
  • Yeniden dağıtım anı, çift işlemenin doğduğu andır. İşler idempotent olmak zorunda.

Önce dur: sharding mi, message broker mı?

Message broker: bütün worker’lar aynı kuyruktan çeker, kim önce alırsa onun olur (RabbitMQ tek queue + N consumer, Redis BRPOP, SQS standart kuyruk). Kafka bu listede yok: partition key seçtiğin an zaten sharding yapıyorsun. Çoğu durumda broker yetiyor ve gereksiz yere sharding yazılıyor:

Broker 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 (cache, 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.

Başlangıç noktası: Modulo (herkes buradan başlar, kimse burada kalmamalı)

Anahtarın hash’ini worker sayısına böl, kalan (7 % 4 = 3) hangi worker’a gideceğini söyler:

Klasik
shard = hash(kullanici_id) % WORKER_SAYISI

# worker 2 sadece sunu isler (4 worker, ikinin kuvveti -> & 3):
SELECT * FROM isler
 WHERE durum = 'bekliyor'
   AND (hashtext(kullanici_id::text) & 3) = 2   -- her seferinde tam tarama; asagiya bak
 ORDER BY olusma
 LIMIT 100;
Neden MOD() değil de &: negatif hash

hashtext() negatif dönebilir ve MOD(-7, 4) = -3’tür: hash’i negatif çıkan kayıtlar (yaklaşık %37) −1, −2, −3 grubuna düşer, hiçbir worker onları seçmez, worker 0 ise çifte yük alır — hata mesajı yok, kuyruk sessizce uzar. ABS() de tam çözüm değil (ABS(-2147483648) taşar); shard sayısı ikinin kuvvetiyse hash & (N-1) ikisini de kökten kaldırır.

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: bellekteki cache’ler çöpe gider, geçiş anında aynı iş iki worker tarafından alınabilir. En sinsisi: % N’i her worker kendi ortam değişkeninden okuyorsa, dağıtım boyunca bazıları 4 bazıları 5 sanıyor.

Hash’i sorguda hesaplama, kolona yaz — iki sebep

Birincisi: hashtext() dokümante edilmemiş bir iç fonksiyon; PostgreSQL major sürüm yükseltmesinde değeri değişebilir ve o gün bütün anahtarların shard’ı kayar — aşağıda “anahtar → shard asla değişmez” diyeceğim, bu fonksiyonla o söz tutmaz. Shard’ı uygulama tarafında sabit bir hash’le (murmur3, xxhash) ya da md5’ten türetilmiş bir tamsayıyla hesapla. İkincisi: sorgudaki hash indeks kullanamaz; 200 bin satırlık kuyrukta her worker her seferinde tam tarama yapar.

-- shard yazma aninda hesaplanir, kolonda durur, indekslenir
ALTER TABLE isler ADD COLUMN shard smallint NOT NULL;      -- uygulama: murmur3(kullanici_id) & 63
CREATE INDEX isler_bekleyen ON isler (shard, olusma) WHERE durum = 'bekliyor';

SELECT * FROM isler WHERE durum = 'bekliyor' AND shard IN (16,17,...,31)   -- benim shard'larim
 ORDER BY olusma LIMIT 100 FOR UPDATE SKIP LOCKED;
-- not: SKIP LOCKED + LIMIT istedigin sayidan az satir dondurebilir (kilitliler limit'ten sonra atilir)

Modulo’nun kusuru anahtar → worker eşlemesinin worker sayısına bağlı olması. İki yöntem de bu bağı koparıyor; farklı yerlerden.

Yöntem 1: Sabit Shard (Static Partitioning)

İşleri worker sayısına değil, önceden belirlenmiş sabit bir shard sayısına bölersin: 64, 128 ya da 256. İki ayrı eşleme var ve ikisini karıştırmamak bütün mesele:

  • Anahtar → Shard: murmur3(kullanici_id) & 63. Bu eşleme sistemin ömrü boyunca sabittir, asla değişmez — o yüzden hash de sabit olmalı (yukarıdaki kutu).
  • Shard → Worker: 64 shard’ı o an ayakta olan worker’lara paylaştırırsın; 4 worker varsa her birine 16. Değişen yalnızca bu.
  • Dağıtım nasıl yapılır: ring’e gerek yok. Arkada bir coordinator durur — etcd, Consul, ZooKeeper ya da basit bir lease tablosu — ve worker geldiğinde boşta kalan shard’ları oradan zimmetler.
  • Nerede kullanılır: Kafka (partition mantığı bu — bir fark: partition sayısını artırınca anahtar → partition eşlemesi bozulur, azaltmak ise mümkün değil; yani Kafka’da partition sayısını ilk günden bol seçmek zorundasın), Redis Cluster (16384 hash slot), veritabanı shard’lama.
64 shard, 4 worker
SHARD_SAYISI = 64          # asla degismez
shard = hash(kullanici_id) & 63

# 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.

Anahtar → shard sabit kaldığı için shard başına tutulan durum, cache ve ilerleme taşınabilir hale geliyor. Shard sayısını seçerken tek kural: ulaşmayı hayal ettiğin worker sayısının birkaç katı olsun — çünkü paralelliğin tavanı shard sayısıdır: 64 shard varken 100 worker açarsan 36’sı boş oturur. Ve “asla değişmez”in bir kaçağı var: ikinin kuvveti seçtiysen 64 → 128 zararsız, bir anahtarın yeni shard’ı ya eskisi ya da eski+64 olur; veri en fazla bir kez ve öngörülebilir biçimde bölünür.

Shard’ı worker’a kim verir: coordinator

“Shard 17 şu an kimde?” sorusuna tek bir yer cevap vermeli; her worker kendi hesaplarsa geçiş anında iki worker aynı shard’ı kendine ait sanır. En basit coordinator, zaten elindeki veritabanı. Tasarımın zor kısmı sahiplenme değil, her worker’ın payını bilmesi: ilk açılan worker 64’ün hepsini kapmasın diye önce canlı worker sayısı, sonra pay.

Coordinator olarak veritabanı: lease’li sahiplik tablosu
CREATE TABLE shard_sahipligi (
  shard          int PRIMARY KEY,   -- 0..63
  sahip          text,
  lease_bitis    timestamptz,
  claim_version  bigint NOT NULL DEFAULT 0   -- fencing token, asagiya bak
);

-- 1) payimi hesapla: canli worker = son 30 sn'de heartbeat atanlar
SELECT ceil(64.0 / count(DISTINCT sahip)) AS pay
  FROM shard_sahipligi WHERE lease_bitis > clock_timestamp();   -- + kendim
-- clock_timestamp(): now() islem basindaki saati verir, uzun islemde lease'i yanlis okursun

-- 2) elimde pay'dan az varsa: sahipsiz ya da lease'i dolmus shard'lari kap
UPDATE shard_sahipligi
   SET sahip = :ben,
       lease_bitis = now() + interval '30 seconds',
       claim_version = claim_version + 1
 WHERE shard IN (
       SELECT shard FROM shard_sahipligi
        WHERE sahip IS NULL OR lease_bitis < now()
        LIMIT :pay - :elimdeki
        FOR UPDATE SKIP LOCKED           -- iki worker ayni satira takilmasin
 )
RETURNING shard, claim_version;          -- her shard'in KENDI surumu

-- 3) her 10 sn: elimdeki (shard, surum) ciftlerinin lease'ini tazele (heartbeat)
UPDATE shard_sahipligi SET lease_bitis = clock_timestamp() + interval '30 seconds'
 WHERE sahip = :ben
   AND (shard, claim_version) IN ((17, 7), (18, 7), (31, 9))    -- elimdekiler
RETURNING shard;
-- 16 gonderdim, 12 dondu -> 4 shard'i KAYBETTIM: o shard'larin islerini HEMEN birak (self-fencing)
-- DB'ye hic ulasamiyorsam da ayni: lease'i tazeleyemeyen worker calismayi durdurur

-- 4) elimde pay'dan cok varsa fazlasini birak (yeni worker gelmis demek)

Worker eklenince pay 16’dan 13’e düşer, eskiler fazlasını bırakır, yeni gelen boşa çıkanları kapar; worker ölünce lease 30 saniyede dolar, shard’ları başkası alır. Ayrı bir health check yok ve “shard 17 kimde” bir SELECT.

Fencing’in asıl çalışma anı 3. adım: heartbeat 16 yerine 12 satır güncellediyse worker 4 shard’ı kaybetmiştir ve o shard’ların işlerini iş bitmeden bırakır. Geri yazmadaki token kontrolü (aşağıda) son savunma; ilki burada. Bunun gizli bedeli de burada: coordinator’a ulaşamayan worker lease tazeleyemez, dolayısıyla durmak zorundadır — doğruluk uğruna kullanılabilirlikten vazgeçiyorsun.

Eksik parça: lease dolunca iki sahip — fencing token

Lease, ölen worker’ı kurtarıyor ama ölmeyen, sadece yavaşlayan worker’ı değil. Kilit yazısındaki tuzak burada da var ve biz de yaşadık:

SaatWorker AWorker Bshard 17
00:00:00shard 17’yi aldı, lease 30 snsahip = A, v7
00:00:05bir işte dış API’ye takıldı; heartbeat thread’i de aynı süreçte, o da durdusahip = A, v7
00:00:30hâlâ takılılease doldu
00:00:41lease_bitis < now() → aldısahip = B, v8
00:00:50uyandı, işi bitirdi, tamamlandi yazdıaynı işi işliyorA, B’nin üstüne yazdı

A’nın geri yazması WHERE id = :id ile gidiyor; shard’ı kaybettiğini hiç kontrol etmiyor. Sonuç: aynı iş iki kez, tablo ise “tamamlandı” diyor. Çözüm, her sahiplenmede artan sayı (claim_version) ve token’ın korunan satırın kendisinde durması — tek satırlık, yarışsız bir karşılaştırma:

-- isler tablosunda: fence bigint NOT NULL DEFAULT 0
UPDATE isler
   SET durum = 'tamamlandi', sonuc = :sonuc, fence = :bendeki_surum
 WHERE id = :id
   AND fence <= :bendeki_surum;   -- A v7 ile gelir, B v8'i coktan yazmistir -> 0 satir, GERI AL
  • Heartbeat yardımcı olur, garanti vermez — yukarıda tazeleyen thread de durdu.
  • Dış sisteme çıkan yan etkiyi token kurtarmaz: A e-postayı takılmadan önce gönderdiyse veritabanı onu geri alamaz. Orası idempotency anahtarının işi.
  • Token monoton artmalı ve sahiplenmeyle aynı atomik UPDATE’te üretilmeli; worker’ın kendi ürettiği sayı işe yaramaz.

Sıra garantisi gerekmiyorsa shard katmanına hiç girme: tek tabloda FOR UPDATE SKIP LOCKED ile her worker işi tek tek kapar, yeniden dağıtım diye bir şey olmaz, autoscale bedavaya gelir. Deneme sayısı, backoff ve dead-letter da oranın konusu; o ayrı bir yazı.

Yöntem 2: Ring Hashing (Consistent Hashing)

Arada “sabit 64 shard” gibi bir katman yoktur. Anahtarı da worker’ı da aynı sanal ring’in (0’dan 2^32’ye bir daire) üstüne dizersin:

  • Anahtar → Worker: hash(kullanici_id) ring’de bir noktaya düşer; saat yönünde ilerleyip karşılaştığı ilk worker işi alır. Kayıttan okunmaz, hesaplanır.
  • Dinamik mimaridir: worker sayısı 4’ten 5’e çıktığında ring’e yeni bir nokta eklenir; yalnızca o noktanın komşusundaki anahtarlar (≈ 1/yeni worker sayısı) yeni worker’a kayar. Arada mantıksal shard katmanı da coordinator yönetimi de yoktur.
  • Nerede kullanılır: Memcached, DynamoDB, Envoy/Nginx load balancer, streaming ve WebSocket node dağıtımları.
Ring
Ring (0 .. 2^32):

   worker-A ●          ● worker-B
       ↘  anahtar 7,12,31  ↙
            ●
        worker-C

# Yeni worker-D eklendiginde:
# Sadece D'nin ring'deki KOMSUSUNUN anahtarlari tasiniyor.
# Digerleri hic dokunulmadan kaliyor.

sahip(anahtar) = ring'de anahtarin hash'inden sonraki ilk worker

# Tek noktali basitlestirme. Gercekte her worker 100-200 sanal noktada durur;
# D eklenince ring'in 150 yerine girer ve HER worker'dan orantili anahtar alir.
# Toplam yine ~1/N, ama "tek komsu etkilenir" yalnizca bu cizimde dogru.
4 → 5 workerYer değiştiren anahtar
Modulo (% N)~%80
Aralık bölme~%40–60
Ring (consistent hashing)%20 (1 / yeni worker sayısı)

Pratik detay: her worker’ı ring’e tek noktada değil, 100–200 sanal noktada yerleştir; tek nokta koyarsan dağılım şansa kalır ve bir worker’a diğerinin üç katı düşebilir. Kütüphanelerin çoğunda virtual node zaten varsayılan.

Neden önemli: reconnect storm

Sahadan

10 bin oturum, 4 streaming-engine’e dağılmış durumda. Yük arttı, beşinci instance eklendi. Modulo kullanıyorduk.

Sonuç: 8 bin oturumun sahibi değişti. Hepsi aynı anda koptu, hepsi aynı anda yeniden bağlandı, hepsi aynı anda abonelik durumunu yeniden kurdu. Kapasite eklemek için yapılan işlem, sistemi 40 saniyeliğine kapasitesiz bıraktı.

Ring’e geçince aynı işlem 2 bin oturumu etkiledi — ve onları da kademeli devrettik. Bu bir çığ etkisi (thundering herd) problemi; çözümü de oradaki gibi kademe ve rastgelelik.

Rebalancing: kademeli devret

Ring taşınacak anahtar sayısını azaltıyor ama sıfırlamıyor. Taşınanları hepsini bir anda değil, parça parça devret — sabit shard için de aynen:

Kademeli devir (graceful handover)
for (parca : tasinacakParcalar) {          // shard ya da anahtar araligi
    eskiSahip.durdur(parca);        // yeni is almayi birak, elindekini bitir
    eskiSahip.onaylaBitti(parca);   // ← beklenecek
    yeniSahip.basla(parca);
    Thread.sleep(200);              // ← sonraki parcaya gecmeden once nefes
}

200 ms’lik bekleme basit görünür ama 16 parçalık bir devri yaklaşık 3 saniyeye yayar; aşağıdaki sistemler tek bir dalga yerine düzgün bir yük görür. Ve devir sırasında çift işleme olacak — eski sahip son mesajı işlerken yeni sahip başlamış olabilir. İdempotency bu yüzden pazarlık konusu değil (detay).

Hangisi: Sabit Shard mı, Ring Hashing mi?

 Yöntem 1: Sabit ShardYöntem 2: Ring Hashing
KatmanAnahtar → 64 shard → workerAnahtar → doğrudan worker
Sahiplik nasıl belirlenirKayıttan okunur: coordinator (tablo, etcd, Redis lease)Hesaplanır: ring’de bir sonraki worker
Worker ekleninceBirkaç shard el değiştirir; kimin neyi aldığı kayıtlı1/N anahtar kayar; kayıt yok, hesap var
Görünürlük“shard 31 kimde” bir SELECTAynı fonksiyonu çalıştırman gerekir
Ek altyapıBir coordinator (çoğu zaman mevcut DB yeter)Merkezi sahiplik kaydı yok; ama tutarlı bir üyelik listesi şart (service discovery, gossip, registry). İki worker üyeliği farklı görürse ring’i farklı hesaplar — çift sahiplik problemi yok olmuyor, yer değiştiriyor
Coordinator / üyelik erişilemezseWorker lease tazeleyemez, durmak zorunda (doğruluk > kullanılabilirlik)Son bilinen üyelikle çalışmaya devam eder; bedeli, geçici çift sahiplik
Ne zamanShard sayısı yüzlerle sınırlı, sahiplik denetlenebilir olsunBinlerce node, merkezi kayıt istemiyorsun
Nerede kullanılırKafka partition, Redis Cluster, DB shardingMemcached, DynamoDB, Envoy/Nginx LB, WebSocket node dağıtımı

İkisini karıştırma — biz bir süre karıştırdık: sabit shard’ların üstüne bir de ring kurmuştuk. Shard katmanın varsa ring’e gerek yok; 64 shard’ı bir tabloyla dağıtmak hem daha basit hem de görünür. Ring, shard katmanı olmayan sistemlerin cevabı. Biz üretimde sabit shard’la kaldık.

Sahadan: 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) & 63

-- sonra: butun shard'lara yay
shard = hash(musteri_id || ':' || bildirim_id) & 63

-- ara yol (salting): 8 shard'a yay, kismi locality'yi koru
shard = hash(musteri_id || ':' || (bildirim_id % 8)) & 63

Artık aynı müşterinin bildirimleri bütün shard’lara yayılıyor. Salting ara yolu ise yükü bölüp cache ve batch’lemeyi kısmen koruyor; sıra gerekiyorsa da alt-anahtar bazında korunabiliyor. Bunu yapabildik çünkü bildirimler arasında sıra garantisi gerekmiyordu. Gerekseydi bu çözüm mümkün olmazdı; o müşteriye özel, daha güçlü bir worker ayırmak gerekirdi.

Sıranın önemli olduğu bir sistemde tersi başımıza geldi: sipariş durumları (hazırlanıyor → kargoda → teslim edildi) iki worker’a bölününce bazı siparişler “teslim edildi”den “kargoda”ya geri döndü. Sipariş kimliğine göre shard’ladık ve veritabanına da kendini koruttuk: UPDATE ... WHERE durum_sirasi < :yeni_sira — geç gelen eski mesaj yazamıyor. 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.

Ölçülmesi gereken dört ş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 p95 kötüyse tek shard tıkalı
Lease dolma / sahiplik el değiştirme sıklığı Heartbeat aralığı ve lease süresi doğru mu Sürekli el değiştiren shard = çift sahibin en erken sinyali

Üçü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, message broker yetmiyor mu?
  • Sabit shard mı, ring mi? İkisi birden değil.
  • Shard sayısı worker sayısından bağımsız mı?
  • Sahipliği tek bir yer mi belirliyor (coordinator), yoksa her worker kendi mi hesaplıyor?
  • Her sahiplenme claim_version artırıyor mu, her geri yazma fence <= ile kontrol ediyor mu?
  • Heartbeat beklenenden az satır dönerse worker o shard’ları anında bırakıyor mu? Coordinator’a ulaşamazsa duruyor mu?
  • Shard payı canlı worker sayısından hesaplanıyor mu, fazla tutan worker bırakıyor mu?
  • Shard hash’i sabit mi (uygulama tarafında, sürümden bağımsız) ve kolonda indeksli mi?
  • Verideki en büyük anahtar toplam yükün yüzde kaçı? (Ölçtün mü?)

Sonuç

İki yöntem var ve seçim basit: shard katmanı kurabiliyorsan sabit shard + bir tablo; kuramıyorsan ring. Baştaki tabloya dönersek: o üç boş worker’ın sebebi kapasite değil, anahtar seçimiydi — ve doğru anahtar her zaman verinin gerçek dağılımına bakılarak seçiliyor, tahminle değil.