Oeps, jodiBooks was traaaaag

Deel 2: het technische verhaal

We hebben ons bij het oprichten van jodiBooks voorgenomen om radical transparency toe te passen. We willen graag eerlijk vertellen wat we doen, waarom we dat doen, welke beslissingen we maken en wat er zoal wel of niet goed gaat.

Dat betekent vandaag dat ik ga toelichten wat er op technisch vlak aan de hand was met de traagheid van jodiBooks. En wat ik allemaal gedaan heb om een eerste versnelling voor elkaar te krijgen.

Photo by Andy Beales on Unsplash

jodiBooks is net nieuw, hoe kan het dan langzaam zijn?

Joep heeft er al een stukje van toegelicht in deel 1.

Het advies wat ik ter harte genomen heb voor jodiBook is dit: Optimize later. Zet een goeie architectuur neer en zoek daarna waar de performance issues zitten. Andersom is veel moeilijker. Een snelle website waar de broncode een ontzettende zooi is, is haast onmogelijk aan te passen. En een langzame website waarvan de broncode een zooi is zien te versnellen… vergeet het maar. Ofwel, eerst een goede architectuur, daarna de performance.

Dus. Ik zal niet zeggen dat ik niet her en der YAGNI geschonden heb (soms is een techniek toepassen gewoon te leuk…), maar ik heb mijn best gedaan. Ik heb me gericht op een flexibel datamodel, een modulair ontwerp en heel veel automatische unit- en integratietests.

“Real knowledge is to know the extent of one’s ignorance.” — Confucius

Performance? Ach, performance. Joh, zit wel snor toch? Op mijn laptop draaide het als een tierelier. Over een half jaar zouden we er wel naar kijken. Of zoiets. Daar heb ik, achteraf bezien, de fout gemaakt: Ik testte op mijn ontwikkelbakkie met één klant en maar een handjevol facturen per keer. Performance testing had vanaf het begin in het testplan gemoeten.

Een onderbuikgevoel

Ik had niet veel informatie om op af te gaan. Het was bij onze klant langzaam. Op mijn laptop niet. Hm… Wij hebben uiteraard op jodiBooks.com ook een eigen account. Daarop kan ik inloggen om dingetjes te controleren of uit te proberen. Ik heb daarop ingelogd. Ook daar zag ik geen probleem, ik kon gewoon bonnetjes invoeren. Waar zit het verschil? Mogelijk in de hoeveelheid data…

Toegegeven, dat had ik niet meteen in de gaten. In de weken ervoor had ik hoogstens een keer opgemerkt dat onze informatie-pagina niet goed laadde. De hoofdpagina deed het prima. Het enige verschil wat ik kon bedenken tussen deze twee pagina’s, is dat de informatie-pagina wat prijzen toont. Deze prijzen komen uit onze database. Omdat de informatie-pagina verder helemaal niks zwaars doet, dacht ik toen nog dat er een storing bij de webhoster was.

Op maandag 18 juni belde de klant met de mededeling dat het onwerkbaar langzaam was. Toen zat ik nog in fase één van het rouwproces: Ontkenning. Ik contacteerde opnieuw de webhoster en vroeg hen na te gaan waar het probleem zat. Maar het zat me helemaal niet lekker. En hoe meer uren er verstreken, hoe meer tijd ik had om scenario’s in gedachten af te spelen.

Op dinsdag had ik een heel slecht gevoel erover. Het was nog steeds traag. Het was dus niet alleen de informatie-pagina die langzaam was. Onze klant kon geen bonnen invoeren. Maar wat opviel: De informatie-pagina was vooral langzaam op momenten dat onze klant was ingelogd..! Ik kreeg een naar idee: de informatie-pagina was zo langzaam, omdat de database het heeeeeel druk had. En geen tijd meer had om die paar prijzen op te halen.

Er zat iets goed mis.

En langzaamaan begon het me te dagen. Ik begon een sterk vermoeden te krijgen waar het probleem zat. Het zat waarschijnlijk voornamelijk in een heel specifiek stukje van de database. Tijdens het ontwikkelen van dat stukje (ik vertel zo wat het is), had ik ergens wel opgepikt dat er performance problemen konden optreden. Maar het was niet echt bewust bij me binnengekomen, eigenlijk. Ik was veeeel te optimistisch geweest. Fase twee van het rouwproces begon: Ik werd boos. Op mezelf. Want dit had ik achteraf bezien kunnen weten. Maar ja… achteraf is altijd makkelijk praten.

