The question
I’ve heard many people said that, in postgres, each connection is a process, so it consume more resource than thread, context switch between processes is also costlier. When there are too many processes, the context switch overhead will cause the performance go down.
Those statements are intuitive. But i want to observe the system from when number of connection small to large.
The setup
General idea
The idea is simple. I created two VPSs, one to run pgBench, another to run Postgres, both in the same region to reduce network latency. I created few tables on postgres, then run pgBench to apply some workloads to that tables. Each run I use a different number of connection, from small to large, during the run, I will collect metrics to later comparing.
Table setup
| |
Workload
Full detail of the sql script can be found at: https://github.com/khanh1998/pg-connection-bench
Write heavy
| |
Read-Write balance
Collected metrics
pgbench: tps, latency, latency stddev postgres:- pg_stat_activity,
- pg_stat_statements
os: /proc/vmstat
kernel:
perf{cpu-cycles,instructions,ref-cycles,bus-cycles},{uops_issued.any,uops_issued.stall_cycles,uops_retired.slots,uops_retired.stall_cycles},{cycle_activity.stalls_total,cycle_activity.stalls_mem_any,cycle_activity.stalls_l1d_miss,cycle_activity.stalls_l3_miss},{resource_stalls.sb,mem_inst_retired.lock_loads,cache-references,cache-misses},msr/aperf/,msr/mperf/,cpu-clock,task-clock,context-switches,cpu-migrations,major-faults,minor-faults
Hardware
CherryServers
- pgBench run on VPS: 8 vCPU (Intel Xeon Processor (Icelake)), 31 GB RAM, 200 GB NVMe
- Postgres run on bare metal: 8 cores 16 threads (Intel Xeon Gold 5315Y @ 3.20GHz), 31 GB RAM, 250 GB NVMe (Raid 1)
Postgres was running on bare metal for a stable performance Both instances was on the same region to reduce network delay
Software
pgbench
Postgres 18 running with config from PgTune:
| |
Benchmark scenarios
The same setup will be executed against these scenarios: 16, 32, 64, 128, 256, 512 and 1024 connections.
The observation
we gonna going from top to the bottom
Write heavy workload
pgBench metrics
| Metric | c16 | c32 | c64 | c128 | c256 | c512 | c1024 |
|---|---|---|---|---|---|---|---|
| TPS | 3085.11 | 6365.24 | 9169.62 | 11808.88 | 13150.34 | 12719.56 | 11967.83 |
| Avg Latency (ms) | 5.186 | 5.019 | 6.971 | 10.827 | 19.415 | 40.029 | 85.088 |
| Latency StdDev (ms) | 3.032 | 3.763 | 4.821 | 6.258 | 12.960 | 35.641 | 90.246 |
| Transactions | 925112 | 1907871 | 2746453 | 3530102 | 3924979 | 3781089 | 3521583 |
Latency is increasing in exponentially. The latency growth rate is always increase, from 512 connections, double connection also double latency. That’s understandable, maybe because when we double connections (more processes) in next scenario, they queue up for the resource. The latency is made up from the time processing and the time it waiting on the the queue.
TPS drop is increasing until c256, then drop. The growth rate of tps is always drop, from c16 to c32, double connection leads to double in tps, but after that, the growth rate reduce. TPS drops and TPS growth rate also drops. It make me think that the resources like CPU has been doing something not useful when we increase the connection.
It make me think so, because, for example, there is a coffee shop with two bartenders, each can make one cup of coffee per minute, together they can make 2 cups per minute. If one people is waiting (no queue) they make 1 cup per minute (50% utilization). If two people request coffee (100% utilization) they make two cup per minute. if four people request (saturation) two people get serve and two people waiting in the queue, but they still making 2 cup per minute. No matter how many people are waiting, they consistently making two cups per minute. If they output drop, that mean the bartenders can’t focus on their job and must doing something else like cleaning table.
Postgres metrics
pg_stat_activity raw wait event count
let’s look at the stat activity to see those processes (connections) waiting on what
This broad view groups wait events by type. It is easier to read when the detailed chart has too many series.
cpu:running is not really a wait, it’s mean the process is running on cpu, so this is a good thing. client:clientread is also not need to care about. it’s just that the client is slow or network is slow. the server wait for data from client. it doesn’t cost cpu for these wait.
The more connection it have, the more lock it is waiting on. the total number of wait seem to double when we double connection
Lwlock:walwrite and lwlock:buffercontent are two most busiest lock. IO:WalSync also show up but without a clear pattern. LWLock:WalInsert shows up mostly at c1024.
Start from c128 and c256, we started to see: IPC:ProcarrayGroupUpdate and LWLock:ProcArray. These wait shows that there are overhead of connection managing when we increase the connection.
lock:transactionid show a clear pattern, when we double connection, it growth even faster.
minor wait events: IO:DataFileExtend and lock:extend appeared rarely. lock:tuple timeout:vacuumdelay timeout:spindelay
pg_stat_activity wait event percents
- the cpu percent is reducing. That mean the more connections we add in, most of them result in waiting, only small portion are actually running.
- the io percent is also drop.
all other wait type show a increasing trend. that mean, the more connection we all, large portion of them are endup waiting for those locks.
- Two dominant wait events are: LWLock:WalWrite and LWLock:BufferContent
pg_stat_statement
LWLock:BufferContent
| Query | c16 | c32 | c64 | c128 | c256 | c512 | c1024 |
|---|---|---|---|---|---|---|---|
| SELECT floor(balance)::int AS sender_balanceFROM wallets WHERE id = ? | — | — | — | — | — | — | 33.3% (47.12s) |
| SELECT id, balanceFROM walletsWHERE id IN (? /*, … */)ORDER BY idFOR UPDATE | — | — | — | — | 3.4% (26.12s) | — | 13.8% (1925.95s) |
| UPDATE wallets SET balance = balance - ?, updated_at = NOW() WHERE id = ? | — | — | — | — | — | 14.3% (57.83s) | 15.5% (142.65s) |
| INSERT INTO transactions (idempotency_key, type, amount, currency, status)VALUES (gen_random_uuid(), ?, ?, ?, ?)RETURNING id | — | — | — | 24.0% (191.50s) | 70.2% (1953.49s) | 85.7% (10433.48s) | 87.8% (41287.90s) |
| INSERT INTO ledger_entries (transaction_id, wallet_id, amount, direction)VALUES (?, ?, ?::numeric, ?), (?, ?, ?::numeric, ?) | — | 14.3% (68.47s) | 46.7% (457.26s) | 47.8% (1235.08s) | 85.6% (10810.96s) | 92.6% (43995.31s) | 89.5% (107084.30s) |
| UPDATE wallets SET balance = balance + ?, updated_at = NOW() WHERE id = ? | — | — | — | — | — | 18.2% (67.93s) | 55.3% (463.03s) |
LWLock:WALWrite
| Query | c16 | c32 | c64 | c128 | c256 | c512 | c1024 |
|---|---|---|---|---|---|---|---|
| SELECT floor(balance)::int AS sender_balanceFROM wallets WHERE id = ? | — | — | — | — | — | 11.1% (8.44s) | 8.3% (11.78s) |
| SELECT id, balanceFROM walletsWHERE id IN (? /*, … */)ORDER BY idFOR UPDATE | — | — | — | — | — | 3.7% (119.11s) | 0.8% (116.72s) |
| UPDATE wallets SET balance = balance - ?, updated_at = NOW() WHERE id = ? | — | — | — | — | — | — | 2.8% (25.94s) |
| INSERT INTO transactions (idempotency_key, type, amount, currency, status)VALUES (gen_random_uuid(), ?, ?, ?, ?)RETURNING id | — | — | — | — | 1.0% (26.76s) | 4.9% (597.51s) | 2.8% (1318.48s) |
| INSERT INTO ledger_entries (transaction_id, wallet_id, amount, direction)VALUES (?, ?, ?::numeric, ?), (?, ?, ?::numeric, ?) | — | — | — | 2.2% (56.14s) | 3.5% (436.81s) | 3.1% (1475.25s) | 4.6% (5552.00s) |
| UPDATE wallets SET balance = balance + ?, updated_at = NOW() WHERE id = ? | — | — | — | — | 20.0% (49.09s) | 18.2% (67.93s) | 2.1% (17.81s) |
Both insert queries spend most of their time waiting on LWLock:BufferContent and LWLock:WALWrite.
After pg_stat_activity and pg_stat_statements, we can see that when we increase more connection, most of connection time now are waiting for. These waiting in theory should cost very little CPU, but how is it behave under high load? let’s see some lower level metrics
OS Metrics
PSI
huge pressure on cpu (no pressure on mem at all)
relative low
The conclusion
afs