omeryanbas.com

Ömer Yanbaş

Genel Müdür, Ticofab Yazılım

PerformansMesajlaşma

Saatte 300’den 19 bine: gönderim kapasitesi aslında nerede kayboluyor

Saatte 300 mesajda takılan bir gönderici ne CPU'suz ne bant genişliğsizdir. Tek tek round trip bekler ve bu ölçülebilir bir sorundur.

İşlettiğimiz bir platformda gönderim işi saatte 300 mesaj civarında kalıyordu. Makinenin canı sıkkındı: tek çekirdeğin yüzde birkaçı, neredeyse sıfır ağ trafiği, her sorguyu tek haneli milisaniyede dönen bir veritabanı. Çekirdek eklemek bir şey değiştirmedi, daha büyük sunucu da değiştirmedi. Zaman harcanmıyordu, tek tek bekleniyordu.

Saat nereye gidiyor

Üst üste binmiş iki yapı vardı ve ikisinin de maliyeti aynı kalemdi.

İçteki yapı seri gönderim. Süreç mesajı gönderiyor, karşı taraf onaylayana kadar bloke oluyor, sonra sıradakine geçiyor. Bu biçimde kapasite, birin servis süresine bölümüdür. Mesaj başına ölçtüğümüz servis süresi 1,1 saniyeydi ve neredeyse tamamı onay beklemekti; yani süreç hiç ara vermeden çalışsa bile tavan saatte 3.200 civarı.

Dıştaki yapı ise tick. Dakikada bir çalışan zamanlanmış görev başlıyor, ayarları yüklüyor, bağlantı açıyor, listeyi çözüyor, sınırlı bir batch gönderiyor ve çıkıyor. Sınırın makul bir gerekçesi var: bir tick bir sonraki başlamadan bitmeli, o yüzden biri batch'i beşe ayarlamış. Dakikada beş mesaj saatte 300 eder ve bu sayının donanımla ilgisi yoktur. Batch boyutunun altmışla çarpımıdır.

Her tick kurulum bedelini yeniden ödüyordu: süreç başlatma, ayar yükleme, DNS, el sıkışma, ilk sorgu. Kurulum kabaca 300 milisaniyeydi ve beş mesaja bölünüyordu; yani mesaj başına bütçenin beşte biri, uzun ömürlü bir sürecin bir kez ödediği işe gidiyordu.

Bu yapıda ikinci bir sunucu eklemek de işe yaramaz. İki tick dakikada beş yerine on mesaj gönderir, yani saatte 600; hâlâ donanımla değil batch sınırıyla konuşuyorsunuzdur. Üstelik aynı listeyi iki süreç birden işlerse aynı mesajı iki kez gönderme riski çıkar ve ekip haklı olarak paralellikten uzak durur.

Bunların hiçbiri kaynak sorunu gibi görünmez. Load average'da görecek bir şey yok, sorgu log'unda yok, ağ grafiğinde yok. Tek görünür belirti, üç gün süren bir kampanyadır.

Nasıl görülür

Önce kapasiteyi ölçmeyin. Tek bir mesajın süresini aşama aşama ölçün ve ortalamaları yazdırın:

# send.log: her satır bir aşama, "id asama milisaniye"
awk '{ sum[$2] += $3; n[$2]++ }
     END { for (p in sum) printf "%-12s %6d cagri %8.1f ms ort\n", p, n[p], sum[p]/n[p] }' send.log
connect            60 cagri    212.4 ms ort
auth               60 cagri     88.1 ms ort
submit            300 cagri     14.9 ms ort
await_ack         300 cagri    861.3 ms ort
db_write          300 cagri      7.6 ms ort

Bütün planı bu tablo belirliyor. Göndermenin maliyeti yok, veritabanının maliyeti yok, bağlantı pahalı ama tick başına bir kez ödeniyor ve mesaj bütçesinin yüzde 86'sı başkasından cevap beklemekle geçiyor. Beklemek, paralelliğin gerçekten ortadan kaldırdığı tek maliyettir, çünkü beklemeler üst üste biner.

İkinci ölçüm, dakikada kaç mesajın gerçekten çıktığı. Bunu kayıtların tutulduğu tablodan alın:

SELECT DATE_FORMAT(sent_at, '%Y-%m-%d %H:%i') AS minute, COUNT(*) AS sent
FROM messages
WHERE sent_at >= UTC_TIMESTAMP() - INTERVAL 20 MINUTE
GROUP BY 1 ORDER BY 1 DESC;

