Vienas sunkus pokalbis galėjo numušti mūsų API
Rugsėjo 28 d., maždaug po 17 minučių nuo laidos, abu mūsų gamybiniai API serveriai tuo pačiu metu išseko atmintis, perkrovėsi, ir maždaug penkias minutes apkrovos balansuotojas neturėjo jokio sveiko serverio, kur nukreipti srautą. Pirmas mūsų sprendimas API suteikė dvigubai daugiau atminties. Tikroji priežastis paaiškėjo vėliau tą pačią dieną: atidarius vieną konkretų pokalbį API vienoje užklausoje buvo paprašyta įkelti 53 MB žinučių. Pokalbio istorija dabar kraunama puslapiais, ribotais pagal dydį, o ne tik pagal skaičių, ir tas pokalbis atsidaro su 5 MB pirmu puslapiu vietoj to, kad nulūžtų.
Kas nutiko
Mūsų API veikia kaip konteineris kiekviename iš dviejų serverių už apkrovos balansuotojo. Kiekvieno konteinerio atminties riba buvo 384 MiB. Įprastai jis naudoja 130–140 MiB, todėl riba atrodė kaip pakankama rezerva.
Abu konteineriai vienu metu pasiekė ribą ir sugedo su pranešimu „JavaScript heap out of memory“. Heap yra atmintis, kurią Node.js procesas naudoja objektams, su kuriais dirba. Kadangi abu serveriai sugedo vienu metu, nebeliko kur nukreipti užklausų. Tai blogiausias gedimo tipas dviejų serverių sąrankai: atsarga padeda tik tada, kai gedimai nesutampa laike.
Pirmas sprendimas: daugiau vietos
Laikas rodė į laidą. Po išleidimo agento vykdymai, kuriuos laida nutraukė, yra tęsiami ir iš naujo įrašo savo pokalbių stenogramas, o toks darbo antplūdis atrodė kaip akivaizdus atminties šuolio šaltinis.
Todėl pirmas pakeitimas padidino API ribą nuo 384 MiB iki 768 MiB. Pritaikėme tai tiesiogiai abiejuose serveriuose tą pačią dieną ir įrašėme į diegimo konfigūraciją, kad kitas diegimas tyliai negrąžintų 384. Rankiniu būdu pataisytas serveris, kurį kitas diegimas vėl pakeičia, – dažnas būdas, kaip ta pati avarija gali grįžti.
Daugiau atminties buvo pagrįsta saugumo riba. Bet tai nebuvo paaiškinimas.
Tikroji priežastis: viena užklausa, 71 MB
Vėliau tą dieną atkūrėme gedimą su vieno pokalbio kopija, naudodami senąją produkcinę atminties ribą. Kopija pati nulaužė API, be jokios laidos.
Atidarius projektą, žiniatinklio programa iš API prašo to pokalbio istorijos. Galutinis taškas grąžindavo naujausias 30 žinučių pilnas. Daugumai pokalbių 30 žinučių yra nedaug. Šiam pokalbiui naujausios 30 žinučių duomenų bazėje užėmė 53 MB, o atsakas buvo 71 MB. Kelios agento atsakymų dalys buvo po 4–10 MB, daugiausia dėl subagentų – pagalbinių agentų, kuriuos pagrindinis agentas paleidžia lygiagrečiai atlikti dalį užduoties, ir kurių visas darbas išsaugomas kartu su atsakymu.
Sukurti tokį atsakymą prireikė daugiau heap, nei turėjo API. Be to, kiekvienas projekto atidarymas ar atnaujinimas užklausą siųsdavo iš naujo. Vienas pokalbis galėjo numušti bet kurį serverį, į kurį pateko, o kadangi puslapį galima perkrauti, jis galėjo pasiekti abu.
„30 žinučių“ niekada nebuvo tikra dydžio riba. Tai buvo skaičiaus riba, kuri paprastiems pokalbiams būdavo maža.
Taisymas: puslapiai ribojami pagal dydį, ne tik skaičių
Istorijos puslapis dabar yra 30 žinučių arba apie 4 MB, kas įvyksta pirma.
Tai veikia dviem žingsniais:
- Pirmiausia paprašomi dydžiai. API paklausia duomenų bazės dėl naujausių žinučių ID ir saugomų dydžių, neimdama turinio. Tokia užklausa pigi, nesvarbu, kokios didelės yra žinutės.
- Įkeliama tik tai, kas telpa. Einant nuo naujausios iki seniausios, pridedamos žinutės, kol kita žinutė peržengtų 4 MB ribą. Tik tada tos eilutės įkeliamos pilnai.
Dvi taisyklės tai saugo:
- Naujausia žinutė visada patenka į puslapį, kokio dydžio ji bebūtų. Puslapis be nieko paliktų pokalbį įstrigusį be galimybės slinkti atgal.
- Nieko nekerpama. Jokia žinutė nesutrumpinama ir nepraleidžiama. Likusi pokalbio dalis įkeliama per valdiklį „Scroll up for earlier messages“ (Slinkti aukštyn, kad pamatytumėte ankstesnes žinutes), kurį seni žinučių puslapiai jau naudojo, po vieną puslapį.
Radome dar vieną vietą su ta pačia forma. Kai sustabdote vykdymą, API patikrina paskutinius kelis atsakymus, ar kuris nors yra tuščias ir turi būti išvalytas. Tai darė įkeldama aštuonias pilnas žinutes. Pokalbyje su kelių megabaitų atsakymais tai ta pati problema mažesne forma. Dabar ji duomenų bazei uždaro klausimą: ar šioje žinutėje yra turinio? – neįkeliant paties turinio.
Kaip patikrinome
Pokalbio kopijoje, veikiant su senąja 384 MiB riba:
- Pirmas puslapis yra 5 MB. Anksčiau ta pati užklausa nulaužė API.
- Penki naršyklės skirtukai, atidarę pokalbį vienu metu, pasiekia 154 MB atminties piką.
- Slenkant atgal per visą pokalbį, grąžinamos visos 190 žinučių, kiekviena tiksliai po vieno karto, eilės tvarka.
Puslapių parinkimo logika taip pat turi vienetų testų, įskaitant testą, sukurtą iš tikrųjų naujausių to pokalbio žinučių dydžių. Pirmasis puslapis grąžina vieną žinutę, nurodo, kad yra daugiau, o kitas puslapis prasideda nuo jos sekančios.
Ką iš to išmokome
- Skaičius nėra dydžio riba. Bet kuris galutinis taškas, grąžinantis „naujausias N“ kažko, kas gali neribotai augti, taip pat reikalauja baitų biudžeto. Duomenys galiausiai suranda spragą.
- Akivaizdus įtariamasis ne visada yra priežastis. Diegimas buvo tikras paskatinimas papildomam darbui, o daugiau atminties buvo verta turėti. Bet avarija atsikartojo be diegimo, ir be atkūrimo būtume sustoję prie atminties padidinimo.
- Du serveriai kartu sugenda, kai gauna tą pačią užklausą. Atsarga apsaugo nuo vieno serverio žūties. Ji neapsaugo nuo užklausos, kuri nužudo bet kurį serverį, kurį pasiekia – ypač tokios, kurią vartotojas natūraliai bandys dar kartą, perkraudamas puslapį.
- Taisykite konfigūracijoje, ne tik serveryje. Atminties padidinimas tą pačią dieną buvo įrašytas į diegimo konfigūraciją, todėl kitas diegimas jį išsaugojo.
Tai jau antra pastarojo meto atminties istorija, susijusi su agento išvestimi. Pirmoji – būsenos etiketė, kuri į eilę įdėjo 690 MB eilučių. Jei Mythex naudojate subagentus, apie tai, kaip jie veikia, galite paskaityti dokumentuose.