Inleiding
In de afgelopen periode hebben we bij Tweakers diverse belangrijke stukken code herschreven. Eerder dit jaar is ons vernieuwde reactiesysteem geïntroduceerd, en in april en mei zijn we bezig geweest om het karmasysteem te herschrijven.
Daarbij is niet alleen de functionaliteit aangepakt, ook de prestaties van de componenten zijn opnieuw bekeken. Bij beide systemen zijn de prestaties bij het gebruik van de database dominant voor de totale benodigde tijd. Een goede vorm om de data op te slaan, met de juiste indices, en het efficiënt uitlezen van de gegevens zijn dus erg belangrijk. Voor beide voorbeelden gebruiken we onze centrale MySQL-database.
Op internet zijn allerlei uitspraken over de snelheid van sql-databases te vinden. In de afgelopen jaren zijn er bijvoorbeeld allerlei NoSQL-databases verschenen en vaak worden dan een of meer negatieve aspecten genoemd die met een dergelijke nieuwe database beter gaan. Vaak wordt gesteld dat het voldoen aan alle vier de componenten van acid de prestaties en schaalbaarheid in de weg zit. Daarbij worden dan vooral consistency en isolation zwakker uitgewerkt in die systemen.
Ook de complexe queries die met sql mogelijk zijn, worden genoemd; die mogelijkheid zou slecht zijn voor de prestaties en bovendien helemaal niet nodig zijn. Het kunnen join-en van tabellen en de mogelijkheden voor subqueries zijn de vaste voorbeelden daarbij. Het gaat dan soms zelfs zover dat in één adem geadviseerd wordt om die zaken ook te vermijden als je wél sql blijft gebruiken. Het zou dan eveneens slecht zijn voor de prestaties en lastig te verenigen zijn met sharding-functionaliteit. Het advies is dan om de queries zo simpel mogelijk te houden en liefst alleen via de primary key in een tabel te zoeken. Complexe handelingen, zoals het combineren van data, kunnen tenslotte ook in de applicatielaag gedaan worden.
Als je een object relational mapper gebruikt, zoals Doctrine, dan wordt dat advies effectief standaard opgevolgd. Bij een orm moet er meestal zelfs moeite gedaan worden om van dat model af te wijken. Een join gebruiken betekent vaak extra code toevoegen.
Hoewel het altijd verstandig is om code eenvoudig te houden, is het wel zonde als daardoor grote prestatiewinsten blijven liggen. De adviezen over sharding van data zijn tenslotte alleen relevant als dat echt gebruikt wordt. Als er geen sharding wordt gebruikt, is het juist handig om data uit diverse queries in de database al te koppelen. Dat kan flink wat overhead schelen; denk aan de losse round-trips van query en data, rechtenchecks en allerlei aanverwante handelingen die dan in de database minder vaak gedaan hoeven te worden.
Er is daarbij natuurlijk wel verschil tussen de diverse platforms. Op sommige platforms, waaronder veel soorten applicaties in java-applicatieservers, kunnen orm's gegevens in het geheugen van de applicatie cachen. In zulke gevallen worden al round-trips bespaard, maar dat valt of staat dan met de effectieve hitratio van de cache en de kwaliteit van de synchronisatie met de database. De hier gegeven adviezen gelden vooral voor toepassingen waarbij dergelijke caches niet praktisch zijn, zoals meestal bij php het geval is.
Wat ons betreft valt de database van Tweakers met 219GB trouwens niet in de orde van grootte om over te stappen op sharding of andere technieken om verscheidene servers tegelijk te gebruiken. Overigens hebben we er wel op diverse manieren voor gezorgd dat de MySQL-database niet voor alle gegevens gebruikt hoeft te worden. We hebben er bijvoorbeeld voor gekozen om bepaalde stukken data in MongoDB op te slaan of in memcached te cachen en veel van de informatie wordt via onze Java-omgeving opgevraagd.
In dit artikel beschrijven we twee toepassingen waarbij de databaseprestaties voor ons belangrijk waren. Daarbij laten we stapsgewijs een aantal optimalisaties zien die significante verbeteringen in de prestaties gaven. Bedenk wel dat dit artikel niet gaat over het plaatsen van de juiste indices of het optimisaliseren van de instellingen van een database. Dit is een parallelle taak waarbij bekeken wordt of de database wel optimaal ingezet wordt, maar een goed geoptimaliseerde tabelstructuur en database zijn uiteraard ook belangrijk voor goede prestaties.
Queries voor de reacties
Volgens sommige bezoekers zijn de reacties onder artikelen vaak nog interessanter dan het artikel zelf. Daarom hebben we veel moeite gedaan om de reacties efficiënt op te slaan en uit de database te kunnen halen, zodat we direct al reacties bij een artikel kunnen tonen. Bovendien leidt snelle verwerking tot betere schaalbaarheid en ruimte om bijvoorbeeld verschillende sorteringsopties en paginagroottes aan te bieden.
Doordat de reacties in boomvorm worden gepresenteerd, is het niet triviaal om te bepalen wat de beste manier van opslaan in een relationele database is. Bij de eenvoudigste opslagvariant, ook wel adjacency list genoemd, worden reacties domweg met een parent id opgeslagen en recursief uitgelezen om de child nodes op te halen. Daarnaast zijn er complexere modellen, zoals de nested set. Dat laatste model is vooral nuttig als er relatief veel gelezen wordt en daarbij complete deelbomen - de volledige boom is ook een deelboom - worden opgehaald. Het nadeel van nested sets is dat ze behoorlijk complexe logica nodig hebben en bovendien bij elke nieuwe node gemiddeld de helft van alle nodes moeten aanpassen.
Omdat deelbomen voor ons niet relevant zijn en de insert performance juist wel, hebben we het simpel opgelost. We slaan bij elke reactie niet alleen de id van de reactie erboven op, maar bovendien die van het artikel waarbij de reactie is geplaatst. De tabel ziet er in beginsel als volgt uit:
Kolom | Type |
id |
int not null primary key |
parentId |
int null default null |
articleTypeId |
int not null |
articleId |
int not null |
userId |
int not null |
insertTime |
datetime not null default current_timestamp |
content |
text not null |
Het articleTypeId is bij ons nodig omdat we een centrale opslag van de reacties willen, maar geen gecombineerde nummering voor nieuws, reviews en andere artikelen.
Met deze structuur kunnen we met één select statement alle reacties op een artikel ophalen. De reacties worden daarna in de php-code in een boomstructuur geplaatst, in de goede volgorde gezet en vervolgens gepagineerd getoond. Dat leidt ertoe dat we standaard 'te veel' reacties ophalen, maar het heeft verder tot gevolg dat we veel makkelijker kunnen sorteren en pagineren. De sortering op reacties kijkt bijvoorbeeld niet alleen naar de score van een specifieke reactie, maar ook naar de scores van alle subreacties.
Hieronder beschrijven we een standaardimplementatie voor een dergelijk reactiesysteem met daarna een aantal optimalisaties voor het benaderen van de database. Daarbij gaan we ervan uit dat de orm voor elk gerelateerd object, zoals de gebruiker die een reactie schreef, opnieuw de database moet benaderen, tenzij anders vermeld. Daarbij laten we overigens alle implementatiedetails weg uit de code, het gaat hier puur om de basis van het systeem en welke queries er in de database worden gedaan. Sterker nog, de voorbeeldcode is niet bedoeld om perfect aan allerlei codestandaarden te voldoen of om zelfs maar te werken, hij dient louter als illustratie.
Voor de bespreking van prestaties gaan we steeds uit van een nieuwsbericht met 153 reacties. Dat is op Tweakers een heel gebruikelijk aantal en dus lang niet het populairste artikel. De sinterklaasacties van de afgelopen drie jaar hadden met respectievelijk 4.464, 4.041 en 5.299 veel meer reacties. Daarom nemen we ook die laatste mee om een reëel worst case scenario te kunnen geven.
Standaardimplementatie met orm
We beginnen met een eenvoudige standaardimplementatie. Voor elke reactie moet de orm de gebruiker en de eventuele subreacties los ophalen. Daarnaast willen we natuurlijk kunnen tonen of een reactie al is beoordeeld. Daarom moet ook het eventueel gegeven oordeel per reactie worden opgehaald.
function displayArticleComments(Article $article, User $currentUser)
{
// SELECT * FROM comments
// WHERE articleId = ? AND articleTypeId = ? AND parentId = 0
$comments = $article->getComments();
displayCommentList($comments, $currentUser);
}
function displayCommentList($comments, User $currentUser)
{
foreach($comments as $comment)
{
// SELECT * FROM users WHERE id = ?
$user = $comment->getUser();
// SELECT * FROM ratings WHERE commentId = ? AND userId = ?
$givenRating = $comment->getRating($currentUser);
displayCommentBody($user, $comment, $givenRating);
// SELECT * FROM comments WHERE parentId = ?
$subComments = $comment->getSubComments();
// Recursion
if( ! empty($subComments))
displayCommentList($subComments, $currentUser);
}
}
De vraagtekens in bovenstaande queries zijn de standaardnotatie voor prepared statements. Op die plek moet nog de waarde van een variabele worden ingevoegd. Of er daadwerkelijk prepared statements worden gebruikt is voor de prestaties overigens niet heel relevant.
Bovenstaande implementatie is lekker simpel. De queries zijn kort en voldoen perfect aan de eerder genoemde adviezen om ze join-vrij te houden. De prestaties ervan zijn echter niet zo goed; er zijn naast de initiële query nog drie queries nodig voor elke reactie. Dat komt neer op 460 queries in totaal en met het artikel met 5.299 reacties zelfs op 15.898 queries!
Optimalisatie 1: geen recursie
Zoals eerder gezegd bouwt de reactiecode van Tweakers zelf de boomstructuur op en is ook de bij reacties op diepere niveaus bekend bij welk artikel ze zijn geplaatst. Het is daarom niet nodig om de reacties recursief uit de database te halen: dat scheelt een query voor iedere getoonde reactie. Het totaal komt dan op 307 queries voor het normale voorbeeld en een nog altijd forse 10.599 bij het grote voorbeeld. De implementatie slaan we over, die is bijna hetzelfde als de code van de volgende optimalisatie. Overal waar een select list staat, kan een * gedacht worden 
Optimalisatie 2: alleen selecteren wat nodig is
De 'select * from' in de bovenstaande twee varianten heeft tot gevolg dat er onnodig data wordt opgehaald. Zo is vooral bij de gebruikers veel te besparen. Alleen de nickname, het abonnementstype en de avatar zijn nodig, terwijl er veel meer kolommen in onze gebruikerstabel zitten. De code ziet er ongeveer zo uit:
function displayArticleComments(Article $article, User $currentUser)
{
// SELECT id, userId, aggregatedScore, insertTime,
// lastUpdateTime, parentid
// FROM comments WHERE articleId = ? AND articleTypeId = ?
// Note: getComments returns a tree of comments with full nesting
$comments = $article->getComments();
displayCommentList($comments, $currentUser);
}
function displayCommentList($comments, User $currentUser)
{
foreach($comments as $comment)
{
// SELECT nickname, showSubscriptionBadge, subscriptionId, avatar
// FROM users WHERE id = ?
$user = $comment->getUser();
// SELECT score FROM ratings WHERE commentId = ? AND userId = ?
$givenRating = $comment->getRating($currentUser);
// No query here
$subComments = $comment->getSubComments();
displayCommentBody($user, $comment, $givenRating);
// Recursion
if( ! empty($subComments))
displayCommentList($subComments, $currentUser);
}
}
De winst bij deze optimalisatie is overigens beperkt. Er wordt weliswaar minder data van de database naar de php-code verstuurd, maar het totale aantal queries is gelijk gebleven aan dat van de vorige optimalisatie.
Batchqueries voor de reacties
Het is ook mogelijk om fors minder queries uit te voeren. In de onderstaande voorbeelden gaan we ervan uit dat de orm ook een bulk-modus heeft en later zelfs aan de hand van joins verscheidene objecten tegelijk kan maken. Daardoor kan dan een grote hoeveelheid gebruikers en beoordelingen aan de hand van een lijst van id's opgehaald worden uit de database.
Optimalisatie 3: bulk load
Als alle reacties, gebruikers en gegeven beoordelingen in bulk worden opgehaald zijn er slechts drie queries nodig. Sql kent daarvoor de in-operator. Daarvoor is dan natuurlijk wel vereist dat de reacties twee keer doorlopen worden, eerst om alle relevante id's te verzamelen en later nog een keer voor de weergave van de boom. De code ziet er ongeveer zo uit:
function displayArticleComments(Article $article, User $currentUser)
{
// SELECT id, userId, aggregatedScore, insertTime,
// lastUpdateTime, parentid
// FROM comments
// WHERE
// articleId = ? AND articleTypeId = ?
// Note: getComments returns a tree of comments with full nesting
$comments = $article->getComments();
// SELECT id, nickname, showSubscriptionBadge, subscriptionId, avatar
// FROM users WHERE id IN(?, ?, ...)
$userList = $article->getUsers($comments);
// SELECT commentId, score, insertTime FROM ratings
// WHERE commentId IN(?, ?, ...) AND userId = ?
$ratings = $article->getRatings($comments, $currentUser);
storeCommentData($comments, $userList, $ratings);
displayCommentList($comments, $currentUser);
}
function displayCommentList($comments, User $currentUser)
{
foreach($comments as $comment)
{
// No query here
$user = $comment->getUser();
// No query here
$givenRating = $comment->getRating($currentUser);
// No query here
$subComments = $comment->getSubComments();
displayCommentBody($user, $comment, $givenRating);
// Recursion
if( ! empty($subComments))
displayCommentList($subComments, $currentUser);
}
}
Bovenstaande code levert een forse besparing in de hoeveelheid queries op. Er zijn er nog slechts drie in totaal, ongeacht hoeveel reacties er zijn. Het is bovendien nog steeds in de geest van de originele serie tips: geen joins gebruiken en bij voorkeur alleen via de primary key uitlezen.
Optimalisatie 4: joins gebruiken
Wat als we toch joins gebruiken? Het totale aantal queries kan dan terug naar één. Vergeleken met de vorige variant zal de database niet eens zo veel minder werk hebben, maar in de php-code zelf wordt ook nog de extra loop bespaard en die twee round-trips naar de database minder zijn eveneens mooi meegenomen.
De code wordt uiteindelijk zoiets:
function displayArticleComments(Article $article, User $currentUser)
{
// SELECT c.id, c.userId, c.aggregatedScore, c.insertTime,
// c.lastUpdateTime, c.parentid,
// u.nickname, u.showSubscriptionBadge, u.subscriptionId, u.avatar,
// r.score, r.insertTime as ratingTime
// FROM comments c
// JOIN users u ON u.id = c.userId
// LEFT JOIN ratings r ON r.commentId = c.id AND r.userId = ?
// WHERE
// articleId = ? AND articleTypeId = ?
// Note: getComments returns a tree of comments with full nesting
// Note: getComments also returns user-objects and ratings for the current user
$comments = $article->getComments();
displayCommentList($comments, $currentUser);
}
function displayCommentList($comments, User $currentUser)
{
foreach($comments as $comment)
{
// No query here
$user = $comment->getUser();
// No query here
$givenRating = $comment->getRating($currentUser);
// No query here
$subComments = $comment->getSubComments();
displayCommentBody($user, $comment, $givenRating);
// Recursion
if( ! empty($subComments))
displayCommentList($subComments, $currentUser);
}
}
Resultaten
De vraag is nu natuurlijk welke van deze vijf varianten het snelst is. Maakt het echt wat uit of er veel of weinig queries worden gedaan? En die join, helpt die ook nog?
Om dat te bepalen hebben we de tijd gemeten die ieder van de vijf varianten nodig heeft in onze testomgeving. Daar gebruiken we onze voormalige master-databaseserver met een volledige kopie van de productiedata. Hoewel die server ondertussen ruim vijf jaar oud is, zijn de prestaties nog behoorlijk vergelijkbaar met die van onze actuele productiedatabase. Belangrijk daarbij is dat (ook) deze server, net als in productie, ruim voldoende geheugen heeft om alle reacties in het ram-geheugen te houden en vrijwel dezelfde configuratie heeft voor Linux en MySQL.
Zoals hierboven te zien is, zijn de laatste twee varianten significant sneller. Bij het artikel met 153 reacties is de bulk load met join zelfs ruim zeventig keer zo snel. Bij de variant met 5.299 reacties is het verschil iets kleiner. Met bijna 49 keer zo snel is dat echter nog steeds zeer significant. Daarbij gaat die grote variant van een voor de gebruiker onacceptabele wachttijd van 11 seconden naar een heel aardige 0,2 seconde.
Het blijft natuurlijk wel belangrijk om dit soort aanpassingen goed bij de beoogde toepassing te laten passen. Als er inderdaad sharding wordt gebruikt of de tabellen nog veel groter zijn, dan kan de bulk load, zeker met joins, wel degelijk minder efficiënt blijken te zijn. Wellicht is dan een variant op de drie losse bulk loads handiger.
Het advies om joins te vermijden en de queries zo simpel mogelijk te houden is echter in ons voorbeeld geen goed advies. Het kost niet alleen in de php-laag meer tijd, ook de database heeft het daardoor uiteindelijk drukker.
Karma: eenvoudige berekening
Zoals in de inleiding van dit artikel is genoemd hebben we ook aan ons karmasysteem gewerkt. Een belangrijke wijziging is dat we de hoeveelheid karma per onderwerp - tag, categorie, merk, serie of product - wilden kunnen bepalen. De code was echter al behoorlijk oud en de opslag was niet geschikt voor dit scenario. Daarom hebben we het moment aangegrepen om de code te herschrijven en de criteria voor de te verdienen karma te actualiseren. Zo zijn diverse berekeningen vereenvoudigd en geharmoniseerd, en er zijn zelfs een paar bugs in de berekeningen gefikst 
Doordat we ook het verdiende karma per onderwerp uit het verleden wilden weten, moesten we al het karma met terugwerkende kracht opnieuw berekenen. In de ruim vijftien jaar dat Tweakers bestaat, hebben gebruikers er echter tientallen miljoenen bijdragen op geplaatst. Dat is normaal gesproken natuurlijk erg tof, maar het vereist bij dit soort berekeningen wel extra aandacht. Met een verkeerde aanpak kan het zomaar verschillende dagen of weken duren voordat een berekening klaar is.
Aangezien we nog niet wisten of de vernieuwde criteria voor de karmaberekeningen goede uitkomsten zouden geven, was het extra belangrijk om de berekening efficiënt te maken. Het is tenslotte niet fijn als je er na een paar dagen rekenen achterkomt dat er een aanpassing nodig is en je opnieuw moet wachten…
In deze en de volgende pagina's beschrijven we hoe we het voor elkaar hebben gekregen dat al die data in slechts twee à drie uur kan worden doorgerekend. Ook hier was weer de kern: verminder het aantal queries en zorg ervoor dat de database zo weinig mogelijk data naar de client stuurt.
Let op: de voorbeelden hieronder en op de volgende pagina's zijn bedoeld als illustratie. Ze geven ongeveer aan hoe een en ander algoritmisch werkt, maar tonen niet exact welke scores in ons karmasysteem worden gegeven. De uitkomsten uit de berekeningen van dit artikel zullen dan ook verschillen van de uitkomsten die we in de productieomgeving geven 
Berekeningen voor ratings
Het eerste voorbeeld is eenvoudig, dat gaat bijvoorbeeld over de beoordelingen van gebruikers van productreviews en/of reacties. Voor iedere rating wordt in deze voorbeeldimplementatie domweg één punt gegeven. De originele implementatie aanpassen zodat er altijd één per moderatie zou worden gegeven, zou zoiets hebben opgeleverd:
function storeRatingKarma()
{
$result = $mysqli->query("SELECT id, userId, time FROM ratings");
while($row = $result->fetch_assoc())
{
// Store 1 karma per rating
$mysqli->query("INSERT INTO per_item_karma
(itemType, itemId, userId, time, karma)
VALUES(15, ?, ?, ?, 1)"
);
}
}
Erg efficiënt is het niet en dat is ook te zien in de benchmarks.
Optimalisaties 1 en 2: transacties en prepared statements
De originele code gebruikte geen prepared statements voor het gebruikte insert-statement en werkte ook niet in een transactie. Wel of geen transactie is op zich niet zo belangrijk voor de prestaties, maar dat autocommit dan ook uitgeschakeld wordt wel. Beide worden vaak genoemd als manier om dit soort operaties te versnellen. In werkelijkheid haalden beide veranderingen weinig uit voor de looptijd van de code. Of dat een speciale oorzaak heeft, hebben we verder niet onderzocht.
Optimalisatie 3: inserts groeperen
MySQL biedt de mogelijkheid om insert-statements te groeperen door verschillende rijen tegelijk te specificeren. Een dergelijke query ziet er dan zo uit:
INSERT INTO per_item_karma
(itemType, itemId, userId, time, karma)
VALUES
(15, 5, 141, 11412121, 1),
...,
(15, 6, 631, 11412122, 1);
Er is op zich geen limiet aan het aantal rijen dat zo kan worden gecombineerd, zolang de query maar binnen een maximale grootte blijft. Andere databases kennen soortgelijke mogelijkheden; PostgreSQL heeft bijvoorbeeld naast de bovenstaande insert-mogelijkheid ook nog het copy-statement. In php-code hebben we dit getest met honderd en duizend rijen gecombineerd tot één insert-statement. De code daarvoor is helaas wat complexer en er moet ook rekening worden gehouden met het feit dat er niet altijd een veelvoud van honderd of duizend rijen zal zijn:
function storeRatingKarma(mysqli $mysqli)
{
$result = $mysqli->query("SELECT id, userId, time FROM ratings");
$rows = array();
$i = 0;
while($row = $result->fetch_assoc())
{
$rows[] = "(15, ?, ?, ?, 1)";
if(++$i % 1000 == 0)
{
// Store 1 karma per rating with 1000 ratings at a time
$mysqli->query("INSERT INTO per_item_karma
(itemType, itemId, userId, time, karma)
VALUES " . implode(",", $rows));
$rows = array();
}
}
// Store the last few rows
if( ! empty($rows))
{
$mysqli->query("INSERT INTO per_item_karma
(itemType, itemId, userId, time, karma)
VALUES " . implode(",", $rows));
}
}
Dit levert uiteraard een reductie van het totale aantal inserts op, met respectievelijk een factor 100 of 1000.
Optimalisatie 4: insert + select gebruiken
Het kan echter met nog minder queries. SQL kent ook de mogelijkheid om een select en een insert direct aan elkaar te koppelen. De query daarvoor is in dit voorbeeld erg eenvoudig:
INSERT INTO per_item_karma
(itemType, itemId, userId, time, karma)
SELECT 15, id, userId, time, 1
FROM ratings;
Resultaten van optimalisaties
Om te bekijken wat de diverse varianten deden, hebben we ook deze op de testomgeving uitgevoerd. We hebben het beperkt tot een test met 100.000 en een met 500.000 beoordelingen. In werkelijkheid zijn er bijna 6 miljoen beoordelingen gegeven, maar omdat deze twee resultaten vrijwel lineair opschaalden, hebben we om tijd te besparen die test niet gedaan.
Hier zien we opnieuw een groot verschil tussen de variant met veel losse queries en de gegroepeerde varianten. De beste tijd werd neergezet door de gecombineerde insert+select. Naast de winst in snelheid is ook de eenvoudigere query een welkome bijkomstigheid. Bovendien maakt het voor de insert+select niet uit hoe snel de verbinding tussen de php-code en de database is; de bijbehorende gegevens worden tenslotte niet tussen de php-code en de database heen en weer gestuurd.
Karma: complexere berekening
Op de vorige pagina toonden we een triviale karmaberekening voor de gegeven beoordelingen, maar niet al onze karmaberekeningen zijn zo simpel. Gaat dezelfde truc op voor complexere varianten? Is MySQL bij complexere berekeningen net zo snel als php?
In de praktijk konden we al onze karmaberekeningen omzetten naar sql. Daarbij hielp het dat de berekeningen al vereenvoudigd werden. Voor de reacties werd bijvoorbeeld voorheen een berekening gebruikt waarbij er verschil zat tussen een reactie die door minimal één 'expert moderator' was beoordeeld en een andere reactie. Verder werd een aantal correcties toegepast om te voorkomen dat een +2-reactie uiteindelijk slechter kon uitkomen dan een +1-reactie. Dat soort dingen hebben we uit de berekeningen gegooid - het laatste is nu in het geheel niet meer mogelijk - en de uiteindelijke versie werd daardoor een stuk behapbaarder.
Implementatie in php
De php-implementatie is uiteindelijk ongeveer dit geworden:
function storeCommentKarma()
{
$result = $mysqli->query("SELECT id, userId, time, score, message
FROM comments");
while($row = $result->fetch_assoc())
{
$karma = calculateCommentKarma($row['score'], $row['message']);
$mysqli->query("INSERT INTO per_item_karma
(itemType, itemId, userId, time, karma)
VALUES(3, ?, ?, ?, ?)");
}
}
function calculateCommentKarma($score, $message)
{
$config = [
-2 => ['base' => -10, 'perChar' => 0.01],
-1 => ['base' => -5, 'perChar' => 0.01],
0 => ['base' => 0, 'perChar' => 0.01],
1 => ['base' => 5, 'perChar' => 0.02],
2 => ['base' => 10, 'perChar' => 0.04],
3 => ['base' => 15, 'perChar' => 0.08]
];
$messageLength = strlen($message);
$karma = $config[$score]['base']
+ ($messageLength * $config[$score]['perChar']);
return min($karma, 100);
}
Kortom, de te verdienen hoeveelheid karma hangt af van de lengte van de reactie, maar de precieze waardering per lengte hangt weer van de ontvangen score af.
Implementatie in sql
Bovenstaande code kon vrij eenvoudig in sql worden omgezet. Bij sql is het lastig om tussenresultaten te bewaren en is het gebruikelijk om daarom met één statement te werken. Alle handelingen daarin combineren komt de leesbaarheid echter niet altijd ten goede. Eventueel is het mogelijk om deelresultaten te bewaren via temporary tables of om functionaliteit te verhuizen naar stored procedures. Het nadeel van stored procedures is dat daarmee ook programmacode in de database terechtkomt. In dit geval kon de leesbaarheid met praktische formatting, het toevoegen van commentaar en het gebruik van een subselect op orde blijven.
INSERT INTO per_item_karma
(itemType, itemId, userId, time, karma)
-- Outer stage, per score calculation
SELECT
3,
id,
userId,
time,
-- Generic maximum for all scores
LEAST(100,
-- Per score calculation of karma
CASE score
WHEN -2 THEN
-10 + (messageLength * 0.01)
WHEN -1 THEN
-5 + (messageLength * 0.01)
WHEN 0 THEN
(messageLength * 0.01)
WHEN 1 THEN
5 + (messageLength * 0.02)
WHEN 2 THEN
10 + (messageLength * 0.04)
WHEN 3 THEN
15 + (messageLength * 0.08)
END
) -- End LEAST
FROM
(
-- Inner stage, basic data preparation
SELECT
id,
userId,
time,
score,
CHAR_LENGTH(message) AS messageLength
FROM
comments
) as commentBase
Resultaten php versus sql
Net als met de eenvoudige berekening van karma wordt hiermee een aanzienlijk aantal queries bespaard. Hoewel de database hier ook het concrete rekenwerk moet verrichten, is het in dit geval alsnog significant sneller.
Het is consistent ongeveer een factor 60 sneller. Doordat in de php-variant alle reacties in het geheugen worden geladen - MySQL stuurt standaard direct alle resultaten voor een select-statement naar de client - is bovendien de php-variant niet erg schaalbaar. Er komt een punt waarop er niet genoeg ram beschikbaar is om het proces verder te laten lopen.
Nog complexer?
Bovenstaande uitwerking is niet de meest complexe karmaberekening die we hebben. Zo wordt bij de berekening voor productreviews ook rekening gehouden met het aantal afbeeldingen en beoordeelde criteria die zijn toegevoegd. Ook dat kan uiteindelijk in één sql-statement worden toegevoegd. Dat hebben we opgelost met het toevoegen van subselects.
-- Inner stage, basic data preparation
SELECT
id,
userId,
time,
score,
CHAR_LENGTH(message) AS messageLength,
(SELECT COUNT(*) FROM reviewimages i
WHERE i.reviewId = r.id) as imageCount
FROM
productreviews r
Ook hebben we berekeningen waarbij bleek dat MySQL het toch niet zo efficiënt kon als we hadden gehoopt. Dat lag in alle gevallen aan het feit dat de selectie van data zelf al behoorlijk lastig was geworden, bijvoorbeeld doordat er diverse zoekcriteria werden gecombineerd. Bij productreviews wordt bijvoorbeeld de lijst van reviews die zelf zijn bijgewerkt gecombineerd met de reviews die nieuwe beoordelingen door gebruikers hebben gekregen. Uiteindelijk werd dan het combineren van die criteria met de uitgebreidere berekening wat te veel van het goede.
Gelukkig was ook dat vrij eenvoudig op te lossen door het selectieproces los te halen en het resultaat eerst in een temporary table op te slaan. Dat kan bijvoorbeeld met zo'n soort opbouw:
CREATE TEMPORARY TABLE reviewsToProcess
(
PRIMARY KEY (id)
)
SELECT id FROM productreviews
WHERE updateTime > NOW() - INTERVAL 20 DAY
UNION
SELECT id FROM reviewsratings
WHERE insertTime > NOW() - INTERVAL 20 DAY;
INSERT INTO per_item_karma
(itemType, itemId, userId, time, karma)
-- Outer stage, per score calculation
SELECT
...
FROM
(
-- Inner stage, basic data preparation
SELECT
...
FROM
productreviews r
JOIN reviews_to_process p ON r.id = p.id
) as reviewData;
Door de selectie van reviews te verhuizen naar een temporary table kan MySQL zich voor de complexere berekenquery beperken tot het uitlezen van de primary key van de productreviews-tabel. Voor de liefhebbers: de union in de eerste select zorgt ervoor dat er alleen unieke id's worden bewaard in de temporary table. Daardoor komt er gegarandeerd geen duplicate key error uit die query 
Conclusie
Na de voorgaande pagina's zijn er diverse conclusies mogelijk. De sql-statements worden met de verdere optimalisaties steeds sneller, maar ook steeds wat complexer.
Dat laatste zal moeten passen bij het team dat de code onderhoudt. Voor ons was dat geen probleem. We, ik in het bijzonder, houden ons vrij veel met databases en de prestaties daarvan bezig. Het optimaliseren van code geeft een zekere voldoening en dat geldt net zo goed voor (groepen van) queries.
Ik vind het bovendien een leuke uitdaging om bij ad-hocverzoeken voor data uit onze database - zoals: "Hoeveel nieuwsberichten publiceren we tussen 8:00 en 10:00u 's ochtends?" - dat met uitsluitend sql-statements op te lossen. Het liefst natuurlijk met één statement dat binnen een paar seconden klaar is. Dat heeft ook een praktisch nut; het scheelt een ad-hoc-(php-)script schrijven, waarbij het leesbaar uitspugen van de gevraagde data lang niet altijd even eenvoudig is. Databasetools als phpmyadmin kunnen resultaten van queries vaak direct als csv- of xls-bestand aanleveren.
Het karma-project was mij door de hoeveelheid data op het lijf geschreven. Tijdens dat project bleek wel dat collega's het wat minder logisch vonden om alles in puur sql te doen. De statements zijn af en toe schermvullend en daardoor minder eenvoudig te begrijpen dan reguliere programmacode. Programmacode heeft (meestal) als voordeel dat het conceptuele model ervan beter bij onze eigen logica past. Bovendien hebben programmeertalen faciliteiten om die logica op te delen. Als dat goed wordt gedaan is concentreren op details net zo makkelijk als het krijgen van een overzicht.
Dat kan helaas niet zo makkelijk met sql. Resultaten kunnen stap voor stap worden opgebouwd met tijdelijke tabellen en via subselects worden gegroepeerd, maar het blijft lastig om daar goed mee om te gaan. Er zijn ook nog valkuilen, zoals het cartesisch product, dat je kunt krijgen met onjuist uitgevoerde joins of het feit dat een group-by-statement tot gevolg heeft dat kolommen hun onderlinge relatie per rij verliezen. Overigens kan het cartesisch product ook nuttig zijn, als er juist wel voor elke combinatie van tabel A en B iets gedaan moet worden.
Desondanks vond ik de prestatiewinst van het gebruiken van puur sql bij het karmasysteem zodanig dat ik die opzet verder heb uitgewerkt. Er waren bij de laatste testrun ruim 50 miljoen items die karma hebben opgeleverd. Daaruit volgden ruim 30 miljoen dagelijkse samenvattingen per gebruiker en 20 miljoen maandelijkse samenvattingen per onderwerp. Ondanks die enorme aantallen kon de sql-code dat in zo'n 2 à 3 uur doorrekenen. En hoewel we nu veel meer informatie uit deze resultaten kunnen halen, zoals wie de meeste karma heeft verzameld bij het onderwerp Software Development, is dit aanzienlijk minder tijd dan de oude code nodig zou hebben om alles opnieuw te berekenen.
Kortom, als de snelheid van het selecteren en/of verwerken van data belangrijk is, volg dan de onderstaande tips, naast het optimaliseren van het datamodel, de queries en de database zelf.
Beperk het aantal statements. Er zijn daarbij uiteraard wel grenzen aan wat zinvol is en/of nog goed past bij de gebruikte code. Het simpelweg in bulk 'preloaden' van data in een orm-cache kan al een zeer effectieve optimalisatie zijn. Verscheidene selects door middel van joins groeperen kan nog extra tijd besparen, maar is misschien lastig te integreren. Het in bulk opslaan van data bespaart meestal veel tijd, in onze voorbeelden scheelt het zelfs een factor 60.
Dit is ook bij updates en deletes mogelijk, hoewel daarbij wel goed opgelet moet worden dat het eindresultaat hetzelfde blijft. Als een record bijgewerkt moet worden, maar toegevoegd als het nog niet bestond, zijn upsert-commando's erg handig, zoals: 'insert .. on duplicate key update' in MySQL.
Mochten de queries langer duren dan gehoopt, dan kan het vaak ook helpen om het werk in delen te splitsen. Door bijvoorbeeld eerst een selectie van data op te slaan in een temporary table kan MySQL vaak weer wel efficiënt werken. Dat proces kan ook herhaald worden met verscheidene temporary tables.
Laat de database vooral doen waar hij goed in is. Complexere queries vereisen weliswaar meer voorbereiding door de database, maar als anders datzelfde werk in de applicatielaag moet worden uitgevoerd kan het toch de nodige overhead besparen. Laat ook data in de database als die verder niet (echt) wordt aangepast in de applicatielaag, maar slechts elders in diezelfde database moet worden opgeslagen. De database kan met een insert+select, update+join/subquery, delete+join/subquery of create-table+select veel efficiënter data kopiëren dan een applicatie dat kan.