MariaDB Performance Tuning for Production: Memory, InnoDB, I/O, and Docker

50 min read

Published · Major revision (MariaDB 10.11 / 11.4 / 11.8 LTS)

Measure → Calculate → Configure → Load test → Observe → Tune again. Every important variable: what it does, the default, what goes wrong, and how to calculate the right value.

MariaDB performance tuning is often reduced to numbers copied from someone else's my.cnf:

READ — example of values you must NOT copy blindly (anti-pattern).
innodb_buffer_pool_size = 16G
max_connections = 500
tmp_table_size = 1G
sort_buffer_size = 8M

It looks reasonable. It may even work. And it can still be completely wrong.

A MariaDB instance on a dedicated 128 GiB database server has very different resource boundaries from one inside a 16 GiB Docker container on a host that also runs Redis, Nginx, application workers, and backup jobs. Good tuning is about the relationship between infrastructure, workload, MariaDB internals, OS limits, and application architecture — not memorizing recommended values.

Tuning loop:
Measure → Calculate → Configure → Load test → Observe → Tune again
The final my.cnf is only the last step.

For every important setting in this guide you get four things:

  1. What it does — in plain language
  2. Default value — upstream MariaDB default if you omit it
  3. What goes wrong — if you leave the default or set it wrong
  4. How to calculate — step-by-step with real numbers

How to read this guide (start here)

Every code block has a colored label. Do only what the label says.

Follow parts in order unless a part says you can skip it. Change example IPs, passwords, and paths to match your lab.

Suggested path: layout/budget → buffer pool → measure before durability changes → one change at a time.

Part 1 — Versions, setup, and configuration layout

What this part is for: get a ready template for: Versions, setup, and configuration layout.
What you should do: COPY files; edit for your host; RUN apply/restart as labeled.

For production, use an actively maintained LTS branch rather than an old non-LTS release just because an older tuning guide happens to use it.

MariaDB 10.11, 11.4, and 11.8 are the relevant Community Server LTS lines discussed here. MariaDB 11.4 was explicitly introduced as an LTS release; MariaDB 11.8 is the newer Community LTS line. MariaDB's current release information shows active 2026 maintenance releases for 10.11, 11.4, and 11.8.

MariaDB Community Server 10.6 reached end of life on July 6, 2026. If you are starting a new Community deployment now, 10.6 should not be your baseline. Older versions are mentioned only when a variable changed, was deprecated, or was removed.

Always check the exact version first

SQL — paste into MariaDB / MySQL / ProxySQL admin client (not bash).
SELECT VERSION();

Sample output — SELECT VERSION();

+---------------------------+
| VERSION()                 |
+---------------------------+
| 11.8.2-MariaDB-ubu2404-log|
+---------------------------+

Meaning: the server is MariaDB 11.8.2; use the 10.5+ single-redo-file model and verify package-specific defaults before applying this guide.

RUN — show the server binary version on the host or inside the container.
mariadbd --version

Sample output — mariadbd --version

mariadbd  Ver 11.8.2-MariaDB for Linux on x86_64 (MariaDB Server)

Meaning: you are tuning against 11.8.x. Defaults and variable names in this article match that line. If you see 10.11 or 11.4, some defaults differ — always confirm with information_schema.SYSTEM_VARIABLES before copying numbers from memory.

Do not assume that MariaDB 11.x means every 11.x release behaves exactly the same. Some variables changed during maintenance releases too.

MariaDB exposes system-variable metadata through information_schema.SYSTEM_VARIABLES, which is extremely useful when you need the actual default, current value, scope, and origin:

SQL — run in MariaDB to compare live vs default variables.
SELECT
    VARIABLE_NAME,
    GLOBAL_VALUE,
    DEFAULT_VALUE,
    VARIABLE_SCOPE,
    GLOBAL_VALUE_ORIGIN
FROM information_schema.SYSTEM_VARIABLES
WHERE VARIABLE_NAME IN (
    'innodb_buffer_pool_size',
    'innodb_log_file_size',
    'innodb_log_buffer_size',
    'innodb_io_capacity',
    'max_connections',
    'tmp_table_size',
    'max_heap_table_size',
    'sort_buffer_size',
    'join_buffer_size'
);

Sample output — SELECT ... FROM information_schema.SYSTEM_VARIABLES

VARIABLE_NAME             GLOBAL_VALUE  DEFAULT_VALUE  VARIABLE_SCOPE  GLOBAL_VALUE_ORIGIN
INNODB_BUFFER_POOL_SIZE   30064771072   134217728      GLOBAL          CONFIG
INNODB_LOG_FILE_SIZE      17179869184   100663296      GLOBAL          CONFIG
INNODB_IO_CAPACITY        6000          200            GLOBAL          CONFIG
MAX_CONNECTIONS           225           151            GLOBAL          CONFIG
TMP_TABLE_SIZE            134217728     16777216       SESSION         CONFIG

Meaning: the live values are 28 GiB, 16 GiB, 6000, 225, and 128 MiB. GLOBAL_VALUE_ORIGIN=CONFIG confirms they came from an option file, not the compiled defaults.

Important distinction: MariaDB compiled/server default is not necessarily the same as the default configuration supplied by Debian/RHEL/Docker image. An OS package or image can ship a .cnf file that overrides the upstream server default.

Find the configuration MariaDB actually reads

Do not assume the main file is always /etc/my.cnf. Depending on installation and distribution, common locations include:

READ — common option-file paths on Linux (find which your image uses).
/etc/my.cnf
/etc/mysql/my.cnf
/etc/mysql/mariadb.conf.d/
/etc/mysql/conf.d/
/etc/my.cnf.d/

Check the actual search path:

RUN — discover which config files MariaDB reads on this host.
mariadbd --help --verbose 2>/dev/null | grep -A20 "Default options are read from"

Sample output — mariadbd --help --verbose

Default options are read from the following files in the given order:
/etc/my.cnf /etc/mysql/my.cnf ~/.my.cnf
The following groups are read: mysqld server mysqld-11.8 mariadb mariadb-11.8 mariadbd mariadbd-11.8 client-server

Meaning: this Debian-style installation reads /etc/mysql/my.cnf, whose included directories must be checked; the mariadbd group is recognized.

Then inspect effective startup options:

RUN — show effective startup options before changing anything.
mariadbd --print-defaults

Sample output — mariadbd --print-defaults

mariadbd would have been started with the following arguments:
--user=mysql --datadir=/var/lib/mysql --innodb-buffer-pool-size=28G --innodb-log-file-size=16G --max-connections=225

Meaning: the option-file parser resolves the intended 28 GiB buffer pool, 16 GiB redo file, and 225 connection limit. This does not prove the running service was restarted; verify live variables too.

The exact file hierarchy matters because later option files can override values loaded earlier.

Recommended configuration layout

For a package-based Linux installation, avoid editing vendor files whenever possible. Create your own file:

The copy-ready snippets in this article target /etc/mysql/mariadb.conf.d/99-production-tuning.cnf and use [mariadbd]. Confirm that the directory is included and the group is listed by mariadbd --help --verbose. On RHEL-family systems, translate the path to the included /etc/my.cnf.d/ directory; do not create a Debian path that the server never reads.

For Docker, keep configuration outside the container image:

READ — diagram or folder layout. Do not paste into a terminal.
mariadb/
├── conf.d/
│   └── 99-production-tuning.cnf
└── compose.yaml

services:
  mariadb:
    image: mariadb:11.4.3-noble
    volumes:
      - ./conf.d:/etc/mysql/conf.d:ro
      - mariadb-data:/var/lib/mysql

Do not SSH into a running container and edit /etc/mysql/... manually — that change disappears when the container is replaced. A production configuration should be version controlled, reproducible, and mounted or baked intentionally.

Part 2 — Resource boundaries and memory budget

