MAATRIX / Блог / Право на забвение технически: удалить данные сразу из семи мест

Право на забвение технически: удалить данные сразу из семи мест

MAATRIX

Пользователь просит удалить его данные — и тут выясняется, что они лежат в основной базе, в объектном хранилище, в поисковом индексе, в кэше, в очереди событий, в логах и ещё в трёх сторонних сервисах, о которых помнит только один разработчик, уволившийся два месяца назад. Разобрать это вручную один раз можно. Гарантированно повторять для каждого запроса — нет. Ниже не юридическая консультация о том, что говорит закон о праве на забвение (это общеизвестная концепция, за деталями применения к вашему случаю — к юристу), а техническая схема, как спроектировать инфраструктуру так, чтобы запрос на удаление отрабатывал полностью и предсказуемо.

Почему обход систем вручную не работает

Классический сценарий: приходит запрос на удаление, инженер идёт в базу и делает DELETE FROM users WHERE id = ..., вспоминает про S3 с аватарками, потом про Elasticsearch, и закрывает тикет. Через полгода выясняется, что данные пользователя всё ещё возвращаются в поисковой выдаче — индекс не тронули, а ещё через год — что email продолжает получать рассылки, потому что про интеграцию с сервисом рассылок никто не вспомнил.

Проблема не в конкретном инженере, а в подходе. Ручной обход систем — это чек-лист, который держится в голове или в устаревшем вики-документе. Он ломается при любом изменении архитектуры: добавили микросервис — забыли внести в список; сменили провайдера аналитики — список ссылается на неактуальный эндпоинт; ушёл человек, который знал про legacy-таблицу с историей заказов — знание ушло вместе с ним. Надёжное удаление технически требует двух вещей: заранее составленной и поддерживаемой карты, где вообще хранятся персональные данные, и автоматизированного процесса, который проходит по этой карте целиком по одному запросу, а не по памяти конкретного человека.

Карта персональных данных: без неё гарантии нет

Карта данных (data map, иногда называют data inventory) — это реестр всех мест хранения, где может встретиться персонально идентифицирующая информация: имя, email, телефон, IP-адрес, платёжные реквизиты, поведенческие данные, привязанные к идентификатору пользователя. Что именно считается персональными данными в вашей юрисдикции — вопрос юридический, а не технический (если нужна точная классификация под конкретный регион, разумно свериться с профильной статьей о том, что считается персональными данными и при необходимости проконсультироваться с юристом). Задача инженерии — для каждого поля, которое юристы или комплаенс относят к персональным данным, знать, в каких системах оно физически лежит.

Практически карта — это структурированный документ (лучше — конфиг в репозитории, а не страница в вики, потому что конфиг проверяется тестами и код-ревью, а вики никто не обновляет). Минимальный формат для каждой записи:

# data_map.yaml
- system: postgres-main
  type: database
  connector: postgres
  tables:
    - table: users
      pii_columns: [email, phone, full_name, ip_last_login]
      key_field: id
    - table: orders
      pii_columns: [shipping_address, phone]
      key_field: user_id
  deletion_strategy: anonymize   # hard_delete | anonymize | soft_delete_then_purge

- system: s3-user-uploads
  type: object_storage
  connector: s3
  bucket: user-uploads-prod
  prefix_template: "users/{user_id}/"
  deletion_strategy: hard_delete

- system: search-index
  type: search
  connector: elasticsearch
  index: users_profiles
  key_field: user_id
  deletion_strategy: hard_delete

- system: sessions-cache
  type: cache
  connector: redis
  key_pattern: "session:{user_id}:*"
  deletion_strategy: hard_delete

- system: analytics-warehouse
  type: olap
  connector: clickhouse
  table: events
  key_field: user_id
  deletion_strategy: anonymize   # обезличивание вместо удаления строк

- system: app-logs
  type: logs
  connector: loki
  deletion_strategy: retention_only   # логи не удаляются вручную, только по TTL

- system: mail-provider
  type: saas
  connector: sendgrid_api
  deletion_strategy: hard_delete_via_api

Карта строится не «когда-нибудь потом», а как часть процесса добавления нового хранилища: ни одна система, способная содержать персональные данные, не идёт в прод без записи в data_map.yaml. Это дешевле в моменте добавления и на порядок дороже, если восстанавливать карту постфактум по коду.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Семь мест, где обычно живут данные пользователя (и восьмое — бэкапы)

