Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

PostgreSQL in Depth  Module 14 – Applications, scaling and what comes next

Scaling PostgreSQL: replicas, caches, shards

When one PostgreSQL server is not enough: measure first, then see what read replicas, caches and shards each relieve, and why a replica can miss a fresh write.

  • Advanced
  • 15 minutes
  • Examples run with PostgreSQL 18.6 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 5 of 15 minutes free
Free: Measure first: one statement often does most of the work · Replicas, caches and shards each relieve a different limit · A replica answers from what it has replayed. With a pass: 3 in-depth parts and 3 interview answers, about 10 minutes.

What you will learn

  • Pick the next scaling step from measurements, among tuning, a bigger machine, replicas, caches, partitioning and sharding
  • Route reads to replicas without breaking read-your-writes
  • Maintain summary data with materialized views or incremental view maintenance
  • Explain what sharding by tenant changes for queries and transactions

Before you start

One slice of an API’s traffic made PostgreSQL touch 168,717 pages of its shared buffers in the run below, and one statement of the three was 96.2 % of them. One index cut that statement from 162,300 pages to 15,618, which is more relief than a second server would have given.

Every further step relieves a different limit and adds parts of its own: read replicas add read capacity, caches save repeated work at the price of freshness, and shards spread writes over several servers. This lesson orders the steps and shows on a recorded replica why a read can miss a fresh write.

Measure first: one statement often does most of the work

The place to start is pg_stat_statements, an extension that ships with PostgreSQL. It must be loaded at server start through shared_preload_libraries, and it then groups statements by their shape, so that 300 page loads for 300 customers count as one statement with 300 calls. The session below sends 2,000 lookups by id, 300 pages of a customer’s orders and 100 new orders, and ranks the statements by the pages they touched; then it adds one index and sends the same traffic again:

The same traffic ranked by pages touched, before and after one index

measure_session.py

"""Measure before scaling, on PostgreSQL 18.6: api_traffic.py (psycopg 3.3.6) sends the same slice of traffic twice,
and pg_stat_statements ranks its statements by the shared buffers they touched, before and after one index. Buffers
are counted instead of times, because times change from run to run on a shared computer and buffer counts do not."""

from pg_session import Server

SETTINGS = {"autovacuum": "off", "shared_preload_libraries": "'pg_stat_statements'",
            "default_statistics_target": "1000", "max_parallel_workers_per_gather": "0", "jit": "off"}

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.silent("CREATE EXTENSION pg_stat_statements")
    pg.psql("measure_setup.sql", flags=("-q",))
    pg.silent("SELECT pg_stat_statements_reset()")
    pg.app("python3", "api_traffic.py")
    pg.psql("top_statements.sql", note="before: which statement costs the most")
    pg.psql("add_index.sql", note="one index on the column the slow statement filters on")
    pg.app("python3", "api_traffic.py")
    pg.psql("top_statements.sql", note="after: the same traffic again")

Output

$ psql -X -q -f measure_setup.sql

$ python3 api_traffic.py
sent: 2,000 lookups, 300 customer pages, 100 inserts

-- before: which statement costs the most
$ psql -X -a -f top_statements.sql
-- The application's statements, ranked by the shared buffers each touched (hits plus reads).
SELECT regexp_replace(query, '\s+', ' ', 'g') AS statement, calls, rows,
       shared_blks_hit + shared_blks_read AS blocks,
       round(100.0 * (shared_blks_hit + shared_blks_read)
             / sum(shared_blks_hit + shared_blks_read) OVER (), 1) AS pct_of_blocks
FROM pg_stat_statements
WHERE query LIKE '% orders %' AND query NOT LIKE '%pg_stat_statements%'
ORDER BY blocks DESC;
                           statement                           | calls | rows  | blocks | pct_of_blocks 
---------------------------------------------------------------+-------+-------+--------+---------------
 SELECT id, total_cents FROM orders WHERE customer_id = $1     |   300 | 15000 | 162300 |          96.2
 SELECT id, total_cents FROM orders WHERE id = $1              |  2000 |  2000 |   6003 |           3.6
 INSERT INTO orders (customer_id, total_cents) VALUES ($1, $2) |   100 |   100 |    414 |           0.2