What this part is for: cover: Resource boundaries and memory budget.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Pre-flight (Senior DevOps): Before changing my.cnf: baseline slow query log + SHOW GLOBAL STATUS, confirm container mem_limit and ulimit -n, take a backup, and complete the checklist in Part 15 (#checklist-table). One variable change per maintenance window.

Before calculating any MariaDB parameter, determine the resource boundary. There are three different questions:

  1. How much RAM does the physical host have?
  2. How much RAM is available to the VM?
  3. How much RAM is actually available to the MariaDB process/container?

They are not necessarily the same.

Measure RAM on Linux

RUN — paste into a shell on the host this section describes.
free -h
cat /proc/meminfo

Sample output — free -h and /proc/meminfo

               total        used        free      shared  buff/cache   available
Mem:            62Gi        18Gi       6.0Gi       1.0Gi        38Gi        43Gi
Swap:          4.0Gi          0B       4.0Gi
MemTotal:       65996564 kB
MemAvailable:   45112064 kB

Meaning: the host has about 64 GiB installed and 43 GiB currently available. Installed RAM is a capacity boundary, while MemAvailable is a point-in-time observation; budget for co-resident services and peak load.

For Docker:

RUN — paste into a shell on the host this section describes.
docker inspect <container> --format '{{.HostConfig.Memory}}'

Sample output — docker inspect mariadb --format '{{.HostConfig.Memory}}'

17179869184

Meaning: 17,179,869,184 bytes = 16 GiB; size MariaDB against this container limit, not host RAM. A value of 0 means Docker did not configure a memory limit.

If the result is non-zero, it represents the container's memory limit in bytes.

For Kubernetes:

RUN — paste into a shell on the host this section describes.
kubectl get pod <pod> -o jsonpath='{.spec.containers[?(@.name=="mariadb")].resources.limits.memory}{"\n"}'

Sample output — kubectl get pod ... -o jsonpath=...

16Gi

Meaning: the MariaDB container memory ceiling is exactly 16 GiB. Empty output means this query found no limit for the named container and should be treated as a deployment risk, not as unlimited safe memory.

The database must be sized against the resource boundary that the MariaDB process actually has.

Build a memory budget

Example host: 64 GiB RAM · also runs Nginx, PHP-FPM, Redis, application workers, Docker, backup agent, log collector
MariaDB Budget = Available RAM − Other Services − OS Reserve − Operational Headroom
Host RAM = 64 GiB
Other services = 12 GiB
OS/filesystem reserve = 4 GiB
Operational headroom = 4 GiB
─────────────────────────────────
MariaDB budget = 44 GiB

This is fundamentally better than 64 × 0.80 = 51.2 GiB because the second calculation ignores the rest of the machine.

Global vs per-connection memory

MariaDB memory is not one big pool. Think about it in two categories:

A 1 GiB global cache is one thing. A 1 GiB limit that can potentially be reached by hundreds of sessions is something else entirely. MariaDB's own documentation warns about the cumulative effect of per-thread buffers at high concurrency.

B + (C × P) + T + G + R ≤ M

Where B = buffer pool, C = expected peak concurrent sessions, P = estimated per-session working memory, T = temporary-table allowance, G = other global MariaDB memory, R = safety reserve, M = available MariaDB memory. This is a capacity-planning model, not an exact MariaDB allocator equation.

Part 3 — InnoDB buffer pool

What this part is for: cover: InnoDB buffer pool.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

The InnoDB buffer pool is normally the largest memory allocation in an InnoDB-heavy workload. It caches table data, indexes, dirty pages, and frequently accessed pages. The objective is to keep the active working set in memory and reduce physical reads.

innodb_buffer_pool_size

What it does: Allocates the InnoDB buffer pool — the primary in-memory cache for InnoDB data and indexes.

Default if omitted: 128 MiB (upstream MariaDB). Conservative server default, not a production recommendation for a large database.

What goes wrong: Too small → excessive physical reads and high latency. Too large → MariaDB competes with per-connection memory, OS page cache, and other services; swap activity under load.

How to calculate — step by step:

  1. Start with the MariaDB memory budget from Part 2.
  2. Reserve memory for connections, temporary tables, per-query operations, Performance Schema, and OS/container safety.
  3. Assign the remainder to the buffer pool.
  4. Check actual database size and buffer-pool effectiveness (below).
  5. Validate under production-like load — does increasing the pool materially reduce physical reads?
Buffer Pool = MariaDB Memory Budget − Connection/Query Budget − Temporary Memory − Global/Internal Headroom
MariaDB budget = 44 GiB · connection/query = 4 GiB · temporary = 2 GiB · headroom = 4 GiB
→ innodb_buffer_pool_size = 34G
COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (replace the measured value for your host).
[mariadbd]
innodb_buffer_pool_size = 34G

Apply: validate the option-file path and parsed value, then restart in a maintenance window. Although the variable can be changed dynamically, persist the same measured value here so it survives restart.

Check actual database size

SQL — measure InnoDB data + index size (GiB) for buffer pool sizing.
SELECT
    ROUND(
        SUM(data_length + index_length) / 1024 / 1024 / 1024,
        2
    ) AS innodb_data_and_index_gib
FROM information_schema.tables
WHERE engine = 'InnoDB';

Sample output — InnoDB data-and-index size query

+--------------------------+
| innodb_data_and_index_gib |
+--------------------------+
|                    28.40 |
+--------------------------+

Meaning: information_schema.tables reports 28.40 GiB of logical InnoDB data plus indexes. It is not the active working-set size or exact on-disk allocation.

If the result is 28.4 GiB and the buffer pool is 34 GiB, the entire current logical data/index footprint can potentially fit. That is useful information, but not enough — the database may grow, there may be fragmentation, and the working set may differ from total size.

Measure buffer pool effectiveness

SQL — paste into MariaDB / MySQL / ProxySQL admin client (not bash).
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';

Sample output — SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%'

Variable_name                       Value
Innodb_buffer_pool_read_requests    100000000
Innodb_buffer_pool_reads            100000

Meaning: 100,000 / 100,000,000 × 100 = 0.10% of logical requests required physical reads during the counter lifetime. Compare equal workload intervals; counters since startup can hide recent change.

Useful counters: Innodb_buffer_pool_read_requests and Innodb_buffer_pool_reads.

Physical Read Ratio % = (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests) × 100
Innodb_buffer_pool_reads = 100,000 · Innodb_buffer_pool_read_requests = 100,000,000
→ 0.1% of logical read requests required physical reads

Do not treat one ratio as a universal target. A workload doing large sequential scans behaves very differently from a small OLTP workload. The right question: does increasing the buffer pool materially reduce physical reads and improve application latency?

Deprecated: innodb_buffer_pool_instances and innodb_buffer_pool_chunk_size

innodb_buffer_pool_instances was deprecated in MariaDB 10.5.1 and removed in 10.6. Modern MariaDB runs the buffer pool as a single instance — do not configure it on MariaDB 10.6+.

innodb_buffer_pool_chunk_size became autosized from MariaDB 10.8. On current LTS releases (10.11.12+, 11.4.6+, 11.8.2+), the variable is deprecated/ignored. Do not configure it unless you have a very specific legacy requirement.

Buffer pool warmup

After restart, the buffer pool is cold and queries hit storage until frequently used pages return to RAM. MariaDB supports:

COPY FILE — create the config file named in the section (edit values first).
# /etc/mysql/mariadb.conf.d/99-production-tuning.cnf
[mariadbd]
innodb_buffer_pool_dump_at_shutdown = ON
innodb_buffer_pool_load_at_startup = ON

Both are enabled by default in current documentation. For a large production database, this is operationally useful because a restart does not necessarily mean starting with a completely cold cache.

Part 4 — Redo log sizing

What this part is for: cover: Redo log sizing.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

The redo log should not be sized from RAM alone. First measure how much redo the workload actually generates.

SQL — paste into MariaDB / MySQL / ProxySQL admin client (not bash).
SHOW GLOBAL STATUS LIKE 'Innodb_os_log_written';

Sample output — first and second SHOW GLOBAL STATUS LIKE 'Innodb_os_log_written' snapshots, 600 seconds apart

-- 14:00:00
Innodb_os_log_written    500000000000
-- 14:10:00
Innodb_os_log_written    507549747200

Meaning: the delta is 7,549,747,200 bytes = 7,200 MiB; over 600 seconds, the measured redo rate is exactly 12 MiB/s. Capture the timestamps externally because this status row contains no timestamp.

Take the value, wait for a representative period (e.g. 10 minutes), then execute again.

Start = 500,000,000,000 · End = 507,549,747,200 · Duration = 600 seconds
Redo generated = 7,549,747,200 bytes = 7,200 MiB → 12 MiB/s

Use peak redo rate, not average. A production database may generate 10 MiB/s normally and 100 MiB/s during peak traffic. Size based on the busiest realistic window.

Peak Redo Rate = Redo Generated During Peak Window / Peak Window Duration
Required Redo Capacity ≈ Peak Redo Rate × Desired Smoothing Window
Measured peak redo rate = 12 MiB/s · desired smoothing window ≈ 20 minutes
→ 12 × 1,200 = 14,400 MiB = 14.06 GiB
Choose the next practical value, 16 GiB. At 12 MiB/s it contains 16,384 / 12 = 1,365.3 seconds = 22.8 minutes of measured redo.

MariaDB has historically recommended two useful starting points: combined redo capacity ≈ 25–50% of buffer pool, or redo capacity ≈ one hour of peak redo generation. These are cross-checks, not simultaneous requirements: at 12 MiB/s, one hour would be 43,200 MiB = 42.19 GiB, while 25–50% of a 28 GiB pool is 7–14 GiB. This worked system chooses 16 GiB because its explicit smoothing objective is about 20 minutes (22.8 minutes actual), then accepts the modest 57% buffer-pool ratio and lower recovery exposure instead of blindly using the one-hour value.

innodb_log_file_size

What it does: Sets the size of the InnoDB redo log file. On MariaDB 10.5+, this is a single file — redo capacity equals this value directly.

Default if omitted: 96 MiB on MariaDB 10.5+ (was 48 MiB in 10.4-era MariaDB).

What goes wrong: Too small → frequent checkpoints, write stalls, elevated I/O. Too large → longer crash recovery, more disk space consumed.

How to calculate:

  1. Measure peak redo rate via Innodb_os_log_written during busiest window.
  2. Multiply by the desired smoothing window (about 20 minutes in the worked example).
  3. Cross-check against 25–50% of buffer pool as a secondary heuristic.
  4. Validate under staging load; from MariaDB 10.9, innodb_log_file_size is dynamically resizable.
Measured target ≈ 14.06 GiB; chosen capacity = 16 GiB → innodb_log_file_size = 16G (one file, no group multiplication). With a 28 GiB buffer pool, this is 57% of the pool: slightly above the 25–50% cross-check, but justified by the measured 12 MiB/s rate and accepted 22.8-minute window.
COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (MariaDB 10.9+; stage and monitor resize/recovery risk).
[mariadbd]
innodb_log_file_size = 16G

Apply: ensure at least 16 GiB plus operational free space is available on the datadir filesystem. MariaDB 10.9+ can resize this dynamically, but production changes still require a backup, load validation, and monitoring; older supported layouts require version-specific restart procedures.

innodb_log_buffer_size

What it does: In-memory buffer for redo log records before they are written to disk.

Default if omitted: 16 MiB

What goes wrong: Too small → extra disk writes for large transactions. Too large → wasted memory with no benefit unless transactions justify it.

How to calculate: log_buffer_size ≥ redo generated by a large normal transaction. If the largest normal transaction generates ~30 MiB of redo, innodb_log_buffer_size = 64M is easy to justify. There is little reason to set 1G unless actual transactions justify it.

COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (only after measuring large normal transactions).
[mariadbd]
innodb_log_buffer_size = 64M

Apply: persist the value, restart if required by your exact release, and confirm its byte value with SHOW GLOBAL VARIABLES LIKE 'innodb_log_buffer_size';.

Deprecated: innodb_log_files_in_group. Older MariaDB used innodb_log_file_size × innodb_log_files_in_group (e.g. 4G × 2 = 8G). MariaDB 10.5 moved to a single redo log file; innodb_log_files_in_group was deprecated and removed in 10.6. Do not use it on modern LTS releases.

Part 5 — Durability and flush settings

What this part is for: cover: Durability and flush settings.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

innodb_flush_log_at_trx_commit

What it does: Controls when InnoDB flushes the redo log buffer to disk relative to transaction commit.

Default if omitted: 1 — write and flush redo at commit (full ACID durability).

What goes wrong: 0 or 2 can lose recent transactions during failures because redo may not have been physically flushed. Mode 2 (write at commit, flush ~once per second) can reduce flush pressure but is not universally "5–10× faster" — actual gain depends on storage latency, transaction rate, filesystem, and workload.

How to calculate: For production transactional systems where losing recently committed transactions is unacceptable, use 1. Only consider 2 when durability requirements explicitly allow it and you have validated the trade-off under your storage and workload.

COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (durable production baseline).
[mariadbd]
innodb_flush_log_at_trx_commit = 1

Apply: this is dynamic, but persist it in the option file and verify the live value after restart. Do not weaken it without a documented data-loss objective.

sync_binlog

What it does: Controls how often the binary log is flushed to disk (when binary logging is enabled).

Default if omitted: 0 — OS controls flush timing.

What goes wrong: 0 risks losing recent binlog events on crash. 1 is safest but adds latency on write-heavy primaries.

How to calculate: For a traditional transactional primary with replication or PITR requirements, a common durability-oriented pairing is innodb_flush_log_at_trx_commit = 1 and sync_binlog = 1. Validate against storage latency. Exception: when the InnoDB-based binary log is enabled, sync_binlog is ignored.

COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (only when conventional binary logging is enabled).
[mariadbd]
sync_binlog = 1

Apply: confirm log_bin=ON and whether the InnoDB-based binary log is in use. Persist and verify with SHOW GLOBAL VARIABLES LIKE 'sync_binlog';; benchmark commit latency.

innodb_flush_method: Historically Linux guides recommended O_DIRECT. MariaDB changed the Unix default to O_DIRECT starting with 10.6. MariaDB 11.0 deprecated innodb_flush_method in favor of granular variables (innodb_data_file_write_through, innodb_data_file_buffering, innodb_log_file_write_through, innodb_log_file_buffering). Do not blindly add O_DIRECT to a MariaDB 11.x configuration.

innodb_file_per_table defaults to ON — keep it. The explicit variable was deprecated in MariaDB 11.0.1 because the desired behavior is already the default.

innodb_doublewrite defaults to ON — protects against partial page writes. Deprecated as a tuning switch in MariaDB 11.0.1. Do not disable it because "NVMe is fast."

innodb_strict_mode defaults to ON — keep it. Not primarily a performance variable; it prevents invalid InnoDB definitions from being silently accepted.

Part 6 — Storage measurement with fio

What this part is for: cover: Storage measurement with fio.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

Seeing "NVMe" and immediately writing innodb_io_capacity = 10000 is not a calculation. First determine where the MariaDB data directory lives:

SQL — paste into MariaDB / MySQL / ProxySQL admin client (not bash).
SELECT @@datadir;

Sample output — SELECT @@datadir;

+-----------------+
| @@datadir       |
+-----------------+
| /var/lib/mysql/ |
+-----------------+

Meaning: MariaDB data is under /var/lib/mysql/; filesystem and fio checks must target the volume that backs this exact path, never an InnoDB data file.

RUN — inspect the filesystem backing the datadir (replace path if @@datadir differs).
findmnt -T /var/lib/mysql
df -hT /var/lib/mysql
lsblk -d -o NAME,TRAN,ROTA,TYPE,SIZE,MODEL

Sample output — findmnt, df, and lsblk for /var/lib/mysql

TARGET SOURCE    FSTYPE OPTIONS
/      /dev/nvme0n1p2 ext4 rw,relatime
Filesystem       Type  Size Used Avail Use% Mounted on
/dev/nvme0n1p2   ext4  450G 210G  218G  50% /
NAME    TRAN ROTA TYPE  SIZE MODEL
nvme0n1 nvme    0 disk  477G SAMSUNG MZVL2512HCJQ

Meaning: the datadir is on an ext4 NVMe filesystem with 218 GiB available; ROTA=0 confirms non-rotational storage.

Run fio on a separate test file — never on MariaDB data files

Do not run fio --filename=/var/lib/mysql/ibdata1 or point benchmarks at .ibd files.

RUN — paste into a shell on the host this section describes.
sudo mkdir -p /var/lib/mysql/fio-test

sudo fio \
  --name=mariadb-storage \
  --filename=/var/lib/mysql/fio-test/testfile \
  --size=8G \
  --rw=randrw \
  --rwmixread=70 \
  --bs=16k \
  --ioengine=libaio \
  --direct=1 \
  --iodepth=32 \
  --numjobs=1 \
  --runtime=60 \
  --time_based \
  --group_reporting

Sample output — fio --name=mariadb-storage ...

read: IOPS=15.4k, BW=241MiB/s, clat (usec): avg=1120, 99.00th=2474
write: IOPS=6598, BW=103MiB/s, clat (usec): avg=2210, 99.00th=4883
Run status group 0 (all jobs):
  READ: bw=241MiB/s (253MB/s), io=14.1GiB, run=60001msec
  WRITE: bw=103MiB/s (108MB/s), io=6.04GiB, run=60001msec

Meaning: the 70/30 mix delivered about 22,000 total IOPS; average read/write completion latency was 1.12/2.21 ms, with write p99 4.883 ms. Use this as a storage bound, not directly as innodb_io_capacity.

The preceding mkdir is normally silent: success is exit status 0 with no stderr. It does not justify fabricated terminal output.

Why 16 KiB? Traditional InnoDB uses 16 KiB pages by default, so --bs=16k approximates InnoDB-oriented storage testing. Real MariaDB I/O also includes sequential access, redo writes, fsync, background flushing, temporary files, and metadata — fio measures the storage subsystem, not MariaDB exactly.

Important fio parameters

READ — fio parameter reference (not SQL; do not paste into a database client).
--rw=randrw          Random read/write workload
--rwmixread=70       70% read, 30% write (adjust to your app: 90=read-heavy, 30=write-heavy)
--bs=16k             I/O size matching InnoDB page size
--iodepth=32         Outstanding I/O operations (queue depth)
--direct=1           Bypass OS page cache for the benchmark
--runtime=60         Run duration (use longer for serious benchmarking)

Do not look only at IOPS. 20,000 IOPS @ 0.5 ms is very different from 20,000 IOPS @ 20 ms. Also consider latency, bandwidth, queue depth, read/write mix, and tail latency.

Test different queue depths

RUN — paste into a shell on the host this section describes.
for depth in 1 4 16 32 64; do
    fio \
      --name=mariadb-q${depth} \
      --filename=/var/lib/mysql/fio-test/testfile \
      --size=8G \
      --rw=randrw \
      --rwmixread=70 \
      --bs=16k \
      --ioengine=libaio \
      --direct=1 \
      --iodepth="$depth" \
      --numjobs=1 \
      --runtime=60 \
      --time_based \
      --group_reporting
done
Queue 1 → 3,000 IOPS → 0.3 ms
Queue 16 → 18,000 IOPS → 0.9 ms
Queue 32 → 22,000 IOPS → 1.7 ms
Queue 64 → 23,000 IOPS → 4.8 ms
Observation: additional 1,000 IOPS caused latency to jump from 1.7 ms to 4.8 ms — storage is approaching saturation.

Condensed sample output — queue-depth fio loop

mariadb-q1:  IOPS=3000,  clat avg=0.30ms
mariadb-q16: IOPS=18000, clat avg=0.90ms
mariadb-q32: IOPS=22000, clat avg=1.70ms
mariadb-q64: IOPS=23000, clat avg=4.80ms

Meaning: raising depth from 32 to 64 adds only 1,000 IOPS while average completion latency rises from 1.70 to 4.80 ms. Treat queue depth 32 as the edge of the useful envelope for this example.

Monitor with iostat and InnoDB status

RUN — watch the datadir device while the workload runs.
iostat -xz 1

Sample output — iostat -xz 1

Device         r/s    w/s  r_await  w_await aqu-sz  %util
nvme0n1     3100.0 1850.0     0.42     1.73   4.51  68.2

Meaning: the device is 68.2% busy; average read/write wait is 0.42/1.73 ms and queue depth is 4.51. Correlate rising waits and %util with the SQL counters.

SQL — inspect dirty-page flushing while iostat runs.
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages_dirty';
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_wait_free';
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages_flushed';

Sample output — InnoDB dirty-page status commands

Innodb_buffer_pool_pages_dirty     18420
Innodb_buffer_pool_wait_free      0
Innodb_buffer_pool_pages_flushed  9274412

Meaning: 18,420 pages are dirty, there have been 0 foreground waits for a free page, and 9,274,412 pages have been flushed since startup. Repeat over equal intervals; the cumulative flushed count alone is not a rate.

Is InnoDB keeping up with dirty-page flushing? If dirty pages keep growing while storage has headroom, background flushing may need attention. If storage latency is already high, increasing flushing can make the problem worse.

Part 7 — InnoDB I/O tuning

What this part is for: cover: InnoDB I/O tuning.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

innodb_io_capacity

What it does: Controls I/O activity for InnoDB background work such as page flushing.

Default if omitted: 200

What goes wrong: Too low → dirty pages accumulate, checkpoint stalls, elevated latency. Too high → excessive flushing reduces cache effectiveness and increases storage latency.

How to calculate — do NOT set to maximum fio IOPS:

  1. Run fio to understand sustainable IOPS and latency at various queue depths.
  2. Start with a conservative value (e.g. 2,000–4,000 on capable NVMe).
  3. Run production-like load; monitor dirty pages and storage latency via iostat.
  4. Increase incrementally (e.g. 2,000 → 4,000 → 6,000) until dirty pages stabilize and latency remains acceptable.
  5. Stop when dirty pages are controlled, database latency is stable, and storage latency is acceptable.
fio reports ~22,000 IOPS → do not set innodb_io_capacity = 22000
Start at 4,000, test, increase to 6,000 when dirty pages grow under load.
COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (measured NVMe starting point).
[mariadbd]
innodb_io_capacity = 6000

Apply: this is dynamic; change one step at a time, persist only the validated value, and compare dirty-page slope plus iostat latency before and after.

innodb_io_capacity_max

What it does: Upper ceiling for background flushing during urgent situations.

Default if omitted: max(2000, 2 × innodb_io_capacity)

What goes wrong: Set too high relative to storage capability → pathological latency spikes during flush bursts.

How to calculate: Start with 2× innodb_io_capacity (e.g. 6,000 / 12,000). The 2× relationship is a starting point, not a law — validate against storage saturation from fio and iostat.

COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (paired with capacity 6000).
[mariadbd]
innodb_io_capacity_max = 12000

Apply: keep the urgent ceiling within the storage envelope observed under fio and application load; persist after validating that flush bursts do not damage p99 latency.

innodb_read_io_threads / innodb_write_io_threads: Default 4 each. Do not use CPU cores = I/O threads as a formula. Storage and workload matter more. Benchmark higher values only if I/O submission concurrency appears limiting.

innodb_flush_neighbors: On SSD/NVMe, innodb_flush_neighbors = 0 is commonly sensible — neighbor flushing optimizes for spinning disks. Validate on your exact version and storage.

innodb_lru_scan_depth: Default 1536 since MariaDB 10.5.7. No good universal formula. If flushing is healthy, leave it alone. If flushing is unhealthy, measure first — do not increase simply because the server uses NVMe.

Part 8 — Temporary tables and per-connection memory

What this part is for: cover: Temporary tables and per-connection memory.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

tmp_table_size and max_heap_table_size

What it does: Limits in-memory internal temporary tables. Effective limit = MIN(tmp_table_size, max_heap_table_size).

Default if omitted: 16 MiB for both.

What goes wrong: Too small → disk-based temp tables (slower). Too large → potential memory exposure under concurrency (500 connections × 1G limit is not allocated to every session, but exposes dangerous ceiling).

How to calculate:

  1. Measure: SHOW GLOBAL STATUS LIKE 'Created_tmp%';
  2. Calculate disk temp table ratio: Created_tmp_disk_tables / Created_tmp_tables
  3. If high, investigate query causes (GROUP BY, ORDER BY, missing indexes) before raising limits.
  4. Start conservatively: 64M–128M. MariaDB historically recommends 32–64 MB and increasing after measurement.
COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (keep both limits equal).
[mariadbd]
tmp_table_size = 128M
max_heap_table_size = 128M

Apply: both are dynamic and session-scoped for new connections. Persist them together, recycle application connections, and remeasure Created_tmp_tables and Created_tmp_disk_tables; some temp tables must use disk regardless of size.

sort_buffer_size

What it does: Per-session buffer for sort operations.

Default if omitted: 2 MiB

What goes wrong: Too large globally → 8M × 100 concurrent sorts = 800M just for that buffer class. Fix missing indexes before raising globally.

How to calculate: Check SHOW GLOBAL STATUS LIKE 'Sort_merge_passes';, run EXPLAIN on slow queries. If a query performs a huge sort because an index is missing, fix the index — do not set sort_buffer_size = 64M globally.

COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (conservative global baseline).
[mariadbd]
sort_buffer_size = 2M

Apply: this per-session value applies to new sessions. Prefer a session override for proven exceptional queries and verify query plans before increasing the global ceiling.

join_buffer_size

What it does: Per-session buffer for joins that cannot use indexes effectively.

Default if omitted: 256 KiB

What goes wrong: Large global value → expensive when many connections perform joins concurrently. A large join buffer should make you ask: why isn't this join using an index?

How to calculate: Keep global value conservative. Optimize indexes and query plans. Use a larger session value only where justified.

COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (conservative global baseline).
[mariadbd]
join_buffer_size = 256K

Apply: new sessions receive the global value. Use EXPLAIN to fix unindexed joins; increase only for a measured session workload.

read_buffer_size (default 128 KiB) and read_rnd_buffer_size (default 256 KiB) follow the same principle — per-session buffers multiply by concurrency. max_allowed_packet (default 16 MiB) and net_buffer_length (default 16 KiB) matter for large BLOB/text transfers. binlog_cache_size (default 32 KiB) — measure Binlog_cache_disk_use before increasing globally.

Part 9 — Connections and threading

What this part is for: cover: Connections and threading.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

max_connections

What it does: Maximum simultaneous client connections MariaDB will accept.

Default if omitted: 151 (minimum allowed: 10). MariaDB reserves one connection for privileged administrative access.

What goes wrong: Too low → "Too many connections" during peaks. Too high → allows more memory pressure and CPU contention without guaranteeing the server can handle that many active workloads.

How to calculate — from the application, not user count:

  1. Application Connections = Application Instances × Pool Size (e.g. 8 replicas × 15 = 120)
  2. Add administrative reserve and operational headroom (e.g. +10 +10 = 140)
  3. Validate: SHOW GLOBAL STATUS LIKE 'Max_used_connections';
  4. Apply safety factor: Target = Observed Peak × 1.25–1.5
Peak observed = 180 · Safety margin 25% → 180 × 1.25 = 225 → max_connections = 225
COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (derived from pools and observed peak).
[mariadbd]
max_connections = 225

Apply: coordinate application pool totals first. Persist the limit, verify Max_used_connections, rejected connections, RAM, and Threads_running under load.

max_user_connections

What it does: Per-user connection limit.

Default if omitted: 0 (no global per-user limit)

What goes wrong: Without limits, one application account can consume all available connections.

How to calculate: Set slightly below max_connections for the main application account (e.g. max_connections=225, max_user_connections=210).

COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (global ceiling; per-account limits can be safer).
[mariadbd]
max_user_connections = 210

Apply: a global value affects all non-exempt accounts. Where tenants differ, prefer account-specific MAX_USER_CONNECTIONS limits and preserve administrator access.

thread_cache_size (default 256 on the LTS versions covered here): calculate the interval ratio ΔThreads_created / ΔConnections, not lifetime counters viewed once. With 18 new threads for 12,000 connections, the interval creation ratio is 0.15%; a cache of 64 is already adequate for the worked workload. A larger value is not automatically better, and the setting is ignored when the thread pool is active.

COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (one-thread-per-connection example).
[mariadbd]
thread_cache_size = 64

Apply: use only with one-thread-per-connection, persist the measured value, then compare counter deltas over the same workload interval. Do not infer that 64 must exceed concurrent connections: it caches idle worker threads, not active sessions.

thread_handling: Linux default is one-thread-per-connection. The MariaDB thread pool (available without commercial restriction) can reduce scheduling pressure at very high connection counts — benchmark first, do not enable just because "high concurrency = thread pool".

back_log: when left unset, MariaDB derives MIN(900, 50 + max_connections / 5) using integer arithmetic. For max_connections=225, the automatic value is 95; for 300 it is 110. Keep the automatic value unless bursts overflow the listen queue and the OS somaxconn is also sufficient. The worked configuration therefore does not set back_log=128.

Part 10 — Table cache, file descriptors, and OS limits

What this part is for: cover: Table cache, file descriptors, and OS limits.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

table_open_cache

What it does: Number of open table handles cached.

Default if omitted: 2000

What goes wrong: Too small → frequent table open/close overhead (Opened_tables grows rapidly). Too large → wasted memory and file descriptors.

How to calculate: Check table count: SELECT COUNT(*) FROM information_schema.tables;. Set modestly above typical open table count. Verify with SHOW GLOBAL STATUS LIKE 'Opened_tables'; and Open_tables after real workload.

COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (2,800-table workload example).
[mariadbd]
table_open_cache = 4000
table_definition_cache = 3000

Apply: table_open_cache is dynamic, but persist only after measuring Open_tables and the interval growth of Opened_tables. Ensure the file-descriptor budget supports the cache.

table_definition_cache (default 400): caches table definition metadata. Increase when many distinct tables are accessed. Default was raised in newer MariaDB versions — verify on your installation.

open_files_limit

What it does: Requests the process file-descriptor limit at startup. The effective value cannot exceed the hard limit granted by systemd, the container runtime, or the launching shell.

Default if omitted: MariaDB calculates a request from connection and table-cache settings, but the effective value is platform/service-limit dependent. Do not assume a universal 32,768 default; inspect @@open_files_limit and the service limit.

What goes wrong: Too low → "Too many open files" errors or a reduced effective table cache. Merely writing a high MariaDB value cannot raise a lower systemd/container hard limit.

How to calculate:

Common MariaDB startup estimate = MAX(max_connections × 5, max_connections + table_open_cache × 2)

For max_connections=225 and table_open_cache=4000: MAX(1,125, 8,225) = 8,225. MariaDB also considers internal engine needs (and version-specific extra connections), so this is a planning estimate, not a promise of the final value. Choosing 65,535 adds operational margin, but only if the service hard limit is at least that high. Verify the live result rather than calling 65,535 the requirement.

COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (requires matching service/container limit).
[mariadbd]
open_files_limit = 65535

Verify OS limits:

RUN — paste into a shell on the host this section describes.
ulimit -n
systemctl show mariadb --property=LimitNOFILE

Sample output — ulimit -n and systemctl show mariadb --property=LimitNOFILE

65535
LimitNOFILE=65535

Meaning: both the current shell soft limit and MariaDB service limit shown here are 65,535. The service property is the relevant launcher limit; finally verify MariaDB accepted it with SELECT @@open_files_limit;.

SQL — verify the effective server limit after restart.
SELECT @@open_files_limit;

Sample output — SELECT @@open_files_limit;

+--------------------+
| @@open_files_limit |
+--------------------+
|              65535 |
+--------------------+

Meaning: the running MariaDB process accepted an effective limit of 65,535; if this is lower than configured, inspect the systemd/container hard limit and startup log.

systemd TasksMax and container ulimits matter at high connection counts. MariaDB cannot exceed the operating system's hard limit.

Part 11 — Timeouts, logging, and query optimization

What this part is for: cover: Timeouts, logging, and query optimization.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

Timeouts must be designed as a chain — application timeout, connection pool timeout, MariaDB wait_timeout, and max_statement_time should be coherent, not contradictory.

wait_timeout / interactive_timeout

What it does: Closes idle non-interactive / interactive connections after N seconds.

Default if omitted: 28800 seconds (8 hours)

What goes wrong: Too high → idle connections hold resources. Too low → connection pool reconnect storms.

How to calculate: Set slightly above your application's connection pool idle timeout. Common production values: 300–600 seconds for pooled applications.

COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (pool idle timeout must be lower).
[mariadbd]
wait_timeout = 600
interactive_timeout = 600

Apply: these session defaults affect new connections. Set the application pool idle timeout below 600 seconds, recycle the pool gradually, and watch reconnect rate.

connect_timeout (default 10s), net_read_timeout (default 30s), net_write_timeout (default 60s): align with application and load balancer timeouts.

max_statement_time (default 0 = unlimited): set a sane limit for runaway queries on supported versions.

Slow query logging:

COPY FILE — create the config file named in the section (edit values first).
# /etc/mysql/mariadb.conf.d/99-production-tuning.cnf
[mariadbd]
slow_query_log = ON
long_query_time = 1

MariaDB 10.11 introduced log_slow_query_time as an alias for long_query_time.

Query optimization before memory optimization. The query cache was removed in MariaDB 10.1.5 — do not configure it. Performance Schema (enabled by default in modern MariaDB) helps identify expensive statements. Fix indexes and query plans before globally increasing per-session buffers.

Binary log format: ROW is the modern default and recommended for replication. Transaction isolation: understand your application's requirements — do not change globally without analysis. innodb_lock_wait_timeout (default 50s): tune based on application deadlock tolerance.

tmpdir: ensure adequate filesystem capacity for temporary files. Docker storage limits and volume sizing matter — a full tmpdir causes query failures unrelated to memory tuning.

Part 12 — Docker, Kubernetes, and container limits

What this part is for: cover: Docker, Kubernetes, and container limits.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

Container memory limits are hard boundaries. MariaDB inside a Docker container with mem_limit: 16g has a 16 GiB boundary and must be sized against it — not the host's 128 GiB.

Note: On Docker Compose (non-Swarm), mem_limit still works. For Swarm stacks, use deploy.resources.limits.memory instead.

COPY FILE — create the config file named in the section (edit values first).
services:
  mariadb:
    image: mariadb:11.4.3-noble
    mem_limit: 16g
    cpus: 4
    volumes:
      - ./conf.d:/etc/mysql/conf.d:ro
      - mariadb-data:/var/lib/mysql
    ulimits:
      nofile:
        soft: 65535
        hard: 65535

For Kubernetes, respect resources.limits.memory and resources.limits.cpu. MariaDB concurrency must be validated against container CPU limits. Storage class and volume performance determine I/O capacity — benchmark fio from inside the pod's volume mount when possible.

CPU limits can cause throttling that looks like database slowness. If pidstat shows MariaDB waiting on CPU while queries are "slow," check cgroup CPU limits before tuning InnoDB.

Part 13 — Worked example: 48 GiB shared VM

What this part is for: get a ready template for: Worked example: 48 GiB shared VM.
What you should do: COPY files; edit for your host; RUN apply/restart as labeled.
Assumptions:
VM RAM = 48 GiB · CPU = 8 vCPU · Storage = NVMe SSD · MariaDB = 11.8
Other services = 8 GiB · OS/operational reserve = 4 GiB

Step 1 — MariaDB memory budget

MariaDB budget = 48 − 8 − 4 = 36 GiB

Step 2 — Connection memory budget

Peak measured application connections = 180. Safety margin 25% → 180 × 1.25 = 225 → max_connections = 225.

Per-session buffers (conservative):

COPY FILE — create the config file named in the section (edit values first).
# /etc/mysql/mariadb.conf.d/99-production-tuning.cnf
[mariadbd]
sort_buffer_size      = 2M
join_buffer_size      = 256K
read_buffer_size      = 128K
read_rnd_buffer_size  = 256K
# Binary-unit planning: 2.625 MiB × 225 = 590.625 MiB ≈ 591 MiB = 0.58 GiB

Reserve ~2 GiB for session/query/temporary activity.

Step 3 — Buffer pool

READ — formula or worked example. Do not execute as a script.
36 GiB budget
− 2 GiB connection/query
− 2 GiB global/internals
− 3 GiB operational safety
= 29 GiB → round to innodb_buffer_pool_size = 28G

We did not use 48 × 70%. We calculated it.

Step 4 — Redo

Peak production-like testing shows redo generation = 12 MiB/s. Use a desired smoothing window around 20 minutes and select a practical 16 GiB file:

12 MiB/s × 1,200 s = 14,400 MiB = 14.06 GiB target; choose 16 GiB → 16,384 MiB / 12 MiB/s = 1,365.3 s = 22.8 min actual window

innodb_log_file_size = 16G is 57% of the 28 GiB buffer pool, slightly above the 25–50% secondary heuristic. The measured workload is the primary evidence: 16 GiB gives about 22.8 minutes at the observed peak rate. Confirm crash-recovery objectives and free disk space before rollout.

Step 5 — I/O capacity

fio shows ~22,000 IOPS at queue depth 32 with 1.7 ms average latency; queue depth 64 adds only ~1,000 IOPS while latency rises to 4.8 ms. Start innodb_io_capacity = 4000, test under load, increase to 6000 when dirty pages grow, and set innodb_io_capacity_max = 12000 as the urgent ceiling. These values are deliberately below the fio saturation edge.

Step 6 — Table cache and file descriptors

2,800 tables → table_open_cache = 4000, table_definition_cache = 3000.

READ — formula or worked example. Do not execute as a script.
MAX(225 × 5, 225 + 4000 × 2) = MAX(1125, 8225) = 8225
→ common planning estimate = 8225; MariaDB may request more for internal needs
→ configure 65535 as margin only when systemd/container grants it; verify live

Part 14 — Example production my.cnf

What this part is for: get a ready template for: Example production my.cnf.
What you should do: COPY files; edit for your host; RUN apply/restart as labeled.

Based on the 48 GiB example above. Assumptions: MariaDB 11.8 · 48 GiB RAM · 8 vCPU · NVMe · Shared VM · 28 GiB buffer pool · 16 GiB redo (22.8 minutes at 12 MiB/s) · 225 max connections · 6,000 I/O capacity.

COPY FILE — /etc/mysql/mariadb.conf.d/99-production-tuning.cnf (Debian/Ubuntu; verify include path).
[mariadbd]

# ============================================================
# INNODB MEMORY
# ============================================================
# Calculated from MariaDB memory budget after reserving
# connections, temporary work, internal allocations, headroom.
innodb_buffer_pool_size = 28G

# ============================================================
# BUFFER POOL WARMUP
# ============================================================
innodb_buffer_pool_dump_at_shutdown = ON
innodb_buffer_pool_load_at_startup = ON

# ============================================================
# INNODB REDO
# ============================================================
innodb_log_file_size = 16G
innodb_log_buffer_size = 64M

# ============================================================
# DURABILITY
# ============================================================
innodb_flush_log_at_trx_commit = 1

# ============================================================
# INNODB I/O
# ============================================================
innodb_io_capacity = 6000
innodb_io_capacity_max = 12000
innodb_read_io_threads = 4
innodb_write_io_threads = 4
innodb_flush_neighbors = 0

# ============================================================
# TEMPORARY TABLES
# ============================================================
tmp_table_size = 128M
max_heap_table_size = 128M

# ============================================================
# PER-CONNECTION MEMORY
# ============================================================
sort_buffer_size = 2M
join_buffer_size = 256K
read_buffer_size = 128K
read_rnd_buffer_size = 256K
binlog_cache_size = 256K
net_buffer_length = 16K
max_allowed_packet = 64M

# ============================================================
# CONNECTIONS
# ============================================================
max_connections = 225
max_user_connections = 210
thread_cache_size = 64
# back_log intentionally omitted: automatic value is 95 for max_connections=225

# ============================================================
# TABLE CACHE
# ============================================================
table_open_cache = 4000
table_definition_cache = 3000

# ============================================================
# FILE DESCRIPTORS
# ============================================================
open_files_limit = 65535

# ============================================================
# CONNECTION TIMEOUTS
# ============================================================
wait_timeout = 600
interactive_timeout = 600
connect_timeout = 10
net_read_timeout = 30
net_write_timeout = 60

# ============================================================
# SLOW QUERY LOG
# ============================================================
slow_query_log = ON
long_query_time = 1

# ============================================================
# TRANSACTIONAL SAFETY
# ============================================================
innodb_strict_mode = ON

# Replication primary (uncomment if this node is a replication source):
# log_bin = ON
# sync_binlog = 1
# gtid_strict_mode = ON

# Query guard (optional):
# max_statement_time = 300
RUN — apply config and verify (maintenance window; expect brief reconnect).
docker compose restart mariadb
docker compose exec mariadb mariadb -uroot -p"$ROOT" -e "
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
SHOW VARIABLES LIKE 'innodb_log_file_size';
SHOW VARIABLES LIKE 'max_connections';"

Sample output — docker compose restart mariadb and live-variable query

 Container site-mariadb-1  Restarting
 Container site-mariadb-1  Started
Variable_name                  Value
innodb_buffer_pool_size        30064771072
innodb_log_file_size           17179869184
max_connections                225

Meaning: the live values are 30,064,771,072 bytes = 28 GiB, 17,179,869,184 bytes = 16 GiB, and 225. A non-container service restart may be silent; success is exit status 0 with no stderr, followed by this explicit SQL verification.

Intentionally absent:

A good production my.cnf should not contain every variable you have ever heard of. It should express intentional deviations from MariaDB defaults, with comments explaining why each value exists.

Part 15 — Reference tables

What this part is for: cover: Reference tables.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

Version changes — quick lookup

READ — diagram or folder layout. Do not paste into a terminal.
Version    │ Key changes relevant to tuning
───────────┼──────────────────────────────────────────────────────────────────
10.5       │ Single buffer-pool instance (instances deprecated)
           │ Single redo log file architecture begins
           │ Background I/O mechanism changes
10.6       │ Removed: innodb_buffer_pool_instances, innodb_log_files_in_group,
           │         innodb_page_cleaners
           │ Unix innodb_flush_method default = O_DIRECT
10.8       │ innodb_buffer_pool_chunk_size autosized
10.9       │ innodb_log_file_size dynamically resizable
10.11 LTS  │ Slow-query alias log_slow_query_time; active maintenance 2026
11.0       │ Deprecated: innodb_flush_method, innodb_file_per_table,
           │         innodb_doublewrite (granular I/O vars introduced)
11.4 LTS   │ Cost-based optimizer model for SSD characteristics; test plans after upgrade
11.8 LTS   │ Current Community LTS baseline; active maintenance 2026

Upstream defaults — quick lookup

READ — diagram or folder layout. Do not paste into a terminal.
Variable                          │ Default
──────────────────────────────────┼────────────────────────────
innodb_buffer_pool_size           │ 128 MiB
innodb_log_file_size              │ 96 MiB (10.5+); 48 MiB (10.4-era)
innodb_log_buffer_size            │ 16 MiB
innodb_flush_log_at_trx_commit    │ 1
innodb_io_capacity                │ 200
innodb_io_capacity_max            │ max(2000, 2 × io_capacity)
innodb_read_io_threads            │ 4
innodb_write_io_threads           │ 4
tmp_table_size                    │ 16 MiB
max_heap_table_size               │ 16 MiB
sort_buffer_size                  │ 2 MiB
join_buffer_size                  │ 256 KiB
read_buffer_size                  │ 128 KiB
read_rnd_buffer_size              │ 256 KiB
net_buffer_length                 │ 16 KiB
max_allowed_packet                │ 16 MiB
max_connections                   │ 151
thread_cache_size                 │ 256
table_open_cache                  │ 2000
table_definition_cache            │ 400
wait_timeout                      │ 28800 s
interactive_timeout               │ 28800 s
connect_timeout                   │ 10 s
net_read_timeout                  │ 30 s
net_write_timeout                 │ 60 s
long_query_time                   │ 10 s
max_statement_time                │ 0 (unlimited)

Verify actual values on your installation via information_schema.SYSTEM_VARIABLES — OS packages and Docker images may override upstream defaults.

Planning formulas

READ — diagram or folder layout. Do not paste into a terminal.
Category         │ Formula
─────────────────┼────────────────────────────────────────────────────────────
Memory budget    │ MariaDB Budget = RAM − Other Services − OS − Headroom
Buffer pool      │ Budget − Connection/Query − Temporary − Global/Internal
Physical reads   │ (buffer_pool_reads / read_requests) × 100
Redo capacity    │ Peak Redo Rate × Smoothing Window
                 │ Secondary: 25–50% of buffer pool
I/O capacity     │ Start conservative; iterate with fio + iostat + dirty pages
                 │ Never = max fio IOPS
Connections      │ Instances × Pool Size + Admin + Headroom
                 │ Validate: Max_used_connections × 1.25–1.5
Temp tables      │ MIN(tmp_table_size, max_heap_table_size)
File descriptors │ MAX(max_connections × 5, max_connections + table_open_cache × 2)
                 │ Effective value is capped by systemd/container hard limit

Post-change monitoring

READ — diagram or folder layout. Do not paste into a terminal.
Area             │ Commands / tools
─────────────────┼────────────────────────────────────────────────────────────
Connections      │ SHOW GLOBAL STATUS LIKE 'Threads%';
                 │ SHOW GLOBAL STATUS LIKE 'Max_used_connections';
Temporary tables │ SHOW GLOBAL STATUS LIKE 'Created_tmp%';
Sorting          │ SHOW GLOBAL STATUS LIKE 'Sort_merge_passes';
Table cache      │ SHOW GLOBAL STATUS LIKE 'Opened_tables';
                 │ SHOW GLOBAL STATUS LIKE 'Open_tables';
Buffer pool      │ SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool%';
Redo             │ SHOW GLOBAL STATUS LIKE 'Innodb_os_log_written';
Binary log       │ SHOW GLOBAL STATUS LIKE 'Binlog_cache%';
OS               │ iostat -xz 1 · free -h · vmstat 1
                 │ pidstat -p $(pidof mariadbd) 1

If vmstat shows swap activity (si/so) under database load, do not immediately increase the buffer pool — the memory budget may be too aggressive.

Tuning checklist

READ — explanatory content. Follow the paragraph above.
Before tuning
  [ ] MariaDB version identified (10.11 / 11.4 / 11.8 LTS)
  [ ] Option-file path identified (mariadbd --print-defaults)
  [ ] VM/container memory and CPU limits identified
  [ ] Datadir and storage device identified
  [ ] Other services on host identified

Memory
  [ ] MariaDB memory budget calculated
  [ ] Buffer pool sized from budget (not RAM × 0.8)
  [ ] Connection memory estimated
  [ ] Temporary-memory budget considered
  [ ] Safety headroom reserved

I/O
  [ ] Storage tested with fio (separate test file, not ibdata1)
  [ ] Multiple queue depths tested; latency measured
  [ ] iostat monitored under load
  [ ] InnoDB dirty-page behavior measured
  [ ] io_capacity tuned iteratively (not set to max fio IOPS)

Connections
  [ ] Application pool size known
  [ ] Peak connections measured (Max_used_connections)
  [ ] max_connections calculated from app architecture
  [ ] max_user_connections considered

File descriptors
  [ ] table_open_cache sized and verified
  [ ] open_files_limit calculated
  [ ] systemd LimitNOFILE and container ulimits checked

Queries
  [ ] Slow query log enabled
  [ ] EXPLAIN used on slow queries
  [ ] Missing indexes investigated before raising buffers

Version compatibility
  [ ] Deprecated variables removed (buffer_pool_instances, log_files_in_group)
  [ ] Removed variables not present in my.cnf
  [ ] Major-version release notes reviewed

Anti-patterns to avoid

READ — diagram or folder layout. Do not paste into a terminal.
Anti-pattern                              │ Why it fails
──────────────────────────────────────────┼────────────────────────────────────
"Use 80% of RAM for buffer pool"          │ Ignores other services and per-connection memory
"Use 1000 connections for a powerful server"│ Capacity ≠ safe concurrency
"NVMe means innodb_io_capacity = 20000"   │ Must measure; fio max ≠ sustainable background I/O
"More sort_buffer is faster"              │ Fix indexes first; per-session buffers multiply
"1 GiB tmp_table_size for WordPress"      │ Exposes dangerous memory ceiling under concurrency
"Copy entire 2018 my.cnf"                 │ Ignores version changes and removed variables
"Query cache makes SELECT faster"         │ Removed in MariaDB 10.1.5
"innodb_io_capacity = fio max IOPS"       │ Causes pathological flushing and latency

Part 16 — Practical walkthrough

What this part is for: complete a working setup for: Practical walkthrough.
What you should do: follow COPY → RUN → SQL labels in order.

Use this sequence when tuning a production or staging MariaDB instance. Do not change every variable at once.

Step 1 — Identify version and effective configuration

SQL — paste into MariaDB / MySQL / ProxySQL admin client (not bash).
SELECT VERSION();

SELECT VARIABLE_NAME, GLOBAL_VALUE, DEFAULT_VALUE, GLOBAL_VALUE_ORIGIN
FROM information_schema.SYSTEM_VARIABLES
WHERE VARIABLE_NAME IN ('innodb_buffer_pool_size','max_connections','innodb_io_capacity');

Sample output — practical walkthrough version and effective-variable query

VERSION()
11.8.2-MariaDB-ubu2404-log
VARIABLE_NAME             GLOBAL_VALUE  DEFAULT_VALUE  GLOBAL_VALUE_ORIGIN
INNODB_BUFFER_POOL_SIZE   30064771072   134217728      CONFIG
MAX_CONNECTIONS           225           151            CONFIG
INNODB_IO_CAPACITY        6000          200            CONFIG

Meaning: this is MariaDB 11.8.2; the running values are 28 GiB, 225, and 6000, all sourced from configuration.

RUN — show effective startup options from the shell.
mariadbd --print-defaults

Sample output — practical walkthrough mariadbd --print-defaults

mariadbd would have been started with the following arguments:
--innodb-buffer-pool-size=28G --innodb-log-file-size=16G --innodb-io-capacity=6000 --max-connections=225

Meaning: the parser sees the intended startup options; compare these with live SQL because this command does not inspect the running process.

Step 2 — Determine resource boundary

RUN — paste into a shell on the host this section describes.
free -h
docker inspect mariadb --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}'
# or kubectl get pod mariadb-0 -o jsonpath='{.spec.containers[0].resources}'