Maar met deze theorie konden we aan de slag. Als de problemen zaten waar ik dacht dat ze zaten, dan werd het probleem steeds zichtbaarder hoe meer klanten, producten, diensten en vooral bonnen je hebt ingevoerd.

Performance testing

Ik ging eerst op zoek naar een tool die voor mij automatisch en herhaaldelijk pagina’s op kon vragen. Ik wilde daarmee de eenvoudigste vraag beantwoorden: Hoe lang duurt het? Van Gatling werd ik blij. Gatling is eenvoudig in gebruik en toont de testresultaten visueel. Fijn 🙂

Gatling performance test results: Indicators before (left) and after (right) our performance improvements

Zoals in de vorige post al vermeld, hebben we drie datasets klaargezet, variërend in grootte. Vervolgens hebben we meerdere tests opgezet. Elke test haalt een pagina 20x op. Bijvoorbeeld de hoofdpagina of informatie-pagina. Maar belangrijker nog: het overzicht van je inkomsten en uitgaven. En ook de pagina’s waarmee je je inkomsten of uitgaven invoert. Bijna elke pagina hebben we wel getest.

Joep is mijn Excel-held. Hij heeft de informatie bij elkaar gebracht, grafieken geplot en zo zichtbaar gemaakt wat er zoal gebeurde qua geheugengebruik, response tijden en disk swaps. En met de Gatling resultaten vonden we de pagina’s met de grootste problemen, zodat we die als eerste aan konden pakken.

Wat meteen opviel was dat het invoeren van je inkomsten inderdaaddramatisch meer resources vroeg als je 1000 facturen had. Dat niet alleen, het ophalen van het overzicht was ook langzaam. Het printen ook. En wat Joep ook al opmerkte: Het verschil tussen 10 en 100 was niet zo groot, maar van 100 naar 1000 was dramatisch.

Profiling

Profile, profile, profile

Behalve het inhuren van een ontwikkelteam, wil ik als het budget het toelaat zeker te weten een licentie van JetBrains’ dotTrace Profiler. Man, wat werkt dat ding goed. Wat nog het mooiste is, is dat je 10 effectieve dagen krijgt om dotTrace uit te proberen. Je kunt je proefperiode pauzeren! Dagje gebruiken, en weer uit die proeflicentie, tot de volgende dag dat je hem nodig hebt. Geweldig licentiemodel! 🙂

Nadat Gatling een eerste hint had gegeven waar de grootste pijnpunten zaten, ben ik een voor een de pagina’s afgegaan die problemen hadden, om te profilen waar de meeste tijd in zat. Mijn vermoeden werd bevestigd.

Entity Framework en het Table per Type (TPT) model

Voor jodiBooks gebruiken we ASP.Net MVC5 met Entity Framework 6. Gewoon, vanilla, proven technology.

Het mooie van Entity Framework is Code First. Je modelleert je classes en EF genereert automatisch migraties. Het voordeel is dat je puur Domain Driven Design kan doen. In ons geval had ik inheritance ingezet, met behulp van het Table per Type model. Daarbij maak je gebruik van overerving om data te delen.

In ons geval zag dat er als volgt uit. Een inkomstenfactuur en een uitgavenfactuur lijken nogal op elkaar. Er staat op welk product of welke dienst er geleverd is (of gaat worden) en aan wie. Bovendien houdt jodiBooks bij hoe er betaald moet worden en of er al betaald is. Ik had dus een basisklasse ‘factuur’ gemaakt en de inkomsten en uitgaven daarvan over laten erven. Dat niet alleen, ik had ook de concept facturen aan gemaakt als aparte klasses.

(Je kunt je afvragen of ik niet een boolean vlag IsDraft had kunnen maken. Ik heb daar niet voor gekozen omdat ik het wijzigen van facturen in de method signatures al wilde verbieden. Ik weet niet of ik dat nu nog een keer zo zou doen… maar voor een eventuele migratie hebben we nu geen tijd, we hebben andere prioriteiten. Deze beslissing dragen we dus met ons mee.)

