C.E.O. Bee
Gerçek Zamanlı Sistemler

Geriye Giden Fiyat Bir Yükümlülüktür: On Dokuz Günde 80 ms Altı Piyasa Verisi Dağıtımı

C.E.O. Bee Mühendislik10 dk okuma

Butik bir türev masası, binlerce trader terminaline 80 ms'nin altında canlı fiyat istiyordu — ve yük altında bile o fiyatların asla geriye sarmamasını. Dört haftalık bir pencerede ikisini birden veren sıralama disiplinini, backpressure tasarımını ve kuyruk gecikmesi ölçümünü baştan sona anlatıyoruz.

Bir trader ekrandaki sayıya bakar ve o sayıya karşı sermaye taahhüt eder. Bu projenin göründüğünden daha zor olmasının tek sebebi bu cümle.

Müşteri işi ilk anlattığında bunu bir kapasite problemi olarak tarif etti. Butik bir emtia ve kripto türev masası; fon ekip büyütüyordu, mevcut terminal yeni koltuk sayısını kaldıramıyordu ve o koltukların açılmasına yaklaşık dört hafta vardı. Brief olarak gayet makul. Performans gereksiniminin altında duran asıl gereksinimi görmemiz ise birkaç görüşme aldı ve o çok daha affetmezdi: fiyatlar hızlı olacaktı, üstüne bir fiyatın ekranda geriye gitmesi yapısal olarak imkânsız olacaktı. Hem hızlı hem monoton. Bu iki hedef birbirini çeker; sonraki on dokuz iş gününün mühendislik olarak ilginç kısmının neredeyse tamamı bu ikisinin arasındaki boşlukta geçti.

Problemin şekli

Önce sayılar, çünkü sonrasındaki her şeyin şeklini onlar belirledi. Altı borsa bağlantısı, yaklaşık 2.400 takip edilen enstrüman, normalize edilmiş ingest zirvede saniyede ~120.000 tick ve planlı makro veri açıklamalarında 190.000'i aşan sıçramalar. Dağıtım hedefi 3.500 eşzamanlı terminaldi; 6.000 için kapasite ayırdık, bugüne kadarki zirve 4.200 oldu.

İlk ciddi tasarım kararı konflasyondu. Her tick'i itmek yerine dağıtım katmanı sembol bazlı durumu 40 ms'lik bir frame'de topluyor: bağlantı başına saniyede 25 frame, yani zirvede katman genelinde saniyede ~105.000 WebSocket frame'i. Ortalamaya eklenen 20 ms, giden frame sayısında bir mertebe azalma satın aldı; zaten 25 Hz, bir insanın bir değişime tepki verebileceği hızın rahatça üzerinde. Yani kimsenin fark edemeyeceği bir şeyi verip ölçebildiğimiz bir şeyi aldık. İstisna, trader'ın o an emir ekranında açık olan enstrümanları: onlar konflasyonu tamamen atlayan bir öncelikli kanaldan akıyor.

tick-to-glass bütçesi (p99 hedefi: 80 ms)
────────────────────────────────────────────────────
borsa zaman damgası → feed handler decode      4 ms
normalizasyon + sembol bazlı sıralama          2 ms
konflasyon frame sınırı (0-40 ms)             20 ms   (ortalama)
encode + dağıtım kuyruğuna alma                3 ms
çekirdek → WAN (medyan trader, RTT/2)         14 ms
istemci decode + store commit + çizim          9 ms
────────────────────────────────────────────────────
bütçelenen p99                                52 ms
ölçülen p99 @ 4.200 bağlantı                  71 ms

Terminaller L7 değil, L4 yük dengeleyici üzerinden bağlanıyor. L7 proxy WebSocket trafiğini yeniden çerçeveleyip tamponluyor; bu hem ölçülebilir gecikme ekliyordu hem de, daha can sıkıcısı, baytların makineden gerçekte ne zaman çıktığı üzerine akıl yürütme imkânımızı yok ediyordu.

Tasarımı gereği monoton

Üretim ölçeğinde karşımıza çıkan ilk arıza bir çökme değildi. Titremeydi. Fiyat 1.842,75'e gidiyor, bir an tekrar 1.842,25 görünüyor, sonra düzeliyordu. Kozmetik olarak önemsiz, ticari olarak hiç değil; çünkü tape'e güvenmeyi bırakan bir trader terminali kullanmayı da bırakır.

