Skip to content
Huseyin Babal
Go back

Postgres vs MariaDB on two dedicated CPUs: the setup, the settings and every run

I ran one small Go service against PostgreSQL 18 and then against MariaDB 12.3, on the same database machine, and pushed it with k6 until the database CPU was full. This post is the reference: machine sizes, both config files, the load script, the full per-level tables, and the places where the numbers are too close or too few to mean much.

The app, the k6 script, the setup and run scripts are in huseyinbabal/db-wars.

Result first

Equal settings, a pool of 100 connections, 5 million orders unless noted. Not one request failed in any run.

TestRunsPostgreSQLMariaDBDifference
Reads (95% reads), up to 800 users37,805 / 7,910 / 7,912 req/s7,789 / 7,902 / 7,906 req/sabout the same
Reads, ceiling312,527 / 13,324 / 13,435 req/s10,344 / 10,772 / 11,016 req/sPostgres +21% to +24%
Reads, p95 at 1,600 users355 to 85 ms117 to 147 ms
Writes (50% writes), ceiling34,122 / 4,385 / 4,277 req/s3,991 / 4,085 / 4,154 req/sPostgres +3% to +7%
20 million orders, ceiling19,827 req/s10,346 req/sMariaDB +5%
Spike 100 → 3,000 → 100 users46,630 / 6,837 / 6,693 / 6,805 req/s7,042 / 6,565 / 6,662 / 6,626 req/sa few percent, both directions

The short version: below the point where the database CPU runs out, the two serve the same traffic at the same latency. The read ceiling is where Postgres pulls ahead. Writes, a dataset bigger than memory and a sudden spike all end within a few percent.

Machines

Three DigitalOcean droplets in one VPC, region lon1, Ubuntu 24.04, CPU-optimized sizes so the vCPUs are dedicated.

MachineSizeRuns
loadc-8 (8 vCPU, 16 GB)k6, Prometheus, Grafana
appc-4 (4 vCPU, 8 GB)the Go service
dbc-2 (2 vCPU, 4 GB)PostgreSQL or MariaDB, never both at once

k6 talks to the app and the app talks to the database only over private addresses. The database machine is the small one on purpose, so it is the first thing to run out.

The sizes matter for one reason worth knowing before you copy this. A first trial on a shared-CPU droplet showed CPU steal climbing from about 6% to about 50% during the session. Throughput fell with it, so two runs an hour apart were not comparable. On a shared host, watch the st column in top or vmstat before you trust any comparison.

Settings, written out for both

Nothing is left on a default that could differ between the two. PostgreSQL gets a file in conf.d:

max_connections = 200
shared_buffers = 1GB
effective_cache_size = 3GB
max_wal_size = 2GB
wal_level = minimal
max_wal_senders = 0
fsync = on
synchronous_commit = on
default_transaction_isolation = 'read committed'
ssl = off/etc/postgresql/18/main/conf.d/bench.conf

MariaDB gets the matching options:

[mariadbd]
max_connections = 200
innodb_buffer_pool_size = 2560M
innodb_log_file_size = 2G
innodb_flush_log_at_trx_commit = 1
skip-log-bin
transaction-isolation = READ-COMMITTED
character-set-server = utf8mb4/etc/mysql/mariadb.conf.d/99-bench.cnf

Read them as pairs: both flush every commit to disk, both use read committed, neither keeps a replication log, neither uses TLS. Memory is the exception, and it is deliberate. PostgreSQL reads through the operating system page cache, so 1 GB of shared_buffers plus the page cache use the machine’s memory. InnoDB opens its files with O_DIRECT and has nothing behind its buffer pool, so it gets the bigger share.

The app

A small order service written with database/sql and hand-written SQL: pgx for PostgreSQL, go-sql-driver/mysql for MariaDB. Placeholders are written as ? and rewritten to $1, $2 for PostgreSQL; otherwise the SQL is the same.

EndpointWhat it does
GET /products/{id}one row by primary key
GET /products?category=&after=one page of products from an index
GET /orders/{id}an order with its items
POST /ordersone transaction with 7 statements: 2 stock updates, 2 price reads, 1 order insert, 2 item inserts

The pool is SetMaxOpenConns(100) with the same number of idle connections. Data is loaded with shop seed -products 100000 -orders 5000000.

Prepare once, on both drivers

This is the part that can make a comparison like this wrong by a factor of two. When you pass arguments to db.QueryContext without a prepared statement, go-sql-driver/mysql (with its default interpolateParams=false) prepares a statement on the server, executes it and closes it: three round trips for one query. pgx caches prepared statements per connection on its own. Left alone, MariaDB pays two extra round trips per query that PostgreSQL does not, and the result measures the driver instead of the database.

The service prepares each query once and reuses the statement on both databases:

var stmts sync.Map

func stmt(ctx context.Context, query string) (*sql.Stmt, error) {
	if s, ok := stmts.Load(query); ok {
		return s.(*sql.Stmt), nil
	}
	s, err := db.PrepareContext(ctx, q(query))
	if err != nil {
		return nil, err
	}
	if old, loaded := stmts.LoadOrStore(query, s); loaded {
		s.Close()
		return old.(*sql.Stmt), nil
	}
	return s, nil
}

Inside the order transaction the prepared statements are bound with tx.StmtContext, and items are sorted by product id first so every transaction takes the row locks in the same order.

The load

Users arrive in levels. Each one sends a request, sleeps 50 to 150 ms and sends the next, so the request rate grows with the number of users until something saturates.

