Эффективные индексы разделов: управление и производительность

Раздел: Управление контентом -> Разделы PHP

Методы индексации разделов в 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
// ...
Обеспечивает изоляцию между разными наборами разделов.

Индексы разделов PHP - comments

En
Indices php section (php)