Neredeyse bir gün boyunca dedup mantığını bozduğumuzdan emindim. Bozmamışız. Sebep kendi yedekliliğimizdi ve tam da tasarlandığı gibi çalışıyordu. Her borsayı, failover'ın görünmez olması için active/active çalışan iki feed handler süreci tüketiyor. Birebir kopyalar handler içinde borsanın update id'siyle eleniyor, ama yük altında iki süreç birbirinden sapıyor; ölçtüğümüz sapma 6 ila 9 ms. Son yazan kazanır mantığı da iki sürümden eskisini, sırf daha sonra geldiği için memnuniyetle uygular. Duvar saatiyle hakemlik yapmak sizi kurtarmıyor: NTP ile disipline edilmiş sunucular arasında bile 1-3 ms fark vardı ve farklı borsaların zaman damgaları zaten kıyaslanabilir değil.

Dolayısıyla sıralamanın zamansal değil, anahtar bazlı ve nedensel olması gerekiyordu. Semboller hash'lenerek sequencer şeritlerine dağıtılıyor; şerit başına tek yazar olması, global koordinasyon olmadan sembol başına tam sıra veriyor. Her tick bir (epoch, seq) sürümü taşıyor; borsanın kendi update id'si varsa ona çıpalanıyor, yoksa üretiliyor. epoch kalıcı olarak saklanıyor ve feed handler yeniden başladığında artıyor; böylece yeni başlamış bir süreç asla eski veri gibi görünen bir sayaç yayımlayamıyor.

// Şerit başına tek yazar ⇒ sembol başına tam sıra, global kilit yok.
// Karşılaştırma (epoch, seq) üzerinde sözlüksel; yeniden başlayan bir
// feed handler epoch'u artırdığı için sıfırlanan sayacı taze görünemez.
function admit(book: SymbolState, tick: Tick): boolean {
  const cur = book.version;                          // { epoch, seq }
  if (tick.epoch < cur.epoch) return false;
  if (tick.epoch === cur.epoch && tick.seq <= cur.seq) {
    metrics.sequenceRegression.inc({ venue: tick.venue });
    return false;                                    // asla uygulanmaz
  }
  book.version = { epoch: tick.epoch, seq: tick.seq };
  book.apply(tick);
  return true;
}

Aynı kontrol terminal SDK'sında da çalışıyor, çünkü bir istemci yeniden bağlanırken başka bir dağıtım düğümüne düşebilir ve eski düğümden yoldaki delta'larla aynı anda bir snapshot alabilir. Snapshot'lar sürüm taşıyor; sürümün altındaki veya eşitindeki delta'lar geldiği anda atılıyor. Yayına aldığımız en değerli tek metrik sequenceRegression sayacı oldu: zirvede dakikada yaklaşık 340 çalışıyor. Bu sayının üstünde bir saniye durmakta fayda var. Demek ki daha önce insanlara dakikada 340 titreme gösteriyormuşuz.

Zaman damgası bir şeyin ne zaman gözlendiğini söyler. Sıra numarası neyin gerçekten neden sonra geldiğini söyler. Trader'ın önüne koyabileceğiniz tek şey ikincisi, saat değil.

Açılış Poisson değildir

Staging yemyeşildi; asıl sorun da yeşil kalmasıydı. Üretimdeki ortalama geliş hızının iki katıyla sentetik yük bindirmiş, her şeyin ayakta kaldığını görmüştük. Sonra seans açılışı geliyor, katman sallanıyor, bağlanma süreleri şişiyor ve terminallerin bir kısmı on beş saniye boyunca spinner'da kalıyordu.

İşin aslı şu: trading masası trafiği korelasyonludur. Açılışta ve planlı veri açıklamalarında binlerce terminal aynı bir-iki saniye içinde yeniden bağlanıp yeniden abone olur. Ortalaması aynı olan bir Poisson süreci bunu asla üretmez; yük testimizin bize sürekli "her şey yolunda" demesinin sebebi de tam olarak buydu.

Bu yüzden yük üretmeyi bırakıp yükü replay etmeye başladık. Altı haftalık anonimleştirilmiş bağlantı zaman damgası logları, saniye bazlı bağlan/kopar geliş izine ve gerçek abonelik kümesi kardinalite dağılımına indirgendi; ardından on iki sunucuya yayılmış bir koşum takımıyla staging'e sürüldü. Gerçek iz, kendi ortalamasının 1 katında, ikinci koşuda staging'i yaklaşık 900 milisaniyede yıktı. Açıkçası bu bizim için bir rahatlamaydı: bir saniyenin altında tekrar üretebildiğiniz bir hata, trader'ların günde bir kez şikâyet ettiği bir hatadan her zaman iyidir.

