MariaDB vs PostgreSQL : limitation de débit via la BDD — pourquoi c’est un piège et quoi faire à la place

Cet article vous a aidé ?

Ça commence innocemment : « Stockons des compteurs dans la base et rejetons les requêtes quand les utilisateurs dépassent le quota. » Deux semaines plus tard, vous regardez une p95 qui grimpe, des lignes chaudes, des attentes de verrous et un canal d’incidents rempli de messages du type « pourquoi la BDD est à 95% CPU alors que le trafic n’est pas si élevé ? »

La limitation de débit est une préoccupation de plan de contrôle. Votre base de données est une machine de plan de données. Si vous lui demandez de contrôler le trafic, vous lui demandez à la fois de courir la course et d’en arbitrer les règles — tout en vendant des snacks dans les gradins.

La thèse : pourquoi « limiter en SQL » échoue en production

Limiter le débit côté base de données paraît attractif parce que c’est centralisé et transactionnel. Vous pouvez appliquer des limites « exactement », par utilisateur, par clé API, par locataire, par ce que vous voulez. Et vous avez déjà une base. Donc vous ajoutez une table comme rate_limits, faites un UPDATE ou un INSERT ... ON CONFLICT/ON DUPLICATE KEY, vérifiez une fenêtre temporelle et refusez si la limite est dépassée.

En production, cette approche a tendance à échouer pour un ensemble de raisons reproductibles :

  • Vous créez un point de sérialisation. La limitation de débit concerne forcément un état partagé. L’état partagé à l’intérieur d’un moteur transactionnel devient contention. La contention devient latence en queue. La latence devient timeouts. Les timeouts deviennent des retries. Les retries deviennent un test de charge non prévu.
  • Vous transformez des rejets « peu coûteux » en rejets « coûteux ». Un bon limiteur rejette vite, avant que vos systèmes coûteux ne fassent du travail. Un limiteur basé sur la BDD demande au système coûteux (la BDD) de faire du travail pour décider s’il doit faire du travail.
  • Vous amplifiez les pics de trafic. Les pics sont précisément quand la limitation importe. Les pics sont aussi quand la contention BDD est la pire. Limiter dans la BDD signifie heurter le pire chemin au moment où vous avez besoin du meilleur comportement.
  • Vous couplez la disponibilité de l’appli à celle de la BDD. Si le limiteur dépend de voyages aller-retour vers la BDD, alors une dégradation de la BDD devient une dégradation de l’API, même pour des endpoints qui pourraient se dégrader gracieusement.
  • Vous obtenez des résultats « corrects » au prix de la correction ailleurs. Vos requêtes métier essentielles se retrouvent en concurrence avec vos requêtes de police. Quand le limiteur chauffe, il vole CPU, cache tampon et budget I/O au business.

C’est la réalité opérationnelle. Résumé sec et ironique : utiliser votre base de données pour limiter le débit, c’est comme utiliser votre détecteur de fumée pour cuisiner — techniquement possible, mais vous allez passer un mauvais moment.

Faits intéressants et contexte historique (pour ne pas répéter l’histoire)

  • Le modèle MVCC de PostgreSQL (héritage du projet Postgres des années 1990) évite souvent les verrous de lecture pour beaucoup de charges, mais la contention d’écriture reste de la contention ; les mises à jour fréquentes peuvent toujours se sérialiser sur le même tuple/page.
  • InnoDB (le moteur par défaut dans l’écosystème MariaDB/MySQL) est construit autour d’index clusterisés ; une clé primaire « chaude » signifie que vous frappez les mêmes pages d’arbre B, ce qui peut se transformer en contention de latch et en churn du buffer pool.
  • Les algorithmes token bucket et leaky bucket apparaissent dans la littérature réseau des décennies avant la plupart des API web ; ils ont été conçus pour des routeurs, pas pour des bases OLTP.
  • L’adoption généralisée de pgbouncer vient d’une réalité pratique : les connexions PostgreSQL ne sont pas gratuites, et « trop de clients » est une cause classique d’incident auto-infligé.
  • La réputation initiale de MySQL pour la vitesse provenait en partie du fait qu’il était livré avec des comportements par défaut plus simples et moins de sécurités ; les gens ont ensuite réintroduit la complexité au niveau applicatif. Le cycle se répète avec « limitons en SQL ».
  • Les advisory locks dans PostgreSQL sont puissants et faciles à mal utiliser ; ce sont des primitives de concurrence, pas des outils de shaping du trafic.
  • Les systèmes à grande échelle ont popularisé les « bulkheads » et les « budgets » (pools séparés et isolation des ressources par locataire) parce que l’équité parfaite coûte cher ; le service prévisible est l’objectif réel.
  • Les retry storms ne sont pas une invention moderne ; les systèmes distribués réapprennent que « les retries peuvent être nuisibles » depuis au moins l’ère RPC.

MariaDB vs PostgreSQL : ce qui change, ce qui ne change pas

Ce qui est identique : la contention, c’est physique

