Künstlich verzögerte Replikation mit PostgreSQL

Bei unseren Arbeiten zum Thema “PostgreSQL für Delfine und Seelöwen” habe ich die PostgreSQL Dokumentation zum Thema Replikation durchgeackert. Was mir dabei aufgefallen ist, ist das PostgreSQL KEINE künstlich verzögerte Replikation kennt, zumindest was diese Doku anbelangt.

Nachdem ich mir bereits Gedanken darüber gemacht habe, wie man eventuell mit PostgreSQL Bordmitteln, eine künstlich verzögerte Replikation basteln könnte, habe ich doch noch mal die Suchmaschine meiner Wahl bemüht und, siehe da, beim zweiten Anlauf hat es dann doch noch geklappt: Ich wurde fündig! [ 2019: 1 ]

Die entsprechende Replikations-Konfiguration heisst: recovery_min_apply_delay [ 2 ]. Leider fehlt das in der Dokumentation zum Thema “Replikation”. Ich habe mal bei der PostgreSQL Community einen Verbesserungsvorschlag eingereicht. Mal schauen, wie weit ich da komme…

Anwendungsfälle

Aber warum will man überhaupt eine Replikation verzögern? Dazu sind mir bisher unterschiedliche Anwendungsfälle begegnet:

  • Am häufigsten wird dieses Setup gegen Ups!-Queries verwendet. Ein Fehler auf dem Primary würde bei einer normalen Replikation unmittelbar auf den Standby Servern einschlagen und auf diesen ebenfalls die Daten zerstören. Mit der verzögerten Replikation erhält man Zeit, den Fehler zu analysieren, allenfalls die Daten aus dem Standby zur Rettung herbeizuziehen oder den Stanby bis vor das Ups-Query aufholen zu lassen und dann auf den Standby zu schwenken. Siehe hierzu auch: PostgreSQL Point-in-Time-Recovery bei Ups-Queries.

  • Ein weiterer Anwendungsfall ist, einer bestimmten Nutzergruppe gezielt alte Daten zur Verfügung zu stellen, um sie bei entsprechender Bezahlung auf die aktuellen Daten zugreifen zu lassen (Freemium Geschäftsmodell) z. B. bei Börsenhandel, Computerwetten, Computerspielen, Sportresultaten, Job- oder sonstigen Angeboten oder Wetterdaten.

  • Ein dritter Anwendungsfall wäre, ein System zu testen, wie es sich bei Replikationsverzögerungen verhält. Bei einer asynchronen Replikation kann es potentiell immer zu Verzögerungen zwischen Primary und Standby kommen. Künstlich eine so hohe Last zu erzeugen, dass dieses Verhalten simuliert werden kann, ist oft schwierig. Mit der künstlich verzögerten Replikation kann man das Verhalten der Anwendung so gezielt testen und allenfalls die Anwendung robuster machen.

  • Der letzte Anwendungsfall wäre, schauen zu können, wie die Datenbank in der Vergangenheit ausgesehen hat, ohne ein Backup wieder einspielen zu müssen. Wenn man die Replikation zum Beispiel mit einer Verzögerung von einer Woche konfiguriert (bei PostgreSQL sind maximal knapp 40 Tage (232 Sekunden) möglich), kann man in der Folgewoche noch schauen, ob die Daten nach dem Change am Wochenende noch korrekt sind.

Aufsetzen der Primary Instanz

Der Primary muss zusätzlich wie folgt konfiguriert werden (Datenbank-Neustart erforderlich!):

#
# postgresql.conf
#
cluster_name     = 'pg18p'
listen_addresses = '*'
archive_mode     = on
archive_command  = 'test ! -f /mnt/backup/wal_archive/%f && cp %p /mnt/backup/wal_archive/%f && sync /mnt/backup/wal_archive/%f'

Damit alte WALs auf dem Primary nicht zu früh gelöscht werden, arbeiten wir hier mit einem “Replication Slot”. Dieser muss zuerst angelegt werden:

postgres=# SELECT * FROM pg_create_physical_replication_slot('slot_for_pg18s1');
    slot_name    | lsn 
-----------------+-----
 slot_for_pg18s1 | 

postgres=# SELECT slot_name, slot_type, active, wal_status, failover, synced
  FROM pg_replication_slots
;
    slot_name    | slot_type | active | wal_status | failover | synced 
-----------------+-----------+--------+------------+----------+--------
 slot_for_pg18s1 | physical  | f      |            | f        | f

Natürlich braucht es auch noch einen Replikations-User:

postgres=# CREATE ROLE replication WITH LOGIN PASSWORD 'secret' REPLICATION;

