Guide:
Optimering av websider
Når man lager nettsider, ender man av og til opp med sider som tar lang tid å laste. Her er noen tips for hvordan du kan unngå slike ting.
Side 1: Introduksjon

Når man utvikler dynamiske websider i PHP er det viktig at sidene er raske å laste. Selv om det svært ofte er veldig lite arbeid som må gjøres for å sende en side til en bruker, kan du raskt få ytelsesproblemer når du begynner å få mange brukere.
I denne forholdsvis korte guiden skal vi kikke på litt forskjellige metoder og programmeringspraksiser du bør forsøke å unngå når du lager dynamiske websider. Vi skal også kikke litt på hvordan du kan optimere MySQL-databaser til å gjøre hurtigere oppslag.
Legg merke til at du ikke trenger å optimere koden din dersom du ikke opplever hastighetsproblemer. Optimering gjør ofte koden din mindre leselig, og leselig kode er en svært god ting. Dette er heller på ingen måte en komplett guide til optimering, og det finnes helt sikkert svært mange triks man kan bruke utenom det vi forteller om her. Denne guiden har derimot som mål å få deg i optimeringshumør.
Dette går vi igjennom i denne guiden;
Side 2: Finn flaskehalsen
Finn flaskehalsen
I de aller fleste tilfeller når man blir nødt til å optimere, er det en enkel del av skriptet som forårsaker problemene. Det er ikke alltid det er like lett å finne ut nøyaktig hvilken del av skriptet dette er, siden problemet gjerne kan være kamuflert i kode. En av prosessene hvor man prøver å finne flaskehalser kalles profilering.
Profilering går ut på å måle hvilken del av koden programmet tilbringer mest tid i. Programmeringsspråk har svært ofte innebygde profileringsverktøy, men det er ingen slike som blir levert sammen med PHP. Du kan derimot installere noen, slik som Xdebug. Mange foretrekker likevel å lage sitt eget system, og den enkleste måten å gjøre dette på i PHP er ved hjelp av microtime-funksjonen.
<?php
$start = ((float) array_sum(explode(" ",microtime())));
for ($i = 0; $i < 100000; $i++)
{
$sum += $i;
}
$end = ((float) array_sum(explode(" ",microtime())));
echo ($end-$start);
?>
Dette er den helt klart enkleste måten å gjøre det på selv.
Mer avanserte skript
Et annet alternativ er å bruke en større tidtakingsklasse, som det finnes mange forskjellige utgaver av. Det som følger er en kort beskrivelse av hvordan man bruker en klasse som undertegnede har skrevet selv. Ved å bruke dette objektet, kan du samle opp kjøretider fra flere gjennomkjøringer. Du kan også markere deler av koden din du vil ta tiden på, og objektet genererer statistikk til deg på slutten.
Vi har lagt ut kildekoden til denne måleklassen, og vil gi deg et enkelt eksempel på hvordan den brukes. Koden gjør en enkel addisjonsoperasjon en million ganger, i steg på hundretusen operasjoner. For hver hundretusende operasjon setter den et tidsmerke, som indikerer at en ny tidsperiode begynner.
<?php
require_once('timer.php');
$t = new Timer(true, true);
$z = 0;
for ($i = 0; $i < 10; $i++)
{
$t->mark($i+1);
for ($j = 0; $j < 100000; $j++)
{
$z++;
}
}
$t->stopClock();
$t->printStats();
?>
Etterhvert som du kjører dette skriptet flere ganger, vil statistikken inneholde mer og mer data, siden dataene blir mellomlagret i en cookie mellom hver kjøring. Når vi hadde kjørt dette skriptet 3 ganger, satt vi igjen med følgende tabell:
Simulation took a total of 0.9681 seconds.
| Set 1 | Set 2 | Set 3 | Average | |||||
|---|---|---|---|---|---|---|---|---|
| Mark | Start | Runtime | Start | Runtime | Start | Runtime | Start | Runtime |
| 1 | 0.0001 s | 0.0001 s | 0.0001 s | 0.0001 s | 0.0001 s | 0.0001 s | 0.0001 s | 0.0001 s |
| 2 | 0.0968 s | 0.0968 s | 0.0979 s | 0.0978 s | 0.0978 s | 0.0977 s | 0.0975 s | 0.0974 s |
| 3 | 0.1930 s | 0.0961 s | 0.1936 s | 0.0957 s | 0.1945 s | 0.0966 s | 0.1937 s | 0.0962 s |
| 4 | 0.2898 s | 0.0969 s | 0.2904 s | 0.0969 s | 0.2900 s | 0.0956 s | 0.2901 s | 0.0964 s |
| 5 | 0.3873 s | 0.0975 s | 0.3871 s | 0.0967 s | 0.3885 s | 0.0985 s | 0.3876 s | 0.0975 s |
| 6 | 0.4847 s | 0.0974 s | 0.4837 s | 0.0967 s | 0.4941 s | 0.1056 s | 0.4875 s | 0.0999 s |
| 7 | 0.5830 s | 0.0983 s | 0.5795 s | 0.0957 s | 0.5872 s | 0.0931 s | 0.5832 s | 0.0957 s |
| 8 | 0.6812 s | 0.0982 s | 0.6763 s | 0.0968 s | 0.6815 s | 0.0943 s | 0.6797 s | 0.0964 s |
| 9 | 0.7785 s | 0.0973 s | 0.7742 s | 0.0979 s | 0.7782 s | 0.0967 s | 0.7769 s | 0.0973 s |
| 10 | 0.8732 s | 0.0947 s | 0.8703 s | 0.0962 s | 0.8729 s | 0.0947 s | 0.8721 s | 0.0952 s |
| End | 0.9689 s | 0.0957 s | 0.9656 s | 0.0953 s | 0.9681 s | 0.0952 s | 0.9675 s | 0.0954 s |
Kolonnene Set 1, Set 2, Set 3 angir de tre forskjellige gangene vi kjørte skriptet. Start-kolonnen inneholder tidspunktet det gitte tidsmerket ble nådd, og Runtime angir når det ble forlatt. Det første tidsmerket vårt var fra vi startet klokken, til vi var inne i den ytterste loopen. Om vi studerer resultatene, ser vi at på denne serveren brukes det omtrent 100ms å legge sammen hundretusen tall, mens det brukes omtrent et sekund å gjøre en million tall. En relativt ubrukelig statistikk, synes du ikke?
NB: Siden vi har svært observante brukere på forumet, så vil noen helt sikkert oppdage at vi ikke ikke tar tiden på en million addisjonsoperasjoner i skriptet over, men faktisk minimum 2.000.020 addisjonsoperasjoner. Ser du hvorfor?
Rask oversikt over metoder
Det er gjerne kjekt å ha en rask oversikt over metodene du kan kalle på tidtakingsobjektet. Om du ser på kildekoden har klassen andre metoder i tillegg, men disse er laget kun for internt bruk.
-
Timer($fStartNow, $fUseCookies)
Timer($fStartNow)
Timer()
Oppretter objektet. Ved å sette$fStartNow = true, starter tidtakingen med en gang.$fUseCookiesangir om objektet skal ta vare på innsamlede data mellom kjøringer (objektet tar kun vare på 4 tidligere kjøringer). Begge variablene er satt tilfalsesom standard.
Merk at dersom du benytter deg av$fUseCookies, må denne kalles før skriptet har skrevet ut noe som helst. -
startClock()
Starter klokken med en gang, dersom den ikke allerede er startet. -
stopClock($fPrintStats)
stopClock()
Stopper klokken. Om du setter$fPrintStatstiltrueskrives statistikken ut med en gang, standard erfalse -
mark($sName)
mark()
Lager et tidsmerke.$sNameangir navnet på merket. Uten parameter brukes nummeret på tidsmerket som blir satt. -
printStats()
Skriver ut statistikk fra denne (og evt. tidligere) kjøringer. -
clearStatistics()
Fjerner eventuell statistikk som er lagret i cookies. Legg merke til at denne må kalles før skriptet har skrevet ut noe som helst.
Side 3: Bruk caching
Bruk caching
Caching, på norsk gjerne kalt nærlagring, brukes svært ofte i websammenheng. En webserver skal gjerne servere mange tusen brukere i løpet av få minutter, og da er det viktig at ikke bruker for mye tid på å gjøre de samme oppgavene om og om igjen. Dette løses med caching.
Dine PHP-sider blir normalt generert på nytt hver gang noen besøker siden. Dersom siden din bare endrer seg med jevne mellomrom, er det ikke noe poeng at PHP bruker prosesseringstid på å hente ut rader i en database hver gang en bruker ber om å få se siden, siden radene i databasen sannsynligvis er de samme som når forrige bruker hentet siden.
Ved å lagre en kopi av siden slik den ser ut når PHP-skriptet har kjørt, kan man spare mye prosesseringstid. Dette finnes flere måter å gjøre på, den enkleste er bare å lagre en kopi av siden det er snakk om som en .html-fil i samme katalog. I stedet for å peke brukere til minside.php, peker du dem til minside.html.
På Linux-baserte webservere kan du blant annet bruke verktøyet wget eller PHP fra kommandolinjen til å prosessere nettsiden med jevne mellomrom. Det gjør du ved hjelp av en cron-job. Hvor ofte cachingen skal skje justerer du etter hvor viktig du mener at det er at siden er helt oppdatert. Erfaring tilsier at et bra intervall kan være alt fra hvert 5. minutt til hvert 10. sekund.
Du kan også bygge caching inn i programmet ditt, ved å lagre en ny versjon av nettsidene til disk hver gang du gjør en endring i databasen som påvirker den siden. Dette må gjøres på en intelligent måte, og kan være uaktuelt dersom du har svært mange som kan endre databasen.
Hva bør caches?
Forsiden på nettsiden din er en god kandidat til caching. På de aller fleste nettsider, er forsiden den mest besøkte siden, og det er derfor viktig at det ikke brukes mye tid på å gjøre klar denne siden. Sider som sjelden endres, er også lurt å cache. Blant annet er forsiden på alle sidene i nettverket cachet.
Om du presenterer artikler og nyhetssaker, kan det og være en idé å cache de individuelle artiklene, men pass på at eventuelle kommentarer som vises på siden blir gjort synlig med en gang. Om en bruker legger inn en kommentar og den ikke kommer opp når han besøker siden igjen etterpå, er det stor risiko for at han poster kommentaren sin på nytt, eller blir irritert fordi siden din "mistet kommentaren hans."
Sider som inneholder navnet på brukeren du er innlogget som, kan være vanskelige å cache. Her må man passe på at man ikke viser en bruker en cachet versjon som inneholder navnet til en annen bruker.
Side 4: Nestede løkker
Unngå nestede løkker
Løkker i ulike former kan være et svært nyttig verktøy i mange sammenhenger, og om man bruker disse rett er de svært raske. Derimot er det lett for å lage strukturer slik at man må ha flere løkker inni hverandre, noe som kan være svært tregt. Et typisk eksempel på dette er følgende kodesnutt;
<?php
for ($i = 0; $i < count($array1); $i++)
{
for ($j = 0; $j < count($array2); $j++)
{
$array1[$i] += $array2[$j];
}
}
?>
Om du tester dette med tabeller som hver har under 100 verdier i seg, vil ikke det ta spesielt lang tid å kjøre denne kodesnutten. Derimot vil det ta svært lang tid dersom mengden verdier plutselig en dag blir tidoblet, da kjøretiden vil vokse kvadratisk.
Med 100 verdier i hver tabell vil du bare måtte gå gjennom 10 000 kjøringer av den innerste løkken. Med tusen verdier i hver tabell, stiger det tallet til en million. Med ti tusen verdier i hver tabell må man gjennom den innerste delen av koden hundre millioner ganger.
Det er svært få problemer som krever at du faktisk benytter deg av to løkker inne i hverandre, og svært mange slike problemer kan omskrives. Løsningen ovenfor kan skrives om på flere forskjellige måter, den første er med en enkel løkke;
<?php
$sumArray2 = array_sum($array2);
for ($i = 0; $i < count($array1); $i++)
{
$array1[$i] += $sumArray2;
}
?>
Dersom du har lyst til å skrive kode som kan være litt mindre leselig, men gjør akkurat det samme, kan du også skrive det slik som under. Legg merke til at selv om du på denne måten ikke skriver en for-løkke eksplisitt i programmet ditt, vil array_map-metoden oversette dette til en for-løkke internt.
<?php
$sumArray2 = array_sum($array2);
function add($n)
{
global $sumArray2;
return $n + $sumArray2;
}
$array1 = array_map("add", $array1);
?>
Vi testet å kjøre disse forskjellige variantene av den samme koden på to tabeller som hver inneholdt 1000 verdier, og vi målte tiden det tok å kjøre koden 1000 ganger. Den første koden ble ikke ferdig innen 30-sekundersgrensen som PHP legger på kjøringen av skript, mens de to neste ble ferdig på henholdsvis 1.5 og 3.9 sekunder.
Legg merke til at bruken av array_map alltid er tregere enn en enkelt for-løkke. array_map er en funksjon som er hentet fra deklarativ programmering, hvor man normalt ikke har løkker på samme måten som i PHP.
Unntak
Doble løkker er ikke nødvendigvis alltid farlig. Dersom du vet at den ytterste løkken aldri kjører mer enn 30 ganger; f.eks. dersom du henter ut 30 rader fra en database. Om du samtidig vet at den innerste løkken også aldri kjører mer enn et visst antall ganger, vil du til en viss grad være trygg.
Derimot, dersom du kan erstatte doble løkker med en enkel løkke, eller to enkle løkker på samme nivå, vil du i lengden spare mye prosessorkraft.
Side 5: Rekursive funksjoner
Rekursive funksjoner
Rekursive funksjoner, eller funksjoner som kaller seg selv, kan virke svært attraktive ved første øyekast. Men i de aller fleste tilfeller er rekursjon i PHP noe du bør forsøke å unngå.
Iterative funksjoner, dvs. funksjoner som benytter seg av løkker istedenfor rekursjon, er i de aller fleste tilfeller raskere enn sine rekursive motparter. Viktigst av alt er at det alltid er mulig å skrive om en rekursiv funksjon til en iterativ funksjon.
Det aller vanligste eksempelet å bruke her, er Fibonacci-tallene. Rent matematisk er Fibonacci-tallene en rekke som begynner med 0 og 1, og resten av tallene er summen av de to foregående tallene. De første 14 tallene i rekken blir da 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144 og 233.
Å skrive en rekursiv funksjon som beregner disse tallene er svært enkelt;
<?php
function recursiveFibonacci($n)
{
if ($n == 1) return 0;
elseif ($n == 2) return 1;
else return (recursiveFibonacci($n - 1) + recursiveFibonacci($n - 2));
}
?>
Å bruke denne funksjonen til å regne ut det 25. Fibonacci-tallet tar omtrent 0,3 sekunder. Det 30. tallet tar 3,6 sekunder, og det 35. tallet tok hele 39,5 sekunder. Det 36. tallet brøt 60-sekundersgrensen for kjøretid (legg merke til at PHP som standard har denne grensen satt ved 30 sekunder).
Hvorfor er recursiveFibonacci så rask på de første 25 tallene, men likevel så treig på det 35. tallet? Svaret ligger i de rekursive kallene. Om vi ser på hvor mange ganger recursiveFibonacci-funksjonen blir kalt når vi spør etter det femte Fibonacci-tallet, ser man det enkelt;

