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:

  1. why is batching help increase insert throughput
  2. 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.micro 2 vCPU, 1GB RAM
  • Postgres 18 RDS db.t4g.micro 2 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

-- unlogged table for faster seeding
CREATE UNLOGGED TABLE transactions (
    id             BIGSERIAL      PRIMARY KEY,
    account_id     BIGINT         NOT NULL,
    merchant_id    BIGINT         NOT NULL,
    amount         NUMERIC(12,2)  NOT NULL,
    currency       CHAR(3)        NOT NULL DEFAULT 'USD',
    status         SMALLINT       NOT NULL DEFAULT 0,
    type           SMALLINT       NOT NULL DEFAULT 0,
    reference_id   UUID           NOT NULL DEFAULT gen_random_uuid(),
    description    VARCHAR(255)   NOT NULL,
    ip_address     INET           NOT NULL,
    device_id      VARCHAR(64)    NOT NULL,
    metadata       JSONB          NOT NULL DEFAULT '{}',
    created_at     TIMESTAMPTZ    NOT NULL DEFAULT clock_timestamp(),
    updated_at     TIMESTAMPTZ    NOT NULL DEFAULT clock_timestamp()
);

-- seeding
INSERT INTO transactions (
    account_id, merchant_id, amount, currency, status, type,
    reference_id, description, ip_address, device_id, metadata,
    created_at, updated_at
)
SELECT
    (random() * {{NUM_ACCOUNTS}})::bigint,
    (random() * {{NUM_MERCHANTS}})::bigint,
    (random() * 10000)::numeric(12,2),
    (ARRAY['USD','EUR','GBP','AUD','SGD'])[(random()*4)::int + 1],
    (random() * 3)::smallint,
    (random() * 1)::smallint,
    gen_random_uuid(),
    'Payment ref-' || g,
    ('10.' || (random()*255)::int || '.' || (random()*255)::int || '.' || (random()*255)::int)::inet,
    'device-' || (random() * {{NUM_ACCOUNTS}})::bigint,
    jsonb_build_object('channel', (ARRAY['web','mobile','pos'])[(random()*2)::int + 1],
                       'attempt', (random()*3)::int + 1),
    NOW() - (random() * INTERVAL '90 days'),
    NOW() - (random() * INTERVAL '90 days')
FROM generate_series(1, {{NUM_ROWS}}) g;

ALTER TABLE transactions SET LOGGED;

CREATE INDEX ON transactions (account_id);
CREATE INDEX ON transactions (merchant_id);
CREATE INDEX ON transactions (account_id, created_at DESC);
CREATE INDEX ON transactions (status, created_at) WHERE status IN (0, 2);
CREATE INDEX ON transactions (created_at);

-- update table's statistic
VACUUM ANALYZE transactions;
CHECKPOINT;

-- warm the table
SELECT COUNT(*) FROM transactions;

Sysbench Lua script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
-- batch_insert.lua
-- Sysbench Lua script for pg batch INSERT benchmark.
-- Accepts --batch-size=N on the CLI (passed via sysbench_options in mybench).
-- All row data is generated in the Lua VM (client side) before the query is sent.
--
-- Usage (standalone):
--   sysbench batch_insert.lua \
--     --pgsql-host=localhost --pgsql-port=5432 \
--     --pgsql-db=bench --pgsql-user=postgres \
--     --threads=8 --time=180 --batch-size=100 run

-- ---------------------------------------------------------------------------
-- Custom CLI options
-- ---------------------------------------------------------------------------
sysbench.cmdline.options = {
    batch_size = {"Number of rows to INSERT per transaction", 1}
}

-- ---------------------------------------------------------------------------
-- Per-thread setup / teardown
-- ---------------------------------------------------------------------------
local CURRENCIES = {"USD", "EUR", "GBP", "AUD", "SGD"}
local CHANNELS   = {"web", "mobile", "pos"}

function thread_init()
    drv = sysbench.sql.driver()
    con = drv:connect()
end

function thread_done()
    con:disconnect()
end