welcher natürlich auch von remote zugreifen können muss:

#
# pg_hba.conf
#
# TYPE  DATABASE        USER            ADDRESS                 METHOD
host    replication     replication     10.223.125.0/24         scram-sha-256

und ein anschliessendes Aktivieren der Konfiguration:

postgres=# SELECT pg_reload_conf();

Aufsetzen der Standby Instanz

Dann bauen wir den verzögerten Standby wie folgt auf:

$ sudo systemctl stop postgresql
$ PGDATA='/var/lib/postgresql/18/main'
$ rm -rf ${PGDATA}/*
$ pg_basebackup --user=replication --host=10.223.125.65 --format=plain --pgdata=${PGDATA}/
$ touch ${PGDATA}/standby.signal

passen die PostgreSQL Konfiguration wie folgt an:

#
# postgresql.conf
#
cluster_name = 'pg18s1'
archive_cleanup_command = 'pg_archivecleanup /mnt/backup/wal_archive %r'

recovery_target_timeline = latest   # default
primary_conninfo = 'host=10.223.125.65 port=5432 user=replication options=''-c wal_sender_timeout=5000'''
recovery_min_apply_delay = '5min'

Das Passwort kann in der Datei ~/.pgpass hinterlegt werden:

10.223.125.65:5432:replication:replication:secret

Dann starten wir die Standby Instanz und zum Schluss überprüfen wir, ob alles sauber funktioniert (ggf. recovery_min_apply_delay warten):

postgres=# SELECT pg_is_in_recovery();
 pg_is_in_recovery 
-------------------
 t

postgres=# SELECT CURRENT_TIMESTAMP AS now, pg_last_xact_replay_timestamp() AS last_xact_timestamp
     , age(CURRENT_TIMESTAMP, pg_last_xact_replay_timestamp())
;
             now              |      last_xact_timestamp      |       age       
------------------------------+-------------------------------+-----------------
 2026-07-29 11:46:21.92429+00 | 2026-07-29 11:46:21.919321+00 | 00:00:00.004969

Das Ups-Query

Jetzt passiert auf dem Primary das Ups-Query:

test=# UPDATE test SET data = 'alles kaputt!';
UPDATE 673

Nach etwas Schreckensstarre ermitteln wir die Zeit (Achtung: UTC!):

test=# SELECT current_timestamp;
       current_timestamp       
-------------------------------
 2026-07-31 14:10:30.083692+00

Stoppen der Replikation

Sobald das Ups-Query aufgetreten ist, müssen wir sofort die Replikation auf dem Standby stoppen:

postgres=# SELECT pg_is_in_recovery();
 pg_is_in_recovery 
-------------------
 t

postgres=# SELECT pg_get_wal_replay_pause_state();
 pg_get_wal_replay_pause_state 
-------------------------------
 not paused

postgres=# SELECT pg_wal_replay_pause();
 pg_wal_replay_pause 
---------------------
 
(1 row)

postgres=# SELECT pg_get_wal_replay_pause_state();
 pg_get_wal_replay_pause_state 
-------------------------------
 paused

postgres=# SELECT pg_is_wal_replay_paused();
 pg_is_wal_replay_paused 
-------------------------
 t

Allenfalls muss auch die Applikation angehalten werden, da ein weiteres Laufen lassen aus Business Sicht keinen Sinn macht.

Anschliessend haben wir Zeit, den Zeitpunkt des Ups-Queries zu ermitteln.

Nach weiterer Recherche und etwas Reserve legen wir den Zeitpunkt des Ups-Queries auf NACH 2026-07-31 14:10:00+00 fest!

Aufholen bis zum Ups-Query:

Dann lassen wir den verzögerten Slave aufholen bis zum Ups-Query:

#
# postgresql.conf
#
# recovery_min_apply_delay = '5min'   # Achtung: auskommentieren!
recovery_target_time = '2026-07-31 14:10:00+00'
# recovery_target_xid = '135586'
# recovery_target_lsn = '0/312DF748'
recovery_target_inclusive = off
recovery_target_action = 'pause'

Und machen die Datenbank nach Überprüfung zum Primary mit anschliessendem Failover:

postgres=# SELECT pg_promote();
 pg_promote 
------------
 t

In unserem Beispiel mussten wir mit 5 Rows (= 5 Sekunden) Datenverlust leben. Wer es genauer braucht, der muss mehr Zeit in das Bestimmen von recovery_target_* investieren. Wie das genau geht erklären wir hier: PostgreSQL Point-in-Time-Recovery bei Ups-Queries.