(3 rows)

-- one index on the column the slow statement filters on
$ psql -X -a -f add_index.sql
CREATE INDEX orders_customer_id ON orders (customer_id);
CREATE INDEX
SELECT pg_stat_statements_reset() IS NOT NULL AS statistics_reset;
 statistics_reset 
------------------
 t
(1 row)

$ python3 api_traffic.py
sent: 2,000 lookups, 300 customer pages, 100 inserts

-- after: the same traffic again
$ psql -X -a -f top_statements.sql
-- The application's statements, ranked by the shared buffers each touched (hits plus reads).
SELECT regexp_replace(query, '\s+', ' ', 'g') AS statement, calls, rows,
       shared_blks_hit + shared_blks_read AS blocks,
       round(100.0 * (shared_blks_hit + shared_blks_read)
             / sum(shared_blks_hit + shared_blks_read) OVER (), 1) AS pct_of_blocks
FROM pg_stat_statements
WHERE query LIKE '% orders %' AND query NOT LIKE '%pg_stat_statements%'
ORDER BY blocks DESC;
                           statement                           | calls | rows  | blocks | pct_of_blocks 
---------------------------------------------------------------+-------+-------+--------+---------------
 SELECT id, total_cents FROM orders WHERE customer_id = $1     |   300 | 15016 |  15618 |          70.2
 SELECT id, total_cents FROM orders WHERE id = $1              |  2000 |  2000 |   6003 |          27.0
 INSERT INTO orders (customer_id, total_cents) VALUES ($1, $2) |   100 |   100 |    612 |           2.8
(3 rows)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 measure_session.py

api_traffic.py

"""One slice of an API's traffic: 2,000 order lookups by id, 300 "orders of a customer" pages and 100 new orders."""
import psycopg

with psycopg.connect(autocommit=True) as conn:
    for n in range(2000):
        # ids from 40,001 up: psycopg sends a Python int as the smallest type that holds it, and pg_stat_statements
        # keeps a statement apart for each parameter type, so one range keeps the lookups in one line
        conn.execute("SELECT id, total_cents FROM orders WHERE id = %s", (40001 + (n * 7919) % 60000,)).fetchone()
    for n in range(300):
        conn.execute("SELECT id, total_cents FROM orders WHERE customer_id = %s", ((n * 13) % 2000 + 1,)).fetchall()
    for n in range(100):
        conn.execute("INSERT INTO orders (customer_id, total_cents) VALUES (%s, %s)", ((n * 17) % 2000 + 1, 1000 + n))
print("sent: 2,000 lookups, 300 customer pages, 100 inserts")

measure_setup.sql

-- 100,000 orders of 2,000 customers, with no index on customer_id yet.
CREATE TABLE orders (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id integer NOT NULL,
  total_cents integer NOT NULL
);
INSERT INTO orders (customer_id, total_cents)
SELECT n % 2000 + 1, (n * 37) % 9000 + 100 FROM generate_series(1, 100000) AS n;
ANALYZE orders;

top_statements.sql

-- The application's statements, ranked by the shared buffers each touched (hits plus reads).
SELECT regexp_replace(query, '\s+', ' ', 'g') AS statement, calls, rows,
       shared_blks_hit + shared_blks_read AS blocks,
       round(100.0 * (shared_blks_hit + shared_blks_read)
             / sum(shared_blks_hit + shared_blks_read) OVER (), 1) AS pct_of_blocks
FROM pg_stat_statements
WHERE query LIKE '% orders %' AND query NOT LIKE '%pg_stat_statements%'
ORDER BY blocks DESC;

add_index.sql

CREATE INDEX orders_customer_id ON orders (customer_id);
SELECT pg_stat_statements_reset() IS NOT NULL AS statistics_reset;

The customer page ran 300 times and touched 162,300 pages, 541 a call: the whole table, because nothing told PostgreSQL where one customer’s rows were. With the index the same 300 calls touched 15,618 pages, about 52 a call, the index and the 50 rows of each customer. The 2,000 lookups by id stayed at 6,003 pages, three a call.

A replica would have run the same 541-page scan, and a cache would only have hidden it. So fix the statements and indexes the measurement points at, pool short connections and move to a bigger machine first; only then add servers or stored results.

