Как стать автором
Обновить
465.75
OTUS
Цифровые навыки от ведущих экспертов

Проблема мягкого удаления (deleted_at) и как её решить

Уровень сложностиПростой
Время на прочтение4 мин
Количество просмотров6.4K

Привет, Хабр!

В этой статье рассмотрим классическую проблему «мягкого удаления» на уровне схемы баз данных и её влияние на аналитику.

Почти в каждой системе встречается требование «не удалять данные окончательно». Причины разные:

  • Юридические: нужно хранить следы операций для аудита.

  • Бизнесовые: надо «откатывать» удалённые записи.

  • 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
);

При загрузке:

  1. Для новых записей — вставляем с valid_from = NOW(), valid_to = NULL.

  2. Для изменённых — ищем is_current = TRUE, обновляем его valid_to = NOW(), is_current = FALSE, и вставляем новую версию.

  3. Для «мягко удалённых» — аналогично обновляем 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. Они помогут расширить горизонты в области системного анализа:

Теги:
Хабы:
+8
Комментарии18

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS