27 октября 2026 года Роман Ноздрин выложил на mariadb.org разбор Алессандро Ветере. С MariaDB 10.5 запросы, данные которых уже лежат в буферном пуле InnoDB, платят за обслуживание списка давно не используемых страниц (LRU). На одном тесте Марка Кэллахана сборка 11.8 с ревизией фикса из ревью дала 57 052 запроса в секунду против 20 326 до патча. Это примерно в 2,8 раза. Уже влитый коммит на другом хосте дал больший прирост на ветках 11.8, 12.3 и 13.1. На обоих стендах 11.8 с фиксом быстрее 10.4.34 примерно на 7-9%.
Счётчик freed_page_clock заводится слишком рано
Буферный пул это кэш страниц InnoDB в памяти. Список LRU решает, какую страницу выбросить, когда нужен свободный кадр. В 10.5 Марко Мякеля убрал несколько экземпляров пула, тикет MDEV-15058. Деление через innodb_buffer_pool_instances пришло из MySQL 5.5, чтобы разрезать один mutex. К 10.5 другие правки уже сняли часть этой борьбы, и с тех пор на сервере один список buf_pool.LRU и один buf_pool.mutex. Мякеля рассчитывал на другое: если рабочий набор помещается в пул, нагрузки на этот mutex почти не будет.
Кэллахан гоняет sysbench на каждом релизе, скрипты и результаты лежат в открытом архиве. В 2024 году он открыл MDEV-33966: при конкуренции часть микротестов стала медленнее старых веток. На части чтений падение пришлось на 10.5. Например, points-covered-pk.pre на машине с двумя сокетами потерял около 24%. Стеки упирались в buf_page_make_young. Функция переносит страницу из старой части списка в молодую, чтобы горячую не вытеснили, и для этого берёт buf_pool.mutex.
Почему при этом быстр 10.4. В buf_page_peek_if_too_old() стоит ранний выход, если freed_page_clock равен нулю. Комментарий в коде говорит прямо: вытеснение ещё не начиналось, это либо прогрев, либо нагрузка целиком из памяти, страницы по LRU не двигать. У Кэллахана пул был 100 ГБ, данные около 19 ГБ. Свободными оставались 5,1 миллиона кадров из 6,4 миллиона. Счётчик сдвигается, только когда страницу снимают со списка, а свободные кадры здесь не кончились. Счётчики I/O sum, которые заполняются только при ненулевых часах, на всех прогонах до 10.5 нулевые. 10.4 не умел быстрее двигать LRU. Он просто ничего не продвигал.
С 10.5 часы завелись по дороге. MDEV-15528 (10.5.2) и MDEV-8139 (код попал в 10.5.4, включён с 10.5.5) поменяли работу с освобождёнными страницами. InnoDB больше не пишет такой мусор обратно в файлы данных, а запись FREE_PAGE в redo позволяет восстановлению эти страницы не трогать. Побочный эффект другой. Когда purge освобождает undo-страницу или освобождается страница B-tree, page cleaner снимает её с LRU и сдвигает счётчик. У Кэллахана innodb_max_dirty_pages_pct=1, cleaner пишет почти сразу, счётчик уходит с нуля ещё на загрузке и обратно не возвращается. Тест range-notcovered-si.pre идёт после загрузки и до записи. В 10.5.6 он уже даёт около половины скорости 10.4.34. Счётчик Pages made young с старта сервера: около 25 тысяч на 10.4.34 и 1,3-1,8 миллиарда на большинстве релизов 10.5 и новее.
На запросе из MDEV-37992 цена пришла в два шага. SELECT с сотней значений в IN по первичному ключу, 8 таблиц по 10 миллионов строк, 40 потоков, 48 ядер. 10.4.34 даёт 73 106 запросов в секунду, 10.5.29 уже 29 832, 10.9.8 даёт 27 182, 11.8.3 даёт 26 044, 12.1.1 даёт 25 916. 10.5.6 теряет около 40%. Когда MDEV-23399 сделал buf_pool.mutex блокирующим, переключений контекста на запрос стало 29,6 вместо 2,8.
Из этой таблицы выбивается только 10.8.8: 76 104 запроса в секунду, рядом с 10.4. Это единственный релиз в наборе, где есть MDEV-30671 и ещё нет заплатки MDEV-31234. Та заплатка закрывала утечку undo-страниц. В статусе 10.8.8 свободно 3 584 буфера, у 10.7.8 и 10.9.8 около 5,2 миллиона. Пул был забит. Молодая часть списка примерно 4 миллиона кадров, больше чем втрое горячий набор, горячие страницы уже молодые и почти не просятся в продвижение. Ветере считает 10.8.8 быстрым по случайности. Полный пул не отменяет цену одного экземпляра: продвижения на этих прогонах из памяти начались в 10.5, а один список делает каждое из них дороже.
На 10.9.8 он посмотрел ещё random-points. Тот же IN, только выбирается столбец c, списки по 10, 100 и 1000 значений. Лишних переключений контекста на прочитанную строку относительно 10.4.34 вышло 0,333, 0,340 и 0,337. Ровно, длина списка почти ни при чём. Старая часть LRU занимала 37% списка, около 7% пула, а сам пул был пуст примерно на 80%. Похоже на один блокирующий захват mutex почти на каждое касание старой страницы.
Продвижение страницы без buf_pool.mutex
Дальше Ветере собрал три варианта одного дерева на ветке main, коммит 81b908dc0ea. Машина с двумя Xeon Gold 6230R, каталог данных на /dev/shm, настройки из тикета MDEV-37992. Медиана трёх прогонов, Pages made young считались только за время замера. Сначала короткая запись oltp_read_write, чтобы счётчик ушёл с нуля, как на загрузке у Кэллахана, потом его oltp_points_covered.
- Без правок: 18 945 запросов в секунду, 95-й перцентиль 2,66 мс, за замер молодыми стали 112 436 741 страница.
- Проверка «страница слишком старая» всегда отвечает нет: 57 286 запросов, 0,99 мс, продвижений ноль.
freed_page_clockне растёт: 56 935 запросов, те же 0,99 мс и ноль продвижений.
Те же две заплатки на 10.4.34 двигают цифру максимум примерно на 3%. Ветере относит это к шуму: там проверка и так отказывалась двигать страницы. Без пересборки похожий эффект даёт innodb_old_blocks_time=2147483647, около 24 суток. За короткий прогон старая страница не успевает получить право на продвижение. База с таким значением дала 55 598 запросов против 19 373.
В боевой конфиг это значение лучше не копировать. Ветере прямо пишет цену такого обхода. Страницы, прочитанные с диска, остаются в старой части списка и вылетают как при полном скане таблицы, даже если они горячие. Такой обход имеет смысл только когда данные и так помещаются в пул. Каталог на /dev/shm и innodb_max_dirty_pages_pct=1 нужны были, чтобы повторить стенд Кэллахана. На сайт их переносить не стоит.
Дальше у него три варианта, и минимальный он отверг. Вернуть счётчику старый смысл, тикать только когда страницу реально вытеснили, на нагрузке из памяти должен вернуть хотя бы цифры 10.4. Ветере считает, что дальше этого не уехать. Счётчик монотонный. Один всплеск данных больше размера пула включает обслуживание LRU до следующего рестарта, и в 10.4 тоже. Если набор в память не лезет, снова один глобальный mutex на каждое касание старой страницы, и MDEV-33966 остаётся. Пока вытеснения не было, порядок при прогреве получается как очередь: кто пришёл раньше, тот ближе к выбросу. К моменту первой нехватки кадров горячие страницы могут лежать не там. Последнее он сам помечает как догадку.
Второй вариант, огромный innodb_old_blocks_time, кода не требует и глушит продвижение. Третий: удешевить само обслуживание LRU. Это продолжение ленивого патча Мякеля из PR 3522. Тогда при чтении только ставился флаг ACCESSED, без mutex, а блок двигали потоки, которые mutex уже держат для сброса или вытеснения. На простом oltp_read_only, пул 1 ГБ и несколько гигабайт данных, патч был медленнее: 264 тысячи транзакций в секунду против 277 тысяч. Мякеля предположил, что приближённый список выбрасывает слишком свежие страницы. Работа на этом встала.
Ветере посмотрел, как жертву выбирает PostgreSQL. Точного списка LRU там нет. У буфера маленький usage_count: доступ увеличивает его атомарно, обход clock sweep уменьшает, пока ищет, кого вытеснить. Цена решения «насколько страница свежая» переезжает с чтения на вытеснение. Он пошёл по третьему варианту и довёл работу Мякеля в PR 5763. В запушенном коммите он соавтор.
На чтении buf_page_t::touch() mutex не берёт. Запоминается время первого доступа. Если блок пора продвигать, атомарным OR ставится бит PROMOTE. Двигают блоки те обходы, которые mutex уже держат: page cleaner, вытеснение LRU, пакеты flush list. Если обходов мало, как на чтении целиком из памяти, у большинства блоков бит просто висит до ближайшего обхода.
Решение принимается в момент доступа и остаётся на блоке. В первой версии решал сам обход. Обход может добраться до блока сильно позже, чем сканирование таблицы коснулось его одной пачкой. Тогда пачка выглядит как повторное чтение, и страница скана уезжает в молодые. Теперь правило innodb_old_blocks_time применяется при доступе, в любом месте списка. Блок годится, только если его прочитали снова не раньше чем через этот интервал после первого доступа или после прошлого продвижения. Так должна сохраниться защита от полного скана, которая была у исходного LRU InnoDB. Бит называется PROMOTE, а не ACCESSED: в нём решение, не сам факт касания. Для 11.8 и 12.3 это важно, обе ветки LTS. Тест innodb.buf_lru_scan_resistance проверяет как раз это. В маленьком пуле таблица, прочитанная одним залпом, вытесняется следующим сканом, а таблица, прочитанная повторно после innodb_old_blocks_time, остаётся.
freed_page_clock убрали. Вместо него buf_pool.access_clock, 16-битный счётчик секунд. Его обновляет фоновый таймер, чтение страницы в системные часы не ходит. У innodb_old_blocks_time разрешение стало одна секунда, значение округляется вверх, внутри оно ограничено примерно 18,2 часами. Возраст блока считается по модулю 65 536 секунд. Раньше модуль был 2^32 миллисекунд, около 49,7 суток. При значении по умолчанию в одну секунду разница от переполнения почти не видна. Если поставить величину у верхней границы, старый блок получает право на продвижение только в коротком окне раз в 18,2 часа, а не раз в 49,7 суток.
Обходы поделены. Пакет flush list двигает только старые блоки: молодой до вытеснения ещё далеко. Обход LRU двигает каждый помеченный блок и каждые 512 переносов отпускает buf_pool.mutex, чтобы не держать остальные потоки. Дескриптор блока не вырос. Он стал на 8 байт меньше.
Поэтому фикс шире, чем «вернуть старые часы». Mutex снят с продвижения по LRU в момент чтения страницы, в том числе когда данные в память не влезают. MDEV-37992 закрывается как следствие, отдельный режим «мы целиком в памяти» не возвращают. Правило больше не ждёт, пока начнётся вытеснение, поэтому приближённый LRU работает и на прогреве. К моменту нехватки кадров горячие страницы уже должны быть молодыми или нести бит PROMOTE. Обход их перенесёт, а не выбросит.
57 052 на ревизии из ревью и прогон на EPYC
Цифры этого куска сняты не с последнего коммита, а с ревизии PR 5763, которую смотрели на ревью: ab4f5dd903b поверх 11.8 e87b958d2e4. После этих прогонов код в ревью ещё менялся. Тест oltp_points_covered.lua, 40 потоков, пул 100 ГБ, медиана трёх заходов, та же машина и те же счётчики, что в эксперименте с тремя деревьями.
- 10.4.34: 52 410 запросов в секунду, 95-й перцентиль 0,92 мс, молодых страниц 0, переключений контекста на запрос 1,07.
- 11.8 до фикса: 20 326 запросов, 2,52 мс, молодых страниц 119 554 561, 28,8 переключения.
- 11.8 с фиксом: 57 052 запроса, 0,95 мс, 338 335 молодых, 1,16 переключения.
Падение «сделано молодыми» не значит, что горячие страницы забыли продвинуть. Вытеснения нет, обходов мало, помеченный блок держит бит и ждёт. Повторные чтения уже помеченного блока в счётчик снова не попадают. Сам счётчик Innodb_buffer_pool_pages_made_young теперь растёт в момент обхода. Со старым сервером его лучше не сравнивать.
На этом тесте фикс примерно на 9% быстрее 10.4.34, и LRU, пусть приближённый, всё ещё ведётся. Вариант, который не продвигает ничего, на main дал примерно ту же скорость. Ветере относит выигрыш относительно 10.4 к другим изменениям после 10.4, не к самому списку. Из того же набора Кэллахана подросли и соседние тесты. random-points с range=1000: примерно с 1 900 до 5 800 запросов в секунду. range-notcovered-si с range=100: примерно с 19 100 до 59 500. update-index не просел, узкое место там в другом месте. Молодых страниц на нём стало около 1,4 миллиона вместо 8,7 миллиона. Это только признак, что список меньше дёргают: счётчик теперь другой.
Опасение Мякеля, что горячие страницы начнут вылетать, проверено на нагрузке, где первая версия патча была медленнее. oltp_read_only, 5 ГБ данных, пул 1 ГБ, ноутбук Core Ultra 7 155H. Доля попаданий в пул при распределении Парето: 98,8% с фиксом и 98,7% без. При равномерном распределении 95,8% в обоих случаях. Скорость не упала: 13 466 транзакций в секунду против 11 971 на Парето, на равномерном примерно то же. Разброс между прогонами на ноутбуке доходит примерно до 14%. В этих тестах приближение горячие страницы не теряло.
Смешанную запись с диском, HammerDB TPROC-C, он ещё не мерил. Там скорость может упираться в другие защёлки. В старых опытах Мякеля по MDEV-33966 всплывал lock_sys.latch. Ветере хочет дождаться цифр, прежде чем говорить про этот класс нагрузки.
Джонатан Миллер проверил уже влитые коммиты отдельно, на машине Hetzner с AMD EPYC 9454, 48 ядер, по одному прогону. В этом тесте одна транзакция равна одному запросу. 11.8: было 17 925, стало 66 653. 12.3: 18 740 и 68 646. 13.1: 18 738 и 66 146. 10.4.34 на том же хосте дал 62 400. Это другой стенд, не Xeon, на котором Ветере получил 52 410 у 10.4.34 и 57 052 у пропатченной 11.8. Прогон Миллера фонд публиковал отдельной заметкой, на сайте таблица уже есть в разборе MDEV-37992.
Нового параметра нет. Список по-прежнему рулится innodb_old_blocks_pct и innodb_old_blocks_time. Колонка FREE_PAGE_CLOCK в INFORMATION_SCHEMA после фикса всегда 0. Если график смотрит в неё, чтобы понять, началось ли вытеснение, после обновления она перестанет что-либо показывать.
Релизы 11.8.10, 12.3.4 и 13.1.2
Прирост в 2,8 раза на Xeon и ещё выше на EPYC снят на 40 потоках точечных чтений, рабочий набор в пуле на 100 ГБ. WordPress с десятком воркеров php-fpm и базой на пару гигабайт этот график не повторит. Ближе к форме теста сервис, где много параллельных чтений по первичному ключу и данные уже в памяти. Смесь запросов магазина здесь не мерили, смешанный HammerDB тоже ещё впереди.
Ветере просит попробовать сборку веток 11.8, 12.3 или 13.1 либо следующие релизы: 11.8.10, 12.3.4 и 13.1.2. В архиве Ubuntu 24.04 стоит линейка 10.11. В архиве Ubuntu 26.04 сейчас 11.8.6, в репозитории MariaDB 11.8 для той же системы свежее пакет 11.8.9. Пока mariadb --version не показывает 11.8.10, 12.3.4 или 13.1.2, конфиг ради LRU лучше не трогать.
Когда нужная версия появится в том репозитории, откуда уже стоит пакет, порядок обычный. Сначала копия данных, потом обновление пакетов сервера, потом перезапуск службы базы. Это код mariadbd, reload nginx и перезапуск php-fpm к фиксу не относятся. mysql_upgrade к этому фиксу не привязан: меняется код LRU в сервере, не формат таблиц. После рестарта версию стоит посмотреть ещё раз. Если innodb_old_blocks_time когда-то задирали далеко за секунду, после фикса внутренний потолок около 18,2 часа, а не прежние десятки суток.
mariadb --version
sudo mariadb -e "SHOW GLOBAL STATUS WHERE Variable_name IN ('Innodb_buffer_pool_read_requests','Innodb_buffer_pool_reads','Innodb_buffer_pool_pages_made_young');"
Innodb_buffer_pool_reads это чтения, которые дошли до диска. Рядом с Innodb_buffer_pool_read_requests по ним видно, живёт ли нагрузка в пуле. Если после 11.8.10, 12.3.4 или 13.1.2 пул для вашей нагрузки тесен и попадания просядут, Ветере просит новый тикет со ссылкой на MDEV-33966, с этими двумя счётчиками, не только со скоростью. Один бит PROMOTE он сам считает грубой оценкой свежести. Дальше можно было бы помнить что-то ближе к usage_count, но биты должны влезть в buf_block_t, а MDEV-41403 как раз хочет ужать этот дескриптор до 128 байт.