PHP: полный мануал с примерами, функциями и ООП
Практический мануал PHP: от переменных и читаемого кода до безопасных форм, интеграций, базы данных и ООП на примере магазина. Учебный маршрут, поиск функций, подробные примеры, разбор ошибок и исходники для скачивания.

Дерево руководства
Глава 140 · После SELECT, JOIN и параметров PDO
MySQL: составные индексы, точные диапазоны и чтение плана запроса
От левого префикса и очереди заказов до покрытия, видимости индекса и фактических строк EXPLAIN ANALYZE.
Как запросу помогает упорядоченный указатель
Менеджеру нужна очередь заказов, бухгалтеру — точный день, списку — короткие поля. Индекс полезен, когда помогает конкретному запросу найти и выдать нужные строки. Здесь построим шесть самостоятельных сценариев и покажем не только SQL, но и реальные таблицы результата вместе с планами.
- Экран: чьи заказы и какие поля нужны
- WHERE: равенства и границы
- ORDER BY: порядок и одинаковые значения
- Индекс: подходящее начало и дальнейшие части
- SELECT: проверенный результат
- EXPLAIN: наблюдавшийся путь выполнения
Индекс можно представить как отдельный упорядоченный указатель к записям. Он добавляет путь поиска, но не заменяет условия запроса и не обещает одинаковый план навсегда. Указатель надо хранить и обновлять. Основы SQL, WHERE, ORDER BY и LIMIT нужны из главы о запросе для экрана; соединение с PHP подготовлено в подключении PDO_MYSQL. Текущая глава подробно разбирает сами решения об индексах.
Запустить и прочитать результат
Требуются PHP 8.4+ с PDO_MYSQL/mysqlnd и MySQL 8.4; контрольное окружение PHP 8.5.11 / MySQL 8.4.13. Включённый helper использует PDO::connect и Pdo\Mysql, поэтому его минимум — PHP 8.4. Подготовьте отдельную локальную учебную базу по инструкции запуска. Права на неё: CREATE, INSERT, SELECT, ALTER, INDEX, DROP.
Задайте в терминале PHPMAN_MYSQL_HOST=127.0.0.1, PHPMAN_MYSQL_PORT, PHPMAN_MYSQL_DATABASE, PHPMAN_MYSQL_USER, PHPMAN_MYSQL_PASSWORD; стенд использует порт 33070. Каждый ZIP содержит example.php, ix114-helper.php и README.txt. Выполните php example.php и откройте result.html. Там видны бизнес-строки, затем реальные планы. result.json содержит таблицу, metrics.json — проверки, версии и планы.
Создаются обычные собственные InnoDB-таблицы mysql114_index_<случайные 16 hex>_orders, в последнем сценарии также customers. Флаги владения выставляются только после успешного CREATE; finally удаляет эти точные имена. DDL нельзя откатить как INSERT. Если процесс аварийно оборван, нужна проверка именно созданных имён в учебной базе; массовое удаление по префиксу не применяется. На рабочую базу этот учебный сценарий переносить не нужно.
Данные специально умеренные: восемь организаций, по 60 заказов. Даты задаются явно, не через текущее время. Мы проверяем смысл SQL и структуру индексов; не выдаём этот набор за нагрузочный тест. Ни FORCE INDEX, ни изменения настроек сервера для получения «красивого» плана не используются.
Слова, которые встретятся в SQL и плане
| Запись | Смысл |
|---|---|
| CREATE INDEX имя ON таблица (a,b) | Один индекс с заданным порядком частей |
| PRIMARY KEY | Уникальный обязательный ключ; в InnoDB обычно определяет кластерный индекс |
| EXPLAIN FORMAT=TRADITIONAL SELECT ... | План с колонками possible_keys, key, type, rows, Extra |
| possible_keys / key | Кандидаты / фактически выбранный индекс |
| type=range / index / ALL | Диапазон / просмотр индекса / просмотр таблицы; index не означает точечный поиск |
| rows в обычном EXPLAIN | Оценка, а не посчитанные при выполнении строки |
| EXPLAIN ANALYZE FORMAT=TREE SELECT ... | Реальное выполнение и дерево наблюдений |
| INVISIBLE / VISIBLE | Доступность индекса оптимизатору по правилу use_invisible_indexes |
В коротком SQL orders и customers — понятные учебные имена. Полная программа создаёт случайные имена и подставляет только проверенные SQL-идентификаторы. Значения передаются через ? и execute; placeholder не подставляет имя таблицы, столбца или направление сортировки. Ошибка подключения или недостаток прав вызывает PDOException и не превращается в отчёт «всё работает».
Один составной индекс и три разных фильтра заказов
У сервиса несколько организаций. Менеджер открывает заказы своей организации, затем выбирает «оплачено». Администратору нужен похожий список сразу по всем организациям. Внешне фильтры близки, но для составного индекса это разные маршруты поиска.
Данные и требование
| Данные | Конкретное значение |
|---|---|
| Заказы | 8 организаций × 60 заказов = 480 строк |
| Организация 3 | ID 121–180 |
| Статусы | В каждой организации 20 new, 20 paid, 20 sent |
| Индекс | tenant_id → status → id |
- Первый ключ группирует записи организации 3
- Внутри группы status выделяет paid
- Внутри paid записи упорядочены по id
- Фильтр только по status пересекает группы организаций
Что получится после полного запуска
| Фильтр | Всего совпало | Первые три ID |
|---|---|---|
| Организация 3 | 60 | 121, 122, 123 |
| Организация 3, оплачено | 20 | 121, 124, 127 |
| Оплачено во всех организациях | 160 | 1, 4, 7 |
Короткая запись основного действия
CREATE INDEX tenant_status ON orders (tenant_id,status,id);
SELECT id,tenant_id,status FROM orders
WHERE tenant_id=3 ORDER BY id LIMIT 3;
SELECT id,tenant_id,status FROM orders
WHERE tenant_id=3 AND status='paid' ORDER BY id LIMIT 3;
SELECT id,tenant_id,status FROM orders
WHERE status='paid' ORDER BY id LIMIT 3;Этот фрагмент объясняет действие на уже созданных данных. Полная программа ниже создаёт все нужные строки, выполняет запросы, записывает HTML и удаляет только свои учебные таблицы.
В записи CREATE INDEX tenant_status ON orders (tenant_id,status,id) tenant_status — придуманное нами имя индекса, orders — таблица, скобки задают порядок полей. Это один составной индекс, а не три отдельных. Левый префикс — его начало без пропусков: tenant_id; затем tenant_id,status; затем все три поля. Аналогия — шкаф «организация → статус → номер заказа». Она объясняет порядок поиска, но не буквальное физическое размещение всей таблицы.
Первые два запроса имеют такие начала. У третьего отсутствует tenant_id: одинаковый status встречается в нескольких группах. Поэтому он не получает тот же обычный поиск по левому префиксу. Это не утверждение «индекс никогда не появится в плане»: возможны просмотр индекса, skip scan при подходящих условиях и другой выбранный доступ. Здесь мы сохраняем фактические EXPLAIN для всех трёх запросов.
Изменение порядка условий в WHERE не меняет порядок полей индекса: status='paid' AND tenant_id=3 описывает тот же фильтр. А изменение определения индекса на status,tenant_id,id — другой порядок хранения ключей. Его оценивают по набору запросов сервиса. Не надо автоматически добавлять INDEX(tenant_id) поверх уже существующего начала; сначала проверьте назначение, размер и реальные планы.
Счётчик COUNT(*) здесь нужен, чтобы увидеть разницу 60, 20 и 160 совпадений. Список отдельно ограничен тремя строками; PHP не получает все заказы ради обрезания массива. Возможность поиска ещё не гарантирует ORDER BY id: внутри одной организации без выбранного статуса записи индекса сначала разделены по status. Зафиксируйте этот вопрос в плане первого запроса.
Границы и ошибки
Левый префикс объясняет форму доступа, а не универсальный рецепт «самое редкое поле всегда первым». Нужны запросы приложения, селективность, сортировка и цена записи. Статистика и объём могут привести к scan даже при подходящем индексе. Фильтр tenant_id является частью бизнес-изоляции и должен также определяться авторизацией; один индекс не создаёт защиту данных.
Полная самостоятельная программа и файлы
<?php
declare(strict_types=1);
require __DIR__.'/ix114-helper.php';
$db=ix114Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$prefix='mysql114_index_'.bin2hex(random_bytes(8));
$orders=$prefix.'_orders'; $customers=$prefix.'_customers';
$ordersCreated=false; $customersCreated=false;
$details=[];
try {
ix114CreateOrders($db,$orders); $ordersCreated=true;
ix114Seed($db,$orders);
$ot=ix114Name($orders);
$db->exec("CREATE INDEX tenant_status ON $ot (tenant_id,status,id)");
$cases=[['Организация 3','tenant_id=?',[3]],['Организация 3, оплачено','tenant_id=? AND status=?',[3,'paid']],['Оплачено во всех организациях','status=?',['paid']]];
$rows=[]; $ids=[]; $counts=[];
foreach ($cases as [$label,$where,$params]) {
$sql="SELECT id,tenant_id,status FROM $ot WHERE $where ORDER BY id LIMIT 3";
$found=ix114Rows($db,$sql,$params); $ids[]=ix114Ids($found);
$count=(int)ix114Rows($db,"SELECT COUNT(*) AS total FROM $ot WHERE $where",$params)[0]['total']; $counts[]=$count;
$rows[]=[$label,$count,implode(', ',ix114Ids($found))];
$details['План: '.$label]=ix114Plan($db,$sql,$params);
}
$index=ix114Index($db,$orders,'tenant_status');
$checks=['fixture_has_480_orders'=>(int)ix114Rows($db,"SELECT COUNT(*) AS total FROM $ot")[0]['total']===480,
'three_filters_correct'=>$ids===[[121,122,123],[121,124,127],[1,4,7]],'counts_correct'=>$counts===[60,20,160],
'index_column_order'=>array_column($index,'COLUMN_NAME')===['tenant_id','status','id']];
$details['Индекс tenant_status']=$index;
} finally {
if ($db->inTransaction()) {$db->rollBack();}
if ($ordersCreated) {$db->exec('DROP TABLE '.ix114Name($orders));}
if ($customersCreated) {$db->exec('DROP TABLE '.ix114Name($customers));}
}
$remaining=ix114Rows($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?)',[$orders,$customers]);
$checks['owned_tables_removed']=(int)$remaining[0]['total']===0;
$db=null;
ix114Report('mysql114-index-leftmost-prefix', ['Фильтр', 'Всего совпало', 'Первые три ID'], $rows, $checks, $runtime, $details);
echo 'mysql114-index-leftmost-prefix: saved result.html and result.json
';
Ожидаемый результат
mysql114-index-leftmost-prefix: saved result.html and result.json
Откройте result.html: сначала результат для человека, затем фактические планы и метаданные. Каждый ZIP самодостаточен; другие примеры перед ним запускать не нужно.
Открыть фактический HTML-результат с планами · Скачать таблицу JSON
Попробуйте сами. Замените tenant_id=3 на 4. Всего останется 60 заказов, оплаченных — 20; первые ID будут 181,182,183 и 181,184,187. Администраторский список без tenant_id не изменится. Если именно этот глобальный список стал основным, рассмотрите отдельный индекс, начинающийся со status, и сохраните его план, не обещая ускорение заранее.
Практический вопрос: Нужен ли отдельный индекс на первой колонке составного ключа. У автора уже был ключ user_id,team_id, но он сомневался, нужен ли ещё индекс только на user_id. Наши три фильтра заказов объясняют начало одного составного индекса и отличие запроса без ведущего поля; SQL и данные оригинальные. Обсуждение даёт исходную проблему; технические правила сверены по официальным источникам, программа и объяснения написаны отдельно.
Очередь: равенства, диапазон даты и устойчивый порядок
Диспетчеру нужны четыре последних оплаченных заказа организации 3 за 2 октября. У двух заказов одинаковое время: экран должен сохранять понятный порядок, а следующий запрос не должен случайно переставлять их местами.
Данные и требование
| Условие | Значение |
|---|---|
| Равенства | tenant_id=3; status=paid |
| Диапазон UTC | От 2026-10-02 00:00 включительно до 2026-10-03 00:00 исключительно |
| Одинаковое время | Заказы 166 и 901: 2026-10-02 21:00 |
| Порядок | created_at DESC, id ASC; не более 4 строк |
- Равенства выбирают одну группу организации и статуса
- Диапазон оставляет девять заказов дня
- Дата идёт от новой к старой
- При равной дате меньший id идёт первым
- LIMIT возвращает четыре нужные строки
Что получится после полного запуска
| Заказ | Дата UTC | Сумма, коп. |
|---|---|---|
| 166 | 2026-10-02 21:00:00.000000 | 4603 |
| 901 | 2026-10-02 21:00:00.000000 | 9900 |
| 163 | 2026-10-02 18:00:00.000000 | 4303 |
| 160 | 2026-10-02 15:00:00.000000 | 4003 |
Короткая запись основного действия
CREATE INDEX queue_page
ON orders (tenant_id,status,created_at DESC,id ASC);
SELECT id,created_at,amount_cents FROM orders
WHERE tenant_id=3 AND status='paid'
AND created_at >= '2026-10-02 00:00:00'
AND created_at < '2026-10-03 00:00:00'
ORDER BY created_at DESC,id ASC LIMIT 4;Этот фрагмент объясняет действие на уже созданных данных. Полная программа ниже создаёт все нужные строки, выполняет запросы, записывает HTML и удаляет только свои учебные таблицы.
В нашем индексе сначала стоят поля с одним выбранным значением, затем дата. Для этой задачи диапазон и главный порядок используют одну колонку created_at. Последний id задаёт однозначный порядок при одинаковом времени. ASC означает возрастание, DESC — убывание. Смешанный порядок создан явно: created_at DESC,id ASC; все поля ASC не равнозначны этому сочетанию.
Мы добавили заказ 901, чтобы равенство времени было видно, а не оставалось обещанием в тексте. В выдаче 166 расположен перед 901, затем идут 163 и 160. Правильность этих строк проверяется независимо от того, выбрал ли сервер queue_page и понадобилась ли ему отдельная сортировка.
Правило «равенства, затем диапазон» относится к построению интервала по ключу. Поля после первого диапазона могут оставаться полезны для других действий; их нельзя объявлять бесполезными вообще. Например, id в данном запросе уточняет порядок. Если фильтр изменится на amount_cents>5000, а сортировка останется по created_at, простое добавление amount_cents перед датой уже не даёт того же упорядоченного диапазона: внутри разных сумм даты перемешаны. Выбор между сокращением кандидатов и получением порядка требует плана конкретного запроса.
Проверка INFORMATION_SCHEMA.STATISTICS показывает A,A,D,A для четырёх частей queue_page. Это доказательство определения индекса; фактическое использование видно в key плана. Для продолжения списка пригодится выборка по требованиям экрана и курсор страницы.
Границы и ошибки
LIMIT 4 ограничивает выдачу, но не доказывает четыре чтения на сервере. Нельзя менять порядок или убирать id, а продолжение страницы считать прежним. Эти данные неизменяемы в пределах примера; конкурентные изменения требуют отдельного договора согласованности. Индекс поддерживается при INSERT/UPDATE и занимает место.
Полная самостоятельная программа и файлы
<?php
declare(strict_types=1);
require __DIR__.'/ix114-helper.php';
$db=ix114Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$prefix='mysql114_index_'.bin2hex(random_bytes(8));
$orders=$prefix.'_orders'; $customers=$prefix.'_customers';
$ordersCreated=false; $customersCreated=false;
$details=[];
try {
ix114CreateOrders($db,$orders); $ordersCreated=true;
ix114Seed($db,$orders);
$ot=ix114Name($orders);
$db->prepare("INSERT INTO $ot VALUES (?,?,?,?,?,?,?)")->execute([901,3,'paid','2026-10-02 21:00:00',2,9900,'Второй заказ в ту же секунду']);
$db->exec("CREATE INDEX queue_page ON $ot (tenant_id,status,created_at DESC,id ASC)");
$sql="SELECT id,created_at,amount_cents FROM $ot WHERE tenant_id=? AND status=? AND created_at>=? AND created_at<? ORDER BY created_at DESC,id ASC LIMIT 4";
$params=[3,'paid','2026-10-02 00:00:00','2026-10-03 00:00:00'];
$found=ix114Rows($db,$sql,$params); $rows=[];
foreach ($found as $row) {$rows[]=[(int)$row['id'],$row['created_at'],(int)$row['amount_cents']];}
$count=(int)ix114Rows($db,"SELECT COUNT(*) AS total FROM $ot WHERE tenant_id=? AND status=? AND created_at>=? AND created_at<?",$params)[0]['total'];
$index=ix114Index($db,$orders,'queue_page');
$checks=['queue_ids_correct'=>ix114Ids($found)===[166,901,163,160],'range_contains_nine'=>$count===9,
'mixed_index_directions'=>array_column($index,'COLLATION')===['A','A','D','A'],
'prices_correct'=>array_map('intval',array_column($found,'amount_cents'))===[4603,9900,4303,4003]];
$details=['План очереди'=>ix114Plan($db,$sql,$params),'Индекс queue_page'=>$index,'Всего в диапазоне'=>$count,'Параметры'=>$params];
} finally {
if ($db->inTransaction()) {$db->rollBack();}
if ($ordersCreated) {$db->exec('DROP TABLE '.ix114Name($orders));}
if ($customersCreated) {$db->exec('DROP TABLE '.ix114Name($customers));}
}
$remaining=ix114Rows($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?)',[$orders,$customers]);
$checks['owned_tables_removed']=(int)$remaining[0]['total']===0;
$db=null;
ix114Report('mysql114-index-range-and-order', ['Заказ', 'Дата UTC', 'Сумма, коп.'], $rows, $checks, $runtime, $details);
echo 'mysql114-index-range-and-order: saved result.html and result.json
';
Ожидаемый результат
mysql114-index-range-and-order: saved result.html and result.json
Откройте result.html: сначала результат для человека, затем фактические планы и метаданные. Каждый ZIP самодостаточен; другие примеры перед ним запускать не нужно.
Открыть фактический HTML-результат с планами · Скачать таблицу JSON
Попробуйте сами. Измените LIMIT 4 на LIMIT 6. После 166,901,163,160 появятся 157 и 154. Затем измените ORDER BY на created_at DESC,id DESC: первыми станут 901,166. Индекс для этого порядка также надо оценить заново; наличие прежнего queue_page ещё не обещает отсутствие сортировки.
Отчёт за день: DATE над колонкой и точные границы
Бухгалтер видит оплату в 23:59:59.999999, но отчёт «до 23:59:59» её теряет. Одновременно DATE(created_at) в WHERE мешает использовать обычный индекс даты как тот же диапазон. Исправим границы и сравним оба плана на одинаковых данных.
Данные и требование
| Хранение | Правило |
|---|---|
| created_at | DATETIME(6), в учебных данных согласованное время UTC |
| День | 2 октября 2026 |
| Дополнительные строки | Секунда до начала, начало, последняя микросекунда, начало следующего дня |
| Основные строки | 8 оплаченных заказов организации 3 за этот день |
- Начало включаем через >=
- Следующее начало исключаем через <
- Последняя микросекунда текущего дня остаётся внутри
- Проверяем одинаковые десять ID у двух вариантов
- Читаем, какие части day_report доступны каждому плану
Что получится после полного запуска
| Граничный заказ | Дата UTC | В отчёте за 2 октября |
|---|---|---|
| 901 | 2026-10-01 23:59:59.999999 | Исключён |
| 902 | 2026-10-02 00:00:00.000000 | Включён |
| 903 | 2026-10-02 23:59:59.999999 | Включён |
| 904 | 2026-10-03 00:00:00.000000 | Исключён |
Короткая запись основного действия
-- Одинаковый день, но разные условия для индексируемой даты:
SELECT id,created_at FROM orders
WHERE tenant_id=3 AND status='paid'
AND DATE(created_at)='2026-10-02' ORDER BY created_at,id;
SELECT id,created_at FROM orders
WHERE tenant_id=3 AND status='paid'
AND created_at >= '2026-10-02 00:00:00'
AND created_at < '2026-10-03 00:00:00'
ORDER BY created_at,id;Этот фрагмент объясняет действие на уже созданных данных. Полная программа ниже создаёт все нужные строки, выполняет запросы, записывает HTML и удаляет только свои учебные таблицы.
DATE(created_at) вычисляет дату из каждого рассматриваемого значения. Сам обычный индекс содержит created_at, а не результат DATE. Вторая запись сравнивает исходную колонку непосредственно с двумя границами. Название «полуоткрытый интервал» означает именно это: левый край включён, правый исключён. Для DATETIME(6) не приходится угадывать последнюю дробную секунду.
Оба запроса возвращают десять одинаковых строк: 145,902,148,151,154,157,160,163,166,903. У 145 и 902 одинаковая полночь, поэтому ORDER BY created_at,id ставит меньший ID первым. В главной таблице отчёта выделены четыре граничные строки, ниже HTML показывает весь набор. Поэтому читатель видит и правильность отбора, и реальное содержимое отчёта, а не только надпись «индекс быстрее».
Мы не утверждаем, что вариант с DATE лишился всех возможностей day_report: префикс tenant_id,status остаётся пригодным, возможен просмотр соответствующей группы. Отличается работа именно с датой как диапазоном этого обычного ключа. EXPLAIN сохраняется для обоих вариантов. Индекс выражения DATE(created_at) или generated column — самостоятельная конструкция с ценой хранения и записи; она может потребоваться для других задач, но не нужна автоматически для такого дневного диапазона.
Здесь DATETIME не переводит UTC в местное время. Если отчёт строится по календарному дню клиента, сначала получите начало и следующее начало его дня в нужной временной зоне, затем переведите обе границы в систему хранения. День при переходе времени не всегда равен 24 часам. Обработка дат и календарные отчёты дополняются в главе о датах PHP и SQL-отчётах по периодам.
Границы и ошибки
Применение функции в SELECT ради показа даты не равно её применению в WHERE для отбора. Не используйте BETWEEN с правым краем «следующая полночь»: BETWEEN включает оба края. Набор специально небольшой; разницу скорости по нему не измеряем. Полуоткрытая запись улучшает прежде всего точность границы и даёт серверу пригодное условие диапазона.
Полная самостоятельная программа и файлы
<?php
declare(strict_types=1);
require __DIR__.'/ix114-helper.php';
$db=ix114Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$prefix='mysql114_index_'.bin2hex(random_bytes(8));
$orders=$prefix.'_orders'; $customers=$prefix.'_customers';
$ordersCreated=false; $customersCreated=false;
$details=[];
try {
ix114CreateOrders($db,$orders); $ordersCreated=true;
ix114Seed($db,$orders);
$ot=ix114Name($orders);
$edgeRows=[[901,'2026-10-01 23:59:59.999999'],[902,'2026-10-02 00:00:00.000000'],[903,'2026-10-02 23:59:59.999999'],[904,'2026-10-03 00:00:00.000000']];
$insert=$db->prepare("INSERT INTO $ot VALUES (?,3,'paid',?,1,1000,'Граничная дата')");
foreach ($edgeRows as [$id,$time]) {$insert->execute([$id,$time]);} $insert->closeCursor();
$db->exec("CREATE INDEX day_report ON $ot (tenant_id,status,created_at,id)");
$functionSql="SELECT id,created_at FROM $ot WHERE tenant_id=? AND status=? AND DATE(created_at)=? ORDER BY created_at,id";
$rangeSql="SELECT id,created_at FROM $ot WHERE tenant_id=? AND status=? AND created_at>=? AND created_at<? ORDER BY created_at,id";
$a=ix114Rows($db,$functionSql,[3,'paid','2026-10-02']);
$b=ix114Rows($db,$rangeSql,[3,'paid','2026-10-02 00:00:00','2026-10-03 00:00:00']);
$selected=ix114Ids($b); $rows=[];
foreach ($edgeRows as [$id,$time]) {$rows[]=[$id,$time,in_array($id,$selected,true)?'Включён':'Исключён'];}
$checks=['same_daily_rows'=>$a===$b,'ten_orders_in_day'=>count($b)===10,
'microsecond_edges_correct'=>$selected===[145,902,148,151,154,157,160,163,166,903],
'adjacent_days_excluded'=>!in_array(901,$selected,true) && !in_array(904,$selected,true)];
$details=['План DATE(created_at)'=>ix114Plan($db,$functionSql,[3,'paid','2026-10-02']),
'План полуоткрытого диапазона'=>ix114Plan($db,$rangeSql,[3,'paid','2026-10-02 00:00:00','2026-10-03 00:00:00']),
'Все десять заказов дня'=>$b,'Индекс day_report'=>ix114Index($db,$orders,'day_report')];
} finally {
if ($db->inTransaction()) {$db->rollBack();}
if ($ordersCreated) {$db->exec('DROP TABLE '.ix114Name($orders));}
if ($customersCreated) {$db->exec('DROP TABLE '.ix114Name($customers));}
}
$remaining=ix114Rows($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?)',[$orders,$customers]);
$checks['owned_tables_removed']=(int)$remaining[0]['total']===0;
$db=null;
ix114Report('mysql114-index-date-boundaries', ['Граничный заказ', 'Дата UTC', 'В отчёте за 2 октября'], $rows, $checks, $runtime, $details);
echo 'mysql114-index-date-boundaries: saved result.html and result.json
';
Ожидаемый результат
mysql114-index-date-boundaries: saved result.html and result.json
Откройте result.html: сначала результат для человека, затем фактические планы и метаданные. Каждый ZIP самодостаточен; другие примеры перед ним запускать не нужно.
Открыть фактический HTML-результат с планами · Скачать таблицу JSON
Попробуйте сами. Сформируйте 3 октября: >= 2026-10-03 00:00:00 и < 2026-10-04 00:00:00. Заказ 904 войдёт, 903 не войдёт. В исходных данных за этот день четыре paid-заказа организации 3: 169,172,175,178; вместе с 904 получится пять. Меняйте и условие, и ожидаемый результат.
Практический вопрос: Как выбрать день и сохранить подходящее условие для индекса даты. Сравнение временной метки с одной датой теряло ожидаемые строки, а DATE(created_at) меняло возможность индексного доступа. Наш сценарий использует DATETIME(6), два равенства перед датой, четыре граничные строки и сравнение фактических наборов, не копируя SQL ответа. Обсуждение даёт исходную проблему; технические правила сверены по официальным источникам, программа и объяснения написаны отдельно.
Покрывающий индекс: краткий список и полная карточка
В списке менеджеру нужны номер, дата и сумма, а длинное примечание он открывает уже в карточке. Проверим, какие значения способен отдать сам индекс, и почему добавление даже одного поля меняет требования к чтению.
Данные и требование
| Представление | Поля |
|---|---|
| Краткий список | id, created_at, amount_cents |
| Расширенная карточка | Те же поля + note |
| list_cover | tenant_id, status, created_at, id, amount_cents |
| note | 12 повторов учебной фразы; 444 байта UTF-8 |
- WHERE и ORDER BY определяют нужные записи
- Краткий SELECT запрашивает только содержащиеся в индексе значения
- Полная карточка дополнительно требует note из записи таблицы
- Сравниваем общие значения двух результатов
- Планы показывают фактический способ чтения
Что получится после полного запуска
| Заказ | Сумма, коп. | Байты note в полной карточке |
|---|---|---|
| 121 | 103 | 444 |
| 124 | 403 | 444 |
| 127 | 703 | 444 |
Короткая запись основного действия
CREATE INDEX list_cover
ON orders (tenant_id,status,created_at,id,amount_cents);
-- Список: все нужные значения присутствуют в этом индексе.
SELECT id,created_at,amount_cents FROM orders
WHERE tenant_id=3 AND status='paid'
ORDER BY created_at,id LIMIT 3;
-- Для полной карточки действительно требуется и note.
SELECT id,created_at,amount_cents,note FROM orders
WHERE tenant_id=3 AND status='paid'
ORDER BY created_at,id LIMIT 3;Этот фрагмент объясняет действие на уже созданных данных. Полная программа ниже создаёт все нужные строки, выполняет запросы, записывает HTML и удаляет только свои учебные таблицы.
Покрывающим называют индекс относительно конкретного запроса: в нём есть значения, которые нужны для отбора, порядка и результата. Здесь list_cover содержит все поля короткого запроса. Добавленный note в нём отсутствует; этот же индекс уже не покрывает расширенный SELECT. Покрытие не является вечной характеристикой «хорошего индекса» независимо от запроса.
В InnoDB вторичная запись индекса содержит также первичный ключ, через который находится основная строка. Поэтому id часто уже доступен, даже если его не написали отдельным полем вторичного индекса. В учебном list_cover он указан явно для читаемого определения порядка. Получение note требует доступа к данным, которых нет в этой индексной записи.
Таблица показывает три заказа и объём note, действительно прочитанного вторым запросом. Первый запрос вообще не возвращает ключ note в массиве PDO. 444 байта — результат strlen строки в UTF-8, а не измерение сетевого пакета или всей памяти PHP. Общие поля совпадают в обоих вариантах. В реальном интерфейсе note можно запросить отдельно по PRIMARY KEY при открытии карточки.
В EXPLAIN отметка Using index обозначает покрывающий доступ. Using index condition относится к проверке условия по индексным значениям и не заменяет это значение. Маленькая таблица может привести оптимизатор к другому пути: структура покрытия останется объяснимой, а наш отчёт честно покажет фактический key. Разбор полей и количества строк, нужных экрану остаётся отправной точкой выбора SELECT.
Границы и ошибки
Не переносите весь TEXT в индекс ради галочки покрытия. Широкий индекс имеет стоимость хранения и обновления; префикс TEXT не сохраняет целое значение для такого SELECT. SELECT * в небольшой внутренней диагностике не запрещён, но контракт публичного списка разумно задавать явно. Покрытие не отменяет фильтр, LIMIT, права доступа и анализ нагрузки.
Полная самостоятельная программа и файлы
<?php
declare(strict_types=1);
require __DIR__.'/ix114-helper.php';
$db=ix114Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$prefix='mysql114_index_'.bin2hex(random_bytes(8));
$orders=$prefix.'_orders'; $customers=$prefix.'_customers';
$ordersCreated=false; $customersCreated=false;
$details=[];
try {
ix114CreateOrders($db,$orders); $ordersCreated=true;
ix114Seed($db,$orders);
$ot=ix114Name($orders);
$db->exec("CREATE INDEX list_cover ON $ot (tenant_id,status,created_at,id,amount_cents)");
$smallSql="SELECT id,created_at,amount_cents FROM $ot WHERE tenant_id=? AND status=? ORDER BY created_at,id LIMIT 3";
$wideSql="SELECT id,created_at,amount_cents,note FROM $ot WHERE tenant_id=? AND status=? ORDER BY created_at,id LIMIT 3";
$small=ix114Rows($db,$smallSql,[3,'paid']); $wide=ix114Rows($db,$wideSql,[3,'paid']);
$rows=[]; $shared=[];
foreach ($wide as $row) {
$rows[]=[(int)$row['id'],(int)$row['amount_cents'],strlen($row['note'])];
unset($row['note']); $shared[]=$row;
}
$index=ix114Index($db,$orders,'list_cover');
$checks=['three_order_ids'=>ix114Ids($small)===[121,124,127],'shared_fields_identical'=>$small===$shared,
'notes_read_only_for_card'=>array_keys($small[0])===['id','created_at','amount_cents'] && array_keys($wide[0])===['id','created_at','amount_cents','note'],
'cover_does_not_store_note'=>array_column($index,'COLUMN_NAME')===['tenant_id','status','created_at','id','amount_cents'],
'note_bytes_correct'=>array_column($rows,2)===[444,444,444]];
$details=['Краткая выдача'=>$small,'План краткой выдачи'=>ix114Plan($db,$smallSql,[3,'paid']),
'План с note'=>ix114Plan($db,$wideSql,[3,'paid']),'Индекс list_cover'=>$index,
'Что измерено'=>'strlen(note) считает только байты текста note. Это не сетевой трафик, память PHP или время запроса.'];
} finally {
if ($db->inTransaction()) {$db->rollBack();}
if ($ordersCreated) {$db->exec('DROP TABLE '.ix114Name($orders));}
if ($customersCreated) {$db->exec('DROP TABLE '.ix114Name($customers));}
}
$remaining=ix114Rows($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?)',[$orders,$customers]);
$checks['owned_tables_removed']=(int)$remaining[0]['total']===0;
$db=null;
ix114Report('mysql114-index-covering-columns', ['Заказ', 'Сумма, коп.', 'Байты note в полной карточке'], $rows, $checks, $runtime, $details);
echo 'mysql114-index-covering-columns: saved result.html and result.json
';
Ожидаемый результат
mysql114-index-covering-columns: saved result.html and result.json
Откройте result.html: сначала результат для человека, затем фактические планы и метаданные. Каждый ZIP самодостаточен; другие примеры перед ним запускать не нужно.
Открыть фактический HTML-результат с планами · Скачать таблицу JSON
Попробуйте сами. Оставьте в коротком запросе только id и amount_cents: они по-прежнему доступны в list_cover. Затем добавьте customer_id — это поле отсутствует в определении. Проверьте новый план и решите, действительно ли оно нужно экрану; не расширяйте индекс без этой причины.
Проверить невидимый индекс и сделать его доступным
Перед изменением схемы хочется отделить создание нового индекса от момента, когда оптимизатор начинает его учитывать. На своей учебной таблице создадим вторичный индекс невидимым, сохраним план, затем включим видимость и сравним результат.
Данные и требование
| Объект | Что меняем |
|---|---|
| Таблица | Своя случайно названная orders, отдельная от рабочих данных |
| Индекс | candidate_queue(tenant_id,status,id) |
| Сначала | INVISIBLE, IS_VISIBLE=NO |
| После ALTER | VISIBLE, IS_VISIBLE=YES |
- CREATE INDEX строит candidate_queue как INVISIBLE
- SELECT получает те же бизнес-строки
- EXPLAIN фиксирует текущий план
- ALTER INDEX меняет видимость на VISIBLE
- SELECT и EXPLAIN выполняются повторно
Что получится после полного запуска
| Состояние | IS_VISIBLE | Первые три заказа |
|---|---|---|
| После CREATE ... INVISIBLE | NO | 121, 124, 127 |
| После ALTER ... VISIBLE | YES | 121, 124, 127 |
Короткая запись основного действия
CREATE INDEX candidate_queue
ON orders (tenant_id,status,id) INVISIBLE;
EXPLAIN FORMAT=TRADITIONAL
SELECT id,amount_cents FROM orders
WHERE tenant_id=3 AND status='paid' ORDER BY id LIMIT 3;
ALTER TABLE orders ALTER INDEX candidate_queue VISIBLE;
EXPLAIN FORMAT=TRADITIONAL
SELECT id,amount_cents FROM orders
WHERE tenant_id=3 AND status='paid' ORDER BY id LIMIT 3;Этот фрагмент объясняет действие на уже созданных данных. Полная программа ниже создаёт все нужные строки, выполняет запросы, записывает HTML и удаляет только свои учебные таблицы.
INVISIBLE не означает «индекса нет» или «он перестал обновляться». Он занимает место и поддерживается при изменении строк. При обычном use_invisible_indexes=off оптимизатор не использует его для выбора плана. Если настройка включена, невидимые индексы также рассматриваются. Программа записывает текущий optimizer_switch, но не меняет его.
ALTER TABLE orders ALTER INDEX candidate_queue VISIBLE меняет свойство уже существующего индекса. Создавать второй такой же индекс не нужно. Мы проверяем NO → YES через INFORMATION_SCHEMA и равенство результатов до и после. VISIBLE разрешает рассмотрение индекса, а не приказывает выбрать его. Поэтому выводы о key делаются из двух сохранённых планов, не из имени команды.
На производстве обратная операция может помочь исследовать последствия удаления кандидата: сначала сделать его INVISIBLE, наблюдать запросы, затем решить вопрос удаления. Это всё равно изменение поведения рабочей системы и требует отдельного плана. Здесь изучается механизм исключительно на данных текущего запуска. PRIMARY KEY нельзя сделать невидимым; в нашем примере первичный ключ id остаётся явным, а candidate_queue — обычным вторичным индексом.
Границы и ошибки
Невидимый UNIQUE продолжает обеспечивать уникальность. Невидимость не снимает ограничений FK, требований к индексу и блокировок DDL. Изменения схемы не откатываются обычным rollBack как INSERT. Дальнейшие шаги, проверку предпосылок и восстановление объясняет глава об изменении схемы.
Полная самостоятельная программа и файлы
<?php
declare(strict_types=1);
require __DIR__.'/ix114-helper.php';
$db=ix114Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$prefix='mysql114_index_'.bin2hex(random_bytes(8));
$orders=$prefix.'_orders'; $customers=$prefix.'_customers';
$ordersCreated=false; $customersCreated=false;
$details=[];
try {
ix114CreateOrders($db,$orders); $ordersCreated=true;
ix114Seed($db,$orders);
$ot=ix114Name($orders);
$db->exec("CREATE INDEX candidate_queue ON $ot (tenant_id,status,id) INVISIBLE");
$sql="SELECT id,amount_cents FROM $ot WHERE tenant_id=? AND status=? ORDER BY id LIMIT 3";
$params=[3,'paid'];
$before=ix114Rows($db,$sql,$params); $hidden=ix114Index($db,$orders,'candidate_queue');
$planBefore=ix114Plan($db,$sql,$params);
$db->exec("ALTER TABLE $ot ALTER INDEX candidate_queue VISIBLE");
$after=ix114Rows($db,$sql,$params); $visible=ix114Index($db,$orders,'candidate_queue');
$rows=[['После CREATE ... INVISIBLE',$hidden[0]['IS_VISIBLE'],implode(', ',ix114Ids($before))],['После ALTER ... VISIBLE',$visible[0]['IS_VISIBLE'],implode(', ',ix114Ids($after))]];
$checks=['created_invisible'=>array_unique(array_column($hidden,'IS_VISIBLE'))===['NO'],
'became_visible'=>array_unique(array_column($visible,'IS_VISIBLE'))===['YES'],
'business_result_unchanged'=>$before===$after && ix114Ids($after)===[121,124,127],
'same_index_columns'=>array_column($hidden,'COLUMN_NAME')===array_column($visible,'COLUMN_NAME')];
$details=['План при INVISIBLE'=>$planBefore,'План при VISIBLE'=>ix114Plan($db,$sql,$params),
'Метаданные до'=>$hidden,'Метаданные после'=>$visible,
'Текущий optimizer_switch'=>ix114Rows($db,'SELECT @@SESSION.optimizer_switch AS value')[0]['value']];
} finally {
if ($db->inTransaction()) {$db->rollBack();}
if ($ordersCreated) {$db->exec('DROP TABLE '.ix114Name($orders));}
if ($customersCreated) {$db->exec('DROP TABLE '.ix114Name($customers));}
}
$remaining=ix114Rows($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?)',[$orders,$customers]);
$checks['owned_tables_removed']=(int)$remaining[0]['total']===0;
$db=null;
ix114Report('mysql114-index-invisible-index', ['Состояние', 'IS_VISIBLE', 'Первые три заказа'], $rows, $checks, $runtime, $details);
echo 'mysql114-index-invisible-index: saved result.html and result.json
';
Ожидаемый результат
mysql114-index-invisible-index: saved result.html and result.json
Откройте result.html: сначала результат для человека, затем фактические планы и метаданные. Каждый ZIP самодостаточен; другие примеры перед ним запускать не нужно.
Открыть фактический HTML-результат с планами · Скачать таблицу JSON
Попробуйте сами. После сохранения второго плана верните candidate_queue в INVISIBLE командой ALTER TABLE ... ALTER INDEX candidate_queue INVISIBLE. Метаданные снова станут NO, список заказов останется 121,124,127. Не переносите эксперимент на PRIMARY KEY и не включайте глобальные настройки ради совпадения плана.
EXPLAIN ANALYZE: прочитать реальный проход через JOIN
Список показывает четыре заказа с именами покупателей. Обычный EXPLAIN даёт оценки, но непонятно, какие операции действительно выполнялись и почему loops не равно числу строк на экране. Получим оба вида отчёта для одного ограниченного JOIN.
Данные и требование
| Набор | Значение |
|---|---|
| Заказы | 480 строк, организация 3, статус paid |
| Покупатели | 5 строк с первичными ключами 1–5 |
| Связь | orders.customer_id = customers.id |
| Экран | 4 заказа по возрастанию id |
- Обычный SELECT возвращает четыре проверяемые строки
- EXPLAIN FORMAT=TREE описывает предполагаемые операторы
- EXPLAIN ANALYZE FORMAT=TREE реально исполняет этот SELECT
- Операторы сообщают оценку, фактические строки и число проходов
- Оба дерева сохраняются целиком под таблицей результата
Что получится после полного запуска
| Заказ | Покупатель | Сумма, коп. |
|---|---|---|
| 121 | Борис | 103 |
| 124 | Дина | 403 |
| 127 | Вера | 703 |
| 130 | Анна | 1003 |
Короткая запись основного действия
EXPLAIN FORMAT=TREE
SELECT o.id,c.name,o.amount_cents
FROM orders AS o JOIN customers AS c ON c.id=o.customer_id
WHERE o.tenant_id=3 AND o.status='paid'
ORDER BY o.id LIMIT 4;
EXPLAIN ANALYZE FORMAT=TREE
SELECT o.id,c.name,o.amount_cents
FROM orders AS o JOIN customers AS c ON c.id=o.customer_id
WHERE o.tenant_id=3 AND o.status='paid'
ORDER BY o.id LIMIT 4;Этот фрагмент объясняет действие на уже созданных данных. Полная программа ниже создаёт все нужные строки, выполняет запросы, записывает HTML и удаляет только свои учебные таблицы.
EXPLAIN ANALYZE возвращает отчёт исполнения, а не обычную таблицу результата SELECT. Поэтому программа отдельно получает четыре строки для экрана, затем запускает анализ. Это осознанное дополнительное выполнение ограниченного чтения, а не команда, которую нужно добавлять к каждому пользовательскому запросу.
Дерево читается по операциям: от источников строк через соединение и возможную сортировку к верхнему ограничению. Не требуйте заранее конкретного nested loop или hash join: выбранный алгоритм зависит от плана. Отступ показывает, чьим входом является дочерний оператор. Названия случайных учебных таблиц в отчёте настоящие и могут различаться между запусками.
| Поле в дереве | Как читать |
|---|---|
| cost=... | Оценка стоимости оптимизатора; не миллисекунды |
| rows=... до actual | Ожидаемое число выходных строк данного оператора |
| actual time=a..b | Время до первой строки и всего прохода, в мс; включает дочерние операции |
| actual rows=... | Фактические выходные строки оператора; при нескольких loops среднее за проход |
| loops=... | Сколько раз этот оператор запускался |
Если дочерний поиск дал в среднем 1 строку при loops=4, он суммарно выдал примерно 4 строки. Это не четыре уникальных покупателя: один и тот же покупатель мог запрашиваться повторно. Округление и остановка родителя по LIMIT тоже важны. Нельзя суммировать rows всех уровней как число уникальных прочитанных заказов: одна строка проходит через несколько операторов. При нескольких loops время также усредняется; время родителя уже включает потомков, поэтому простое сложение завышает итог.
Фактические деревья расположены сразу под таблицей в result.html. Сначала найдите верхний LIMIT и его строки, затем источник заказов и доступ к customers. Сравнивайте оценку и actual на одном и том же операторе. Большое расхождение — повод исследовать статистику и распределение данных, а не доказательство ошибки SQL. Программа проверяет наличие реального отчёта исполнения и правильность бизнес-строк; миллисекунды и число операторов не зашиты в тест.
Границы и ошибки
В MySQL 8.4 используем явное FORMAT=TREE: это работает независимо от установленного explain_format. ANALYZE действительно выполняет запрос; не запускайте неизвестную тяжёлую команду только ради просмотра плана. Здесь исключительно ограниченный SELECT на собственной базе. Нагрузочные выводы требуют представительных данных и повторяемой методики, которой этот урок не подменяет.
Полная самостоятельная программа и файлы
<?php
declare(strict_types=1);
require __DIR__.'/ix114-helper.php';
$db=ix114Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$prefix='mysql114_index_'.bin2hex(random_bytes(8));
$orders=$prefix.'_orders'; $customers=$prefix.'_customers';
$ordersCreated=false; $customersCreated=false;
$details=[];
try {
ix114CreateOrders($db,$orders); $ordersCreated=true;
ix114Seed($db,$orders);
$ot=ix114Name($orders);
$ct=ix114Name($customers);
$db->exec("CREATE TABLE $ct (id INT UNSIGNED NOT NULL PRIMARY KEY,name VARCHAR(40) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci");
$customersCreated=true;
$insert=$db->prepare("INSERT INTO $ct VALUES (?,?)");
foreach ([[1,'Анна'],[2,'Борис'],[3,'Вера'],[4,'Глеб'],[5,'Дина']] as $item) {$insert->execute($item);} $insert->closeCursor();
$db->exec("CREATE INDEX report_page ON $ot (tenant_id,status,id,customer_id,amount_cents)");
$sql="SELECT o.id,c.name,o.amount_cents FROM $ot AS o JOIN $ct AS c ON c.id=o.customer_id WHERE o.tenant_id=? AND o.status=? ORDER BY o.id LIMIT 4";
$params=[3,'paid']; $found=ix114Rows($db,$sql,$params); $rows=[];
foreach ($found as $row) {$rows[]=[(int)$row['id'],$row['name'],(int)$row['amount_cents']];}
$estimatedRows=ix114Rows($db,'EXPLAIN FORMAT=TREE '.$sql,$params);
$actualRows=ix114Rows($db,'EXPLAIN ANALYZE FORMAT=TREE '.$sql,$params);
$estimated=implode("\n",array_map(fn(array $row)=>(string)array_values($row)[0],$estimatedRows));
$actual=implode("\n",array_map(fn(array $row)=>(string)array_values($row)[0],$actualRows));
$checks=['four_orders_correct'=>ix114Ids($found)===[121,124,127,130],
'names_follow_customer_ids'=>array_column($found,'name')===['Борис','Дина','Вера','Анна'],
'estimates_reported'=>str_contains($estimated,'rows='),
'actual_iteration_reported'=>str_contains($actual,'actual time=') && str_contains($actual,'rows=') && str_contains($actual,'loops='),
'analysis_did_not_change_data'=>(int)ix114Rows($db,"SELECT COUNT(*) AS total FROM $ot")[0]['total']===480 && (int)ix114Rows($db,"SELECT COUNT(*) AS total FROM $ct")[0]['total']===5];
$details=['Обычный EXPLAIN TREE: оценка'=>$estimated,'EXPLAIN ANALYZE TREE: фактическое выполнение'=>$actual,
'Индекс report_page'=>ix114Index($db,$orders,'report_page'),
'Как читать'=>'Каждый отступ — оператор плана. rows до actual — оценка; actual rows — строки, выданные этим оператором за проход; loops — число его проходов. Не складывайте строки разных уровней как уникальные заказы.'];
} finally {
if ($db->inTransaction()) {$db->rollBack();}
if ($ordersCreated) {$db->exec('DROP TABLE '.ix114Name($orders));}
if ($customersCreated) {$db->exec('DROP TABLE '.ix114Name($customers));}
}
$remaining=ix114Rows($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?)',[$orders,$customers]);
$checks['owned_tables_removed']=(int)$remaining[0]['total']===0;
$db=null;
ix114Report('mysql114-index-analyze-iterators', ['Заказ', 'Покупатель', 'Сумма, коп.'], $rows, $checks, $runtime, $details);
echo 'mysql114-index-analyze-iterators: saved result.html and result.json
';
Ожидаемый результат
mysql114-index-analyze-iterators: saved result.html and result.json
Откройте result.html: сначала результат для человека, затем фактические планы и метаданные. Каждый ZIP самодостаточен; другие примеры перед ним запускать не нужно.
Открыть фактический HTML-результат с планами · Скачать таблицу JSON
Попробуйте сами. Замените LIMIT 4 на LIMIT 2. На экране останутся заказы 121 и 124. Откройте новое дерево: верхний LIMIT должен вернуть две строки, но нижний оператор мог обработать больше, особенно если есть сортировка. Не меняйте ожидаемые значения plans на придуманные числа — сравните реальные отчёты.
Практический вопрос: Что означают actual rows и loops при JOIN с LIMIT. Автор видел одну выходную строку в нескольких операторах и не понимал, сколько строк участвовало в соединении с LIMIT. Наш отчёт отдельно показывает четыре бизнес-строки и оба реальных дерева, объясняя уровень оператора и повторные проходы. Обсуждение даёт исходную проблему; технические правила сверены по официальным источникам, программа и объяснения написаны отдельно.
Следующий шаг: менять схему с проверкой результата
Мы научились объяснять индекс через запрос и читать его фактический план. Теперь важно безопасно вводить изменения: проверить исходную схему, выполнить ограниченный шаг, проверить данные и понять, как восстановиться. Эту последовательность продолжает глава об изменениях схемы MySQL. Для связей и выдачи нескольких экранов используйте реляционную модель и связанные данные для интерфейсов.
Источники и обсуждения
- MySQL 8.4: multiple-column indexes
- MySQL 8.4: range optimization
- MySQL 8.4: ORDER BY optimization
- MySQL 8.4: clustered and secondary InnoDB indexes
- MySQL 8.4: EXPLAIN output columns
- MySQL 8.4: invisible indexes
- MySQL 8.4: EXPLAIN and EXPLAIN ANALYZE
- MySQL engineering: interpreting EXPLAIN ANALYZE loops
- MySQL 8.4: CREATE INDEX and functional key parts
- PHP: PDO::prepare
- PHP: PDO::connect
- PHP: PDO_MYSQL and Pdo\Mysql
Код и руководство с собой
Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.
Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.
Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.


