Привет, Хабр!
В этой статье рассмотрим классическую проблему «мягкого удаления» на уровне схемы баз данных и её влияние на аналитику.
Почти в каждой системе встречается требование «не удалять данные окончательно». Причины разные:
- Юридические: нужно хранить следы операций для аудита. 
- Бизнесовые: надо «откатывать» удалённые записи. 
- UX: в интерфейсе показывать корзину, откуда можно восстановить элемент. 
Самый простой способ — добавить в таблицу поле deleted_at TIMESTAMP NULL и при «удалении» записывать туда текущую метку времени. Всё остальное — при выборке добавляете WHERE deleted_at IS NULL. Казалось бы — дело в шляпе. Но на практике происходит пару проблемок.
Что происходит с JOIN’ами при наличии soft delete
Пример схемы
-- Таблица пользователей
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    deleted_at TIMESTAMP NULL
);
-- Таблица заказов
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INT NOT NULL REFERENCES users(id),
    amount NUMERIC(10,2) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    deleted_at TIMESTAMP NULL
);Если вы неаккуратно пишете JOIN:
SELECT
    u.id   AS user_id,
    u.name AS user_name,
    o.id   AS order_id,
    o.amount
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.deleted_at IS NULL;мы фильтруем только пользователей. Всё удалённые заказы останутся в выборке, потому что у них своё deleted_at. В результате запись пользователя без активности выпадет, а заказы удалённого пользователя по-прежнему попадут в отчёт.
Если фильтр на каждый JOIN забыть, столбцы могут расти на удалённых сущностях.
Всегда дублируйте условие для каждой таблицы:
FROM users u
JOIN orders o
  ON o.user_id = u.id
  AND o.deleted_at IS NULL
WHERE u.deleted_at IS NULL;Как моделировать удаление
Audit log через INSERT-триггеры
Если нужно сохранить полное «что было в момент T», заводите отдельную таблицу аудита.
-- Таблица аудита
CREATE TABLE users_audit (
    audit_id SERIAL PRIMARY KEY,
    id INT,
    email TEXT,
    name TEXT,
    deleted_at TIMESTAMP NULL,
    changed_at TIMESTAMP NOT NULL DEFAULT NOW(),
    operation CHAR(1) NOT NULL  -- I = insert, U = update, D = delete
);
-- Триггер на users
CREATE OR REPLACE FUNCTION users_audit_trigger() RETURNS trigger AS $$
BEGIN
  IF (TG_OP = 'INSERT') THEN
    INSERT INTO users_audit (id, email, name, deleted_at, operation)
    VALUES (NEW.id, NEW.email, NEW.name, NEW.deleted_at, 'I');
  ELSIF (TG_OP = 'UPDATE') THEN
    INSERT INTO users_audit (id, email, name, deleted_at, operation)
    VALUES (NEW.id, NEW.email, NEW.name, NEW.deleted_at, 'U');
  ELSIF (TG_OP = 'DELETE') THEN
    INSERT INTO users_audit (id, email, name, deleted_at, operation)
    VALUES (OLD.id, OLD.email, OLD.name, OLD.deleted_at, 'D');
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_users_audit
  AFTER INSERT OR UPDATE OR DELETE
  ON users
  FOR EACH ROW EXECUTE PROCEDURE users_audit_trigger();SCD — Type 2
Если вы строите хранилище данных по Kimball-методологии, SCD Type 2 поможет сохранять историю до и после изменений, включая удаление.
CREATE TABLE dim_users (
    user_key SERIAL PRIMARY KEY,
    user_id INT NOT NULL,
    email TEXT,
    name TEXT,
    valid_from TIMESTAMP NOT NULL,
    valid_to   TIMESTAMP NULL,  -- NULL означает «до сих пор валидно»
    is_current BOOLEAN NOT NULL DEFAULT TRUE
);При загрузке:
- Для новых записей — вставляем с - valid_from = NOW(),- valid_to = NULL.
- Для изменённых — ищем - is_current = TRUE, обновляем его- valid_to = NOW(),- is_current = FALSE, и вставляем новую версию.
- Для «мягко удалённых» — аналогично обновляем - valid_to,- is_current = FALSE.
Так отчётность по времени аккуратно покажет, какие записи существовали на любой момент. SCD удобно автоматизировать в dbt или Airflow.
Построение витрины с фильтрацией по живым записям
Предположим, хочется построить витрину активных пользователей с суммой заказов. Для скорости — воспользуемся materialized view.
CREATE MATERIALIZED VIEW active_user_orders AS
SELECT
  u.id        AS user_id,
  u.email,
  u.name,
  SUM(o.amount)::NUMERIC(14,2) AS total_amount,
  COUNT(o.id)               AS orders_count,
  MAX(o.created_at)         AS last_order_at