So anyway. We hebben vier klasses (inkomsten, concept inkomsten, uitgave, concept uitgave) die allemaal overerven van ‘factuur’.

Hier zat het grootste pijnpunt: Op het moment dat je Invoices.OfType<X>() gebruikt, genereert Entity Framework een gruwelijke query met een bak aan joins. Alleen al het opbouwen en naar de server sturen van die query duurt lang, maar het queryen zelf ook.

Ik zag er weinig heil in om het database design overnieuw te doen. Hoe krijgen we het sneller dan? Een veelbelovende techniek is om af te stappen van LINQ to Entities voor het queryen. In feite werk je dan om Entity Framework heen. Je neemt de volledige controle over de queries en bouwt deze zelf op. Een voorbeeld van hoe dit te doen is door gebruik te maken van Dapper ORM. Ik vond het een prima startpunt, dus dat is wat ik gedaan heb.

Daarnaast valt er winst te behalen in het gebruiken van een indexed view. Ik heb de database misschien iets te enthousiast genormaliseerd… Door spaarzaam een indexed view of twee te introduceren, halen we nog meer winst. Voor het opbouwen van de overzichtspagina’s van inkomsten en uitgaven wordt nu een redelijk platte view opgebouwd met alleen het hoogstnodige. In ons geval: De datum, de klant of leverancier, de betaalwijze, het bedrag zonder btw, het bedrag met btw en de btw zelf. Wil je precies weten wat er op de factuur staat, dan moet je naar de detail pagina. Zonde om die gegevens op de overzichtspagina ook te laden, als je ’t toch niet laat zien.

Composite keys en indexes

Op zich voegt Entity Framework voor de primary keys al een clustered index toe. Toch valt er nog wat te winnen. Al is het echt heel weinig vergeleken met de dramatische verbetering van de vorige sectie.

We gebruiken composite keys voor veel van onze data. Een combinatie van een CustomerId en een Id voor het record. Met het toevoegen van een index op CustomerId, zag ik een lichte performance verbetering.

Moet je per se alles tegelijk zien? Paginering is een goed idee 🙂

Met het oplossen van de grootste bottlenecks, valt er nog iets meer uit te persen door niet alle resultaten tegelijk te laten zien. Inkomsten en uitgaven tonen nu 50 records per pagina. In release 1.3 zullen ook de prijslijst, het klantenbestand en leveranciersbestand 50 stuks per pagina tonen.

Third party libraries

Als je een standaard ASP.Net MVC5 project aanmaakt, krijg je er ‘gratis’ allerlei troep bij. Knikker ApplicationInsights er even uit als je daar toch (nog) niks mee doet 😉

Ook kun je System.Web.Mvc.Ajax uit de Web.config van je Views wegdonderen, als je er niks van gebruikt. Voor jodiBooks gebruiken we sowieso zo weinig mogelijk JavaScript. Filosofie: zonder JavaScript moet het ook werken. En van System.Web.Mvc.Ajax gebruiken we nu niks. Toen ik de laatste profiling slagen aan het doen was, zag ik dat dat toch werd ingeladen. Ongebruikt = weg ermee. Kunnen we later altijd nog toevoegen als we’t wel gebruiken.

Precompiled views

ASP.Net MVC biedt Razor views (*.cshtml) om je views mee op te bouwen. Je merkt het meteen: De eerste keer laden van elke pagina is traag. Dat komt omdat de views pas gecompileerd worden op het moment dat de pagina de eerste keer geladen wordt. Als de Application Pool wordt gerecycled, ben je terug bij af.

Nou is het mogelijk om bij Publishing van je applicatie, de views te precompilen. Maar, ik moet eerlijk zeggen, ik kreeg dat niet naar mijn zin geconfigureerd. Wat ik ook deed, ik kreeg voortdurend *.compiled bestanden in mijn output directory. Erger nog, elke nieuwe publish gaf deze bestanden nieuwe namen (met een unique Guid-achtige constructie). Ik zat er niet zo op te wachten om bij elke publish stap op de server, deze bestanden handmatig weg te moeten halen.