Sample output — free -h plus Docker resource inspection

Mem: 62Gi 18Gi 6.0Gi 1.0Gi 38Gi 43Gi
51539607552 8000000000

Meaning: Docker grants 51,539,607,552 bytes = 48 GiB and 8,000,000,000 NanoCPUs = 8 CPUs. This container boundary, not the host's displayed 62 GiB, drives the worked budget.

Write down: total RAM available to MariaDB, CPU limit, and what else runs on the host.

Step 3 — Build memory budget and size buffer pool

SQL — sizing queries (run in MariaDB; comments are notes only).
# MariaDB budget = available RAM − other services − OS − headroom
# Buffer pool = budget − connection/query − temporary − internal headroom

SELECT ROUND(SUM(data_length + index_length)/1024/1024/1024,2) AS innodb_gib
FROM information_schema.tables WHERE engine='InnoDB';

SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';

Sample output — practical walkthrough footprint and buffer-pool query

innodb_gib
28.40
Innodb_buffer_pool_read_requests  100000000
Innodb_buffer_pool_reads          100000

Meaning: logical InnoDB data plus indexes total 28.40 GiB, while the physical-read ratio is 0.10% over the counter lifetime. The footprint and working set are different measurements.

Step 4 — Measure redo generation

SQL — snapshot redo bytes; run twice during peak traffic (see paragraph).
# Snapshot Innodb_os_log_written, wait 10+ minutes during peak traffic, snapshot again
SHOW GLOBAL STATUS LIKE 'Innodb_os_log_written';

