MariaDB Performance Tuning on AlmaLinux 10: InnoDB Buffer Pool, Slow Query Log and Indexes

MySQL/MariaDB on AlmaLinux series, part 6. Part 1 installed MariaDB, part 2 covered users and remote access, part 3 covered backups, part 4 built GTID replication and part 5, MariaDB Galera Cluster, turned db1, db2 and db3 into a three-node cluster. This part makes that database fast: memory, redo log, connections, and finding the queries that actually hurt.

A stock MariaDB install is tuned for a small machine: a 128 MiB InnoDB buffer pool, a 96 MiB redo log and a slow query log that is switched off. On a real server that means most reads go to disk and nobody knows which query is responsible. This guide sizes the InnoDB buffer pool and redo log, sets sensible flush, connection and temporary table settings, turns on the slow query log with log rotation, analyzes it with mariadb-dumpslow and pt-query-digest, and fixes a slow query with EXPLAIN, ANALYZE and an index. Every setting below was checked against the MariaDB documentation and a live MariaDB 10.11.18 server.

Applies to: AlmaLinux 10.x with MariaDB 10.11 from AppStream (mariadb-server, currently 10.11.18). The same steps work on RHEL 10, Rocky Linux 10 and Oracle Linux 10, and on the mariadb11.8-server stream with the few differences called out below. A short MySQL 8.4 section is at the end.

The tuning workflow

  • Measure first. Record a baseline of status counters and the slowest queries before changing anything.
  • Fix memory. The buffer pool is the single biggest win on an InnoDB server.
  • Find the bad queries. A missing index costs more than any server variable can give back.
  • Change one thing at a time, keep every change in one drop-in file, and compare against the baseline.

Settings go in /etc/my.cnf.d/zz-tuning.cnf, following the zz-*.cnf drop-in pattern used throughout the series. Files in /etc/my.cnf.d/ are read in alphabetical order and the last value wins, so zz-tuning.cnf is read after zz-galera.cnf and zz-local.cnf.

Lab layout

HostIPRole
db1.example.lan192.168.1.40Galera node (part 5), receives all writes through HAProxy
db2.example.lan192.168.1.41Galera node
db3.example.lan192.168.1.42Galera node

Each node has 4 vCPUs, 16 GiB of RAM and SSD storage. If you skipped part 5 and run a single server, ignore the Galera notes; everything else is identical.

Prerequisites

  • MariaDB installed and secured as in part 1; root logs in over the Unix socket with sudo mariadb.
  • Enough uptime under normal load for the status counters to mean something. Counters reset on restart, so record values after at least a day of real traffic where possible.
  • A recent backup (part 3). Tuning is low risk, but a typo in a .cnf file stops the server from starting.
  • For a Galera cluster: all three nodes Synced, and a plan to restart them one at a time.

Step 1: Record a baseline

Check the version and the current values of the settings this guide touches:

sudo mariadb -e "SELECT VERSION();"
sudo mariadb -e "SHOW GLOBAL VARIABLES WHERE Variable_name IN (
 'innodb_buffer_pool_size','innodb_buffer_pool_size_max','innodb_log_file_size',
 'innodb_log_buffer_size','innodb_flush_log_at_trx_commit','innodb_flush_method',
 'innodb_io_capacity','max_connections','thread_cache_size','tmp_table_size',
 'max_heap_table_size','table_open_cache','slow_query_log','long_query_time');"

On a fresh AlmaLinux 10 install you will see the upstream defaults:

VariableDefault (10.11)Notes
innodb_buffer_pool_size128MFar too small for a dedicated database server
innodb_buffer_pool_size_max8T (virtual address reservation)From 10.11.17; ceiling for growing the pool at runtime
innodb_log_file_size96MSingle redo log file ib_logfile0
innodb_log_buffer_size16M
innodb_flush_log_at_trx_commit1Fully durable
innodb_flush_methodO_DIRECTDefault since 10.6; leave it
innodb_io_capacity / _max200 / 2000Background flushing budget in IOPS
max_connections151
thread_cache_size256, capped at max_connectionsShows 151 on a default install
tmp_table_size / max_heap_table_size16M / 16MThe smaller of the two applies
table_open_cache2000
slow_query_log / long_query_timeOFF / 10Ten seconds catches almost nothing

Save a snapshot of all status counters so you can compare later:

sudo mkdir -p /root/dbtuning
sudo mariadb -e "SHOW GLOBAL STATUS" | sudo tee /root/dbtuning/status-$(date +%F-%H%M).txt >/dev/null
sudo mariadb -e "SHOW GLOBAL VARIABLES" | sudo tee /root/dbtuning/variables-$(date +%F-%H%M).txt >/dev/null

Finally, see how much InnoDB data you have. The buffer pool does not need to hold all of it, only the part that is read often (the working set), but the total is a useful upper bound:

sudo mariadb -e "
SELECT table_schema,
       ROUND(SUM(data_length)/1024/1024)  AS data_mb,
       ROUND(SUM(index_length)/1024/1024) AS index_mb
