Ситуация обычная: пришёл запрос на удаление персональных данных, разработчик выполнил DELETE FROM clients WHERE id = ..., отчитался. Формально всё правильно — строки в таблице нет, приложение её не видит, выгрузка не содержит.

А в файле базы она есть. Целиком, вместе с именем и номером карты, и достаётся обычным grep.

Показываю на SQLite

Создаём базу, кладём двести записей, удаляем часть.

import sqlite3

c = sqlite3.connect('base.db')
c.execute('pragma secure_delete = 0')
c.execute('create table clients(id integer, name text, card text)')
for i in range(1, 201):
    c.execute('insert into clients values (?,?,?)',
              (i, f'Клиент №{i} Иванов Пётр', f'4276 1600 0000 {i:04d}'))
c.commit()
c.execute('delete from clients where id between 5 and 50')
c.commit()

Проверяем, что удалилось:

строк в таблице: 154

Теперь ищем удалённое прямо в файле:

grep -a -c 'Клиент №7 Иванов Пётр' base.db
grep -a -c '4276 1600 0000 0007' base.db
1
1

Строки нет в таблице, а данные лежат в файле. DELETE помечает страницу как свободную для повторного использования, но не затирает её содержимое. Пока эту страницу не займут новые данные, там остаётся всё: имя, карта, что угодно.

От чего зависит результат: режим secure_delete

Строка pragma secure_delete = 0 стоит в примере не для красоты — именно она определяет, что произойдёт со страницей при удалении.

У SQLite есть два режима работы с освобождаемым местом. При secure_delete = 0 страница помечается свободной, содержимое остаётся нетронутым до момента, когда её займут новые данные. При secure_delete = 1 освобождаемая область заполняется нулями сразу.

Разница видна на том же примере. С secure_delete = 0 поиск по файлу находит удалённую запись:

'Клиент №7 Иванов Пётр'      вхождений в файле: 1
'4276 1600 0000 0007'        вхождений в файле: 1

С secure_delete = 1 тот же поиск не находит ничего.

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

Узнать текущее значение для конкретной базы:

print(sqlite3.connect('base.db').execute('pragma secure_delete').fetchone())

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

Отдельно стоит помнить, что secure_delete = 1 не отменяет ни журнала — WAL или откатного, куда прежние версии страниц попадают в ходе транзакций, — ни уже существующих резервных копий.

С другими базами то же самое, только сложнее

PostgreSQL при DELETE помечает версию строки мёртвой, физически она живёт до VACUUM. При обычном VACUUM место переиспользуется внутри файла, при VACUUM FULL файл перестраивается. Плюс есть журнал предзаписи, куда старые значения тоже попадают.

MySQL с InnoDB — аналогично: страницы освобождаются, но не затираются, плюс журналы отката и бинлог, который хранит операции с данными столько, сколько настроено.

Иначе говоря, «удалить строку» и «удалить данные» — не одно и то же ни в одной популярной СУБД.

Где это по-настоящему стреляет

Запросы на удаление персональных данных. Компания отвечает «данные удалены», имея в виду DELETE. При изъятии или утечке файла базы удалённое достаётся вместе с остальным.

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

Реплики и аналитика. Строку удалили в основной базе, а в хранилище отчётности она приехала утренней выгрузкой и осталась.

Файл, отданный вовне. Дамп SQLite-базы мобильного приложения, отправленный в поддержку для разбора, содержит всё, что пользователь когда-либо удалял.

Что с этим делать

Для SQLite — включить затирание там, где хранится чувствительное:

PRAGMA secure_delete = ON;

Плюс VACUUM после массовых удалений: он перестраивает файл и выбрасывает свободные страницы.

Для серверных СУБД — не полагаться на физическое затирание вообще, а решать задачу на уровне архитектуры:

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

Хранить меньше. Данные, которых нет, не нужно ни удалять, ни объяснять регулятору.

Держать срок хранения в самой схеме. Поле «удалить после» и регулярная задача очистки надёжнее, чем обещание удалить по запросу.

И самое простое: в процедуру обработки запроса на удаление внесите пункт про резервные копии, реплики и аналитические выгрузки. Формально ответ «удалено» без этого пункта неверен, а проверить это может кто угодно, у кого окажется файл базы.