Que vous exécutiez MariaDB ou PostgreSQL, un limiteur implémenté comme « une ligne par sujet, mise à jour à chaque requête » crée un point chaud. La base doit coordonner ces mises à jour. La coordination est tout le travail d’une base de données, mais ce n’est pas gratuit — surtout aux niveaux de QPS où la limitation importe.

Ce qui diffère : comment la douleur se manifeste

PostgreSQL a tendance à montrer la douleur comme :

  • Attentes de verrous au niveau des lignes quand plusieurs transactions mettent à jour la même ligne (visible via pg_locks, pg_stat_activity).
  • Volume WAL important à cause des mises à jour incessantes, même si les données « métier » ne changent pas beaucoup.
  • Pression d’autovacuum due au churn rapide ; les mises à jour fréquentes créent des tuples morts qui doivent être vacuumés, et le vacuum demande des I/O.
  • Dans les cas extrêmes : CPU consommé par des LWLocks ou contention autour des shared buffers / activités liées aux checkpoints.

MariaDB (InnoDB) a tendance à montrer la douleur comme :

  • Attentes de verrous et deadlocks autour de la même clé primaire ou des entrées d’index uniques (SHOW ENGINE INNODB STATUS est à la fois votre ami et votre ennemi).
  • Pression sur les undo logs / purge à cause des updates constantes, ce qui peut entraîner une croissance de la history list et une dégradation des performances.
  • Contention du buffer pool et pages d’index chaudes quand la même plage de clés est martelée.
  • Lag de réplication si vous binloggez chaque mise à jour du limiteur ; vos replicas deviennent des « consommateurs de mises à jour du limiteur » au lieu de servir des lectures.

Tableaux de bord différents. Même problème racine : le limiteur est en concurrence avec votre charge pour le débit transactionnel.

Comment la limitation par la BDD casse tout : verrous, lignes chaudes et travaux invisibles

1) Le problème de la ligne chaude (a.k.a. « une ligne pour tous »)

La plupart des schémas naïfs utilisent une seule ligne par utilisateur/clé API. Sous trafic en rafale, de nombreux workers applicatifs mettent à jour la même ligne. La concurrence s’effondre en sérialisation. Votre « limite » devient « la capacité de la BDD à mettre à jour cette ligne par seconde », ce qui n’est pas la limite que vous vouliez. Pire : vous ne pouvez pas l’augmenter sans augmenter le débit d’écriture de la BDD.

2) Inflation de la latence en queue (le tueur silencieux)

La limitation est souvent exécutée à chaque requête. Donc même une petite augmentation de la latence du limiteur se multiplie sur votre trafic. Vous n’ajoutez pas juste 2 ms ; vous ajoutez 2 ms à tout, puis des retries, puis du queueing. Vous obtenez une longue panne lente qui ressemble à « tout est un peu pire » jusqu’à devenir soudainement catastrophique.

3) WAL/binlog et vacuum/purge : la taxe du « travail invisible »

Chaque mise à jour a des effets en aval :

  • PostgreSQL écrit du WAL ; la réplication consomme le WAL ; les checkpoints flushent des pages ; autovacuum nettoie.
  • InnoDB écrit des redo/undo ; les threads de purge nettoient ; la réplication rejoue des événements binlog.

Votre limiteur devient maintenant un moteur d’amplification d’écritures. Le système passe plus de temps à maintenir le registre qu’à exécuter le métier.

4) Couplage des défaillances : quand la BDD est malade, votre API l’est aussi

Un limiteur devrait protéger votre base d’une surcharge. S’il tourne dans la base, vous avez construit une ceinture de sécurité qui ne fonctionne que lorsque la voiture n’est pas en panne. Lors d’une dégradation de la BDD, le limiteur ralentit, ce qui augmente les requêtes concurrentes, ce qui aggrave la dégradation de la BDD. C’est une boucle de rétroaction au doux nom financier : « downtime non planifié ».

5) « Mais on va sharder la table du limiteur » (phase optimiste)

Les équipes tentent souvent de résoudre les lignes chaudes en shardan les compteurs sur N lignes et en les sommant. Cela réduit la contention sur une seule ligne mais introduit plus de lectures, plus de complexité, et toujours beaucoup de charge d’écriture. Aussi : sommer des compteurs à chaque requête est une excellente façon de transformer un limiteur en moteur de requêtes.

Deuxième vérité sèche et drôle : un limiteur SQL shardé, c’est comment transformer une ligne chaude en plusieurs lignes un peu moins chaudes, comme répartir un mal de tête sur toute la tête.

Playbook de diagnostic rapide : trouver le goulot en quelques minutes

Quand vous suspectez que la limitation « dans la BDD » fait fondre tout, ne commencez pas par réécrire des algorithmes. Commencez par prouver où le temps passe.

Premier point : confirmez si le limiteur est sur le chemin critique

  • Vérifiez les traces applicatives : y a-t-il un appel BDD pour chaque requête même lors d’un rejet ?
  • Comparez les latences pour les requêtes autorisées vs rejetées. Le rejet doit être moins coûteux. Si c’est plus coûteux, vous avez déjà trouvé le problème.