В типичном веб-сервисе персональные данные почти всегда рассредоточены по одному и тому же набору мест:

  1. Основная реляционная база — таблица пользователей, заказы, обращения в поддержку, всё, что связано внешним ключом на user_id.
  2. Объектное хранилище — аватарки, загруженные документы, вложения писем, сканы для верификации.
  3. Поисковый индекс — Elasticsearch/OpenSearch/Meilisearch, куда профиль и контент пользователя реплицируются для быстрого поиска.
  4. Кэш и сессии — Redis/Memcached с сессионными токенами, временными данными профиля, счётчиками.
  5. Очереди и событийный лог — Kafka/RabbitMQ, где события с персональными данными могут физически лежать в топике до истечения retention.
  6. Аналитическое хранилище — ClickHouse, BigQuery, любой data warehouse, куда стекаются события с привязкой к пользователю.
  7. Сторонние SaaS-интеграции — сервис email-рассылок, CRM, платёжный провайдер, инструменты аналитики поведения (их персональные данные физически лежат не у вас, а удаляются через API этих сервисов).

Формально есть и восьмое место — резервные копии. Оно стоит отдельно, потому что технически, в отличие от первых семи, вы не можете гарантированно и немедленно удалить данные из уже сделанного бэкапа без нарушения целостности всей цепочки восстановления. Об этом — отдельный раздел ниже.

Единый запрос вместо обхода систем по одной

Как только карта данных существует, задача превращается в инженерную: по идентификатору пользователя пройти по всем записям карты и для каждой выполнить предписанную стратегию удаления. Это должен делать не человек кликами по консолям, а сервис-оркестратор, запускаемый по одному событию — например, DeletionRequested{user_id, requested_at}.

Практическая схема:

[API запрос на удаление]
        │
        ▼
[deletion_requests] ← таблица со статусами
        │
        ▼
[очередь задач] (RabbitMQ / SQS / pg-based job queue)
        │
        ├──▶ worker: postgres-main     → anonymize()
        ├──▶ worker: s3-user-uploads   → hard_delete()
        ├──▶ worker: search-index      → hard_delete()
        ├──▶ worker: sessions-cache    → hard_delete()
        ├──▶ worker: analytics-warehouse → anonymize()
        ├──▶ worker: mail-provider     → hard_delete_via_api()
        └──▶ worker: app-logs          → отметка "исключить из будущих выгрузок"
        │
        ▼
