← все задачи

SQL · задача 9 из 10

Вставить или обновить без гонок

Продвинутый 20 минут INSERT ON CONFLICTблокировкиатомарность

Условие

Есть таблица счётчиков (key, value). Нужно увеличивать счётчик на единицу, создавая строку, если её ещё нет. Код «прочитать, прибавить, записать» теряет обновления под нагрузкой — почему и как правильно?

Что требуется

  • Ни одно увеличение не теряется при конкурентных вызовах
  • Первая запись создаётся автоматически
  • Без явных блокировок таблицы

Пример

-- два процесса одновременно:
-- SELECT value -> 10; 10 + 1 = 11; UPDATE value = 11
-- результат 11 вместо 12: одно увеличение потеряно

Сначала уточните

Вопросы до кода — половина оценки. Молча начать печатать хуже, чем задать два вопроса.

  • Точность важна или допустимо приблизительное значение (тогда подойдёт Redis)?
  • Насколько высока конкуренция по одному ключу — это будет горячая строка
  • Нужна ли история изменений или только текущее значение?
Показать решение Скрыть решение

Решение

-- Атомарное увеличение с созданием строки
INSERT INTO counters (key, value)
VALUES ('page_views', 1)
ON CONFLICT (key)
DO UPDATE SET value = counters.value + 1
RETURNING value;

-- Если строка точно есть, достаточно одного UPDATE:
UPDATE counters
SET value = value + 1
WHERE key = 'page_views';

-- Когда между чтением и записью нужна логика:
BEGIN;
SELECT value FROM counters WHERE key = 'page_views' FOR UPDATE;
-- ... вычисления в приложении ...
UPDATE counters SET value = :new_value WHERE key = 'page_views';
COMMIT;

Почему так

Почему read-modify-write теряет обновления

  • Два процесса читают одно значение, оба прибавляют единицу и записывают — второй перетирает результат первого
  • Транзакция сама по себе не спасает: на уровне READ COMMITTED оба честно прочитали актуальное значение
  • Это и есть классическая потерянная запись; на неё нужен либо атомарный UPDATE, либо блокировка строки

Почему value = value + 1 атомарен

  • СУБД вычисляет новое значение на своей стороне и держит блокировку строки до конца транзакции
  • Второй процесс ждёт на этой же строке и прибавляет уже к обновлённому значению
  • Никакого «прочитали в приложении» здесь нет — окна для гонки не существует

Зачем ON CONFLICT, а не «проверить и вставить»

  • Между SELECT и INSERT два процесса успеют вставить одинаковый ключ — один получит ошибку уникальности
  • ON CONFLICT решает это на стороне базы, атомарно и без лишнего запроса
  • Важно: ON CONFLICT работает только при наличии уникального индекса по ключу конфликта

Когда нужен SELECT FOR UPDATE

  • Если между чтением и записью есть логика приложения, которую нельзя выразить одним UPDATE
  • FOR UPDATE блокирует строку до конца транзакции, и второй процесс ждёт своей очереди
  • Цена — сериализация по строке и риск взаимоблокировок, если разные транзакции берут строки в разном порядке

Что спросят дальше

  • Спросят про горячую строку: тысяча увеличений в секунду по одному ключу выстроится в очередь — помогает шардирование счётчика на N строк
  • Спросят про уровни изоляции: на SERIALIZABLE база сама поймает конфликт, но транзакцию придётся повторять
  • Спросят, почему не Redis INCR: быстрее и проще, но это отдельное хранилище со своей надёжностью

Следующая задача

N+1 и как её увидеть в SQL — Задача на стыке ORM и SQL: почему страница делает 201 запрос и что показать в ответ.