0 ciasteczeknic się nie ładuje przed kliknięciemcały zespół bez limituodpowiadasz ze slacka albo discordaprzycisk czatu <4kbzero opłat od głowy

/blog / dowody

1000 wiadomości na sekundę na jednym rdzeniu

· pisze kuba · 8 min czytania

Przestraszony kot Spookata na najwyższym z sześciu rosnących stopni, obok napis 1000/s.

26 września 2026 zrobiliśmy load test Spookata na serwerze, na którym chodzi produkcja. Jeden proces na jednym rdzeniu przyjął 1000 wiadomości od odwiedzających na sekundę z p99 191 ms i zerem błędów, a 800 na sekundę, kiedy każdą wiadomość wysyłał jeszcze do atrapy Slacka. Tu jest metoda, każdy schodek testu, trzy poprawki, które nas tam doprowadziły, i to, czego nie zmierzyliśmy.

po co to było

Spookat to mała aplikacja. Jeden proces Buna, jeden plik SQLite, serwer, który dzielimy z innymi projektami. Taki setup jest tani i łatwo go ogarnąć głową, a oczywiste pytanie brzmi: ile wytrzyma, zanim się wyłoży.

Pierwsza odpowiedź, jaką mieliśmy, była zgadywaniem. Widget miał sztywny limit 500 wiadomości od odwiedzających na stronę dziennie, ustawiony na początku jako hamulec na spam, a na naszej stronie stało, że czaty są bez limitu. Obie rzeczy naraz nie mogą być prawdą, więc chcieliśmy prawdziwej liczby, zanim zdecydujemy, którą zmienić.

Chcieliśmy też wiedzieć, co zapcha się pierwsze. Jeśli baza, poprawka wygląda w jeden sposób. Jeśli runner jobów, który wysyła na Slacka, wygląda zupełnie inaczej.

setup

Wszystko szło na produkcyjnym serwerze, obok żywej aplikacji, ale bez dotykania jej:

  • Binarka: ten sam release, który obsługuje prawdziwy ruch, odpalony jako osobny proces na własnym porcie.
  • Baza: świeży plik SQLite w katalogu testowym, z 1200 testowymi stronami. Produkcyjnej bazy i jej repliki w Litestream nikt nie otwierał.
  • Sieć: ruch szedł przez loopback. Cloudflare i nginx nie były po drodze, więc te liczby to sama aplikacja.
  • Usługi z zewnątrz: proces testowy nie miał kluczy do Slacka, Discorda ani Paddle, więc nic nie mogło wyjść z serwera. Do przebiegów „ze Slackiem” podpięliśmy do binarki małą atrapę (BUN_OPTIONS=--preload), która odpowiada na każdy post po około 50 ms.
  • Ruch: generator otwierał websockety widgetu i wysyłał wiadomości w stałym tempie, schodkami po 60 sekund. Każda wiadomość to prawdziwa ramka widgetu, więc idzie przez ten sam kod co odwiedzający, który coś pisze: rate limity, hamulec na floody, saveMessage(), kolejkę jobów, post na Slacka i ack z powrotem do widgetu.
  • Co czytaliśmy: p99 acka (czas od wysłania wiadomości przez widget do potwierdzenia zapisu przez serwer), CPU procesu serve, lag event loopa, błędy, RAM, a przy Slacku to, ile wiadomości faktycznie doszło do atrapy.

Serwer ma 8 rdzeni. Proces serve używa jednego, bo Bun wykonuje twój JavaScript na jednym wątku. Generator brał 4–7% innego rdzenia, więc to nie on nas ograniczał.

runda pierwsza: schodki przed poprawkami

docelowe tempo zapisane p99 ack CPU (1 rdzeń)
50/s 50/s 6 ms 15%
100/s 92/s 5,5 ms 25%
200/s 146/s 6 ms 36%
400/s 273/s 482 ms 60%

Dwie rzeczy w tej tabeli wymagają komentarza.

Kolumna „zapisane” od 100/s jest poniżej celu. To wina naszego generatora, a nie serwera: powtarzał client id, a serwer słusznie odrzucał takie wiadomości jako duplikaty. Przed drugą rundą generator był już poprawiony.

Prawdziwe znalezisko to skok przy 400/s. CPU stało na 60%, rdzeń nie był pełny, a mimo to p99 skoczyło z 6 ms na 482 ms, a event loop trzynaście razy miał lag ponad 100 ms. Coś blokowało.

RAM przez cały czas trzymał się około 110 MB. Każda wiadomość dokładała do bazy około 0,5 kB.

trzy poprawki

Sprofilowaliśmy proces pod obciążeniem. Wyszły trzy rzeczy.