Second point : identifiez la ressource partagée en contention

  • Attentes de verrous en BDD (verrous de ligne, verrous de transaction).
  • Saturation du pool de connexions (threads en attente d’une connexion).
  • Pression sur le write-ahead logging ou le binlog (pics de volume WAL/binary log).
  • Queueing I/O (await élevé, buffers sales, flushs lents).

Troisième point : déterminez si vous êtes dans une boucle de rétroaction

  • Y a-t-il des retries sur 429/5xx qui augmentent la charge ?
  • Les timeouts applicatifs sont-ils plus courts que la récupération BDD après un pic ?
  • L’autoscaling ajoute-t-il des clients, aggravant la contention ?

Quatrième point : choisissez la mitigation la plus rapide et sûre

  • Déplacez la limitation vers l’edge (API gateway / ingress) pour les endpoints les plus chauds.
  • Appliquez des caps de connexion et du queueing en dehors de la BDD (poolers, sémaphores, files de workers).
  • Désactivez ou contournez temporairement le limiteur basé sur la BDD s’il provoque une défaillance systémique (avec une limite compensatoire ailleurs).

Idée paraphrasée de Werner Vogels (CTO d’Amazon) : « Tout échoue, tout le temps — concevez des systèmes qui s’y attendent et continuent de servir. »

Tâches pratiques : commandes, sorties et décisions qu’elles entraînent

Voici les vérifications que j’effectue quand j’arrive dans un système où quelqu’un est fier de dire « nous limitons en SQL ». Chaque tâche inclut : la commande, un exemple de sortie, ce que cela signifie, et la décision à prendre.

Task 1: Check if the database is CPU-bound or I/O-bound (Linux)

cr0x@server:~$ mpstat -P ALL 1 3
Linux 6.5.0 (db01)  12/31/2025  _x86_64_ (16 CPU)

12:01:11 PM  CPU   %usr  %nice   %sys %iowait  %irq %soft  %steal %idle
12:01:12 PM  all  72.10   0.00  17.22    2.10  0.00  0.88    0.00  7.70
12:01:13 PM  all  76.55   0.00  16.40    1.90  0.00  0.70    0.00  4.45
12:01:14 PM  all  74.80   0.00  18.00    2.40  0.00  0.60    0.00  4.20

Signification : %usr+%sys élevé, %iowait bas : pression CPU, probablement contention ou exécution de requêtes lourdes.

Décision : Si le limiteur est write-heavy, concentrez-vous sur la contention/verrous/WAL/binlog et les mises à jour chaudes, pas d’abord sur les disques.

Task 2: Check disk latency and queueing (Linux)

cr0x@server:~$ iostat -xz 1 3
Linux 6.5.0 (db01)  12/31/2025  _x86_64_ (16 CPU)

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          71.4    0.0    17.9     2.1     0.0     8.6

Device            r/s     w/s    rkB/s    wkB/s  aqu-sz  await  svctm  %util
nvme0n1          12.0   980.0   210.0  18340.0    5.20   5.10   0.25  26.0

Signification : Les écritures sont élevées mais await est bas ; pas de saturation I/O. La BDD effectue beaucoup de petites écritures (typique des mises à jour de compteurs).

Décision : N’achetez pas de disques plus rapides pour corriger un design de limiteur. Corrigez le design.

Task 3: Check PostgreSQL active queries and wait events

cr0x@server:~$ psql -h db01 -U postgres -d app -c "select pid, usename, state, wait_event_type, wait_event, left(query,80) as q from pg_stat_activity where state<>'idle' order by now()-query_start desc limit 8;"
 pid  | usename | state  | wait_event_type |  wait_event   | q
------+---------+--------+-----------------+---------------+-----------------------------------------------
 8121 | app     | active | Lock            | tuple         | update rate_limits set count=count+1 where key=
 8177 | app     | active | Lock            | tuple         | update rate_limits set count=count+1 where key=
 8204 | app     | active | Lock            | tuple         | update rate_limits set count=count+1 where key=
 8290 | app     | active | IO              | DataFileRead  | select * from orders where tenant_id=$1 order by

Signification : Plusieurs sessions en attente sur des verrous de tuple pour le même motif de mises à jour. Ce sont vos lignes de limiteur qui servent de mutex.

Décision : Arrêtez de faire des mises à jour par requête en SQL. Déplacez l’état du limiteur hors de la BDD ou batcher les mises à jour.

Task 4: Find top PostgreSQL queries by total time (requires pg_stat_statements)

cr0x@server:~$ psql -h db01 -U postgres -d app -c "select calls, round(total_exec_time::numeric,1) as total_ms, round(mean_exec_time::numeric,3) as mean_ms, left(query,70) as q from pg_stat_statements order by total_exec_time desc limit 5;"
 calls | total_ms  | mean_ms | q
-------+-----------+---------+----------------------------------------------------------------------
 92000 | 580000.2  | 6.304   | update rate_limits set count=count+1, reset_at=$1 where key=$2
 41000 | 210000.7  | 5.122   | insert into rate_limits(key,count,reset_at) values($1,$2,$3) on conflict
  8000 |  90000.4  | 11.250  | select * from orders where tenant_id=$1 order by created_at desc limit 50

