Эффективные индексы разделов: управление и производительность
Методы индексации разделов в PHP
Каким образом организовать быструю навигацию по иерархии разделов и получение всех подразделов за один запрос?
Эффективное решение: вложенные множества (Nested Sets).
Модель вложенных множеств хранит для каждого узла два числа: left и right. Эти числа образуют интервалы, которые охватывают всех потомков. Такой подход позволяет одним запросом получить всё поддерево, проверить принадлежность узла и отсортировать ветки.
Структура таблицы
CREATE TABLE sections (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255),
lft INT NOT NULL,
rgt INT NOT NULL,
level INT NOT NULL DEFAULT 0,
INDEX(lft, rgt)
);
Вставка корневого раздела
// первый корень
$maxRight = $db->query("SELECT COALESCE(MAX(rgt),0) FROM sections")->fetchColumn();
$lft = $maxRight + 1;
$rgt = $maxRight + 2;
$stmt = $db->prepare("INSERT INTO sections (title, lft, rgt, level) VALUES (?, ?, ?, 0)");
$stmt->execute(['Корневой раздел', $lft, $rgt]);
Получение всех потомков раздела с id=5
$node = $db->query("SELECT lft, rgt, level FROM sections WHERE id=5")->fetch();
$sql = "SELECT * FROM sections WHERE lft BETWEEN ? AND ? ORDER BY lft";
$stmt = $db->prepare($sql);
$stmt->execute([$node['lft'], $node['rgt']]);
$descendants = $stmt->fetchAll();
Типичные проблемы: при добавлении нового узла в середину дерева требуется обновлять lft/rgt многих записей, что может вызвать блокировки. Решение – выполнять обновления в транзакции и использовать MyISAM (или InnoDB с правильным порядком). Для больших деревьев применяют пакетные операции.
Цель: максимальная производительность на чтение, особенно для построения навигации, sitemap.
Как реализовать простую иерархию с помощью смежных списков (Adjacency List)?
Каждый раздел хранит ссылку на родителя через поле parent_id. Это самый наглядный способ.
CREATE TABLE sections (
id INT AUTO_INCREMENT PRIMARY KEY,
parent_id INT DEFAULT NULL,
title VARCHAR(255),
sort_order INT DEFAULT 0,
FOREIGN KEY (parent_id) REFERENCES sections(id)
);
Для получения всех подразделов используется рекурсивный запрос или рекурсия в PHP.
function getTree($parentId = null) {
global $db;
$stmt = $db->prepare("SELECT * FROM sections WHERE parent_id " . ($parentId === null ? "IS NULL" : "= ?") . " ORDER BY sort_order");
if ($parentId !== null) $stmt->execute([$parentId]);
else $stmt->execute();
$children = $stmt->fetchAll();
foreach ($children as &$child) {
$child['children'] = getTree($child['id']);
}
return $children;
}
// Вызов: $tree = getTree();
Основная проблема: при глубине дерева более 2-3 уровней рекурсия в PHP или множественные SQL-запросы (N+1) снижают производительность. Решение – кэширование результата или использование рекурсивных CTE в MySQL.
Используется для небольших деревьев, где простота важнее скорости.
Как получить путь к разделу без рекурсивных операций через материализованный путь (Materialized Path)?
В поле path хранится строка вида '1/5/12', где числа – ID предков. Для поиска всех потомков применяется условие LIKE.
CREATE TABLE sections (
id INT AUTO_INCREMENT PRIMARY KEY,
path VARCHAR(255) NOT NULL DEFAULT '',
title VARCHAR(255)
);
// Вставка дочернего раздела
$parentPath = '1/5';
$childPath = $parentPath . '/' . $newId;
$db->exec("INSERT INTO sections (title, path) VALUES ('Подраздел', '$childPath')");
// Получение всех потомков раздела с id=5
$stmt = $db->prepare("SELECT * FROM sections WHERE path LIKE ? OR path = ?");
$stmt->execute(['1/5/%', '1/5']);
$descendants = $stmt->fetchAll();
Проблема: при перемещении узла нужно обновлять path для всего поддерева. Длина пути ограничена VARCHAR. Решение – использовать TEXT и индексы FULLTEXT или хранить путь в виде массива в отдельной таблице.
Подходит для систем, где дерево редко изменяется, а чтение часто.
Как задать порядок разделов одного уровня через числовой индекс?
Поле sort_order (или position) определяет порядок среди одноуровневых разделов. Это простейшая форма индексации.
CREATE TABLE sections (
id INT AUTO_INCREMENT PRIMARY KEY,
parent_id INT DEFAULT NULL,
title VARCHAR(255),
sort_order INT DEFAULT 0
);
// Получение разделов с сортировкой
$stmt = $db->query("SELECT * FROM sections WHERE parent_id IS NULL ORDER BY sort_order, id");
Проблема: необходимо поддерживать уникальность sort_order при вставке/перемещении. Решение – вычислять значение как MAX+1 или использовать дробные числа.
Вариант для плоских списков или когда иерархия не глубокая.
Как ускорить доступ к данным раздела по ID с помощью PHP-массива?
Избранные данные всех разделов загружаются в ассоциативный массив, где ключ – ID раздела.
$sections = $db->query("SELECT id, parent_id, title FROM sections")->fetchAll(PDO::FETCH_ASSOC);
$indexed = [];
foreach ($sections as $row) {
$indexed[$row['id']] = $row;
}
// Теперь быстрый доступ по ID
$section = $indexed[42] ?? null;
Для построения дерева можно построить также индекс по parent_id.
$childrenIndex = [];
foreach ($sections as $row) {
$childrenIndex[$row['parent_id']][] = $row['id'];
}
function buildTree($parentId = null) {
global $indexed, $childrenIndex;
$result = [];
if (isset($childrenIndex[$parentId])) {
foreach ($childrenIndex[$parentId] as $id) {
$node = $indexed[$id];
$node['children'] = buildTree($id);
$result[] = $node;
}
}
return $result;
}
Проблема: при изменении данных в БД массив устаревает. Решение – использовать кэш (Redis, Memcached) с инвалидацией.
Применяется в приложениях с высокой нагрузкой на чтение, где данные обновляются редко.
Расширенные примеры работы с индексами разделов.
Пример 1: Nested Sets – добавление дочернего узла (сложная операция)
При добавлении нового подраздела к существующему нужно раздвинуть правые границы всех узлов, которые находятся справа от вставляемого.
// Предположим, добавляем к узлу с id=5 (lft=10, rgt=20)
$db->beginTransaction();
// Сдвигаем вправо lft и rgt для всех узлов, у которых lft > 20
$db->exec("UPDATE sections SET lft = lft + 2 WHERE lft > 20");
$db->exec("UPDATE sections SET rgt = rgt + 2 WHERE rgt >= 20");
// Вставляем новый узел с lft=20, rgt=21, level = level предка + 1
$stmt = $db->prepare("INSERT INTO sections (title, lft, rgt, level) VALUES (?, ?, ?, ?)");
$level = $db->query("SELECT level FROM sections WHERE id=5")->fetchColumn() + 1;
$stmt->execute(['Новый раздел', 20, 21, $level]);
$db->commit();
Результат: все узлы, которые были правее вставки, сдвинулись, новый узел стал последним потомком узла 5.
Пример 2: Adjacency List с рекурсивным CTE (MySQL 8+)
Использование SQL-запроса для получения полного дерева:
WITH RECURSIVE cte AS (
SELECT id, parent_id, title, 1 AS depth FROM sections WHERE parent_id IS NULL
UNION ALL
SELECT s.id, s.parent_id, s.title, cte.depth + 1
FROM sections s
JOIN cte ON s.parent_id = cte.id
)
SELECT * FROM cte ORDER BY depth, id;
Возвращает все узлы с глубиной, упорядоченные по уровню вложенности.
Пример 3: Materialized Path – обновление пути при перемещении узла
Если узел переносится к другому родителю, нужно обновить path для всего поддерева.
function moveNode($nodeId, $newParentId) {
global $db;
// получаем старый путь узла
$oldPath = $db->query("SELECT path FROM sections WHERE id=$nodeId")->fetchColumn();
$newPath = $db->query("SELECT path FROM sections WHERE id=$newParentId")->fetchColumn();
$newPath .= '/' . $nodeId;
// обновляем все пути, начинающиеся со старого
$db->prepare("UPDATE sections SET path = CONCAT(?, SUBSTRING(path, ?)) WHERE path = ? OR path LIKE ?")
->execute([$newPath, strlen($oldPath)+1, $oldPath, $oldPath . '/%']);
}
После выполнения все подразделы получат корректные пути.
Пример 4: Комбинированная индексация – использование двух методов
Хранить parent_id для простоты и lft/rgt для быстрых чтений. Синхронизация выполняется триггером или в PHP.
CREATE TABLE sections (
id INT AUTO_INCREMENT PRIMARY KEY,
parent_id INT,
lft INT,
rgt INT,
title VARCHAR(255)
);
// Вычисляем lft/rgt по parent_id (например, после загрузки)
function rebuildNestedSet() {
// рекурсивно обходим дерево и устанавливаем lft/rgt
}
Этот подход используется в фреймворках (например, Laravel Nested Set через пакет).
Пример 5: Индексация разделов с поддержкой множественных деревьев
Когда есть несколько независимых деревьев разделов (например, для разных сайтов).
CREATE TABLE sections (
id INT AUTO_INCREMENT PRIMARY KEY,
tree_id INT NOT NULL, -- идентификатор дерева
parent_id INT,
lft INT,
rgt INT,
title VARCHAR(255)
);
// запросы с дополнительным условием по tree_id
// ...
Обеспечивает изоляцию между разными наборами разделов.