Replication lag and where to observe it

Replication serves two purposes at once: scaling a database to ever larger workloads, and making the infrastructure redundant and resilient. Both come with a trade-off — the possibility of replication lag, which has to be watched. The central source of insight on the sending side is the pg_stat_replication system view, which holds the information needed to identify and diagnose replication problems.

That view describes WAL senders. Whenever two PostgreSQL servers talk to each other, the sending machine runs a WAL sender process and the receiving machine runs a WAL receiver. One WAL sender exists per destination, and pg_stat_replication reports on exactly those processes. On a primary it is populated as expected; under cascading replication it also returns rows when the server queried is a replica forwarding data to other replicas.

An example setup for these checks is a PostgreSQL 16 database server with a single replica.

monitoring postgresql replication primary and secondary database

Here is what the view looks like:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

postgres=# d pg_stat_replication

                    View 'pg_catalog.pg_stat_replication'

      Column      |           Type           | Collation | Nullable | Default

------------------+--------------------------+-----------+----------+---------

pid              | integer                  |           |          |

usesysid         | oid                      |           |          |

usename          | name                     |           |          |

application_name | text                     |           |          |

client_addr      | inet                     |           |          |

client_hostname  | text                     |           |          |

client_port      | integer                  |           |          |

backend_start    | timestamp with time zone |           |          |

backend_xmin     | xid                      |           |          |

state            | text                     |           |          |

sent_lsn         | pg_lsn                   |           |          |

write_lsn        | pg_lsn                   |           |          |

flush_lsn        | pg_lsn                   |           |          |

replay_lsn       | pg_lsn                   |           |          |

write_lag        | interval                 |           |          |

flush_lag        | interval                 |           |          |

replay_lag       | interval                 |           |          |

sync_priority    | integer                  |           |          |

sync_state       | text                     |           |          |

reply_time       | timestamp with time zone |           |          |

Reading the state of each stream

In a cascading configuration, a server in the middle carries both a WAL receiver and one or more WAL senders.

monitoring postgresql replication cascading primary secondary

In this particular setup one server streams to a secondary on the same machine, and querying the view returns a single row:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

postgres=# x

Expanded display is on.

postgres=# SELECT * FROM pg_stat_replication;

-[ RECORD 1 ]----+------------------------------

pid              | 48929

usesysid         | 10

usename          | hs

application_name | walreceiver

client_addr      | ::1

client_hostname  |

client_port      | 61445

backend_start    | 2023-08-01 10:53:48.068431+02

backend_xmin     |

state            | streaming

sent_lsn         | 0/303C750

write_lsn        | 0/303C750

flush_lsn        | 0/303C750

replay_lsn       | 0/303C750

write_lag        |

flush_lag        |

replay_lag       |

sync_priority    | 0

sync_state       | async

reply_time       | 2023-08-01 10:55:00.783736+02

That single entry means there is one active stream. The important consequence is that inactive streams produce no row at all: if a stream is supposed to exist and there is no entry for it, streaming is not active. Verifying that the expected row exists is therefore the primary check.

The state of the stream matters just as much. A healthy stream can be identified as streaming; states such as catchup appear while the server is still syncing. The basic rule is to confirm that the right processes are correctly aimed at the right replication target.

  • A missing row means a missing stream, not an idle one.
  • A state other than streaming warrants a closer look.

The four *_lsn columns

After the existence and state of the streams, the deeper investigation concerns the *_lsn columns. Four of them describe the path data takes from one server to the next:

  • sent_lsn
  • write_lsn
  • flush_lsn
  • replay_lsn
monitoring postgresql replication flow of data and relationship to columns

Data arrives at the second server through the WAL receiver; that position is sent_lsn, known as sent_location in older PostgreSQL versions.

Once received, the data is processed and handed to the operating system: PostgreSQL asks the kernel to write it. This does not prove the data reached the disk — it only means it was passed to the file system, which may cache it. write_lsn is the position in the transaction log stream that has made it that far.

flush_lsn is the next relevant value, and the distinction from write_lsn is that writing to a file is not the same as persisting it. write_lsn measures how much data has been sent to the kernel, while flush_lsn measures how much has actually been flushed, meaning the data will survive a power outage.

replay_lsn is the value that generates the most confusion. A transaction can already be on disk on the replica without being visible to end users, and this holds true even under synchronous replication — synchronous replication generally does not guarantee that data committed on the primary is visible on the replica. Replication conflicts explain how: the replica may write and flush a transaction yet not apply the change until the conflict is resolved. In short, replay_lsn reports how much data is already visible.

The receiver side and replication slots

The WAL receiver has its own view, pg_stat_wal_receiver, covering the receiving end of the connection. It is considerably harder to read, so pg_stat_replication remains the more convenient basis for monitoring.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

postgres=# d pg_stat_wal_receiver

                      View 'pg_catalog.pg_stat_wal_receiver'

        Column         |           Type           | Collation | Nullable | Default

-----------------------+--------------------------+-----------+----------+---------

pid                   | integer                  |           |          |

status                | text                     |           |          |

receive_start_lsn     | pg_lsn                   |           |          |

receive_start_tli     | integer                  |           |          |

written_lsn           | pg_lsn                   |           |          |

flushed_lsn           | pg_lsn                   |           |          |

received_tli          | integer                  |           |          |

last_msg_send_time    | timestamp with time zone |           |          |

last_msg_receipt_time | timestamp with time zone |           |          |

latest_end_lsn        | pg_lsn                   |           |          |

latest_end_time       | timestamp with time zone |           |          |

slot_name             | text                     |           |          |

sender_host           | text                     |           |          |

sender_port           | integer                  |           |          |

conninfo              | text                     |           |          |

Replication slots are a separate concern with their own failure mode. A slot keeps WAL from being removed while a replica is behind it; without a slot, the primary recycles WAL as soon as it no longer needs it itself. The check is to look for stale slots that are pending and no longer needed, and to drop them.

The view for that inspection:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

postgres=# d pg_replication_slots

             View 'pg_catalog.pg_replication_slots'

       Column        |  Type   | Collation | Nullable | Default

---------------------+---------+-----------+----------+---------

slot_name           | name    |           |          |

plugin              | name    |           |          |

slot_type           | text    |           |          |

datoid              | oid     |           |          |

database            | name    |           |          |

temporary           | boolean |           |          |

active              | boolean |           |          |

active_pid          | integer |           |          |

xmin                | xid     |           |          |

catalog_xmin        | xid     |           |          |

restart_lsn         | pg_lsn  |           |          |

confirmed_flush_lsn | pg_lsn  |           |          |

wal_status          | text    |           |          |

safe_wal_size       | bigint  |           |          |

two_phase           | boolean |           |          |

conflicting         | boolean |           |          |