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.


DALIBO

DALIBO est le spécialiste français de PostgreSQL®. Nous proposons du support, de la formation et du conseil depuis 2005.