1. fsync przy każdym commicie

SQLite w trybie WAL z domyślnym synchronous = FULL czeka na dysk po każdym commicie. SQLite w Bunie jest synchroniczny, więc kiedy czeka, nic innego w procesie się nie dzieje. Przy 400 wiadomościach na sekundę to 400 czekań na dysk na sekundę, na tym samym wątku, który odpowiada na każdy websocket.

Przeszliśmy na synchronous = NORMAL, tak jak Litestream zaleca dla WAL. Kompromis po ludzku: jeśli proces się wywali, nic nie ginie. Jeśli cała maszyna straci prąd, ostatnie commity sprzed zaniku mogą przepaść. Przyjęliśmy to i napisaliśmy wprost w kodzie obok tego ustawienia.

2. ten sam SQL kompilowany w kółko

Drizzle, nasz query builder, przygotowywał każde zapytanie od zera przy każdym wywołaniu. Kompilowanie identycznego SQL-a raz za razem zjadało około 16% CPU jednej wiadomości z widgetu. Teraz trzymamy prepared statements w cache’u (do 1000, najstarsze wylatują pierwsze) i używamy ich ponownie. Potem poszliśmy dalej i najgorętsze zapytania na ścieżce widget → Slack przygotowujemy raz, ręcznie, co zdjęło mniej więcej kolejne 20% CPU.

3. payload webhooka, o który nikt nie prosił

Każda zapisana wiadomość budowała payload webhooka, nawet na stronach bez webhooka. Teraz payload powstaje dopiero wtedy, gdy jest dostawa do zapisania.

Lokalnie, przy 300 wiadomościach na sekundę, transakcja zapisu spadła z 0,95 ms do 0,52 ms, a p99 z 41 ms do 7 ms. Przy 1200 na sekundę na laptopie p99 wyniosło 8,6 ms, bez błędów.

runda druga: schodki po poprawkach

Ten sam serwer, ten sam setup, release z poprawkami:

tempo p99 ack CPU (1 rdzeń) błędy
200/s 8 ms 38% 0
400/s 11 ms 56% 0
600/s 42 ms 76% 0
800/s 3,9 s 92% 0

400 na sekundę spadło z 482 ms do 11 ms. Przy 800 rdzeń był pełny: wiadomości stały w kolejce i czekały, ale żadna nie przepadła.

W tej rundzie zrobiliśmy też test floodu, bo dzienny limit zniknął, a zamiast niego wszedł hamulec per strona. Przez 90 sekund jedna strona dostawała 5 nowych odwiedzających na sekundę, a pozostałe strony w tym czasie 200 wiadomości na sekundę. Zalewana strona przepuściła 60 nowych czatów, potem hamulec wszedł poziom wyżej, a minutę później jeszcze wyżej. Odrzucił 87% floodu (389 z 449 wiadomości). Pozostałe strony miały p99 8,6 ms i zero błędów, tyle samo co bez floodu.

liczba była zła

600 na sekundę wyglądało świetnie. I było zawyżone, co złapaliśmy, zanim gdziekolwiek to napisaliśmy.

Żadna testowa strona nie miała podpiętego kanału na Slacku ani Discordzie. U prawdziwego klienta każda wiadomość od odwiedzającego staje się dodatkowo jobem: runner go bierze, wysyła na Slacka i oznacza jako zrobiony. Wszystko to kręci się na tym samym rdzeniu co widget. Podpięliśmy więc testowe strony do atrapy Slacka i puściliśmy test jeszcze raz.

Przy 200 wiadomościach na sekundę ze Slackiem rdzeń był na 96%, a do atrapy doszło w trakcie przebiegu tylko 8129 z 11 998 wiadomości. Prawdziwy sufit dla strony z podpiętym Slackiem był poniżej 200 na sekundę.

czwarta i piąta poprawka

Profil pod ruchem ze Slackiem był jednoznaczny. Około 70% CPU szło na runner jobów, który szukał pracy.

  • Po każdym nowym i każdym skończonym jobie runner przeszukiwał kolejki wszystkich lane’ów (posty na czat, webhooki, maile, utrzymanie), żeby zobaczyć, co może odpalić. Większość lane’ów prawie zawsze była pusta, więc prawie całe to szukanie nic nie znajdowało. Teraz bierze pracę tylko z lane’u, w którym może jakaś być.
  • Lane czatu miał 8 slotów, co ograniczało wszystkie posty na Slacka i Discorda na całym serwerze do około 140 na sekundę, niezależnie od CPU. Teraz ma 64. Kolejność per kanał i 429 od Slacka obsługuje sama kolejka, więc więcej slotów nie znaczy postów w złej kolejności.