Checkpoint

Check: Measuring before scaling

  1. Question 1 of 2 pg_stat_statements shows a statement with 400 calls and 220,000 shared pages touched (hits plus reads). How many pages did one call touch on average?

    Type a number.

    A hint

    Divide the pages by the calls.

    Show the answer: Question 1 of 2

    Answer: 550

    220,000 / 400 = 550 pages a call. In the lesson's run, 162,300 pages over 300 calls was 541 a call, the size of the whole table, which is what pointed at the missing index.

  2. Question 2 of 2 One statement is 90 % of the pages touched on a primary that is at its limit, and it scans its whole table on every call. What is the first step?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Fix that statement, for example with the index its filter needs, and measure again

    Measure, then fix what the measurement points at. More servers and stored results come after the statements are sound, because they multiply or hide the cost instead of removing it.

Replicas, caches and shards each relieve a different limit

Once a tuned server on the biggest machine is not enough, the limit decides the step. Read replicas are standbys that answer read-only queries, long reports among them, while they replay the primary’s WAL: the hot standby: A standby server that answers read-only queries while it replays the primary's write-ahead log. The setting hot_standby is on by default, so a standby built with pg_basebackup is a hot standby. More in “Streaming replication: standbys, slots and lag” of the streaming replication lesson. Replicas add capacity for reads only: every write still goes to the one primary, and each replica answers from what it has replayed so far.

Caches keep a computed result so that it is read instead of computed again, in the application or inside PostgreSQL as a materialized view: A view whose result PostgreSQL stores like a table. It shows the data from its last REFRESH MATERIALIZED VIEW, not the current state of the tables it reads. or a summary table. Caches trade freshness for work, because a stored result is as old as its last update.

Shards split the rows of a table across several servers by a shard key: The column whose value decides which server, or shard, holds a row, such as the tenant id when each tenant's rows are kept together., such as the tenant a row belongs to. Shards scale writes as well as reads, but a query or a transaction that needs rows from several shards becomes the application’s job, or an extension’s.

Partitioning splits one table into smaller ones on the same server. Each option comes with methods of its own:

  • read-your-writes: A guarantee that a user who has just changed something sees that change on the next read, even when reads normally go to a replica that may be behind. routing reads from the primary after a user’s write, or waits for the replica.
  • REFRESH MATERIALIZED VIEW CONCURRENTLY recomputes a view without locking out its readers.
  • incremental view maintenance: Keeping a stored query result current by applying each change of its base tables to it, instead of computing the whole query again; the pg_ivm extension does it for PostgreSQL. with pg_ivm updates a summary as the change happens.
  • Two-phase commit prepares a change on every shard before committing it on any.
  • Citus is an extension that spreads tables over several PostgreSQL servers.

Answer four questions about your own database, and the table rules out the steps that cannot help yet:

