Многие знают о технике мягкого удаления. Это когда записи не удаляются физически из базы, а помечаются флажком is_deleted или выставляется время deleted_at. Удобно, что ничего не удаляется, не нужно воевать с внешними ключами и ограничением целостности. Самое главное — если что-то пошло не так, то снял флажок, и данные на месте.

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

Начнем с того, что удаленные записи отнимают место на диске. За несколько лет их набегает порядочное количество. Удаленные записи по-прежнему участвуют в индексах, и они тоже занимают место, замедляют обход. В Постгресе есть функциональные индексы когда запись попадает только при условии NOT is_deleted или deleted_at IS NULL. Но чаще всего этим не заморачиваются, и в индекс валится все.

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

    email        | is_deleted
-----------------|-------
ivan@grishaev.me | false
ivan@grishaev.me | true

могут спокойно сосуществовать. Но восстановить вторую запись нельзя, потому что она вступит в конфликт с первой. Спрашивается: зачем мы ее храним? Что с ней можно сделать? Ничего. Это просто балласт.

Что касается запросов, то разработчики часто забывают условие WHERE NOT is_deleted, и в выборке оказываются удаленные данные.

А в другом проекте было по-другому: проблему удаления решали переносом в другие таблицы. Например, для таблицы messages создается ее аналог messages_deleted. Запись переносится атомарно таким запросом:

with deleted as (delete from messages where id = 42 returning *)
insert into messages_deleted select * from deleted

Перенос обратно:

with deleted as (delete from messages_deleted where id = 42 returning *)
insert into messages select * from deleted

Есть и другой вариант этого запроса без выражения WITH; можно сделать это триггером, хоть я и не люблю их. Важно, что запрос выполняется атомарно даже если не заключен в транзакцию. При этом запись переходит между таблицами на стороне сервера. Она даже не приходит клиенту.

На первый взгляд это неудобно, потому что нужны лишние таблицы. Но как замечательно это оказалось на практике! В основной таблице только актуальные данные, никаких флажков и предикатов. Выбирая что-то из messages, ты на 100% уверен, что нет риска выбрать что-то не то. Данные всегда в чистоте. Если однажды мы решим, что удаленные сообщения не нужны, мы грохнем таблицу messages_deleted без риска задеть живые данные.

С тех пор я придерживаюсь переноса, если это возможно.

Принцип “я ничего не удаляю” аналогичен принципу “я ничего не выбрасываю”. С ним база, простите меня, засирается. И хотя перенос данных не значит их удаление, он очищает рабочую область — а это крайне важно.