-- ---------------------------------------------------------------------------
-- Main benchmark event
-- Each call = one transaction inserting `batch_size` rows.
-- The entire VALUES list is built in Lua (client side) before the query fires.
-- ---------------------------------------------------------------------------
function event()
    local batch_size = tonumber(sysbench.opt.batch_size)
    local values     = {}

    for i = 1, batch_size do
        local account_id  = sysbench.rand.uniform(1,      100000)
        local merchant_id = sysbench.rand.uniform(1,       10000)
        local amount      = math.floor(sysbench.rand.uniform(1, 1000000)) / 100.0   -- 2 decimal places
        local currency    = CURRENCIES[sysbench.rand.uniform(1, #CURRENCIES)]
        local status      = sysbench.rand.uniform(0, 3)
        local txn_type    = sysbench.rand.uniform(0, 1)
        local channel     = CHANNELS[sysbench.rand.uniform(1, #CHANNELS)]
        local attempt     = sysbench.rand.uniform(1, 4)
        local device_id   = "device-" .. sysbench.rand.uniform(1, 100000)
        local ip          = sysbench.rand.uniform(0, 255) .. "." ..
                            sysbench.rand.uniform(0, 255) .. "." ..
                            sysbench.rand.uniform(0, 255) .. "." ..
                            sysbench.rand.uniform(1, 254)

        -- Escape single quotes in description just in case
        local description = "Payment ref-" .. sysbench.rand.uniform(1, 1000000)

        values[i] = string.format(
            -- account_id, merchant_id, amount, currency, status, type,
            -- reference_id, description, ip_address, device_id, metadata,
            -- created_at, updated_at
            "(%d, %d, %.2f, '%s', %d, %d, gen_random_uuid(), '%s', '%s'::inet, '%s', " ..
            "'{\"channel\":\"%s\",\"attempt\":%d}'::jsonb, clock_timestamp(), clock_timestamp())",
            account_id, merchant_id, amount, currency, status, txn_type,
            description, ip, device_id,
            channel, attempt
        )
    end

    local sql = "INSERT INTO transactions " ..
        "(account_id, merchant_id, amount, currency, status, type, " ..
        "reference_id, description, ip_address, device_id, metadata, " ..
        "created_at, updated_at) VALUES " ..
        table.concat(values, ",")

    con:query("BEGIN")
    con:query(sql)
    con:query("COMMIT")
end

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:
1
2
3
NUM_ACCOUNTS 100000
NUM_MERCHANTS 10000
NUM_ROWS 10000

Sample sysbench command:

1
sysbench --db-driver=pgsql --pgsql-host=localhost --pgsql-port=5432 --pgsql-user=postgres --pgsql-password=password --pgsql-db=benchmark --threads=2 --time=180 --batch-size=10000 --report-interval=5 ./benchmark.lua run

Methodology

  1. Choose a batch size (from small to large)
  2. Drop the table if exist. Create the table and seeding some data.
  3. Update statistic and request checkpoint.
  4. Warm data.
  5. Run sysbench benchmark. during the run, collect postgres metrics: pg_stat_activity, pg_stat_statement, pg_stat_tables,…, collect OS metrics: CPU, Disk, Memory,…
  6. Drop the table
  7. 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_size of the instance is 2GB, checkpoint_completion_target is 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

Metricbatch_1batch_10batch_50batch_100batch_500batch_1000batch_2000batch_5000batch_10000
TPS505.44412.13199.02122.4823.899.003.571.010.28
QPS1516.311236.40597.06367.4471.6726.9910.713.020.84
Avg Latency (ms)3.954.8510.0516.3383.70222.31560.331982.847135.48
p95 Latency (ms)4.255.5711.2419.29164.45669.891903.576835.9622034.77
Transactions909817418735825220484301165667518555
Rows Inserted90,981741,8701,791,2502,204,8002,150,5001,656,0001,350,000925,000550,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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
SELECT
  COALESCE(wait_event_type, 'CPU') AS wait_event_type,
  COALESCE(wait_event, 'running')  AS wait_event,
  COUNT(*)                          AS occurrences,
  COUNT(DISTINCT _collected_at)     AS snapshot_count,
  COUNT(*)/COUNT(DISTINCT _collected_at) AS aas
FROM snap_pg_stat_activity
WHERE _run_id = ?
  AND state = 'active'
GROUP BY 1, 2
ORDER BY 3 DESC
LIMIT 20

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 EventDescription
LWLock:WalInsertWaiting to insert WAL data into a memory buffer.
LWLock:WalWriteWaiting for WAL buffers to be written to disk.
IO:WalWriteWaiting for a write to a WAL file.
IO:WalSyncWaiting 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.

  1. Reserve space in Wal buffer. No lock, CurrBytePos: [atomic fetch-and-add]
  2. 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
All backends (parallel, competing)
    └── LWLock:WalInsert    write own record to WAL buffer

Flush point reached
    ├── One backend becomes leader
           └── IO:WalWrite  IO:WalSync  signals completion
    
    └── Everyone else becomes followers
            └── LWLock:WalWrite  (waiting for leader's signal)

Leader signals  followers wake up  all proceed to commit

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 TypeWait EventCountLoad (AAS/2 vCPU)
CPUrunning191.06
IOWalSync121.00
LWLockWALWrite11.00

From the batch 1, we already see pressure on the WAL because of this write heavy workload.

batch_10

Wait TypeWait EventCountLoad (AAS/2 vCPU)
CPUrunning261.44
ClientClientRead71.17
IOWalSync61.00
LWLockWALWrite11.00

batch_50

Wait TypeWait EventCountLoad (AAS/2 vCPU)
CPUrunning331.74
ClientClientRead71.17
IOWalSync41.00
IODataFileRead21.00
IOWalInitWrite11.00
LWLockWALWrite11.00
TimeoutVacuumDelay11.00

Until batch 50, the postgres is still under utilization AAS < 2 vCPU baseline.

batch_100

Wait TypeWait EventCountLoad (AAS/2 vCPU)
CPUrunning402.22
ClientClientRead31.00
Lockextend22.00
TimeoutVacuumDelay22.00
IODataFileExtend11.00
IODataFileRead11.00
LWLockBufferContent11.00

The CPU is overload here.

batch_500

Wait TypeWait EventCountLoad (AAS/2 vCPU)
CPUrunning583.05
ClientClientRead21.00
TimeoutVacuumDelay11.00

batch_1000

Wait TypeWait EventCountLoad (AAS/2 vCPU)
CPUrunning713.74
ClientClientRead11.00
LWLockBufferContent11.00

batch_2000

Wait TypeWait EventCountLoad (AAS/2 vCPU)
CPUrunning683.58
ClientClientRead11.00
LWLockBufferContent11.00
TimeoutVacuumDelay11.00

batch_5000

Wait TypeWait EventCountLoad (AAS/2 vCPU)
CPUrunning653.42
TimeoutVacuumDelay31.00
LWLockBufferContent11.00

batch_10000

Wait TypeWait EventCountLoad (AAS/2 vCPU)
CPUrunning723.60
LWLockBufferContent21.00
ClientClientRead11.00
IODataFileExtend11.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

Metricbatch_1batch_10batch_50batch_100batch_500batch_1000batch_2000batch_5000batch_10000
TPS473.85421.61223.23118.5824.4612.186.052.240.38
QPS1421.541264.82669.70355.7473.3936.5318.146.721.15
Avg Latency (ms)4.224.748.9616.8681.73163.95330.71891.895229.24
p95 Latency (ms)4.915.7716.1238.94383.33669.891235.622828.8721255.35
Transactions8529675892401872134644092200109140678
Rows Inserted85,296758,9202,009,3502,134,6002,204,5002,200,0002,182,0002,030,000780,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

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
-- batch_insert_unnest.lua
-- Sysbench Lua script for pg batch INSERT benchmark using unnest().
-- Instead of a flat VALUES list, builds one array literal per column and uses:
--   INSERT … SELECT … FROM unnest(ARRAY[…]::type[], …) AS t(col, …)
-- This produces a single, fixed-shape query regardless of batch size, which
-- can benefit from plan caching and may reduce per-row parse overhead.
--
-- Accepts --batch-size=N on the CLI (passed via sysbench_options in mybench).

sysbench.cmdline.options = {
    batch_size = {"Number of rows to INSERT per transaction", 1}
}

local CURRENCIES = {"USD", "EUR", "GBP", "AUD", "SGD"}
local CHANNELS   = {"web", "mobile", "pos"}

function thread_init()
    drv = sysbench.sql.driver()
    con = drv:connect()
end

function thread_done()
    con:disconnect()
end

function event()
    local batch_size = tonumber(sysbench.opt.batch_size)

    -- Per-column arrays (avoids one big table of tuples)
    local account_ids   = {}
    local merchant_ids  = {}
    local amounts       = {}
    local currencies    = {}
    local statuses      = {}
    local txn_types     = {}
    local descriptions  = {}
    local ip_addresses  = {}
    local device_ids    = {}
    local channels      = {}
    local attempts      = {}

    for i = 1, batch_size do
        account_ids[i]  = sysbench.rand.uniform(1, 100000)
        merchant_ids[i] = sysbench.rand.uniform(1, 10000)
        amounts[i]      = string.format("%.2f", math.floor(sysbench.rand.uniform(1, 1000000)) / 100.0)
        currencies[i]   = CURRENCIES[sysbench.rand.uniform(1, #CURRENCIES)]
        statuses[i]     = sysbench.rand.uniform(0, 3)
        txn_types[i]    = sysbench.rand.uniform(0, 1)
        descriptions[i] = "Payment ref-" .. sysbench.rand.uniform(1, 1000000)
        ip_addresses[i] = sysbench.rand.uniform(0, 255) .. "." ..
                          sysbench.rand.uniform(0, 255) .. "." ..
                          sysbench.rand.uniform(0, 255) .. "." ..
                          sysbench.rand.uniform(1, 254)
        device_ids[i]   = "device-" .. sysbench.rand.uniform(1, 100000)
        channels[i]     = CHANNELS[sysbench.rand.uniform(1, #CHANNELS)]
        attempts[i]     = sysbench.rand.uniform(1, 4)
    end

    -- Build array literals for each column
    local function int_array(t)
        return "ARRAY[" .. table.concat(t, ",") .. "]"
    end
    local function quoted_array(t)
        local q = {}
        for i, v in ipairs(t) do q[i] = "'" .. v .. "'" end
        return "ARRAY[" .. table.concat(q, ",") .. "]"
    end
    local function numeric_array(t)
        return "ARRAY[" .. table.concat(t, ",") .. "]"
    end

    -- metadata is built from channels + attempts
    local metadata_arr = {}
    for i = 1, batch_size do
        metadata_arr[i] = string.format("'{\"channel\":\"%s\",\"attempt\":%d}'",
            channels[i], attempts[i])
    end

    local sql = string.format([[
INSERT INTO transactions
    (account_id, merchant_id, amount, currency, status, type,
     reference_id, description, ip_address, device_id, metadata,
     created_at, updated_at)
SELECT
    a, m, n::numeric(12,2), c, s::smallint, tp::smallint,
    gen_random_uuid(), d, ip::inet, dev, meta::jsonb,
    clock_timestamp(), clock_timestamp()
FROM unnest(
    %s::bigint[],
    %s::bigint[],
    %s::numeric[],
    %s::text[],
    %s::int[],
    %s::int[],
    %s::text[],
    %s::text[],
    %s::text[],
    %s::jsonb[]
) AS t(a, m, n, c, s, tp, d, ip, dev, meta)]],
        int_array(account_ids),
        int_array(merchant_ids),
        numeric_array(amounts),
        quoted_array(currencies),
        int_array(statuses),
        int_array(txn_types),
        quoted_array(descriptions),
        quoted_array(ip_addresses),
        quoted_array(device_ids),
        -- jsonb array: elements are already single-quoted objects
        "ARRAY[" .. table.concat(metadata_arr, ",") .. "]"
    )

    con:query("BEGIN")
    con:query(sql)
    con:query("COMMIT")
end

AAS

batch_1

Wait TypeWait EventCountLoad (AAS/4 vCPU)
CPUrunning251.39
IOWalSync91.00

batch_10

Wait TypeWait EventCountLoad (AAS/4 vCPU)
CPUrunning281.56
IOWalSync91.00
LWLockWALWrite11.00

batch_50

Wait TypeWait EventCountLoad (AAS/4 vCPU)
CPUrunning241.33
IODataFileRead111.57
IOWalSync71.00
LWLockWALWrite31.00
TimeoutVacuumDelay31.00
ClientClientRead22.00
IOWalInitWrite21.00
IODataFileWrite11.00

batch_100

Wait TypeWait EventCountLoad (AAS/4 vCPU)
CPUrunning281.56
IODataFileRead211.75
IOWalSync51.00
TimeoutVacuumDelay31.00
LWLockWALWrite21.00

batch_500

Wait TypeWait EventCountLoad (AAS/4 vCPU)
IODataFileRead291.93
CPUrunning231.28
TimeoutVacuumDelay61.00
ClientClientRead21.00
IODataFilePrefetch21.00
IOWalSync11.00

batch_1000

Wait TypeWait EventCountLoad (AAS/4 vCPU)
IODataFileRead271.80
CPUrunning261.44
TimeoutVacuumDelay41.00
IOWalSync31.00
IODataFilePrefetch11.00
IODataFileWrite11.00

batch_2000

Wait TypeWait EventCountLoad (AAS/4 vCPU)
IODataFileRead302.00
CPUrunning261.44
TimeoutVacuumDelay51.00
LWLockBufferContent21.00
IOWalSync11.00

batch_5000

Wait TypeWait EventCountLoad (AAS/4 vCPU)
CPUrunning291.53
IODataFileRead282.00
ClientClientRead21.00
IOWalWrite11.00

batch_10000

Wait TypeWait EventCountLoad (AAS/4 vCPU)
CPUrunning783.71
ClientClientRead31.00