Не удаляйте индекс MySQL по статистике одного сервера

1 ноября 2026 года Arunjith Aravindan выложил в блоге Percona скрипт check_unused_indexes. Он обходит узлы одной топологии MySQL, на каждом читает sys.schema_unused_indexes и оставляет индекс в списке, только если чтений не было нигде. Код в репозитории Percona-Lab/check_unused_indexes, лицензия Apache 2.0. Один файл, Python 3.6 или новее, библиотека pymysql. Скрипт только читает: DDL печатает, на серверах ничего не меняет.

Схема, которой несколько лет, обрастает индексами. Отчёт уже не строят. Миграция ORM оставила свой ключ. Кто-то добавил индекс на всякий случай и забыл. На каждый INSERT и UPDATE сервер пишет ещё и этот ключ. Буферный пул держит лишние страницы, бэкап толще, смена схемы дольше. С MySQL 5.7 представление sys.schema_unused_indexes показывает индексы с нулём чтений с момента старта этого сервера. Список легко принять за очередь на DROP INDEX.

Счётчики Performance Schema живут на том сервере, который спросили. Про реплики они не знают. Primary часто берёт запись и короткие запросы, тяжёлое чтение уходит в другое место. Реплика для отчётов опирается на индекс, которого приложение на primary не касается. Реплика Aurora для чтения отдаёт дашборд, и writer этих поисков не видит. В Galera или Percona XtraDB Cluster балансировщик может слать один тип запросов на один узел чаще, чем на остальные. Индекс с нулём чтений на primary при этом нужен отчёту на реплике, иначе тот не укладывается в секунду. DROP на primary реплицируется, и запрос на реплике уходит в полный просмотр таблицы.

Второй подвох в том же счётчике. Он обнуляется при перезапуске MySQL. Сервер, который обновили во вторник, видел несколько дней. Недельное задание или квартальный отчёт ещё не запускались, и их индексы выглядят брошенными.

Двенадцать на primary и два на всех узлах

Пост приводит пример из README, цвета в выводе сняты. В файле inventory четыре строки. db-analytics реплицируется с другого primary, обход его отбрасывает. Остаются три узла.

У db-primary (10.0.0.10:3306) в выводе Uptime 42,1 дня и 12 кандидатов. У db-replica-1 те же 42,1 дня и 8 кандидатов. У db-replica-2 Uptime 14,0 дня и 5 кандидатов. Пересечение по всем трём: два индекса, app_db.orders / idx_legacy_status и app_db.users / idx_created_at. В выводе это строка Safe Target Indexes. Десять индексов из двенадцати на primary где-то читаются. Снести их означало бы задеть чужую нагрузку.

Двенадцать против двух. Реплики как раз забирают то, чего primary не обслуживает. Имена app_db, orders и users из этого примера, не из таблиц WordPress. Флаг -qs (--quiet-summary) печатает только DDL, если список нужен в заявку, а не таблицу в терминал.

Если на VPS одна MariaDB под WordPress и реплик нет, пересекать скрипту нечего. Разбор про MySQL, где ниже primary есть узлы: обычные реплики, кольцо, Aurora, Galera или Percona XtraDB Cluster. Одна база после рестарта врёт тем же счётчиком. Список «нулевых» индексов на следующее утро после обновления пакета лучше не превращать в DROP.

Обход от primary вниз по репликам

На схеме в посте цепочка из пяти блоков и подпись: индекс попадает в отчёт, только если согласны все узлы. Сначала inventory, файл или аргументы командной строки, строка вида alias=host:port. Дальше три шага и голубой блок DDL: по умолчанию индекс делают невидимым, не удаляют. От первого шага стрелка вниз в остановку, если primary недоступен. От второго такая же стрелка, если не ответил любой узел и не передан --force.

Скрипт получает список серверов и имя primary. На первом шаге он решает, кто из inventory входит в эту топологию. С каждого узла читает SHOW REPLICA STATUS, на старых версиях SHOW SLAVE STATUS, переменную @@wsrep_cluster_name (так отмечает Galera и PXC), на Aurora таблицу information_schema.replica_host_status и статус Uptime. Потом идёт вниз от primary. Реплика, у которой источник совпал с адресом primary, входит в обход, затем её собственные реплики. Цепочка и кольцо идут так же. Узел Galera тянет соседей по кластеру. Читателей Aurora сопоставляют по server ID или по префиксу псевдонима: writer называют aurora, читателей aurora-reader-1, aurora-reader-2 и дальше по тому же правилу. Строки inventory, которые к этому primary не относятся, остаются в стороне. Один файл можно держать на весь парк и запускать скрипт с разным --primary.

На втором шаге на каждом найденном узле запрос к sys.schema_unused_indexes. Схемы sys, mysql, performance_schema и information_schema пропускаются всегда. Индекс, у которого первый столбец входит во внешний ключ, тоже выпадает: MySQL держит индекс по столбцам внешнего ключа и не даст снять тот, на котором висит ограничение. Выборку сужают флагами --include-schema и --include-table. Лишние схемы отсекает --ignore-schema.

Третий шаг начинает со списка primary и вычёркивает всё, чего нет на каждом другом узле. На печать идут таблица и DDL, который предлагают выполнить на primary. Если полного ответа нет со всех узлов, скрипт останавливается и не печатает список, за который не может ответить.

Сопоставление Source_Host со строками inventory идёт как обычные строки. Реплики заводили через CHANGE REPLICATION SOURCE TO SOURCE_HOST='db-primary.internal', значит в inventory нужен тот же hostname, не IP. Короткий список узлов почти всегда отсюда. Порт по умолчанию 3306, пустые строки и строки с # скрипт пропускает.

Учётка одна на все узлы, из ~/.my.cnf или из файла, который передают флагом -c. Ей нужно читать sys.schema_unused_indexes и information_schema и выполнять SHOW REPLICA STATUS. В примере Percona пользователь index_audit и пароль change_me. Это заглушка из поста, не рабочий секрет. В inventory из того же примера четыре адреса, аналитический узел в топологию primary не входит:

git clone https://github.com/Percona-Lab/check_unused_indexes.git
cd check_unused_indexes
pip3 install --user pymysql
python3 check_unused_indexes.py --inventory inventory.txt --primary db-primary

Файл inventory.txt в примере выглядит так: db-primary=10.0.0.10:3306, db-replica-1=10.0.0.11:3306, db-replica-2=10.0.0.12:3306, db-analytics=10.0.0.13:3306. Для короткой проверки те же узлы можно передать без файла, флагами --primary и --replicas, в виде alias=host:port через запятую. Адреса в посте учебные.

Перед прогоном стоит увидеть, какая база отвечает клиенту. Пост пишет команды под MySQL. Строка с MariaDB означает другой сервер, и DDL из примера к нему не копируют как есть.

mysql --version

Сначала невидимый индекс, DROP после цикла

По умолчанию скрипт печатает не DROP INDEX, а смену видимости. Для двух индексов из примера это такие операторы, их выполняют на primary:

ALTER TABLE `app_db`.`orders` ALTER INDEX `idx_legacy_status` INVISIBLE;
ALTER TABLE `app_db`.`users` ALTER INDEX `idx_created_at` INVISIBLE;

Сам скрипт их не запускает. Список это кандидаты, которые ещё надо прочитать глазами. Невидимые индексы появились в MySQL 8.0. Оптимизатор такой индекс не выбирает, а сервер по-прежнему поддерживает его на каждой записи. Сделать индекс невидимым и вернуть обратно значит поменять метаданные, откат занимает секунды. Настоящий DROP INDEX и сборка заново на большой таблице могут идти часами.

Порядок, который описывает автор, такой.

  1. Прочитать имена кандидатов и убедиться, что каждое понятно.
  2. Выполнить INVISIBLE на primary. Операторы реплицируются, узлы топологии меняются вместе.
  3. Переждать полный рабочий цикл, включая конец месяца и недельные задания. Смотреть slow query log и метрики запросов.
  4. Если запрос замедлился, вернуть индекс через ALTER INDEX ... VISIBLE.
  5. Когда сомнений не осталось, индексы снять. --action drop печатает DROP INDEX, --action both печатает оба варианта. По умолчанию --action invisible.