Interactive · answer and compare Which scaling step comes next for your database? Answer four questions about your database; each answer rules out the steps that cannot help yet, or adds a caveat, and says why.
In pg_stat_statements, does one statement use most of the buffers or time?
Are most of the requests that strain the database reads?
Must every read show the latest write, the moment after it?
Is the primary already tuned and on the largest machine you can buy?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Six ways to grow a PostgreSQL database, against what each scales and what it costs
Step More reads More writes Every read sees the last write Moving parts added
Fix statements and indexes Changes: the work each query does Good one index cut a query from 162,300 buffers to 15,618 Good fewer indexes and cheaper statements help writes too Good nothing is copied, so nothing is stale Good none: the same server and the same code paths
A bigger machine Changes: CPU, memory and disk of one server Good more memory keeps more of the data cached Good more CPU and faster disks for the one primary Good still one server, so every read is current Good a restart or failover, and a larger bill
Read replicas Changes: where reads run Good each replica answers reads with a full copy Poor every write still goes to the one primary Poor a replica answers from what it has replayed Mixed servers to run and reads to route
Caches and summary tables Changes: how often a result is computed Good a stored result is read instead of recomputed Mixed keeping the result current costs work Poor the stored result is as old as its last update Mixed something must refresh or invalidate it
Partitioning Changes: how one table is stored Mixed queries on the key read only some partitions Mixed smaller indexes, old data dropped in one step Good still one server and one copy of each row Mixed a partition key and partition upkeep
Sharding Changes: which server holds which rows Good each server reads only its own part of the rows Good each server takes the writes for its part Good each row has one home, read where it is written Poor queries across shards need extra work
How the answers decide
  • “In pg_stat_statements, does one statement use most of the buffers or time?” Yes, one statement stands out: rules out Read replicas and Sharding. Fix the statement first. More servers would each run the same wasteful plan, while one index cut it from 162,300 buffers to 15,618 in the run.
  • “In pg_stat_statements, does one statement use most of the buffers or time?” Yes, one statement stands out: adds a caveat to Caches and summary tables. A cache only hides the costly statement, because every miss still runs its full scan; fix the statement before you store its result.
  • “Are most of the requests that strain the database reads?” Writes strain it too: rules out Read replicas and Caches and summary tables. Replicas and caches take load off reads only; every write still lands on the one primary.
  • “Must every read show the latest write, the moment after it?” Yes, every read: adds a caveat to Read replicas and Caches and summary tables. A replica answers from what it has replayed, and a stored result is as old as its last update, so reads that must be current need a rule of their own.
  • “Is the primary already tuned and on the largest machine you can buy?” No, not yet: rules out Sharding. Sharding adds the most moving parts of all six; tuning and a bigger machine come first.
  • “Is the primary already tuned and on the largest machine you can buy?” No, not yet: adds a caveat to Read replicas and Caches and summary tables. Tune the statements and size up the one server first; replicas and stored results each bring parts of their own to run.

Checkpoint

Check: What replicas, caches and shards scale

  1. Question 1 of 2 A tuned primary on the largest machine available is at its limit because of writes: inserts from many tenants, around the clock. Which step relieves that limit?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Sharding by tenant, so that each server takes the writes of its own tenants

    Name the limit first. Reads point at replicas or caches, the size of one table at partitioning, and the writes of one tuned primary at shards.

  2. Question 2 of 2 Which statement about read replicas is right?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: They add capacity for reads, and every write still goes to the primary

    Replicas copy everything and answer reads; they relieve read load and nothing else.

A replica answers from what it has replayed

The manual’s chapter on hot standby says that data takes time to reach a standby, so the same query on the primary and a standby can give different results at nearly the same moment. The session below makes that delay hold still. It pauses replay on the replica with pg_wal_replay_pause(), takes an order on the primary, asks the replica, and then lets it catch up:

An order on the primary, read from a replica that is behind

lag_session.py

"""A read replica that lags, on PostgreSQL 18.6: a primary and one streaming hot standby, its replica. Replay on the
replica is paused with pg_wal_replay_pause(), so the lag that real replicas have now and then holds still long
enough to look at; the primary takes an order, and the replica is asked about it before and after replay resumes.
The session waits (unprinted) until the replica has replayed the setup, and later the new order, before it reads."""

from pg_session import Server

with Server(settings={"autovacuum": "off"}) as pg:
    pg.up()
    pg.psql("lag_setup.sql", flags=("-q",))
    replica = pg.standby()
    pg.wait_for("SELECT count(*) FROM orders", "100000", port=replica)
    pg.psql("lag_pause.sql", port=replica, note="the replica (port 5433)")
    pg.wait_for("SELECT pg_get_wal_replay_pause_state()", "paused", port=replica)
    pg.psql("lag_write.sql", note="the primary (port 5432)")
    pg.psql("lag_read.sql", port=replica, note="the replica, while it is behind")
    pg.psql("lag_resume.sql", port=replica, note="the replica applies the WAL it has received")
    pg.wait_for("SELECT count(*) FROM orders", "100001", port=replica)
    pg.psql("lag_read.sql", port=replica, note="the replica, after it has caught up")

Output

$ psql -X -q -f lag_setup.sql

-- the replica (port 5433)
$ psql -X -a -f lag_pause.sql
-- On the replica: stop applying WAL for a moment, which is how a replica that lags behind looks from outside.
SELECT pg_wal_replay_pause();
 pg_wal_replay_pause 
---------------------
 
(1 row)

