Diviser les temps de calcul informatique par 10 ou 20, simplement
Une application web classique fonctionne toujours avec la même logique séquentielle:
Un utilisateur se trouve sur un site web et demande une information,
La demande d'information est reçue par le serveur, ce dernier "va chercher l'information demandée' puis renvoie l'information souhaitée à l'utilisateur
le navigateur de l'utilisateur affiche l'information demandée.
A chaque fois qu'il interagit avec l'application, l'utilisateur va donc constater, ou non, une certaine lenteur entre sa demande et le rafraîchissement de sa page.
Théoriquement, nous avons 3 situations et 3 moments bien distincts où une requête peut perdre du temps, mais empiriquement, j'ai constaté que 99% des problèmes de lenteur ressentis par les utilisateurs sont liés à l'étape 2 décrite plus haut, plus précisément à des calculs pour assembler l'information demandée qui prennent juste plus de temps que nécessaire.
Le premier réflexe "humain" quand un serveur met du temps à faire certains calculs, c'est de muscler le serveur (plus de mémoire, plus de puissance processeur, etc). Je pense que c'est un très mauvais réflexe pour au moins trois raisons :
Il ne règle pas le mal à la racine
Il fait exploser les coûts
Il fait exploser l'empreinte carbone du logiciel.
Ma conviction personnelle est que dans 99% des cas d'usages d'une application web, un simple serveur peut gérer les demandes de 100 000 utilisateurs sans problème, c'est à dire leur renvoyer l'information demandée en moins d'une seconde.
Pour améliorer une application, le premier besoin est de savoir où sont les problèmes de lenteur.
Identifier les temps de calculs trop longs
Il existe de nombreux services qui promettent d'identifier les lenteurs, que ce soit lors du développement ou dans un environnement de production. Je n'en suis pas friand, car ils consistent souvent à tout "fliquer", ralentissant de fait l'application, et c'est une dépendance informatique de plus à gérer...
Je vous propose une solution alternative très simple et très efficace : mettre en place un middleware (c'est à dire un petit script qui sera exécuté à chaque fois qu'un utilisateur fera une demande au serveur) dont le boulot sera de mesurer le temps passé entre la requête de l'utilisateur et la réponse du serveur.
Voici un exemple avec le framework Laravel
// ici il y a le code du middeware par défaut
// ...
//
// On ajoute à la fonction terminate() le code suivant
// pour qu'il s'exécute quand la réponse est envoyée.
public function terminate($request, $response)
{
$timeMeasure = round(microtime(true) - LARAVEL_START,1);
// A noter que LARAVEL_START est une variable définie
// par Laravel à chaque requête. A adapter selon vos besoins.
$scopedUri = Str::startsWith($request->getRequestUri(), '/nova/') ? 0 : 1;
// Ici on peut réduire le périmètre qui nous intéresse,
// ou exclure certaines requêtes.
if (defined('LARAVEL_START') && $request instanceof Request && $scopedUri) {
// On a défini dans app.threshold_for_logs une constante pour le seuil,
// par exemple '1' pour 1 seconde
if ($timeMeasure > config('app.threshold_for_logs') ) {
// Si le seuil est dépassé, on enregistre le temps avec plusieurs propriétés
// pour retrouver où est le problème (l'utilisateur concerné,
// la méthode qu'il a appelé, la route demandée, etc...)
app('log')->debug('Response time too long', [
'method' => $request->getMethod(),
'uri' => $request->getRequestUri(),
'seconds' => $timeMeasure,
'ip' => $request->ip(),
'user_id' => $request->user()->id ?? '',
'livewire_path' => $request->all()['fingerprint']['path'] ?? '',
]);
}
}
}
Et hop, vous voilà en mesure d'identifier vos requêtes serveurs où votre serveur patine !
Revoir le code trop lent et lister les pistes d'améliorations
La deuxième étape est bien évidemment de regarder la fonction (ou les fonctions) dont le temps de calcul est étonnamment long. Au risque de me répéter, dans 99% des cas d'usage, un temps de calcul de plus d'une seconde est un indicateur fort d'un code améliorable.
Dans le cas pratique que nous allons étudier ci-dessous, j'ai identifié une fonction qui met 7 secondes à tourner en production, et 2 secondes en environnement local (tout est toujours plus rapide en environnement local, c'est pourquoi c'est la production qu'il faut plutôt surveiller). Le rôle de la fonction est de revoir tous les candidats à une relance par email, puis d'envoyer la relance si un certain nombre de contraintes sont respectées. La volumétrie est d'environ 2000 lignes, et on ne peut pas se satisfaire d'un temps de calcul de 7 secondes pour une tâche aussi dérisoire.
Voyons donc le code (simplifié pour cet article) et distribuons les bons et mauvais points sur le code, en commentaire:
public function handle()
{
$start_time = microtime(true);
// toujours cette bonne habitude de mesurer le temps pris :-)
$contactsToSendStep = [];
$dateTimeToday = Carbon::now();
$steps_with_reminder = Step::query()
->join('trainings', 'trainings.id', '=', 'steps.training_id')
->join('users', 'users.id', '=', 'trainings.user_id')
->where('steps.is_rule_reminder_activated', true)
->where('steps.rule_reminder', '<>','0-0' )
->where('steps.triggered_at', '<=', $dateTimeToday)
->withQuestionsCount()
->get();
// Analyse 1 : On dresse la liste des 'steps' dans un périmètre donné
// ceux pour lesquels la règle de relance automatique est activée.
foreach ($steps_with_reminder as $step) {
// Analyse 2 : On commence une boucle, source n°1
// des problèmes de lenteur de requête, ouvrons l'oeil :-)
// SECURITY 1
if ($step->nb_questions_asked === 0) { continue; }
// SECURITY 2
$explode_rule_reminder = explode('-', $step->rule_reminder);
if (count($explode_rule_reminder) !== 2) { continue; }
$nb_of_reminder = (int)$explode_rule_reminder[0];
$step_between_reminder = (int)$explode_rule_reminder[1];
if ($nb_of_reminder === 0 || $step_between_reminder === 0) { continue; }
$sent_emails = $step->sent_emails;
// Analyse 3 : Aie Aie Aie ! ci-dessus on se connecte à
// la base de données pour rapatrier plus d'informations
// Puisque cela est fait au sein d'une boucle, la performance
// sera catastrophique : Si la boucle tourne 200 fois,
// le script va effectuer 200 requêtes en base de données
// SECURITY 3
if ($sent_emails->count() === 0) { continue; }
// Analyse 4 : Aie Aie Aie ! encore une nouvelle requête en bdd
// qui va être multipliée par le nombre d'éléments dans la boucle
$contactsInScope = Contact::query()
->where('contacts.training_id',$step->training_id)
->with('training.user')
->get();
foreach ($contactsInScope as $contact) {
// Analyse 5 : Une nouvelle boucle dans une boucle...
// aussi appelée la boucle de la Muerte :-) Cela signifie que si on a
// 100 "steps_with_reminder" , et 100 "contactsInScope",
// la prochaine requête en base de données
// va être effectuée 10 000 fois !!!
$last_sent_email = SentEmail::where('contact_id', $contact->id)
->where('step_id', $step->id)
->orderBy('created_at', 'desc')
->first();
// Analyse 6 : Et bim !
// SECURITY 4
if (!$last_sent_email) { continue; }
//
// SECURITY 5
if ($step->rule_private_list) {
if (!in_array($contact->id, json_decode($step->rule_private_list))) { continue; }
}
//
// SECURITY 6
$click_events_count = QuizAnswer::where('contact_id', $contact->id)
->where('step_id', $step->id)
->orderBy('created_at', 'desc')
->distinct(DB::raw('CONCAT_WS("-",contact_id, step_id, question_id)'))
->get()
->count();
if ($click_events_count > 0) { continue; }
//
// SECURITY 7
$nb_of_reminder_sent_count = SentEmail::where('contact_id', $contact->id)->where('step_id', $step->id)->where('is_reminder', true)->count();
// Analyse 7 : Et re-bim !
if ($nb_of_reminder_sent_count >= $nb_of_reminder) { continue; }
//
// SECURITY 8
if ($last_sent_email->created_at < Carbon::now()->subDays(30)) { continue; }
//
// SECURITY 9
$nb_of_sent_for_step_today = SentEmail::query()
->where('sent_emails.contact_id', $contact->id)
->where('sent_emails.step_id', $step->id)
->where('sent_emails.created_at', '>=', Carbon::today())
->count();
// Analyse 8 : Et re-re-bim !
if ($nb_of_sent_for_step_today > 0) { continue; }
//
$last_sent_email_sub_step_reminder = Carbon::parse($last_sent_email->created_at)->addWeekDays($step_between_reminder);
$now = Carbon::now();
// Analyse 9 : A ce stade, si l'élément a passé toutes
// les sécurités, il est prêt à la relance, c'est ce qui
// est fait sur les lignes qui suivent
if ($now->gte($last_sent_email_sub_step_reminder)) {
// ENFIN, ON DECLENCHE ICI L'ENVOI DE LA RELANCE
// POUR LE COUPLE (STEP / CONTACT) CONCERNE.
// ... code ...
// Add info for further bulk insert
$info = [
'contact_id' => $contact->id,
'step_id' => $step->id,
'when' => $now->toDateTimeString(),
];
array_push($contactsToSendStep, $info);
}
}
}
// Perform the bulk insert in one query
$sentEmailsData = [];
foreach ($contactsToSendStep as $contactToSend) {
// On ajoute l'info à $sentEmailsData
}
SentEmail::insert($sentEmailsData);
//
// Analyse 10 : OUF, enfin une bonne nouvelle ! ici
// l'algo ne va faire qu'une seule écriture "massive" en base de donnée
// au lieu de faire une multitude d'écritures uniques dans la boucle
$end_time = microtime(true);
$execution_time = round(($end_time - $start_time), 2);
Log::info('Execution time : ' . $execution_time . ' seconds');
return 0;
}
Après lecture du code, il est évident que la "double boucle de la Muerte", couplée à des requêtes en base de données au niveau le plus fin possible, expliquent le temps de 7 secondes mis par la fonction.
Nous avons vu que l'algo identifie d'abord une liste de "steps" éligibles, puis pour chaque "step" identifie une liste de "contacts" éligibles, enfin pour chaque couple (step / contact), détermine s'il faut envoyer une relance automatique (ou non).
Ainsi, si nous avons cent steps, et pour chaque step, cent contacts, nous allons faire 100 * 100 soit 10 000 vérifications, pour, en réalité n'envoyer une relance que pour une infime partie de ce périmètre.
De plus, mécaniquement, plus nous avons d'éléments dans les boucles, plus la fonction prendra du temps à tourner.
Ce n'est pas tenable et nous pouvons mieux faire !
Comment pouvons nous remédier à cela ?
Quelques principes :
La boucle dans la boucle est un signal ultra fort qu'il faut "changer de logique" dans le fonctionnement de l'algo.
Tout calcul ou filtre qui peut être fait par la base de données doit être fait par la base de données.
Une refonte d'un algorithme peut vite devenir chronophage. J'aime procéder par "lots" et ne m'accorder qu'une ou deux demi-journées de travail pour améliorer le maximum possible dans ce temps.
Appliquons ces principes à notre analyse du code
Il faut donc repenser l'algorithme pour :
1/ Récupérer la liste du périmètre éligible avec une seule opération en base de donnée plutôt que 10 000.
2/ Filtrer le périmètre de ce qui peut l'être facilement au niveau de la base de données.
Plus haut, nous avons vu qu'on finissait avec des couples (step / contacts). Rien de plus simple que d'obtenir une telle liste avec une seule requête en base de données : il suffit de joindre les tables "steps" et "contacts" sur un critère commun (ici le training_id) puis de grouper à la maille unique souhaitée, dans notre cas (stepId,contactId)
On va ensuite lister les informations dont on a besoin pour tester les différentes "sécurités".
On essaiera alors de calculer ces informations directement dans MySQL (la base de données), et pour certains cas "simples", on fera effectuer les tests de sécurité directement par MySQL, pour encore plus de vélocité.
Ce qui peut donner...
public function handle()
{
$start_time = microtime(true);
$contactsToSendStep = [];
$dateThirtyDaysAgo = Carbon::now()->subDays(30)->toDateTimeString();
$dateTimeToday = Carbon::now();
$linesToCheckForReminder = Step::query()
->where(`steps.triggered_at`, "<=", $dateTimeToday)
->where("steps.rule_reminder", "<>","0-0" )
->where('steps.is_rule_reminder_activated', true)
->whereHas('questions') // SECURITY 1 est vérifiée ici
->join('trainings', 'steps.training_id', '=', 'trainings.id')
->join('users', 'users.id', '=', 'trainings.user_id')
->join('contacts', 'contacts.training_id', '=', 'trainings.id')
->join('sent_emails', function ($join) {
$join->on('sent_emails.step_id', '=', 'steps.id')
->on('sent_emails.contact_id', '=', 'contacts.id');
})
->leftJoin('quiz_answers', function ($join) {
$join->on('quiz_answers.step_id', '=', 'steps.id')
->on('quiz_answers.contact_id', '=', 'contacts.id');
})
->select([
'steps.id as stepId',
'steps.triggered_at as stepTriggered_at',
'steps.rule_private_list as step_rule_private_list',
'steps.rule_reminder',
'steps.is_rule_reminder_activated as step_rule_reminder_activated',
'contacts.id as contactId',
DB::raw('MAX(sent_emails.created_at) as last_sent_email_date'),
DB::raw('COUNT(sent_emails.id) as sent_emails_count'),
DB::raw('COUNT(CASE WHEN sent_emails.is_reminder = 1 THEN sent_emails.id ELSE NULL END) as reminders_count'),
DB::raw('COUNT(CASE WHEN DATE(sent_emails.created_at) >= CURDATE() THEN sent_emails.id ELSE NULL END) as sent_today_for_step_contact'),
DB::raw("CHAR_LENGTH(REPLACE(REPLACE(REPLACE(GROUP_CONCAT(quiz_answers.answer), 'null', ''), ',', ''),'0', '')) as answersHistoryCount"),
])
->groupBy('steps.id','contacts.id')
// pour valider SECURITY 8
->havingRaw('last_sent_email_date > ?', [$dateThirtyDaysAgo])
// pour valider SECURITY 3 et 4
->havingRaw('sent_emails_count > 0')
// pour valider SECURITY 7
->havingRaw(`reminders_count < SUBSTRING_INDEX(steps.rule_reminder, "-", 1)`)
// pour valider SECURITY 6
->havingRaw('(answersHistoryCount = 0 OR answersHistoryCount IS NULL)')
->get();
// Nous avons maintenant une première liste, filtrée, de couples.
// Certaines contraintes doivent encore être vérifiées
// Je décide de les effectuer via une boucle sur le résultat filtré, car :
// elles me semblaient trop complexes à implanter côté MySQL et
// je souhaitais garder une trace des logs sur celles-ci. Comme
// il n' y a plus aucun appel à la base de données dans la boucle,
// cela ne posera pas de problèmes de performance.
$finalScope = $linesToCheckForReminder->reject(function ($line) {
if ($line->step_rule_private_list && !in_array($line->contactId, json_decode($line->step_rule_private_list))) {
return true; // On rejette la ligne si SECURITY 5 est vérifiée
}
if ($line->sent_today_for_step_contact > 0) {
return true; // On rejette la ligne si SECURITY 9 est vérifiée
}
// Check correct timing for reminder
$explode_rule_reminder = explode('-', $line->rule_reminder);
$step_between_reminder = (int)$explode_rule_reminder[1];
$last_sent_email_sub_step_reminder = Carbon::parse($line->last_sent_email_date)->addWeekDays($step_between_reminder);
$now = Carbon::now();
if ($now->lt($last_sent_email_sub_step_reminder)) {
return true;
// On rejette la ligne si le timing n'est pas
// en phase avec la règle de relance auto.
}
return false; // Sinon on garde la ligne dans le périmètre.
});
// Nous avons maintenant la liste des couple (step / contact)
// heureux élus pour une relance
$now = Carbon::now();
foreach ($finalScope as $line) {
// FINALEMENT, ON DISPATCH ICI L'ENVOI DE LA RELANCE
// POUR TOUS LES COUPLES UNIQUES (STEP / CONTACT) CONCERNES.
// Add info for further bulk insert
$info = [
'contact_id' => $contact->id,
'step_id' => $step->id,
'when' => $now->toDateTimeString(),
];
array_push($contactsToSendStep, $info);
}
// Perform the bulk insert in one query
$sentEmailsData = [];
foreach ($contactsToSendStep as $contactToSend) {
/* On ajoute l'info à $sentEmailsData */
}
SentEmail::insert($sentEmailsData);
/* une seule insertion en base
pour plein de lignes : super efficace. */
$end_time = microtime(true);
$execution_time = round(($end_time - $start_time), 2);
return 0;
}
Après ce travail, la première chose est de vérifier que le périmètre récupéré est le même.
La deuxième chose est de vérifier que la fonction tourne plus vite. C'est le cas, avec un temps sous les 100ms (contre 2 secondes précédemment) en environnement de développement.
Voilà donc une optimisation de calcul, faite en une demi-journée, tests de non-régression inclus.
Une fois mise en production, ce calcul met maintenant... 0.35sec !
Nous sommes donc passés d'une requête très lente et sensible à la volumétrie à une requête quasi-insensible aux volumes, et qui tourne 20 fois plus vite, en déportant la charge de calcul sur notre base de données !
Merci MySQL ;-)
A bientôt !