Как реализовать удаление записи без каскадного удаления при глубокой иерархии таблиц
Что проверяют
Умеете ли вы удалить узел глубокой иерархии осознанно: понимать, что станет с потомками, сколько строк заденет операция и как не положить базу долгой транзакцией. Следом обычно спрашивают про мягкое удаление и про то, как это выглядит в вашем ORM.
Как отвечать
Сначала уточните требование, от него зависит решение:
- потомки должны остаться: внешний ключ с
ON DELETE SET NULLили перепривязка к другому родителю в той же транзакции. Если связи обязательны, удаление запрещают черезON DELETE RESTRICT, а в Django черезon_delete=models.PROTECT; - нужно удалить всё поддерево, но каскад запрещён: собираете идентификаторы рекурсивным запросом
WITH RECURSIVEи удаляете снизу вверх, от листьев к корню, в одной транзакции; - данные нельзя терять: мягкое удаление, колонка
deleted_at, частичный индексWHERE deleted_at IS NULLи фильтр по умолчанию в менеджере модели или в запросах SQLAlchemy.
Для больших поддеревьев удаляйте пачками по несколько тысяч строк с коммитом после каждой. Одна транзакция на миллион строк держит блокировки, раздувает WAL и мешает автовакууму.
Упомяните особенности инструментов. Django по умолчанию эмулирует CASCADE в Python: собирает зависимые объекты, шлёт сигналы pre_delete и post_delete, потом удаляет. Если на моделях есть сигналы, на глубокой иерархии это множество запросов и все объекты в памяти. В SQLAlchemy похожая разница между cascade="all, delete-orphan" на relationship и passive_deletes=True, когда работу отдают базе.
Где спотыкаются
Предлагают DELETE ... WHERE NOT EXISTS (SELECT 1 FROM child ...). Такой запрос просто не удалит родителя с потомками, задача остаётся нерешённой, и проверяет он только один уровень вложенности.
Забывают про индексы. PostgreSQL не создаёт индекс на ссылающейся колонке внешнего ключа автоматически, и удаление каждого родителя превращается в полный просмотр дочерней таблицы.
Не думают о гонках: пока вы собирали поддерево, другой процесс добавил нового потомка. Удаление падает на ограничении или оставляет сироту, если ограничения нет. Помогает блокировка корня через SELECT ... FOR UPDATE перед сбором.