Чек-лист: мягкое удаление и уникальные индексы в PostgreSQL
Если запись помечена удалённой, можно ли занять её email или slug повторно? В PostgreSQL 16 это решает частичный уникальный индекс, но перенос существующей схемы, восстановление строк и гонки при записи требуют отдельной проверки.

После мягкого удаления пользователя бизнес обычно хочет разрешить регистрацию нового аккаунта с тем же email. Но обычный UNIQUE продолжает учитывать удалённую строку, а проверка только в приложении не защитит от двух параллельных запросов. Для PostgreSQL 16 надёжный вариант — частичный уникальный индекс по активным записям; перед продакшеном важно проверить миграцию, восстановление и точное правило сравнения значений.
Сначала зафиксируйте, что значит «удалён»
В примерах ниже активной считается строка с deleted_at IS NULL, а удалённой — строка с заполненным временем удаления. Это не просто способ скрыть запись из интерфейса: именно условие активности определяет, какие строки индекс обязан считать уникальными.
Для почты часто нужна уникальность без учёта регистра. В PostgreSQL 16 это можно задать индексом по выражению:
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
deleted_at timestamptz
);
CREATE UNIQUE INDEX users_active_email_uq
ON users (lower(email))
WHERE deleted_at IS NULL;
Теперь для активных пользователей значения Sam@example.com и sam@example.com конфликтуют, а удалённые строки в проверку не попадают. Если регистр важен для вашего идентификатора, используйте обычный индекс по email, а не lower(email). Пробелы и другие правила нормализации индекс сам не угадывает: задайте их явно и одинаково применяйте при записи и поиске.
Частичный индекс — индекс, который включает только строки, удовлетворяющие условию WHERE. В данном случае это активные записи; «частичный» не означает, что проверка уникальности работает лишь иногда.
Мягкое удаление не освобождает уникальное значение само по себе. Его освобождает только правило индекса, которое исключает удалённую строку.
Выберите способ, который действительно обеспечивает уникальность
У подходов разная цена. Для обычной модели «активные записи уникальны, удалённые сохраняются» частичный индекс наиболее прямо выражает правило и проверяет его на уровне базы.
| Подход | Что гарантирует | Цена и ограничение |
|---|---|---|
| Частичный UNIQUE INDEX по активным строкам | Уникальность только там, где deleted_at IS NULL; проверку выполняет PostgreSQL при записи. | Условие активности должно совпадать с моделью приложения. Нельзя использовать такой индекс как обычное ограничение UNIQUE с произвольной ссылкой из внешнего ключа. |
UNIQUE по паре (email, deleted_at) | Не обеспечивает нужную уникальность активных строк в PostgreSQL: по умолчанию NULL не равен NULL для проверки уникальности. | Можно получить несколько строк с одинаковым email и NULL в deleted_at. Внешне решение выглядит компактным, но правило не соблюдает. |
| Проверка в приложении перед INSERT | Может дать понятную ошибку пользователю до записи. | Два параллельных запроса способны одновременно пройти проверку. Без ограничения в базе гонка может создать дубликат. |
Чек-лист перед выкладкой
- Проверьте модель данных. Убедитесь, что активность везде означает одно и то же: например, только
deleted_at IS NULL. Если часть кода использует флагis_deleted, а другая — дату, зафиксируйте, какое поле является источником истины. - Найдите уже существующие дубликаты. До создания индекса выполните проверку ниже. Если запрос вернул строки, сначала разберите каждую группу и определите, какую запись оставить активной. Проверка по
lower(email)должна совпадать с выражением индекса. - Добавьте индекс и проверьте его в окружении, близком к продакшену. Для тестовых данных повторите вставку с одинаковым email в разном регистре: вторая активная запись должна завершиться ошибкой уникальности. Удалённая запись с таким email не должна блокировать создание новой.
- Проверьте все пути записи. Создание, импорт, API, фоновые задачи и админские операции должны обрабатывать конфликт от базы. Предварительный SELECT полезен для текста ошибки, но не заменяет индекс.
- Пройдите сценарий восстановления. Если старую строку снова сделать активной, а email уже занят новой записью, обновление должно быть отклонено. Решите заранее, что увидит оператор или пользователь: отказ, смену email или ручное объединение данных.
- Проверьте план миграции и отката. Создание индекса на большой таблице может влиять на запись и потребовать времени. Для индекса, создаваемого конкурентно, учитывайте ограничения PostgreSQL на запуск команды и предусмотрите проверку результата, если операция завершилась ошибкой.
Поиск конфликтов до миграции:
SELECT lower(email) AS normalized_email, count(*) AS active_rows
FROM users
WHERE deleted_at IS NULL
GROUP BY lower(email)
HAVING count(*) > 1;
Если таблица уже защищена обычным UNIQUE по email, не удаляйте его первым шагом: иначе между удалением старого ограничения и появлением нового правила возможны дубликаты. Сначала создайте и проверьте частичный индекс, затем отдельной миграцией удалите старое ограничение. Имя ограничения зависит от вашей схемы — сначала найдите его в каталоге PostgreSQL, не копируйте имя из примера вслепую.
Что ломается на практике
Восстановление записи неожиданно падает
Это ожидаемое следствие правила, а не дефект индекса. Если новый аккаунт уже занял email, восстановление старого нарушит уникальность активных строк. Не обходите ошибку отключением индекса в приложении: определите бизнес-сценарий и покажите конфликт как отдельный результат операции.
Индекс не создаётся из-за дубликатов
Сначала разберите результат запроса поиска конфликтов. Не удаляйте строки автоматически по принципу «оставить первую»: связанные заказы, права доступа и история могут принадлежать разным записям. Зафиксируйте решение, затем повторите миграцию.
Предварительная проверка пропускает гонку
Запрос вида «есть ли уже такой email» не резервирует значение. Два процесса могут получить ответ «нет» до того, как любой из них выполнит INSERT. Обрабатывайте нарушение уникальности от PostgreSQL и преобразуйте его в ожидаемую ошибку домена. Именно база остаётся последней линией защиты.
Условие запроса не совпадает с условием индекса
Планировщик PostgreSQL использует частичный индекс, когда может доказать, что условие запроса подразумевает предикат индекса. Для поиска активных пользователей явно добавляйте deleted_at IS NULL; не рассчитывайте, что планировщик поймёт произвольное эквивалентное выражение или бизнес-логику из приложения.
Что настроить дальше
Перед выпуском проверьте сценарии создания двух активных записей, повторного использования значения после удаления и восстановления старой строки. В продакшене наблюдайте за ошибками уникальности и временем миграции. Если таблица большая, отрепетируйте создание индекса на копии с сопоставимым объёмом данных; для конкурентного создания изучите ограничения PostgreSQL 16 и предусмотрите проверку, не остался ли после сбоя недействительный индекс. Следующий отдельный вопрос — как именно приложение фильтрует удалённые строки: частичный индекс обеспечивает уникальность, но не скрывает записи из запросов автоматически.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: PostgreSQL 16: частичные индексы, PostgreSQL 16: ограничения целостности данных
Частые вопросы
Можно ли просто добавить deleted_at в обычный UNIQUE?
Что произойдёт, если восстановить удалённую запись, а её email уже занят?
Достаточно ли проверять email запросом перед сохранением?
Что делать, если индекс не создаётся?
Подойдёт ли этот подход для PostgreSQL 15 или 17?
Безопасно ли создавать индекс на большой рабочей таблице?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.