Dakikada sabit beş bir performans eğrisi değil, ayarlanmış bir sınırdır. Düz çıkan sayılar genelde bir yerde bir cap olduğunu söyler ve o cap'i bulmak, bir şeyleri tune etmekten hızlıdır.

Bunların hepsinden önce kampanyanın gerçekten çalıştığını doğrulayın. Saat dilimi uyuşmazlığı yüzünden vadesi hiç gelmemiş bir iş, saatlik gönderim sayan bir panelde yavaş bir işten ayırt edilemez.

Çözüm

Dört değişiklik, yapacağım sırayla.

Kurulum bedelini mesaj başına ödemeyi bırakın. Dakikalık görevi, bağlantısını açık tutan ve yalnızca bağlantı düştüğünde yeniden kimlik doğrulayan uzun ömürlü bir worker ile değiştirin. Bu tek başına hem 300 milisaniyelik kurulumu hem de yapay batch sınırını kaldırır.

Veritabanı işini toplu yapın. Mesaj başına bir insert yerine çok satırlı tek insert, batch başına tek durum güncellemesi. Satır başına 7,6 milisaniyeyle bu henüz darboğaz değil, ama gönderim hızı yirmi kat artınca darboğaz olur.

Beklemeleri paralel çalıştırın ve havuzu ölçüme göre boyutlandırın. Gereken eşzamanlılık, hedef hız çarpı servis süresidir. Saniyede 5,5 mesaj ve mesaj başına 1,1 saniye, aynı anda altı mesaj demek; karşı tarafı sonsuz saymadan dalgalanmayı da karşılamak için sekizlik bir havuz yeterli:

const RATE_PER_SEC = 25;   // saglayici tavani 30, pay birakiyoruz
const POOL = 8;            // 5,5/s hedef * 1,1 s olcum = 6, ustune dalgalanma

const bucket = tokenBucket(RATE_PER_SEC);
const queue = boundedQueue(POOL * 4);   // back pressure: dolunca doldurma bloke olur

async function worker() {
  for (;;) {
    const item = await queue.take();    // bloke olur, bellekte sinirsiz liste yok
    if (!item) return;
    await bucket.take();                // token bosalana kadar bekler
    const res = await submit(item);
    results.push(res);                  // yazici gorev tarafindan toplu bosaltilir
  }
}

await Promise.all(Array.from({ length: POOL }, worker));

Sınırlı kuyruk, havuz kadar önemli. Sekiz worker'ı beslemek için yüz bin satırı diziye yükleyen bir okuyucu, sorunu belleğe taşımıştır. Kuyruğu bir cursor'dan doldurun, dolunca bloke olsun, üretici kendiliğinden yavaşlasın.

Değiştireceğiniz ayarı okuyan bir kod var mı, ona bakın. İlk denememde saatler buraya gitti. Platformda adı kampanya başına paralel gönderim vaat eden bir ayar anahtarı vardı. Config dosyasındaydı, dokümandaydı ve değiştirmek hiçbir şeyi değiştirmiyordu; bu da paralelliği çıkmaz sokak gibi gösteriyordu. Kod o anahtarı hiç okumuyordu:

grep -rn "parallel_processes_per_campaign" ./app ./lib ./config
# config/app.ini:118:parallel_processes_per_campaign = 5
# docs/settings.md:240:| parallel_processes_per_campaign | kampanya basina ...

İki sonuç, ikisi de doküman. Davranışı başka iki anahtar yönetiyordu: fork açık mı ve aynı anda kaç batch çalışabilir. Ayarlar kendilerini okuyan koddan uzun yaşar; ölü bir anahtar, olmayan anahtardan daha kötüdür, çünkü sormak üzere olduğunuz soruyu cevaplamış gibi yapar.

Çalıştığını nasıl doğrularsınız

Aynı sorgu, öncesi ve sonrası:

# once
2025-05-05 09:56     5
2025-05-05 09:57     5
2025-05-05 09:58     5

# sonra
2025-05-05 10:29   318
2025-05-05 10:30   326
2025-05-05 10:31   331

Dakikada 326 civarı, saatte 19.560 eder. Mesaj başına gecikmenin yük altında 1,1 saniyeden 1,4 saniyeye çıktığını da not edin. Bu normaldir ve havuzu paylı boyutlamanın sebebi tam olarak budur: 1,4 saniyeyle sekiz worker saniyede 5,7 eder, ölçülen 5,4 de onun hemen altında oturur. Saniyede 25'lik limiter burada bağlayıcı kısıt değil, karşı taraf hızlandığı gün devreye girecek korkuluktur.

