Egy ASP.NET MVC optimalizálás története az Azure-on
A NetAcademia weboldala, a netacademia.hu hosszú ideje egy virtuális gépen futott az Azure-ban. Idén elhatároztuk, hogy átköltöztetjük egy webalkalazásra. A költözés rendben lezajlott, és nem is vettünk észre semmi különöset. Aztán érkezett egy nagyobb, de nem igazán nagy terhelés az oldalra, és az oldalunk válaszideje felment egy perc körüli értékekre, gyakorlatilag használhatatlanná vált. Alkalmazásunk jól skálázható, így további szervereket üzembeállítva (vertikális skálázással) viszonylag gyorsan visszaállítottuk a használhatóságot, de a helyzet nem volt megnyugtató. Egy gyors felülvizsgálat mellett döntöttünk.
#CACHE
Az első dolog, ami gyanús volt, az az, hogy a ritkán használt oldalak nagyon lassan töltődtek be, azonban ha egyszer betöltöttük, utána már viszonylag gyorsan bejött újra. Ez a jelenség cache problémára utalt. Mivel az oldal terhelése nem folyamatos, ezért lehetnek 10-20-30 percek, amikor egy oldalt sem töltenek le a látogatók. Ilyenkor az Azure törli az alkalmazást a futtató szerver memóriájából, és csak akkor tölti be ismét, ha újabb kérés érkezik. Mivel a cache-t intenzíven használjuk a gyorsabb válaszidő érdekében, és ilyenkor a nulláról kell felépíteni a gyorsítótárat, ezt nem engedhetjük. így mikor átköltöztünk, akkor a mindig bekapcsolva (Always On) kapcsolót beállítottuk.
Természetesen az ördög ilyenkor ahelyett, hogy aludna, kipattan az ágyból. Ellenőriztük hát ismét, és sajnos azt találtuk, hogy ki van kapcsolva. Talán amikor az automatikus buildet és a telepítési slot-okat állítottuk be valamit elnyomtunk, mivel nem powershell scriptekkel, hanem kattingatásos módszerrel dolgoztunk. Ezzel az egyik rejtélynek sikerült utánajárni.
De ha már elindultunk ezen az úton egy kicsit még nézelődtünk.
#SESSION STATE BLOCKING
Mivel az általános cache beállítások legalább fél óráig nem ürítik a gyorsítótárat, így a terhelés miatti lassulás ezzel a kapcsolóval nem magyarázható.
Ellenőriztük hát a böngészőben az oldalletöltés menetét, és egy ilyet találtunk:
Itt van öt kérés, ami valahogy sokkal tovább tart, mint kéne neki. Ezek oldallekérések, és ha egyesével hívjuk őket, akkor 6-800ms alatt mindegyik véget ér, de ha az oldalról hívjuk valahogy mindig van egy nagyon komoly késleltetés.
Telepítettük a Glimpse nevű remek eszközt, és megnéztük, hogy a kérés ami 3 másodpercig tart, vajon merre jár eközben:
Azt találtuk, hogy a világon semmit nem csinál az idő 90%-ban, csak vár. Majd gyorsan elvégzi a dolgát.
Ekkor már meglehetőse gyanús lett a dolog, hogy olyan, mintha a szerver a párhuzamos kéréseket nem szolgálná ki párhuzamosan, hiába teszünk az oldalra ajax kérést, ami elvileg párhuzamosan a háttérben végrehajtódik, a valóságban úgy tűnik, szépen kivárja a sorát, és csak, ha sorrakerül, akkor válaszol.
Ez pedig az ASP.NET skálázhatóságának Achilles sarkára irányította figyelmünket: ASession State Blocking-ra. Arról van szó, hogy egy kérés során lehetőségünk van a Session nevű helyre adatokat írni. Ez a session egy felhasználóhoz tartozik, tehát akár megtehetjük azt is, hogy két oldalletöltés között egy-egy felhasználónak átmeneti információit rögzítjük. Például az előző oldal adatait, ha éppen szükségünk lenne rá.
Nos, NE TEGYÜK! Ugyanis az ASP.NET MVC alapértelmezésben ezt úgy oldja meg, hogy lock-ot tesz erre a Session nevű tárolóra, így egy felhasználótól csak egy kérés fér egyszerre hozzá. Ezért az azonos felhasználótól érkező kérések szépen sorbaállnak, és egymás után hajtódnak végre.
Nesze neked skálázhatóság, ahogy Marcell mondaná.
Hát, mi Sessionban nem tárolunk, mivel a Session használata házo belül tiltólistán van. Ha egy adatnak két kérés között meg kell maradnia, akkor az a perzisztens adat. És mint ilyennek, az adatbázisban a helye.
Jó. De akkor miért történik mégis ez velünk?
Hát nem elég nem használni ezt a Session-t, erről értesíteni is kell a az MVC keretrendszert, a megfelelő kontrollert dekorálni kell egy erre való attribútummal. Például így, elég, ha szólunk, hogy nem akarunk módosítani a Session állapotán, és kész.
[SessionState(System.Web.SessionState.SessionStateBehavior.ReadOnly)]
public class CalendarController : Controller
{
...
}
Na ez maradt le két kontrolleren valahogy. Miután pótoltuk, változott a helyzet:
#REQUEST PIPELINE
Itt még mindig indoklást kíván, hogy vajon mi az, ami 600ms-ig tart, hiszen egy cache-elt válaszról beszélünk, ez az idő rengeteg, azért.
Gyorsan belenéztünk a Glimpse-sel ismét, hátha elkerülte valami a figyelmünket.
Itt egy olyan filtert látunk, ami minden kérésre lefut, és az egyes kérések alatt 100-300ms-ot használ. Tekintettel arra, hogy a most vizsgált oldalakon nincs rá szükség, gyorsan kiiktattuk. Most ez a helyzet:
Ez még mindig nem a Forma 1 sebesség, de már használható. Nem feledjük el, hogy ez csak egy gyors vizsgálat, nem töltünk napokat az optimalizálással, hanem max egy nap alatt a legnagyobb hibákat megszüntetjük.
Most persze lehetne elmélyülni az egyes kérések lelkivilágában, illetve a cache-t gyorsítani, illetve megvizsgálni, hogy a rengeteg javascript és css állományt hogyan tudnánk minél kevesebb kérésbe szervezni, és CDN-re tenni statikus állományokat - de ezek a következő menetben kerülnek sorra.
Cserében, mielőtt elégedetten hátradőlnénk, azért egy load tesztet elvégzünk, hogy megnézzük, hogy változik a felhasználószám növekedésével az oldalunkat kiszolgáló szerver terhelése.
#LOAD TEST
Ehhez a legegyszerűbb regisztrálni a VSTS-en (Visual Studio Team Services)-on, és itt (sok minden más mellett) 25000 felhasználópercet kapunk ingyen. Csak kell egy jó teszt, de ezt viszonylag egyszerűen össze tudjuk hozni:
- Készítünk egy HTTP archív állományt.
Én Chrome-ot használtam. Ehhez előhívtam a Developer tools ablakot F12-vel, és a naplóállomány megtartását bekapcsoltam a Network fülön.
Majd néhány lépést tettem a teszthez a következő kombóban: főoldal->bejelentkezés->tanfolyami oldal->tanfolyam->kijelentkezés. Ezután jobb egérgomb a napló valamelyik során, és a Save as HAR with content menüpontot választom.
- A VSTS projektemben load testet készítek
Elmegyek a Load Testekhez, és letrehozok egy újat, méghozzá HTTP Archive based test-et, ahova feltöltöm az előbb létrehozott állományomat. Majd átváltok a beállításokra,
és beállítok egy olyan 5 perces tesztet, ahol 60 felhasználóval indulok, és 15 másodpercenként hozzáadok 60 felhasználót. Így eljutok 1000-ig, ahol a felhasználószám növelésben megállok.
Elindítom a tesztet, majd miután lefut, láthatom a teszteredményből, hogy alkalmazásunk rendes válaszidővel elérhető maradt:
Gyorsan megnézzük még, hogy mindeközben az Azure-on:
Szemmel láthatóan megterheltük a szervert, de nem terheltük túl:
És mivel ez egy szerveren így néz ki, és skálázódunk terhelés alapján legfeljebb 10 node-ig, az alkalmazásunk a napi terhelést bírni fogja.
https://gplesz.github.io/ASP.NET-opt/