Sample output — practical walkthrough redo snapshots

14:00:00  Innodb_os_log_written  500000000000
14:10:00  Innodb_os_log_written  507549747200

Meaning: 7,549,747,200 bytes = 7,200 MiB were written in 600 seconds, exactly 12 MiB/s. A 16 GiB file therefore represents 22.8 minutes at this measured rate.

Calculate peak redo rate. Size innodb_log_file_size from peak rate × smoothing window, cross-check against 25–50% of buffer pool.

Step 5 — Benchmark storage with fio

RUN — create a disposable file and benchmark the datadir volume.
sudo mkdir -p /var/lib/mysql/fio-test
sudo fio --name=test --filename=/var/lib/mysql/fio-test/testfile \
  --size=8G --rw=randrw --rwmixread=70 --bs=16k --ioengine=libaio \
  --direct=1 --iodepth=32 --runtime=60 --time_based --group_reporting

Sample output — practical walkthrough fio test

read: IOPS=15.4k, BW=241MiB/s, clat avg=1.12ms, p99=2.47ms
write: IOPS=6598, BW=103MiB/s, clat avg=2.21ms, p99=4.88ms

Meaning: total throughput is about 22,000 IOPS; write p99 is 4.88 ms. The mkdir portion is silent on success (exit 0, no stderr); only fio produces the shown measurements.

