sqlpostgresqlupdateconcurrency

Условный UPDATE: как атомарно проверить условие и изменить данные

Перенесите предусловие внутрь UPDATE, чтобы списания, смены статуса и compare-and-swap не проигрывали гонку между SELECT и записью.

10 мин чтенияСправочникsql · postgresql · update · concurrency · transactions · mysql

Иногда в SQL нужно не просто обновить строку, а обновить её только при выполнении условия.

Например:

  • списать деньги со счёта, только если денег хватает;
  • уменьшить остаток товара, только если товар есть на складе;
  • перевести заказ в статус shipped, только если он сейчас paid;
  • сохранить изменения, только если запись никто не поменял после того, как мы её прочитали.

Наивный подход часто выглядит так:

  1. Сначала делаем SELECT.
  2. Проверяем данные в коде приложения.
  3. Потом делаем 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.

Он делает две вещи одной командой:

  1. Проверяет, подходит ли строка под условие.
  2. Если подходит — сразу изменяет её.

И главное: это происходит атомарно внутри базы данных.

Проблема двух шагов: 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 сам:

  1. Находит строку.
  2. Проверяет условие balance >= 200.
  3. Берёт нужную блокировку на строку.
  4. Считает новое значение balance - 200.
  5. Записывает результат.

Другой транзакции нельзя вклиниться «между проверкой и списанием», потому что для приложения это не два отдельных действия, а одна команда.

Именно поэтому условный 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;

Если списание прошло, запрос вернёт строку:

id balance
1 100

Если денег не хватило или счёта нет, результат будет пустым.

Это удобно: вы одной командой и обновили данные, и получили новое состояние.

Без дополнительного 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:

id status
42 paid

Можно написать:

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 строк, возможны разные причины:

  1. Счёта id = 1 не существует.
  2. Счёт существует, но денег меньше 200.
  3. Счёт заблокирован или изменён в другой логике.
  4. Условие в 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-тренажёре с мгновенной проверкой и подсказками.

Открыть тренажёр