Üç ayrı sebep çıktı.

Birincisi token doğrulama. jwt.verify(token, pemString) her çağrıda PEM'i yeniden ayrıştırıp key nesnesi kuruyor: çağrı başına yaklaşık 0,9 ms CPU, senkron, olay döngüsünün üstünde. Bir patlamada dört bin tanesi ~3,6 saniye bloke olay döngüsü demek ve o süre boyunca hiçbir yeni bağlantı kabul edilmiyor. crypto.createPublicKey() çağrısını modül başlangıcına taşımak bunu ~0,05 ms'ye indirdi. Doğrulamayı libuv thread havuzuna almak ve doğrulanmış claim'leri token parmak iziyle (exp ile sınırlı TTL) önbelleklemek geriye kalanı neredeyse sıfırladı; çünkü yeniden bağlanma fırtınası aynı 15 dakikalık token'ları defalarca sunuyor, isabet oranı ~%97.

İkincisi snapshot hidrasyonu. Her yeniden abonelik, snapshot'ı bağlantı başına ve sembol başına Redis'ten okuyup kodluyordu: patlama penceresinde yaklaşık 150.000 encode. Bunu sembol başına tek bir ön-kodlanmış ikili snapshot ile değiştirdik, sürümle geçersizleştiriliyor; hidrasyon refcount'lu bir buffer referansına dönüştü. Hidrasyon p99'u 340 ms'den 4 ms'ye indi.

Üçüncüsü ve beni asıl huzursuz edeni metastabilite. Accept backlog taşıyor, istemciler 10 saniyelik timeout'a düşüyor ve zaman aşımına uğrayan her istemci yeniden deniyordu; yani ikinci dalga birincisinden büyük oluyordu. Böyle bir sistem eşiği bir kez aştıktan sonra kendi kendine toparlanmaz, birinin gidip bir şeyleri kapatması gerekir. Upgrade yoluna token bucket koyduk (sürekli 1.200 upgrade/s, burst 2.500); limiti aşan istek kuyruğa alınmak yerine Retry-After ile 503 alıyor. İstemci tarafında full-jitter üstel backoff, close frame'inde ise sunucunun verdiği reconnect_after_ms var; böylece rolling deploy kendi sürüsünü bilerek yayabiliyor. 250 ms'de bağlantıların %2'si oranıyla 4.200 terminal bir dakikanın altında, izdiham olmadan boşalıyor.

Yük testinizin geliş deseni üretimden daha düzgünse ölçtüğünüz şey throughput'tur; adına yük demeniz bunu değiştirmez. Kimsenin kullanıcıları teker teker gelmiyor.

Göremediğiniz p99.9

Trader'lar sürekli "tape bir an donuyor" diyordu. APM'imiz ise hiçbir şey olmadığında ısrarcıydı: p99 düz, hata oranı sıfır. Ortalama almak tam olarak bunu yapar. Bir dakikalık toplulaştırma, ortalamaların ortalaması ve %5 span örneklemesi, sekiz saniyede bir yaşanan 55 ms'lik bir takılmayı rahatça saklar; çünkü o takılma 60 saniyelik ortalamayı bir milisaniyenin çok altında oynatır.

Biz de en baştan elimizde olması gereken aleti kurduk. HDR histogramları: üç anlamlı basamak, 1 µs-60 s aralığı, her biri ~48 KB sabit bellek, başlatmadan sonra sıfır tahsis; istek başına değil tick başına, beş boru hattı aşamasında kaydediliyor, on saniyede bir döndürülüp serileştiriliyor. Asıl işe yarayan özellik, HDR histogramlarının toplanabilir ve kayıpsız birleşmesiydi: bir gün sonra, merak ettiğimiz herhangi bir kırk saniyelik pencerenin p99.9'unu birebir yeniden kurabiliyorduk.

Saatler konusunda da dürüst davrandık, ki bunu atlamak yapmaktan kolaydır. Sunucular arası tek yön gecikmesi ancak zaman disiplininiz kadar güvenilirdir. Donanım zaman damgalı chrony ingest LAN'ında sapmayı ~100 µs altında tuttu; terminal tarafında ise %0,5'lik örnek kohortta RTT/2'yi açıkça bir üst sınır olarak raporladık, laptop'larda senkron saatimiz varmış gibi yapmadık.

