Skip to main content

29 Tekniikkaa

29Tekniikka logo
Node.js-sovellus voi näyttää päällisin puolin terveeltä, vaikka käyttäjät joutuvat odottamaan vastauksia.
CPU ei välttämättä ole täynnä. Palvelimia on tarpeeksi. Tietokanta vastaa normaalisti.
Silti API alkaa hidastella.
Yksi pyyntö kestää liian kauan, ja hetken päästä sama näkyy muillekin käyttäjille. Ongelma voi olla Node.js:n event loopissa.
Tässä kohtaa kannattaa unohtaa hetkeksi ajatus siitä, että Node.js käsittelisi jokaisen pyynnön omassa säikeessään. Sen toimintamalli on erilainen. JavaScript-koodia suoritetaan event loopin kautta, ja jos sen käsittely jää kiinni pitkäksi aikaa, muut työt joutuvat odottamaan.
Tämä on event loop -ongelman ydin.
Yksi liian raskas operaatio voi vaikuttaa paljon laajemmalle kuin sen ympärillä oleva koodi antaa ymmärtää.

Kaikki hitaus ei kuitenkaan ole event loopin ongelma

Tämä on ensimmäinen asia, jonka tarkistaisin.
Jos API-vastaus kestää kolme sekuntia, siitä ei vielä seuraa, että event loop olisi jumissa.
Node.js voi aivan hyvin odottaa tietokantaa tai ulkoista API:a ilman, että JavaScriptin event loop on varsinaisesti tukossa.
Esimerkiksi:
const customer = await customerRepository.findById(id);
Jos tietokantakutsu kestää kolme sekuntia, Node.js ei yleensä istu kolme sekuntia tekemässä CPU-työtä tämän yhden pyynnön vuoksi.
Tilanne on aivan toinen, jos requestin aikana suoritetaan raskas JavaScript-operaatio, joka pitää event loopin kiireisenä.
Siksi hidastumista tutkiessa kannattaa ensin selvittää, odottaako Node jotain vai tekeekö se aktiivisesti työtä.

Suuri looppi on helppo tapa tukkia event loop

☑️ Esimerkiksi tällainen koodi ei näytä erityisen vaaralliselta:
for (let i = 0; i < 1000000000; i++) {
// raskasta käsittelyä
}
☑️ Mutta käytännössä se pysäyttää muun JavaScript-käsittelyn siksi aikaa, kunnes looppi valmistuu.
☑️ Jos tämä tapahtuu HTTP-requestin aikana, seuraavat pyynnöt voivat joutua odottamaan.
☑️ Tuotannossa ongelma voi näyttää tältä:
  • ensimmäinen request alkaa käsitellä dataa
  • CPU-kuorma nousee
  • muut requestit alkavat jonottaa
  • vasteajat kasvavat
  • käyttäjä kokee koko palvelun hitaaksi
☑️ Tämä on paljon pahempi tilanne kuin yhden requestin hidas käsittely antaa ymmärtää.

Suuret taulukot voivat aiheuttaa saman ongelman

➢ Usein ongelma ei ole miljardissa silmukassa.
➢ Riittää, että sovellus käsittelee liian suurta datamäärää yhdellä kertaa.
➢ Esimerkiksi:
const result = users
.filter(user => user.active)
.map(user => transformUser(user))
.sort(compareUsers);
➢ Jos users sisältää muutaman sata alkiota, tällä ei ole käytännössä merkitystä.
➢ Jos niitä on miljoonia, tilanne muuttuu.
➢ Node.js joutuu tekemään kaiken tämän JavaScript-ajon event loopin yhteydessä.
➢ Silloin ongelma ei välttämättä näy yksittäisenä “hitaana rivinä”. Pullonkaula syntyy siitä, että liian paljon työtä tehdään yhdessä erässä.

JSON:n käsittely voi olla yllättävän kallista

API-palveluissa tulee vastaan myös toinen melko arkinen ongelma: liian suuret JSON-dokumentit.
Esimerkiksi:
const data = JSON.parse(largePayload);
ja myöhemmin:
const payload = JSON.stringify(result);
JSON.parse() ja JSON.stringify() ovat synkronisia operaatioita.
Jos käsiteltävä dokumentti on suuri, Node.js joutuu tekemään työn ennen kuin event loop voi jatkaa seuraaviin tehtäviin.
Pieni JSON ei ole ongelma.
Kymmenien tai satojen megatavujen käsittely requestin sisällä on jo aivan eri asia.
Jos suuri tiedosto pitää käsitellä, streamit ovat usein järkevämpi vaihtoehto kuin koko sisällön lataaminen muistiin ja käsittely yhdellä kertaa.

Synkroniset API:t ovat tuotannossa helposti vaarallisia