Signification : Vos requêtes de limiteur dominent le temps d’exécution total. Elles ne sont pas un « petit overhead ». Elles constituent la charge.

Décision : Traitez cela comme un bug fonctionnel, pas comme du « tuning » de performance. Retirez les écritures du chemin chaud.

Task 5: Check PostgreSQL WAL generation rate

cr0x@server:~$ psql -h db01 -U postgres -d app -c "select now(), pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')) as wal_since_start;"
              now              | wal_since_start
------------------------------+----------------
 2025-12-31 12:03:10.123+00   | 148 GB

Signification : Grand volume de WAL sur la durée d’exécution indique un churn d’écritures important. Les compteurs du limiteur sont des suspects principaux.

Décision : Si les replicas prennent du retard ou l’archivage s’accumule, réduisez le volume d’écritures immédiatement (limites en edge, caching, mises à jour batch).

Task 6: Check PostgreSQL vacuum pressure for the limiter table

cr0x@server:~$ psql -h db01 -U postgres -d app -c "select relname, n_live_tup, n_dead_tup, last_autovacuum from pg_stat_user_tables where relname='rate_limits';"
  relname   | n_live_tup | n_dead_tup |     last_autovacuum
------------+------------+------------+---------------------------
 rate_limits|     120000 |    9800000 | 2025-12-31 11:57:42+00

Signification : Les tuples morts dépassent largement les tuples vivants : mises à jour constantes. Autovacuum travaille en surcroît juste pour nettoyer les écritures du limiteur.

Décision : Arrêtez de mettre à jour ces lignes à chaque requête. Le tuning d’autovacuum est un pansement ; la blessure est le design.

Task 7: Check MariaDB/InnoDB deadlocks and lock waits

cr0x@server:~$ mariadb -h db01 -u root -p -e "SHOW ENGINE INNODB STATUS\G" | sed -n '1,120p'
...
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 34588921, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 7123, OS thread handle 140377, query id 991233 app 10.0.3.41 updating
UPDATE rate_limits SET count=count+1, reset_at=FROM_UNIXTIME(173564...) WHERE k='tenant:913'
*** (2) TRANSACTION:
TRANSACTION 34588922, ACTIVE 0 sec starting index read
...

Signification : Deadlocks sur la table du limiteur. Votre « mécanisme de sécurité » est devenu une source de rollbacks et de retries.

Décision : Retirez le limiteur d’InnoDB. Si vous devez garder un état, stockez-le dans un système à faible contention (Redis/memcache) avec expiration.

Task 8: Check MariaDB top statements (performance_schema enabled)

cr0x@server:~$ mariadb -h db01 -u root -p -e "SELECT DIGEST_TEXT, COUNT_STAR, ROUND(SUM_TIMER_WAIT/1e12,1) AS total_s FROM performance_schema.events_statements_summary_by_digest ORDER BY SUM_TIMER_WAIT DESC LIMIT 3;"
DIGEST_TEXT                                                     COUNT_STAR total_s
UPDATE `rate_limits` SET `count` = `count` + ? WHERE `k` = ?     210344     980.2
INSERT INTO `rate_limits` (`k`,`count`,`reset_at`) VALUES (...)  50321     220.5
SELECT * FROM `orders` WHERE `tenant_id` = ? ORDER BY ...         21000     190.0

Signification : Le limiteur est en tête. La BDD passe la majorité de son temps à contrôler le trafic.

Décision : Déplacez l’application de la règle plus tôt dans le chemin des requêtes. Laissez la BDD aux données.

Task 9: Check connection saturation (PostgreSQL)

cr0x@server:~$ psql -h db01 -U postgres -d app -c "select count(*) as total, sum(case when state='active' then 1 else 0 end) as active from pg_stat_activity;"
 total | active
-------+--------
  480  |  220

Signification : Beaucoup de connexions et beaucoup d’actives. Si vous ne pooliez pas correctement, vous payez un overhead par connexion et augmentez la contention.

Décision : Ajoutez/vérifiez un pooler (pgbouncer) et limitez la concurrence par endpoint/tenant hors de la BDD.

Task 10: Check pgbouncer pool health

cr0x@server:~$ psql -h pgb01 -U pgbouncer -d pgbouncer -c "show pools;"
 database | user | cl_active | cl_waiting | sv_active | sv_idle | maxwait
---------+------+-----------+------------+----------+---------+---------
 app     | app  |       180 |         45 |       40 |       0 |     12.3

Signification : Des clients attendent ; les serveurs sont saturés. L’appli génère plus de travail concurrent que la BDD ne peut en supporter en sécurité.

Décision : Faites respecter des limites de concurrence dans l’appli (sémaphores), déplacez la limitation vers le gateway, et réduisez les allers-retours BDD par requête.

Task 11: Check NGINX rate limiting effectiveness at the edge

cr0x@server:~$ sudo nginx -T 2>/dev/null | grep -n "limit_req"
134:    limit_req_zone $binary_remote_addr zone=perip:10m rate=20r/s;
201:            limit_req zone=perip burst=40 nodelay;