Hver firkant i dette treet representerer ett kall til recursiveFibonacci, og for det femte Fibonacci-tallet ser vi at funksjonen blir kalt 9 ganger. For å finne det sjette Fibonacci-tallet, må vi kalle funksjonen 15 ganger, for å finne det syvende må vi kalle funksjonen 25 ganger, og så videre. Tallet øker fort, og vi hadde ikke hatt plass til å tegne opp et slikt tre for recursiveFibonacci(8).
Hva gjør man?
Det første man kan gjøre for å gjøre den rekursive funksjonen raskere, er å innføre minne. Funksjonen husker da verdier den har regnet ut tidligere, og man kan da kutte omtrent halvparten av treet ovenfor. Koden blir da som følger;
<?php
function recmemFibonacci($n, &$mem = Array(0, 1))
{
if (isset($mem[$n-1]))
return $mem[$n-1];
else
{
$new = recmemFibonacci($n-1, &$mem) + recmemFibonacci($n-2, &$mem);
$mem[$n-1] = $new;
return $new;
}
}
?>
Denne nye funksjonen gjør at det ikke lenger vil være vanskelig å regne ut det 35. Fibonacci-tallet, og utregningen følger fremdeles det rekursive prinsippet. Forskjellen er at vi har innført et minne som husker alle Fibonacci-tallene vi har regnet ut tidligere, og det er denne tankegangen vi ønsker å følge.
Ved å tenke litt på hva det egentlig er vi gjør, ser vi at utregningen også kan gjøres ved hjelp av en for-løkke istedet for rekursjonen;
<?php
function itermemFibonacci($n)
{
$mem = Array(0, 1);
for ($i = 2; $i < $n; $i++)
{
$mem[$i] = $mem[$i-1] + $mem[$i-2];
}
return $mem[$n-1];
}
?>
Denne versjonen er svært mye raskere enn recursiveFibonacci, og gjør det også svært lett å finne hele Fibonacci-rekken frem til f.eks. det 35. Fibonacci-tallet. Utregningen av det 100. Fibonacci-tallet var også øyeblikkelig med denne funksjonen, selv om PHP skifter over til normalform når den oppgir så store tall.
Dersom du skal ha flere tall i Fibonacci-rekken er funksjonen over den enkleste, men man kan likevel dra litt mer ut av maskinen dersom man bare skal ha et eneste Fibonacci-tall. Ved å la være å lagre mer enn de 2 siste Fibonacci-tallene når man kjører funksjonen, sparer man noe minne.
<?php
function iterativeFibonacci($n)
{
$oldest = 0;
$old = 1;
for ($i = 2; $i < $n; $i++)
{
$new = $old + $oldest;
$oldest = $old;
$old = $new;
}
return $new;
}
?>
Side 6: MySQL-spørringer
MySQL-spørringer
De aller fleste som programmerer i PHP, kommer på et eller annet tidspunkt innom MySQL. Når man bruker databaser i forbindelse med PHP, er hovedregelen at man skal forsøke å bruke databasens egne funksjoner der det er mulig.
Bruk COUNT() for å finne antall
Dersom du ikke trenger dataene radene returnerer, men vil ha antallet rader, så ikke fall for fristelsen å bruke mysql_num_rows-funksjonen. En dårlig måte å finne antall rader på, er å be MySQL returnere alle radene, og deretter bruke mysql_num_rows til å telle hvor mange rader som er returnert;
<?php
$result = mysql_query("SELECT * FROM tabell", $link);
$antall = mysql_num_rows($result);
?>
En mye bedre måte å gjøre dette på, er ved å bruke følgende spørring;
<?php
$result = mysql_query("SELECT COUNT(*) AS antall FROM tabell", $link);
$antall = mysql_result($result, 0, 'antall');
?>
Dette er raskere av flere grunner. Først og fremst slipper MySQL å hente frem alle radene i tabellen. På grunn av måten MySQL lagrer tabeller på, kan denne spørringen slås opp direkte i beskrivelsen av tabellen, hvor antallet rader er lagret. Om du har en WHERE-klausul i SQL-spørringen blir det noe verre for MySQL å slå opp antallet, men det er fremdeles raskere enn å sende alt over til PHP. I større serverparker er ofte MySQL-serveren på en fysisk annen server enn webserveren som PHP kjører på. Når du da kjører den første spørringen på en tabell med 200MB data, må alle disse 200 megabytene overføres til webserveren over nettverket.
mysql_num_rows har derimot sine bruksområder. Dersom du f.eks. har en spørring med en LIMIT-klausul, der du ber om å få tilbake 30 rader, kan det ofte være en fordel å vite hvor mange rader du fikk igjen. Dersom det ikke fantes 30 rader som stemte overens med din spørring, kan det være du bare fikk 5 rader i retur, og da er mysql_num_rows en god måte å sjekke dette på.
Pass på indekser
De tabellene som du kjører flest spørringer på, bør ha indekser på kolonner som ofte finnes i WHERE-klausuler. Indekser gjør det svært mye raskere for databasen å slå opp radene du leter etter. Indeksering er et tema som man kan bruke svært lang tid på for å finne et optimalt oppsett, så vær sikker på at du har brukt nok tid på dette.
Samle opp INSERT-spørringer
Om du skal sette inn mange rader i en tabell, kan det være en fordel å samle opp alle disse innsettingene til en stor spørring som du kjører mot slutten av skriptet. MySQL, og de fleste andre databasesystemer, støtter innsetting av flere rader i samme tabell i den vanlige INSERT INTO-syntaksen.
INSERT INTO tabell(kol1, kol2, kol3)
VALUES ('verdi1a', 'verdi1b', 'verdi1c'),
('verdi2a', 'verdi2b', 'verdi2c'),
('verdi3a', 'verdi3b', 'verdi3c')
Dette kan være mye raskere om MySQL-serveren er en annen fysisk server, siden man da slipper å vente på overføring over nettverket for mange spørringer. Ved å bruke spørringen over, trenger du bare å vente på nettverksforsinkelser den ene gangen du utfører spørringen, når alternativet er å vente på nettverksforsinkelsen ganger antallet rader du setter inn.
Om det er ikke er nødvendig at dataene du setter inn blir tilgjengelig for andre med en gang, kan du bruke INSERT DELAYED-syntaksen i MySQL.
Bruk INSERT DELAYED
Om dataene du setter inn i en tabell ikke er livsviktige, og du regelmessig setter inn små mengder data, kan det være en fordel å bruke INSERT DELAYED i MySQL. INSERT DELAYED godkjenner spørringen din med en gang, men venter med å sette dataene inn i databasen. Når tabellen du setter inn i ikke lenger blir brukt av andre, samler MySQL opp alle INSERT DELAYED-spørringene, og skriver dem ut til disk samlet.
Dette har selvsagt en bakdel. Dersom MySQL-serveren krasjer før den får skrevet dine utsatte INSERT-spørringer til disk, er disse spørringene tapt. Du bør derfor ikke bruke denne metoden på data som er kritisk at du beholder. Data som kan lages på nytt senere uten spesielt mye prosessering er derimot en god kandidat til INSERT DELAYED.
Du kan lese mer om INSERT DELAYED i MySQL-manualen.
Side 7: Oppsummering
Oppsummering
Denne guiden dekker langt fra alle tenkelige situasjoner man kan komme opp i når det gjelder dårlig ytelse. Når du utvikler din nye supernettside i PHP, er dette ting du bør tenke på;
- Benytt profileringsverktøy for å finne ut hvilken del av koden som går sakte.
- Cache de delene av siden som ikke endrer seg ofte.
- Bruk en egnet og rask algoritme.
- Forsøk å unngå lange nestede løkker.
- Forsøk å unngå rekursive funksjoner, skriv om til iterative.
- Bruk databasens innebygde funksjoner så ofte som mulig.
- Samle opp innsettingsspørringer når det er mulig.
Det finnes mange ting å tenke på, men kanskje den viktigste er at du finner den beste algoritmen til å løse et gitt problem. Optimering bør ikke gjøres før man ellers er ferdig med koden, siden optimering ofte etterlater uryddig kode.
Har du andre tips som kan hjelpe andre å optimere PHP- og MySQL-kode? Tips brukerne på forumet.