☑️ Node.js tarjoaa useita API:eja sekä synkronisina että asynkronisina versioina.
☑️ Esimerkiksi tiedoston lukeminen:
fs.readFileSync(path);
☑️ on helppo kirjoittaa.
☑️ Samalla se pysäyttää event loopin operaation ajaksi.
☑️ Jos tiedosto on pieni ja koodi ajetaan sovelluksen käynnistyessä, tämä voi olla täysin hyväksyttävää.
☑️ Jos samaa tehdään jokaisen HTTP-pyynnön aikana, tilanne on toinen.
☑️ Esimerkiksi:
app.get(‘/report’, (req, res) => {
const data = fs.readFileSync(‘/data/report.json’);
res.json(data);
});
☑️ voi toimia kehityskoneella täysin ongelmitta.
☑️ Kun pyyntöjä tulee samanaikaisesti enemmän, synkroninen tiedostonluku alkaa näkyä vasteajoissa.
☑️ Sama periaate koskee muitakin synkronisia Node.js-operaatioita.

Salaus ja hashaukset voivat käyttää CPU:ta kunnolla

➢ Kaikki raskas työ ei liity datan käsittelyyn.
➢ Salaukset, hashaukset, pakkaaminen ja muu CPU-intensiivinen työ voivat myös muodostaa ongelman.
➢ Esimerkiksi erittäin raskas kryptografinen operaatio requestin aikana voi pitää JavaScriptin kiireisenä niin kauan, että muut pyynnöt alkavat odottaa.
➢ Esimerkiksi erittäin raskas kryptografinen operaatio requestin aikana voi pitää JavaScriptin kiireisenä niin kauan, että muut pyynnöt alkavat odottaa.
➢ Jos Node.js:n oma kirjasto pystyy siirtämään työn libuvin worker pooliin, tilanne voi olla parempi kuin täysin synkronisessa JavaScript-koodissa.
➢ Jos työ tehdään suoraan JavaScriptissä pitkänä CPU-laskentana, event loop jää paljon helpommin tukkoon.
➢ Sama koodi voi siis näyttää “asynkroniselta” API-tasolla olematta sitä suorituskyvyn kannalta.

Async ei automaattisesti tarkoita nopeaa

Tämä väärinkäsitys tulee vastaan aika usein.
Koodi voi näyttää tältä:
const result = await processLargeDataset(data);
ja näyttää siltä, että Node.js hoitaa työn taustalla.
Mutta await ei tee CPU-raskaasta funktiosta automaattisesti taustatyötä.
Jos processLargeDataset() tekee pitkän synkronisen laskennan, event loop voi silti olla kiireinen koko ajan.
await kertoo enemmän siitä, miten Promiseen perustuvaa toimintaa käsitellään koodissa kuin siitä, missä CPU-työ suoritetaan.
Tämä ero on tärkeä, kun ongelmaa yritetään ratkaista.

Yksi huono algoritmi voi riittää

☑️ Event loop -ongelmaa ei aina tarvitse etsiä infrastruktuurista.
☑️ Jos sovellus käy suuren datamäärän läpi sisäkkäisillä loopeilla, suorituskyky voi romahtaa jo suhteellisen pienellä liikenteellä.
☑️ Esimerkiksi käyttäjien ja tilausten vertaaminen näin:
for (const user of users) {
for (const order of orders) {
// vertailu
}
}
☑️ voi olla ongelmallista, jos molemmat listat kasvavat.
☑️ Kehitysdatan kanssa koodi voi tuntua nopealta.
☑️ Tuotannossa miljoonien yhdistelmien käsittely onkin aivan toinen tarina.
☑️ Tässä tapauksessa event loop ei ole varsinainen juurisyy. Algoritmin tehokkuus on.
☑️ Event loop vain tekee ongelman näkyväksi koko sovelluksessa.

Regex voi joskus lukita koko prosessin

➢ Regular expression -ongelmat jäävät helposti huomaamatta.
➢ Tietyt monimutkaiset regex-kuviot voivat aiheuttaa niin sanottua catastrophic backtrackingia, jolloin yhden merkkijonon käsittely voi kestää huomattavan kauan.
➢ Jos regex ajetaan suoraan requestin aikana, yksittäinen poikkeava syöte voi saada Node.js-prosessin käyttämään paljon CPU-aikaa.
➢ Tätä on vaikea huomata tavallisella testidatalla.
➢ Siksi regexien kohdalla kannattaa olla varovainen erityisesti, jos syöte tulee käyttäjältä ja pattern on monimutkainen.

Ulkoinen API ei yleensä jumita event loopia samalla tavalla