RUN — observe the datadir device during fio or application load.
iostat -xz 1

Sample output — practical walkthrough iostat -xz 1

Device       r/s    w/s  r_await  w_await aqu-sz %util
nvme0n1   3100.0 1850.0     0.42     1.73   4.51  68.2

Meaning: 68.2% utilization and 1.73 ms write wait leave some headroom in this sample; retain full time-series output for saturation decisions.

Record sustainable IOPS and latency — not just peak IOPS.

Step 6 — Calculate max_connections from application

SQL — check connection pressure before raising max_connections.
# App instances × pool size + admin + headroom
SHOW GLOBAL STATUS LIKE 'Max_used_connections';
SHOW GLOBAL STATUS LIKE 'Threads_connected';
SHOW GLOBAL STATUS LIKE 'Threads_running';

Sample output — practical walkthrough connection-pressure query

Max_used_connections  180
Threads_connected     142
Threads_running       18

Meaning: the observed high-water mark is 180; applying 25% headroom gives 225. At this instant 142 sessions are connected and 18 are actively running.

Step 7 — Write version-controlled 99-production-tuning.cnf

COPY FILE — create the config file named in the section (edit values first).
# /etc/mysql/mariadb.conf.d/99-production-tuning.cnf
[mariadbd]
# Example comment style:
# 2026-08-28 — Peak measured redo: 12 MiB/s; smoothing window ~20 min
# Validated under staging workload.
innodb_log_file_size = 16G