FROM information_schema.tables
WHERE engine = 'InnoDB'
GROUP BY table_schema WITH ROLLUP;"
free -g

Step 2: Size the InnoDB buffer pool

The buffer pool caches table and index pages in memory. When the working set fits, reads are served from RAM and the disk only sees writes. MariaDB’s documentation suggests up to 80% of RAM on a server that runs only InnoDB, but that assumes nothing else needs memory. Leave room for:

  • the operating system and page cache for logs and binlogs (at least 1 to 2 GiB);
  • per-connection buffers (sort, join, read buffers and temporary tables) multiplied by your real peak connection count;
  • on Galera, the gcache ring buffer (gcache.size=1G in part 5) and the memory used by mariabackup during a state transfer;
  • anything else on the host: monitoring agents, backups compressing with zstd, and so on.
RAMDedicated DB serverShared with an application
4 GiB2G1G
8 GiB5G3G
16 GiB10G to 11G6G
32 GiB22G to 24G14G
64 GiB48G to 50G30G

For the 16 GiB lab nodes, 10G is a safe start. If the data set is smaller than that, there is no need to go bigger: a 3 GB database is fully cached by a 4G pool.

Grow the buffer pool without a restart

innodb_buffer_pool_size is dynamic. On MariaDB 10.11.17 and later (AlmaLinux 10 ships 10.11.18), innodb_buffer_pool_size_max defaults to an 8 TiB virtual address reservation, so you can grow the pool online:

sudo mariadb -e "SET GLOBAL innodb_buffer_pool_size = 10*1024*1024*1024;"
sudo mariadb -e "SELECT @@innodb_buffer_pool_size/1024/1024/1024 AS buffer_pool_gb;"

The statement blocks until the resize is finished. Two version quirks to know about:

  • On 10.11.12 to 10.11.16, innodb_buffer_pool_size_max defaulted to the startup size, so growing at runtime failed with warning 1292. On those versions set the value in the config file and restart, or set innodb_buffer_pool_size_max at startup.
  • From 10.11.12, innodb_buffer_pool_chunk_size is deprecated and ignored; the pool is resized in 1 MiB steps. Remove it from old config files.

Do not set innodb_buffer_pool_instances

Old tuning guides tell you to split the pool into several instances. MariaDB 10.5 went back to a single buffer pool, and 10.6 removed innodb_buffer_pool_instances completely. A leftover line does not stop the server, but it logs this at every start and does nothing:

[Warning] 'innodb-buffer-pool-instances' was removed. It does nothing now and exists only for compatibility with old my.cnf files.

The same applies to innodb_log_files_in_group, innodb_page_cleaners and innodb_thread_concurrency, which were all removed in 10.6. Find leftovers with:

sudo grep -rnE 'buffer_pool_instances|buffer-pool-instances|log_files_in_group|page_cleaners|thread_concurrency|chunk_size|query_cache_size' /etc/my.cnf /etc/my.cnf.d/
sudo journalctl -u mariadb -b | grep -i 'was removed\|deprecated'

Keep the cache warm across restarts

innodb_buffer_pool_dump_at_shutdown and innodb_buffer_pool_load_at_startup are both ON by default: InnoDB saves the list of cached pages at shutdown and reloads them after a restart, so you do not start cold. Watch the reload with:

sudo mariadb -e "SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_load_status';"

Step 3: Redo log size and flush settings

innodb_log_file_size

The redo log (ib_logfile0) records every change before the data pages are written. When it fills, InnoDB has to flush dirty pages aggressively to make room, which shows up as write stalls. The 96M default is small for any busy server. A common sizing goal is to hold roughly an hour of peak redo writes; 1G to 2G suits most servers. Measure how much redo you write per minute during peak load:

sudo mariadb -e "SHOW GLOBAL STATUS LIKE 'Innodb_os_log_written'"; sleep 60; \
sudo mariadb -e "SHOW GLOBAL STATUS LIKE 'Innodb_os_log_written'"

Subtract the two values to get bytes per minute, then multiply by 60. Since MariaDB 10.9 the redo log can be resized online, and the server builds the new file in the background:

sudo mariadb -e "SET GLOBAL innodb_log_file_size = 1024*1024*1024;"
sudo mariadb -e "SELECT @@innodb_log_file_size/1024/1024 AS redo_mb;"

Also put the value in the config file (step 5) so it survives a restart. Never delete ib_logfile0 to resize it; since 10.8 the server refuses to start without it. A larger redo log means less flushing pressure but a longer crash recovery.

To check whether the log is under pressure, compare Innodb_checkpoint_age with Innodb_checkpoint_max_age. If the age regularly gets close to the maximum, or Innodb_log_waits keeps rising (writes waiting on a full innodb_log_buffer_size), make the log or the log buffer bigger.

sudo mariadb -e "SHOW GLOBAL STATUS WHERE Variable_name IN
 ('Innodb_checkpoint_age','Innodb_checkpoint_max_age','Innodb_log_waits');"

innodb_flush_log_at_trx_commit

ValueOn COMMITCan lose on crash
1 (default)Write and fsync the redo logNothing; fully ACID
2Write to the OS; fsync about once a secondUp to ~1 s of commits on an OS crash or power loss (not on a mariadbd crash)
0Nothing; write and fsync once a secondUp to ~1 s of commits even if only mariadbd crashes

Keep 1 on a standalone server or an async primary, together with sync_binlog=1 from part 4. On a Galera cluster, 2 is a common and reasonable choice: a committed transaction has already been replicated to the other nodes, so a single node losing its last second is repaired by IST when it rejoins. The risk is the whole cluster losing power at the same moment. Pick deliberately; do not copy 0 from the commented example in mariadb-server.cnf.

innodb_flush_method and innodb_io_capacity

Leave innodb_flush_method at its O_DIRECT default, which avoids double caching in the Linux page cache. On the MariaDB 11.8 stream it is deprecated in favour of four dynamic switches (innodb_log_file_buffering, innodb_data_file_buffering, innodb_log_file_write_through, innodb_data_file_write_through); the defaults are right for most servers there too.

innodb_io_capacity tells InnoDB how many IOPS background flushing may use. The default of 200 suits a single spinning disk. For SSD or NVMe start around 1000 and keep innodb_io_capacity_max at its automatic value (2000 or twice innodb_io_capacity, whichever is higher). MariaDB recommends staying on the low side, because a too-high value flushes pages before they can be modified again. If you do not know what the storage can do, measure it first; see finding hard drive IOPS on Linux.

Step 4: Connections, threads, temporary tables and caches

max_connections and the thread cache

sudo mariadb -e "SHOW GLOBAL STATUS WHERE Variable_name IN
 ('Max_used_connections','Threads_connected','Threads_running',
  'Threads_created','Connections','Aborted_connects','Aborted_clients');"
  • If Max_used_connections is close to max_connections, clients are about to get ERROR 1040 (HY000): Too many connections. Raise it, but first ask why: a connection leak or a missing connection pool in the application is the usual cause. MariaDB always keeps one extra slot for an account with CONNECTION ADMIN, so you can still log in to investigate.
  • Threads_running is what actually burns CPU. Hundreds of open connections with 5 running are fine; 60 running on 4 vCPUs means queries are queueing.
  • Threads_created / Connections is the thread cache miss rate. With the default thread_cache_size it is usually near zero; raise thread_cache_size only if it is not.
  • Rising Aborted_connects means failed logins or network problems (see part 2); rising Aborted_clients usually means applications that drop connections without closing them.

Every connection can allocate session buffers: sort_buffer_size (2M), join_buffer_size (256K), read buffers and in-memory temporary tables. Leave these per-session buffers at their defaults; raising them globally multiplies by every connection and is a classic way to run a server out of memory. Raise them for a single session (SET SESSION sort_buffer_size = 16*1024*1024;) before a known heavy report instead.

For workloads with thousands of mostly idle connections, MariaDB also has a built-in thread pool (thread_handling = pool-of-threads). Test it under load before switching; for typical web applications with a connection pool the default one-thread-per-connection is fine.

Temporary tables

sudo mariadb -e "SHOW GLOBAL STATUS LIKE 'Created_tmp%tables';"

Created_tmp_disk_tables counts internal temporary tables (from GROUP BY, DISTINCT, UNION, derived tables) that grew past the in-memory limit and were converted to on-disk Aria tables. If more than a few percent of Created_tmp_tables go to disk, raise both limits together, because the smaller of the two applies:

tmp_table_size      = 64M
max_heap_table_size = 64M

Do not go wild: each running query can create its own temporary table up to this size. Queries that sort or group millions of rows are better fixed with an index (step 9). Note that tables with TEXT or BLOB columns in the result may go straight to disk regardless of the size limits.

Table cache and open files

sudo mariadb -e "SHOW GLOBAL STATUS WHERE Variable_name IN ('Open_tables','Opened_tables','Opened_table_definitions');
SHOW GLOBAL VARIABLES WHERE Variable_name IN ('table_open_cache','table_definition_cache','open_files_limit');"

If Opened_tables keeps climbing quickly while Open_tables sits at table_open_cache, the cache is too small for the number of tables in use. The default 2000 suits most servers; raise it to 4000 or more for applications with thousands of tables, and raise table_definition_cache (default 400) to cover the total number of tables. open_files_limit is sized automatically; if the error log warns that it could not raise it, add a systemd override:

sudo systemctl edit mariadb
# add:
[Service]
LimitNOFILE=65535

sudo systemctl daemon-reload

Leave the query cache off

The query cache is disabled by default (query_cache_type=OFF). It serializes on a global lock, is invalidated by every write to a table, and is not a win on multi-core servers. Do not set query_cache_size to a non-zero value: that silently switches the cache back on.

Step 5: Write zz-tuning.cnf

Collect the decisions in one file. This example is for a 16 GiB dedicated node with SSD storage; adjust the sizes for your hardware:

sudo tee /etc/my.cnf.d/zz-tuning.cnf >/dev/null <<'EOF'
# Performance tuning - MySQL/MariaDB series part 6
[mariadb]
# --- InnoDB memory and redo log ---
innodb_buffer_pool_size        = 10G
innodb_log_file_size           = 1G
innodb_log_buffer_size         = 32M
innodb_flush_log_at_trx_commit = 1
innodb_io_capacity             = 1000
# innodb_flush_method: leave at the O_DIRECT default
# innodb_buffer_pool_instances: removed in 10.6, do not set

# --- connections ---
max_connections     = 300

# --- temporary tables and caches ---
tmp_table_size      = 64M
max_heap_table_size = 64M
table_open_cache    = 4000
table_definition_cache = 2000

# --- slow query log (step 6) ---
slow_query_log      = 1
slow_query_log_file = /var/log/mariadb/mariadb-slow.log
long_query_time     = 1
log_slow_verbosity  = query_plan
log_queries_not_using_indexes = 0
EOF
sudo chown root:mysql /etc/my.cnf.d/zz-tuning.cnf
sudo chmod 640 /etc/my.cnf.d/zz-tuning.cnf

Check what the server will actually read before you restart, including any duplicates in other files (the last one listed wins):

sudo my_print_defaults --mysqld | grep -E 'buffer_pool|log_file_size|flush_log|io_capacity|max_connections|tmp_table|heap_table|table_open|slow|long_query'

Apply the settings. On a standalone server just restart. On the Galera cluster, copy the file to all three nodes and restart one node at a time, waiting for it to return to Synced before moving on. Start with db3 and finish with db1, the HAProxy writer:

# on db3, then db2, then db1
sudo systemctl restart mariadb
sudo mariadb -e "SHOW GLOBAL STATUS WHERE Variable_name IN
 ('wsrep_local_state_comment','wsrep_cluster_size','wsrep_ready');"
# continue only when: Synced, 3, ON

A short restart rejoins through IST from the gcache, so it takes seconds rather than a full SST. A buffer pool this large adds a few seconds to startup and to the page dump at shutdown; the unit file allows 300 seconds.

Step 6: The slow query log

The settings above already enable the log at startup. To turn it on immediately without waiting for a restart, set the same values at runtime:

sudo mariadb -e "
SET GLOBAL slow_query_log_file = '/var/log/mariadb/mariadb-slow.log';
SET GLOBAL long_query_time     = 1;
SET GLOBAL log_slow_verbosity  = 'query_plan';
SET GLOBAL slow_query_log      = 1;"

/var/log/mariadb is owned by mysql and already carries the mysqld_log_t SELinux type, so the server can create the file there without any policy changes. A new long_query_time set with SET GLOBAL only applies to connections opened afterwards; existing sessions keep their old value.

SettingWhat it does
slow_query_logTurns the log on. Called log_slow_query from 10.11; both names work.
slow_query_log_fileLog path. Default is <hostname>-slow.log in the datadir. Alias log_slow_query_file.
long_query_timeThreshold in seconds; fractions allowed (0.5). Default 10. Alias log_slow_query_time.
log_slow_verbosityExtra detail: query_plan (full scans, temporary tables, filesorts), explain (EXPLAIN output per query), engine/innodb, warnings, all.
log_queries_not_using_indexesAlso log queries that do no index lookup, regardless of time. Noisy; enable for short periods.
min_examined_row_limitSkip queries that examine fewer rows than this. Alias log_slow_min_examined_row_limit.
log_slow_rate_limitLog only one in N qualifying queries, to keep the log small on busy servers.
log_slow_admin_statementsLog slow ALTER, OPTIMIZE, ANALYZE and similar. ON by default.

Start with 1 second, look at what you catch, and lower it to 0.5 or 0.2 once the worst offenders are fixed. To hunt for missing indexes, enable log_queries_not_using_indexes together with min_examined_row_limit = 1000 for an hour, then turn it off again.

Test it with a deliberately slow statement:

sudo mariadb -e "SELECT SLEEP(2);"
sudo mariadb -e "SHOW GLOBAL STATUS LIKE 'Slow_queries';"
sudo tail -n 8 /var/log/mariadb/mariadb-slow.log

A logged query with log_slow_verbosity = query_plan looks like this:

# Time: 261114  3:14:03
# User@Host: shop_app[shop_app] @ app1.example.lan [192.168.1.50]
# Thread_id: 7  Schema: shop  QC_hit: No
# Query_time: 0.068686  Lock_time: 0.000012  Rows_sent: 20  Rows_examined: 500020
# Rows_affected: 0  Bytes_sent: 936
# Full_scan: Yes  Full_join: No  Tmp_table: No  Tmp_table_on_disk: No
# Filesort: Yes  Filesort_on_disk: No  Merge_passes: 0  Priority_queue: Yes
SET timestamp=1794647643;
SELECT id, total, created_at FROM orders WHERE customer_id = 1234 ORDER BY created_at DESC LIMIT 20;

Rows_examined: 500020 for Rows_sent: 20 and Full_scan: Yes is the signature of a missing index. That query is fixed in step 9.

Rotate the slow log

The mariadb-server package ships /etc/logrotate.d/mariadb for the error log only. Add a separate file for the slow log rather than editing the packaged one, so updates do not leave an .rpmnew behind:

sudo tee /etc/logrotate.d/mariadb-slow >/dev/null <<'EOF'
/var/log/mariadb/mariadb-slow.log {
    su mysql mysql
    missingok
    notifempty
    daily
    rotate 14
    compress
    delaycompress
    create 0640 mysql mysql
    postrotate
        /usr/bin/mariadb-admin --local flush-slow-log
    endscript
}
EOF
sudo logrotate -d /etc/logrotate.d/mariadb-slow
sudo logrotate -f /etc/logrotate.d/mariadb-slow
ls -l /var/log/mariadb/

flush-slow-log makes the server close and reopen the file, and --local keeps the FLUSH out of the binary log (and therefore out of Galera replication). Like the packaged error-log rule, this relies on the mysql@localhost account that mariadb-install-db creates with Unix socket authentication. If the flush fails, test it by hand with sudo -u mysql mariadb-admin --local flush-slow-log.

Step 7: Summarize the log with mariadb-dumpslow

mariadb-dumpslow groups similar queries by replacing numbers with N and strings with 'S'. On AlmaLinux it is in the mariadb-server-utils package:

sudo dnf install -y mariadb-server-utils
# top 10 by total time
sudo mariadb-dumpslow -s t -t 10 /var/log/mariadb/mariadb-slow.log
# top 10 by rows examined, only queries touching orders
sudo mariadb-dumpslow -s e -t 10 -g orders /var/log/mariadb/mariadb-slow.log
Reading mysql slow query log from /var/log/mariadb/mariadb-slow.log
Count: 5  Time=0.09s (0s)  Lock=0.00s (0s)  Rows_sent=20.0 (100), Rows_examined=89262.2 (446311), Rows_affected=0.0 (0), shop_app[shop_app]@app1.example.lan
  SELECT id, total, created_at FROM orders WHERE customer_id = N ORDER BY created_at DESC LIMIT N

Useful sort orders are t (total time), at (average time, the default), c (count), e (rows examined) and l (lock time). mariadb-dumpslow is quick but simple: the extra comment lines written by log_slow_verbosity (temporary table sizes, EXPLAIN output) sometimes end up glued into the grouped query text, and the header line written when the server starts creates a junk entry. For anything more than a quick look, use pt-query-digest.

Step 8: Deeper analysis with pt-query-digest

pt-query-digest from Percona Toolkit ranks queries by total response time and prints percentiles, a time distribution and ready-to-run EXPLAIN statements for each one. It is not in EPEL 10; install it from Percona’s repository, which has EL10 packages:

sudo dnf install -y https://repo.percona.com/yum/percona-release-latest.noarch.rpm
sudo percona-release enable pt release
sudo dnf install -y percona-toolkit
pt-query-digest --version

Analyze the whole log, or just a recent window:

sudo pt-query-digest --limit 10 /var/log/mariadb/mariadb-slow.log > /root/dbtuning/digest-$(date +%F).txt
sudo pt-query-digest --since 24h --limit 10 /var/log/mariadb/mariadb-slow.log | less

The profile at the top is the part to read first:

# Profile
# Rank Query ID                            Response time Calls R/Call V/M
# ==== =================================== ============= ===== ====== ====
#    1 0x89E39D9104C5B5C295D4FDB7C55E4C73   0.5696 81.7%     6 0.0949  0.00 SELECT orders
#    2 0xAF982714169E9936F63A12CF49529C46   0.1280 18.3%     2 0.0640  0.00 SELECT orders

Fix queries in rank order. A 90 ms query that runs 50,000 times an hour matters far more than one 20-second monthly report. Each detailed section below the profile shows the Rows examine distribution, the databases, users and hosts involved, and an EXPLAIN line you can paste into the client.

If you prefer not to add another repository, MariaDB’s sys schema gives similar per-statement statistics from the Performance Schema, which is off by default. Enabling it needs performance_schema = ON in zz-tuning.cnf and a restart, and costs some memory and CPU:

sudo mariadb -e "SELECT query, exec_count, total_latency, rows_examined_avg
FROM sys.statement_analysis ORDER BY total_latency DESC LIMIT 10;"

Step 9: Fix a slow query with EXPLAIN, ANALYZE and an index

Take the top query from the digest. The examples use a 500,000-row shop.orders table; to follow along on a test server, create it with the seq_1_to_500000 table from MariaDB’s built-in Sequence engine:

sudo mariadb shop -e "
CREATE TABLE IF NOT EXISTS orders (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  customer_id INT UNSIGNED NOT NULL,
  status      VARCHAR(16) NOT NULL,
  total       DECIMAL(10,2) NOT NULL,
  created_at  DATETIME NOT NULL
) ENGINE=InnoDB;
INSERT INTO orders (customer_id, status, total, created_at)
SELECT FLOOR(1 + RAND()*5000),
       ELT(1 + FLOOR(RAND()*3), 'new', 'paid', 'shipped'),
       ROUND(RAND()*500, 2),
       NOW() - INTERVAL FLOOR(RAND()*365*86400) SECOND
FROM seq_1_to_500000;
ANALYZE TABLE orders;"

EXPLAIN: the plan the optimizer intends to use

sudo mariadb shop -e "EXPLAIN SELECT id, total, created_at FROM orders
WHERE customer_id = 1234 ORDER BY created_at DESC LIMIT 20;"
+------+-------------+--------+------+---------------+------+---------+------+--------+-----------------------------+
| id   | select_type | table  | type | possible_keys | key  | key_len | ref  | rows   | Extra                       |
+------+-------------+--------+------+---------------+------+---------+------+--------+-----------------------------+
|    1 | SIMPLE      | orders | ALL  | NULL          | NULL | NULL    | NULL | 498839 | Using where; Using filesort |
+------+-------------+--------+------+---------------+------+---------+------+--------+-----------------------------+
ColumnWhat to look for
typeALL = full table scan (bad on big tables). Better: index, range, ref, eq_ref, const.
possible_keys / keyWhich indexes could be used, and which one was. NULL means none.
rowsEstimated rows to examine. Compare with how many you actually return.
ExtraUsing filesort and Using temporary mean extra sorting or temporary tables; Using index means the index alone answered the query (covering index).

ANALYZE: run the query and show what really happened

MariaDB’s ANALYZE statement executes the query and adds measured columns next to the estimates (MySQL’s equivalent is EXPLAIN ANALYZE):

sudo mariadb shop -e "ANALYZE SELECT id, total, created_at FROM orders
WHERE customer_id = 1234 ORDER BY created_at DESC LIMIT 20;"
+------+-------------+--------+------+---------------+------+---------+------+--------+--------+----------+------------+-----------------------------+
| id   | select_type | table  | type | possible_keys | key  | key_len | ref  | rows   | r_rows | filtered | r_filtered | Extra                       |
+------+-------------+--------+------+---------------+------+---------+------+--------+--------+----------+------------+-----------------------------+
|    1 | SIMPLE      | orders | ALL  | NULL          | NULL | NULL    | NULL | 498839 | 20.00  |   100.00 |     100.00 | Using where; Using filesort |
+------+-------------+--------+------+---------------+------+---------+------+--------+--------+----------+------------+-----------------------------+

r_rows and r_filtered are real measurements; a big gap between rows and r_rows means stale statistics (fix with ANALYZE TABLE orders;). ANALYZE FORMAT=JSON SELECT ... adds per-step timings (r_total_time_ms) and is the best tool for complex joins. Because ANALYZE really runs the statement, do not point it at an UPDATE or DELETE on production data.

Add the right index

The query filters on customer_id and sorts by created_at, so a composite index on (customer_id, created_at) serves both the lookup and the order:

sudo mariadb shop -e "ALTER TABLE orders
  ADD INDEX idx_customer_created (customer_id, created_at),
  ALGORITHM=INPLACE, LOCK=NONE;"
+------+-------------+--------+------+----------------------+----------------------+---------+-------+------+-------------+
| id   | select_type | table  | type | possible_keys        | key                  | key_len | ref   | rows | Extra       |
+------+-------------+--------+------+----------------------+----------------------+---------+-------+------+-------------+
|    1 | SIMPLE      | orders | ref  | idx_customer_created | idx_customer_created | 4       | const | 115  | Using where |
+------+-------------+--------+------+----------------------+----------------------+---------+-------+------+-------------+

From scanning 498,839 rows with a filesort to reading about 115 index entries with no sort at all. Rules of thumb for indexes:

  • Put equality columns first, then the range or ORDER BY column: (customer_id, created_at), not the other way round.
  • A composite index also serves queries on its leftmost columns, so (customer_id, created_at) makes a separate index on customer_id redundant.
  • Functions on indexed columns defeat the index: WHERE DATE(created_at) = '2026-11-01' scans, WHERE created_at >= '2026-11-01' AND created_at < '2026-11-02' uses the index. The same goes for a leading wildcard in LIKE '%term'.
  • Every index slows down writes and uses buffer pool memory. Drop indexes nothing uses, and check sizes with the query below.
sudo mariadb -e "
SELECT table_name, index_name,
       ROUND(stat_value * @@innodb_page_size / 1024 / 1024, 1) AS size_mb
FROM mysql.innodb_index_stats
WHERE database_name = 'shop' AND stat_name = 'size'
ORDER BY size_mb DESC;"

On Galera, schema changes are cluster-wide events. With the default wsrep_OSU_method=TOI (total order isolation), an ALTER TABLE runs on all nodes at the same point in the replication stream and blocks writes to that table on every node until it finishes, even though InnoDB would do it online on a standalone server. Small tables are no problem; for big ones schedule a quiet window, use pt-online-schema-change from Percona Toolkit, or do a rolling schema upgrade with wsrep_OSU_method=RSU one node at a time (only for changes that stay compatible with replication, such as adding an index).

Step 10: Monitor the important status counters

Buffer pool hit ratio

sudo mariadb -e "
SELECT ROUND(100 * (1 -
  (SELECT VARIABLE_VALUE FROM information_schema.GLOBAL_STATUS WHERE VARIABLE_NAME = 'INNODB_BUFFER_POOL_READS') /
  (SELECT VARIABLE_VALUE FROM information_schema.GLOBAL_STATUS WHERE VARIABLE_NAME = 'INNODB_BUFFER_POOL_READ_REQUESTS')
), 2) AS bp_hit_pct;
SHOW GLOBAL STATUS WHERE Variable_name IN ('Innodb_buffer_pool_pages_total','Innodb_buffer_pool_pages_free',
 'Innodb_buffer_pool_pages_dirty','Innodb_buffer_pool_wait_free');"

Innodb_buffer_pool_read_requests are logical reads; Innodb_buffer_pool_reads are the ones that had to go to disk. The ratio since startup hides recent behaviour, so also look at the change over a minute of normal load. MariaDB’s guideline is that disk reads should be under 1% of read requests:

sudo mariadb-admin -r -i 60 -c 2 extended-status | grep -E 'Innodb_buffer_pool_read(s|_requests) '

The second block of output shows the increase during that minute. Below 99% under steady load, with Innodb_buffer_pool_pages_free near zero, means the working set does not fit: add memory to the pool or reduce what queries read. A non-zero, rising Innodb_buffer_pool_wait_free means queries waited for a free page: the pool is too small or flushing cannot keep up (check innodb_io_capacity).

A quick health snapshot

Counter(s)HealthyInvestigate when
Innodb_buffer_pool_reads vs _read_requestsUnder 1% over a minuteAbove 1%, free pages near 0
Created_tmp_disk_tables vs Created_tmp_tablesA few percentLarge and rising: tune or index
Threads_created vs ConnectionsNear 0Raise thread_cache_size
Max_used_connectionsWell under max_connectionsWithin 10 to 20%
Slow_queriesFlat or slowly risingSudden jump after a deploy
Select_full_join0Joins without indexes
Sort_merge_passesLowBig sorts spilling to disk
Innodb_row_lock_waits, Innodb_row_lock_time_avgLowLock contention, long transactions
Innodb_log_waits0 or flatIncrease innodb_log_buffer_size

SHOW ENGINE INNODB STATUS\G gives the detailed view: the BUFFER POOL AND MEMORY section, pending reads and writes, and the latest deadlock.

mysqltuner as a second opinion

MySQLTuner is a Perl script that reads the same counters and prints suggestions. It is packaged in EPEL 10:

sudo dnf install -y epel-release
sudo dnf install -y mysqltuner
sudo mysqltuner

Treat its output as hints, not instructions. It needs at least a day of uptime to be meaningful, it does not know your workload, and its generic advice (for example on per-session buffers) can conflict with the memory budget you worked out above.

Galera-specific considerations

  • Keep the nodes alike. Galera runs at the speed of the slowest node: when a node cannot apply write-sets fast enough it triggers flow control and pauses replication for the whole cluster. Deploy the same zz-tuning.cnf on all three nodes.
  • Watch flow control. wsrep_flow_control_paused is the fraction of time replication was paused since the last FLUSH STATUS; near 0.0 is healthy, 0.2 or more needs attention. wsrep_local_recv_queue_avg rising on one node points at that node.
  • Applier threads. wsrep_slave_threads defaults to 1. On multi-core nodes with a write-heavy workload, raising it (for example to 4, the number of vCPUs here) lets write-sets apply in parallel. If you see consistency errors, go back to 1.
  • Large transactions. A multi-million-row UPDATE or DELETE becomes one huge write-set that every node must certify and apply. Batch it (for example 5,000 rows per transaction) or use streaming replication (wsrep_trx_fragment_size) for the occasional big job.
  • The slow log is per node. Writes arrive on db1 through HAProxy, but reports pointed at db2 or db3 log there. Collect and digest the slow log from every node.
  • Durability. See the innodb_flush_log_at_trx_commit discussion in step 3.
sudo mariadb -e "SHOW GLOBAL STATUS WHERE Variable_name IN
 ('wsrep_flow_control_paused','wsrep_local_recv_queue_avg','wsrep_local_send_queue_avg',
  'wsrep_cert_deps_distance','wsrep_local_state_comment');"

Verification checklist

# settings active after the restart
sudo mariadb -e "SELECT @@innodb_buffer_pool_size/1024/1024/1024 AS bp_gb,
 @@innodb_log_file_size/1024/1024 AS redo_mb, @@innodb_io_capacity AS io_cap,
 @@max_connections AS max_conn, @@tmp_table_size/1024/1024 AS tmp_mb,
 @@slow_query_log AS slowlog, @@long_query_time AS lqt, @@slow_query_log_file AS slowfile;"

# no warnings about removed or unknown options
sudo journalctl -u mariadb -b | grep -iE 'warning|error' | tail -20

# the slow log is written and rotates
sudo mariadb -e "SELECT SLEEP(1.5);" && sudo tail -n 3 /var/log/mariadb/mariadb-slow.log
sudo logrotate -d /etc/logrotate.d/mariadb-slow

# memory headroom: no swapping
free -h; vmstat 5 3

Then compare a fresh status snapshot with the baseline from step 1 after a day of normal traffic: lower Innodb_buffer_pool_reads per hour, fewer Created_tmp_disk_tables and a shorter slow log.

Troubleshooting

SymptomCause and fix
MariaDB does not start after editing the fileA typo or unknown option. Run sudo journalctl -u mariadb -b and look for unknown variable; test with sudo -u mysql /usr/libexec/mariadbd --verbose --help >/dev/null, which parses the config and reports errors.
Warning 1292 on SET GLOBAL innodb_buffer_pool_sizeAbove innodb_buffer_pool_size_max (10.11.12 to 10.11.16 default). Set the size in the config file and restart.
OOM killer stops mariadbd; dmesg shows Out of memory: Killed process ... (mariadbd)Buffer pool plus per-connection buffers exceed RAM. Lower innodb_buffer_pool_size, revert raised session buffers, check Max_used_connections.
Restart takes minutes or systemd times outHuge buffer pool dump/load or a crash recovery with a big redo log. Wait it out (watch the error log); raise TimeoutSec with systemctl edit mariadb if needed.
Slow log stays emptyslow_query_log is OFF, the threshold is too high, or the session was opened before SET GLOBAL long_query_time. Reconnect and check SHOW GLOBAL STATUS LIKE 'Slow_queries'.
File '/var/log/mariadb/mariadb-slow.log' not found (Errcode: 13)Permissions or SELinux. Use /var/log/mariadb (owned by mysql, type mysqld_log_t); for another directory run semanage fcontext -a -t mysqld_log_t '/path(/.*)?' and restorecon -Rv /path.
Slow log keeps growing after rotationThe server is still writing to the rotated file. The postrotate flush failed; run sudo -u mysql mariadb-admin --local flush-slow-log and read the error.
ERROR 1040: Too many connectionsRaise max_connections (it is dynamic) and find the leak; check SHOW PROCESSLIST for idle Sleep connections and lower wait_timeout if they pile up.
Hit ratio low right after a restartNormal: the pool is warming up. Check Innodb_buffer_pool_load_status and measure again later.
Writes stall in burstsRedo log too small or innodb_io_capacity too low for the write rate. Check checkpoint age (step 3) and dirty pages.
Galera: whole cluster slows down during an ALTER TABLETOI schema change. Use a quiet window, pt-online-schema-change, or RSU (step 9).
Galera: wsrep_flow_control_paused highOne node is slower (hardware, disk, competing load) or applier threads are too few. Compare wsrep_local_recv_queue_avg across nodes.

MySQL 8.4 notes

  • Redo log size is set with innodb_redo_log_capacity (MySQL 8.0.30 and later) instead of innodb_log_file_size.
  • MySQL still has innodb_buffer_pool_instances, and innodb_dedicated_server=ON sizes the buffer pool and redo log automatically on a dedicated host.
  • The query cache was removed in MySQL 8.0.
  • Use EXPLAIN ANALYZE (MySQL) instead of ANALYZE (MariaDB). The Performance Schema is on by default and the sys schema views work out of the box.
  • The slow log variables are slow_query_log, slow_query_log_file and long_query_time; log_slow_verbosity does not exist, but log_slow_extra=ON adds similar fields. The summary tool is mysqldumpslow, and pt-query-digest works the same way.

Next in the series

Part 7 covers TLS-encrypted client connections and troubleshooting common MariaDB/MySQL errors. Earlier parts: part 5, MariaDB Galera Cluster, part 4, GTID primary/replica replication, part 3, backups and point-in-time recovery, part 2, users, grants and remote access and part 1, install and secure MariaDB/MySQL. For the same exercise on PostgreSQL, see PostgreSQL performance tuning with EXPLAIN ANALYZE, indexes and autovacuum.

Official references: MariaDB: InnoDB buffer pool, MariaDB: InnoDB system variables, MariaDB: InnoDB redo log, MariaDB: slow query log overview, MariaDB: flow control in Galera Cluster and Percona Toolkit: pt-query-digest.

Leave a Reply

Your email address will not be published. Required fields are marked *