Выбор типа каталога для PHP-приложения

Раздел: Разработка каталогов на PHP -> Конфигурация каталога

Типы структур для хранения иерархических каталогов

В разработке каталогов на PHP (товары, категории) одна из ключевых задач - выбор способа хранения иерархии. От этого зависит производительность чтения/записи, сложность запросов и масштабируемость. Рассмотрим основные типы с примерами и детальным разбором.

Какой тип каталога обеспечивает наилучший баланс производительности и гибкости?

Наиболее эффективным решением для большинства современных веб-приложений является Closure Table (таблица замыканий). Он сочетает быстрые выборки поддерева, предков и глубины с приемлемым объёмом данных и простотой поддержки целостности.

-- Таблица категорий (узлов)
CREATE TABLE categories (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL
);

-- Таблица замыканий (предок -> потомок)
CREATE TABLE category_closure (
    ancestor_id INT UNSIGNED NOT NULL,
    descendant_id INT UNSIGNED NOT NULL,
    depth INT UNSIGNED NOT NULL DEFAULT 0,
    PRIMARY KEY (ancestor_id, descendant_id),
    FOREIGN KEY (ancestor_id) REFERENCES categories(id) ON DELETE CASCADE,
    FOREIGN KEY (descendant_id) REFERENCES categories(id) ON DELETE CASCADE
);

Catalog php type (тип каталога php)

-- Пример вставки корневого узла 'Электроника'
INSERT INTO categories (name) VALUES ('Электроника');
SET @new_id = LAST_INSERT_ID();
INSERT INTO category_closure (ancestor_id, descendant_id, depth)
VALUES (@new_id, @new_id, 0);

-- Вставка дочернего узла 'Телефоны' под 'Электроника' (id=1)
INSERT INTO categories (name) VALUES ('Телефоны');
SET @child_id = LAST_INSERT_ID();
INSERT INTO category_closure (ancestor_id, descendant_id, depth)
SELECT ancestor_id, @child_id, depth+1
FROM category_closure
WHERE descendant_id = 1
UNION ALL
SELECT @child_id, @child_id, 0;
-- Выбор всех потомков 'Электроника' (id=1)
SELECT c.*
FROM categories c
JOIN category_closure cc ON c.id = cc.descendant_id
WHERE cc.ancestor_id = 1;
-- Выбор предков 'Телефоны' (id=2)
SELECT c.*
FROM categories c
JOIN category_closure cc ON c.id = cc.ancestor_id
WHERE cc.descendant_id = 2
ORDER BY cc.depth DESC;

Типичная ошибка: не включать связь узла с самим собой (depth=0). Это ломает выборку предков и получение корня. Решение: всегда добавлять self-relation при вставке любого узла.

Проблема: объём таблицы замыканий быстро растёт (O(n^2) в худшем случае). Для каталогов с тысячами узлов это приемлемо; для миллионов - нужна оптимизация (архивация, шардинг).

Как реализовать простейший тип каталога (Adjacency List)?

Adjacency List - самый интуитивный тип: каждый узел хранит ссылку на родителя. Подходит для небольших каталогов (до нескольких сотен узлов) с редкими обновлениями.

CREATE TABLE categories (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    parent_id INT UNSIGNED NULL,
    FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE CASCADE
);

-- Вставка корня
INSERT INTO categories (name) VALUES ('Электроника');
-- Вставка подкатегории
INSERT INTO categories (name, parent_id) VALUES ('Ноутбуки', 1);
-- Рекурсивный CTE для получения всех потомков (MySQL 8+)
WITH RECURSIVE cte AS (
    SELECT id, name, parent_id FROM categories WHERE id = 1
    UNION ALL
    SELECT c.id, c.name, c.parent_id
    FROM categories c JOIN cte ON c.parent_id = cte.id
)
SELECT * FROM cte;

Ошибка: в MySQL до версии 8 нет рекурсивных запросов - приходится делать много запросов в PHP (n+1 проблема) или использовать хранимые процедуры. Решение: обновиться до MySQL 8+ или перейти на Closure Table.

Проблема: удаление ветки каскадно может оставить сирот, если внешний ключ не настроен. Рекомендуется ON DELETE CASCADE.

Какую структуру выбрать для быстрого чтения поддерева (Nested Sets)?