Ajatellaan esimerkiksi:
const response = await fetch(paymentUrl);
Jos maksupalvelu vastaa hitaasti, Node.js voi odottaa Promisea ilman, että JavaScript itse käyttää CPU:ta koko ajan.
Tämä ei tarkoita, ettei hitaalla ulkoisella palvelulla olisi merkitystä.
Se voi kuluttaa yhteyksiä, kasvattaa requestien määrää ja aiheuttaa aikakatkaisuja.
Mutta ratkaisu on silloin todennäköisemmin timeoutien, retryjen, connection poolien tai palveluarkkitehtuurin puolella kuin event loopin optimoinnissa.
Tämä erotus säästää paljon aikaa vianhaussa.

Event loopin viive on hyödyllinen mittari

☑️ Kun epäillään event loop -ongelmaa, pelkkä CPU-prosentti ei aina riitä.
☑️ Hyödyllinen mittari on event loop lag eli käytännössä se, kuinka paljon event loopin käsittely viivästyy siitä, mitä normaalisti odotetaan.
☑️ Node.js tarjoaa tähän omia mittausmekanismeja, kuten perf_hooks-moduulin monitorEventLoopDelay-toiminnon.
☑️ Jos event loopin viive kasvaa samaan aikaan kun vasteajat kasvavat, epäily vahvistuu.
☑️ Tässä kohtaa mittaus on paljon hyödyllisempi kuin se, että lisätään koodiin satunnaisia optimointeja.

CPU voi olla täynnä ilman että ongelma on helposti nähtävissä

➢ Node.js-palvelimessa yksi prosessi voi käyttää käytännössä yhden CPU-ytimen tehokkaasti.
➢ Jos sovellukseen tulee raskas laskenta, yksi prosessi voi päätyä jatkuvasti korkeaan CPU-kuormaan samalla kun muu liikenne jonottaa.
➢ Moniprosessointi tai useampi Node.js-instanssi voi auttaa liikenteen jakamisessa.
➢ Mutta se ei korjaa huonoa CPU-raskasta operaatiota.
➢ Jos jokainen instanssi joutuu tekemään saman kalliin työn, ongelma vain skaalautuu vaakasuunnassa.
➢ Ensin kannattaa siis selvittää, miksi yksi request tarvitsee niin paljon CPU-aikaa.

Worker Threads ovat yksi vaihtoehto raskaalle CPU-työlle

Jos sovellus joutuu oikeasti tekemään raskasta laskentaa, kaikkea ei tarvitse väkisin tehdä event loopissa.
Node.js:n worker_threads mahdollistaa CPU-raskaan työn siirtämisen erilliseen worker-ympäristöön.
Tätä voi käyttää esimerkiksi kuvankäsittelyssä, raskaissa laskutoimituksissa tai muussa työssä, jota ei ole järkevää suorittaa suoraan pääsäikeessä.
Worker ei kuitenkaan ole ilmainen ratkaisu.
Dataa pitää siirtää workerin ja pääprosessin välillä, työn hallinta lisää monimutkaisuutta ja väärin suunniteltu worker-järjestelmä voi tuoda uusia ongelmia.
Sitä kannattaa käyttää silloin, kun mittaus osoittaa CPU-työn olevan oikeasti pullonkaula.

Queue voi olla parempi ratkaisu kuin requestin venyttäminen

☑️ Jos käyttäjän ei tarvitse saada raskaan työn tulosta saman HTTP-pyynnön aikana, työtä ei välttämättä kannata tehdä requestissa ollenkaan.
☑️ Esimerkiksi raportin muodostaminen voi mennä jonoon.
☑️ HTTP-request luo työn ja palauttaa nopeasti vastauksen.
☑️ Taustatyö käsittelee raportin myöhemmin.
☑️ Tämä muuttaa järjestelmän toimintaa, joten ratkaisu ei sovi kaikkeen.
☑️ Mutta suurissa CPU-raskaissa operaatioissa se voi olla paljon järkevämpi kuin yrittää saada sama työ valmistumaan event loopin ympärillä jokaisen requestin aikana.

Event loop -ongelman jäljittäminen tuotannossa

➢ Jos tuotannossa alkaa näkyä satunnaisia hitaita requesteja, aloittaisin aikajanan rakentamisesta.
➢ Milloin vasteajat alkoivat kasvaa?
➢ Kasvoiko samaan aikaan CPU?
➢ Kasvoiko event loop delay?
➢ Muuttuiko liikenteen määrä?
➢ Tuliko uusi deploy?
➢ Kasvoiko käsiteltävän datan määrä?
➢ Näillä kysymyksillä ongelma alkaa rajautua.
➢ Jos event loop delay nousee juuri silloin, kun tietty endpoint saa liikennettä, seuraava askel on selvittää, mitä kyseinen endpoint tekee synkronisesti.
➢ Profilerista voi tässä vaiheessa olla paljon enemmän hyötyä kuin lokien selaamisesta.