const stages = [];
for (const u of USERS) {
  stages.push({ target: u, duration: '10s' });          // new users arrive
  stages.push({ target: u, duration: `${HOLD}s` });     // hold
}

WRITES is the share of requests that create an order; the reads split into 50% product by id, 30% product list and 20% order by id.

k6 run -o experimental-prometheus-rw -e BASE_URL=http://10.0.0.2:8000 \
  -e WRITES=0.05 -e USERS=100,200,400,800,1600,3200 -e HOLD=80 users.js

The first read round and the first write round held each level for 170 seconds and stopped at 1,600 users. The later rounds use the command above. Before every run both databases are stopped, the page cache is dropped, one database is started and its data reloaded, and the run starts after 30 seconds of quiet. During the run vmstat, iostat, top, pidstat and a load-average loop write one line per second on the database and app machines, and k6 sends its metrics to Prometheus.

Reads, level by level

Third read round. Request rate and latency come from k6; the CPU columns are the database machine.

 users  req/s     p50      p95      p99  fail% | db cpu:  usr  sys wait idle  load
   100    986     0.7      2.3      3.8   0.00 |            8    6    2   82   8.3
   200   1978     0.7      2.3      3.4   0.00 |           12    9    3   74   2.9
   400   3951     0.6      2.5      3.7   0.00 |           19   15    5   59   1.4
   800   7912     0.6      2.8      4.9   0.00 |           33   25    5   35   1.5
  1600  13351    12.8     54.5     82.1   0.00 |           57   42    0    1  51.6
  3200  13435    98.2    388.6    587.0   0.00 |           57   41    0    1  84.6output:
 users  req/s     p50      p95      p99  fail% | db cpu:  usr  sys wait idle  load
   100    984     0.8      2.3      3.3   0.00 |           10    4    1   82   7.7
   200   1972     0.7      2.2      3.4   0.00 |           17    8    3   71   2.3
   400   3951     0.7      2.5      3.8   0.00 |           29   14    4   51   1.1
   800   7906     0.7      3.1      5.6   0.00 |           48   24    3   23   2.5
  1600  11016    33.6    117.3    175.5   0.00 |           63   31    0    0  46.8
  3200  10936   136.1    551.4    842.6   0.00 |           66   33    0    1  84.0output:

Look at the 800-user row. Both serve about 7,910 requests per second (7,912 and 7,906), so they do the same work, and both answer 95% of requests in about 3 ms. PostgreSQL does it with 35% of the CPU idle and MariaDB with 23%. Most of the gap is user time: 33% against 48%, with system time at 25% and 24%. That spare CPU is the head start PostgreSQL has when the next level arrives. At 1,600 users both machines are at 0 to 1% idle, and the request rate stops where each database ran out.

I measured how much CPU each one used for the same requests. I did not profile where MariaDB spends the extra user time, so I am not going to guess at it here.

Past the ceiling, the extra users only add waiting. The request rate stays flat from 1,600 to 3,200 users while p95 grows about seven times on PostgreSQL and five times on MariaDB, and the load average reaches 84 on a two-core machine.

At the limit the two show up differently in top. PostgreSQL forks one backend process per connection, so with a pool of 100 the run queue fills with postgres processes. MariaDB is a single mariadbd process with one thread per connection, so top shows one line at about 190% CPU, both cores.

Writes

With WRITES=0.5, every second request is a new order. The ceiling comes much earlier, between 400 and 800 users, because every order is a seven-statement transaction with a flush to disk at commit.

 users  req/s     p50      p95      p99  fail%
   100    971     1.7      4.3     25.3   0.00
   200   1951     2.1      4.5      7.4   0.00
   400   3800     3.0     16.4     35.2   0.00
   800   4250    71.3    220.4    326.6   0.00
  1600   4218   202.2    788.6   1201.2   0.00
  3200   4277   457.6   1894.1   2921.4   0.00output:

Across three rounds PostgreSQL was 3% to 7% ahead at the ceiling. MariaDB’s curve has the same shape.

The useful lesson here is what saturation looks like. Nothing crashed and nothing returned an error, even with 3,200 users on a database that tops out around 4,200 requests per second. The pool of 100 limits how many connections reach the database, and everyone else waits for a connection. The cost shows up only in latency: p95 went from 16 ms at 400 users to almost 1.9 seconds at 3,200. A dashboard that only counts errors would show this as healthy.

20 million orders

Same read mix, four times the orders, so the working set no longer fits in memory on the database machine.

Ceiling at 5M orders (best run)Ceiling at 20M orders
PostgreSQL13,435 req/s9,827 req/s
MariaDB11,016 req/s10,346 req/s

PostgreSQL lost more and MariaDB ended 5% ahead. This test ran once, and the spike below shows why one run is not enough to settle a 5% gap.

The spike

100 users, then 3,000 at once, then 100 again, with 20% writes.

RunPostgreSQLMariaDB
16,630 req/s7,042 req/s
26,837 req/s6,565 req/s
36,693 req/s6,662 req/s
46,805 req/s6,626 req/s

After the first run MariaDB was 6% ahead. After the next three, PostgreSQL was ahead by up to 4%. Repeated runs on the same machines moved by a few percent, so any gap of that size from one run is not a result. When the spike ended, both returned to the low-millisecond latency of the 100-user level.

What this does not show

Sources


Share this post:

Previous Post
An 11-year-old hole in the Rust borrow checker, reproduced on stable, nightly and Rust 1.4
Next Post
HTTPS from the terminal: eleven checks with curl and openssl