Mount via Docker Compose or place in mariadb.conf.d/. Restart MariaDB.

Step 8 — Load test and observe

SQL — paste into MariaDB / MySQL / ProxySQL admin client (not bash).
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool%';
SHOW GLOBAL STATUS LIKE 'Created_tmp%';
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages_dirty';

Sample output — practical walkthrough MariaDB status checks

Innodb_buffer_pool_read_requests   100000000
Innodb_buffer_pool_reads           100000
Created_tmp_tables                 420000
Created_tmp_disk_tables            21000
Innodb_buffer_pool_pages_dirty     18420

Meaning: the physical-read ratio is 0.10%, the disk-temp ratio is 21,000 / 420,000 = 5.0%, and 18,420 pages are currently dirty. Compare deltas and dirty-page trends over equal load windows.

RUN — watch OS-level I/O and CPU while the SQL checks above run under load.
iostat -xz 1
vmstat 1
pidstat -p $(pidof mariadbd) 1

Sample output — iostat, vmstat, and MariaDB pidstat under load

nvme0n1: r_await=0.42ms w_await=1.73ms aqu-sz=4.51 %util=68.2
vmstat:  r=3 b=1 swpd=0 si=0 so=0 us=42 sy=8 id=43 wa=7
pidstat: %usr=286.0 %system=34.0 %CPU=320.0 CPU=3 Command=mariadbd