FROM users u
JOIN orders o
  ON o.user_id = u.id
  AND o.deleted_at IS NULL
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.email, u.name;Чтобы view всегда было актуальным, настраиваем simple cron:
# crontab -e
*/5 * * * * psql -d analytics -c "REFRESH MATERIALIZED VIEW CONCURRENTLY active_user_orders;"Или интегрируем в Airflow:
from airflow import DAG
from airflow.providers.postgres.operators.postgres import PostgresOperator
from datetime import datetime, timedelta
with DAG(
    dag_id="refresh_active_user_orders",
    schedule_interval="*/5 * * * *",
    start_date=datetime(2025, 5, 20),
    catchup=False,
) as dag:
    refresh = PostgresOperator(
        task_id="refresh_mv",
        postgres_conn_id="analytics_db",
        sql="REFRESH MATERIALIZED VIEW CONCURRENTLY active_user_orders;",
    )Тепер любой отчёт подтягиваете только живых пользователей и их заказы.
Когда стоит использовать партиции по deleted_at
Если таблицы сотни миллионов строк и мягко удалённых записей становится существенно больше, разумно организовать партицию по диапазону deleted_at:
-- Основная таблица с включённой партиционной логикой
CREATE TABLE orders (
    id SERIAL,
    user_id INT,
    amount NUMERIC(10,2),
    created_at TIMESTAMP NOT NULL,
    deleted_at TIMESTAMP NULL
) PARTITION BY RANGE (deleted_at);
-- Партиция «активные» (deleted_at IS NULL)
CREATE TABLE orders_active
  PARTITION OF orders
  FOR VALUES FROM (MINVALUE) TO ('infinity')
  WHERE deleted_at IS NULL;
-- Партиции для удалённых: например, ежемесячные архивы
CREATE TABLE orders_deleted_2025_04
  PARTITION OF orders
  FOR VALUES FROM ('2025-04-01') TO ('2025-05-01');Все это дело ускоряет DELETE, т.к фактически вы просто меняете партицию или очищаете её. Фильтрация живых записей быстрее — индекс скан не идёт по всем партициям.
Подведем итоги
Soft delete — удобная штука, но требует дисциплины: фильтруйте deleted_at IS NULL в каждом JOIN.
Для тех, кто заинтересован в развитии своих профессиональных навыков и углубленном понимании ключевых процессов, рекомендую к посещению несколько открытых уроков от Otus. Они помогут расширить горизонты в области системного анализа:
Комментарии (20)
 - Kwisatz20.05.2025 20:48- Вы же в курсе что null не индексируется, правда? - Посему тут должна быть статья про is_deleted = false как явный или генерируемый столбец. - А так же про индексы с where is_deleted=false, например уникальные. - Чтобы view всегда было актуальным, настраиваем simple cron: - Жесть... во первых чтобы оно ВСЕГДА было актуальным, пересчитывать надо при заключении заказов, во вторых лучше не пересчитывать всю таблицу (матвью) а иметь отдельную таблицу агрегатов - Всегда дублируйте условие для каждой таблицы: - Чушь редкостная. Так вы порушите логическую целостность, которую софтдел как раз призван сохранить. - PS - Для тех, кто заинтересован в развитии своих профессиональных навыков - До уровня статьи? Крутая реклама....  - vlad4kr720.05.2025 20:48- А еще бывает нужно soft delete И уникальность. - Тогда приходится делать - is_deleted bool default false,- или - is_active bool default true- и включать соответствующую колонку в uniq constraint  - Kwisatz20.05.2025 20:48- не нужно. Есть такая штука, называется частичный индекс. - create unique index users_email_uindex on acl.users (email) where is_deleted=false;- если же ваша субд не поддерживает такое то есть вот такая хитрость - create unique index users_email_uindex on acl.users ((case when is_deleted=false then email else null end));- Тут мы используем функциональный индекс и тот факт что null не индексируется - Тем более что включение is_deleted/is_active в индекс проблему не решает, стоит, например, два раза подряд удалить пользователя лицо с одним и тем же почтовым адресом. - PS - Автору же я хотел намекнуть что все реестры удаленных объектов, все джоины, где действительно важно выбирать неудаленные сущности, у него будут работать без индексов. А уж какое веселье начнется если сюда еще SCD добавить: тут ни в сказке сказать, ни пером описать... 
 
  - erogov20.05.2025 20:48- Чушь редкостная - Истинная правда. Но - Вы же в курсе что null не индексируется - в Постгресе как раз индексируются.  - Kwisatz20.05.2025 20:48- Благодарю,узнал много всяких прикольных штук. Однако, вот буквально только что читал что is [not] null не использует индексы, остальное прямо вот сяду изучать на выходных, много интересных штук пропустил. 
 
  - cupraer20.05.2025 20:48- А что не так с созданием рядом таблицы - DeletedUsersкоторая будет точной копией- Usersи куда записи будут попадать из триггера на удаление? View тогда будет просто юнионом, если он вдруг часто нужен. - Kwisatz20.05.2025 20:48- Не так тот факт что вам надо ссылаться на любых пользователей, удалены они или нет, а вот если у вас рядом таблица со всеми версиями то другое дело но ключи сложнее да и зачем если есть частичные индексы ? 
  - avvensis20.05.2025 20:48- Идея с DeletedUsers и VIEW через UNION кажется простой и элегантной, но на практике влечёт за собой немало сложностей. - Если у пользователя есть связанные сущности — заказы, активности, сообщения — то при его удалении (и переносе в DeletedUsers) придётся переносить и их. Иначе нарушится целостность (внешние ключи, каскадные удаления), да и восстановить данные будет невозможно. Получается, нужно создавать DeletedOrders, DeletedMessages, DeletedWhatever — с теми же колонками и логикой. С ростом модели количество «зеркальных» таблиц быстро удваивает схему. - А самое главное — восстановление данных перестаёт быть надёжным. Например: - Не получится отличить заказы, удалённые пользователем вручную, от тех, что удалились каскадно; 
