PostgreSQL CookbookCRUD-беглостьUPDATE/DELETE безопасно
0 / 63 (0%)

UPDATE/DELETE безопасно

У тебя на экране — UPDATE по заказам. Ботыр разворачивается на стуле, не дожидаясь Enter.

Ботыр
Пятница, вечер, UPDATE по orders. Мой самый дорогой инцидент начинался ровно так.
Тот самый? Одна строка?
Ботыр
Одна. UPDATE orders SET status = 'cancelled' — а WHERE остался в голове. Ни одной ошибки — и все заказы Brew отменены. Все. Это было до твоего прихода: звонки из кофейни, откат из бэкапа до полуночи.
Дмитрий
Запрос был синтаксически безупречен — в этом и ужас. База сделала ровно то, что попросили.
А как увидеть масштаб до того, как всё станет навсегда?
Дмитрий
Между командой и «навсегда» бывает окно. Сегодня разберём, как его открывать.
Ботыр
С тех пор пальцы жмут BEGIN раньше, чем голова закончит мысль. Дорогая привычка — в смысле, дорого досталась.

Цель юнита — привычки, которые превращают такую ошибку из катастрофы в опечатку: знать масштаб изменения (сколько строк задето и какие) и делать рискованную запись внутри транзакции — её можно откатить, пока не убедишься, что задето то, что нужно.

Масштаб: RETURNING и RowsAffected

UPDATE и DELETE, как и INSERT, поддерживают RETURNING (см. 03-01). На записи это особенно ценно: UPDATE ... RETURNING отдаёт ровно те строки, которые изменились — не «сколько-то», а конкретный список. Если ждал, что задеты три позиции кофе, а вернулось 300 строк — что-то пошло не так, и ты видишь это сразу.

Когда сами строки не нужны, достаточно их числа. Драйвер возвращает CommandTag с количеством затронутых строк; в sqlc запрос с суффиксом :execrows отдаёт это число прямо как int64. RowsAffected — это и есть «радиус поражения» команды: 1 — поправили одну строку, число размером со всю таблицу — забыли WHERE.

Безопасность: транзакция как сеть безопасности

Знать масштаб мало, если изменение уже на диске. Поэтому рискованную запись оборачивают в транзакцию: BEGIN, команда, проверка (RowsAffected/RETURNING), и только потом COMMIT — или ROLLBACK, если число не то. До COMMIT изменения не видны другим сессиям и не зафиксированы; ROLLBACK возвращает всё как было, будто команды не было.

В Go это pool.Begin(ctx) → tx; сгенерированные sqlc-методы привязываются к транзакции через queries.WithTx(tx), так что все запросы внутри идут в одной транзакции. tx.Rollback(ctx) откатывает. Удобный приём — defer tx.Rollback(ctx) сразу после Begin: если функция выйдет раньше (ошибка, паника), транзакция гарантированно откатится; явный Commit/Rollback ниже просто решает её судьбу.

Окно отмены: таймлайн транзакции

BEGIN открывает «черновик»: команда выполняется, но до COMMIT её не видит никто и на диск она не легла. Между командой и COMMIT есть окно, где можно посмотреть масштаб и передумать:

plaintext
BEGIN  ── «черновик»: изменения не на диске и не видны другим сессиям
  │
  ▼
UPDATE price_lab SET price = price + 50      ← нет WHERE: задело ВСЮ таблицу
  │
  ▼
проверка: RowsAffected = 5                   ← ждали 3, пришло 5 — что-то не так
  │
  ├──────────────┬──────────────────
  ▼              ▼
ROLLBACK       COMMIT
строк как      изменения зафиксированы,
не бывало      видны всем
(наш выбор)

До COMMIT чужие сессии видят прежние данные; ROLLBACK возвращает всё как было, будто команды не случилось. Это окно и есть сеть безопасности: забытый WHERE в нём — наблюдение, а не катастрофа.

Что показывает наш код

Запросы в query.sql — это два «масштаба» и одна катастрофа. Целевой UPDATE с RETURNING:

sql
-- name: RaiseCategory :many
UPDATE price_lab SET price = price + sqlc.arg(delta)
WHERE category = sqlc.arg(category)
RETURNING id, name, price;

И «забытый WHERE» — UPDATE без условия и DELETE, оба в форме :execrows (возвращают число строк):

sql
-- name: RaiseAll :execrows
UPDATE price_lab SET price = price + sqlc.arg(delta);     -- нет WHERE → вся таблица
-- name: DeleteCategory :execrows
DELETE FROM price_lab WHERE category = sqlc.arg(category);

В main.go мы засеваем лабораторную таблицу price_lab (5 строк), делаем безопасный целевой UPDATE (видим 3 изменённые строки через RETURNING), а затем внутри транзакции исполняем «забытый WHERE» и DELETE, печатаем их RowsAffected — и делаем ROLLBACK. После отката состояние таблицы — ровно как до катастрофы.

Запуск

Подними песочницу (из корня репозитория) и накати схему Brew + таблицу юнита:

sh
docker compose up -d
make lecture L=03-crud-fluency/03-03-update-delete-safely T=db-reset
make lecture L=03-crud-fluency/03-03-update-delete-safely

(T=run — значение по умолчанию. Изнутри каталога юнита это make db-reset, make run.)

Вывод:

plaintext
1) price_lab засеян (5 строк):
   #1 Эспрессо coffee 3.00
   #2 Капучино coffee 4.50
   #3 Латте coffee 4.80
   #4 Колд брю cold 5.20
   #5 Зелёный чай tea 2.50
 
2) Целевой UPDATE ... WHERE category='coffee' SET price+=50, RETURNING изменённое:
   #1 Эспрессо 3.50
   #2 Капучино 5.00
   #3 Латте 5.30
   (RETURNING показал ровно 3 затронутые строки)
 
3) «Забыл WHERE» внутри транзакции — смотрим масштаб и откатываем:
   UPDATE без WHERE затронул бы строк: 5 (вся таблица!)
   DELETE WHERE category='coffee' затронул бы строк: 3
   → ROLLBACK: ни одно изменение не применено.
 
4) Состояние после ROLLBACK — как в шаге 2 (5 строк, кофе +50, остальное нетронуто):
   #1 Эспрессо coffee 3.50
   #2 Капучино coffee 5.00
   #3 Латте coffee 5.30
   #4 Колд брю cold 5.20
   #5 Зелёный чай tea 2.50

Целевой UPDATE поднял цену трёх кофе и вернул именно эти три строки. «Забытый WHERE» внутри транзакции показал свой масштаб — 5 строк под UPDATE, 3 под DELETE — но ROLLBACK отменил всё: в шаге 4 видно, что задеты только три кофе из шага 2, остальное цело. Транзакция превратила инцидент в наблюдение.

Заборчик

Здесь мы откатываем катастрофу сами, потому что заранее знаем, что она случится. В проде забытый WHERE заметен не всегда — поэтому полагаются не на бдительность, а на барьеры:

  • Барьеры вместо внимательности. Ревью миграций и запись-скриптов, прогон на staging, а для интерактивного psql — режим без авто-коммита (\set AUTOCOMMIT off, и тогда каждый UPDATE/DELETE ждёт явного COMMIT).
  • Масштаб у нас игрушечный. price_lab крошечная, и UPDATE/DELETE по всей таблице мгновенны; на большой таблице массовая запись — это ещё и долгая блокировка строк (другие транзакции ждут) и распухание (UPDATE в MVCC создаёт новые версии строк), о чём — модуль 05 и VACUUM.
  • RETURNING на массовой записи — это трафик. Он притащит все изменённые строки в приложение; на миллионе строк берут :execrows (только число) или правят пачками.
  • Опасный DELETE часто заменяют «мягким удалением» (deleted_at-флаг), чтобы данные можно было вернуть.

Что забрать с собой

  • UPDATE/DELETE без WHERE задевают всю таблицу — и делают это без ошибок; база выполняет ровно то, что просили.
  • Всегда знай масштаб: RETURNING показывает, какие именно строки задеты; :execrows (RowsAffected) — сколько.
  • Рискованную запись делай внутри транзакции: BEGIN → команда → проверка → COMMIT/ROLLBACK. Забытый WHERE тогда откатывается.
  • В Go: pool.Begin → queries.WithTx(tx) → tx.Commit/tx.Rollback; defer tx.Rollback(ctx) сразу после Begin — страховка от раннего выхода.
  • RETURNING на массовой записи тащит все строки в приложение — для счёта бери :execrows.

Дальше — юнит 03-04 «upsert через ON CONFLICT»: научимся «вставить или обновить» одной командой — идиома для синхронизации справочников и счётчиков, где строки то появляются, то меняются.

·Модуль 04

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
CRUD-беглость / UPDATE/DELETE безопасно