Signification : La limitation en edge est configurée. Si votre BDD est encore surchargée, soit les limites sont trop élevées, soit elles sont mal clefées, soit contournées pour du trafic clé.

Décision : Validez les clés (par clé API/tenant, pas par IP derrière un NAT), et ajoutez des budgets par route.

Task 12: Verify the app is not retrying 429/lock errors aggressively

cr0x@server:~$ rg -n "retry|backoff|429|Too Many Requests" /etc/myapp/config.yaml
118:retry:
119:  max_attempts: 6
120:  backoff: "fixed"
121:  backoff_ms: 50
122:  retry_on_status: [429, 500, 502, 503, 504]

Signification : Backoff fixe de 50ms avec 6 tentatives est un générateur de retry storm. 429 est un signal pour ralentir, pas pour accélérer l’envoi de requêtes au serveur.

Décision : Arrêtez de retenter automatiquement sur 429, ou utilisez un backoff exponentiel avec jitter et un plafond strict. Privilégiez le lissage côté client.

Task 13: Identify hot keys in the limiter table (PostgreSQL example)

cr0x@server:~$ psql -h db01 -U postgres -d app -c "select key, count(*) as updates_last_min from rate_limit_audit where ts > now()-interval '1 minute' group by key order by updates_last_min desc limit 5;"
     key      | updates_last_min
--------------+------------------
 tenant:913   |            18400
 tenant:1442  |            10210
 tenant:77    |             9800

Signification : Quelques locataires dominent le volume d’updates. C’est là que l’équité et l’isolation comptent plus que des limites globales parfaites.

Décision : Introduisez des budgets par locataire et des bulkheads hors de la BDD ; envisagez des pools/workers séparés par niveau.

Task 14: Check replication lag (MariaDB replica)

cr0x@server:~$ mariadb -h db-rep01 -u root -p -e "SHOW SLAVE STATUS\G" | egrep "Seconds_Behind_Master|Slave_IO_Running|Slave_SQL_Running"
Slave_IO_Running: Yes
Slave_SQL_Running: Yes
Seconds_Behind_Master: 187

Signification : Le réplica a du retard. Si les updates du limiteur sont répliquées, vous dépensez le débit de réplication pour la police plutôt que pour les données métier.

Décision : Gardez l’état du limiteur hors de l’OLTP répliqué, ou au moins ne le répliquez pas (instance/schéma séparé avec durabilité différente), et corrigez au niveau de l’edge.

Trois mini-récits d’entreprise tirés du terrain

Mini-récit 1 : l’incident causé par une mauvaise hypothèse

Une équipe SaaS voulait des limites par locataire. Le produit demandait « limite stricte, exacte, pas plus de N requêtes par minute. » L’ingénierie a entendu « transactionnel ». Ils ont construit une table PostgreSQL indexée par tenant ID, mise à jour à chaque requête, et ont utilisé UPDATE ... RETURNING pour décider d’autoriser ou refuser. Ça a passé les tests de charge, parce que les tests utilisaient des locataires uniformément répartis et une montée en charge polie.

Puis un vrai client a lancé un script de migration qui a martelé un seul locataire en forte concurrence. La table du limiteur est devenue la table la plus mise à jour de la base. Les symptômes étaient classiques : p95 a grimpé, puis p99 s’est effondré. La BDD n’était pas « down », elle était juste occupée à attendre des verrous. L’application a timeouté, retenté, et a doublé le trafic. Le responsable incident demandait sans cesse pourquoi le limiteur n’avait pas protégé la BDD contre la surcharge. Silence, puis la lente réalisation : le limiteur était la surcharge.

Ils ont corrigé en déplaçant la limitation de première ligne dans la passerelle (par clé API) et en utilisant une file de travail par locataire pour les endpoints de migration coûteux. L’exigence « exacte par minute » a été réécrite en quelque chose que des adultes peuvent gérer : « appliquer des limites approximatives avec burst borné, prioriser les endpoints essentiels. » Tout le monde a survécu. Personne n’a adoré le postmortem, mais les graphes furent instructifs.

Mini-récit 2 : l’optimisation qui a mal tourné

Une autre société utilisait MariaDB et avait une table de limiteur avec une ligne par clé API. Ils ont remarqué des attentes de verrous et ont essayé d’« optimiser » en réduisant la taille de la ligne, en convertissant la clé en entier, et en compactant les compteurs dans moins d’octets. Puis ils ont ajouté un index couvrant parce que « les index rendent les recherches rapides ».

Ce qui s’est passé : l’UPDATE devait toujours modifier l’enregistrement de l’index clusterisé (clé primaire) et l’index secondaire. Ils ont augmenté l’amplification d’écriture et rendu la page chaude encore plus chaude. Le CPU a monté, pas baissé. Des deadlocks sont apparus parce que le pattern d’accès a changé subtilement sous la concurrence, et leur logique de retry a transformé chaque deadlock en petit pic.

