I often hear that batch insert can help to increase the throughput. Instead of insert row by row, we can combine many rows into one batch and insert once. But I want to understand two things:
- why is batching help increase insert throughput
- if batching increase the throughput, why don’t i just use a very huge batch. Is there a upper limit for a batch size.
Benchmark setup
Environment
- Sysbench running on EC2
t3.micro2 vCPU, 1GB RAM - Postgres 18 RDS
db.t4g.micro2 vCPU, 1GB RAM, 20GB storage, 90MB shared buffer - Both EC2 and RDS are in the same region
- I choose Sysbench over PgBench because it help me to build the batch data from client with Lua script easily
Scripts
Schema
| |
Sysbench Lua script
| |
Run Parameters
- Number of threads: 2. Meaning two sysbench clients will concurrently send requests to RDS
- Duration: 180 seconds
- Batch size: 1, 10, 50, 100, 500, 1000, 2000, 5000, 10000
- Parameter value was used in the seeding file:
Sample sysbench command:
| |
Methodology
- Choose a batch size (from small to large)
- Drop the table if exist. Create the table and seeding some data.
- Update statistic and request checkpoint.
- Warm data.
- Run sysbench benchmark. during the run, collect postgres metrics: pg_stat_activity, pg_stat_statement, pg_stat_tables,…, collect OS metrics: CPU, Disk, Memory,…
- Drop the table
- Wait for 30s and to the next benchmark with different batch size
All postgres metrics are collect in every 10 seconds and store to a timeseries database for analyze. OS metrics are collect every minute.
Note: since the EC2 and RDS instances are burstable, I only do benchmark when I got a alot of CPU and IO burstable credit, make sure it’s won’t ever run out during benchmark.
Weak points:
- All data are fit on share buffer
- for each batch size, benchmark is ran once, can suffer from outlier
- Since we use RDS, OS metrics from Enhance monitoring are not comprehensive
max_wal_sizeof the instance is 2GB,checkpoint_completion_targetis 0.9, checkpoint_timeout is 5 minutes . With the benchmark running in 180s, produce at most 2M row, it won’t trigger checkpoint during benchmark.
Result
Overview
| Metric | batch_1 | batch_10 | batch_50 | batch_100 | batch_500 | batch_1000 | batch_2000 | batch_5000 | batch_10000 |
|---|---|---|---|---|---|---|---|---|---|
| TPS | 505.44 | 412.13 | 199.02 | 122.48 | 23.89 | 9.00 | 3.57 | 1.01 | 0.28 |
| QPS | 1516.31 | 1236.40 | 597.06 | 367.44 | 71.67 | 26.99 | 10.71 | 3.02 | 0.84 |
| Avg Latency (ms) | 3.95 | 4.85 | 10.05 | 16.33 | 83.70 | 222.31 | 560.33 | 1982.84 | 7135.48 |
| p95 Latency (ms) | 4.25 | 5.57 | 11.24 | 19.29 | 164.45 | 669.89 | 1903.57 | 6835.96 | 22034.77 |
| Transactions | 90981 | 74187 | 35825 | 22048 | 4301 | 1656 | 675 | 185 | 55 |
| Rows Inserted | 90,981 | 741,870 | 1,791,250 | 2,204,800 | 2,150,500 | 1,656,000 | 1,350,000 | 925,000 | 550,000 |
Rows Inserted vs Avg Latency
Avg Latency vs p95 Latency
As you can see, both throughput (row inserted) and latency perform best at batch 100 for this specific workload and environment. Increase batch size larger than 100 barely help. That mean, for each workload and environment, there is a batch size that work best.
Average Active Session (AAS)
During the benchmark run, my tool my take a snapshot of pg_stat_activity every 10s and store as a timeseries. In this analysis, we will care about the column wait_event and wait_event_type. Those two can let’s we know what are the transaction are waiting on.
The query to compute the AAS table bellow are look like this:
| |
For example, during benchmark 180s, we take a snapshot every 10s, there are 18 snapshots. the wait IO:DataFileRead occurs 2 times in 2 snapshot, it’s AAS is 2/2 = 1.
WalSync and WalWrite lock explain
In this benchmark, we will see WalSync and WalWrite quite often. So I want to introduce about it.
In Postgres, there is a Wal buffer (4MB default). During the time data is sync to storage (long), the running transaction will write data to this buffer, and waiting for the next sync. This is a way to batch commits together and called group commit. LWLock:WalWrite and LWLock:WalSync is to protect this buffer. When the transaction commit, it will call
| Wait Event | Description |
|---|---|
| LWLock:WalInsert | Waiting to insert WAL data into a memory buffer. |
| LWLock:WalWrite | Waiting for WAL buffers to be written to disk. |
| IO:WalWrite | Waiting for a write to a WAL file. |
| IO:WalSync | Waiting for a WAL file to reach durable storage. |
LWLock:WalInsert This is a set of 8 locks protects the in memory WAL ring buffer. When a transaction want to write WAL record to WAL buffer. 8 locks meaning maximum 8 transactions can concurrently insert to wal buffer.
- Reserve space in Wal buffer. No lock, CurrBytePos: [atomic fetch-and-add]
- Copy data to reserved range in wal buffer (need one of 8 locks)
IO:WalWrite the write syscall
The first transaction (leader) in group commit will write wal buffer to os page cache.
IO:WalSync the fdatasync syscall
The leader in group commit wait for the OS to confirm data has physically reach the storage.
LWLock:WalWrite The leader in group commit will take responsibility to write data to storage. the followers in group commit will wait on this.
| |
Other waits
CPU:running This isn’t wait, this mean the backend is running with cpu, not waiting on anything. But it’s not good if this is higher than vCPU. It indicating that the CPU is overload.
Client:ClientRead Waiting to read data from the client. This mean the client is slow and can’t keep up with the speed of postgres. There can be because of slow network between client and postgres.
IO:DataFileRead Waiting for a read from a relation data file.
Timeout:VacuumDelay Waiting in a cost-based vacuum delay point.
IO:DataFileExtend Waiting for a relation data file to be extended. Tables and indexes in Postgres are files under the hood. When there are no free pages left in the files, postgres must extend the files by writing empty pages to it.
Lock:extend Waiting to extend a relation. only one relation can extend at a time.
LWLock:BufferContent Waiting to access a data page in memory. Each 8KB pages in shared buffer has a internal latch (light weight lock), transaction when access these page need to acquire this lock.
IO:WalInitWrite Waiting for a write while initializing a new WAL file.
batch_1
| Wait Type | Wait Event | Count | Load (AAS/2 vCPU) |
|---|---|---|---|
| CPU | running | 19 | 1.06 |
| IO | WalSync | 12 | 1.00 |
| LWLock | WALWrite | 1 | 1.00 |
From the batch 1, we already see pressure on the WAL because of this write heavy workload.
batch_10
| Wait Type | Wait Event | Count | Load (AAS/2 vCPU) |
|---|---|---|---|
| CPU | running | 26 | 1.44 |
| Client | ClientRead | 7 | 1.17 |
| IO | WalSync | 6 | 1.00 |
| LWLock | WALWrite | 1 | 1.00 |
batch_50
| Wait Type | Wait Event | Count | Load (AAS/2 vCPU) |
|---|---|---|---|
| CPU | running | 33 | 1.74 |
| Client | ClientRead | 7 | 1.17 |
| IO | WalSync | 4 | 1.00 |
| IO | DataFileRead | 2 | 1.00 |
| IO | WalInitWrite | 1 | 1.00 |
| LWLock | WALWrite | 1 | 1.00 |
| Timeout | VacuumDelay | 1 | 1.00 |
Until batch 50, the postgres is still under utilization AAS < 2 vCPU baseline.
batch_100
| Wait Type | Wait Event | Count | Load (AAS/2 vCPU) |
|---|---|---|---|
| CPU | running | 40 | 2.22 |
| Client | ClientRead | 3 | 1.00 |
| Lock | extend | 2 | 2.00 |
| Timeout | VacuumDelay | 2 | 2.00 |
| IO | DataFileExtend | 1 | 1.00 |
| IO | DataFileRead | 1 | 1.00 |
| LWLock | BufferContent | 1 | 1.00 |
The CPU is overload here.
batch_500
| Wait Type | Wait Event | Count | Load (AAS/2 vCPU) |
|---|---|---|---|
| CPU | running | 58 | 3.05 |
| Client | ClientRead | 2 | 1.00 |
| Timeout | VacuumDelay | 1 | 1.00 |
batch_1000
| Wait Type | Wait Event | Count | Load (AAS/2 vCPU) |
|---|---|---|---|
| CPU | running | 71 | 3.74 |
| Client | ClientRead | 1 | 1.00 |
| LWLock | BufferContent | 1 | 1.00 |
batch_2000
| Wait Type | Wait Event | Count | Load (AAS/2 vCPU) |
|---|---|---|---|
| CPU | running | 68 | 3.58 |
| Client | ClientRead | 1 | 1.00 |
| LWLock | BufferContent | 1 | 1.00 |
| Timeout | VacuumDelay | 1 | 1.00 |
batch_5000
| Wait Type | Wait Event | Count | Load (AAS/2 vCPU) |
|---|---|---|---|
| CPU | running | 65 | 3.42 |
| Timeout | VacuumDelay | 3 | 1.00 |
| LWLock | BufferContent | 1 | 1.00 |
batch_10000
| Wait Type | Wait Event | Count | Load (AAS/2 vCPU) |
|---|---|---|---|
| CPU | running | 72 | 3.60 |
| LWLock | BufferContent | 2 | 1.00 |
| Client | ClientRead | 1 | 1.00 |
| IO | DataFileExtend | 1 | 1.00 |
OS metrics
peak cpu batch_1 19.4% batch_10 25.6% batch_50 42.5% batch_100 48.5% batch_500 51.6% batch_1000 45.7% batch_2000 42.3% batch_5000 41.6% batch_10000 36.3%
Unnest insert
Overview
| Metric | batch_1 | batch_10 | batch_50 | batch_100 | batch_500 | batch_1000 | batch_2000 | batch_5000 | batch_10000 |
|---|---|---|---|---|---|---|---|---|---|
| TPS | 473.85 | 421.61 | 223.23 | 118.58 | 24.46 | 12.18 | 6.05 | 2.24 | 0.38 |
| QPS | 1421.54 | 1264.82 | 669.70 | 355.74 | 73.39 | 36.53 | 18.14 | 6.72 | 1.15 |
| Avg Latency (ms) | 4.22 | 4.74 | 8.96 | 16.86 | 81.73 | 163.95 | 330.71 | 891.89 | 5229.24 |
| p95 Latency (ms) | 4.91 | 5.77 | 16.12 | 38.94 | 383.33 | 669.89 | 1235.62 | 2828.87 | 21255.35 |
| Transactions | 85296 | 75892 | 40187 | 21346 | 4409 | 2200 | 1091 | 406 | 78 |
| Rows Inserted | 85,296 | 758,920 | 2,009,350 | 2,134,600 | 2,204,500 | 2,200,000 | 2,182,000 | 2,030,000 | 780,000 |
Rows Inserted vs Avg Latency
Avg Latency vs p95 Latency
CPU
IO
Write IOPS vs Read IOPS
Disk Queue Length vs Await
Utilization
+1m 0s batch_10000: 98.60 batch_5000: 87.96 batch_2000: 29.97 batch_500: 23.78 batch_1000: 22.32 batch_100: 20.70 batch_50: 14.98 batch_10: 6.67 batch_1: 1.28
Load Average:
+1m 0s batch_10000: 14.23 batch_5000: 10.28 batch_1000: 3.08 batch_2000: 2.54 batch_500: 2.53 batch_50: 2.26 batch_100: 1.30 batch_10: 1.08 batch_1: 0.67
Comparison
Rows Inserted
Avg Latency
Sysbench Lua script
| |
AAS
batch_1
| Wait Type | Wait Event | Count | Load (AAS/4 vCPU) |
|---|---|---|---|
| CPU | running | 25 | 1.39 |
| IO | WalSync | 9 | 1.00 |
batch_10
| Wait Type | Wait Event | Count | Load (AAS/4 vCPU) |
|---|---|---|---|
| CPU | running | 28 | 1.56 |
| IO | WalSync | 9 | 1.00 |
| LWLock | WALWrite | 1 | 1.00 |
batch_50
| Wait Type | Wait Event | Count | Load (AAS/4 vCPU) |
|---|---|---|---|
| CPU | running | 24 | 1.33 |
| IO | DataFileRead | 11 | 1.57 |
| IO | WalSync | 7 | 1.00 |
| LWLock | WALWrite | 3 | 1.00 |
| Timeout | VacuumDelay | 3 | 1.00 |
| Client | ClientRead | 2 | 2.00 |
| IO | WalInitWrite | 2 | 1.00 |
| IO | DataFileWrite | 1 | 1.00 |
batch_100
| Wait Type | Wait Event | Count | Load (AAS/4 vCPU) |
|---|---|---|---|
| CPU | running | 28 | 1.56 |
| IO | DataFileRead | 21 | 1.75 |
| IO | WalSync | 5 | 1.00 |
| Timeout | VacuumDelay | 3 | 1.00 |
| LWLock | WALWrite | 2 | 1.00 |
batch_500
| Wait Type | Wait Event | Count | Load (AAS/4 vCPU) |
|---|---|---|---|
| IO | DataFileRead | 29 | 1.93 |
| CPU | running | 23 | 1.28 |
| Timeout | VacuumDelay | 6 | 1.00 |
| Client | ClientRead | 2 | 1.00 |
| IO | DataFilePrefetch | 2 | 1.00 |
| IO | WalSync | 1 | 1.00 |
batch_1000
| Wait Type | Wait Event | Count | Load (AAS/4 vCPU) |
|---|---|---|---|
| IO | DataFileRead | 27 | 1.80 |
| CPU | running | 26 | 1.44 |
| Timeout | VacuumDelay | 4 | 1.00 |
| IO | WalSync | 3 | 1.00 |
| IO | DataFilePrefetch | 1 | 1.00 |
| IO | DataFileWrite | 1 | 1.00 |
batch_2000
| Wait Type | Wait Event | Count | Load (AAS/4 vCPU) |
|---|---|---|---|
| IO | DataFileRead | 30 | 2.00 |
| CPU | running | 26 | 1.44 |
| Timeout | VacuumDelay | 5 | 1.00 |
| LWLock | BufferContent | 2 | 1.00 |
| IO | WalSync | 1 | 1.00 |
batch_5000
| Wait Type | Wait Event | Count | Load (AAS/4 vCPU) |
|---|---|---|---|
| CPU | running | 29 | 1.53 |
| IO | DataFileRead | 28 | 2.00 |
| Client | ClientRead | 2 | 1.00 |
| IO | WalWrite | 1 | 1.00 |
batch_10000
| Wait Type | Wait Event | Count | Load (AAS/4 vCPU) |
|---|---|---|---|
| CPU | running | 78 | 3.71 |
| Client | ClientRead | 3 | 1.00 |