-- the primary (port 5432)
$ psql -X -a -f lag_write.sql
-- On the primary: a customer places an order.
INSERT INTO orders (customer_id, total_cents) VALUES (42, 1999) RETURNING id;
   id   
--------
 100001
(1 row)

INSERT 0 1
SELECT count(*) AS orders_on_primary FROM orders;
 orders_on_primary 
-------------------
            100001
(1 row)

-- the replica, while it is behind
$ psql -X -a -f lag_read.sql
-- On the replica: the same questions, answered from what it has replayed so far.
SELECT count(*) AS orders_on_replica FROM orders;
 orders_on_replica 
-------------------
            100000
(1 row)

SELECT count(*) AS new_order_visible FROM orders WHERE id = 100001;
 new_order_visible 
-------------------
                 0
(1 row)

SELECT pg_is_in_recovery() AS is_replica, pg_is_wal_replay_paused() AS replay_paused;
 is_replica | replay_paused 
------------+---------------
 t          | t
(1 row)

-- the replica applies the WAL it has received
$ psql -X -a -f lag_resume.sql
SELECT pg_wal_replay_resume();
 pg_wal_replay_resume 
----------------------
 
(1 row)

-- the replica, after it has caught up
$ psql -X -a -f lag_read.sql
-- On the replica: the same questions, answered from what it has replayed so far.
SELECT count(*) AS orders_on_replica FROM orders;
 orders_on_replica 
-------------------
            100001
(1 row)

SELECT count(*) AS new_order_visible FROM orders WHERE id = 100001;
 new_order_visible 
-------------------
                 1
(1 row)

SELECT pg_is_in_recovery() AS is_replica, pg_is_wal_replay_paused() AS replay_paused;
 is_replica | replay_paused 
------------+---------------
 t          | f
(1 row)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lag_session.py

lag_setup.sql

CREATE TABLE orders (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, customer_id integer NOT NULL, total_cents integer NOT NULL);
INSERT INTO orders (customer_id, total_cents) SELECT n % 2000 + 1, 500 FROM generate_series(1, 100000) AS n;

lag_pause.sql

-- On the replica: stop applying WAL for a moment, which is how a replica that lags behind looks from outside.
SELECT pg_wal_replay_pause();

lag_write.sql

-- On the primary: a customer places an order.
INSERT INTO orders (customer_id, total_cents) VALUES (42, 1999) RETURNING id;
SELECT count(*) AS orders_on_primary FROM orders;

lag_read.sql

-- On the replica: the same questions, answered from what it has replayed so far.
SELECT count(*) AS orders_on_replica FROM orders;
SELECT count(*) AS new_order_visible FROM orders WHERE id = 100001;
SELECT pg_is_in_recovery() AS is_replica, pg_is_wal_replay_paused() AS replay_paused;

lag_resume.sql

SELECT pg_wal_replay_resume();

The primary counted 100,001 orders and the replica 100,000, and order 100001 was not there; after pg_wal_replay_resume() the replica showed it. Nothing was wrong with the replica. It answered correctly for the point in the WAL it had reached, and the answer carried no sign that it was old. Step through the same order, server by server:

Interactive · message by message A write on the primary, a read from the replica Step through one order; the numbers under the servers count the orders each one can show.
A write on the primary, a read from the replica, step 5 of 5. Messages so far: App to Primary: INSERT order; Primary to Replica: WAL of the commit; App to Replica: SELECT; Replica to App: no order.Appa requestPrimary100,001Replica100,001INSERT orderWAL of the commitSELECTno order

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The order is written. The app inserts order 100001 on the primary, the only server that accepts writes. The primary commits it and answers.
  2. The WAL travels. The primary streams the WAL of that commit to the replica, which receives it. Receiving is not yet applying it.
  3. A read arrives too early. The app's next page reads from the replica. Its replay is behind, so it answers from the state before the order and finds 100,000 orders.
  4. The old answer. The replica returns a correct but old answer. Nothing failed; the order is simply not there yet.
  5. Replay catches up. The replica applies the WAL it received. From now on every query on it sees order 100001.

A user who saves a profile and is then shown the old one has met this replication lag: How far a standby is behind its primary: in bytes, as the gap between two WAL positions, or in time, from a commit on the primary until the standby has replayed it. More in “Streaming replication: standbys, slots and lag”, so the reads that follow a user’s own write need a rule of their own.