Nelere dikkat etmeli

  • Veritabanı bağlantıları çarpılır. Sekiz gönderici, bir yazıcı ve bir zamanlayıcı, uygulamanın tamamının eskiden tuttuğundan fazla bağlantı tutabilir. Havuzu açıkça boyutlandırın ve sunucunun üst sınırına bakın; yoksa yavaş kampanyayı, kimsenin giriş yapamadığı bir siteyle takas edersiniz.
  • Dosya tanıtıcıları sessizce biter. Uçuştaki her bağlantı bir tanıtıcıdır ve süreç başına varsayılan sınır, eşzamanlılığı artırdıktan bir gün sonra vurulacak kadar düşüktür. Sınırı bilerek yükseltin ve çok fazla açık dosya hatalarını izleyin.
  • Sağlayıcı sizin makinenizden önce kırılır. Anlaşılan hızın üstünde throttle yanıtları gelmeye başlar ve bunları kalıcı hata sayan bir istemci, parasını ödediğiniz işi çöpe atar. Onları yavaşla sinyali sayın, kaydı kuyrukta tutun ve tekrar deneyin.
  • Spam klasöründe biten kapasite kapasite değildir. Mail gönderiyorsanız önce doğrulama tarafı oturmalı, çünkü hizalanmamış bir From alan adı hızlı göndericiyi itibar yakmanın verimli bir yoluna çevirir.
  • Ölçümü tek bir kampanyayla yapın. Aynı anda birkaç kampanya çalışıyorsa dakikalık sayılar toplanır ve hangi değişikliğin neyi iyileştirdiğini göremezsiniz.
  • Paralel worker'lar sırasız biter. Sıralı tamamlanmayı varsayan ne varsa, ilerleme sayaçları, müşteri başına adalet, sıralı numaralandırma, artık açıkça yazılmak zorundadır.

Ders gönderimin ötesine geçer. Bir süreç yavaşken makine boştaysa zaman beklemeye gidiyordur ve beklemek, eşzamanlılığın gerçekten kaldırdığı tek maliyettir. Bir birim işi aşama aşama ölçün, istediğiniz sayıya ulaşmak için kaç beklemenin üst üste binmesi gerektiğini hesaplayın, havuzu o sayı artı bir paya kurun. Sonra da yeni darboğazın ne olduğunu bulmaya gidin, çünkü her zaman bir tane vardır ve onunla salı öğleden sonra tanışmak, ilk büyük kampanyanın ortasında tanışmaktan iyidir.

Sorular ve cevaplar

CPU neredeyse boştayken gönderim neden yavaş?
Çünkü süreç hesaplamıyor, bekliyor. Her mesaj çıkıyor, sonra karşı taraf onaylayana kadar süreç bloke oluyor ve ancak ondan sonra sıradakine geçiyor. Bu modelde kapasite birin round trip süresine bölümüdür; yani bir saniyelik round trip, makine ne kadar hızlı olursa olsun sizi saatte 3.600 civarına sabitler.
Kaç paralel worker çalıştırmalıyım?
İstediğiniz hızı ölçtüğünüz mesaj başına gecikmeyle çarpın. Saniyede on mesaj ve mesaj başına 400 milisaniye, aynı anda dört mesaj demektir; altı ile sekiz arasında bir havuz normal dalgalanmayı da karşılar. Havuzu büyüttükten sonra tekrar ölçün, çünkü yük altında gecikme genelde artar ve aritmetik değişir.
Bir ayarın gerçekten bir işe yaradığını nasıl anlarım?
Anahtarın adını kaynak kodda aratın. Ayarlar, onları okuyan koddan daha uzun yaşar; yani bir anahtar dokümanda ve config dosyanızda dururken artık hiçbir yerde okunmuyor olabilir. Tek sonuç config dosyası ve doküman ise verdiğiniz değer süstür, istediğiniz davranışı başka bir yer yönetiyordur.
Eşzamanlılığı artırınca ilk ne kırılır?
Genelde önce veritabanı bağlantı havuzu, sonra açık dosya tanıtıcıları, sonra sağlayıcı. Uçuştaki her mesaj bir bağlantıyı ve bir soketi tutma eğilimindedir, bu yüzden otuz worker'lık bir havuz eskisinin birkaç katı bağlantı isteyebilir. Ardından sağlayıcı throttle yanıtı vermeye başlar; bu kaydedilecek bir hata değil, yavaşlama sinyalidir.