На MySQL 8.0 в одной сессии можно включить use_invisible_indexes в optimizer_switch и проверить сомнительный запрос, не открывая индекс приложению. Разбор одной сессии, включая SET_VAR(optimizer_switch = 'use_invisible_indexes=on'), автор вынес в прошлый пост про дубли, неиспользуемые и невидимые индексы на одном сервере.

Перед DROP каждое имя смотрят в SHOW CREATE TABLE. Представление не включает первичные ключи, но UNIQUE в список попасть может. Чтений ноль, а правило «значение не повторяется» индекс всё ещё держит. На WordPress лишний ключ это ещё и более долгий ALTER, когда плагин меняет таблицу. Имена из примера Percona к wp_posts и wp_postmeta отношения не имеют.

Если узел молчит, списка не будет

Пересечение честное, только когда ответил каждый узел. Пропущенная реплика даёт список, который выглядит законченным. Индекс, нужный только ей, может попасть в кандидаты. Текущая версия в таком случае не угадывает.

  • Primary недоступен на первом шаге. Остановка: топологию не из чего строить.
  • Часть строк inventory не отвечает на первом шаге. Предупреждение с именами, эти узлы не оценивались. Уже лежащую реплику скрипт не может признать частью топологии, статус репликации он не прочитал. Предупреждение читают до того, как верить результату.
  • Primary отвалился на втором шаге. Остановка, пересекать не с чем.
  • Реплика или сосед по кластеру отвалился на втором шаге. Остановка и список узлов, если не передан --force. С --force прогон идёт по тем, кто ответил, печатает предупреждение и подписывает результат «checked nodes». Флаг для узла, который снимают насовсем, не для сетевого сбоя.
  • Uptime ниже порога. Прогон заканчивается и предупреждает, что статистика может быть неполной. По умолчанию 7 дней, -u меняет порог, 0 выключает проверку. В примере реплика с 14 днями в пересечение входит. Предупреждение было бы при сроке короче недели.

Флаг --pause-seconds ждёт между первым и вторым шагом. Он нужен, чтобы эту остановку проверить, а не чтобы «подождать нагрузку». Пост предлагает запуск python3 check_unused_indexes.py -i inventory.txt -p db-primary --pause-seconds 60, на паузе остановить MySQL на одной реплике и убедиться, что скрипт выходит с кодом 1 и строкой FATAL ERROR: Failed to inspect index state on resolved replica/peer node(s). Это проверка на тестовом стенде.

Дальше ограничения, которые автор перечисляет сам. Скрипт видит только серверы из inventory. Забытая реплика для него не существует, вместе со всем, что она обслуживает. Статистика начинается с последнего рестарта. Предупреждение про uptime помогает, а достаточно ли сервер проработал, решает тот, кто знает нагрузку. Performance Schema включён по умолчанию. Если его выключили, представлению нечего показать. Хосты сравниваются строками, как они записаны в Source_Host. Дубли ключей скрипт не ищет: индекс может быть лишним, даже если по нему ходят, когда другой индекс покрывает те же столбцы. Для этого пост отсылает к pt-duplicate-key-checker из Percona Toolkit и к тому же прошлому разбору.

Групповая репликация и реплика с несколькими источниками в этот обход не входят. Если топология читается неверно, автор просит issue или pull request в тот же репозиторий. Скрипт не видит трафик, которого ещё не было. Квартальный отчёт, не попавший в окно uptime, оставит индекс среди кандидатов. Поэтому сначала невидимый индекс и цикл, в котором отчёты успели отработать, и только потом DROP на primary. nginx и PHP-FPM скрипт не трогает. Ломается запрос, который после такой команды уехал в полный просмотр.

Источники и ссылки


Комментарии загружаются…