Key idea

A replica gives correct answers for an earlier moment: more replicas add read capacity, never freshness, and every write still lands on the one primary.

Checkpoint

Check: Why a replica can be behind

  1. Question 1 of 2 Right after an INSERT commits on the primary, the same SELECT on a replica does not find the new row. What is the most likely cause?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The replica has not replayed the WAL of that commit yet

    A replica is correct for the point it has reached in the WAL. The answer gives no sign that it is old, which is why reads after a user's own write need a rule.

  2. Question 2 of 2 In the lesson's run, what did the replica answer while its replay was paused and after it resumed?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 100,000 orders while paused, then 100,001 with the new order visible

    Pausing replay made a short lag hold still: the same query gave the old count, then the new one.

Reading your own writes from a replica, on 18 and on 19 Pro: opens with a Pro, Premium or Ultimate pass

Sending every read to a replica breaks the first page a user loads after saving something. On PostgreSQL 18 the fix is to read that user’s pages from the primary for a while, or to wait until the replica has replayed the write; PostgreSQL 19 adds a command for the waiting.

In this part:

  • Three ways to read right after a write, recorded on 18
  • WAIT FOR LSN on PostgreSQL 19, recorded on 19 RC 1

2 runnable examples (profiles_setup.sql, read_your_writes.py, ryw_session.py, wait_lsn.py, wait_session.py) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Summary tables kept current: REFRESH, CONCURRENTLY and pg_ivm Pro: opens with a Pro, Premium or Ultimate pass

A dashboard of daily totals should not add up 100,000 orders on every page view. Three ways keep such totals stored, and each pays in a different place: the readers, the refresh, or every write.

In this part:

  • What a refresh locks and what it reads, recorded
  • pg_ivm: the summary changes with every write, recorded

2 runnable examples (ivm.sql, ivm_insert.sql, ivm_reads.sql, ivm_session.py, ivm_setup.sql, matview_session.py, mv_concurrently.sql, mv_cost.sql, mv_index.sql, mv_reader.sql, sales_setup.sql) · about 4 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Sharding by tenant: routing, fan-out queries and two-phase commit Pro: opens with a Pro, Premium or Ultimate pass

Sharding moves work from the database into the application, because every query must know where its rows live. The runs below shard seven tenants over two clusters and show which queries stay simple and which do not.

In this part:

  • A router and the three kinds of query, recorded
  • Moving money across shards with two-phase commit, recorded
  • Citus: sharding inside PostgreSQL

1 diagram · 2 runnable examples (blocked_update.sql, prepare_disabled.sql, prepared_list.sql, resolve.sql, shard_router.py, shards_session.py, shards_setup.sql, transfer.py, twophase_session.py, wallets_setup.sql) · about 6 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Practise: choosing the next step

These questions use this lesson’s measurements and its replica, starting with the idea you have shown least:

Practice that adapts to you

Practise this lesson

