SQL Literacy: Изоляция, Блокировки и Идемпотентность
Создание надежных и высоконагруженных бэкенд-приложений требует глубокого понимания того, как базы данных обрабатывают конкурентный доступ. Это руководство рассматривает типичные сценарии, подводные камни и экспертные стратегии обеспечения целостности данных и производительности.
1. Зачем это нужно Senior-разработчику?
Знание «внутренностей» SQL (MVCC, ID транзакций, механизмы блокировок) позволяет:
- Избегать трудновоспроизводимых багов (Race Conditions).
- Оптимизировать нагрузку на БД, выбирая минимально необходимые блокировки.
- Строить надежные интеграции с внешними системами (очереди, платежные шлюзы).
2. Кейс №1: Состояние гонки при обновлении (Update Race Condition)
Сценарий
Система, где несколько пользователей могут одновременно изменять один и тот же ресурс, например, бронировать «слот» на митапе.
Наивная реализация:
$slot = $db->query("SELECT * FROM meetup_slots WHERE id = 1")->fetch();
if ($slot->speaker_id === null) {
$db->query("UPDATE meetup_slots SET speaker_id = $userId WHERE id = 1");
}
Проблема: два пользователя одновременно читают speaker_id IS NULL. Оба выполняют UPDATE. Второй пользователь перезаписывает первого (потерянное обновление).
Решения:
-
Пессимистическая блокировка (
SELECT FOR UPDATE): Блокирует строку до завершения транзакции. Другие транзакции будут ждать на этапеSELECT.NOWAIT: если вы хотите, чтобы запрос упал сразу, а не ждал освобождения блокировки:SELECT * FROM meetup_slots WHERE id = 1 FOR UPDATE NOWAIT;SKIP LOCKED: идеально для очередей. Пропускает строки, уже заблокированные другими:SELECT * FROM jobs WHERE status = 'pending' LIMIT 1 FOR UPDATE SKIP LOCKED;
-
Атомарные обновления (рекомендуется): Объедините проверку и обновление в одном SQL-запросе:
UPDATE meetup_slotsSET speaker_id = :user_idWHERE id = :id AND speaker_id IS NULL;Всегда проверяйте количество Rows Affected. Если оно равно
0, значит условие уже не выполняется и операция не удалась.
3. Кейс №2: Идемпотентность (обработка повторов)
Сценарий
API для добавления посетителя. Из-за сетевых сбоев или гарантии доставки "at-least-once" (Kafka) один и тот же запрос может прийти несколько раз.
Проблема: пользователь может быть добавлен несколько раз, занимая лишние места.
Решение:
- Unique Constraints: создайте уникальный индекс на пару
(meetup_id, user_id). - Обработка ошибок:
Перехватывайте ошибку
Duplicate Key. Если произошел повтор, база заблокирует вторую вставку. Обработайте это как успех (или состояние «уже сделано»), чтобы клиент получил консистентный ответ.
4. Кейс №3: Управление лимитами (агрегаты)
Сценарий
Гарантия того, что количество участников не превысит лимит (например, 100 мест).
Наивная реализация:
SELECT COUNT(*) FROM visitors WHERE meetup_id = 1; -- Возвращает 99
-- ... если count < limit, то ...
INSERT INTO visitors (meetup_id, user_id) VALUES (1, 101);
Проблема: параллельные транзакции могут обе увидеть 99 и обе выполнить вставку, в итоге участников станет 101.
Решения:
- Блокировка родительской строки:
SELECT * FROM meetups WHERE id = 1 FOR UPDATE;Это выстраивает все регистрации на конкретный митап в очередь. - Денормализация + CHECK Constraint:
Добавьте колонку
booked_seatsв таблицуmeetupsи ограничениеCHECK (booked_seats <= total_seats). Обновляйте счетчик в той же транзакции, что и вставку:База данных обеспечит выполнениеUPDATE meetups SET booked_seats = booked_seats + 1 WHERE id = 1;CHECKдля всех транзакций.
5. Глубокое погружение: MVCC и скрытые колонки
Для работы конкурентности (одна транзакция читает, другая пишет одновременно) базы данных используют MVCC (Multi-Version Concurrency Control).
PostgreSQL
Postgres сохраняет все версии строк прямо в основной таблице (heap). У каждой строки есть скрытые системные колонки:
xmin: ID транзакции, которая вставила строку.xmax: ID транзакции, которая удалила или обновила строку.ctid: физический адрес версии строки на диске.
MySQL (InnoDB)
MySQL хранит только последнюю версию строки в таблице, а исторические данные выносит в Undo Log:
DB_TRX_ID: ID последней транзакции, вставившей или обновившей строку.DB_ROLL_PTR: указатель на предыдущую версию в Undo Log (через который можно «отмотать» изменения).DB_ROW_ID: скрытый ID строки (используется, если у таблицы нет первичного ключа).