La vraie correction n’était pas des lignes plus petites. C’était changer d’architecture : utiliser un compteur distribué en mémoire avec TTL pour la police, et laisser MariaDB gérer les écritures durables métier. Ils ont gardé un « quota consommé quotidien » grossier dans MariaDB pour l’audit et la facturation, mis à jour de façon asynchrone. Le limiteur a arrêté de se battre avec le flux de checkout, ce qui est généralement considéré comme une bonne manière.

Mini-récit 3 : la pratique ennuyeuse mais correcte qui a sauvé la mise

Une autre équipe avait déjà été brûlée par les « contrôles côté BDD ingénieux ». Ils faisaient tourner PostgreSQL avec pgbouncer et avaient une règle simple : la base ne fait pas de contrôle d’admission des requêtes. L’admission se fait avant la BDD et utilise des budgets : plafonds de concurrence par route, équité par locataire, et protection globale contre la surcharge à la passerelle.

Ils traçaient quand même l’usage pour la facturation, mais ils le faisaient par agrégation asynchrone. Les requêtes émettaient des événements vers une file ; un worker les agrégait chaque minute et écrivait une ligne de synthèse par locataire. La base voyait une écriture par locataire par minute, pas une par requête. Quand le trafic a augmenté, la profondeur de la file a grandi. C’était acceptable ; personne ne s’est fait réveiller à 3h du matin.

Un jour, une campagne marketing a provoqué un pic. La passerelle a commencé à renvoyer des 429 pour un sous-ensemble de clients abusifs, l’appli a gardé une latence stable, et la base a à peine remarqué. Leur ticket d’incident tenait en une ligne : « Augmentation du taux de 429 due à la campagne ; aucun impact client. » C’est le rêve : ennuyeux, prévisible et un peu satisfait.

Que faire à la place : schémas sensés qui montent en charge

Principe : rejeter tôt, à moindre coût et de façon indépendante

Le rôle de votre limiteur est de protéger les ressources coûteuses. Il doit donc vivre en amont d’elles et rester fonctionnel quand elles sont dégradées.

Schéma 1 : limitation en edge (gateway/ingress)

Utilisez votre API gateway ou controller ingress pour appliquer des limites de base :

  • Par identifiant client (clé API, sujet JWT, tenant ID).
  • Par route (les endpoints de connexion et de recherche n’ont pas les mêmes besoins).
  • Avec des bursts bornés (token bucket) et des réponses par défaut sensées (429 avec retry-after).

Ce n’est pas nécessairement d’une précision globale parfaite. C’est la vitesse et la prévisibilité qui comptent.

Schéma 2 : limites de connexion et de concurrence (bulkheads)

La plupart des « limitations en BDD » sont en réalité « nous avons besoin de moins de requêtes concurrentes ». Résolvez cela directement :

  • Utilisez un pooler (pgbouncer pour PostgreSQL, ProxySQL ou pooling côté appli pour MariaDB).
  • Limitez la concurrence par endpoint (par ex. génération de rapports coûteuse) avec des sémaphores.
  • Limitez la concurrence par locataire pour éviter les voisins bruyants.

Ceci est de l’ingénierie du plan de contrôle qui contrôle réellement quelque chose : le travail concurrent.

Schéma 3 : mettre le travail coûteux en file

Si un endpoint déclenche une activité BDD lourde, ne laissez pas la concurrence HTTP dicter la concurrence BDD. Mettez le travail lourd derrière une file. Laissez la profondeur de file absorber les pics. Exécutez des workers avec une concurrence fixe et des patterns de requêtes connus. Votre base vous remerciera en restant vive.

Schéma 4 : utiliser un store en mémoire pour les compteurs courte fenêtre

Si vous avez vraiment besoin d’un compteur partagé avec TTL, utilisez un système conçu pour cela (Redis/memcached-like). Vous obtenez :

  • Incréments atomiques rapides.
  • Sémantique d’expiration sans le drame du vacuum/purge.
  • Isolation du chemin d’écriture de votre OLTP.

Appliquez quand même de l’hygiène : évitez les clés uniques chaudes, ajoutez du jitter dans les TTL, et partitionnez par tenant si nécessaire.

Schéma 5 : stocker les quotas durables de façon asynchrone

La facturation et la conformité exigent souvent des enregistrements durables. Parfait — stockez-les hors bande :

  • Émettez des événements d’usage.
  • Agrégez en fenêtres temporelles (par minute/heure/jour).
  • Écrivez des résumés dans MariaDB/PostgreSQL.

La base reste une base, pas un magasin de compteurs haute fréquence.

Schéma 6 : budgétisez explicitement la base

Au lieu de « N requêtes par minute », définissez des budgets liés à la ressource réellement rare :

  • Budget de concurrence de requêtes : nb max de requêtes BDD actives par locataire/service.
  • Budget CPU : limiter les endpoints coûteux par temps d’exécution (lorsque mesurable).
  • Budget I/O : throttler les exports et migrations massifs.

Vous ne pouvez pas acheter l’équité parfaite à bon marché. Vous pouvez acheter une latence prévisible et une surcharge survivable. Choisissez la surcharge survivable.

Erreurs courantes : symptômes → cause racine → correction