Sonra perf_hooks GC kayıtlarını gecikme zaman çizelgesinin üstüne bindirdik ve cevap orada bizi bekliyordu. 120 ms'yi aşan her sıçrama bir mark-compact penceresinin içindeydi: zirvede yaklaşık her sekiz saniyede bir tekrarlayan 40-70 ms'lik duraklamalar. Günlerdir sessizce ağdan şüpheleniyorduk; suçlu baştan beri kendi heap'imizmiş.

Darboğaz bellek tahsisiydi

v1 dağıtım döngüsü bariz olanı yapıyordu: değişen her sembol için, her abone için bir mesaj nesnesi kurup serialize et. Zirvede bu, saniyede yaklaşık 1,5 milyon JSON encode ve ~1,6 GB/s kısa ömürlü çöp demek.

Beni asıl şaşırtan kısım şu oldu. Yalnız başına yüksek tahsis hızı yaşanabilir bir şeydir; her şey genç ölüyorsa scavenge ucuzdur. Bizim derdimiz, çöpün bir türlü genç ölmemesiydi. Backpressure altındaki soketlere verilen buffer'lar scavenge boyunca erişilebilir kalıyor, old space'e terfi ediyor ve old space saniyede yüzlerce megabaytla doluyordu. Katmandaki en yavaş bağlantılar, diğer tüm bağlantılar için GC duraklaması imal ediyordu — ortak bir servisi işletmenin epeyce kasvetli bir yolu.

Yeniden yazım dört parçaydı.

Her bağlantının abonelik kümesini abone olma anında bir kez hash'liyoruz; böylece bir frame içinde aynı imzayı paylaşan tüm bağlantılar tek bir kodlanmış buffer'ı paylaşıyor. Masanın gerçek watchlist dağılımında 4.200 bağlantı, 40 ms'lik frame başına yaklaşık 260 farklı payload'a iniyor: 1,5 milyon yerine saniyede ~6.500 encode.

Piyasa verisi kanalında permessage-deflate'i de kapattık, çünkü bağlantı başına kayan pencereli sıkıştırma bağlamı paylaşılan buffer'ı tanımı gereği imkânsız kılıyor. Yerine statik sözlüklü ikili bir codec koyduk (sembol id'leri uint16'ya intern edildi, fiyatlar ölçeklenmiş int64 delta): 1,1 KB JSON ~310 bayta indi, çıkış trafiği ~115 MB/s'den ~33 MB/s'ye düştü.

Üçüncüsü, yazmaları sıralama döngüsünden çıkardık. Node canlı bir TCP soketini worker_thread'e devredemez, çünkü handle'lar bu sınırı geçemez; dolayısıyla dağıtım katmanı thread değil süreç tabanlı oldu: sunucu başına dört yazıcı süreç, SO_REUSEPORT ile çekirdek accept'leri dağıtıyor ve yolda hiçbir master süreç durmuyor. Sunucu başına tek bir sequencer süreci her frame'i tam olarak bir kez kodluyor ve /dev/shm içindeki mmap'lenmiş bir ring buffer'a yayımlıyor (küçük bir N-API katmanı, çünkü SharedArrayBuffer süreç sınırını geçmez). Worker thread'ler hâlâ işe yarıyor, ama yalnızca hiçbir handle'a dokunmayan CPU işleri için.

Dördüncüsü, sınırlı kuyruklar. Her bağlantının 64 frame'lik, "en eskiyi at" politikalı bir çıkış ring'i var. Bu ancak taşınan şey bir olay günlüğü değil, durum yerine geçen veri olduğu için güvenli; o da yalnızca istemci bir şey kaçırdığını anlayabiliyorsa. Bu yüzden bir düşürme boşluk bayrağı set ediyor ve o bağlantıya giden bir sonraki frame delta değil, güncel sürümdeki tam snapshot oluyor. Yavaş bir terminal daha düşük tazeleme hızına düşer; asla bayat bir taban üzerine delta uygulamaz.

Sınırsız kuyruk backpressure değil, belleği tüketmenin daha yavaş bir yolu. İstemci neyi kaçırdığını biliyorsa, sınırlı ve kayıplı olanı ölene kadar dürüst olana her gün tercih ederim.

