Иногда в SQL нужно не просто обновить строку, а обновить её только при выполнении условия.
Например:
- списать деньги со счёта, только если денег хватает;
- уменьшить остаток товара, только если товар есть на складе;
- перевести заказ в статус
shipped, только если он сейчас paid;
- сохранить изменения, только если запись никто не поменял после того, как мы её прочитали.
Наивный подход часто выглядит так:
- Сначала делаем
SELECT.
- Проверяем данные в коде приложения.
- Потом делаем
UPDATE.
Например:
SELECT balance
FROM accounts
WHERE id = 1;
Приложение получает баланс, проверяет, хватает ли денег, и потом выполняет:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1;
На первый взгляд всё логично.
Но под нагрузкой такой подход может сломаться.
Пока приложение думает между SELECT и UPDATE, другая транзакция может изменить эту же строку. В итоге вы можете принять решение на основе старых данных.
Правильный приём — записать проверку прямо в WHERE того же самого UPDATE.
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
Это и есть условный UPDATE.
Он делает две вещи одной командой:
- Проверяет, подходит ли строка под условие.
- Если подходит — сразу изменяет её.
И главное: это происходит атомарно внутри базы данных.
Проблема двух шагов: SELECT, потом UPDATE
Представим таблицу счетов accounts:
| id |
user_id |
balance |
| 1 |
42 |
300 |
Пользователь хочет списать 200.
Если делать всё в два шага, получится так:
SELECT balance
FROM accounts
WHERE id = 1;
Приложение видит:
balance = 300
Денег хватает.
Потом приложение решает выполнить:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1;
Если операция одна — всё хорошо. Баланс станет 100.
Но теперь представим, что одновременно пришли две операции списания по 200.
Обе почти одновременно сделали SELECT.
Первая увидела:
balance = 300
Вторая тоже увидела:
balance = 300
Обе решили:
Денег хватает.
И обе выполнили списание.
В итоге баланс может уйти в минус или одно из изменений может перетереть другое — зависит от конкретной схемы обновления и логики приложения.
Главная проблема здесь в том, что между чтением и записью есть окно гонки.
В это окно может вклиниться другая транзакция.
Правильный вариант: проверка прямо в UPDATE
Вместо двух шагов используем один:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
Здесь условие balance >= 200 — это не просто фильтр «для красоты».
Это бизнес-правило:
списывать можно только тогда, когда на счёте есть минимум 200.
Если денег хватает, строка подходит под WHERE, и PostgreSQL выполнит обновление.
Если денег не хватает, строка не подходит под WHERE, и PostgreSQL ничего не изменит.
База не будет ругаться ошибкой. Она просто скажет:
UPDATE 0
То есть обновлено 0 строк.
А если списание прошло успешно:
UPDATE 1
То есть обновлена 1 строка.
Почему это атомарно
В запросе:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
проверка и изменение происходят внутри одной SQL-команды.
PostgreSQL сам:
- Находит строку.
- Проверяет условие
balance >= 200.
- Берёт нужную блокировку на строку.
- Считает новое значение
balance - 200.
- Записывает результат.
Другой транзакции нельзя вклиниться «между проверкой и списанием», потому что для приложения это не два отдельных действия, а одна команда.
Именно поэтому условный UPDATE часто лучше, чем схема:
SELECT -> проверка в коде -> UPDATE
Важная деталь: balance - 200 считается от текущего значения
Посмотрим ещё раз:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
Выражение balance = balance - 200 использует значение balance, которое база видит в момент обновления строки.
Это лучше, чем заранее посчитать новый баланс в приложении и отправить готовое число.
Плохо:
UPDATE accounts
SET balance = 100
WHERE id = 1;
Почему плохо?
Потому что 100 могло быть посчитано по старому балансу, который уже изменился.
Лучше:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
Так база сама берёт актуальное значение и меняет его безопасно.
Число затронутых строк — это ответ базы
После условного UPDATE не нужно сразу делать второй SELECT, чтобы понять, получилось или нет.
Сама команда уже даёт ответ.
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
В PostgreSQL результат будет выглядеть так:
UPDATE 1
или:
UPDATE 0
UPDATE 1 означает:
строка нашлась, условие выполнилось, списание прошло.
UPDATE 0 означает:
подходящей строки не было.
Причин может быть несколько:
- счёта с
id = 1 не существует;
- баланс меньше
200;
- строка не подходит под другое условие в
WHERE.
В приложении это обычно доступно как rowcount, affected rows или похожее поле в драйвере.
То есть логика становится простой:
если обновлена 1 строка -> успех
если обновлено 0 строк -> операция не прошла
RETURNING: сразу получить новый баланс
В PostgreSQL удобно использовать RETURNING.
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200
RETURNING id, balance;
Если списание прошло, запрос вернёт строку:
Если денег не хватило или счёта нет, результат будет пустым.
Это удобно: вы одной командой и обновили данные, и получили новое состояние.
Без дополнительного SELECT.
Пример: списание товара со склада
Тот же приём отлично подходит для склада.
Есть таблица товаров products:
| id |
name |
stock |
| 10 |
Keyboard |
5 |
Покупатель хочет купить 2 клавиатуры.
Плохой вариант:
SELECT stock
FROM products
WHERE id = 10;
Потом приложение проверяет остаток и делает:
UPDATE products
SET stock = stock - 2
WHERE id = 10;
Под нагрузкой два покупателя могут одновременно увидеть один и тот же остаток.
Лучше:
UPDATE products
SET stock = stock - 2
WHERE id = 10
AND stock >= 2
RETURNING id, name, stock;
Если товара хватает, остаток уменьшится.
Если товара не хватает, строка не обновится.
Так база сама защищает вас от продажи товара, которого уже нет.
Пример: смена статуса заказа
Условный UPDATE полезен не только для чисел.
Допустим, заказ можно отправить только из статуса paid.
Таблица orders:
Можно написать:
UPDATE orders
SET status = 'shipped'
WHERE id = 42
AND status = 'paid'
RETURNING id, status;
Если заказ действительно был paid, он станет shipped.
Если заказ уже отменён, уже отправлен или вообще не найден, запрос вернёт пустой результат.
Это защищает от неправильных переходов статусов.
Например, вы не сможете случайно отправить заказ, который уже находится в статусе cancelled, потому что он не пройдёт условие status = 'paid'.
Compare-and-swap: обновить только если значение не изменилось
Условный UPDATE часто используют как compare-and-swap.
Идея такая:
«Обнови строку только если она всё ещё находится в том состоянии, которое я ожидаю».
Пример со статусом заказа:
UPDATE orders
SET status = 'shipped'
WHERE id = 42
AND status = 'paid';
Это означает:
«Переведи заказ в shipped, но только если он сейчас paid».
Если два процесса одновременно пытаются отправить один и тот же заказ, только один из них сможет успешно обновить строку.
Первый выполнит:
UPDATE 1
Второй увидит, что статус уже не paid, и получит:
UPDATE 0
Это не ошибка базы. Это нормальный сигнал:
операция больше не актуальна, кто-то уже изменил строку раньше.
Оптимистичная блокировка через version
Для более общего случая часто добавляют колонку version.
Например:
| id |
status |
version |
| 42 |
paid |
7 |
Приложение прочитало заказ и увидело:
version = 7
Потом пользователь или процесс хочет изменить заказ.
Запрос:
UPDATE orders
SET status = 'shipped',
version = version + 1
WHERE id = 42
AND version = 7
RETURNING id, status, version;
Если версия всё ещё 7, значит строку никто не менял. Обновление проходит, а версия становится 8.
Если за это время кто-то уже изменил заказ, версия будет не 7. Тогда запрос обновит 0 строк.
Это называется оптимистичная блокировка.
Она не блокирует строку заранее. Она просто проверяет в момент записи:
данные всё ещё такие, как я ожидал, или уже устарели?
Если устарели — приложение должно перечитать данные и решить, что делать дальше.
Чем это отличается от SELECT FOR UPDATE
Есть два популярных подхода к конкурентным изменениям.
Пессимистичный подход
Сначала блокируем строку:
BEGIN;
SELECT id, balance
FROM accounts
WHERE id = 1
FOR UPDATE;
UPDATE accounts
SET balance = balance - 200
WHERE id = 1;
COMMIT;
Это подход через SELECT FOR UPDATE.
Он говорит:
«Я заранее заблокирую строку, потому что сейчас буду с ней работать».
Это хорошо, когда конфликт очень вероятен и нужно выполнить сложную логику.
Оптимистичный подход
Не блокируем заранее, а проверяем условие в момент записи:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
Или через версию:
UPDATE orders
SET status = 'shipped',
version = version + 1
WHERE id = 42
AND version = 7;
Это хорошо, когда операция простая или конфликты редкие.
Практическое правило:
если можно выразить проверку прямо в WHERE одного UPDATE, часто это проще и надёжнее, чем делать отдельный SELECT FOR UPDATE.
Почему READ COMMITTED не ломает этот приём
В PostgreSQL уровень изоляции по умолчанию — READ COMMITTED.
В этом режиме каждая SQL-команда видит актуальные подтверждённые данные на момент своего выполнения.
Для условного UPDATE это как раз то, что нужно.
Если другая транзакция уже изменила строку, PostgreSQL при обновлении проверит актуальную версию строки и заново применит условие из WHERE.
То есть запрос:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
не полагается на старое значение, которое приложение могло увидеть раньше.
Он проверяет условие в момент самого обновления.
Но важно понимать:
если вы до этого делали отдельный SELECT, то решение, принятое по результату этого SELECT, всё равно может устареть.
Защищает именно условие внутри UPDATE.
Ноль затронутых строк — это не всегда «не найдено»
Очень частая ошибка в API — считать UPDATE 0 обычным «объект не найден».
Например:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
Если обновлено 0 строк, возможны разные причины:
- Счёта
id = 1 не существует.
- Счёт существует, но денег меньше 200.
- Счёт заблокирован или изменён в другой логике.
- Условие в
WHERE не выполнилось по другой причине.
Для пользователя это разные ситуации.
«Счёт не найден» и «недостаточно средств» — не одно и то же.
Если нужно различать причины, есть несколько вариантов.
Самый простой — после отказа сделать отдельное чтение:
SELECT id, balance
FROM accounts
WHERE id = 1;
Если строки нет — счёт не найден.
Если строка есть, но баланс меньше суммы — недостаточно средств.
Для большинства приложений это нормально: сначала быстрый атомарный UPDATE, а дополнительный SELECT только при отказе.
Более подробный ответ через RETURNING
RETURNING отлично работает при успехе:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200
RETURNING id, balance;
Если вернулась строка — успех.
Но если строка не вернулась, RETURNING сам по себе не объяснит причину.
Он не скажет:
- счёт не найден;
- денег не хватило;
- версия устарела.
Он просто вернёт пустой результат.
Поэтому точную причину отказа обычно выясняют отдельным запросом после неуспешного обновления или проектируют API так, чтобы пользователю было достаточно общего сообщения.
Пример с API: как обработать результат
Допустим, есть endpoint:
POST /accounts/1/debit
Он должен списать 200.
SQL:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200
RETURNING id, balance;
Логика:
- если вернулась строка — вернуть
200 OK и новый баланс;
- если строка не вернулась — сделать
SELECT по id;
- если счёта нет — вернуть
404 Not Found;
- если счёт есть — вернуть
409 Conflict или 400 Bad Request с причиной «недостаточно средств».
Главное: не делать проверку баланса до UPDATE как основной механизм защиты.
Проверка должна быть в WHERE.
Частая ошибка: условие только в приложении
Плохо:
SELECT balance
FROM accounts
WHERE id = 1;
Потом в коде:
if balance >= 200:
выполнить UPDATE
А сам UPDATE такой:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1;
Проблема: финальный UPDATE уже не содержит защитного условия.
Правильно:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
Даже если приложение до этого что-то читало, финальная защита должна быть в самой команде обновления.
Частая ошибка: отправлять готовое значение вместо изменения
Плохо:
UPDATE accounts
SET balance = 100
WHERE id = 1
AND balance >= 200;
Почему не идеально?
Потому что 100 могло быть посчитано в приложении по старым данным.
Лучше:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
Так новое значение считается от актуального значения в базе.
Конечно, бывают случаи, когда нужно записать именно готовое значение. Но для счётчиков, балансов и остатков часто лучше использовать изменение относительно текущего значения: balance = balance - 200 или stock = stock - 2.
Частая ошибка: забыть проверить rowcount
Условный UPDATE полезен только если приложение смотрит на результат.
Недостаточно просто выполнить:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
и всегда отвечать пользователю «успешно».
Нужно проверить, сколько строк обновлено.
Если обновлено 0 строк, операция не прошла.
Если обновлена 1 строка, операция прошла.
Для массовых обновлений логика может быть другой, но для операций по конкретной сущности это базовое правило.
MySQL: почти тот же подход, но есть нюанс с affected rows
В MySQL/InnoDB условный UPDATE тоже работает как основной приём для атомарной проверки и изменения.
Например:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
Но есть нюанс с количеством затронутых строк.
В MySQL результат может зависеть от режима клиента.
Обычно MySQL считает именно реально изменённые строки. Но с флагом CLIENT_FOUND_ROWS может возвращать количество найденных строк, даже если значение фактически не изменилось.
Почему это важно?
Например:
UPDATE orders
SET status = 'paid'
WHERE id = 42
AND status = 'paid';
Строка подходит под WHERE, но значение status уже было paid.
В одних настройках это может считаться как «строка найдена», в других — как «ничего не изменилось».
Для операций вроде balance = balance - 200 это обычно не проблема, потому что значение реально меняется при успехе.
Но если вы используете rowcount как бизнес-сигнал в MySQL, важно понимать настройки драйвера и поведение конкретной базы.
ClickHouse: не для такой логики
ClickHouse — аналитическая база данных.
Она отлично подходит для быстрых чтений, агрегаций, аналитики, событий и больших объёмов данных.
Но для строгой логики вида:
«списать деньги только если баланс не уйдёт в минус»
ClickHouse не подходит как основная база.
В ClickHouse есть конструкции для изменения данных, например ALTER TABLE ... UPDATE, но это не обычный OLTP-UPDATE в стиле PostgreSQL или MySQL.
Такие изменения могут выполняться асинхронно и не дают привычной транзакционной модели для check-and-set операций.
Поэтому балансы, остатки, статусы заказов и другие критичные данные лучше хранить и менять в OLTP-базе: PostgreSQL, MySQL и похожих системах.
А ClickHouse использовать для аналитики по этим данным.
Короткая шпаргалка
Списать деньги, только если хватает баланса:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;
То же самое с возвратом нового баланса:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200
RETURNING id, balance;
Уменьшить остаток товара, только если товар есть:
UPDATE products
SET stock = stock - 2
WHERE id = 10
AND stock >= 2
RETURNING id, stock;
Перевести заказ в новый статус только из ожидаемого статуса:
UPDATE orders
SET status = 'shipped'
WHERE id = 42
AND status = 'paid'
RETURNING id, status;
Обновить строку только если версия не изменилась:
UPDATE orders
SET status = 'shipped',
version = version + 1
WHERE id = 42
AND version = 7
RETURNING id, status, version;
Главное правило
Условный UPDATE — это простой и мощный приём:
UPDATE table_name
SET column = new_value
WHERE id = 1
AND business_condition;
Главная идея:
проверка должна жить в том же WHERE, что и обновление.
Не так:
SELECT -> проверка в коде -> UPDATE без условия
А так:
UPDATE с бизнес-условием в WHERE
После этого приложение смотрит на количество затронутых строк:
1 — операция прошла;
0 — условие не выполнилось или строка не найдена.
Если нужен новый результат, используйте RETURNING.
Если нужно понять точную причину отказа, делайте дополнительное чтение уже после неуспешного UPDATE.
Запомните коротко:
один UPDATE, предусловие в WHERE, решение по числу обновлённых строк.
Так вы убираете окно гонки между чтением и записью и отдаёте базе то, что она умеет делать лучше всего: атомарно проверять и изменять данные.
Иногда в SQL нужно не просто обновить строку, а обновить её только при выполнении условия.
Например:
shipped, только если он сейчасpaid;Наивный подход часто выглядит так:
SELECT.UPDATE.Например:
SELECT balance FROM accounts WHERE id = 1;Приложение получает баланс, проверяет, хватает ли денег, и потом выполняет:
UPDATE accounts SET balance = balance - 200 WHERE id = 1;На первый взгляд всё логично.
Но под нагрузкой такой подход может сломаться.
Пока приложение думает между
SELECTиUPDATE, другая транзакция может изменить эту же строку. В итоге вы можете принять решение на основе старых данных.Правильный приём — записать проверку прямо в
WHEREтого же самогоUPDATE.UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;Это и есть условный UPDATE.
Он делает две вещи одной командой:
И главное: это происходит атомарно внутри базы данных.
Проблема двух шагов: SELECT, потом UPDATE
Представим таблицу счетов
accounts:Пользователь хочет списать
200.Если делать всё в два шага, получится так:
SELECT balance FROM accounts WHERE id = 1;Приложение видит:
Денег хватает.
Потом приложение решает выполнить:
UPDATE accounts SET balance = balance - 200 WHERE id = 1;Если операция одна — всё хорошо. Баланс станет
100.Но теперь представим, что одновременно пришли две операции списания по
200.Обе почти одновременно сделали
SELECT.Первая увидела:
Вторая тоже увидела:
Обе решили:
И обе выполнили списание.
В итоге баланс может уйти в минус или одно из изменений может перетереть другое — зависит от конкретной схемы обновления и логики приложения.
Главная проблема здесь в том, что между чтением и записью есть окно гонки.
В это окно может вклиниться другая транзакция.
Правильный вариант: проверка прямо в UPDATE
Вместо двух шагов используем один:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;Здесь условие
balance >= 200— это не просто фильтр «для красоты».Это бизнес-правило:
Если денег хватает, строка подходит под
WHERE, и PostgreSQL выполнит обновление.Если денег не хватает, строка не подходит под
WHERE, и PostgreSQL ничего не изменит.База не будет ругаться ошибкой. Она просто скажет:
То есть обновлено 0 строк.
А если списание прошло успешно:
То есть обновлена 1 строка.
Почему это атомарно
В запросе:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;проверка и изменение происходят внутри одной SQL-команды.
PostgreSQL сам:
balance >= 200.balance - 200.Другой транзакции нельзя вклиниться «между проверкой и списанием», потому что для приложения это не два отдельных действия, а одна команда.
Именно поэтому условный
UPDATEчасто лучше, чем схема:Важная деталь: balance - 200 считается от текущего значения
Посмотрим ещё раз:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;Выражение
balance = balance - 200использует значениеbalance, которое база видит в момент обновления строки.Это лучше, чем заранее посчитать новый баланс в приложении и отправить готовое число.
Плохо:
UPDATE accounts SET balance = 100 WHERE id = 1;Почему плохо?
Потому что
100могло быть посчитано по старому балансу, который уже изменился.Лучше:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;Так база сама берёт актуальное значение и меняет его безопасно.
Число затронутых строк — это ответ базы
После условного
UPDATEне нужно сразу делать второйSELECT, чтобы понять, получилось или нет.Сама команда уже даёт ответ.
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;В PostgreSQL результат будет выглядеть так:
или:
UPDATE 1означает:UPDATE 0означает:Причин может быть несколько:
id = 1не существует;200;WHERE.В приложении это обычно доступно как
rowcount,affected rowsили похожее поле в драйвере.То есть логика становится простой:
RETURNING: сразу получить новый баланс
В PostgreSQL удобно использовать
RETURNING.UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200 RETURNING id, balance;Если списание прошло, запрос вернёт строку:
Если денег не хватило или счёта нет, результат будет пустым.
Это удобно: вы одной командой и обновили данные, и получили новое состояние.
Без дополнительного
SELECT.Пример: списание товара со склада
Тот же приём отлично подходит для склада.
Есть таблица товаров
products:Покупатель хочет купить 2 клавиатуры.
Плохой вариант:
SELECT stock FROM products WHERE id = 10;Потом приложение проверяет остаток и делает:
UPDATE products SET stock = stock - 2 WHERE id = 10;Под нагрузкой два покупателя могут одновременно увидеть один и тот же остаток.
Лучше:
UPDATE products SET stock = stock - 2 WHERE id = 10 AND stock >= 2 RETURNING id, name, stock;Если товара хватает, остаток уменьшится.
Если товара не хватает, строка не обновится.
Так база сама защищает вас от продажи товара, которого уже нет.
Пример: смена статуса заказа
Условный
UPDATEполезен не только для чисел.Допустим, заказ можно отправить только из статуса
paid.Таблица
orders:Можно написать:
UPDATE orders SET status = 'shipped' WHERE id = 42 AND status = 'paid' RETURNING id, status;Если заказ действительно был
paid, он станетshipped.Если заказ уже отменён, уже отправлен или вообще не найден, запрос вернёт пустой результат.
Это защищает от неправильных переходов статусов.
Например, вы не сможете случайно отправить заказ, который уже находится в статусе
cancelled, потому что он не пройдёт условиеstatus = 'paid'.Compare-and-swap: обновить только если значение не изменилось
Условный
UPDATEчасто используют как compare-and-swap.Идея такая:
Пример со статусом заказа:
UPDATE orders SET status = 'shipped' WHERE id = 42 AND status = 'paid';Это означает:
Если два процесса одновременно пытаются отправить один и тот же заказ, только один из них сможет успешно обновить строку.
Первый выполнит:
Второй увидит, что статус уже не
paid, и получит:Это не ошибка базы. Это нормальный сигнал:
Оптимистичная блокировка через version
Для более общего случая часто добавляют колонку
version.Например:
Приложение прочитало заказ и увидело:
Потом пользователь или процесс хочет изменить заказ.
Запрос:
UPDATE orders SET status = 'shipped', version = version + 1 WHERE id = 42 AND version = 7 RETURNING id, status, version;Если версия всё ещё
7, значит строку никто не менял. Обновление проходит, а версия становится8.Если за это время кто-то уже изменил заказ, версия будет не
7. Тогда запрос обновит 0 строк.Это называется оптимистичная блокировка.
Она не блокирует строку заранее. Она просто проверяет в момент записи:
Если устарели — приложение должно перечитать данные и решить, что делать дальше.
Чем это отличается от SELECT FOR UPDATE
Есть два популярных подхода к конкурентным изменениям.
Пессимистичный подход
Сначала блокируем строку:
BEGIN; SELECT id, balance FROM accounts WHERE id = 1 FOR UPDATE; UPDATE accounts SET balance = balance - 200 WHERE id = 1; COMMIT;Это подход через
SELECT FOR UPDATE.Он говорит:
Это хорошо, когда конфликт очень вероятен и нужно выполнить сложную логику.
Оптимистичный подход
Не блокируем заранее, а проверяем условие в момент записи:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;Или через версию:
UPDATE orders SET status = 'shipped', version = version + 1 WHERE id = 42 AND version = 7;Это хорошо, когда операция простая или конфликты редкие.
Практическое правило:
Почему READ COMMITTED не ломает этот приём
В PostgreSQL уровень изоляции по умолчанию —
READ COMMITTED.В этом режиме каждая SQL-команда видит актуальные подтверждённые данные на момент своего выполнения.
Для условного
UPDATEэто как раз то, что нужно.Если другая транзакция уже изменила строку, PostgreSQL при обновлении проверит актуальную версию строки и заново применит условие из
WHERE.То есть запрос:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;не полагается на старое значение, которое приложение могло увидеть раньше.
Он проверяет условие в момент самого обновления.
Но важно понимать:
если вы до этого делали отдельный
SELECT, то решение, принятое по результату этогоSELECT, всё равно может устареть.Защищает именно условие внутри
UPDATE.Ноль затронутых строк — это не всегда «не найдено»
Очень частая ошибка в API — считать
UPDATE 0обычным «объект не найден».Например:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;Если обновлено 0 строк, возможны разные причины:
id = 1не существует.WHEREне выполнилось по другой причине.Для пользователя это разные ситуации.
«Счёт не найден» и «недостаточно средств» — не одно и то же.
Если нужно различать причины, есть несколько вариантов.
Самый простой — после отказа сделать отдельное чтение:
SELECT id, balance FROM accounts WHERE id = 1;Если строки нет — счёт не найден.
Если строка есть, но баланс меньше суммы — недостаточно средств.
Для большинства приложений это нормально: сначала быстрый атомарный
UPDATE, а дополнительныйSELECTтолько при отказе.Более подробный ответ через RETURNING
RETURNINGотлично работает при успехе:UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200 RETURNING id, balance;Если вернулась строка — успех.
Но если строка не вернулась,
RETURNINGсам по себе не объяснит причину.Он не скажет:
Он просто вернёт пустой результат.
Поэтому точную причину отказа обычно выясняют отдельным запросом после неуспешного обновления или проектируют API так, чтобы пользователю было достаточно общего сообщения.
Пример с API: как обработать результат
Допустим, есть endpoint:
Он должен списать 200.
SQL:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200 RETURNING id, balance;Логика:
200 OKи новый баланс;SELECTпоid;404 Not Found;409 Conflictили400 Bad Requestс причиной «недостаточно средств».Главное: не делать проверку баланса до
UPDATEкак основной механизм защиты.Проверка должна быть в
WHERE.Частая ошибка: условие только в приложении
Плохо:
SELECT balance FROM accounts WHERE id = 1;Потом в коде:
А сам
UPDATEтакой:UPDATE accounts SET balance = balance - 200 WHERE id = 1;Проблема: финальный
UPDATEуже не содержит защитного условия.Правильно:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;Даже если приложение до этого что-то читало, финальная защита должна быть в самой команде обновления.
Частая ошибка: отправлять готовое значение вместо изменения
Плохо:
UPDATE accounts SET balance = 100 WHERE id = 1 AND balance >= 200;Почему не идеально?
Потому что
100могло быть посчитано в приложении по старым данным.Лучше:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;Так новое значение считается от актуального значения в базе.
Конечно, бывают случаи, когда нужно записать именно готовое значение. Но для счётчиков, балансов и остатков часто лучше использовать изменение относительно текущего значения:
balance = balance - 200илиstock = stock - 2.Частая ошибка: забыть проверить rowcount
Условный
UPDATEполезен только если приложение смотрит на результат.Недостаточно просто выполнить:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;и всегда отвечать пользователю «успешно».
Нужно проверить, сколько строк обновлено.
Если обновлено 0 строк, операция не прошла.
Если обновлена 1 строка, операция прошла.
Для массовых обновлений логика может быть другой, но для операций по конкретной сущности это базовое правило.
MySQL: почти тот же подход, но есть нюанс с affected rows
В MySQL/InnoDB условный
UPDATEтоже работает как основной приём для атомарной проверки и изменения.Например:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;Но есть нюанс с количеством затронутых строк.
В MySQL результат может зависеть от режима клиента.
Обычно MySQL считает именно реально изменённые строки. Но с флагом
CLIENT_FOUND_ROWSможет возвращать количество найденных строк, даже если значение фактически не изменилось.Почему это важно?
Например:
UPDATE orders SET status = 'paid' WHERE id = 42 AND status = 'paid';Строка подходит под
WHERE, но значениеstatusуже былоpaid.В одних настройках это может считаться как «строка найдена», в других — как «ничего не изменилось».
Для операций вроде
balance = balance - 200это обычно не проблема, потому что значение реально меняется при успехе.Но если вы используете
rowcountкак бизнес-сигнал в MySQL, важно понимать настройки драйвера и поведение конкретной базы.ClickHouse: не для такой логики
ClickHouse — аналитическая база данных.
Она отлично подходит для быстрых чтений, агрегаций, аналитики, событий и больших объёмов данных.
Но для строгой логики вида:
ClickHouse не подходит как основная база.
В ClickHouse есть конструкции для изменения данных, например
ALTER TABLE ... UPDATE, но это не обычный OLTP-UPDATEв стиле PostgreSQL или MySQL.Такие изменения могут выполняться асинхронно и не дают привычной транзакционной модели для check-and-set операций.
Поэтому балансы, остатки, статусы заказов и другие критичные данные лучше хранить и менять в OLTP-базе: PostgreSQL, MySQL и похожих системах.
А ClickHouse использовать для аналитики по этим данным.
Короткая шпаргалка
Списать деньги, только если хватает баланса:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200;То же самое с возвратом нового баланса:
UPDATE accounts SET balance = balance - 200 WHERE id = 1 AND balance >= 200 RETURNING id, balance;Уменьшить остаток товара, только если товар есть:
UPDATE products SET stock = stock - 2 WHERE id = 10 AND stock >= 2 RETURNING id, stock;Перевести заказ в новый статус только из ожидаемого статуса:
UPDATE orders SET status = 'shipped' WHERE id = 42 AND status = 'paid' RETURNING id, status;Обновить строку только если версия не изменилась:
UPDATE orders SET status = 'shipped', version = version + 1 WHERE id = 42 AND version = 7 RETURNING id, status, version;Главное правило
Условный
UPDATE— это простой и мощный приём:UPDATE table_name SET column = new_value WHERE id = 1 AND business_condition;Главная идея:
Не так:
А так:
После этого приложение смотрит на количество затронутых строк:
1— операция прошла;0— условие не выполнилось или строка не найдена.Если нужен новый результат, используйте
RETURNING.Если нужно понять точную причину отказа, делайте дополнительное чтение уже после неуспешного
UPDATE.Запомните коротко:
Так вы убираете окно гонки между чтением и записью и отдаёте базе то, что она умеет делать лучше всего: атомарно проверять и изменять данные.