Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with MySQL 8.4.11 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Forty orders read twice after a restart, with the pool's read counters

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

  1. Question 1 of 2 In one minute a server made 1,250,000 page requests to its buffer pool, and 25,000 of them had to read a page from disk. What was its hit rate over that minute, in percent?

    Type a number.

    A hint

    The share answered from memory is one minus the share that went to disk.

    Show the answer: Question 1 of 2

    Answer: 98 % (anything from 97.95 to 98.05 counts)

    The misses are 25,000 of 1,250,000 requests, 2 %, so the hit rate is 1 − 25,000 ÷ 1,250,000 = 0.98, or 98 %. The recorded cold read worked the same way: 1 − 21 ÷ 65 = 67.7 %.

  2. Question 2 of 2 Which status variable counts the page requests that InnoDB could not answer from the buffer pool?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Innodb_buffer_pool_reads

    Innodb_buffer_pool_read_requests counts all page requests and Innodb_buffer_pool_reads the ones that went to disk, so the change in the two over the same minute gives 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:

Interactive · step by step One page through the buffer pool's LRU list Step through one page a scan reads once and one page a query comes back to, in a pool with no empty frame left.
One page through the buffer pool's LRU list, step 9 of 9. Showing A query, Data file, Empty frame (frame reused), Young sublist (kept in the pool), Old sublist (newer scan pages), Tail of the list (next old page). In focus: Young sublist.not in the poolread inused after 1 sagesevictedA queryneeds page 7Data filepages on diskEmpty frameframe reusedYoung sublistkept in the poolOld sublistnewer scan pagesTail of the listnext old page

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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:

A scan larger than the pool, with the one-second window and without it

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

  1. Question 1 of 2 Where does InnoDB put a page it has just read from a data file into a full buffer pool?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: At the head of the old sublist, about three eighths of the way from the tail of the LRU list

    New pages go to the midpoint, the boundary between the young part and the old sublist. Only a later access, at least innodb_old_blocks_time after the first, moves a page to the young head.

  2. Question 2 of 2 In the recorded session the same scan of history ran a second time with innodb_old_blocks_time set to 0. What happened to the 502 orders pages, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: All were evicted, because every history page became young at its first access and the scan filled the young part

    The window is what turns a scan's burst of reads into accesses that do not count. Without it, between 5,000 and 10,000 history pages were made young and orders lost all 502 of its cached pages.

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:

The flushing defaults of MySQL 8.4.11, and dirty pages before and after the cleaner

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:

Interactive · step by step How changed pages get back to the data file Step through one UPDATE of 100 orders and the background thread that writes its pages, with the limits that set its pace.
How changed pages get back to the data file, step 8 of 8. Showing An UPDATE, Redo log, LRU tail, Flush list (no dirty pages), Page cleaner, Data file (502 pages clean). In focus: Flush list, Data file.pages changedlogged firstpages to writeredo ratewritten backAn UPDATE100 orders changedRedo logwritten at commitLRU tailframes to reuseFlush listno dirty pagesPage cleanerwakes every secondData file502 pages clean

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 %.
  6. 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.
  7. 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.
  8. 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

  1. Question 1 of 2 Right after the UPDATE of 100 orders, the session counted 51 dirty pages of the table. Where are those changes at that moment?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: In the changed pages in the buffer pool and in the redo log, while the data file still holds the old pages

    An update changes cached pages and writes redo; the dirty pages stay in memory until the page cleaner writes them back. The recorded run shows the same after the load: 502 dirty pages, all clean five quiet seconds later.

  2. Question 2 of 2 What does innodb_io_capacity set?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The I/O operations a second that background work such as flushing may use

    innodb_io_capacity should be close to the operations a second the storage can sustain. The page cleaner divides it among the pool instances and may use all of it while no page is changing, since innodb_idle_flush_pct is 100.

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:

Growing the pool from 128 MiB to 256 MiB online, then a size that is rounded

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:

Interactive · change it and see Disk reads a second at each hit ratio, for 100,000 page requests Change the reads a second, the share of them the cache answers and how many reads the database can take. Every miss goes to the database.
Database load by hit ratio: database reads a second against hit ratio (%).020406080100025k50k75k100khit ratio (%)database reads a seconddatabase loadlimit 3,000now 99 %

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.

Reads that reach the database 1,000 a second
Reads the cache answers 99,000 a second
Hit ratio needed to stay under the limit at least 97 %
The numbers as a table
Database load at a few hit ratios
Hit ratioDatabase 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

  1. Question 1 of 2 A server runs with innodb_buffer_pool_chunk_size at 128 MiB and 2 buffer pool instances. Someone sets innodb_buffer_pool_size to 1,000 MiB. To how many MiB does the server adjust it?

    Type a number.

    A hint

    The size has to be a multiple of the chunk size times the instances.

    Show the answer: Question 1 of 2

    Answer: 1024 MiB

    Chunk size times instances is 256 MiB, and 1,000 MiB is not a multiple of it, so the server adjusts the size to the next multiple up, 1,024 MiB. The recorded session did the same with one instance: 300 MiB became 384 MiB.

  2. Question 2 of 2 In the model the pool receives 100,000 page requests a second, and the disk can serve 3,000 reads a second. What is the lowest hit rate, in percent, that keeps the disk within that limit?

    Type a number.

    A hint

    Every miss is one read from disk.

    Show the answer: Question 2 of 2

    Answer: 97 % (anything from 96.95 to 97.05 counts)

    The disk may take 3,000 of the 100,000 requests, 3 %, so at least 97 % must hit, as the model's last readout says. At 95 % it would be asked for 5,000 reads a second.

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:

Saving the pool's page list and loading it back at the next start

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

  1. Question 1 of 2 A server with one pool instance of 8,192 pages, every frame in use, shuts down with the default innodb_buffer_pool_dump_pct. About how many page numbers does it write to ib_buffer_pool?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 2048 pages (anything from 2043 to 2053 counts)

    The default saves the most recently used 25 % of each instance's pages, and a quarter of 8,192 is 2,048. The recorded session raised the share to 100 % first, which is why its file held all 506 pages of the orders tablespace.

  2. Question 2 of 2 What does the ib_buffer_pool file in the data directory hold?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: One line per saved page, with its tablespace id and its page number

    A saved list of page ids is enough to warm a restart: a background thread reads those pages back and skips any that no longer exist.

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

  1. One question A server moves from MySQL 8.4.11 to 9.7.2 with an option file that never mentions innodb_change_buffering, and nobody persisted it with SET PERSIST. What does the 9.7.2 server do with changes to secondary indexes?

    Choose one answer.

    Show the answer: One question

    Answer: It buffers them, because its compiled default is all, as the recorded comparison shows

    Defaults belong to the version you run. Read SELECT @@innodb_change_buffering after the upgrade, and set the variable in the option file if the workload depends on it.

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.

  1. Question 1 of 20 Which two status variables give the buffer pool's hit rate?

    Choose one answer.

    Show the answer: Question 1 of 20

    Answer: Innodb_buffer_pool_read_requests and Innodb_buffer_pool_reads

    Take both counters at two moments and use the differences: 1 − reads ÷ requests. The recorded cold read gives 1 − 21 ÷ 65 = 67.7 %.

  2. Question 2 of 20 After the restart, the first read of orders 101 to 140 needed 21 reads from disk, and the same read a moment later needed none. What explains the difference?

    Choose one answer.

    Show the answer: Question 2 of 20

    Answer: The restart emptied the pool, so the first read brought those pages into memory and the second found them there

    A page read once stays in a frame until it is evicted, so the second read made 44 requests and every one was answered from memory.

  3. Question 3 of 20 You want to know which tables fill the 200 GiB buffer pool of a busy production primary. What does the manual advise for sys.innodb_buffer_stats_by_table and INNODB_BUFFER_PAGE?

    Choose one answer.

    Show the answer: Question 3 of 20

    Answer: Ask them on a test instance that reproduces the workload, because reading them walks the whole pool and can slow the server

    Counters such as Innodb_buffer_pool_reads are cheap to read anywhere. Per-page tables and the sys views built on them belong on a test instance; the lesson's answer of 24 pages for orders came from a throwaway server.

  4. Question 4 of 20 With the default innodb_old_blocks_pct, what share of the LRU list is the young part, in percent?

    Type a number.

    Show the answer: Question 4 of 20

    Answer: 63 %

    The old sublist is 37 % of the list by default, the three eighths the algorithm began with, so the young part at the head is the other 63 %.

  5. Question 5 of 20 Read-ahead brings a page into the pool, and no query ever asks for it. What happens to that page?

    Choose one answer.

    Show the answer: Question 5 of 20

    Answer: It enters at the midpoint, ages down the old sublist and is evicted from the tail without becoming young

    Midpoint insertion treats every new arrival the same way, which is why prefetched pages that turn out to be unneeded take up only the old part of the list.

  6. Question 6 of 20 A page enters the old sublist and is accessed at once. It is accessed again 400 ms later, and once more 1,300 ms after the first access. With the default settings, which access moves it to the head of the young part?

    Choose one answer.

    Show the answer: Question 6 of 20

    Answer: The one 1,300 ms after the first, the first access that comes at least 1,000 ms after it

    The window is measured from the first access. Accesses inside it leave the page where it is, and the first access after it moves the page to the young head.

  7. Question 7 of 20 In the recorded scan with the default window, what did PAGES_NOT_MADE_YOUNG count?

    Choose one answer.

    Show the answer: Question 7 of 20

    Answer: Accesses that left an old page where it was, such as a scan's second read of a page inside its first second

    The count is of accesses, not of distinct pages, so a scan that reads two rows on each of 8,501 pages produces more than 10,000 of them while making fewer than 100 pages young.

  8. Question 8 of 20 The recorded scan read the 8,501 leaf pages of history through a pool of 8,191 frames that was already full. With the default settings, what did it leave behind?

    Choose one answer.

    Show the answer: Question 8 of 20

    Answer: A pool whose 502 orders pages were all still cached and young

    Scan resistance does not stop a scan from reading pages into the pool; it keeps them in the old 37 %, where they replace each other instead of the pages queries use.

  9. Question 9 of 20 A logical backup reads every table once, from start to end, on a busy MySQL 8.4 server with default settings. What is its main effect on the pages the application uses?

    Choose one answer.

    Show the answer: Question 9 of 20

    Answer: Little, because each page is read in one burst and leaves from the old sublist, so mostly the old 37 % turns over

    The manual's Buffer Pool page describes such a scan as evicting older data, which matches a window of 0. With the 1000 ms that MySQL 8.4 ships with, pages read once stay old, as the recorded scan showed.

  10. Question 10 of 20 With the MySQL 8.4 defaults, at what share of dirty pages in the pool does preflushing start, in percent?

    Type a number.

    Show the answer: Question 10 of 20

    Answer: 10 %

    innodb_max_dirty_pages_pct_lwm is 10 by default, the low-water mark at which preflushing starts; flushing turns aggressive at innodb_max_dirty_pages_pct, 90.

  11. Question 11 of 20 The 502 dirty pages of the new orders table were all written back within five quiet seconds, although they were only about 6 % of the pool, under the 10 % low-water mark. Which setting allowed that?

    Choose one answer.

    Show the answer: Question 11 of 20

    Answer: innodb_idle_flush_pct, which lets the cleaner use all of innodb_io_capacity while no page is changing

    In a busy period flushing follows the dirty-page marks and adaptive flushing; in a quiet one the cleaner catches up at up to innodb_idle_flush_pct of innodb_io_capacity, as the recorded count of 0 dirty pages shows.

  12. Question 12 of 20 A server's redo log is small and its writes come in bursts. What raises the flushing rate as redo builds up, and what normally limits that rate?

    Choose one answer.

    Show the answer: Question 12 of 20

    Answer: Adaptive flushing raises it, up to innodb_io_capacity_max, and only checkpoint bursts go further while innodb_flush_sync is ON

    Each second, adaptive flushing works out how many dirty pages to write from the dirty pages and the rate of redo writing, and it may go past innodb_io_capacity when flushing falls behind, up to innodb_io_capacity_max.

  13. Question 13 of 20 What must innodb_buffer_pool_size always be a multiple of?

    Choose one answer.

    Show the answer: Question 13 of 20

    Answer: innodb_buffer_pool_chunk_size times innodb_buffer_pool_instances

    Chunk size times instances is the unit of the pool. With the values on this lab host, 128 MiB and one instance, every size is a whole number of 128 MiB chunks.

  14. Question 14 of 20 MySQL 8.4 starts with --innodb-dedicated-server on a host where it detects 32 GB of memory, and no option sets innodb_buffer_pool_size. What pool size does it choose, in GB?

    Type a number.

    Show the answer: Question 14 of 20

    Answer: 24 GB

    Above 4 GB of detected memory the option sets the pool to three quarters of it: 0.75 × 32 = 24 GB. Below 1 GB it keeps 128 MiB, and from 1 to 4 GB it takes half.

  15. Question 15 of 20 In the model 100,000 page requests arrive a second. A bigger pool lifts the hit rate from 99 % to 99.9 %. By how many disk reads a second does the load fall?

    Type a number.

    Show the answer: Question 15 of 20

    Answer: 900 reads a second

    At 99 % the misses are 1,000 a second and at 99.9 % they are 100, so the disk does 900 fewer reads a second: a tenfold drop from a change of less than one percentage point.

  16. Question 16 of 20 Does loading the saved page list at startup delay the moment the server accepts connections?

    Choose one answer.

    Show the answer: Question 16 of 20

    Answer: No, the pages are loaded by a background thread after the server has started

    Startup and warming overlap: queries that arrive early are served while the load continues, and each page the load has already read is a hit for them.

  17. Question 17 of 20 Between the shutdown that saved the list and the next start, a table named in the file was dropped. What does the load do with its page numbers?

    Choose one answer.

    Show the answer: Question 17 of 20

    Answer: Skips them, because the load ignores pages that no longer exist

    Because the file holds only ids, an out-of-date entry costs nothing: the load reads the pages that still exist and leaves out the rest.

  18. Question 18 of 20 A server's pool holds 40 GiB of pages the application uses, and every restart is still slow for an hour although the dump at shutdown and the load at startup are both on. Which setting do you check first?

    Choose one answer.

    Show the answer: Question 18 of 20

    Answer: innodb_buffer_pool_dump_pct, which saves only the most recently used 25 % of the pages by default

    The defaults warm a restart only in part. Raising the share, up to 100 %, costs a longer list of page ids and more reading in the background at startup.

  19. Question 19 of 20 What is the default of innodb_adaptive_hash_index on MySQL 8.4, as the flushing session records it?

    Choose one answer.

    Show the answer: Question 19 of 20

    Answer: OFF

    The 8.4 manual lists the adaptive hash index among the InnoDB defaults that changed from 8.0, from ON to OFF.

  20. Question 20 of 20 After an upgrade to MySQL 9.7, how do you tell whether the server buffers changes to secondary indexes?

    Choose one answer.

    Show the answer: Question 20 of 20

    Answer: Ask the running server with SELECT @@innodb_change_buffering

    Read the running server's value, and set the variable in the option file when the workload depends on it.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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 %.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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_pool file 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.