Tahsis ~1,6 GB/s'den 80 MB/s altına, mark-compact sekiz saniyede birden yaklaşık dört dakikada bire, p99 ise 210 ms'den 71 ms'ye indi.

Doksan altı saatlik kötü hava

Trader bağlantıları gün boyu çalkalanır. Laptop uyur, VPN düşer, gün sonunda oturum zorla kapatılır. Dört saatlik bir QA turu bu davranış sınıfı hakkında hiçbir şey kanıtlamaz; bu yüzden yayına almadan önce gerçek churn desenlerini üretimin 1,3 katı hızda tekrar oynatan 96 saatlik bir soak testi koşturduk.

Üçüncü saatte bir değişmez (invariant) tetiklendi ve ilk düşüncem "düzelttiğimizden daha kötü bir şey kırdık" oldu. Ucuz, 30 saniyelik bir tutarlılık kontrolü koymuştuk: sembol indeksindeki abone kümelerinin kardinalite toplamı, bağlantı başına abonelik sayılarının toplamına eşit olmalı. Değer sıfırdan çıktı ve tek yönlü tırmanmaya başladı.

İki farklı kapanış yolu vardı; dönüp bakınca biri fazlaymış. Düzgün kapanış close handler'ından unsubscribeAll(conn)'a gidiyor ve sembol başına abone kümelerini geziyordu. Ani kapanış, yani karşı taraftan RST, bu popülasyonda kopmaların yaklaşık %6'sı, önce error handler'ına düşüyor; orada soket yok ediliyor ve bağlantı tablosundaki kayıt siliniyordu. close beklendiği gibi ardından tetikleniyordu, ama ilk satırı const conn = connections.get(id); if (!conn) return; idi. Kayıt çoktan gitmişti. Her ani kopmada temizlik sessizce atlanıyordu: iki kod yolu ve biri, diğerinin bağımlı olduğu indeksi siliyor.

Altmışıncı saatte sıcak abone kümelerindeki kayıtların yaklaşık %19'u artık var olmayan bağlantıları işaret ediyordu ve staging p99'u 68 ms'den 141 ms'ye kaymıştı. Toplam sızan bellek ~90 MB — 4 GB'lık bir heap'in yanında görünmez. Yani bu, bir bellek alarmına dönüşmeden çok önce bir gecikme regresyonuydu ve üretimde masanın ilk canlı haftasının ortasında bir yerde yüzeye çıkacaktı; heap dump okumak isteyeceğiniz en son hafta da odur. Çözüm tek sahiplikti: her yoldan erişilen, idempotent tek bir teardown(id, reason), indeks temizliği bağlantı kaydı silinmeden önce gelecek şekilde sıralandı. Değişmez kontrolü üretimde de çalışmaya devam ediyor; otuz saniyede bir yaklaşık 2 ms'ye mal oluyor.

Göstergeler bir sayının şu an kaç olduğunu söyler. Değişmezler ise sisteme dair kafanızdaki modelin tam olarak ne zaman yanlışlandığını. Bunu yakalayabilmemizin tek sebebi, her zaman sıfır çıkması gereken bir çıkarmanın sıfır çıkmamasıydı.

Ne yayına çıktı

Dört haftalık pencerenin on dokuzuncu gününde canlıya geçtik. 4.200 eşzamanlı terminalde tick-to-glass p50 11 ms, p99 71 ms, p99.9 128 ms; 6.000 bağlantıya kadar kapasite doğrulandı. Yayına alınmasından bu yana geçen yaklaşık 150 seansta tek bir bayat ya da geri saran fiyat vakası yaşanmadı; bu sırada sequenceRegression sayacı zirvede dakikada ~340 sırasız tick'i atmaya devam ediyor. Sıralama disiplini kesintisiz olarak gerçek iş yapıyor ve masa bunu hiç görmüyor.

Kayıtlara geçsin: dağıtım katmanını Rust ile yazabilir, kuyruktan muhtemelen 10-15 ms daha kırpabilirdik. On dokuz gün ve wire codec'i sunucuyla paylaşan bir TypeScript terminal SDK'sı varken önemli olan kısıt bu değildi; hangi kısıtın önemli olduğunu seçebilmek de zaten işin büyük kısmı. Faturada gecikme yazıyordu. Masanın gerçekte satın aldığı şey, ekranlarındaki bir sayı değiştiğinde "piyasa değiştiği için değişti" diyebilme hakkı. Yukarıdakilerin tamamı, bunu yük altında söyleyebilmenin bedeli.