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 есть окно, где можно посмотреть масштаб и передумать:
BEGIN ── «черновик»: изменения не на диске и не видны другим сессиям
│
▼
UPDATE price_lab SET price = price + 50 ← нет WHERE: задело ВСЮ таблицу
│
▼
проверка: RowsAffected = 5 ← ждали 3, пришло 5 — что-то не так
│
├──────────────┬──────────────────
▼ ▼
ROLLBACK COMMIT
строк как изменения зафиксированы,
не бывало видны всем
(наш выбор)До COMMIT чужие сессии видят прежние данные; ROLLBACK возвращает всё как было, будто команды не случилось. Это окно и есть сеть безопасности: забытый WHERE в нём — наблюдение, а не катастрофа.
Что показывает наш код
Запросы в query.sql — это два «масштаба» и одна катастрофа. Целевой UPDATE с RETURNING:
-- 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 (возвращают число строк):
-- 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 + таблицу юнита:
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.)
Вывод:
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»: научимся «вставить или обновить» одной командой — идиома для синхронизации справочников и счётчиков, где строки то появляются, то меняются.