Erreur 1 : « les 429 augmentent mais la BDD est aussi lente »

Symptômes : Taux de 429 en hausse, CPU BDD en hausse, attentes de verrous en hausse, p95 augmente sur tous les endpoints.

Cause racine : Les rejets dépendent d’écritures/verrous BDD ; le limiteur est lui-même la charge.

Correction : Déplacez le limiteur vers la gateway/magasin en mémoire. Rendre le chemin de rejet sans BDD. Ajoutez des caps de concurrence côté appli.

Erreur 2 : « un seul locataire est lent, mais tout le monde souffre »

Symptômes : Un gros client provoque un ralentissement global. La BDD montre des attentes de verrous sur la table du limiteur ; les pools de connexions sont saturés.

Cause racine : Ressources de limiteur partagées ou pool DB partagé sans isolation par locataire.

Correction : Implémentez des bulkheads : limites de concurrence par locataire, pools de workers séparés pour les endpoints lourds, et files pour les gros jobs.

Erreur 3 : « On a ajouté des index à la table du limiteur et c’est pire »

Symptômes : CPU plus élevé, I/O d’écriture augmenté, deadlocks en hausse, débit global en baisse.

Cause racine : Amplification d’écriture : les index secondaires doivent être maintenus à chaque update ; les pages chaudes deviennent plus chaudes.

Correction : Retirez le design, pas seulement les index. Si vous devez garder une table, écrivez moins souvent (batch/rollup).

Erreur 4 : « Autovacuum n’arrive pas à suivre / purge lag croît »

Symptômes : Les tuples morts s’accumulent ; la history list MariaDB grandit ; les performances se dégradent au fil du temps.

Cause racine : Mises à jour haute fréquence sur les mêmes lignes créent un churn que la maintenance ne peut amortir.

Correction : Éliminez les mises à jour par requête ; utilisez des compteurs TTL en mémoire ; agrégez les écritures.

Erreur 5 : « Ça marche en staging mais échoue en production »

Symptômes : Les tests de charge passent ; le vrai trafic provoque de la contention de verrous et de la latence en queue.

Cause racine : La staging manque de skew (locataires chauds), manque de retries, manque de rafales réelles, et souvent manque de schémas de concurrence réalistes.

Correction : Testez avec distributions skewées, rafales, retries et concurrence client réaliste. Et arrêtez d’utiliser la BDD comme limiteur.

Erreur 6 : « Nous avons utilisé des advisory locks pour implémenter un limiteur » (PostgreSQL)

Symptômes : Le débit s’effondre sous charge ; les sessions s’empilent en attente d’advisory locks ; blocages difficiles à déboguer.

Cause racine : Les advisory locks sérialisent le travail et sont faciles à transformer en mutex global. Ce n’est pas du shaping du trafic.

Correction : Remplacez par des limites au gateway et des caps de concurrence par locataire. Utilisez advisory locks uniquement pour des coordinations rares, pas pour filtrer par requête.

Listes de contrôle / plan étape par étape

Plan étape par étape : migrer hors du limiteur BDD sans tout casser

  1. Inventoriez ce qui est limité. Identifiez endpoints et clés (par IP, par clé API, par locataire). Si vous ne pouvez pas l’expliquer, vous ne pouvez pas l’exploiter.
  2. Mesurez le coût actuel du limiteur. Utilisez pg_stat_statements ou le performance_schema de MariaDB pour quantifier le temps passé dans les statements de limiteur.
  3. Arrêtez de retenter sur 429 par défaut. Corrigez les clients/middleware appli. Le 429 doit réduire la charge, pas la multiplier.
  4. Ajoutez la limitation en edge pour les pires offenders. Commencez par les 1–3 endpoints principaux par taux de requête ou coût BDD.
  5. Ajoutez des caps de concurrence par route dans l’application. Limitez directement le travail BDD coûteux. C’est souvent le gain le plus important.
  6. Introduisez une file pour les tâches lourdes. Migrations, exports, génération de rapports : si c’est lourd, ça appartient à des workers.
  7. Déplacez les compteurs courte fenêtre dans un store en mémoire si nécessaire. Utilisez des compteurs TTL et une sémantique token bucket hors de la BDD.
  8. Gardez un rollup durable dans la BDD pour facturation/audit. Écrivez une ligne par locataire par minute/heure, pas par requête.
  9. Implémentez un comportement de surcharge. Définissez ce qui se passe quand le magasin de limiteur est indisponible (fail open pour endpoints basse risque ? fail closed pour endpoints sensibles ?). Décidez, documentez et testez.
  10. Supprimez l’ancien chemin du limiteur. La suppression via feature-flag vaut mieux que « on le garde au cas où ». « Au cas où » est la façon dont le code mort s’accumule.

Checklist opérationnelle : protégez la base

  • Limites en gateway en place pour le trafic par client et par route.
  • Pool de connexions configuré et monitoré (active, waiting, maxwait).
  • Budgets explicites de concurrence BDD par service et par locataire.
  • Timeouts alignés : gateway < app < DB statement timeout (avec intention).
  • Politique de retry incluant backoff exponentiel + jitter ; pas de retry automatique sur 429.
  • Dashboards suivants : attentes de verrous, lag de réplication, taux WAL/binlog, profondeur des files, taux de 429.

