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.
- İ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:
- İşler birbirinden bağımsız
- Sıra önemli değil
- Worker durum tutmuyor
Örnek: rapor PDF’i üretmek, görsel küçültmek.
- 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:
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;
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 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.
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.
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.
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.
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:
| Saat | Worker A | Worker B | shard 17 |
|---|---|---|---|
| 00:00:00 | shard 17’yi aldı, lease 30 sn | sahip = A, v7 | |
| 00:00:05 | bir işte dış API’ye takıldı; heartbeat thread’i de aynı süreçte, o da durdu | sahip = A, v7 | |
| 00:00:30 | hâlâ takılı | lease doldu | |
| 00:00:41 | lease_bitis < now() → aldı | sahip = B, v8 | |
| 00:00:50 | uyandı, işi bitirdi, tamamlandi yazdı | aynı işi işliyor | A, 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 (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 worker | Yer 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
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:
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 Shard | Yöntem 2: Ring Hashing | |
|---|---|---|
| Katman | Anahtar → 64 shard → worker | Anahtar → doğrudan worker |
| Sahiplik nasıl belirlenir | Kayıttan okunur: coordinator (tablo, etcd, Redis lease) | Hesaplanır: ring’de bir sonraki worker |
| Worker eklenince | Birkaç 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 SELECT | Aynı 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şilemezse | Worker lease tazeleyemez, durmak zorunda (doğruluk > kullanılabilirlik) | Son bilinen üyelikle çalışmaya devam eder; bedeli, geçici çift sahiplik |
| Ne zaman | Shard sayısı yüzlerle sınırlı, sahiplik denetlenebilir olsun | Binlerce node, merkezi kayıt istemiyorsun |
| Nerede kullanılır | Kafka partition, Redis Cluster, DB sharding | Memcached, 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.
-- 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.
Ölçülmesi gereken dört şey
| Ölçü | Neyi gösterir | Kö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
- 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_versionartırıyor mu, her geri yazmafence <=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.