Dus ben ik voor plan B gegaan: Razor Generator. Eenvoudig via NuGet te verkrijgen onder de Apache 2.0 license. Eenvoudig in gebruik: Je markeert alle views zodat Razor Generator ze precompiled naar *.generated.cs files. De *.cshtml files zet je op Build Action = None. Je hoeft ze niet meer te deployen.

Vergeet de Visual Studio Extension niet te installeren als je alle Views met Razor Generator wil doen. Je krijgt daar een leuk commando bij wat alles Views in één klap omzet.

JavaScript is toch wel een béétje zinnig

Dus… Ik heb nogal een aversie ontwikkeld tegen zwaar gebruik van JavaScript. Niet dat ik zelf geen JavaScript applicaties ontwikkeld heb. Je kan er leuke dingen mee. Maar als consument zijnde kom ik te vaak op pagina’s die zo zwaar in de JavaScript zitten dat de pagina alle kanten op knippert, er knoppen verdwijnen of verschuiven als je wil klikken, het boeltje heel traag laadt (en ik heb toch niet bezuinigd op mijn laptop qua specs…) of in het ergste geval: Helemaal niet laadt. Hoe vaak ik wel niet tegen een blanco pagina aankijk als JavaScript uit staat…

Ik heb dus JavaScript op veel plekken vermeden. Maar dit dingetje was misschien niet zo handig: op onze homepage tonen we de laatste 5 berichten van ons WordPress blog. Die berichten laad ik server-side in. En laat dat nou echt opvallend traag zijn. Ik zag het tijdens het profilen voorbij komen, de meeste tijd zit in een HTTP Request naar ons blog. Hmm…

Dat zet ik toch maar om naar een JavaScript scriptje deze week… Dan laadt de homepage sneller en halen we met JavaScript de blog post titels wel op 🙂

Photo by Markus Spiske on Unsplash

Ander klein grut

Na de grootste pijnpunten opgelost te hebben, vond ik door verder te profilen nog een aantal kleinere dingen. Zo heeft EntityFramework een DoSeed methode. Handig, maar doe er niet te veel in zonder te checken of het wel nodig is… Elke keer als je Application Pool start, wordt die methode aangeroepen. En als je webhoster de idle timeout op 5 minuten heeft staan (…) en je die zelf niet aan kan passen omdat je voorlopig nog een shared webhosting pakket hebt, dan heb je dus elke keer die hit te pakken.

Ook checken we als je inlogt of je nog wel een jodiBooks pakket hebt. Als dat verlopen is, krijg je een melding. We checken nog veel meer: Of je je e-mailadres geverifieerd hebt, of je eventueel nog iets moet betalen, etc etc. Dat alles doen we in een Filter wat op elke pagina draait. Daar gaan we nog even wat aan doen, want dat is niet op alle pagina’s per se noodzakelijk.

Oh ja, nog eentje. Misschien zijn onze afbeeldingen iets te hoge resolutie… Vooral op mobile is dat nergens voor nodig. Gaan we ook nog even wat aan doen 😉

Conclusie

Het verbaast me niks dat er heel veel te winnen viel op performance gebied. Ik had me er tenslotte niet echt op gefocust. Eigenlijk mag het ook geen verrassing zijn als je in een optimism bias tuint.

Photo by Ryoji Iwata on Unsplash

Wat me wel een beetje shockeerde is ontdekken hoe zeer dit aspect blijkbaar voorheen door collega’s met affiniteit voor performance werd afgedekt. In elk team zit wel een bit-neuker die een zesde zintuig heeft voor performance problemen. Die persoon was ik zelf nooit. Dat betekent ook dat ik, tot de afgelopen weken, weinig ervaring heb opgedaan met dit aspect van mijn vakgebied. Een hele nuttige les. Alleen erg jammer dat onze klanten daar last van hebben, natuurlijk. Mijn excuses heb ik aangeboden en er werd gelukkig erg begripvol en geduldig gereageerd.

Het lijkt nu allemaal weer onder controle. En vanaf nu draaien we de performance tests standaard in het testplan mee 🙂

Ik ben net zo traag als jodiBooks was als het gaat om blog posts schrijven… In de tussentijd hebben we al twee nieuwe releases gedaan die de problemen verhelpen 🙂

Leave a Reply