Зачем нужен SAVEPOINT
В прошлом уроке мы увидели жёсткое правило: любая ошибка переводит транзакцию в aborted state. Все последующие команды отбрасываются, единственный выход — ROLLBACK всей транзакции.
Но иногда это слишком грубо. Представь: ты импортируешь 1000 строк, и одна из них нарушила CHECK. Откатить всю транзакцию — потерять 999 валидных строк. Хочется откатить только сбойную строку и продолжить.
Для таких случаев есть SAVEPOINT — именованная точка внутри транзакции, к которой можно вернуться. Это что-то вроде «закладки в книге»: ты ставишь её, читаешь дальше, и если что — возвращаешься на закладку, не закрывая саму книгу.
Слева: всё откатывается, надо начинать заново. Справа: откатывается только часть после savepoint, остальное в силе.
Синтаксис
Команд три:
SAVEPOINT <name>;— установить закладку с именем<name>.ROLLBACK TO SAVEPOINT <name>;— вернуться к закладке. Все изменения после неё откатываются, но сама транзакция продолжает работать.RELEASE SAVEPOINT <name>;— удалить закладку. Изменения после неё остаются в транзакции, просто эту точку больше нельзя использовать.
RELEASE SAVEPOINT нужен не обязательно — все savepoints автоматически удаляются при COMMIT или ROLLBACK всей транзакции. Но при долгих транзакциях с сотнями savepoints полезно явно их освобождать — каждый savepoint занимает память и слегка замедляет работу.
Базовый паттерн: попробовать опасную операцию, откатиться к savepoint при ошибке
Восстановление транзакции после ошибки
Главное практическое применение savepoint — восстановить транзакцию после ошибки, не откатывая всё.
В обычной транзакции после ошибки все следующие команды возвращают «current transaction is aborted, commands ignored until end of transaction block». Но если есть savepoint до ошибки, то ROLLBACK TO SAVEPOINT возвращает транзакцию в нормальное состояние — можно продолжать.
ROLLBACK TO SAVEPOINT снимает aborted state и позволяет продолжить транзакцию
Видишь? Первый UPDATE прошёл. INSERT упал. После ROLLBACK TO SAVEPOINT транзакция «ожила», и второй UPDATE тоже прошёл. Если бы мы сделали COMMIT вместо ROLLBACK в конце — оба UPDATE бы зафиксировались.
Pattern: «попробовать N операций, откатить только проблемные»
Классический сценарий — массовый импорт с пропуском плохих строк.
Импорт с savepoint на каждую строку — откатываем только сбойные
В реальности эту логику обычно делает не SQL, а драйвер — psycopg, pg, JDBC. Каждая строка превращается в SAVEPOINT row_N; INSERT ...; RELEASE row_N; (или ROLLBACK TO + логирование ошибки). Так работают ORM-уровневые try/except: внутри Python try ORM ставит savepoint, в except — делает ROLLBACK TO. Без этого транзакция в Django/SQLAlchemy после первой же ошибки перестала бы работать.
Savepoints — не вложенные транзакции
Один из частых источников путаницы: savepoints не дают «настоящих» вложенных транзакций. Вот в чём разница.
Если бы вложенные транзакции были «настоящими», внутренняя могла бы закоммититься независимо от внешней — то есть «нижние изменения сохранены, верхние ещё нет». Это поведение в PostgreSQL невозможно — есть только одна реальная транзакция, и она либо вся commit’ится, либо вся откатывается.
Savepoint — это только точка отката. RELEASE SAVEPOINT не «коммитит» подтранзакцию — он просто говорит «эта точка больше не нужна, изменения остаются в общей транзакции». Если потом откатывается основная транзакция — откатываются и все «успешно RELEASE’нутые» изменения.
Это означает, что если вашему приложению нужна истинная вложенная транзакция (commit’нуть часть, оставив остальное живым) — savepoints не подойдут. Нужно архитектурно разделить на две независимые транзакции, например, через очередь сообщений: «первая транзакция commit’ится, посылает событие; вторая обрабатывает событие в своей транзакции».
Вложенные savepoints
Savepoints можно вкладывать друг в друга. Имена должны быть уникальны в рамках одной транзакции — повторное использование имени не запрещено, но «затеняет» предыдущее.
Вложенные savepoints — каждый со своей точкой возврата
Это называется «nested transactions» в маркетинге, но строго говоря — это просто иерархия savepoints в рамках одной плоской транзакции. Настоящих вложенных транзакций в стандарте SQL нет.
Стоимость savepoint
Savepoint — не бесплатный. PostgreSQL создаёт
pg_subtrans, занимает место в памяти. На паре десятков savepoints это незаметно, на десятках тысяч — может замедлить транзакцию.
Поэтому правило: savepoints — для обработки ошибок и намеренного частичного отката, не для «давайте на всякий случай поставим savepoint после каждой команды». В типичной транзакции их 0–5.
Когда savepoint не помогает
Savepoint работает только внутри одной транзакции. Если транзакция уже сделала COMMIT, откатить её через savepoint нельзя — COMMIT финален.
Также savepoint не защищает от ошибок уровня соединения (потеря TCP, kill -9 клиента). При обрыве соединения PostgreSQL откатывает всю незакомиченную транзакцию целиком, включая все savepoints.
RELEASE SAVEPOINT vs ROLLBACK TO
Две похожие команды, легко перепутать. Разница:
RELEASE SAVEPOINT sp— забываем точку, сохраняя все изменения после неё. Закладку убрали, страницы остались.ROLLBACK TO SAVEPOINT sp— возвращаемся к точке, откатывая все изменения после. Закладка остаётся (можно к ней снова откатиться, пока не RELEASE).
Большинство случаев — это RELEASE после успешной операции и ROLLBACK TO при ошибке. Это то, что делают ORM в try/except-блоках.
RELEASE — успех, закладку убираем, изменения остаются. ROLLBACK TO — неудача, откатываем всё после закладки.
Как это работает в популярных ORM
Реальная польза savepoints — даже не SQL-разработчику, а backend-инженеру. Когда ORM делает try/except вокруг группы запросов, под капотом он использует savepoint.
В Django есть transaction.atomic() — этот контекст-менеджер устанавливает savepoint и при exception откатывает к нему. Это означает, что можно вкладывать atomic() друг в друга, и каждый внутренний может откатиться, не разрушая внешний.
with transaction.atomic():
# внешняя транзакция или внешний savepoint
user = create_user(...)
try:
with transaction.atomic():
# внутренний savepoint
send_welcome_email(user)
except EmailError:
# внутренний savepoint откачен, но user остался
log_email_failure(user)
# commit внешней транзакции — user сохраняется в базу
В SQLAlchemy аналог — session.begin_nested(). Внутренний контекст-менеджер ставит savepoint, исключение делает ROLLBACK TO, успех — RELEASE.
Знание savepoints позволяет понимать, почему такие конструкции работают и где их использовать. В SQL это часто пишется руками, в ORM — это часть API.
Производительность savepoint
Сделаем замер мысленно. Когда ты ставишь savepoint, PostgreSQL:
- Назначает subtransaction свой
xid(отдельный, но из общей последовательности с обычными транзакциями). - Сохраняет map subtransaction → parent transaction в
pg_subtrans. - При чтении строк проверяет visibility с учётом текущей subtransaction.
На паре десятков savepoints это незаметно. На тысячах — особенно если они вложенные — начинают расти затраты на проверку visibility (нужно пройти по цепочке savepoints). Также если транзакция использует больше чем PGPROC_MAX_CACHED_SUBXIDS = 64 subtransactions, переключается на disk-resident map и каждый visibility check становится медленнее.
Правило большого пальца: savepoints — для логически разных стадий или для обработки ошибок, не для «каждой команды». Если делаешь bulk-import — используй COPY и обрабатывай ошибки на уровне приложения, а не savepoint на каждую строку.
Чек-лист
SAVEPOINT <name>— закладка внутри транзакции; можно поставить много.ROLLBACK TO SAVEPOINT <name>— откатить всё, сделанное после закладки; транзакция остаётся живой, можно продолжать.RELEASE SAVEPOINT <name>— удалить закладку, но сохранить изменения после.- Главный кейс — обработка ошибок: после ошибки внутри транзакции
ROLLBACK TOснимает aborted state. - Savepoints позволяют ORM реализовать try/except на уровне базы.
- Savepoints стоят память; не злоупотребляй — типично 0–5 на транзакцию.
- Savepoint работает только внутри одной транзакции — после
COMMITоткатить уже нельзя.