Profilerilla kannattaa etsiä aikaa, ei vain hitaita funktioita

CPU-profiloinnissa kiinnostavaa ei ole pelkästään se, mikä funktio näkyy listalla.
Kiinnostavaa on, mihin Node.js käyttää aikansa.
Jos suuri osa ajasta kuluu esimerkiksi:
JSON.parse
ongelma voi liittyä liian suureen payloadiin.
Jos aikaa kuluu regexiin, kannattaa tutkia patternia ja syötteen kokoa.
Jos yksi oma funktio vie suurimman osan CPU-ajasta, silloin koodi tai algoritmi on paljon vahvempi epäilty.
Tällä tavalla event loop -ongelma muuttuu abstraktista Node.js-ongelmasta konkreettiseksi suorituskykyongelmaksi.

Älä korjaa tietokantaa, jos event loop on tukossa

☑️ Tämä kuulostaa itsestään selvältä, mutta tuotanto-ongelmassa helposti tehdään väärä oletus.
☑️ API on hidas.
☑️ Silloin tutkitaan tietokantakyselyt.
☑️ Tietokanta näyttää normaalilta.
☑️ Seuraavaksi säädetään connection poolia.
☑️ Todellisuudessa Node.js saattaa käyttää suurimman osan ajasta CPU-raskaaseen JavaScript-käsittelyyn ennen kuin se edes ehtii tehdä tietokantakutsua.
☑️ Siksi ensimmäinen kysymys kannattaa pitää mahdollisimman yksinkertaisena:
Missä request viettää aikansa?
Jos vastaus on event loopissa, tietokantakonfiguraation muuttaminen ei ratkaise sitä.

Myös pieni CPU-työ voi olla ongelma suurella liikenteellä

➢ Kaiken ei tarvitse olla valtava laskenta.
➢ Jos yksi request tekee esimerkiksi 20 millisekuntia CPU-työtä, se voi olla täysin hyväksyttävää pienessä järjestelmässä.
➢ Kun requesteja tulee paljon samanaikaisesti, sama työ alkaa kuitenkin kasaantua.
➢ Event loopin näkökulmasta jokainen tällainen pieni työ on tehtävä ennen kuin seuraava työ pääsee etenemään.
➢ Siksi suorituskykyä ei kannata arvioida vain yhden requestin perusteella.
➢ Liikennemäärä ja samanaikaisuus muuttavat ongelman luonnetta.

Miten lähtisin itse tutkimaan event loop -ongelmaa?

☑️ Ensin tarkistaisin, onko kyse todella event loopista.
☑️ Katsoisin event loop delayta ja CPU-kuormaa.
☑️ Sen jälkeen ottaisin yhden hitaaksi muuttuneen endpointin ja selvittäisin, mitä se tekee requestin aikana.
☑️ Erityisesti etsisin:
  • synkronisia tiedosto-operaatioita
  • suurten JSON-dokumenttien käsittelyä
  • raskaita looppeja
  • suuria taulukko-operaatioita
  • monimutkaisia regexejä
  • CPU-raskasta laskentaa
  • CPU-raskasta laskentaa
☑️ Jos yksi näistä löytyy, profiloisin sen ennen kuin muuttaisin rakennetta.
☑️ Jos taas event loop näyttää terveeltä, siirtyisin tutkimaan muita kerroksia.
☑️ Tämä rajaus on tärkeä, koska Node.js-sovelluksen hitaus ei automaattisesti tarkoita event loop -ongelmaa.

Lopullinen korjaus voi olla hyvin pieni

➢ Joskus ratkaisu on vain vaihtaa synkroninen API asynkroniseksi.
➢ Joskus suuri JSON käsitellään streamina.
➢ Joskus miljoonan rivin käsittely siirretään taustatyöksi.
➢ Joskus algoritmi vaihdetaan tehokkaampaan.
➢ Ja joskus selviää, ettei event loopissa ollut mitään vikaa, vaan tietokanta tai ulkoinen palvelu oli hidas.
➢ Siksi en lähtisi ensimmäisenä muuttamaan koko Node.js-arkkitehtuuria.
➢ Event loop on yksi osa järjestelmää. Sen merkitys korostuu silloin, kun JavaScript tekee liikaa työtä yhdessä prosessissa ja pitää muita pyyntöjä odottamassa.
➢ Kun tämä ero ymmärretään, myös vianhaku helpottuu.
➢ Node.js:n event loop ei yleensä “hajoa” itsestään.
➢ Useimmiten sille annetaan vain liikaa tehtävää kerralla.