[все worker'ы завершили] → deletion_requests.status = 'completed'
        │
        ▼
[уведомление комплаенс/пользователю]

Таблица статуса — обязательный элемент, а не украшение. Без неё невозможно ответить на вопрос «этот запрос точно выполнен полностью, во всех системах?» — а это ровно тот вопрос, который задаст регулятор или сам пользователь, если данные где-то всплывут повторно.

CREATE TABLE deletion_requests (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id uuid NOT NULL,
    requested_at timestamptz NOT NULL DEFAULT now(),
    status text NOT NULL DEFAULT 'pending', -- pending | in_progress | completed | failed
    completed_at timestamptz
);

CREATE TABLE deletion_request_targets (
    request_id uuid REFERENCES deletion_requests(id),
    system text NOT NULL,           -- значение из data_map.yaml: system
    status text NOT NULL DEFAULT 'pending', -- pending | done | error
    attempts int NOT NULL DEFAULT 0,
    last_error text,
    updated_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (request_id, system)
);

Каждый worker идемпотентен: повторный запуск на уже удалённых данных не должен падать с ошибкой, а должен корректно завершаться (данных уже нет — цель достигнута). Это важно, потому что часть worker'ов будет ретраиться из-за сетевых сбоев или недоступности стороннего API.

Как реально удалять в каждом типе хранилища

Технически «удалить» в разных системах — разные операции, и это стоит явно закладывать в стратегию каждой записи карты.

Реляционная база. Часто правильнее не DELETE, а анонимизация: заменить email, телефон и имя на технические плейсхолдеры, сохранив строку ради целостности внешних ключей (заказы, платежи, история обращений в поддержку часто нужны для бухгалтерии и не могут просто исчезнуть).

UPDATE users
SET email = 'deleted-' || id || '@removed.local',
    full_name = 'Deleted User',
    phone = NULL,
    ip_last_login = NULL,
    deleted_at = now()
WHERE id = :user_id;

Объектное хранилище. Здесь обычно можно и нужно удалять физически — файлы не участвуют в ссылочной целостности так, как строки БД.

aws s3 rm "s3://user-uploads-prod/users/${USER_ID}/" --recursive
# или для self-hosted MinIO
mc rm --recursive --force "myminio/user-uploads-prod/users/${USER_ID}/"

Поисковый индекс. Удаление документа по ключу, отдельно от исходной базы — индекс переживёт удаление в БД, если про него забыть.

curl -X POST "http://es-host:9200/users_profiles/_delete_by_query" \
  -H 'Content-Type: application/json' \
  -d '{"query": {"term": {"user_id": "'"$USER_ID"'"}}}'

Кэш и сессии. KEYS в проде использовать нельзя — блокирует Redis целиком на большом датасете; правильный инструмент — SCAN с фильтром по паттерну и UNLINK вместо DEL (неблокирующее удаление).

redis-cli --scan --pattern "session:${USER_ID}:*" | xargs -L 100 redis-cli unlink

Очереди и событийный лог. Для Kafka с compacted-топиками удаление конкретного ключа делается tombstone-сообщением (запись с тем же ключом и пустым value) — компактор со временем физически уберёт предыдущие значения этого ключа.

Аналитическое хранилище. В ClickHouse точечное удаление строк — дорогая операция (мутация), поэтому чаще практичнее делать ALTER TABLE ... UPDATE user_id = 0, ip = '' WHERE user_id = :id (обезличивание) либо заранее проектировать таблицы так, чтобы персональные атрибуты лежали в отдельной, легко перезаписываемой dimension-таблице, а не в каждой строке фактов.

Логи. Особый случай: персональные данные в логах (IP, email в теле запроса, токены) обычно не удаляются точечно, а исключаются политикой хранения — retention/TTL в Loki или ILM в Elasticsearch, плюс — что важнее на будущее — правило вообще не писать их в лог в сыром виде. Если такого правила в проекте ещё нет, стоит сначала закрыть эту дыру — она разобрана отдельно, вместе с типичными последствиями, в статье про токены и персональные данные в логах.

Сторонние SaaS. Удаление через официальный API сервиса — у большинства email-провайдеров, CRM и аналитических платформ есть эндпоинт suppress/delete по email или user-id. Интеграцию с каждым таким сервисом стоит держать отдельным worker'ом в оркестраторе, а не ручной задачей «написать в поддержку сервиса».

Бэкапы: временное исключение, которое нужно задокументировать

Это единственное место в схеме, где немедленное и полное удаление технически недостижимо без разрушения самой идеи резервного копирования. Бэкап — это неизменяемый снимок состояния на момент создания; если начать выборочно патчить старые бэкапы под каждый запрос на удаление, вы теряете гарантию, что бэкап реально восстановит систему в то состояние, в каком она была.

Есть два рабочих технических подхода, и они не взаимоисключающие:

  1. Естественное истечение срока хранения. Если ротация бэкапов настроена разумно (скажем, ежедневные хранятся 30 дней, еженедельные — несколько месяцев), данные пользователя, удалённые из продакшена, выпадут из всех бэкапов сами по мере ротации старых копий. Это нужно явно посчитать и зафиксировать как максимальный срок, в течение которого удалённые данные всё ещё технически существуют где-то в инфраструктуре — и держать этот срок в голове (и в документе для комплаенса) как честное ограничение процесса, а не замалчивать.
  2. Crypto-shredding (криптографическое уничтожение). Если персональные данные каждого пользователя (или партиции пользователей) шифруются отдельным ключом, удаление ключа делает зашифрованные данные в бэкапе практически недоступными без физического удаления самого бэкапа. Это сложнее внедрить постфактум, но снимает проблему «данные технически ещё лежат в старом бэкапе N дней».

Этот момент стоит формализовать регламентом, а не оставлять молчаливым допущением — у нас в блоге есть отдельный разбор именно этого стыка процессов, регламент удаления данных и копии в бэкапах, с механикой ротации применительно к запросам на удаление. Оркестратору стоит фиксировать в deletion_requests не только «удалено из прод-систем», но и расчётную дату, когда последняя резервная копия с этими данными выйдет из ротации — это и есть честный ответ на вопрос «когда данных не останется вообще нигде».

Сам оркестратор удаления и журнал таких запросов — обычно отдельный небольшой сервис с постоянной нагрузкой (очередь задач, воркеры, база статусов), который разумно держать на отдельном сервере, а не подмешивать в основной прод-кластер: изоляция снижает риск, что сбой в приложении утащит за собой и подсистему комплаенс-удаления.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Нужно ли действительно физически удалять строки из базы, а не просто ставить флаг deleted?

Флаг is_deleted без последующей очистки — это не удаление, а фильтрация в интерфейсе; данные физически остаются доступны при прямом обращении к БД. Для честного выполнения запроса нужен либо hard delete, либо необратимая анонимизация конкретных полей.

Сколько времени разумно закладывать на полное выполнение запроса на удаление?

Технически зависит от количества систем и объёма данных; конкретные законодательные сроки (например, привязанные к GDPR) — вопрос к юристу, а не к инженерной оценке. Технически разумная цель — чтобы оркестратор проходил все прод-системы за минуты-часы, а не дни, оставляя дни только на подтверждённое истечение ротации бэкапов.

Что делать, если один из worker'ов оркестратора упал и не смог удалить данные в своей системе?

Таблица deletion_request_targets фиксирует отдельный статус по каждой системе со счётчиком попыток; запрос не считается выполненным, пока не закрыты все цели, а не пока не отработал первый успешный worker.

Можно ли обойтись без отдельного оркестратора, если систем всего две-три?

На старте можно скриптом по чек-листу. Но по мере роста числа интеграций ручной чек-лист теряет актуальность; лучше сразу закладывать структуру данных — таблицу статусов и карту систем, даже если исполнение первое время остаётся полуручным.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →