MySQL & MariaDB in Depth Module 4 – InnoDB internals
The buffer pool: LRU, flushing and warm-up
How the InnoDB buffer pool keeps hot pages through a large scan, when its page cleaner writes dirty pages, and how to size, resize and warm it on MySQL 8.4.
What you will learn
- Explain how midpoint insertion and the old sublist keep one large scan from emptying the buffer pool
- Read the hit rate, dirty pages and unused frames of the pool from status variables and information_schema
- Size the buffer pool from its working set, resize it online and keep it warm across a restart
- Tune flushing with innodb_io_capacity and the dirty-page limits, and explain what adaptive flushing changes
- Predict which buffer pool defaults differ between MySQL 8.0, 8.4 and 9.7, starting with change buffering
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how midpoint insertion and the old sublist keep one large scan from emptying the buffer pool
- Read the hit rate, dirty pages and unused frames of the pool from status variables and information_schema
- Size the buffer pool from its working set, resize it online and keep it warm across a restart
- Tune flushing with innodb_io_capacity and the dirty-page limits, and explain what adaptive flushing changes
- Predict which buffer pool defaults differ between MySQL 8.0, 8.4 and 9.7, starting with change buffering
Before you start
On this page
Unless someone sizes it, MySQL 8.4 caches InnoDB data in 128 MiB of memory, 8,191 frames of 16 KB, and every row a query reads must be in one of those frames first. In the first recorded session, 40 orders read right after a restart needed 21 pages from disk, and the same read a moment later needed none. That memory is the buffer pool: The area of memory where InnoDB caches table and index pages, in frames the size of a page, 16 KB by default. Queries read pages through it and changes are made in it first; innodb_buffer_pool_size sets its size, 128 MiB by default.. Its least-recently-used list starts new pages three eighths from the tail, so one large scan cannot push out the pages queries keep using; it writes changed pages back in the background; and it can save a list of its pages at shutdown, so the next start begins warm.
You will read the pool’s counters, step through midpoint insertion, watch a scan larger than the pool leave a hot table cached, count dirty pages before and after the page cleaner, resize the pool online and restart a server warm.
Every page a query reads passes through the pool
InnoDB never answers a query straight from a data file. It looks for each page it needs in the pool, and when no frame
holds that page it first reads the 16 KB page from the table’s file into a frame. Two status variables count the
traffic: Innodb_buffer_pool_read_requests counts every page request, and Innodb_buffer_pool_reads counts the
requests the pool could not answer, each of which cost a read from disk. The session below makes 1,000 orders of about
7 KB each, restarts the server with an empty pool, and reads orders 101 to 140 twice, taking both counters before and
after each read:
pool_reads_session.py
"""Every page a query needs comes through the buffer pool: the pool's size on this lab host, then the same 40 orders
read twice after a restart, once from an empty pool and once from a warm one.
A throwaway MySQL 8.4.11 server (mysql_session.py) starts with --innodb-buffer-pool-load-at-startup=OFF, so that its
restart really empties the pool. orders.sql makes 1,000 orders of about 7 KB each; the restart clears the pool; and
pool_reads.sql, run twice, reads the pool's two read counters, reads orders 101 to 140 and reads the counters again.
Last, the sys schema's innodb_buffer_stats_by_table view shows how much of the pool the orders table holds now.
"""
from mysql_session import Server
with Server(options=["--innodb-buffer-pool-load-at-startup=OFF"]) as my:
my.up()
my.sql("SELECT @@innodb_buffer_pool_size AS pool_bytes, @@innodb_page_size AS page_bytes, "
"@@innodb_buffer_pool_instances AS instances, POOL_SIZE AS frames "
"FROM information_schema.INNODB_BUFFER_POOL_STATS")
my.mysql("orders.sql", flags=("-t",))
my.restart(show=True)
my.mysql("pool_reads.sql", note="first read after the restart: the pages are only on disk")
my.mysql("pool_reads.sql", flags=("-t",), note="the same script again, results only")
my.sql("SELECT object_name, allocated, data, pages FROM sys.innodb_buffer_stats_by_table "
"WHERE object_schema = 'shop'", note="what the pool holds of the orders table now") Output
$ mysql -t -e "SELECT @@innodb_buffer_pool_size AS pool_bytes, @@innodb_page_size AS page_bytes, @@innodb_buffer_pool_instances AS instances, POOL_SIZE AS frames FROM information_schema.INNODB_BUFFER_POOL_STATS"
+------------+------------+-----------+--------+
| pool_bytes | page_bytes | instances | frames |
+------------+------------+-----------+--------+
| 134217728 | 16384 | 1 | 8191 |
+------------+------------+-----------+--------+
$ mysql -t < orders.sql
+-------------+---------+----------+----------+
| Table | Op | Msg_type | Msg_text |
+-------------+---------+----------+----------+
| shop.orders | analyze | status | OK |
+-------------+---------+----------+----------+
$ mysqld --no-defaults --datadir=./data --bind-address=127.0.0.1 --port=$PORT --socket=mysql.sock --mysqlx=OFF --innodb-buffer-pool-load-at-startup=OFF &
-- first read after the restart: the pages are only on disk
$ mysql -t -vvv --skip-comments < pool_reads.sql
--------------
SELECT note = '' AS empty_note FROM shop.orders WHERE id = 1000
--------------
+------------+
| empty_note |
+------------+
| 0 |
+------------+
1 row in set
--------------
SELECT VARIABLE_VALUE INTO @requests FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests'
--------------
Query OK, 1 row affected
--------------
SELECT VARIABLE_VALUE INTO @from_disk FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads'
--------------
Query OK, 1 row affected
--------------
SELECT COUNT(*) AS orders_read FROM shop.orders WHERE id BETWEEN 101 AND 140 AND note <> ''
--------------
+-------------+
| orders_read |
+-------------+
| 40 |
+-------------+
1 row in set
--------------
SELECT r.VARIABLE_VALUE - @requests AS page_requests, d.VARIABLE_VALUE - @from_disk AS read_from_disk,
ROUND(100 * (1 - (d.VARIABLE_VALUE - @from_disk) / (r.VARIABLE_VALUE - @requests)), 1) AS hit_pct
FROM performance_schema.global_status AS r, performance_schema.global_status AS d
WHERE r.VARIABLE_NAME = 'Innodb_buffer_pool_read_requests' AND d.VARIABLE_NAME = 'Innodb_buffer_pool_reads'
--------------
+---------------+----------------+---------+
| page_requests | read_from_disk | hit_pct |
+---------------+----------------+---------+
| 65 | 21 | 67.7 |
+---------------+----------------+---------+
1 row in set
Bye
-- the same script again, results only
$ mysql -t < pool_reads.sql
+------------+
| empty_note |
+------------+
| 0 |
+------------+
+-------------+
| orders_read |
+-------------+
| 40 |
+-------------+
+---------------+----------------+---------+
| page_requests | read_from_disk | hit_pct |
+---------------+----------------+---------+
| 44 | 0 | 100 |
+---------------+----------------+---------+
-- what the pool holds of the orders table now
$ mysql -t -e "SELECT object_name, allocated, data, pages FROM sys.innodb_buffer_stats_by_table WHERE object_schema = 'shop'"
+-------------+------------+------------+-------+
| object_name | allocated | data | pages |
+-------------+------------+------------+-------+
| orders | 384.00 KiB | 315.03 KiB | 24 |
+-------------+------------+------------+-------+
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pool_reads_session.py
orders.sql
-- The lesson's small table: 1,000 orders whose 7,000-character notes fill a 16 KB page about two rows at a time.
-- ANALYZE TABLE writes the table's statistics now, so no background update of them reads pages later.
CREATE DATABASE shop;
CREATE TABLE shop.orders (id INT PRIMARY KEY, note VARCHAR(7000) NOT NULL) ENGINE=InnoDB;
INSERT INTO shop.orders (id, note)
WITH RECURSIVE seq (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1000)
SELECT n, REPEAT('o', 7000) FROM seq;
ANALYZE TABLE shop.orders; pool_reads.sql
-- Orders 101 to 140 (40 rows on about 20 leaf pages), with the pool's two read counters taken before and after.
-- The first statement opens the table, so the dictionary pages it needs are not counted in the measured read.
SELECT note = '' AS empty_note FROM shop.orders WHERE id = 1000;
SELECT VARIABLE_VALUE INTO @requests FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests';
SELECT VARIABLE_VALUE INTO @from_disk FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads';
SELECT COUNT(*) AS orders_read FROM shop.orders WHERE id BETWEEN 101 AND 140 AND note <> '';
SELECT r.VARIABLE_VALUE - @requests AS page_requests, d.VARIABLE_VALUE - @from_disk AS read_from_disk,
ROUND(100 * (1 - (d.VARIABLE_VALUE - @from_disk) / (r.VARIABLE_VALUE - @requests)), 1) AS hit_pct
FROM performance_schema.global_status AS r, performance_schema.global_status AS d
WHERE r.VARIABLE_NAME = 'Innodb_buffer_pool_read_requests' AND d.VARIABLE_NAME = 'Innodb_buffer_pool_reads'; mysql_session.py
"""Throwaway MySQL 8.4.11 servers for the examples of this lesson.
Every session creates a server of its own: `mysqld --initialize-insecure` makes a new data directory inside a new
folder in the examples folder, the server starts on it, the session runs `mysql` client scripts and MySQL's programs
against it and prints what they printed, and then the server is stopped and the folder deleted, also when something
fails. The server is the pinned MySQL 8.4.11 from the mise installations next to the Python that runs this file;
Server(basedir=...) runs the programs of another unpacked MySQL tarball instead (dump_defaults.py uses it).
Safety: the server listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system
reports as unused; the X Protocol listener is off (--mysqlx=OFF); its Unix socket, temporary files, error log and
import/export folder all stay inside the run folder. Right after the first start the session gives root a password made
up for the run (never printed) and connects only with a client option file that holds it, so no password ever appears
on a command line. Every program starts with --no-defaults or --defaults-file, so no option file of the computer
(/etc/my.cnf, ~/.my.cnf) can change a result, and HOME points at the run folder, so ~/.mylogin.cnf is not read either.
A printed mysqld command shows the options that matter to the lesson; the session also passes, on the command line,
the folders it keeps inside the run folder (--basedir, --tmpdir, --log-error, --pid-file, --secure-file-priv) and
the port, which is why performance_schema.variables_info reports those variables as COMMAND_LINE.
learn:verify's run sandbox cannot read the home folder, where mise keeps MySQL, so these sessions are recorded with
LEARN_VERIFY_NETWORK=allow; they read only this folder and the toolchain and connect to nothing but 127.0.0.1.
The output is exactly what the programs printed, with these substitutions so that a record never shows the computer it
ran on and is the same on every run: the run folder is shown as ".", the folder of MySQL's programs is left out of
their paths and MySQL's own folder is shown as <basedir>, the computer's name is shown as "lab-host" and the operating-system user's name as "learner", the time a
statement took (the client's "(0.01 sec)") is left out, the time stamp SET PERSIST writes into mysqld-auto.cnf is
shown as <when it was set>, error-log lines lose their timestamp and thread number, and a session can name more values
that change between runs with my.mask(pattern, replacement), which then prints in their place. A client table whose cells
change is drawn again so that its borders still line up.
The sessions never print the password, and the run's port prints as <port>. A session stops with status 1 when a command that should have worked
fails, or when a script reports a different number of errors than the session expects, so a run that went wrong is
never recorded as if it had worked.
"""
import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path
MYSQL_VERSION = "8.4.11"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
PINNED = INSTALLS / "mysql" / MYSQL_VERSION
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
HOST = socket.gethostname().split(".")[0]
# 2026-10-17T16:50:01.123456Z 0 [ERROR] ... -> [ERROR] ...
LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \d+ ", re.M)
def free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Server:
"""One throwaway server: initialise, start; stopped and deleted when the with block ends.
options: extra mysqld options for every start (for example "--sql-mode=" or "--character-set-server=latin1").
basedir: the folder of an unpacked MySQL tarball to run instead of the pinned 8.4.11.
masks: (pattern, replacement) pairs, as for mask().
"""
def __init__(self, options=(), redo=None, basedir=None, masks=()):
self.options = [str(o) for o in options]
self.redo = redo
self.basedir = Path(basedir).resolve() if basedir else PINNED
self.bin = self.basedir / "bin"
self.masks = []
for pattern, replacement in masks:
self.mask(pattern, replacement)
self.proc = None
def mask(self, pattern, replacement):
"""From now on every printed line shows text matching the regular expression as the replacement: for a value
that changes from run to run (an uptime, a time stamp), so that the recorded output stays the same."""
self.masks.append((re.compile(pattern), replacement))
# ---- the folder ------------------------------------------------------------------------------------------------
def __enter__(self):
if not (self.bin / "mysqld").exists():
sys.exit(f"no MySQL server program in {self.basedir}" if self.basedir != PINNED
else f"MySQL {MYSQL_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="my", dir=".")).resolve()
for sub in ("tmp", "files"):
(self.base / sub).mkdir(mode=0o700)
self.port = free_port()
self.password = secrets.token_urlsafe(24)
self.client_cnf = self.base / "client.cnf"
self._write_client_cnf(password=None)
self.env = {key: value for key, value in os.environ.items()
if not key.startswith("MYSQL") and key not in ("TZ", "HOME", "LANG", "LC_ALL")}
self.env.update({"HOME": str(self.base), "TZ": "UTC", "LANG": "en_US.UTF-8",
"MYSQL_TEST_LOGIN_FILE": str(self.base / "no-login-paths.cnf")})
for pattern in ("*.sql", "*.cnf", "*.csv", "*.txt"):
for f in Path(".").glob(pattern):
shutil.copy(f, self.base / f.name)
return self
def __exit__(self, *exc):
if self.proc and self.proc.poll() is None:
self.stop(quiet=True)
shutil.rmtree(self.base, ignore_errors=True)
def _write_client_cnf(self, password):
lines = ["[client]", "user=root", "host=127.0.0.1", f"port={self.port}", "protocol=TCP"]
if password:
lines.append(f"password={password}")
self.client_cnf.write_text("\n".join(lines) + "\n")
self.client_cnf.chmod(0o600)
def server_options(self):
b = self.base
return ["--no-defaults", f"--basedir={self.basedir}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
f"--secure-file-priv={b / 'files'}",
*([f"--innodb-redo-log-capacity={self.redo}"] if self.redo else [])]
# ---- the server ------------------------------------------------------------------------------------------------
def initialize(self, show=False):
"""mysqld --initialize-insecure: a new data directory whose root account has no password yet."""
args = ["mysqld", *self.server_options(), "--initialize-insecure"]
code, out = self._call(args)
if show:
_show(["mysqld", "--no-defaults", "--initialize-insecure", "--datadir=./data"], out)
if code != 0:
print(self.log())
sys.exit(f"mysqld --initialize-insecure ended with status {code}")
def start(self, extra=(), defaults_file=None, expect_fail=False, show=False):
"""Starts mysqld on 127.0.0.1 and waits until it answers. With expect_fail, prints the error-log lines of a
start that must fail, and returns them."""
log = self.base / "mysqld.err"
before = log.read_text() if log.exists() else ""
opts = self.server_options()
if defaults_file:
opts[0] = f"--defaults-file={self.base / defaults_file}"
args = [str(self.bin / "mysqld"), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
"--socket=mysql.sock", "--mysqlx=OFF", *self.options, *[str(e) for e in extra]]
if show:
shown = ["mysqld", opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
"--port=$PORT", "--socket=mysql.sock", "--mysqlx=OFF", *self.options, *extra]
print("$ " + " ".join(_arg(a) if a != "--port=$PORT" else a for a in shown) + " &")
print()
with open(self.base / "mysqld.out", "a") as out:
self.proc = subprocess.Popen(args, stdout=out, stderr=subprocess.STDOUT, cwd=self.base, env=self.env)
for _ in range(600):
if self.proc.poll() is not None:
break
if self._ping():
break
time.sleep(0.1)
if self.proc.poll() is not None:
new = (log.read_text() if log.exists() else "")[len(before):]
lines = [l for l in self.clean(LOG_PREFIX.sub("", new)).splitlines() if "[ERROR]" in l]
if expect_fail:
print("\n".join(lines))
print(f"(mysqld ended with status {self.proc.returncode})")
print()
self.proc = None
return lines
print("\n".join(lines))
sys.exit(f"mysqld ended with status {self.proc.returncode} while starting")
if expect_fail:
sys.exit("mysqld started, but the session expected it to fail")
if not self._ping():
sys.exit("mysqld did not answer within 60 seconds")
return []
def _ping(self):
code, _ = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "--connect-timeout=2", "ping"])
return code == 0
def up(self, extra=(), defaults_file=None, show=False):
"""Initialise, start (with start()'s options), and give root the run's password, quietly."""
self.initialize()
self.start(extra=extra, defaults_file=defaults_file, show=show)
return self.login()
def login(self):
"""Gives root the run's password (never printed) on a server started from a new data directory, and makes
every later client of the session use it."""
self.silent(f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
self._write_client_cnf(self.password)
return self
def stop(self, quiet=False):
"""A clean shutdown (mysqladmin shutdown), then waits until the process has ended."""
code, out = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "shutdown"])
if code != 0 and not quiet:
sys.exit(f"mysqladmin shutdown failed: {out}")
try:
self.proc.wait(timeout=120)
except subprocess.TimeoutExpired:
self.proc.terminate()
self.proc.wait(timeout=60)
self.proc = None
def restart(self, extra=(), defaults_file=None, show=False):
self.stop()
self.start(extra=extra, defaults_file=defaults_file, show=show)
# ---- running things --------------------------------------------------------------------------------------------
def _call(self, args, stdin=None, merge=True):
"""Runs one of MySQL's programs in the run folder: (status, cleaned output). merge=False keeps only what it
wrote to standard output (its warnings on standard error are dropped)."""
args = [str(a) for a in args]
done = subprocess.run([str(self.bin / args[0]), *args[1:]], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT if merge else subprocess.DEVNULL,
text=True, cwd=self.base, env=self.env, input=stdin)
return done.returncode, self.clean(done.stdout)
def output(self, *args, connect=False, status=0):
"""Runs one of MySQL's programs without printing anything and returns its standard output, for example the
SQL that mysql_tzinfo_to_sql writes, which the session saves with write_file(..., show=False) and runs next."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run, merge=False)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def clean(self, text):
"""The substitutions of the docstring. A table of the client whose cells change is drawn again with the
new column widths, so its borders still line up."""
lines, out, i = text.split("\n"), [], 0
while i < len(lines):
if lines[i].startswith("+-"):
j = i
while j < len(lines) and lines[j][:1] in ("+", "|"):
j += 1
block = lines[i:j]
done = [self._clean(l) for l in block]
out.extend(done if done == block else _redraw(block, self._clean))
i = j
else:
out.append(self._clean(lines[i]))
i += 1
return "\n".join(out)
def _clean(self, text):
text = text.replace(str(self.bin) + "/", "").replace(str(self.basedir), "<basedir>")
text = text.replace(str(self.base) + "/", "./").replace(str(self.base), ".")
text = text.replace("\x07", "") # the mysql client rings the terminal bell on an error; drop it
text = re.sub(r" \(\d+\.\d\d sec\)", "", text)
text = re.sub(r'"Timestamp": \d+', '"Timestamp": <when it was set>', text)
text = re.sub(rf"\b{re.escape(HOST)}\b", "lab-host", text)
text = re.sub(rf"\b{re.escape(OS_USER)}\b", "learner", text)
text = re.sub(rf"(?<![\d.]){self.port}(?![\d.])", "<port>", text)
for pattern, replacement in self.masks:
text = pattern.sub(replacement, text)
return text
def log(self, pattern=None):
"""The error log without timestamps and thread numbers, optionally only the lines matching a pattern."""
text = self.clean(LOG_PREFIX.sub("", (self.base / "mysqld.err").read_text()))
if pattern:
text = "\n".join(l for l in text.splitlines() if re.search(pattern, l))
return text
def show_log(self, pattern, note=None):
"""Prints the error-log lines that match a pattern, as grep would."""
print(f"$ grep -E {_arg(pattern)} mysqld.err" + (f" # {note}" if note else ""))
print(self.log(pattern))
print()
def query(self, sql, db=None):
"""One value from one query, unprinted (the session uses it to wait or to decide)."""
args = ["mysql", f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
code, out = self._call(args, stdin=sql)
if code != 0:
sys.exit(f"the query failed: {out}")
return out.strip()
def silent(self, sql):
"""Runs a statement without printing it (used only for the run's password)."""
self.query(sql)
def mysql(self, script, errors=0, status=None, db=None, flags=("-t", "-vvv", "--skip-comments"), note=None, force=None):
"""Runs a client script from standard input and prints the command and its output. `errors` is how many
ERROR lines the script must print; with errors > 0 the client runs on past an error (--force) and then ends
with status 0, without --force it stops at the first error with status 1. The default flags print each
statement (-vvv) as a table (-t) without the script's comments (--skip-comments)."""
force = errors > 0 if force is None else force
shown = ["mysql", *flags, *(["--force"] if force else []), *([db] if db else [])]
args = ["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]]
code, out = self._call(args, stdin=(self.base / script).read_text())
if note:
print(f"-- {note}")
print("$ " + " ".join(_arg(a) for a in shown) + f" < {script}")
if out.strip():
print(out.rstrip("\n"))
print()
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
expected_status = (1 if errors and not force else 0) if status is None else status
if found != errors or code != expected_status:
sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
return out
def shell(self, *args, status=0, shown=None, connect=False):
"""Runs one of MySQL's programs and prints the command and its output. connect=True gives a client program
(mysqladmin, mysqlshow, mysql) the run's client option file, which the printed command leaves out."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run)
_show(shown or args, out)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def sql(self, statement, flags=("-t",), errors=0, db=None, note=None):
"""Runs one statement with mysql -e and prints the command and its output."""
shown = ["mysql", *flags, *([db] if db else []), "-e", statement]
code, out = self._call(["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]])
if note:
print(f"-- {note}")
_show(shown, out)
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
if found != errors or (code != 0) != (errors > 0):
sys.exit(f"{statement}: {found} errors and status {code}; the session expects {errors}")
return out
def write_file(self, path, text, show=True):
"""Writes a text file into the run folder (an option file, a script) and prints it, as cat would."""
(self.base / path).write_text(textwrap.dedent(text).lstrip("\n"))
if show:
self.show_file(path)
def show_file(self, path):
print(f"$ cat {path}")
print(self.clean((self.base / path).read_text()).rstrip("\n"))
print()
def listing(self, folder, prefix=""):
"""Prints the names in a folder of the run (sorted, folders with a slash), as `ls -p` would list them."""
path = self.base / folder
names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
print(f"$ ls -p {folder}" + (f" | grep '^{prefix}'" if prefix else ""))
print(textwrap.fill(" ".join(names), width=78, break_on_hyphens=False))
print()
return names
def _redraw(block, clean):
"""Draws a client table again after its cells were cleaned. The columns come from the first border line; the row
after it is the header (left-aligned); a column whose data cells were padded on the left (numbers) stays
right-aligned (an empty cell says nothing about its column)."""
cuts = [k for k, ch in enumerate(block[0]) if ch == "+"]
rows = [None if line.startswith("+") else [line[x + 1:y] for x, y in zip(cuts, cuts[1:])] for line in block]
header = next(k for k, r in enumerate(rows) if r is not None)
ncol = len(cuts) - 1
right = [any(r is not None and k != header and r[c].strip() and r[c].startswith(" ") for k, r in enumerate(rows))
for c in range(ncol)]
cells = [None if r is None else [clean(x.strip()) for x in r] for r in rows]
widths = [max(len(r[c]) for r in cells if r is not None) for c in range(ncol)]
drawn = []
for k, r in enumerate(cells):
if r is None:
drawn.append("+" + "+".join("-" * (w + 2) for w in widths) + "+")
else:
drawn.append("|" + "|".join(" " + (t.rjust(w) if right[c] and k != header else t.ljust(w)) + " "
for c, (t, w) in enumerate(zip(r, widths))) + "|")
return drawn
def _arg(a):
"""A command-line argument as a reader would type it: quotes only where the shell needs them."""
a = str(a)
if re.fullmatch(r"[\w@%+=:,./-]+", a):
return a
if not re.search(r"[\"$`\\!]", a):
return f'"{a}"'
return shlex.quote(a)
def _show(args, out):
print("$ " + " ".join(_arg(a) for a in args))
if out.strip():
print(out.rstrip("\n"))
print()
# ====================================================================================================================
# Concurrency for the MVCC and locking lessons (undo-logs-mvcc-and-purge, row-gap-next-key-locks).
# Two or more persistent `mysql` clients keep their own transactions open at the same time, so a session can show what
# one transaction sees of another's uncommitted or committed changes, and the record, gap and next-key locks a
# statement takes. One statement can be started without waiting (it blocks on a lock); an observer connection polls
# performance_schema until the wait is visible; the blocked statement then finishes (with its result or its
# ERROR 1205 / ERROR 1213). Output is masked so two runs are byte-identical.
# ====================================================================================================================
import threading
import pty
import termios
import struct
import fcntl
_PROMPT = "READYmk> " # a distinctive mysql prompt we use as the boundary between statements
class Client:
"""A persistent interactive mysql client on a pseudo-terminal: one connection whose transaction lives across
calls. Built by Server.client(name). run() sends one statement, waits for the next prompt and prints the result;
start() sends a statement expected to block and returns at once; finish() waits for that blocked statement.
A pseudo-terminal is used because the mysql client block-buffers its output to a plain pipe, which would hide the
result until the process ends; on a terminal it flushes a prompt after every statement. Terminal echo is off (the
statement is printed by the session, not by the terminal) and the window is made wide so tables never wrap."""
def __init__(self, server, name):
self.server = server
self.name = name
self.pty_fd, tty_fd = pty.openpty()
attrs = termios.tcgetattr(tty_fd)
attrs[3] = attrs[3] & ~termios.ECHO # lflags: no echo of what we type
termios.tcsetattr(tty_fd, termios.TCSANOW, attrs)
fcntl.ioctl(tty_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 100, 1000, 0, 0)) # 1000 columns, no wrap
args = [str(server.bin / "mysql"), f"--defaults-file={server.client_cnf}",
f"--prompt={_PROMPT}", "--skip-auto-rehash", "-t"]
self.proc = subprocess.Popen(args, stdin=tty_fd, stdout=tty_fd, stderr=tty_fd,
cwd=server.base, env=server.env, close_fds=True)
os.close(tty_fd)
self._buf = ""
self._consumed = 0
self._lock = threading.Lock()
self._reader = threading.Thread(target=self._read, daemon=True)
self._reader.start()
self._read_until_prompt() # discard the welcome banner up to the first prompt
self.conn_id = self._value("SELECT CONNECTION_ID()")
def _read(self):
while True:
try:
data = os.read(self.pty_fd, 4096)
except OSError:
break
if not data:
break
with self._lock:
self._buf += data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "")
def _send(self, sql):
os.write(self.pty_fd, (sql.rstrip().rstrip(";") + ";\n").encode())
def _read_until_prompt(self, timeout=60):
deadline = time.time() + timeout
while time.time() < deadline:
with self._lock:
idx = self._buf.find(_PROMPT, self._consumed)
if idx != -1:
block = self._buf[self._consumed:idx]
self._consumed = idx + len(_PROMPT)
return block
time.sleep(0.02)
return None
def _collect(self, sql, timeout=60):
self._send(sql)
block = self._read_until_prompt(timeout=timeout)
if block is None:
sys.exit(f"{self.name}: statement did not finish within {timeout}s: {sql}")
return block
def _value(self, sql):
text = self.server.clean(self._collect(sql))
cells = [l for l in text.splitlines() if l.startswith("|")]
return cells[-1].split("|")[1].strip() if len(cells) >= 2 else ""
def run(self, sql, note=None, errors=0):
"""Run a statement to completion and print it as the mysql client showed it."""
out = self._collect(sql)
self._print(sql, out, note)
self._check(sql, out, errors)
return out
def start(self, sql, note=None):
"""Send a statement expected to block on a lock; return at once. finish() collects its output."""
self._pending = (sql, note)
self._send(sql)
def finish(self, errors=0, timeout=60):
"""Wait for the statement start() sent and print it (its result, or its ERROR 1205/1213)."""
sql, note = self._pending
block = self._read_until_prompt(timeout=timeout)
if block is None:
sys.exit(f"{self.name}: blocked statement did not finish within {timeout}s: {sql}")
self._print(sql, block, note)
self._check(sql, block, errors)
return block
def _check(self, sql, out, errors):
found = sum(1 for l in out.splitlines() if l.startswith("ERROR "))
if found != errors:
sys.exit(f"{self.name}: {found} errors on {sql!r}, expected {errors}")
def _print(self, sql, out, note):
print(f"-- {self.name}" + (f": {note}" if note else ""))
print(f"mysql> {sql.rstrip().rstrip(';')};")
text = self.server.clean(out).strip("\n")
if text:
print(text)
print()
def close(self):
try:
os.write(self.pty_fd, b"\\q\n")
except OSError:
pass
try:
self.proc.wait(timeout=10)
except subprocess.TimeoutExpired:
self.proc.terminate()
try:
os.close(self.pty_fd)
except OSError:
pass
# ---- Server helpers for the concurrency lessons --------------------------------------------------------------------
def _server_client(self, name):
"""A new persistent connection whose transaction lives across statements."""
if not hasattr(self, "_clients"):
self._clients = []
c = Client(self, name)
self._clients.append(c)
return c
def _server_wait_for(self, sql, want, timeout=20):
"""Poll a single-value query (through a short-lived connection) until it returns `want` (as text)."""
want = str(want)
deadline = time.time() + timeout
while time.time() < deadline:
if self.query(sql) == want:
return True
time.sleep(0.05)
sys.exit(f"wait_for timed out: {sql} never became {want}")
def _server_locks(self, note=None, where="1=1"):
"""Print performance_schema.data_locks, only the columns that do not change between runs, ordered stably."""
sql = ("SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA "
"FROM performance_schema.data_locks "
f"WHERE OBJECT_SCHEMA='test' AND ({where}) "
"ORDER BY INDEX_NAME, FIELD(LOCK_MODE,'X','X,GAP','X,REC_NOT_GAP','X,GAP,INSERT_INTENTION','S','S,GAP'), "
"LOCK_DATA")
self.sql(sql, note=note)
def _server_status_deadlock(self, note=None):
"""The LATEST DETECTED DEADLOCK section of SHOW ENGINE INNODB STATUS, trimmed to the parts that do not change
between runs: the two transactions, the locks each holds and waits for, and which one was rolled back. The
per-record hex dumps and the run-varying ids, times and thread handles are left out or masked."""
out = self.output("mysql", "-E", "-e", "SHOW ENGINE INNODB STATUS", connect=True)
lines = out.splitlines()
try:
a = next(i for i, l in enumerate(lines) if "LATEST DETECTED DEADLOCK" in l)
b = next(i for i in range(a + 2, len(lines)) if lines[i].startswith("-----------"))
except StopIteration:
a, b = 1, len(lines)
kept, i = [], a - 1
while i < b:
l = lines[i]
if l.startswith("Record lock, heap no"): # skip the record's hex field dump up to the blank line
while i < b and lines[i].strip():
i += 1
continue
kept.append(l)
i += 1
text = "\n".join(kept)
text = re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d .*$", "<date and time> <thread>", text, flags=re.M)
text = re.sub(r"TRANSACTION \d+,", "TRANSACTION <id>,", text)
text = re.sub(r"ACTIVE \d+ sec", "ACTIVE <n> sec", text)
text = re.sub(r"heap size \d+", "heap size <n>", text)
text = re.sub(r"MySQL thread id \d+, OS thread handle \w+, query id \d+",
"MySQL thread id <tid>, OS thread handle <h>, query id <q>", text)
text = re.sub(r"trx id \d+", "trx id <id>", text)
text = re.sub(r"\n{3,}", "\n\n", text)
if note:
print(f"-- {note}")
print("mysql> SHOW ENGINE INNODB STATUS\\G (LATEST DETECTED DEADLOCK section)")
print(self.clean(text).strip("\n"))
print()
Server.client = _server_client
Server.wait_for = _server_wait_for
Server.locks = _server_locks
Server.status_deadlock = _server_status_deadlock The first table is the pool on this lab host: 134,217,728 bytes in one instance, divided into 8,191 frames of 16,384
bytes. The restart ran with --innodb-buffer-pool-load-at-startup=OFF, so none of the orders pages were in memory
afterwards. The first read made 65 page requests, and 21 of them had to read a page from disk, since the 40 rows sit
about two to a leaf page; its hit rate was 1 − 21 ÷ 65 = 67.7 %. The same script a moment later made 44 requests and
read nothing from disk: 100 %. Both counters only grow while the server runs, so the hit rate worth watching is the
one computed from their change over a minute or an hour, as the script does, and not from their totals.
The same few sources answer every question this lesson asks of the pool:
| What you want to know | Where to read it | Recorded in this lesson |
|---|---|---|
| Page requests, and those that went to disk | Innodb_buffer_pool_read_requests, Innodb_buffer_pool_reads |
65 and 21 on the cold read |
| Frames, pages holding data, frames unused | POOL_SIZE, DATABASE_PAGES, FREE_BUFFERS in INNODB_BUFFER_POOL_STATS |
8,191 frames |
| Dirty pages | Innodb_buffer_pool_pages_dirty; per page, OLDEST_MODIFICATION in INNODB_BUFFER_PAGE |
51 after one update |
| Each table’s share of the pool | sys.innodb_buffer_stats_by_table |
orders: 24 pages, 384 KiB |
The session’s last query uses that sys view: after the two reads, orders holds 24 pages of the pool, 384 KiB. The view
and the INNODB_BUFFER_PAGE table it reads walk every page of the pool, and the manual warns that querying them can
affect the performance of a production server, so on a large pool ask them on a test instance that reproduces the
workload.
Checkpoint
Check: Page requests, disk reads and the hit rate
New pages enter at the midpoint, in the old sublist
A pool of 8,191 frames fills long before a busy server has read all of its data, and from then on every page read from disk needs a frame that some other page gives up. InnoDB picks that page with its LRU list: The list in which InnoDB orders the buffer pool's pages from most to least recently used. When a page has to be read and no frame is empty, the page at the tail of the list is evicted to make room., the pages ordered from most to least recently used, and evicts from the tail. A strict LRU would put each newly read page at the head, so one pass over a big table would walk every older page down to the tail and out. InnoDB uses midpoint insertion: InnoDB's rule for placing a page it has just read: at the head of the old sublist, about three eighths from the tail of the LRU list, instead of at the head of the list, so that pages read only once do not displace pages in regular use. instead: it treats the list as a young part at the head and the old sublist: The tail part of the buffer pool's LRU list, 37 % of it by default (innodb_old_blocks_pct). New pages enter at its head and move to the young part only when used again at least innodb_old_blocks_time (1000 ms) after their first access. at the tail, and it puts each newly read page at the head of the old sublist.
Two variables set the rule, and the scan session in the next section prints both. innodb_old_blocks_pct is the old
sublist’s share of the list, 37 % by default, the three eighths the algorithm began with; it accepts 5 to 95.
innodb_old_blocks_time is how long a page must stay in the old sublist after its first access before a later access
can move it to the head of the young part: 1000 ms by default. Pages in both parts age as other pages arrive or are
made young, so a page that nobody touches again drifts to the tail and is evicted. Step through one page that a scan
reads once and one page that a query comes back to:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- One list in two parts. InnoDB keeps the pages it has cached in one list, 8,191 frames in a 128 MiB pool, and on a busy server nearly every frame is in use. The young sublist at the head holds pages used again recently; the old sublist behind it holds 37 % of the list, and the page at its tail is the next to leave.
- A page that is not cached. A scan needs page 7 of a table, and no frame holds it. With no empty frame left, InnoDB first evicts the page at the tail of the old sublist, the one used least recently, and takes its frame.
- In at the midpoint. Page 7 is read from the table's data file into that frame and placed where the two sublists meet: at the head of the old sublist, not at the head of the whole list. The young sublist is not touched.
- Read again, too soon. The scan reads every row on page 7 in quick succession. Each of those reads comes less than 1000 ms after the first one, the default innodb_old_blocks_time, so page 7 stays in the old sublist.
- Pushed toward the tail. Every page the scan reads next lands in front of page 7, and each one evicts the page at the tail. Page 7 drifts down the old sublist; the young sublist, with the pages other queries keep using, takes no part.
- Evicted, used once. When page 7 reaches the tail, the next read that needs a frame evicts it. It leaves after a single burst of use, and no young page was displaced on its account.
- A page used again later. Page 12 arrives the same way, at the midpoint, but a query reads it again two seconds after its first access. That read is past the 1000 ms window, so page 12 moves to the head of the young sublist.
- Young pages age as well. Each page made young pushes the others down. When the young part grows past its share of about 63 %, the page at its far end crosses the midpoint and counts as old again, and from there it can be evicted like any other.
- What the two rules protect. Pages read only in one burst pass through the old 37 % and leave from its tail; only pages read again after a second earn a place in the young 63 %. A single large scan therefore cannot push the young pages out.
The two pages take the two paths the rule allows. Page 7 is read in one burst, every access inside its first second, so it never leaves the old part and goes out from its tail. Page 12 is read again two seconds later and moves to the head of the young part. Pages that read-ahead brings in follow the same rule: they enter at the midpoint too, and one that no query asks for before it reaches the tail leaves without ever being young.
Reading old as about to be dropped
A page in the old sublist is cached like any other: a query that reads it gets it from memory, and the access counts as a hit. Old decides only which pages are evicted first and whether an access moves the page, so a larger old share does not make the cache smaller.
One scan larger than the pool leaves the hot pages alone
The claim that one scan does not empty the cache can be tested on a 128 MiB pool. The recorded session makes three
tables of 7,000-character rows: customers, whose 7,601 leaf pages nearly fill the pool by themselves; orders, 501 leaf
pages that are read often; and history, 8,501 leaf pages, more than the pool’s 8,191 frames. After a restart it reads
customers once and orders twice, 1.2 seconds apart, then scans history and asks where the orders pages are. Last it
sets innodb_old_blocks_time to 0 and scans history again:
scan_resistance_session.py
"""A scan larger than the buffer pool, first with the old sublist's one-second rule and then without it.
A throwaway MySQL 8.4.11 server (mysql_session.py) keeps the default 128 MiB pool and starts with
--innodb-buffer-pool-load-at-startup=OFF, so that its restart begins with an empty pool. scan_tables.sql makes three
tables of 7,000-character rows; the restart empties the pool; scan_warm.sql fills it with one read of customers and
reads orders twice, 1.2 seconds apart; scan.sql scans history, which is larger than the pool, and reads where the
orders pages are afterwards. The session runs scan.sql once with innodb_old_blocks_time at its default of 1000 ms and
once more with it set to 0. scan.sql prints the two young-making counters and the unused frames as ranges, because
their exact values change from one run to the next: the frames left unused after a scan depend on whether the page
cleaner has freed frames at the tail of the list since it ended.
"""
from mysql_session import Server
with Server(options=["--innodb-buffer-pool-load-at-startup=OFF"]) as my:
my.up()
my.mysql("scan_tables.sql", flags=("-t",))
my.restart()
my.sql("SELECT @@innodb_old_blocks_pct AS old_pct, @@innodb_old_blocks_time AS old_ms, "
"POOL_SIZE AS frames FROM information_schema.INNODB_BUFFER_POOL_STATS")
my.mysql("scan_warm.sql", flags=("-t",))
my.mysql("scan.sql", flags=("-t",), note="the scan with the default window of 1000 ms")
my.sql("SET GLOBAL innodb_old_blocks_time = 0")
my.mysql("scan.sql", flags=("-t",), note="the same scan with innodb_old_blocks_time = 0") Output
$ mysql -t < scan_tables.sql +----------------+---------+----------+----------+ | Table | Op | Msg_type | Msg_text | +----------------+---------+----------+----------+ | shop.customers | analyze | status | OK | | shop.history | analyze | status | OK | | shop.orders | analyze | status | OK | +----------------+---------+----------+----------+ +------------+------------+ | table_name | leaf_pages | +------------+------------+ | customers | 7601 | | history | 8501 | | orders | 501 | +------------+------------+ $ mysql -t -e "SELECT @@innodb_old_blocks_pct AS old_pct, @@innodb_old_blocks_time AS old_ms, POOL_SIZE AS frames FROM information_schema.INNODB_BUFFER_POOL_STATS" +---------+--------+--------+ | old_pct | old_ms | frames | +---------+--------+--------+ | 37 | 1000 | 8191 | +---------+--------+--------+ $ mysql -t < scan_warm.sql +----------------+ | customers_rows | +----------------+ | 15200 | +----------------+ +-------------+ | orders_rows | +-------------+ | 1000 | +-------------+ +-------------+ | orders_rows | +-------------+ | 1000 | +-------------+ +--------------+----------------+ | orders_pages | in_old_sublist | +--------------+----------------+ | 502 | 0 | +--------------+----------------+ -- the scan with the default window of 1000 ms $ mysql -t < scan.sql +--------------+ | history_rows | +--------------+ | 17000 | +--------------+ +------------------+----------------------+-------------+ | pages_made_young | pages_not_made_young | free_frames | +------------------+----------------------+-------------+ | under 100 | over 10,000 | 0 to 1,024 | +------------------+----------------------+-------------+ +--------------+----------------+ | orders_pages | in_old_sublist | +--------------+----------------+ | 502 | 0 | +--------------+----------------+ $ mysql -t -e "SET GLOBAL innodb_old_blocks_time = 0" -- the same scan with innodb_old_blocks_time = 0 $ mysql -t < scan.sql +--------------+ | history_rows | +--------------+ | 17000 | +--------------+ +------------------+----------------------+-------------+ | pages_made_young | pages_not_made_young | free_frames | +------------------+----------------------+-------------+ | 5,000 to 10,000 | none | 0 to 1,024 | +------------------+----------------------+-------------+ +--------------+----------------+ | orders_pages | in_old_sublist | +--------------+----------------+ | 0 | 0 | +--------------+----------------+
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 scan_resistance_session.py
scan_tables.sql
-- Three tables of 7,000-character rows, about two rows to a 16 KB page. customers (about 7,600 pages) fills the
-- 128 MiB pool when it is read; orders (about 500 pages) is read often; history (about 8,500 pages) is larger than
-- the whole pool and is read by one scan.
SET SESSION cte_max_recursion_depth = 17000;
CREATE DATABASE shop;
CREATE TABLE shop.customers (id INT PRIMARY KEY, note VARCHAR(7000) NOT NULL) ENGINE=InnoDB;
INSERT INTO shop.customers (id, note)
WITH RECURSIVE seq (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 15200)
SELECT n, REPEAT('c', 7000) FROM seq;
CREATE TABLE shop.history LIKE shop.customers;
INSERT INTO shop.history (id, note)
WITH RECURSIVE seq (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 17000)
SELECT n, REPEAT('h', 7000) FROM seq;
CREATE TABLE shop.orders LIKE shop.customers;
INSERT INTO shop.orders (id, note)
WITH RECURSIVE seq (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1000)
SELECT n, REPEAT('o', 7000) FROM seq;
ANALYZE TABLE shop.customers, shop.history, shop.orders;
SELECT table_name, stat_value AS leaf_pages FROM mysql.innodb_index_stats
WHERE database_name = 'shop' AND index_name = 'PRIMARY' AND stat_name = 'n_leaf_pages' ORDER BY table_name; scan_warm.sql
-- A full pool, as on a busy server: customers read once; then orders read twice, 1.2 seconds apart.
SELECT COUNT(*) AS customers_rows FROM shop.customers WHERE note <> '';
SELECT COUNT(*) AS orders_rows FROM shop.orders WHERE note <> '';
DO SLEEP(1.2);
SELECT COUNT(*) AS orders_rows FROM shop.orders WHERE note <> '';
SELECT COUNT(*) AS orders_pages, SUM(IS_OLD = 'YES') AS in_old_sublist
FROM information_schema.INNODB_BUFFER_PAGE WHERE TABLE_NAME = '`shop`.`orders`'; scan.sql
-- One scan of history, with the pool's young-making counters taken before and after it, and its unused frames
-- after it. All three are shown in bands, because their exact values change from one run to the next.
SELECT PAGES_MADE_YOUNG, PAGES_NOT_MADE_YOUNG INTO @young, @not_young
FROM information_schema.INNODB_BUFFER_POOL_STATS;
SELECT COUNT(*) AS history_rows FROM shop.history WHERE note <> '';
SELECT CASE WHEN made = 0 THEN 'none' WHEN made < 100 THEN 'under 100' WHEN made < 5000 THEN '100 to 5,000'
WHEN made < 10000 THEN '5,000 to 10,000' ELSE 'over 10,000' END AS pages_made_young,
CASE WHEN refused = 0 THEN 'none' WHEN refused < 100 THEN 'under 100' WHEN refused < 5000 THEN '100 to 5,000'
WHEN refused < 10000 THEN '5,000 to 10,000' ELSE 'over 10,000' END AS pages_not_made_young,
CASE WHEN free_now <= @@innodb_lru_scan_depth THEN CONCAT('0 to ', FORMAT(@@innodb_lru_scan_depth, 0))
ELSE CONCAT('over ', FORMAT(@@innodb_lru_scan_depth, 0)) END AS free_frames
FROM (SELECT PAGES_MADE_YOUNG - @young AS made, PAGES_NOT_MADE_YOUNG - @not_young AS refused,
FREE_BUFFERS AS free_now FROM information_schema.INNODB_BUFFER_POOL_STATS) AS counters;
SELECT COUNT(*) AS orders_pages, COALESCE(SUM(IS_OLD = 'YES'), 0) AS in_old_sublist
FROM information_schema.INNODB_BUFFER_PAGE WHERE TABLE_NAME = '`shop`.`orders`'; Before the scan all 502 orders pages are cached, the 501 leaves and the root, and none of them is in the old sublist: the second read, 1.2 s after the first, made every one of them young. With the default window the scan of history read every one of its pages, and afterwards no more than 1,024 of the pool’s 8,191 frames were unused; yet the 502 orders pages were all still cached and all still young. The counters show why. Fewer than 100 pages were made young during the whole scan, while more than 10,000 accesses left a page where it was: each history page holds two rows, the scan reads them one right after the other, and every such access falls inside the page’s first second.
With the window at 0, the same scan behaved like a strict LRU. Between 5,000 and 10,000 pages were made young, none were left where they were, and orders lost all 502 of its pages. A window of 0 makes a page young at its first access, so the history pages went to the head of the young part as the scan read them, and they pushed orders down the list and out of the pool.
Why three values print as ranges
The changes in PAGES_MADE_YOUNG and PAGES_NOT_MADE_YOUNG differ from one run to the next, by thousands for the
second, and so do the unused frames. Once a second a background thread works through up to innodb_lru_scan_depth
pages, 1,024, at the tail of the list so that reads have empty frames ready, and a count taken just after a scan
reads 0 in one run and 1,024 in the next. The script prints the range each value falls in; the ranges and the orders
counts are the same on every run.
The manual’s Buffer Pool page still describes pages read by queries as moving to the young part at once, by default. That is the behaviour of the second run, a window of 0, and not of the 1000 ms that MySQL 8.4 ships with.
Key idea
A page earns the young part by being used again after a second, not by being read: a scan reads each page in one burst, so its pages pass through the old 37 % and leave from the tail while the pages queries return to stay cached.
Checkpoint
Check: Midpoint insertion and the old sublist · Why one large scan leaves hot pages cached
Changed pages wait in memory until the page cleaner writes them
An UPDATE changes pages in the pool, not in the data file. A page changed since it was read or last written back is A page in the buffer pool that has changed since it was read from its data file or last written back, so the copy on disk is older than the one in memory.; the change is safe because, with the default settings, the redo log reaches disk at commit. The page itself goes back to its file later, written by a An InnoDB background thread that writes dirty pages from the buffer pool back to the data files; innodb_page_cleaners sets how many run. thread in the background. The session lists the settings that pace that work on this lab host, loads the orders table, and counts its dirty pages three times: right after the load, after five seconds in which nothing runs, and right after one hundred orders change:
flushing_session.py
"""Dirty pages and the page cleaner: the flushing settings of MySQL 8.4.11 on this lab host, then the orders table's
dirty pages counted right after it is loaded, after five quiet seconds, and right after one hundred orders change.
A throwaway MySQL 8.4.11 server (mysql_session.py) runs flush_defaults.sql, which lists the settings that decide when
InnoDB writes changed pages back; orders.sql makes 1,000 orders of about 7 KB each; dirty_pages.sql counts the table's
cached and dirty pages, waits five seconds without running anything, counts again, changes 100 orders and counts once
more.
"""
from mysql_session import Server
with Server() as my:
my.up()
my.mysql("flush_defaults.sql", flags=("-t",))
my.mysql("orders.sql", flags=("-t",))
my.mysql("dirty_pages.sql") Output
$ mysql -t < flush_defaults.sql
+--------------------------------+----------------+
| VARIABLE_NAME | VARIABLE_VALUE |
+--------------------------------+----------------+
| innodb_adaptive_flushing | ON |
| innodb_adaptive_flushing_lwm | 10 |
| innodb_adaptive_hash_index | OFF |
| innodb_change_buffering | none |
| innodb_flush_neighbors | 0 |
| innodb_flushing_avg_loops | 30 |
| innodb_idle_flush_pct | 100 |
| innodb_io_capacity | 10000 |
| innodb_io_capacity_max | 20000 |
| innodb_lru_scan_depth | 1024 |
| innodb_max_dirty_pages_pct | 90.000000 |
| innodb_max_dirty_pages_pct_lwm | 10.000000 |
| innodb_page_cleaners | 1 |
+--------------------------------+----------------+
$ mysql -t < orders.sql
+-------------+---------+----------+----------+
| Table | Op | Msg_type | Msg_text |
+-------------+---------+----------+----------+
| shop.orders | analyze | status | OK |
+-------------+---------+----------+----------+
$ mysql -t -vvv --skip-comments < dirty_pages.sql
--------------
SELECT COUNT(*) AS orders_pages, SUM(OLDEST_MODIFICATION > 0) AS dirty
FROM information_schema.INNODB_BUFFER_PAGE WHERE TABLE_NAME = '`shop`.`orders`'
--------------
+--------------+-------+
| orders_pages | dirty |
+--------------+-------+
| 502 | 502 |
+--------------+-------+
1 row in set
--------------
DO SLEEP(5)
--------------
Query OK, 0 rows affected
--------------
SELECT COUNT(*) AS orders_pages, SUM(OLDEST_MODIFICATION > 0) AS dirty
FROM information_schema.INNODB_BUFFER_PAGE WHERE TABLE_NAME = '`shop`.`orders`'
--------------
+--------------+-------+
| orders_pages | dirty |
+--------------+-------+
| 502 | 0 |
+--------------+-------+
1 row in set
--------------
UPDATE shop.orders SET note = REPEAT('u', 7000) WHERE id <= 100
--------------
Query OK, 100 rows affected
Rows matched: 100 Changed: 100 Warnings: 0
--------------
SELECT COUNT(*) AS orders_pages, SUM(OLDEST_MODIFICATION > 0) AS dirty
FROM information_schema.INNODB_BUFFER_PAGE WHERE TABLE_NAME = '`shop`.`orders`'
--------------
+--------------+-------+
| orders_pages | dirty |
+--------------+-------+
| 502 | 51 |
+--------------+-------+
1 row in set
Bye
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 flushing_session.py
flush_defaults.sql
-- The settings that decide when InnoDB writes changed pages back to the data files, as this server reports them.
SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.global_variables
WHERE VARIABLE_NAME IN ('innodb_page_cleaners', 'innodb_io_capacity', 'innodb_io_capacity_max',
'innodb_lru_scan_depth', 'innodb_max_dirty_pages_pct_lwm', 'innodb_max_dirty_pages_pct',
'innodb_adaptive_flushing', 'innodb_adaptive_flushing_lwm', 'innodb_flushing_avg_loops',
'innodb_idle_flush_pct', 'innodb_flush_neighbors', 'innodb_change_buffering', 'innodb_adaptive_hash_index')
ORDER BY VARIABLE_NAME; dirty_pages.sql
-- The orders table's pages in the pool and how many are dirty: right after the load, after five quiet seconds,
-- and right after one hundred orders change.
SELECT COUNT(*) AS orders_pages, SUM(OLDEST_MODIFICATION > 0) AS dirty
FROM information_schema.INNODB_BUFFER_PAGE WHERE TABLE_NAME = '`shop`.`orders`';
DO SLEEP(5);
SELECT COUNT(*) AS orders_pages, SUM(OLDEST_MODIFICATION > 0) AS dirty
FROM information_schema.INNODB_BUFFER_PAGE WHERE TABLE_NAME = '`shop`.`orders`';
UPDATE shop.orders SET note = REPEAT('u', 7000) WHERE id <= 100;
SELECT COUNT(*) AS orders_pages, SUM(OLDEST_MODIFICATION > 0) AS dirty
FROM information_schema.INNODB_BUFFER_PAGE WHERE TABLE_NAME = '`shop`.`orders`'; All 502 pages of the new table start dirty, because the insert built them in memory. Five quiet seconds later none
are: while no page is changing, the cleaner may use innodb_idle_flush_pct, 100 % by default, of
innodb_io_capacity, and at 10,000 I/O operations a second 502 pages take a small part of one second. The update of
100 orders then leaves 51 pages dirty, about two rows to a page. Follow those 51 pages to the data file:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The change stays in memory. The UPDATE changes 100 orders on 51 pages of the buffer pool; nothing is written to the table's data file yet. The recorded run counts those 51 pages as dirty right after the statement, and they go on the flush list.
- Logged before it is written. With the default settings, the change reaches the redo log on disk at commit, so a crash cannot lose it while the pages are still only in memory. The data file can wait, and several changes to a page can share one write.
- A thread that wakes each second. A page cleaner thread wakes about once a second and writes dirty pages from the flush list. On this lab host there is one cleaner, because innodb_page_cleaners follows innodb_buffer_pool_instances, and that is 1.
- A quiet server catches up. While no page is being changed, the cleaner may use innodb_idle_flush_pct, 100 % by default, of innodb_io_capacity, 10,000 I/O operations a second. The recorded run shows it: all 502 freshly loaded pages of orders were clean after five quiet seconds.
- A busy server, the dirty share. While work keeps arriving, preflushing starts once dirty pages pass innodb_max_dirty_pages_pct_lwm, 10 % of the pool, and flushing turns aggressive at innodb_max_dirty_pages_pct, 90 %. The 51 pages here are under 1 %.
- Adaptive flushing watches redo. Adaptive flushing, on by default, sets the rate from the dirty pages and how fast the server writes redo. When flushing falls behind, the rate may rise past innodb_io_capacity up to innodb_io_capacity_max, 20,000; only a checkpoint burst may go further, while innodb_flush_sync is ON.
- Clean pages at the LRU tail. Once a second the cleaner also checks up to innodb_lru_scan_depth, 1,024 pages, at the tail of the LRU list and writes the dirty ones, so a read finds a clean page to replace. A read that finds none waits while InnoDB flushes first, and Innodb_buffer_pool_wait_free counts those waits.
- Written back, still cached. A page that has been written stays in the pool, now clean, and the next query still reads it from memory. Writing a page back and evicting it are separate events, decided by separate lists.
The recorded list holds the settings the figure used. Each has one job:
| Setting | 8.4.11 value | What it decides |
|---|---|---|
innodb_io_capacity |
10000 | I/O operations a second that background work such as flushing may use |
innodb_io_capacity_max |
20000 | the ceiling when flushing falls behind |
innodb_idle_flush_pct |
100 | the share of that capacity used while no page is changing |
innodb_max_dirty_pages_pct_lwm |
10 | the dirty share of the pool at which preflushing starts |
innodb_max_dirty_pages_pct |
90 | the dirty share at which flushing turns aggressive |
innodb_adaptive_flushing |
ON | a flushing rate worked out from dirty pages and redo writing |
innodb_lru_scan_depth |
1024 | pages per instance checked at the LRU tail each second |
innodb_page_cleaners |
1 | cleaner threads: one per pool instance, and this lab host has one |
innodb_flush_neighbors |
0 | whether dirty neighbours in the same extent are written together |
adaptive flushing: InnoDB's way of choosing how many dirty pages to write back each second from the number of dirty pages and the rate at which redo is written, so that flushing keeps pace with the workload without bursts. It is on by default. keeps a busy server’s writing smooth: it looks at the number of
dirty pages and at how fast the server writes redo, and sets how many pages to write each second, so that no sudden
burst of flushing takes the I/O that queries need. Once redo use passes innodb_adaptive_flushing_lwm, 10 % of the
redo capacity, it runs even with the variable switched off.
innodb_io_capacity should be close to the I/O operations a second the storage can really sustain: as low as
practical, because pages written back too eagerly gain little from being cached, but not so low that flushing falls
behind. The manual advises against values above 20,000 unless lower ones have proved too small. Dirty pages that stay
well under innodb_max_dirty_pages_pct outside bulk loads say the value can come down; throughput that dips whenever
InnoDB makes a checkpoint says it should go up. Neighbour flushing is off because writing a page’s dirty
neighbours together saves seeks on a spinning disk, while an SSD has no seeks to save and gains from writes that are
spread out.
Lowering the dirty-page limit to flush faster
innodb_max_dirty_pages_pct is a target, and the manual says it does not affect the rate of flushing. Setting it
lower changes when flushing turns aggressive, not how fast the cleaner writes; the rate comes from
innodb_io_capacity, adaptive flushing and the state of the redo log.
Checkpoint
Check: Dirty pages and the page cleaner
Size the pool for the working set, and resize it while it runs
The 128 MiB default exists so that a server can start on a small machine: the manual says the default configuration
lets MySQL start on a virtual machine with about 512 MB of memory. A production pool is sized from its
The pages a workload keeps coming back to over a period such as a day: the part of the data the buffer pool has to hold for those reads to stay in memory..
For a host that runs only the database, the manual suggests 50 to 75 % of memory and notes that up to 80 % is common;
InnoDB adds about 10 % on top of the configured size for its control structures. --innodb-dedicated-server applies
a rule of that kind at startup: 128 MiB below 1 GB of memory, half of it from 1 to 4 GB, three quarters above 4 GB.
The pool changes size while the server runs, in chunks of innodb_buffer_pool_chunk_size, 128 MiB by default, and
its size is always a multiple of the chunk size times the number of instances. The recorded session doubles the pool
and then asks for a size that is not such a multiple:
resize_session.py
"""Resizing the buffer pool while the server runs: from the 128 MiB default to 256 MiB, then a request that is not a
multiple of the chunk size.
A throwaway MySQL 8.4.11 server (mysql_session.py) starts with --log-error-verbosity=3, so that InnoDB's notes about
the resize reach the error log. resize.sql prints the pool's size, chunk size and instances and asks for 256 MiB; the
session waits until the pool reports its new size, then reads the resize status, the error-log notes, and the pool
in pages; last it asks for 300 MiB and reads what the server made of that request.
"""
from mysql_session import Server
POOL_PAGES = "SELECT POOL_SIZE FROM information_schema.INNODB_BUFFER_POOL_STATS"
RESIZE_CODE = ("SELECT VARIABLE_VALUE FROM performance_schema.global_status "
"WHERE VARIABLE_NAME = 'Innodb_buffer_pool_resize_status_code'")
with Server(options=["--log-error-verbosity=3"]) as my:
my.up()
my.mask(r"Completed resizing buffer pool at \d+ +\d+:\d\d:\d\d\.", "Completed resizing buffer pool at <time>.")
before = int(my.query(POOL_PAGES))
my.mysql("resize.sql")
my.wait_for(f"SELECT POOL_SIZE > {before} FROM information_schema.INNODB_BUFFER_POOL_STATS", 1, timeout=60)
my.wait_for(RESIZE_CODE, 0, timeout=60)
my.sql("SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.global_status "
"WHERE VARIABLE_NAME LIKE 'Innodb_buffer_pool_resize%' ORDER BY VARIABLE_NAME")
my.show_log("[Rr]esiz|chunks", note="the resize, as InnoDB logged it")
my.sql("SELECT @@innodb_buffer_pool_size AS pool_bytes, POOL_SIZE AS pool_pages "
"FROM information_schema.INNODB_BUFFER_POOL_STATS")
middle = int(my.query(POOL_PAGES))
my.sql("SET GLOBAL innodb_buffer_pool_size = 314572800; SHOW WARNINGS", note="300 MiB is not a multiple of 128 MiB")
my.wait_for(f"SELECT POOL_SIZE > {middle} FROM information_schema.INNODB_BUFFER_POOL_STATS", 1, timeout=60)
my.wait_for(RESIZE_CODE, 0, timeout=60)
my.sql("SELECT @@innodb_buffer_pool_size AS pool_bytes, POOL_SIZE AS pool_pages "
"FROM information_schema.INNODB_BUFFER_POOL_STATS") Output
$ mysql -t -vvv --skip-comments < resize.sql
--------------
SELECT @@innodb_buffer_pool_size AS pool_bytes, @@innodb_buffer_pool_chunk_size AS chunk_bytes,
@@innodb_buffer_pool_instances AS instances
--------------
+------------+-------------+-----------+
| pool_bytes | chunk_bytes | instances |
+------------+-------------+-----------+
| 134217728 | 134217728 | 1 |
+------------+-------------+-----------+
1 row in set
--------------
SET GLOBAL innodb_buffer_pool_size = 268435456
--------------
Query OK, 0 rows affected
Bye
$ mysql -t -e "SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME LIKE 'Innodb_buffer_pool_resize%' ORDER BY VARIABLE_NAME"
+-------------------------------------------+-------------------------------------------+
| VARIABLE_NAME | VARIABLE_VALUE |
+-------------------------------------------+-------------------------------------------+
| Innodb_buffer_pool_resize_status | Completed resizing buffer pool at <time>. |
| Innodb_buffer_pool_resize_status_code | 0 |
| Innodb_buffer_pool_resize_status_progress | 100 |
+-------------------------------------------+-------------------------------------------+
$ grep -E "[Rr]esiz|chunks" mysqld.err # the resize, as InnoDB logged it
[Note] [MY-012398] [InnoDB] Requested to resize buffer pool. (new size: 268435456 bytes)
[Note] [MY-013954] [InnoDB] Status code 1: Resizing buffer pool from 134217728 to 268435456 (unit=134217728).
[Note] [MY-013954] [InnoDB] Status code 5: Starting pool resize
[Note] [MY-013954] [InnoDB] Status code 5: buffer pool 0 : resizing with chunks 1 to 2.
[Note] [MY-011891] [InnoDB] buffer pool 0 : 1 chunks (8191 blocks) were added.
[Note] [MY-011894] [InnoDB] Completed to resize buffer pool from 134217728 to 268435456.
[Note] [MY-013954] [InnoDB] Status code 0: Completed resizing buffer pool at <time>.
$ mysql -t -e "SELECT @@innodb_buffer_pool_size AS pool_bytes, POOL_SIZE AS pool_pages FROM information_schema.INNODB_BUFFER_POOL_STATS"
+------------+------------+
| pool_bytes | pool_pages |
+------------+------------+
| 268435456 | 16382 |
+------------+------------+
-- 300 MiB is not a multiple of 128 MiB
$ mysql -t -e "SET GLOBAL innodb_buffer_pool_size = 314572800; SHOW WARNINGS"
+---------+------+----------------------------------------------------------------+
| Level | Code | Message |
+---------+------+----------------------------------------------------------------+
| Warning | 1292 | Truncated incorrect innodb_buffer_pool_size value: '314572800' |
+---------+------+----------------------------------------------------------------+
$ mysql -t -e "SELECT @@innodb_buffer_pool_size AS pool_bytes, POOL_SIZE AS pool_pages FROM information_schema.INNODB_BUFFER_POOL_STATS"
+------------+------------+
| pool_bytes | pool_pages |
+------------+------------+
| 402653184 | 24573 |
+------------+------------+
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 resize_session.py
resize.sql
-- The pool's size, its chunk size and its instances: the size must be a multiple of chunk size times instances.
SELECT @@innodb_buffer_pool_size AS pool_bytes, @@innodb_buffer_pool_chunk_size AS chunk_bytes,
@@innodb_buffer_pool_instances AS instances;
SET GLOBAL innodb_buffer_pool_size = 268435456; The first request, 268,435,456 bytes, is two chunks: the error log shows InnoDB adding one chunk of 8,191 blocks,
Innodb_buffer_pool_resize_status reports the finished resize, and the pool then has 16,382 frames. The request for
300 MiB is not a multiple of 128 MiB, so the server answers with warning 1292 and rounds the size up to 402,653,184
bytes, three chunks and 24,573 frames. A resize waits for active transactions to finish before it starts, and while
it adds chunks other threads that need the pool wait for it, so a large change belongs in a quiet moment.
Chunk size and instance count are read at startup and change only with a restart. The instances default to 1 for a pool of 1 GiB or less, as here, and above that to the smaller of half the chunk count and a quarter of the logical processors, 64 at most; keep the chunk count, the pool size divided by the chunk size, at 1,000 or fewer.
Whether a bigger pool pays off is a question of misses, not of the hit rate’s percentage. Every miss is a page read from disk, so the disk’s load is the page requests times the share that misses. The model below takes 100,000 page requests a second and a disk that can serve 3,000 reads a second; it calls the disk “the database”. Start at a 99 % hit ratio and drag it down:
At 99 % hits, 1,000 of 100,000 reads a second reach the database, within its limit of 3,000. Every extra nine of hit ratio cuts the database load tenfold.
The numbers as a table
| Hit ratio | Database reads a second |
|---|---|
| 50 % | 50,000 |
| 80 % | 20,000 |
| 90 % | 10,000 |
| 95 % | 5,000 |
| 99 % | 1,000 |
| 99.9 % | 100 |
At 99 % the disk serves 1,000 reads a second. The model’s last readout says the ratio must stay at 97 % or more to keep the disk under 3,000, and at 95 % it would be asked for 5,000. Going the other way, from 99 % to 99.9 %, cuts disk reads from 1,000 to 100 a second, which is why a pool that holds the whole working set is worth more than its percentage suggests.
Measure the working set before you size for it
Sample Innodb_buffer_pool_reads once a minute through a full day of real traffic. A rate that stays near zero
outside scans and restarts means the working set fits; per-page detail from INNODB_BUFFER_PAGE_LRU belongs on a
test copy, because reading that table on a large pool is expensive.
Checkpoint
Check: Sizing and resizing the pool
A restart starts cold unless the pool’s page list was saved
The cold read of the first section is what every restart would look like if InnoDB kept nothing of a pool that took
hours to warm. By default it keeps a list: at shutdown it writes the tablespace id and page number of the most recently
used pages, 25 % of them per instance (innodb_buffer_pool_dump_pct), to the file ib_buffer_pool in the data
directory, and at startup a background thread reads those pages back without delaying the start. The recorded session
shows the settings, raises the share to 100 %, saves the list at once with innodb_buffer_pool_dump_now, counts the
orders lines in the file, restarts the server and waits until the load reports that it has finished:
warm_up_session.py
"""Keeping the pool warm across a restart: the dump file ib_buffer_pool and the load at startup.
A throwaway MySQL 8.4.11 server (mysql_session.py) keeps its defaults for saving and loading the pool. orders.sql makes
1,000 orders of about 7 KB (about 500 pages); warm_up.sql shows the dump and load settings, raises the share of the
pool that is saved to 100 % and saves it now. The session counts the lines for the orders tablespace in the file with
grep, restarts the server, waits until the background load reports that it has finished, counts the orders
tablespace's pages in the pool by page type before any query has opened the table, and then reads 40 orders with pool_reads.sql.
"""
import subprocess
from mysql_session import Server
LOAD_DONE = ("SELECT VARIABLE_VALUE LIKE '%load completed%' FROM performance_schema.global_status "
"WHERE VARIABLE_NAME = 'Innodb_buffer_pool_load_status'")
def run(my, command):
"""Runs a shell command in the run folder and prints it with its output, as a terminal would."""
done = subprocess.run(["/bin/sh", "-c", command], cwd=my.base, capture_output=True, text=True)
print(f"$ {command}")
print(my.clean(done.stdout).rstrip("\n"))
print()
with Server() as my:
my.up()
my.mask(r"completed at \d+ +\d+:\d\d:\d\d", "completed at <time>")
my.mysql("orders.sql", flags=("-t",))
my.mysql("warm_up.sql")
space = my.query("SELECT SPACE FROM information_schema.INNODB_TABLESPACES WHERE NAME = 'shop/orders'")
print(f"-- shop/orders is tablespace {space}; each line of the file is one page: tablespace,page")
run(my, f"grep -c '^{space},' data/ib_buffer_pool")
run(my, f"grep '^{space},' data/ib_buffer_pool | sort -t, -k2 -n | head -3")
my.restart(show=True)
my.wait_for(LOAD_DONE, 1, timeout=60)
my.sql("SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_load_status'")
my.sql(f"SELECT PAGE_TYPE, COUNT(*) AS pages FROM information_schema.INNODB_BUFFER_PAGE WHERE SPACE = {space} "
"GROUP BY PAGE_TYPE ORDER BY pages DESC, PAGE_TYPE", note="no query has opened shop.orders since the restart")
my.mysql("pool_reads.sql", flags=("-t",), note="the first read of orders after the restart") Output
$ mysql -t < orders.sql
+-------------+---------+----------+----------+
| Table | Op | Msg_type | Msg_text |
+-------------+---------+----------+----------+
| shop.orders | analyze | status | OK |
+-------------+---------+----------+----------+
$ mysql -t -vvv --skip-comments < warm_up.sql
--------------
SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.global_variables
WHERE VARIABLE_NAME IN ('innodb_buffer_pool_dump_at_shutdown', 'innodb_buffer_pool_load_at_startup',
'innodb_buffer_pool_dump_pct', 'innodb_buffer_pool_filename')
ORDER BY VARIABLE_NAME
--------------
+-------------------------------------+----------------+
| VARIABLE_NAME | VARIABLE_VALUE |
+-------------------------------------+----------------+
| innodb_buffer_pool_dump_at_shutdown | ON |
| innodb_buffer_pool_dump_pct | 25 |
| innodb_buffer_pool_filename | ib_buffer_pool |
| innodb_buffer_pool_load_at_startup | ON |
+-------------------------------------+----------------+
4 rows in set
--------------
SET GLOBAL innodb_buffer_pool_dump_pct = 100
--------------
Query OK, 0 rows affected
--------------
SET GLOBAL innodb_buffer_pool_dump_now = ON
--------------
Query OK, 0 rows affected
--------------
DO SLEEP(1)
--------------
Query OK, 0 rows affected
--------------
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_dump_status'
--------------
+--------------------------------+-----------------------------------------+
| Variable_name | Value |
+--------------------------------+-----------------------------------------+
| Innodb_buffer_pool_dump_status | Buffer pool(s) dump completed at <time> |
+--------------------------------+-----------------------------------------+
1 row in set
Bye
-- shop/orders is tablespace 2; each line of the file is one page: tablespace,page
$ grep -c '^2,' data/ib_buffer_pool
506
$ grep '^2,' data/ib_buffer_pool | sort -t, -k2 -n | head -3
2,0
2,1
2,2
$ mysqld --no-defaults --datadir=./data --bind-address=127.0.0.1 --port=$PORT --socket=mysql.sock --mysqlx=OFF &
$ mysql -t -e "SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_load_status'"
+--------------------------------+-----------------------------------------+
| Variable_name | Value |
+--------------------------------+-----------------------------------------+
| Innodb_buffer_pool_load_status | Buffer pool(s) load completed at <time> |
+--------------------------------+-----------------------------------------+
-- no query has opened shop.orders since the restart
$ mysql -t -e "SELECT PAGE_TYPE, COUNT(*) AS pages FROM information_schema.INNODB_BUFFER_PAGE WHERE SPACE = 2 GROUP BY PAGE_TYPE ORDER BY pages DESC, PAGE_TYPE"
+-------------------+-------+
| PAGE_TYPE | pages |
+-------------------+-------+
| INDEX | 502 |
| FILE_SPACE_HEADER | 1 |
| IBUF_BITMAP | 1 |
| INODE | 1 |
| SDI_INDEX | 1 |
+-------------------+-------+
-- the first read of orders after the restart
$ mysql -t < pool_reads.sql
+------------+
| empty_note |
+------------+
| 0 |
+------------+
+-------------+
| orders_read |
+-------------+
| 40 |
+-------------+
+---------------+----------------+---------+
| page_requests | read_from_disk | hit_pct |
+---------------+----------------+---------+
| 44 | 0 | 100 |
+---------------+----------------+---------+
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 warm_up_session.py
warm_up.sql
-- The settings that save the pool at shutdown and load it at startup, then a save of the whole pool now.
SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.global_variables
WHERE VARIABLE_NAME IN ('innodb_buffer_pool_dump_at_shutdown', 'innodb_buffer_pool_load_at_startup',
'innodb_buffer_pool_dump_pct', 'innodb_buffer_pool_filename')
ORDER BY VARIABLE_NAME;
SET GLOBAL innodb_buffer_pool_dump_pct = 100;
SET GLOBAL innodb_buffer_pool_dump_now = ON;
DO SLEEP(1);
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_dump_status'; Orders lives in tablespace 2, and the file holds 506 lines for it, each one a tablespace id and a page number: the 502
index pages plus four pages that hold the tablespace’s own records, which the listing after the restart names as
FILE_SPACE_HEADER, IBUF_BITMAP, INODE and SDI_INDEX. The restart’s clean shutdown saved the list once more, and
the new server loaded all 506 pages before any query had opened the table. The session counts them by tablespace
because INNODB_BUFFER_PAGE leaves the table name empty until the server has accessed the table. The first read of
orders 101 to 140 then found every page in memory:
| First read after a restart | Page requests | From disk | Hit rate |
|---|---|---|---|
| Load at startup off (first section) | 65 | 21 | 67.7 % |
| Saved page list loaded at startup | 44 | 0 | 100 % |
Three details decide how warm a restart is. With the default innodb_buffer_pool_dump_pct of 25, a full pool comes
back only a quarter warm; the file holds page numbers, not pages, so even 100 % keeps it small, and the cost of a
higher share is the reading at startup, which runs in the background. Pages that no longer exist when the list is read,
such as those of a table dropped in between, are skipped. And Innodb_buffer_pool_dump_status and
Innodb_buffer_pool_load_status report each operation’s progress, as the session reads them.
Checkpoint
Check: Saving and loading the pool across a restart
Change buffering is off in 8.4 and on again in 9.7
Several defaults in the flushing session’s list are recent, and two of them belong to structures this lesson has not met yet. The change buffer: A structure that holds changes to secondary index pages that are not in the buffer pool, inside part of the pool, and merges them when those pages are read. innodb_change_buffering controls it; MySQL 8.4 sets it to none by default. keeps changes to secondary index pages that are not in the pool, in part of the pool itself, and merges them when those pages are read later: it saves random reads on tables with many secondary indexes, and costs pool memory and merge work. The An index InnoDB builds by itself in memory, from a prefix of an index key, over index pages that are searched often; MySQL 8.4 leaves it off by default. speeds up lookups on pages that are searched often. MySQL 8.4 switched both off by default, and the recorded defaults show 9.7.2 switching change buffering back on:
| Setting | 8.0 default | 8.4.11 run | 9.7.2 run |
|---|---|---|---|
innodb_change_buffering |
all | none | all |
innodb_change_buffer_max_size |
25 | 25 | 5 |
innodb_adaptive_hash_index |
ON | OFF | OFF |
innodb_io_capacity |
200 | 10000 | 10000 |
innodb_page_cleaners |
4 | 1 on this lab host | 1 on this lab host |
The 8.0 column comes from the 8.4 manual’s list of InnoDB defaults that changed in 8.4;
innodb_change_buffer_max_size is not on that list, because it stayed at 25. The 8.4.11 column is this lesson’s
flushing session, except for that 25, which the session does not list. The 25 and the whole 9.7.2 column come from the
comparison of compiled defaults recorded in
configuration, option files and SET PERSIST, which lists only the
variables whose defaults differ between the two versions: the two change-buffer settings are among them, and the other
three are not, so they are the same as in 8.4.11. A server that moves from 8.0 to 8.4 without setting
innodb_io_capacity also lets background flushing use fifty times the I/O it had before.
The 9.7 manual is not of one mind about change buffering: the variable’s own entry gives all as the default, while
its table of values and the Change Buffer page still name none. The running server is the only answer to trust. After
an upgrade, read SELECT @@innodb_change_buffering, and set the variable in the option file if the workload depends
on either value. MariaDB went its own way: its documentation lists both change-buffer variables as removed in
MariaDB 11.0 and keeps innodb_io_capacity at 200, the old MySQL 8.0 value, so a move between the two servers
changes both.
Key idea
A default belongs to the version you run, not to InnoDB: read the running server’s values after every upgrade, and put the ones your workload depends on in the option file.
Checkpoint
Check: Change buffering and the defaults that moved
Practise: predict what the pool will do
These questions use the lesson’s own numbers: the 21 cold reads, the 502 orders pages that outlived the scan, the 51 dirty pages and the 300 MiB request that became 384. They start with the idea you know least and bring back the ones you miss:
Practice that adapts to you
Practise this lesson
20 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
On a server of your own
Read the counters twice, a minute apart, and work out the hit rate from the difference:
SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME IN ('Innodb_buffer_pool_read_requests', 'Innodb_buffer_pool_reads', 'Innodb_buffer_pool_pages_dirty', 'Innodb_buffer_pool_wait_free');
Then compare SELECT @@innodb_buffer_pool_size, @@innodb_buffer_pool_instances, @@innodb_io_capacity, @@innodb_change_buffering;
with what you expected, and keep the per-page tables for a test copy.
Interview questions
Why does one scan of a table larger than the buffer pool not empty it of the pages queries use most?
Because InnoDB does not put a newly read page at the head of its LRU list. It inserts it at the midpoint, the head of
the old sublist, which is the last 37 % of the list by default. A page moves to the young part only when it is
accessed again at least innodb_old_blocks_time, 1000 ms by default, after its first access. A scan reads each page in
one quick burst, so its pages never qualify: they drift down the old sublist and are evicted from its tail, while the
pages that queries keep returning to stay in the young part. On MySQL 8.4.11, a recorded scan of 8,501 pages through a
full 128 MiB pool left a hot table’s 502 pages cached; with the window at 0 it evicted them all.
Two harder questions carry the same mechanisms into production: mornings that stay slow after a nightly report, and the plan for a new primary whose predecessor stalled under write bursts. Decide what you would check first in each case before you open its row; the page behind it gives a first step and the follow-up questions:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A billing service on MySQL 8.4 has a 24 GiB buffer pool. Every night a reporting job of about 40 minutes reads a year of invoices, and each morning the first hour of checkout is slow until the cache recovers. How do you work out what the job does to the pool, and what do you change?
-
Mid to staff
A team moves its primary to a 64 GB host that runs only MySQL 8.4, on NVMe storage rated at 50,000 IOPS. They plan a 60 GB buffer pool and innodb_io_capacity at 50,000; the old server stalled under write bursts. What do you change in the plan, and how do you check it?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How is the buffer pool hit rate worked out?
One minus disk reads divided by page requests, over a window of time. The recorded cold read scored 67.7 % and the same read on a warm pool 100 %.
-
Where does a newly read page enter InnoDB's LRU list, and when does it become young?
At the head of the old sublist, the last 37 % of the list. It is moved to the young part only if it is used again at least innodb_old_blocks_time (1000 ms) after its first access.
-
Why does one table scan larger than the buffer pool leave the hot pages cached?
A scan touches each page in one burst, inside the innodb_old_blocks_time window, so its pages never become young and leave from the old tail. In the run all 502 hot pages survived a scan of 8,501 pages; with the window at 0 every one was evicted.
-
Is a page in the old sublist about to leave the buffer pool?
No: it is cached like any other, and reading it counts as a hit. Old only decides which pages are evicted first and whether an access moves a page.
-
What sets how fast the page cleaner writes dirty pages?
innodb_io_capacity, the dirty-page marks of 10 % and 90 %, and adaptive flushing, which follows the redo log. innodb_max_dirty_pages_pct is a target: lowering it changes when flushing turns aggressive, not the rate.
-
How do you size the buffer pool, and can it change while the server runs?
Size it from the working set, within 50 to 75 % of a dedicated host's memory, in multiples of chunk size times instances. It grows online: the recorded request for 300 MiB was rounded up to 384 MiB.
-
How does InnoDB start with a warm buffer pool after a restart?
At shutdown it saves the numbers of the cached pages in ib_buffer_pool, and a background load reads those pages back after startup; the first read after the recorded warm restart needed no disk read.
-
Is change buffering on by default in MySQL 8.4, and in 9.7?
Off in 8.4 and on again in 9.7. Defaults belong to the version you run: read the running values after every upgrade and pin the ones your workload relies on in the option file.
Key takeaways
- Every page a query reads passes through the pool’s 16 KB frames, and the hit rate is one minus disk reads over page requests, taken over a window: the recorded cold read scored 67.7 % and the same read warm 100 %.
- A newly read page enters at the head of the old sublist, 37 % of the LRU list, and becomes young only when it is
used again at least
innodb_old_blocks_time, 1000 ms, after its first access. - That window is why a scan of 8,501 pages through a full pool of 8,191 frames left all 502 orders pages cached and young, while the same scan with the window at 0 evicted every one of them.
- Changed pages stay dirty in memory until the page cleaner writes them, at a pace set by
innodb_io_capacity, the dirty-page marks of 10 % and 90 % and adaptive flushing, which follows the redo log. - A pool is sized from its working set, within 50 to 75 % of a dedicated host’s memory and in multiples of chunk size times instances; it grows online, and the recorded request for 300 MiB was rounded up to 384 MiB.
- The
ib_buffer_poolfile saves page numbers at shutdown and a background load restores them, so the first read after the recorded warm restart needed no disk read; defaults such as change buffering are worth reading again after each upgrade.
Terms in this lesson
- adaptive flushing
- InnoDB's way of choosing how many dirty pages to write back each second from the number of dirty pages and the rate at which redo is written, so that flushing keeps pace with the workload without bursts. It is on by default.
- buffer pool
- The area of memory where InnoDB caches table and index pages, in frames the size of a page, 16 KB by default. Queries read pages through it and changes are made in it first; innodb_buffer_pool_size sets its size, 128 MiB by default.
- change buffer
- A structure that holds changes to secondary index pages that are not in the buffer pool, inside part of the pool, and merges them when those pages are read. innodb_change_buffering controls it; MySQL 8.4 sets it to none by default.
- LRU list
- The list in which InnoDB orders the buffer pool's pages from most to least recently used. When a page has to be read and no frame is empty, the page at the tail of the list is evicted to make room.
- midpoint insertion
- InnoDB's rule for placing a page it has just read: at the head of the old sublist, about three eighths from the tail of the LRU list, instead of at the head of the list, so that pages read only once do not displace pages in regular use.
- old sublist
- The tail part of the buffer pool's LRU list, 37 % of it by default (innodb_old_blocks_pct). New pages enter at its head and move to the young part only when used again at least innodb_old_blocks_time (1000 ms) after their first access.
References
- MySQL 8.4 Reference Manual: 17.5.1 Buffer Pool (Oracle Corporation)
- MySQL 8.4 Reference Manual: 17.8.3.3 Making the Buffer Pool Scan Resistant (Oracle Corporation)
- MySQL 8.4 Reference Manual: 17.8.3.5 Configuring Buffer Pool Flushing (Oracle Corporation)
- MySQL 8.4 Reference Manual: 17.8.7 Configuring InnoDB I/O Capacity (Oracle Corporation)
- MySQL 8.4 Reference Manual: 10.5.8 Optimizing InnoDB Disk I/O (Oracle Corporation)
- MySQL 8.4 Reference Manual: 17.8.3.1 Configuring InnoDB Buffer Pool Size (Oracle Corporation)
- MySQL 8.4 Reference Manual: 10.12.3.1 How MySQL Uses Memory (Oracle Corporation)
- MySQL 8.4 Reference Manual: 17.8.12 Enabling Automatic InnoDB Configuration for a Dedicated MySQL Server (Oracle Corporation)
- MySQL 8.4 Reference Manual: 17.8.3.6 Saving and Restoring the Buffer Pool State (Oracle Corporation)
- MySQL 8.4 Reference Manual: 17.14 InnoDB Startup Options and System Variables (Oracle Corporation)
- MySQL 8.4 Reference Manual: 7.1.10 Server Status Variables (Oracle Corporation)
- MySQL 8.4 Reference Manual: 17.15.5 InnoDB INFORMATION_SCHEMA Buffer Pool Tables (Oracle Corporation)
- MySQL 8.4 Reference Manual: 28.4.2 The INFORMATION_SCHEMA INNODB_BUFFER_PAGE Table (Oracle Corporation)
- MySQL 8.4 Reference Manual: 28.4.4 The INFORMATION_SCHEMA INNODB_BUFFER_POOL_STATS Table (Oracle Corporation)
- MySQL 8.4 Reference Manual: 30.4.3.8 The innodb_buffer_stats_by_table and x$innodb_buffer_stats_by_table Views (Oracle Corporation)
- MySQL 8.4 Reference Manual: 17.5.2 Change Buffer (Oracle Corporation)
- MySQL 8.4 Reference Manual: 17.5.3 Adaptive Hash Index (Oracle Corporation)
- MySQL 8.4 Reference Manual: 1.4 What Is New in MySQL 8.4 since MySQL 8.0 (Oracle Corporation)
- MySQL 9.7 Reference Manual: 17.14 InnoDB Startup Options and System Variables (Oracle Corporation)
- MySQL 9.7 Reference Manual: 17.5.2 Change Buffer (Oracle Corporation)
- MariaDB Server documentation: InnoDB System Variables (MariaDB)
Report a problem with this lesson
Kept only in this browser. Your Learn progress