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

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

Самый дорогой инцидент в работе с базой пишется в одну строку: UPDATE orders SET status = 'cancelled' — и кто-то забыл WHERE id = 42. Команда отработала без единой ошибки и пометила отменёнными все заказы Brew. DELETE FROM customers без условия — то же самое, только хуже. База сделала ровно то, что попросили; беда в том, что попросили не то.

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

Масштаб: 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.Beginqueries.WithTx(tx)tx.Commit/tx.Rollback; defer tx.Rollback(ctx) сразу после Begin — страховка от раннего выхода.
  • RETURNING на массовой записи тащит все строки в приложение — для счёта бери :execrows.

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

·Модуль 04

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

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

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