MariaDB Performance Tuning for Production: Memory, InnoDB, I/O, and Docker
50 min read
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:
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.
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:
- What it does — in plain language
- Default value — upstream MariaDB default if you omit it
- What goes wrong — if you leave the default or set it wrong
- 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.
- Read Diagram, formula, or explanation — do not paste into a terminal.
- Copy file Create this file on disk (path is in the label or first comment). Edit values for your environment.
- Run Paste into a shell on the machine this section describes.
- SQL Paste into a database client (
mariadb/mysql/ ProxySQL admin), not bash.
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 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
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.
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:
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:
/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:
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:
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:
- Debian/Ubuntu:
/etc/mysql/mariadb.conf.d/99-production-tuning.cnf - RHEL/Rocky/AlmaLinux:
/etc/my.cnf.d/99-production-tuning.cnf
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:
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 you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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:
- How much RAM does the physical host have?
- How much RAM is available to the VM?
- How much RAM is actually available to the MariaDB process/container?
They are not necessarily the same.
Measure RAM on Linux
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:
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:
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
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:
- Global allocations: InnoDB buffer pool, Performance Schema, other global caches
- Dynamic/per-session allocations: sort buffers, join buffers, read buffers, temporary tables, network buffers, transaction-related memory
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.
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 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:
- Start with the MariaDB memory budget from Part 2.
- Reserve memory for connections, temporary tables, per-query operations, Performance Schema, and OS/container safety.
- Assign the remainder to the buffer pool.
- Check actual database size and buffer-pool effectiveness (below).
- Validate under production-like load — does increasing the pool materially reduce physical reads?
→ innodb_buffer_pool_size = 34G
/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
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
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.
→ 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:
# /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 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.
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.
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.
→ 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:
- Measure peak redo rate via
Innodb_os_log_writtenduring busiest window. - Multiply by the desired smoothing window (about 20 minutes in the worked example).
- Cross-check against 25–50% of buffer pool as a secondary heuristic.
- Validate under staging load; from MariaDB 10.9,
innodb_log_file_sizeis dynamically resizable.
/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.
/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 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.
/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.
/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 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:
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.
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.
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
--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
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 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
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.
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 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:
- Run fio to understand sustainable IOPS and latency at various queue depths.
- Start with a conservative value (e.g. 2,000–4,000 on capable NVMe).
- Run production-like load; monitor dirty pages and storage latency via iostat.
- Increase incrementally (e.g. 2,000 → 4,000 → 6,000) until dirty pages stabilize and latency remains acceptable.
- Stop when dirty pages are controlled, database latency is stable, and storage latency is acceptable.
innodb_io_capacity = 22000Start at 4,000, test, increase to 6,000 when dirty pages grow under load.
/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.
/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 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:
- Measure:
SHOW GLOBAL STATUS LIKE 'Created_tmp%'; - Calculate disk temp table ratio:
Created_tmp_disk_tables / Created_tmp_tables - If high, investigate query causes (GROUP BY, ORDER BY, missing indexes) before raising limits.
- Start conservatively: 64M–128M. MariaDB historically recommends 32–64 MB and increasing after measurement.
/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.
/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.
/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 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:
Application Connections = Application Instances × Pool Size(e.g. 8 replicas × 15 = 120)- Add administrative reserve and operational headroom (e.g. +10 +10 = 140)
- Validate:
SHOW GLOBAL STATUS LIKE 'Max_used_connections'; - Apply safety factor:
Target = Observed Peak × 1.25–1.5
/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).
/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.
/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 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.
/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:
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.
/etc/mysql/mariadb.conf.d/99-production-tuning.cnf (requires matching service/container limit).[mariadbd] open_files_limit = 65535
Verify OS limits:
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;.
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 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.
/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:
# /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 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.
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 you should do: COPY files; edit for your host; RUN apply/restart as labeled.
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
Step 2 — Connection memory budget
Peak measured application connections = 180. Safety margin 25% → 180 × 1.25 = 225 → max_connections = 225.
Per-session buffers (conservative):
# /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
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:
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.
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 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.
/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
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:
innodb_buffer_pool_instances— removed in MariaDB 10.6innodb_log_files_in_group— removed in MariaDB 10.6 (single redo file since 10.5)innodb_flush_method = O_DIRECT— Unix default since 10.6; deprecated in 11.0
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 you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Version changes — quick lookup
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
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
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
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
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
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 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
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.
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
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
# 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
# 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
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.
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
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
# /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
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.
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 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:
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:
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 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.
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.