Les façons de capturer les changements dans Postgres
(blog.sequin.io)- Pour transmettre en temps réel les changements de Postgres à d’autres systèmes, il faut du CDC (Change Data Capture) ; selon l’option choisie, de la simple notification à la réplication basée sur le WAL, la fiabilité et la charge opérationnelle varient fortement
- Listen/Notify est le moyen le plus léger pour démarrer, mais avec une livraison at-most-once, des notifications éphémères et une limite de payload de 8 000 octets, il relève davantage d’un signal auxiliaire que d’un CDC central
- Le polling de tables et les tables d’audit (outbox pattern) peuvent être implémentés uniquement avec des tables et triggers standards, mais il faut gérer soi-même la détection des suppressions, les diffs, l’ordre des commits, l’amplification des écritures et la backpressure
- La réplication logique (logical replication) est une approche puissante qui streame les insert/update/delete depuis le WAL, mais l’application doit gérer les replication slots, les ack, les redémarrages et la montée en débit
- Sequin s’appuie sur la réplication logique de Postgres pour transmettre les changements vers SQS, Kafka, Elasticsearch, Redis, des endpoints HTTP, etc., et réduit la charge liée à la gestion directe des replication slots
Quand on a besoin de CDC avec Postgres
- Postgres est très efficace pour manipuler les données stockées, mais si l’on veut déclencher des workflows à partir de changements de tables ou streamer en temps réel vers d’autres stores de données, systèmes ou services, il faut concevoir séparément le déplacement des données
- Le Change Data Capture (CDC) consiste à identifier et capturer les changements de la base de données, puis à les transmettre en temps réel aux systèmes downstream
- Il existe plusieurs façons de capturer les changements dans Postgres, avec des niveaux de difficulté d’implémentation, de fiabilité et de charge opérationnelle différents
Listen/Notify : le pub-sub le plus simple
- Listen/Notify de Postgres est une fonctionnalité de communication inter-processus qui fonctionne selon le pattern publish-subscribe
- Une session peut
listensur un canal donné, et l’activité de la base de données ou d’autres sessions peuvent envoyer unnotifysur ce canal - Pour capturer les changements, on peut l’utiliser avec un trigger
- Un exemple de trigger consiste, au moment d’un
after insert or update or delete, à construire en JSON letable, l’idet l’actionde l’enregistrement modifié, puis à appelerpg_notify('table_changes', payload::text)
- Un exemple de trigger consiste, au moment d’un
- Les limites sont nettes
- La sémantique de livraison est at-most-once : le listener doit être connecté au moment où la notification est publiée
- Le listener ne reçoit que les notifications émises après son abonnement ; une brève coupure réseau peut donc faire perdre des notifications
- La taille du payload est limitée à 8 000 octets ; au-delà, la commande
notifyéchoue - Le nom du canal est inclus dans la taille du payload et, comme les identifiants Postgres, il peut faire jusqu’à 64 octets
- C’est utilisable pour une détection basique des changements ou pour optimiser le polling de tables, mais cela peut mal convenir à des besoins CDC complexes
Polling de tables : simple, mais faible sur les suppressions et les diffs
- La façon robuste la plus simple de capturer les changements est de poller directement les tables
- Chaque table doit disposer d’une colonne comme
updated_at, mise à jour à chaque modification de ligne, et éventuellement créée via un trigger - On utilise la combinaison de
updated_atetidcomme curseur, et la logique applicative stocke et gère ce curseur - En ajoutant un abonnement Notify, on peut informer l’application des insertions et modifications d’enregistrements afin de réduire la fréquence du polling
- Les notifications Postgres étant éphémères, elles conviennent surtout comme optimisation au-dessus du polling
- Les principaux inconvénients sont au nombre de trois
- Les lignes supprimées ne restant pas dans la table, la détection des suppressions est impossible
- Comme contournement, un trigger delete peut stocker l’
idet les colonnes nécessaires dans une table séparée commedeleted_contacts, que l’application pourra poller - On peut savoir qu’un enregistrement a été mis à jour, mais pas ce qui a changé
- Les datetime et sequences de Postgres pouvant ne pas respecter l’ordre des commits, on peut manquer des lignes encore en cours de commit pendant la lecture d’un bloc basé sur
updated_at
- C’est une option raisonnable pour un suivi simple des changements lorsque les suppressions, les diffs et les oublis occasionnels ne sont pas de gros problèmes
Tables d’audit : stocker le journal des changements avec l’outbox pattern
- L’approche par table d’audit (audit table) enregistre les changements dans une table
changelogséparée ; on l’appelle aussi outbox pattern changelogpeut contenir les colonnes liées au changementaction: indiqueinsert,updateoudeleteold:jsonbde l’enregistrement avant changement, vide pour un insertvalues:jsonbdes champs modifiés, vide pour un deleteinserted_at: heure à laquelle le changement s’est produit
- Pour l’implémenter, il faut une fonction trigger qui insère dans
changelogà chaque changement, ainsi qu’un trigger par table à surveiller - On peut aussi consommer
changelogcomme une file- Un worker applicatif récupère les changements depuis la table
- Pour un traitement approximativement exactly-once, on peut utiliser
for update skip lockedde Postgres - Le worker peut ouvrir une transaction, verrouiller un lot avec
order by timestamp limit 100 for update skip locked, le traiter, supprimer les enregistrements traités, puis committer
- Il existe des inconvénients opérationnels
- Une écriture sur une table unique entraîne plusieurs écritures dans la table d’audit : c’est de l’amplification d’écriture (write amplification)
- En général, il y a au minimum trois écritures : l’insert initial dans la table d’audit, un update pendant le traitement, puis un delete après traitement
- Le fan-out via workers doit être conçu sur mesure pour l’application
- Avant un déploiement à l’échelle de la production, il faudra probablement ajuster les fonctions trigger et le design des tables
- On peut aussi définir des politiques détaillées, par exemple une limite de temps pendant laquelle un worker peut garder un changement check-outé
- Même si un worker ne parvient pas à traiter les changements, la table d’audit continue de se remplir, ce qui limite la gestion de la backpressure
Foreign Data Wrapper : une option plutôt proche de la synchronisation entre Postgres spécifiques
- Foreign Data Wrapper (FDW) est une fonctionnalité qui permet à une base Postgres de lire et d’écrire dans des sources de données externes
- L’extension basée sur FDW la plus largement prise en charge est
postgres_fdw- Elle permet de connecter deux bases Postgres et de créer, depuis une base, une structure proche d’une view qui référence les tables de l’autre base
- En interne, une base Postgres devient cliente et l’autre serveur
- Lorsqu’on interroge une foreign table, la base cliente envoie une requête à la base serveur via le wire protocol de Postgres
- FDW n’est pas une méthode courante de capture des changements et il est difficile de le recommander en dehors de situations très spécifiques
- Si l’on veut écrire les changements d’une base Postgres dans une autre base Postgres, FDW peut être approprié
- Par exemple dans une situation où l’on utilise séparément une base de données comptable et une base de données applicative
- On peut sauter l’étape intermédiaire de capture des changements et répliquer directement entre bases avec
postgres_fdw
- Il est aussi possible d’écrire son propre FDW pour POSTer les changements vers une API interne
- Comme l’écriture vers l’API se fait dans le commit, l’API peut rejeter le changement et provoquer un rollback du commit
- FDW est puissant, mais il est rarement le meilleur choix pour du CDC, et écrire soi-même un FDW fait partie des travaux les plus lourds parmi les méthodes de capture des changements
- L’écriture d’un FDW personnalisé est devenue plus simple avec des outils comme Supabase wrappers, mais cela reste un gros chantier
Réplication logique directe : un CDC puissant basé sur le WAL
- Postgres dispose de protocoles pour la réplication de bases de données, dont la réplication logique (logical replication)
- La réplication logique est construite au-dessus du WAL (write-ahead log) de Postgres
- Tous les insert, update et delete de la base sont suivis
- Les changements sont streamés vers un subscriber
- L’utilisateur commence par créer un replication slot sur le primary
- On utilise une forme comme
pg_create_logical_replication_slot('<your_slot_name>', '<output_plugin>')
- On utilise une forme comme
output_plugindésigne le plugin qui décodera les changements du WALpgoutputest le plugin par défaut et produit une sortie au format binaire attendu par le serveur clienttest_decodingest un plugin de sortie simple qui fournit les changements du WAL sous une forme lisible par un humain- Bien qu’il ne soit pas intégré à Postgres,
wal2jsonest un plugin populaire ; JSON est plus facile à manipuler comme point de départ applicatif que le format binaire de Postgres
- Après avoir créé le replication slot, on peut démarrer et consommer
- Un replication slot utilise une zone du protocole Postgres différente de celle des requêtes standards
- Plusieurs bibliothèques clientes fournissent des fonctions pour travailler avec les replication slots
- Dans un exemple avec
psycopg2, on consomme les messages WAL aveccursor.start_replication(...)etcursor.consume_stream(...), puis on envoie un ack aveccursor.send_feedback(flush_lsn=msg.wal_end)
- Le client doit ack les messages WAL reçus, et le replication slot fonctionne un peu comme Kafka avec un offset
- La réplication logique est une méthode robuste conçue pour le CDC, mais elle est complexe
- Les replication slots et le replication protocol sont moins familiers aux développeurs que les tables et requêtes classiques
- Il faut une stratégie pour ne pas perdre de messages pendant les redémarrages
- Il faut concevoir le système pour absorber le volume important de messages provenant de Postgres
Sequin : un outil CDC qui encapsule la réplication logique
- Sequin est un outil CDC qui transmet les changements et les lignes Postgres vers des files, streams, index de recherche, caches, endpoints HTTP, etc.
- Les destinations incluent SQS, Kafka, Elasticsearch, Redis, des HTTP endpoints, etc.
- En interne, Sequin utilise la réplication logique de Postgres, mais abstrait la complexité du protocole low-level
- Il peut capturer les insert, update et delete ; pour les update et delete, il capture à la fois les valeurs
newetoldde la ligne - Les conditions dans lesquelles envisager Sequin sont les suivantes
- Vous avez besoin de CDC en temps réel
- Vous voulez streamer directement vers une destination comme SQS ou un webhook, sans système intermédiaire
- Vous avez besoin de fonctionnalités comme le backfill de données historiques et le filtrage des changements basé sur des clauses SQL
where - Vous cherchez une alternative plus simple que la gestion directe des replication slots
- Vous avez besoin de garanties de traitement exactly-once
- Il existe aussi des inconvénients
- Sequin n’est pas une extension interne de Postgres, mais un outil tiers qui s’exécute à côté de la base de données
- Comme ce n’est pas une extension, il offre une large compatibilité avec toutes sortes de bases Postgres, mais si vous n’utilisez pas Sequin Cloud, vous devez mettre en place vous-même une infrastructure supplémentaire
Critères de choix
- Au début, Listen/Notify et le polling de tables conviennent bien
- Listen/Notify est utile pour capturer des événements non critiques, prototyper et optimiser le polling
- Le polling est une solution correcte et directe pour des cas d’usage simples
- À un stade un peu plus sérieux, une table d’audit peut être un choix intermédiaire
- Elle permet de capturer les payloads
newetolddes lignes - Bien conçue, elle peut fournir un système de traitement exactly-once
- À l’échelle, l’amplification des écritures et l’absence de backpressure deviennent problématiques, et une configuration manuelle erronée peut faire perdre des messages
- Elle permet de capturer les payloads
- À l’échelle, la réplication logique est ce qui se rapproche le plus d’une solution robuste
- Il est toutefois recommandé d’utiliser un outil comme Sequin plutôt que de lire directement depuis un slot
- FDW est une fonctionnalité intéressante, mais elle a peu de chances de répondre aux besoins CDC courants
1 commentaires
Avis Hacker News
Les triggers + tables d’historique (tables d’audit) sont la bonne réponse dans 98 % des cas. Si vous ne les utilisez pas déjà, vous pouvez commencer dès aujourd’hui. C’est une technique éprouvée depuis plus de 30 ans.
Un exemple simple d’implémentation générique se trouve ici : https://gist.github.com/slotrans/353952c4f383596e6fe8777db5d.... C’est une approche qui renonce à l’efficacité en espace au profit d’une « implémentation facile ».
Pouvoir stocker des données immuables serait vraiment idéal, mais votre base contient probablement énormément de données mutables, et il est fort possible que vous oubliiez beaucoup de choses chaque jour. N’oubliez pas : utilisez des tables d’historique.
Référence : https://github.com/matthiasn/talk-transcripts/blob/master/Hi...
Mieux vaut éviter les bibliothèques ou techniques de suivi d’historique au niveau applicatif, comme Papertrail. Elles sont lentes, sujettes aux erreurs, et ne capturent pas les modifications de la DB qui contournent la stack applicative. Essayer de renseigner un timestamp
updateddepuis l’application est aussi fondamentalement erroné, car chaque serveur web a sa propre horloge. Il faut utiliser l’horloge de la DB, et c’est la seule horloge correcte.now()dans la requête.Mais synchroniser uniquement sur la base de ce timestamp ne suffit pas. En effet, le timestamp est généré au début de la transaction, et non au moment du commit.
Si vous pollez une table en filtrant sur les timestamps récents, vous pouvez manquer une partie des transactions dont l’ordre de commit est entremêlé. On peut prévoir une fenêtre tampon en remontant quelques minutes plus loin dans le passé et en dédupliquant, mais dans PostgreSQL la durée des transactions n’est pas bornée, et remonter trop loin devient très coûteux. Si l’exactitude et l’efficacité comptent, cette approche n’est pas la bonne.
Avec le numéro de séquence du journal, l’heure de la DB et
REPLICA IDENTITY FULL, on obtient même les états avant/après des changements. Ensuite, en matérialisant des collections dans un système comme Snowflake, on obtient par défaut des tables synchronisées qui suivent les mises à jour de la DB source.Depuis le même data lake sous-jacent, on peut aussi transformer ou matérialiser l’historique complet des tables à des fins d’audit, sans devoir rattacher une nouvelle capture ou un lecteur WAL à la DB source.
J’ai aussi décrit séparément une approche SQLite qui implémente un motif similaire avec des colonnes plutôt qu’avec du JSON : https://simonwillison.net/2023/Apr/15/sqlite-history/
Cet article résume de façon concise les différentes approches possibles avec les fonctionnalités de base de Postgres.
Pour la partie « capturer les changements dans une table d’audit », nous avons bien utilisé le motif des Temporal Tables dans mon entreprise précédente. Contrairement à d’autres grands SGBDR, Postgres ne l’intègre pas nativement, mais il existe un motif simple exploitable via des fonctions SQL : https://github.com/nearform/temporal_tables
Il permet de voir l’état d’une table à un instant donné, et donc de répondre à des questions comme : « quels étaient les paramètres de cet utilisateur le 12 août ? », « combien d’enregistrements en attente y avait-il hier soir à 23 h 55 ? », ou « montrez-moi les différences de feature flags entre aujourd’hui et il y a une semaine ».
J’ai déjà fait du conseil dans une entreprise qui avait un très gros SQL Server monolithique. Ce n’était pas Postgres, mais on peut supposer que ce serait similaire avec Postgres.
Il avait été exploité pendant des dizaines d’années et servait à toutes sortes d’usages dans l’entreprise ; en pratique, presque toutes les applications et tous les processus métier de l’entreprise stockaient leurs données dans cette base.
Le problème, c’est qu’il y avait beaucoup d’applications qui interrogeaient cette DB, et un nombre énorme de processus et procédures qui inséraient ou modifiaient des données ; quand des processus amont d’insertion ou de modification changeaient, ou que de nouveaux étaient ajoutés, ils pouvaient casser des invariants au niveau applicatif. Même des processus normaux se comportaient différemment en présence de mauvaises données.
Il était très difficile d’en retrouver la cause, parce que ce qu’on inspectait datait souvent de 10 ans et que les employés qui l’avaient écrit avaient déjà quitté l’entreprise.
Je me demande s’il serait possible de capturer les changements d’une base Postgres sous forme de DAG, pour savoir quels processus insèrent, modifient et suppriment des données, comment ils se sont comportés historiquement, comment plusieurs applications interrogent ces données et comment les statistiques de requêtes évoluent au fil du temps.
Je ne sais pas s’il existe des précédents, ni quelle approche permettrait de construire un tel outil. J’avais déjà envisagé de faire quelque chose de similaire, mais j’ai l’impression que c’est un domaine où il faut une compréhension du niveau d’un ingénieur core Postgres pour faire les bons choix.
On n’obtient pas, pour chaque changement, les données d’origine au niveau client.
Mais il existe des contournements. Le flux de réplication logique peut aussi inclure les messages d’information de la fonction
pg_logical_emit_message, donc le client peut y insérer lui-même des métadonnées. Il serait peut-être possible de configurer l’émission d’un identifiant client au début de chaque transaction.last updated by). C’est peut-être un antipattern, donc une solution plus robuste serait préférable.Cette approche fonctionne avec presque toutes les solutions de type SQL qui utilisent le WAL ou des triggers.
J’ai utilisé plusieurs fois l’approche par triggers avec SQL Server, mais journaliser toutes les requêtes a tendance à ralentir le système. Concevoir un mécanisme d’insertion qui ne bloque pas la production n’est pas parfait, et un échantillonnage peut être nécessaire.
Si vous partez sur la voie des « tables d’audit », autant utiliser simplement pgaudit. C’est une extension éprouvée en production, et si vous utilisez AWS, elle est aussi disponible sur RDS.
https://github.com/pgaudit/pgaudit/blob/master/README.md
https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Appen...
https://github.com/arkhipov/temporal_tables
https://news.ycombinator.com/item?id=26748096
Ce n’est pas forcément nécessaire. Vouloir cela revient à transformer les relations Postgres en contrats. Aucun service ne peut plus persister son état interne.
C’est peut-être possible si l’on s’engage vraiment dans le domain-driven design, mais il vaut mieux utiliser un système événementiel léger et pragmatique.
Un truc événementiel est 1000 fois plus complexe.
Interroger une colonne
updated_atpar polling n’est pas robuste dans sa forme la plus simple, car rien ne garantit que les transactions soient commit dans cet ordre.updated_atde la ligne 1 est défini à2023-09-22 12:00:01.Peu après, la transaction B démarre, le
updated_atde la ligne 2 est défini à2023-09-22 12:00:02, et B est commit en premier.La requête de polling s’exécute, voit la ligne 2 comme dernier changement et met à jour le curseur à
2023-09-22 12:00:02; si A est commit ensuite, la ligne 1 sera manquée.Une façon simple d’éviter ce problème est de ne pas faire de polling quasiment en temps réel. L’ordre finit par devenir cohérent.
Une proposition plus robuste pourrait être d’utiliser une séquence. Par exemple, avoir une colonne
updated_at_idxincrémentée à chaque modification d’une ligne.Je me demande si, même avec un trigger before qui met
now(), les timestampsupdated_atde deux lignes peuvent être différents de l’ordre de commit des transactions.updated_atet le timestamp de commit n’ont pas besoin d’être identiques, maisupdated_atdoit représenter précisément l’ordre des commits à la milliseconde ou à la microseconde près.updated_at, une colonne_txidque le trigger définit avec l’ID de transaction courant. Ensuite, lors du polling, on utilisetxid_current()pour vérifier quelles transactions ont été commit et lesquelles ne le sont pas encore.C’est un peu limite et très facile à rater avec des erreurs de borne, mais ça tourne bien en production depuis plusieurs années.
Excellent article.
Si vous utilisez Elixir et Postgres, j’ai créé une petite bibliothèque qui écoute les changements du WAL avec une approche similaire : https://github.com/cpursley/walex
Ces approches sont toutes un peu médiocres, et personnellement je pense que le polling est la plus pratique
J’aimerais que Postgres innove dans ce domaine
Tant que ce n’est pas dans le standard SQL, je pense qu’il sera difficile de créer une dynamique dans l’espace noyau des SGBD relationnels. Les options sont nombreuses et complexes, et les solutions réussies côté utilisateur ne sont pas non plus excessivement coûteuses en performances
À noter que les personnes qui travaillent sur ce domaine penchent généralement pour l’approche des tables d’audit. Parce qu’elle conserve des propriétés ACID cohérentes dans la base de données, et maintient Postgres comme point de défaillance unique au lieu d’ajouter un proxy ou des tâches de polling
Il y a un grand manque dans le monde de la donnée. Au lieu de demander les résultats à un magasin de données, ce serait bien que les résultats de requêtes soient poussés de manière incrémentale
On fait beaucoup d’analytique temps réel et streaming : on peut faire du traitement de flux, et aussi en traiter une partie dans le magasin de données avec des vues matérialisées. Mais une fois que les données sont entrées dans une DB ou un data lake, pour observer les changements en aval on revient en pratique au polling
Il existe peu de solutions propres pour réagir lorsqu’une situation se produit dans les données, ou pour mettre à jour l’écran sans rechargement de page. Les solutions de cet article ressemblent davantage à des contournements qu’à des fonctionnalités de première classe
Si l’on veut créer un rapport qui se met à jour en temps réel sans rechargement de page, on finit généralement par charger les données depuis la DB, puis par faire circuler les changements vers l’interface graphique avec Kafka et WebSocket. On se retrouve alors à exploiter une étrange architecture lambda, où certaines analyses sont traitées dans le code et d’autres dans la DB
Il y a de l’innovation dans ce domaine. KSQL et Kafka Streams peuvent émettre des changements, Materialize propose des abonnements, et ClickHouse a des vues live. Mais beaucoup de fonctionnalités sont récentes ou en préversion, et ne conviennent pas parfaitement. Je les ai toutes essayées, et j’ai eu l’impression qu’elles reportaient trop de travail sur les développeurs
J’aimerais qu’il existe une bibliothèque permettant de recevoir directement un flux de changements avec une option du type
[select * from orders with suscribe]. C’est un domaine suffisamment important, mais qui a reçu trop peu d’attentionIl y a un gros piège de la réplication que l’article n’aborde pas, et c’est pour cela que je ne l’utilise pas
Postgres cherche à garantir très fortement que les consommateurs des slots de réplication ne manquent pas de données. Donc si un consommateur ne consomme pas les données de son slot, Postgres conserve gentiment les données manquées, jusqu’à ce que le disque finisse par être plein et que la DB tombe. Cela m’est arrivé pendant du prototypage sur deux DB SaaS différentes, et la seule façon de récupérer était d’ouvrir un ticket au support
Si un consommateur de slot de réplication cesse de lire, une alerte doit impérativement se déclencher
Une autre raison est que le chemin de code pour récupérer le snapshot initial d’une table et celui pour lire les changements sont complètement différents. Initialiser la lecture d’un slot de réplication sans manquer le moindre changement n’a rien de trivial
Malheureusement, du point de vue de la capture de changements, la réplication est la solution la moins bricolée
J’utilise le polling, mais je stocke le txid au lieu de
updated_atJe serais curieux de savoir quel comportement vous préféreriez
Si vous traitez de gros volumes de données, vous aurez envie de gérer différemment le snapshot initial et la lecture des changements. Parce qu’il faut pouvoir faire des choses comme une initialisation parallèle ou une initialisation basée sur une sauvegarde physique. Cela dit, je comprends qu’une fonctionnalité permettant de streamer sélectivement les données existantes après la création du slot pourrait être utile
La partie consistant à initialiser la lecture du slot de réplication sans manquer de changements ne devrait pas être difficile, me semble-t-il ; je serais curieux de savoir où vous avez bloqué
Quand on n’a pas besoin de tous les changements, les slots de réplication temporaires, qui se nettoient d’eux-mêmes lorsque la connexion est coupée, sont aussi utiles. Il existe aussi une configuration pour fixer un maximum de WAL conservés afin de ne pas tuer le serveur
J’aimerais en savoir plus sur la manière dont vous utilisez txid au lieu de
updated_at