Paris, 15 mai 2025
La réplication logique est une machine complexe, la correction de bug est parfois longue à la fois pour rendre le problème reproductible et le corriger.
Un exemple récent est la correction de ce bug publié dans la version 17.5 de PostgreSQL et qui a nécessité toute la persévérance et l’ingéniosité des développeurs :
Éviter des pertes de données quand des opérations DDL qui ne posent pas de verrous forts affectent les tables qui sont répliquées logiquement (Shlok Kyal, Hayato Kuroda)
Les changements du catalogue causés par ces commandes DDL n’étaient pas reflétés dans les processus de décodage des WAL. Le décodage qui suivait se basait alors sur des données périmées, ce qui pouvait provoquer des corruptions de données.
Le contexte
L’histoire commence avec quelques bugs remontés par des utilisateurs qui constataient des pertes de données dans leurs dispositifs de réplication, comme dans ce rapport de bug. L’utilisateur y décrit que lors de l’initialisation d’une nouvelle base avec la réplication logique, après la copie initiale des données, l’activation des clés étrangères est impossible car il manque des lignes sur la souscription. Le problème n’arrive pas tout le temps, et il ne semble pas y avoir de dénominateur commun entre les serveurs impactés.
Le problème a fini par être reproduit par Tomas Vondra, qui a déterminé que le souci venait d’un défaut d’invalidation du cache sur l’instance publieuse.
Ce défaut a différentes conséquences :
-
certaines données ne sont pas répliquées alors qu’elles devraient :
-- Menage SELECT * FROM pg_drop_replication_slot('test'); DROP PUBLICATION pb ; DROP TABLE d; -- SESSION 1 CREATE PUBLICATION pb; -- SESSION 3 SELECT * FROM pg_create_logical_replication_slot('test', 'pgoutput', false, true); -- SESSION 1 CREATE TABLE d(data text not null); INSERT INTO d VALUES('d1'); -- SESSION 2 BEGIN; INSERT INTO d VALUES('d2'); -- SESSION 1 ALTER PUBLICATION pb ADD TABLe d; -- SESSION 2 COMMIT; INSERT INTO d VALUES('d4'); -- SESSION 1 INSERT INTO d VALUES('d5'); -- SESSION 3 SELECT encode(data, 'escape') as conv_data FROM pg_logical_slot_peek_binary_changes('test', null, null, 'proto_version', '1', 'publication_names', 'pb' );
La commande ne renvoie pas les données modifiées après l’
ALTER PUBLICATION
. -
des données continuent à être répliquées après qu’une table est retirée de la publication :
-- Menage SELECT * FROM pg_drop_replication_slot('test'); DROP PUBLICATION pb ; DROP TABLE d; -- SESSION 1 CREATE TABLE d(data text not null); INSERT INTO d VALUES('d1'); CREATE PUBLICATION pb FOR TABlE d; -- SESSION 3 SELECT * FROM pg_create_logical_replication_slot('test', 'pgoutput', false, true); -- SESSION 2 BEGIN; INSERT INTO d VALUES('d2'); -- SESSION 1 ALTER PUBLICATION pb DROP TABLE d; -- SESSION 2 COMMIT; INSERT INTO d VALUES('d4'); -- SESSION 1 INSERT INTO d VALUES('d5'); -- SESSION 3 SELECT encode(data, 'escape') as conv_data FROM pg_logical_slot_peek_binary_changes('test', null, null, 'proto_version', '1', 'publication_names', 'pb' );
La commande renvoie alors des lignes qui ne devraient pas être répliquées :
conv_data -------------------------------------------------------------------------------------------------- B\000\000\000\000\x11\250\320h\000\x02\321\217t\r\250a\000\x01\3534 R\000\000a\337public\000d\000d\000\x01\000data\000\000\000\000\x19\377\377\377\377 I\000\000a\337N\000\x01t\000\000\000\x02d2 C\000\000\000\000\000\x11\250\320h\000\000\000\000\x11\250\320x\000\x02\321\217t\r\250a B\000\000\000\000\x11\250\320\270\000\x02\321\217t\r\275\263\000\x01\3536 I\000\000a\337N\000\x01t\000\000\000\x02d4 <= ici C\000\000\000\000\000\x11\250\320\270\000\000\000\000\x11\250\320\350\000\x02\321\217t\r\275\263 B\000\000\000\000\x11\250\321(\000\x02\321\217tov\234\000\x01\3537 I\000\000a\337N\000\x01t\000\000\000\x02d5 <= la C\000\000\000\000\000\x11\250\321(\000\000\000\000\x11\250\321X\000\x02\321\217tOV\234 (10 rows)
Les deux exemples ci-dessus utilisent des INSERT
par commodité mais l’observation
est vraie pour UPDATE
, DELETE
et TRUNCATE
aussi.
Plus de détails sur le problème
Ce bug est dû au fait que la réplication logique ne provoque pas d’invalidation
du cache dans les transactions concurrentes lorsque des commandes DDL (par
example ALTER|DROP PUBLICATION
et ALTER TYPE
) sont exécutées. Les
transactions concurrentes continuent donc à décoder leurs changements sur la
base d’informations incorrectes. Le cache en question est le relcache, qui est
privé au backend, il contient des meta-données sur les tables et index, et
notamment la liste des publications. Ce cache continue d’être utilisé après que
ces transactions ont été terminées, ce qui aggrave le problème.
La définition du problème a considérablement évolué, de quelques commandes ALTER
PUBLICATION
à presque toutes, et les solutions évoquées pour résoudre le
problème avec.
Au début, il était question de changer le niveau de verrou de SHARED UPDATE
EXCLUSIVE
à SHARED ROW EXCLUSIVE
voire ACCESS EXCLUSIVE
. Quand les
commandes ALTER PUBLICATION .. ADD TABLES IN SCHEMA
et DROP PUBLICATION
sont apparues dans la discussion, la solution a été mise de côté… cela aurait
provoquer un verrouillage fort de toutes les tables de la publication et donc
potentiellement… de la base de données. Un autre effet indésirable de cette
solution est qu’elle pouvait aussi provoquer des deadlocks dans certains cas.
Une nouvelle option a donc été explorée : invalider le cache des transactions en cours. Elle a d’abord sucité des inquiétudes quant à son potentiel impact sur les performances, les quelles ont été dissipées lors des tests. Elle pose également une question intéressante sur la validité du résultat ci-dessous :
BEGIN;
INSERT INTO matable (id int, statut, text) values (1, 'created');
UPDATE matable SET statut = 'processing' where id = 1;
Dans une autre transaction, on arrête la publication des modifications pour
matable
, puis on revient sur la première session pour terminer la transaction
en cours :
UPDATE matable SET statut = 'done' where id = 1;
COMMIT;
La souscription se retrouve avec le statut ‘processing’, du point de vue séquence c’est compréhensible, mais comme on est dans une transaction, je trouve ça étrange.
Le patch
Le patch final, reprend la dernière proposition. Il corrige le problème en distribuant les messages d’invalidation depuis les transactions qui modifient le catalogue vers toutes les transactions concurrentes. Cela permet de reconstruire le cache et donc de décoder correctement les changements suivant les commandes DDL.
L’impact au niveau performance n’est sensible que si l’on exécute beaucoup DDL sur les publications. Pour des modifications isolées ou qui ne concernent pas les tables publiées, le coût est négligeable.
La modification a été reportée telle quelle jusqu’en version 14. La version 13 ne contient pas toute l’infrastructure nécessaire pour corriger l’ensemble du problème et les modifications nécessaires pourraient avoir des effets secondaires ou provoquer d’autres bugs pour une version qui est en fin de vie.
L’exemple suivant montre les cas pris en charge sur la version 13 :
-- SESSION 1
CREATE TABLE d(data text not null);
INSERT INTO d VALUES('d1');
-- SESSION 2
BEGIN;
INSERT INTO d VALUES('d2');
-- SESSION 1
ALTER PUBLICATION pb ADD TABLE d;
-- SESSION 2
INSERT INTO d VALUES('d3');
COMMIT
INSERT INTO d VALUES('d4');
-- SESSION 1
INSERT INTO d VALUES('d5');
Avant le patch, aucune modification n’aurait été publiée. Avec ce patch les
modifications d4
et d5
sont publiées. Sur des versions plus récentes : d3
,
d4
et d5
sont publiées.
Mettre à jour souvent
La correction de ce bug peut passer inaperçue dans la note de mise à jour de la version 17.5, pourtant son impact est important. La morale de cette histoire est donc qu’il faut mettre à jour vos instances régulièrement, afin de bénéficier de toutes les corrections faites dans les mises à jour mineures et qui peuvent impacter la durabilité et la sécurité de vos données ainsi que les performances de l’instance.
Mot de fin
Un grand merci à Hubert Depesz Lubaczewski, Tomas Vondra, Shlok Kyal, Hayato Kuroda, Zhijie Hou, Masahiko Sawada et Amit Kapila pour avoir remonté le problème, et créé, relu, ainsi que testé le patch.