- Восстанавливать нужно уже кодом, не через триггеры, строго в порядке каскадного удаления; 
- Могут нарушиться уникальные индексы или внешние ключи при восстановлении; 
- Придётся придумывать идентификатор удаления наподобие request_id в микросервисах, чтобы связывать удалённые группы записей между собой. Сделать доступным такой request_id во всех триггерах удаления это нетривиальная задача; 
 - В итоге «простое» удаление с переносом в Deleted* может превратиться в мину замедленного действия, потому что успех восстановления будет зависеть от конкретных данных в таблицах и связей между ними. Тестировать такие ситуации будет сложно. - При этом остаются и другие минусы: дублирование схем, необходимость синхронизации структуры таблиц, рост количества сущностей. Всё это усложняет сопровождение. - В большинстве случаев soft delete (is_deleted, deleted_at, deleted_by и пр.) — это куда более управляемый и безопасный подход. 
 
  - Gromilo20.05.2025 20:48- Можно использовать частичный индекс, в котором будут только не удалённые записи. + можно на него навешать уникальность, если нужно. 
 
 - Mur46620.05.2025 20:48- Использование партиции как раз замедлит удаление, потому что приводит к копированию записи в новую партицию и удалению записи из старой партиции. С точки зрения скорости запроса с join, у которой ведущая таблица users, никакого улучшения не будет (если в плане используется доступ nested loops). То есть для данного конкретного отчёта из примера партиционирование вредно. - Плюс ошибки с пониманием работы inner join и оперативности обновления matview, на которые уже указали выше. - И в конце предложение кого-то чему-то научить на открытых уроках. Может стоит самим вернуться к RTFM?  - Fragster20.05.2025 20:48- Удаление замедлит, но, кажется, чтение происходит намного чаще? Случаи, конечно, разные бывают, но в основном так.  - Mur46620.05.2025 20:48- Автор обосновывает партиционирование именно ускорением удаления: "Все это дело ускоряет - DELETE". А это с точностью до наоборот. Если нет других причин, то зачем городить партиционирование? Оно имеет как плюсы, так и минусы, поэтому должны быть явные причины. Просто потому что "много записей" - это еще не аргумент.- Допустим мы используем партиционирование по дате для таблиц которые по сути своей являются логами. То есть данные в них имеют ценность ограниченное время и не имеют на себя ссылок в виде внешних ключей. Партиционирование позволяет дешево очищать логи за прошлые периоды, так как drop партиции целиком гораздо дешевле, чем удаление всех записей в таблице или партиции. Это я сейчас про Oracle, может в Postgres свои особенности. - В OLAP-базах можно использовать партиционирование для оптимизации запросов. Но в примере автора юзер-заказ имеется ввиду OLTP система. Там партиционирование для оптимизации запросов реже бывает полезно, так как запросы в основном идут по юзеру, по заказу, а не по дате за большой период (месяцы). А по дате за малый период (день) индекс по дате будет эффективнее.  - Fragster20.05.2025 20:48- Да, в данном случае аргументация автора немного странная. Вообще партиции это решение уже для следующего этапа роста, когда ну совсем много данных. В большинстве случаев частичного индекса должно хватить. Иногда можно добавить несколько полей без упорядочивания для того, чтобы он стал в частых запросах покрывающим. 
 
 
 
 - surly20.05.2025 20:48- Чтобы не забыть отбросить удалённые данные, создайте view для каждой таблицы, включив в него условие «deleted_at IS NULL». Когда в приложении требуются только актуальные данные, используйте эти представления вместо таблиц. - Непонятно, за чем введён дополнительный столбец is_active. Достаточно проверки по условию «valid_to IS NULL». 
 
           
 

kretuk
что у вас за странная СУБД такая, что этот запрос вам заказы удалённого пользователя?