Working with Snapshots in PostgreSQL

By John Doe December 17, 2024

Summary: In this article, we will learn how to work with snapshots in PostgreSQL.

Table of Contents

Introduction

Originally implemented in version 9.2, snapshot exports allow multiple transactions to share identical views of the same database state. Back then there was no practical use case for this feature until pg version 9.3 came out presenting a true use-case when multi-threaded dumps were introduced to pg_dump.

How pg_dump works with snapshots?

Here’s a simple example; let’s start by creating two tables with lots of data:

begin;
create table t1(
    c1 serial primary key,
    c2 text default lpad('',500,md5(random()::text)),
    c3 timestamp default clock_timestamp()
);

create table t2(like t1 including all);

insert into t1 select generate_series(1,5e6);
insert into t2 select generate_series(1,5e6);

analyze;
commit;
=> \d
                    List of relations
 Schema | Name | Type  |  Owner   |  Size   | Description
--------+------+-------+----------+---------+-------------
 public | t1   | table | postgres | 2791 MB |
 public | t2   | table | postgres | 2791 MB |

TIP: Changing the arguments in the generate_series function will populate the tables that best fits your hardware’s capabilities.

The traditional invocation of a logical dump uses a single CPU/thread archiving the tables one at a time. The CLI time generates timing statistics returning the time it takes to perform the dump using the “compressed” format:

# execution time: 60 seconds
time pg_dump -Fc db01 > db01.db

real    1m0.322s
user    0m50.203s
sys     0m1.309s

An alternate invocation generating the dump uses the “directory” format:

# execution time: 52 seconds
time pg_dump -Fd db01 -f db01_dirA

real    0m52.304s
user    0m50.908s
sys     0m1.152s

Thus far the execution utilizes a single CPU. Now look at the execution time when the ‘-j’, or ‘–jobs’ switch, where multiple connections are used to generate the dump:

# execution time: 31 seconds
time pg_dump -Fd db01 -f db01_dirB -j 2

real    0m31.115s
user    1m0.242s
sys     0m1.377s

Notice the number of processes running in the background. The third connection is a leader process which coordinates the two worker processes:

$ ps aux | grep -v grep | grep pg_dump
postgres 1872809  0.1  0.0  14444  5968 pts/1    S+   08:52   0:00 pg_dump -Fd db01 -f db01_dirB -j 2
postgres 1872811  100  0.0  14804  4480 pts/1    R+   08:52   0:12 pg_dump -Fd db01 -f db01_dirB -j 2
postgres 1872812  100  0.0  14804  4488 pts/1    R+   08:52   0:12 pg_dump -Fd db01 -f db01_dirB -j 2

Without the synchronized snapshot feature, the different worker jobs wouldn’t be guaranteed to see the same data in each connection, which could lead to an inconsistent backup.

One important caveat to remember is that the pg_dump jobs switch only works with the “directory” format. Attempting to use jobs to a single database dump fails:

$ pg_dump -Fc db01 -j 2 > db01.db
pg_dump: error: parallel backup only supported by the directory format

There is a full description of other caveats using the jobs switch in the PostgreSQL documentation.

So we’ve shown how it works with pg_dump, but can we go further? … YES!

Using snapshots

We can replicate the behavior using the snapshot synchronization function pg_export_snapshot().

Continuing with the two previously created tables, let’s create another scenario. Look at the table below for each step:

  • STEP 1: Three psql sessions login and are interacting with tables t1 and t2 in tandem.
  • STEP 2: Session 1 inserts every five seconds to the tables. Session 2 sees the data differently than session 3, note the three-second delay queried in session 3, thus making it problematic seeing the same dataset at the same time.
  • STEP 3: Session 1 continues updating tables t1, t2 but now sessions 2 and 3 are using the same snapshot session.
  • STEP 4: While session 1 continues updating tables t1, t2 sessions 2 and 3 see the same datasets, i.e. they are synchronized.
  • STEP 5: Both sessions 2, and 3 are actively copying data at the same time without fear of inconsistency.
  • STEP 6: The COPY is completed so let’s finish up by committing the transactions.
STEP Session 1 Session 2 Session 3
1 psql db01 psql db01 psql db01
2 WITH a(x) AS (SELECT max(c1) FROM t1),
b AS (INSERT INTO t1 SELECT generate_series(x + 1, x + 10) FROM a returning *),
c AS (INSERT INTO t2 SELECT generate_series(x + 1, x + 10) FROM a returning *)
SELECT x AS increment FROM a;
watch 5
WITH a(x) AS (SELECT max(c1) FROM t1)
SELECT c1, md5(c2), c3 FROM t1, a WHERE c1 = x
UNION ALL
SELECT c1, md5(c2), c3 FROM t2, a WHERE c1 = x;
WITH a(x) AS (SELECT max(c1) FROM t1)
SELECT c1, md5(c2), c3 FROM t1, a WHERE c1 = x
UNION ALL
SELECT c1, md5(c2), c3 FROM t2, a, pg_sleep(3) WHERE c1 = x;
3 Step 2 is active -- let’s export the snapshot 00000003-000021CE-1
begin transaction isolation level repeatable read;
SELECT pg_export_snapshot();
-- let’s import the snapshot
begin transaction isolation level repeatable read;
set transaction snapshot ‘00000003-000021CE-1’;
4 Step 2 is active WITH a(x) AS (SELECT max(c1) FROM t1)
SELECT c1, md5(c2), c3 FROM t1, a WHERE c1 = x
UNION ALL
SELECT c1, md5(c2), c3 FROM t2, a WHERE c1 = x;
WITH a(x) AS (SELECT max(c1) FROM t1)
SELECT c1, md5(c2), c3 FROM t1, a WHERE c1 = x
UNION ALL
SELECT c1, md5(c2), c3 FROM t2, a, pg_sleep(3) WHERE c1 = x;
5 Step 2 is active COPY t1 TO PROGRAM ‘gzip > t1.gz’ COPY t2 TO PROGRAM ‘gzip > t2.gz’
6 Step 2 is terminated -- commit or rollback, it’s your choice
COMMIT
-- commit or rollback, it’s your choice
COMMIT

And there you have it, a neat little trick exporting snapshots! Of course, there’s more you can do than just copying tables in a multi-threaded fashion but hopefully, this little use case will lead to more opportunities to simplify your life while working with PostgreSQL.