FAQ

1) La limitation en base de données est-elle parfois acceptable ?

Uniquement pour des workloads back-office à faible QPS et faible concurrence où la correction prime sur la latence, et où un écrit du limiteur ne rentrera pas en concurrence avec le trafic métier. Pour les API publiques ou les services internes à fort QPS, c’est un piège.

2) Lequel est « meilleur » pour une limitation en BDD : MariaDB ou PostgreSQL ?

Aucun n’est « meilleur » dans le sens que vous voulez. Les deux sont d’excellentes bases OLTP. Les deux souffriront quand vous leur imposez des updates de compteurs partagés à haute fréquence. Les modes de défaillance diffèrent en instrumentation et maintenance, pas en résultat final.

3) Et si j’ai besoin de limites strictes et globalement cohérentes ?

Demandez-vous pourquoi. La plupart des exigences « strictes » concernent en réalité le contrôle des coûts ou la prévention d’abus. Utilisez un limiteur distribué conçu pour cela, ou acceptez des limites approximatives avec burst borné et monitoring fort. Si vous exigez vraiment la stricte cohérence, vous acceptez les coûts de la coordination — placez cette coordination dans un système fait pour ça, pas dans votre OLTP.

4) Puis-je juste utiliser une instance de base séparée pour la limitation ?

Vous pouvez, et c’est mieux que de contaminer votre OLTP primaire. Mais c’est toujours une base qui fait de la coordination d’écritures par requête. Vous redécouvrirez probablement les mêmes limites d’échelle, juste sur un feu plus petit et moins cher.

5) Pourquoi les « tables de compteurs avec TTL » nuisent-elles spécifiquement à PostgreSQL ?

Les mises à jour fréquentes créent des tuples morts et du WAL. Autovacuum doit les nettoyer ; s’il prend du retard, les performances se dégradent. Même s’il suit, vous dépensez I/O et CPU pour de la maintenance qui n’apporte pas de valeur métier.

6) Pourquoi nuisent-elles spécifiquement à MariaDB/InnoDB ?

InnoDB doit gérer redo/undo, des latches de pages et des hotspots potentiels dans les index clusterisés. Sous forte concurrence, vous verrez des attentes de verrous, des deadlocks et de la pression de purge. Le limiteur devient un point chaud d’écriture avec dégâts collatéraux.

7) Utiliser Redis pour limiter le débit n’est-ce pas juste déplacer le problème ?

Vous le déplacez vers un système conçu pour des opérations atomiques rapides et des TTL. Vous devez quand même architecturer pour les clés chaudes et les pics, mais vous n’imposez plus à votre base transactionnelle et à sa machinerie de durabilité une charge par requête.

8) Quelle est la protection la plus efficace pour la BDD ?

Limiter la concurrence vers la base. Les limites en requêtes par seconde sont indirectes ; les caps de concurrence sont directs. Si vous empêchez 500 requêtes BDD coûteuses concurrentes, la BDD reste debout.

9) Comment gérer les pannes du magasin de limiteur (ex. Redis en panne) ?

Décidez par endpoint : fail open pour les routes en lecture à faible risque, fail closed pour les routes sensibles aux abus (login, recherches coûteuses). Implémentez un circuit breaker et une valeur par défaut sûre. Testez en heures ouvrables, pas pendant un incident.

10) Et la « limitation dans la BDD » pour des services internes seulement ?

Le trafic interne est souvent le pire : retries, fan-out, jobs batch et volonté de marteler. Utilisez aussi des budgets et des bulkheads en interne. La BDD ne se soucie pas que la surcharge soit « interne ».

Conclusion : prochaines étapes pratiques

Si vous ne retenez qu’une chose : une base de données n’est pas un videur. Elle est la piste de danse. Ne lui faites pas contrôler les entrées pendant que la musique joue.

Prochaines étapes qui changent vraiment les résultats :

  1. Prouvez-le par les données : identifiez les statements du limiteur dans pg_stat_statements ou les digests MariaDB et quantifiez leur coût.
  2. Retirez la BDD du chemin de rejet : implémentez des limites en edge pour les endpoints principaux et arrêtez de retenter sur 429.
  3. Captez la concurrence BDD : pool de connexions et bulkheads par route et par locataire.
  4. Filez le travail lourd : empêchez les rafales HTTP de se transformer en rafales BDD.
  5. Conservez la durabilité où elle appartient : agrégez l’usage en rollups et stockez-les en base de façon asynchrone.

Vous délivrerez plus vite, dormirez mieux, et votre base reviendra à ce qu’elle fait de mieux : stocker des données, pas arbitrer l’ambition humaine.

← Précédent
Docker vs iptables/nftables : mettre fin à la guerre des pare-feu et réparer le réseau
Suivant →
Sécurité de zfs destroy : Les vérifications qui empêchent « Oups, mauvais dataset »

Laisser un commentaire