Meaning: there is no swap-in/out (si=0, so=0), I/O wait is 7%, and MariaDB consumes about 3.2 CPU cores. Device wait remains below 2 ms in this snapshot.

Change one category at a time. Compare against baseline. Keep or rollback.

Step 9 — Iterate io_capacity

Start conservative (e.g. 4000). Under production-like load, if dirty pages grow continuously and storage latency is acceptable, increase incrementally. Stop when dirty pages stabilize.

Part 17 — Tuning process and rollout

What this part is for: cover: Tuning process and rollout.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

A tuning change is not finished when MariaDB starts successfully. Test with workload that resembles production — sysbench, application replay, API load test. Important metrics: QPS/TPS, p50/p95/p99 latency, CPU, RAM, swap, IOPS, I/O latency, Threads_running, buffer-pool reads, temporary disk tables, redo generation.

A configuration that improves average throughput but makes p99 terrible may be a regression.

Production rollout sequence:

READ — explanatory content. Follow the paragraph above.
Current production
      ↓
Create version-controlled candidate (Git + PR)
      ↓
Staging + production-like load test
      ↓
Canary / small deployment
      ↓
Observe (Part 15 monitoring table)
      ↓
Full rollout

For MariaDB version upgrades, a staged replica-based approach gives a safer rollback path. Review release notes for deprecated/removed variables before promoting.

Document why each value exists in version control:

COPY FILE — example comment style inside 99-production-tuning.cnf (document every change).
# innodb_buffer_pool_size = 28G
# because: MariaDB memory budget is 36G, 8G reserved for
# connections, temporary work, internal memory, and safety.

Part 18 — Wrapping up

What this part is for: cover: Wrapping up.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

MariaDB tuning is not a collection of magic numbers. It is resource engineering.

READ — explanatory content. Follow the paragraph above.
RAM        → Memory budget     → Buffer pool + dynamic memory
Storage    → fio + iostat      → InnoDB flushing → io_capacity
Application→ Connection pool   → max_connections
Queries    → EXPLAIN + status  → Per-query buffers
Version    → Supported vars    → Current defaults → No deprecated options

The correct answer to "What is the best MariaDB configuration?" is almost never a configuration file. The better answer: first measure the environment, then calculate the configuration.

A production-ready MariaDB deployment should have enough memory to keep its working set hot, enough redo capacity to absorb the write workload, enough I/O capacity to keep dirty pages under control, enough connection capacity to satisfy the application, and enough operating-system headroom to remain stable during peaks. It should also remain understandable six months later.

The configuration should be measured, calculated, version-aware, tested, and documented — not copied, tweaked, and hoped.