9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 9 What must happen before pg_stat_statements can count statements?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: It is loaded at server start through shared_preload_libraries, and the extension is created

    Once preloaded, it groups statements by shape, so 300 page loads for 300 customers appear as one line with 300 calls.

  2. Question 2 of 9 After the index, the customer page took 15,618 pages for 300 calls. About how many pages is that per call, to the nearest whole number?

    Type a number.

    A hint

    Divide, then compare with the 541 of the full scan.

    Show the answer: Question 2 of 9

    Answer: 52 (anything from 51 to 53 counts)

    15,618 / 300 ≈ 52: the index pages plus the 50 rows of one customer, against 541 pages a call for the whole table before.

  3. Question 3 of 9 A team wants a cache in front of the database because one report is slow, and pg_stat_statements shows the report scanning its whole table on every call. What do you say?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: Fix the report's statement first; a cache would hide the scan until its next miss

    Measure, fix what the measurement points at, then decide whether more servers or stored results are needed.

  4. Question 4 of 9 What do shards give that read replicas do not?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: Capacity for writes, because each shard takes the writes for its own rows

    Replicas copy everything and help reads; shards divide everything and help writes as well.

  5. Question 5 of 9 A summary of yesterday's sales is shown on a dashboard and changes once a day. Which option fits best?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: A cache or a materialized view of the summary, updated once a day

    Caches trade freshness for work; when the data changes rarely, that trade costs almost nothing.

  6. Question 6 of 9 A read-heavy shop is tuned and on a large machine. Product pages may be seconds old, but a customer must see their own orders the moment after paying. Which plan fits?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: Read replicas for product pages, with a customer's own order pages read in a way that sees their writes

    Match each kind of read to its freshness: stale-tolerant reads to replicas or caches, reads after a user's own write somewhere that has seen it.

  7. Question 7 of 9 What does the manual's hot standby chapter say about a query run on the primary and on a standby at nearly the same moment?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: They may return different results, because data takes time to reach the standby

    The delay is usually short, and the answer gives no sign of it, as the run's 100,000 against 100,001 showed.

  8. Question 8 of 9 Why did the lesson's session pause replay on the replica with pg_wal_replay_pause()?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: To make a short lag hold still, so the stale read happens on every run

    The pause turns a race that happens now and then into a state that can be looked at, and pg_wal_replay_resume() ends it.

  9. Question 9 of 9 Users sometimes see their old profile right after saving it, and someone suggests adding a third replica. What does that change?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: Nothing for this problem; each replica can be behind, and the reads after a write need a rule of their own

    The fix is about where a user's next reads go after a write, not about how many replicas there are.

Interview questions

Three design questions follow: a cart that empties once replicas arrive, a dashboard that times out during each refresh, and tenants that outgrow one primary.

  1. Mid to senior

    A shop moved all reads to two streaming replicas. Now some customers add an item to the cart, land on the cart page and see it empty; a reload a second later shows the item. Replication lag is usually under 200 ms. What is happening, and how do you fix it without giving up the replicas?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A sales dashboard reads a materialized view of totals per store and hour, built from a 60 million row orders table. A job runs REFRESH MATERIALIZED VIEW every 5 minutes, and dashboard requests time out for about 40 seconds each time. What do you change, and what does each option cost?

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    An invoicing service holds 4 TB for 30,000 tenants on one tuned PostgreSQL primary at 85 % CPU, mostly from writes. Five tenants produce 35 % of the writes, and finance runs reports across all tenants each month. Plan the move to several servers.

    Model answer, Pro. First step and follow-ups

Flashcards · 3 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Why measure before scaling, and what comes before adding servers?

    pg_stat_statements groups statements by shape, and one statement often does most of the work (a customer page touched 162,300 pages, cut to 15,618 by one index). Fix statements and indexes, pool short connections and move to a bigger machine before adding servers or caches.

  2. What limit does each of replicas, caches and shards relieve?

    Replicas add capacity for reads only, since the primary still takes all the writes, and each answers from what it has replayed so far; caches (a materialized view or a summary table) trade freshness for work; shards split rows by a shard key and scale writes as well, but a query or transaction across shards becomes the application's job, or an extension's.

  3. Why can a read replica return a stale answer?

    A replica answers from the point in the WAL it has replayed, so it can miss a just-committed write (replication lag). The answer is correct for an earlier moment and carries no sign it is old, so reads that follow a user's own write need a read-your-writes rule.

Key takeaways

  • Measure with pg_stat_statements before adding servers: one statement was 96.2 % of the pages touched, and one index cut it from 162,300 pages to 15,618.
  • Replicas scale reads, caches trade freshness for work, and shards spread writes as well as reads, at the price of queries across shards.
  • A replica answers correctly for the WAL it has replayed, so a read right after a write can show the old row.

Terms in this lesson

incremental view maintenance
Keeping a stored query result current by applying each change of its base tables to it, instead of computing the whole query again; the pg_ivm extension does it for PostgreSQL.
materialized view
A view whose result PostgreSQL stores like a table. It shows the data from its last REFRESH MATERIALIZED VIEW, not the current state of the tables it reads.
read-your-writes
A guarantee that a user who has just changed something sees that change on the next read, even when reads normally go to a replica that may be behind.
shard key
The column whose value decides which server, or shard, holds a row, such as the tenant id when each tenant's rows are kept together.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.