Sama poprawka runnera zbiła 200 na sekundę ze Slackiem z 96% do 63% rdzenia i wszystko dochodziło. Prepared statements na ścieżce do Slacka dołożyły swoje.

runda trzecia: 1000 na sekundę

Finalny build, na serwerze, z atrapą Slacka i bez niej:

przed poprawkami po
bez Slacka, 800/s p99 3,9 s, pełny rdzeń p99 12 ms, 58% rdzenia
bez Slacka, 1000/s nie doszliśmy p99 191 ms, 69%, 0 błędów
bez Slacka, 1200/s nie doszliśmy p99 290 ms, 79%: granica
Slack, 200/s 96% rdzenia, doszło 8129 z 11 998 63%, wszystko doszło
Slack, 800/s nie doszliśmy p99 75 ms, 86%, wszystko doszło
Slack, 1000/s nie doszliśmy nasycenie, p99 408 ms, zaległości zeszły same w około minutę

Czyli jeden proces na jednym rdzeniu przyjmuje 1000 wiadomości od odwiedzających na sekundę bez Slacka i 800 na sekundę, kiedy każda leci na Slacka. RAM trzymał się blisko 110 MB.

Dla skali: 800 na sekundę to około 69 milionów wiadomości dziennie. I żadna pojedyncza strona nie zbliży się do tego, bo hamulec per strona zaczyna ją przyhamowywać przy 600 wiadomościach na minutę, czyli 10 na sekundę.

co próbowaliśmy i nic nie dało

Dwa pomysły dobrze wyglądały na papierze i nie zmieniły nic w liczbach, więc nie weszły:

  • większy page cache SQLite
  • indeksy częściowe na tabeli wiadomości

Zbudowaliśmy też wersję, w której runner jobów chodzi jako osobny proces, na własnym rdzeniu. Przy dużym ruchu wyszła gorzej niż jeden proces, bo SQLite pozwala na jednego piszącego naraz i dwa procesy czekały na siebie nawzajem. Ta gałąź została gałęzią. To temat na osobny wpis.

czego ten test nie zmierzył

Uczciwość co do granic benchmarku to część benchmarku.

  • Internet. Ruch szedł przez loopback. Prawdziwi odwiedzający dokładają TLS, Cloudflare, nginx i własną sieć. To dodaje opóźnienia do każdej wiadomości, a nie CPU na naszym rdzeniu, ale w tych liczbach tego nie ma.
  • Prawdziwy Slack. Atrapa odpowiada w około 50 ms i nigdy nie odmawia. Prawdziwy Slack przyjmuje mniej więcej jeden post na sekundę na kanał, a powyżej zwraca 429. Nasza kolejka obsługuje 429 per kanał, ale prawdziwy Slack pod obciążeniem zachowuje się inaczej niż atrapa.
  • Discord. Ta sama ścieżka przez runner jobów, osobno nietestowana.
  • Otwarte połączenia. Generator trzymał umiarkowaną liczbę socketów. Pierwszym prawdziwym sufitem dla wielu otwartych widgetów był nginx: miał pozwolenie na 768 połączeń na workera, czyli około 3000 otwartych widgetów na cały serwer. Podnieśliśmy to do 16 384 na workera. Dziesiątek tysięcy bezczynnych socketów jeszcze przez to nie przepchnęliśmy.
  • Dashboardy. W trakcie testu nikt nie miał otwartego inboxa, więc nic nie leciało na żywo do dashboardu.
  • Zanik prądu. synchronous = NORMAL jest bezpieczne przy crashu procesu, co da się przetestować. Zanik prądu przyjęliśmy na wiarę z dokumentacji SQLite.

co to znaczy dla ciebie

Aplikacja, która odpowiada twoim odwiedzającym, to jeden mały proces. Na jednym rdzeniu przyjmuje 80 razy więcej, niż hamulec pozwala wysłać jednej stronie. Kiedy będzie trzeba więcej, droga jest znana: zdjąć posty na Slacka z rdzenia widgetu tak, żeby dwóch piszących do SQLite nie biło się o jeden plik.

Zbudowaliśmy Spookata mały z premedytacją, a mały okazał się szybki. Launcher ma mniej niż 4 kb, przed kliknięciem jest 0 requestów do naszego API, a serwer za nim przyjmuje 1000 wiadomości na sekundę na jednym rdzeniu.

Chcesz widget, który ktoś już za ciebie przemielił? Odpal 14-dniowy trial, bez karty.

wchodzisz? zapisz się przed startem.

zaproszenia wysyłamy małymi partiami. potem 14 dni za darmo, bez karty.