Комментарии (6)


  1. cpud47
    27.08.2026 16:53

    secure_delete это миф. И даже шифрование не всегда спасает. Если вам реально важно удаление пд, то это большая инженерная и аналитическая работа. Никаких волшебных палочек здесь нет.


    1. DAlexis
      27.08.2026 16:53

      А почему не спасает шифрование? Из-за сложной инфраструктуры работы с ключами такого шифрования?


      1. cpud47
        27.08.2026 16:53

        А здесь довольно много разных факторов. Перечислю их безотносительно их критичности.

        Для начала встаёт вопрос а что именно шифровать. Шифровать всё затея не лучшая (и непонятно что делать с джоинами). В идеале шифровать только собственно пд. Но тогда проблема в том, что пользователи любят указывать свои пд в условных комментариях или описаниях.

        Дальше вопрос а как часто эти пд расшифровуются. Если на каждый запрос, то встаёт риск, что они куда-нибудь утекут после расшифровки: в логи, при ошибке, в другой микросервис, во внешнюю систему, в отчёт и прочее. Чтобы их расшифровывать пореже нужно аккуратно понять, а что именно шифровать, что нет. И дальше нужно очень аккуратно писать код, чтобы он мог работать с шифрованными данными.

        Аналогичный вопрос, что с пд происходит до шифрования. Здесь тоже полно мест для утечки.

        Ну и сама работа с ключами тоже весёлая. Ключи могут утечь из памяти в своп или кордамп. ВМ могут заснапшотить с памятью — тогда ключи можно достать из снапшота. К счастью, в логи обычно ключи не пишут (хотя бывает =().

        Плюс ключи ведь нужно где-то хранить, в отдельном микросервисе например. И этот микросервис тоже нужно бэкапить. В нём тоже нужно как-то реализовывать физическое удаление и всё прочее.

        С бэкапами тоже интересно. По-хорошему бэкапы должны быть иммутабельными и храниться где-нибудь отдельно. В частности их могут хранить на каком-нибудь (внешнем) s3 — а как оно там мутируется и удаляется никто не знает. Ну и при мутации бэкапов есть риск сломать его...

        Здесь также есть всякая экзотика, что даже перезапись данных нулями не гарантирует их полного удаления. WAL может довольно долго не транкатиться (особенно в условном mssql). Есть всякие CoW фс по типу btrfs, которые при перезаписе создадут копию. А при прямом доступе к диску, бывает можно восстановить часть данных даже после перезаписи. Например всякий page redirect на ssd.

        Короче говоря, рисков очень много разных. И самый главный вопрос: "а оно Вам надо"? ** Чаще всего удаление ПД подразумевает "к данным нельзя обратиться штатными средствами и они не используются при штатной обработке". В частности, формально даже soft delete является корректной мерой. Поэтому меры против такой вот археологии нужно отдельно обосновывать в рамках Вашей модели угроз.

        **не является юридическим советом, проконсультируйтесь с юристом.

        P.S. Я уже не говорю о том что шифрование, а тем более шифрование в бд довольно сложное (легко сделать неправильно). Многие программисты могут просто захардкодить ключ шифрования...


  1. r_o_m_k_o_l_a
    27.08.2026 16:53

    Про копии и реплики — самое больное место, и обычно про него вспоминают последним.

    У нас в августе был внутренний аудит по 152-ФЗ, и удаление по запросу выглядело закрытым вопросом: есть ручка, есть DELETE, есть каскады. Дыра нашлась не в базе. Значения персональных полей уезжали в прикладные логи (мы логировали тело запроса при ошибках валидации), в дампы для отладки и в аналитическую копию, которая наливалась раз в сутки и жила своим сроком хранения. То есть после честного DELETE + VACUUM данные оставались ещё в трёх местах, ни одно из которых не считалось хранилищем персданных.

    Что помогло: сначала выписать все места, куда значение вообще может утечь по пути (лог, метрика, очередь, реплика, бэкап, отчёт), а уже потом чинить удаление. Список получился неприятно длинным, зато после него VACUUM перестал казаться решением задачи.

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


  1. gr-pr-moi-do
    27.08.2026 16:53

    Шифровать чувствительные поля отдельным ключом на запись или на клиента.

    тут что-то с падежами.

    а где хранить этот ключ, чтоб он не утекал с бэкапами?


    1. jbenderov Автор
      27.08.2026 16:53

      1) Спасибо, поправил.
      2) Главное - хранить ключ вне базы данных. Например: в системе управления секретами / KMS / клиентском хранилище. Плюс у него должен быть отдельный цикл бэкапирования и строгий аудит доступа (чтобы другие пайплайны случайно не получали к нему доступ).