Nested Sets (вложенные множества) хранят для каждого узла left и right ключи, что позволяет одним запросом получить всё поддерево. Идеально для каталогов, где чтение значительно преобладает над записью.

CREATE TABLE categories (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    lft INT UNSIGNED NOT NULL,
    rgt INT UNSIGNED NOT NULL
);

-- Вставка корня (lft=1, rgt=2)
INSERT INTO categories (name, lft, rgt) VALUES ('Электроника', 1, 2);
-- Вставка дочерней категории 'Телефоны' после корня
-- Необходимо сначала сдвинуть правые ключи последующих узлов
UPDATE categories SET rgt = rgt + 2 WHERE rgt >= 2;
UPDATE categories SET lft = lft + 2 WHERE lft > 2;
INSERT INTO categories (name, lft, rgt) VALUES ('Телефоны', 2, 3);
-- Получение всех потомков 'Электроника' (lft=1, rgt=6)
SELECT * FROM categories WHERE lft >= 1 AND rgt <= 6 ORDER BY lft;

Ошибка: при вставке/удалении нужно пересчитывать lft и rgt для многих узлов - это блокирует таблицу. Решение: использовать транзакции и временное отключение проверок внешних ключей.

Проблема: сложность в поддержке при частых изменениях. Nested Sets неудобен для каталогов, где категории активно перестраиваются.

Какой тип каталога подходит для работы с URL-путями (Path Enumeration)?

Path Enumeration хранит для каждого узла полный путь от корня в виде строки (например, '1/4/7/'). Удобен для построения хлебных крошек и URL, но неудобен для перемещения узлов.

CREATE TABLE categories (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    path VARCHAR(255) NOT NULL
);

-- Вставка корня
INSERT INTO categories (name, path) VALUES ('Электроника', '1/');
-- Вставка подкатегории 'Телефоны' под корень
INSERT INTO categories (name, path) VALUES ('Телефоны', '1/2/');
-- Получение всех потомков категории с id=1
SELECT * FROM categories WHERE path LIKE '1/%';

Ошибка: при перемещении узла нужно обновлять path у всех потомков - строковые операции медленные и подвержены ошибкам. Решение: добавить триггер или ограничить перемещения.

Проблема: длина строки может превысить лимит при глубокой вложенности. Используйте VARCHAR(1000) или TEXT.

Расширенные примеры и сценарии

Closure Table: полная реализация с функциями на PHP

Пример на PHP (PDO) для работы с Closure Table.

Пример
class CategoryManager {
    private PDO $pdo;

    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }

    public function addCategory(string $name, ?int $parentId = null): int {
        $this->pdo->beginTransaction();
        try {
            // Вставка узла
            $stmt = $this->pdo->prepare('INSERT INTO categories (name) VALUES (?)');
            $stmt->execute([$name]);
            $newId = (int)$this->pdo->lastInsertId();

            // Вставка замыкания для самого себя
            if ($parentId === null) {
                // Корень
                $stmt = $this->pdo->prepare('INSERT INTO category_closure (ancestor_id, descendant_id, depth) VALUES (?,?,0)');
                $stmt->execute([$newId, $newId]);
            } else {
                // Копируем замыкания родителя и добавляем себя
                $stmt = $this->pdo->prepare('
                    INSERT INTO category_closure (ancestor_id, descendant_id, depth)
                    SELECT ancestor_id, ?, depth+1
                    FROM category_closure
                    WHERE descendant_id = ?
                    UNION ALL
                    SELECT ?, ?, 0
                ');
                $stmt->execute([$newId, $parentId, $newId, $newId]);
            }
            $this->pdo->commit();
            return $newId;
        } catch (Exception $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    public function getSubtree(int $id): array {
        $stmt = $this->pdo->prepare('
            SELECT c.*, cc.depth
            FROM categories c
            JOIN category_closure cc ON c.id = cc.descendant_id
            WHERE cc.ancestor_id = ?
            ORDER BY cc.depth, c.name
        ');
        $stmt->execute([$id]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function moveSubtree(int $nodeId, ?int $newParentId): void {
        // Перемещение поддерева: удалить старые замыкания, добавить новые
        $this->pdo->beginTransaction();
        try {
            // Удаляем все связи, где данный узел или его потомки являются потомками
            $stmt = $this->pdo->prepare('
                DELETE FROM category_closure
                WHERE descendant_id IN (
                    SELECT descendant_id FROM category_closure WHERE ancestor_id = ?
                )
                AND ancestor_id IN (
                    SELECT ancestor_id FROM category_closure WHERE descendant_id = ? AND ancestor_id != ?
                )
            ');
            $stmt->execute([$nodeId, $nodeId, $nodeId]);

            // Вставляем новые замыкания от нового родителя до всех потомков узла
            $stmt = $this->pdo->prepare('
                INSERT INTO category_closure (ancestor_id, descendant_id, depth)
                SELECT super.ancestor_id, sub.descendant_id, super.depth + sub.depth + 1
                FROM category_closure AS super
                CROSS JOIN category_closure AS sub
                WHERE super.descendant_id = ?
                AND sub.ancestor_id = ?
            ');
            $stmt->execute([$newParentId, $nodeId]);
            $this->pdo->commit();
        } catch (Exception $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }
}

Adjacency List: получение предков без CTE (старый MySQL)

Если MySQL < 8, можно использовать множественные запросы в цикле PHP:

Пример
function getAncestors(PDO $pdo, int $nodeId): array {
    $ancestors = [];
    $currentId = $nodeId;
    while ($currentId !== null) {
        $stmt = $pdo->prepare('SELECT id, name, parent_id FROM categories WHERE id = ?');
        $stmt->execute([$currentId]);
        $node = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$node) break;
        $ancestors[] = $node;
        $currentId = $node['parent_id'];
    }
    return array_reverse($ancestors);
}

// Пример использования:
$ancestors = getAncestors($pdo, 5);
print_r($ancestors);
Array
(
    [0] => Array
        (
            [id] => 1
            [name] => Электроника
            [parent_id] => 
        )
    [1] => Array
        (
            [id] => 3
            [name] => Компьютеры
            [parent_id] => 1
        )
    [2] => Array
        (
            [id] => 5
            [name] => Ноутбуки
            [parent_id] => 3
        )
)

Nested Sets: вставка с блокировкой и пересчётом

Пример хранимой процедуры для MySQL (осторожно, блокирует таблицу):

Пример
DELIMITER //
CREATE PROCEDURE insert_category(IN p_name VARCHAR(255), IN p_parent_lft INT)
BEGIN
    DECLARE v_right INT;
    -- Находим правый ключ родителя
    SELECT rgt INTO v_right FROM categories WHERE lft = p_parent_lft;
    -- Сдвигаем узлы
    UPDATE categories SET rgt = rgt + 2 WHERE rgt >= v_right;
    UPDATE categories SET lft = lft + 2 WHERE lft > v_right;
    -- Вставляем новую категорию
    INSERT INTO categories (name, lft, rgt) VALUES (p_name, v_right, v_right + 1);
END //
DELIMITER ;

-- Вызов: CALL insert_category('Смартфоны', 1);

Path Enumeration: обновление путей при удалении узла

Пример триггера для автоматического обновления потомков:

Пример
CREATE TRIGGER `after_delete_category`
AFTER DELETE ON `categories` FOR EACH ROW
BEGIN
    UPDATE categories
    SET path = REPLACE(path, CONCAT(OLD.path, OLD.id, '/'), '')
    WHERE path LIKE CONCAT(OLD.path, OLD.id, '/%');
END;
-- После удаления категории 'Компьютеры' (id=3, path='1/3/')
-- Путь '1/3/5/' станет '1/5/' - это может сломать иерархию. Нужно аккуратно.
-- Лучше использовать триггер BEFORE DELETE для пересчёта, либо запретить удаление нелистовых узлов.

Сравнение производительности (пример с 10 000 категорий)

ТипЧтение поддереваВставка одного узлаПеремещение
Adjacency List (CTE)~3 ms~0.5 ms~2 ms
Nested Sets~0.3 ms~50 ms (пересчёт)~100 ms
Closure Table~1 ms~2 ms (вставка + замыкания)~20 ms
Path Enumeration~5 ms~0.8 ms~200 ms (строковые операции)

Тип каталога PHP - comments

En
Catalog php type (php)