20. Транзакции и изоляция
🧭 Введение: почему транзакции решают не «SQL-задачу», а бизнес-риск
Когда система работает под нагрузкой, ошибки редко выглядят как «запрос не выполнился».
Гораздо чаще запросы выполняются, но данные остаются в частично обновлённом состоянии: деньги списали, но не зачислили; заказ перевели в
Гораздо чаще запросы выполняются, но данные остаются в частично обновлённом состоянии: деньги списали, но не зачислили; заказ перевели в
paid, но склад не уменьшили.В этой теме разберём основу production-подхода:
BEGIN / COMMIT / ROLLBACK;- ACID (обзорно, без избыточной теории);
- частичные обновления и как их предотвращать;
- блокировки и конкуренцию;
- уровни изоляции (обзорно);
- эталонный сценарий перевода денег.
💡 Совет:
Транзакция нужна не «потому что так красиво», а потому что бизнес-операция должна завершаться целиком или не выполняться совсем.
✅ Вывод:
Надёжная работа с данными начинается с транзакционного мышления, а не с отдельных SQL-операторов.
⚠️ Проблема -> решение
Типичная проблема:
- разработчик делает несколько
UPDATE/INSERTкак отдельные запросы; - один из шагов падает;
- часть данных уже изменилась и система попадает в неконсистентное состояние.
Решение:
- группировать связанные изменения в транзакцию;
- при ошибке делать
ROLLBACK; - выбирать адекватный уровень изоляции под бизнес-сценарий;
- контролировать блокировки и порядок работы со строками.
🟢 Если совсем просто:
Одна бизнес-операция должна жить в одной транзакции.
🎯 Как понять, что этап прошёл успешно:
После ошибки база остаётся в том же состоянии, что и до старта операции.
🛠️ Чем помогает и как работает
Транзакции нужны в каждом домене, где важна корректность:
- платежи;
- остатки на складе;
- биллинг и подписки;
- статусы заказов и отгрузок;
- синхронизация нескольких связанных таблиц.
🟢 Если совсем просто:
Транзакция собирает несколько запросов в один «атомарный пакет».
🎯 Как понять, что этап прошёл успешно:
Вы можете объяснить для каждой критичной операции, где начинается
BEGIN и где заканчивается COMMIT/ROLLBACK.Чем помогает:
- защищает от частичных обновлений;
- сохраняет согласованность между таблицами;
- упрощает разбор инцидентов;
- снижает риск «тихих» багов при конкуренции.
Как это работает:
- Шаг 1: открываем транзакцию (
BEGIN); - Шаг 2: выполняем связанные запросы;
- Шаг 3: при успехе фиксируем (
COMMIT); - Шаг 4: при ошибке откатываем (
ROLLBACK); - Шаг 5: контролируем изоляцию и блокировки под нагрузкой.
✅ Вывод:
Транзакция превращает набор SQL-запросов в управляемую бизнес-операцию.
📚 Ключевые термины (простыми словами)
Перед практикой синхронизируем словарь.
🟢 Если совсем просто:
Без общих терминов сложно обсуждать инциденты и ревьюить SQL.
🎯 Как понять, что этап прошёл успешно:
Вы уверенно различаете
rollback, lock, isolation level и deadlock.- Transaction (транзакция) — единица работы БД, которая фиксируется целиком или откатывается.
- BEGIN — старт транзакции.
- COMMIT — зафиксировать изменения.
- ROLLBACK — отменить изменения текущей транзакции.
- Atomicity (атомарность) — «всё или ничего».
- Consistency (согласованность) — данные после операции остаются валидными по правилам схемы.
- Isolation (изоляция) — параллельные транзакции не ломают друг другу логику.
- Durability (надёжность фиксации) — после
COMMITданные не теряются при сбое. - Lock (блокировка) — механизм, который временно ограничивает конкурентный доступ к данным.
- Deadlock (взаимная блокировка) — транзакции ждут друг друга по кругу.
- Isolation level (уровень изоляции) — степень защиты от аномалий параллельного чтения/записи.
- Partial update (частичное обновление) — часть шагов операции выполнилась, часть нет.
✅ Вывод:
Эти термины покрывают 90% разговоров о транзакционных проблемах на проекте.
🧱 1. BEGIN / COMMIT / ROLLBACK: каркас безопасных изменений
Любая серьёзная операция начинается не с
Именно границы определяют, что считать одной логической операцией.
UPDATE, а с определения границ транзакции.Именно границы определяют, что считать одной логической операцией.
🟢 Если совсем просто:
BEGIN открывает пакет изменений, COMMIT подтверждает, ROLLBACK отменяет.🎯 Как понять, что этап прошёл успешно:
Вы можете показать SQL-блок, который либо полностью фиксируется, либо полностью откатывается.
Назначение:
Объединить связанные SQL-действия в одну управляемую единицу.
Простыми словами:
Сначала открываем транзакцию, потом делаем шаги операции, затем фиксируем или отменяем.
Для новичка:
Если запросов несколько и они логически связаны, почти всегда нужна транзакция.
Аналогия:
Это как банковский перевод в кассе: либо оформлен полностью, либо отменён.
Пример:
BEGIN; UPDATE ordersSET status = 'paid'WHERE id = 1001; INSERT INTO payments (order_id, amount, status)VALUES (1001, 2500.00, 'captured'); COMMIT;BEGIN; UPDATE ordersSET status = 'paid'WHERE id = 1002; ROLLBACK;🔎 Как это происходит на практике:
- Контекст: API-ручка выполняет 2-3 SQL-запроса в рамках одной операции.
- Действия: транзакция открывается на уровне сервиса перед первым запросом.
- Результат: при ошибке на шаге 2 шаг 1 не остаётся «висячим».
Характеристики:
- транзакционные границы задают целостность;
- откат работает только до
COMMIT; - без явных границ риск частичных изменений резко выше.
Когда использовать:
Для любой операции, где несколько изменений должны быть согласованы.
✅ Вывод:
BEGIN/COMMIT/ROLLBACK — обязательный каркас для критичных бизнес-операций.🧪 2. ACID (обзорно): что именно гарантирует БД
ACID часто воспринимают как «теория для собеседования», но это практический чек-лист риска.
Если вы понимаете ACID, вы заранее видите, где операция может сломаться под нагрузкой.
Если вы понимаете ACID, вы заранее видите, где операция может сломаться под нагрузкой.
🟢 Если совсем просто:
ACID описывает, почему данным можно доверять после выполнения транзакции.
🎯 Как понять, что этап прошёл успешно:
Вы можете связать каждую букву ACID с реальным поведением запроса.
Назначение:
Зафиксировать базовые гарантии корректной обработки данных.
Простыми словами:
ACID = атомарность, согласованность, изоляция, надёжность фиксации.
Для новичка:
Если «деньги списались, но не дошли» — это нарушение атомарности.
Пример:
BEGIN; UPDATE accountsSET balance = balance - 500WHERE id = 10; UPDATE accountsSET balance = balance + 500WHERE id = 20; COMMIT;🔎 Как это происходит на практике:
- Atomicity: без второй операции первая не должна остаться в базе.
- Consistency: ограничения (
CHECK,FK) защищают валидность данных. - Isolation: параллельные переводы не должны «перетирать» друг друга.
- Durability: после
COMMITрезультат не исчезает после рестарта.
Характеристики:
- ACID не отменяет необходимость корректного SQL;
- ACID работает лучше всего с продуманными ограничениями схемы;
- ACID не заменяет бизнес-валидацию в приложении.
Когда использовать:
Всегда, когда операция влияет на деньги, остатки, статусы и отчётность.
✅ Вывод:
ACID — это инженерная модель качества данных, а не абстрактный термин.
🧩 3. Частичное обновление: почему без транзакции процесс ломается
Главная практическая боль — «полуоперации»: первый шаг прошёл, второй упал.
В результате система внешне работает, но данные уже расходятся с бизнес-логикой.
В результате система внешне работает, но данные уже расходятся с бизнес-логикой.
🟢 Если совсем просто:
Частичное обновление — это когда операция завершилась наполовину.
🎯 Как понять, что этап прошёл успешно:
Вы можете показать, как один
ROLLBACK убирает риск «полуобновлённых» данных.Назначение:
Не допустить состояний, которые невозможно корректно объяснить бизнесу.
Простыми словами:
Если шагов несколько, они должны откатываться и фиксироваться как единый блок.
Для новичка:
Два
UPDATE подряд без транзакции — уже потенциальный инцидент.Аналогия:
Это как оформить заказ без создания оплаты: в интерфейсе заказ есть, а бизнес-процесс не закрыт.
Пример:
UPDATE ordersSET status = 'paid'WHERE id = 2001; UPDATE inventorySET reserved = reserved - 1WHERE sku = 'SKU-42';BEGIN; UPDATE ordersSET status = 'paid'WHERE id = 2001; UPDATE inventorySET reserved = reserved - 1WHERE sku = 'SKU-42'; COMMIT;🔎 Как это происходит на практике:
- Контекст: checkout меняет заказ, оплату и склад.
- Действия: любой сбой на одном шаге приводит к
ROLLBACK. - Результат: нет «половинчатых» бизнес-состояний.
Характеристики:
- частичное обновление особенно опасно в интеграциях;
- последствия часто проявляются не сразу, а в отчётах;
- откат дешевле, чем восстановление вручную.
Когда использовать:
Для всех multi-step операций в одной БД.
✅ Вывод:
Транзакции закрывают класс ошибок «выполнилось наполовину».
🔒 4. Блокировки: как БД защищает данные при конкуренции
Когда много запросов работают одновременно, БД должна координировать доступ к строкам.
Без блокировок два процесса могут принять решение на устаревших данных и сделать конфликтные изменения.
Без блокировок два процесса могут принять решение на устаревших данных и сделать конфликтные изменения.
🟢 Если совсем просто:
Блокировка — это «занято», пока транзакция не закончится.
🎯 Как понять, что этап прошёл успешно:
Вы знаете, какие строки блокируются и как избежать долгих ожиданий.
Назначение:
Защитить конкурентные операции от гонок и потери обновлений.
Простыми словами:
Одна транзакция временно «захватывает» строку, другие ждут или обходят.
Для новичка:
SELECT ... FOR UPDATE — базовый способ заблокировать строки перед изменением.Пример:
BEGIN; SELECT id, balanceFROM accountsWHERE id IN (10, 20)FOR UPDATE; UPDATE accountsSET balance = balance - 500WHERE id = 10; UPDATE accountsSET balance = balance + 500WHERE id = 20; COMMIT;🔎 Как это происходит на практике:
- Контекст: несколько переводов одновременно затрагивают одни и те же счета.
- Действия: блокируем строки перед проверкой и обновлением.
- Результат: нет гонки «оба потока увидели старый баланс».
Характеристики:
- блокировки удерживаются до
COMMIT/ROLLBACK; - длинные транзакции увеличивают время ожидания других запросов;
- разный порядок блокировки может привести к deadlock.
- deadlock в production — нормальный технический кейс, его нужно уметь корректно ретраить.
Когда использовать:
Когда решение зависит от текущего значения строки и затем эта строка обновляется.
✅ Вывод:
Управление блокировками — ключ к корректности под параллельной нагрузкой.
🧪 5. Уровни изоляции (обзорно): баланс корректности и производительности
Изоляция отвечает за то, что одна транзакция может «видеть» из действий другой.
Чем выше уровень изоляции, тем меньше аномалий, но тем выше цена по блокировкам/повторам.
Чем выше уровень изоляции, тем меньше аномалий, но тем выше цена по блокировкам/повторам.
🟢 Если совсем просто:
Уровень изоляции — это правило, насколько «независимы» параллельные транзакции.
🎯 Как понять, что этап прошёл успешно:
Вы можете обосновать, почему для сценария выбран именно этот уровень.
Назначение:
Снизить риск аномалий чтения/записи в конкурентных сценариях.
Простыми словами:
Это настройка «строгости» параллельного доступа.
Для новичка:
В PostgreSQL по умолчанию
READ COMMITTED, и для многих CRUD-задач этого достаточно.Пример:
BEGIN; SET LOCAL TRANSACTION ISOLATION LEVEL REPEATABLE READ; SELECT COUNT(*) AS pending_ordersFROM ordersWHERE status = 'pending'; COMMIT;🔎 Как это происходит на практике:
READ COMMITTED: меньше ограничений, выше шанс «изменившихся данных между чтениями».REPEATABLE READ: стабильнее повторные чтения в транзакции.SERIALIZABLE: максимальная строгость, возможны retriable-ошибки, которые нужно повторять.
Короткая карта аномалий (для ревью):
Dirty read(грязное чтение) — почти нигде не допускается в современных OLTP-СУБД.Non-repeatable read(повторное чтение изменилось) — возможно наREAD COMMITTED.Phantom read(в диапазоне появились новые строки) — зависит от СУБД и уровня изоляции.Write skew— типичный MVCC-кейс, когда два корректных решения вместе нарушают общий инвариант.
Практика по инвариантам:
Инварианты лучше фиксировать ограничениями (
UNIQUE, CHECK), ключами и явными блокировками, а не полагаться только на уровень изоляции.Характеристики:
- уровень изоляции задаётся под сценарий, а не «везде максимальный»;
- слишком высокий уровень без необходимости ухудшает throughput;
- выбор уровня должен быть зафиксирован в архитектурных правилах.
Практика для
SERIALIZABLE:- ловим ошибку сериализации в приложении;
- повторяем транзакцию целиком 1-3 раза с небольшим backoff;
- проектируем операцию идемпотентной (или с ключом операции), чтобы ретрай не дал двойной эффект.
Когда использовать:
Когда параллельные операции критично влияют на бизнес-результат.
✅ Вывод:
Изоляция — это осознанный компромисс между корректностью и производительностью.
💸 6. Пример перевода денег: эталонная транзакция
Перевод денег — самый наглядный сценарий, где видно ценность транзакции, блокировок и изоляции.
Здесь важно не только «обновить баланс», но и корректно пережить конкуренцию и ошибки.
Здесь важно не только «обновить баланс», но и корректно пережить конкуренцию и ошибки.
🟢 Если совсем просто:
Перевод = списать, зачислить, записать лог в одной транзакции.
🎯 Как понять, что этап прошёл успешно:
Невозможно получить состояние, где деньги «пропали между счетами».
Назначение:
Показать боевой шаблон для критичных финансовых операций.
Простыми словами:
Операция должна быть атомарной и защищённой от гонок.
Для новичка:
Сначала блокируем счета, потом проверяем баланс, потом делаем обновления.
Пример:
BEGIN; SET LOCAL TRANSACTION ISOLATION LEVEL REPEATABLE READ; SELECT id, balanceFROM accountsWHERE id IN (10, 20)ORDER BY idFOR UPDATE; UPDATE accountsSET balance = balance - 500WHERE id = 10 AND balance >= 500; UPDATE accountsSET balance = balance + 500WHERE id = 20; INSERT INTO transfers (from_account_id, to_account_id, amount, status)VALUES (10, 20, 500, 'completed'); COMMIT;Production-усиление 1: проверка
row count после списанияUPDATE accountsSET balance = balance - 500WHERE id = 10 AND balance >= 500; SELECT ROW_COUNT();Если списание затронуло
0 строк, операция должна завершиться ROLLBACK с ошибкой «недостаточно средств».Production-усиление 2: идемпотентность перевода
INSERT INTO transfers (idempotency_key, from_account_id, to_account_id, amount, status)VALUES ('trf-2026-03-05-1001', 10, 20, 500, 'pending');idempotency_key должен быть UNIQUE, чтобы повторный ретрай не создал двойной перевод.🔎 Как это происходит на практике:
- Контекст: несколько параллельных переводов между одними и теми же счетами.
- Действия: блокируем строки и применяем единый транзакционный сценарий.
- Результат: нет потери/дублирования суммы и есть аудиторский след.
Характеристики:
- нужна проверка бизнес-условий (например, достаточность баланса);
- нужен единый порядок блокировки для снижения риска deadlock;
- нужен лог операции для аудита и расследований.
Когда использовать:
Для платежей, бонусных баллов, внутреннего биллинга и любых переводов между сущностями.
✅ Вывод:
Транзакционный перевод денег — базовый шаблон для критичных операций в OLTP-системе.
🚩 7. Красный флаг: не держим транзакцию во время внешних вызовов
Самая дорогая ошибка в production — открыть
Такая транзакция держит блокировки дольше, растит конфликты и может «положить» throughput под нагрузкой.
BEGIN, затем делать HTTP/queue/API вызов и только потом продолжать SQL.Такая транзакция держит блокировки дольше, растит конфликты и может «положить» throughput под нагрузкой.
🟢 Если совсем просто:
Снаружи транзакции делаем сеть, внутри транзакции делаем только БД.
🎯 Как понять, что этап прошёл успешно:
В транзакционном блоке нет внешних вызовов, только SQL к одной БД.
Антипаттерн:
BEGIN; UPDATE ordersSET status = 'processing'WHERE id = 7001; SELECT pg_sleep(5); COMMIT;Правильный подход:
- внешний вызов выполняется до транзакции или после неё;
- если нужно гарантированно отправить событие после
COMMIT, используем outbox-паттерн (см. ниже).
✅ Вывод:
Короткая транзакция = меньше блокировок, меньше конфликтов, стабильнее прод.
🧷 8. SAVEPOINT: частичный откат внутри транзакции
Иногда операция в целом должна завершиться успешно, но один побочный шаг может быть опциональным.
Для таких случаев используют
Для таких случаев используют
SAVEPOINT и ROLLBACK TO SAVEPOINT.🟢 Если совсем просто:
SAVEPOINT — это «точка возврата» внутри одной транзакции.🎯 Как понять, что этап прошёл успешно:
Вы можете откатить локальный шаг, не отменяя всю операцию.
Пример:
BEGIN; UPDATE accountsSET updated_at = NOW()WHERE id = 10; SAVEPOINT after_balance; INSERT INTO audit_log (entity, action)VALUES ('accounts', 'balance_updated'); ROLLBACK TO SAVEPOINT after_balance; COMMIT;✅ Вывод:
SAVEPOINT даёт тонкий контроль: общий успех операции + локальный откат побочных действий.🧠 9. Lost update и шаблон read-modify-write
Lost update — классическая проблема конкуренции, когда одно изменение «перетирает» другое.Особенно часто проявляется в логике «прочитал -> вычислил -> записал».
🟢 Если совсем просто:
Два потока читают старое значение и пишут разные новые, одно обновление теряется.
🎯 Как понять, что этап прошёл успешно:
В критичных read-modify-write операциях есть защита (
FOR UPDATE или optimistic locking).Плохой сценарий (потеря обновления):
- T1 читает
balance = 1000; - T2 читает
balance = 1000; - T1 пишет
900; - T2 пишет
800-> итог перетёр изменение T1.
Шаблон защиты (пессимистично):
BEGIN; SELECT id, balanceFROM accountsWHERE id = 10FOR UPDATE; UPDATE accountsSET balance = balance - 100WHERE id = 10; COMMIT;✅ Вывод:
Lost update — одна из главных причин, почему
FOR UPDATE нужен не «для красоты», а для корректности.⚖️ 10. Optimistic locking: альтернатива тяжёлым блокировкам
Для высоконагруженных API часто используют оптимистичный контроль версий (
Идея: обновление проходит только если версия совпадает с ожидаемой.
version/updated_at).Идея: обновление проходит только если версия совпадает с ожидаемой.
🟢 Если совсем просто:
Если кто-то успел изменить запись раньше вас, ваш
UPDATE не применится.🎯 Как понять, что этап прошёл успешно:
При конфликте версия не совпадает, приложение видит
rowcount = 0 и обрабатывает конфликт.Пример:
UPDATE ordersSET status = 'paid', version = version + 1WHERE id = 1001 AND version = 7;Если изменено
0 строк, нужно перечитать запись и повторить бизнес-логику (или вернуть 409 Conflict).✅ Вывод:
Optimistic locking хорошо масштабируется и прозрачно сигнализирует о конфликте параллельных изменений.
🧵 11. Очереди и воркеры: FOR UPDATE SKIP LOCKED (PostgreSQL)
В задачах типа job-queue важно, чтобы воркеры не ждали друг друга на одних и тех же строках.
SKIP LOCKED позволяет каждому воркеру брать только свободные задачи.🟢 Если совсем просто:
Воркеры параллельно «разбирают очередь», не блокируя друг друга ожиданием.
🎯 Как понять, что этап прошёл успешно:
Каждый воркер получает свой пакет задач без конфликтов и долгих wait.
Пример:
BEGIN; WITH picked AS ( SELECT id FROM jobs WHERE status = 'new' ORDER BY created_at LIMIT 50 FOR UPDATE SKIP LOCKED)UPDATE jobs jSET status = 'processing'FROM picked pWHERE j.id = p.idRETURNING j.*; COMMIT;✅ Вывод:
SKIP LOCKED — production-стандарт для параллельной обработки очередей в PostgreSQL.📦 12. Outbox pattern: надёжный мост из БД во внешние события
Проблема интеграций:
Outbox решает это через одну транзакцию: доменные изменения + запись в outbox.
COMMIT прошёл, а публикация в брокер не ушла, или ушла дважды.Outbox решает это через одну транзакцию: доменные изменения + запись в outbox.
🟢 Если совсем просто:
Сначала надёжно фиксируем событие в БД, потом отдельный воркер публикует наружу.
🎯 Как понять, что этап прошёл успешно:
Нет сценария «данные изменились, а событие потерялось».
Пример:
BEGIN; UPDATE ordersSET status = 'paid'WHERE id = 9001; INSERT INTO outbox (event_type, payload, status, created_at)VALUES ('order.paid', '{\"orderId\":9001}', 'new', NOW()); COMMIT;Отдельный воркер читает
outbox, публикует в Kafka/queue/webhook и помечает событие как отправленное.✅ Вывод:
Outbox закрывает класс багов «COMMIT прошёл, событие не ушло».
🧰 13. Autocommit, ORM и видимые границы транзакции
Частый прод-баг: команда думает, что операция транзакционная, а фактически каждый statement в autocommit.
Плюс ORM может открывать implicit-транзакции, а пул соединений требует аккуратного закрытия.
Плюс ORM может открывать implicit-транзакции, а пул соединений требует аккуратного закрытия.
🟢 Если совсем просто:
Границы транзакции должны быть явно видны в коде сервиса.
🎯 Как понять, что этап прошёл успешно:
В коде и тестах явно видно, где начинается и где заканчивается транзакция.
Правило:
- старт/конец транзакции фиксируются в сервисном слое;
- в тестах проверяется rollback-путь;
- соединение всегда возвращается в пул без «висячей» транзакции.
✅ Вывод:
Явные транзакционные границы в коде важны так же, как корректный SQL.
🆚 Сравнение: без транзакции vs с транзакцией
| Подход | Что происходит | Риск | Когда уместно |
|---|---|---|---|
| Несколько независимых запросов | Каждый шаг живёт отдельно | Частичное обновление, гонки | Только для некритичных, независимых действий |
Транзакция BEGIN ... COMMIT | Шаги фиксируются вместе | Ниже риск, выше контроль | Стандарт для связанных изменений |
| Транзакция + блокировки + изоляция | Контролируется конкуренция | Сложнее код, но выше корректность | Финансы, остатки, статусы под нагрузкой |
✅ Вывод:
Чем выше бизнес-цена ошибки, тем жёстче должен быть транзакционный контур.
🧠 Must-Know (запомнить)
- Транзакции держим короткими: внешние HTTP/queue/API вызовы выполняем вне транзакции.
- Одна бизнес-операция = одна транзакция с явным
BEGIN/COMMIT/ROLLBACK. - Без транзакции multi-step операция уязвима к частичному обновлению.
- Для read -> check -> write используем
FOR UPDATEили optimistic locking. - Для optimistic locking проверяем
rowcount:0строк = конфликт версии. - Для перевода денег проверяем
rowcountсписания:0строк = недостаточно средств ->ROLLBACK. - Для снижения deadlock блокируем сущности всегда в одном порядке (например, по
id). - Deadlock — нормальный production-кейс, его нужно ретраить в коде.
SERIALIZABLEможет требовать повторов 1-3 раза с backoff.- Для очередей/воркеров используем
FOR UPDATE SKIP LOCKED(PostgreSQL). - Для надёжной публикации событий после
COMMITиспользуем outbox-паттерн. - Границы транзакции должны быть явно видны в коде сервиса и тестах (autocommit/ORM под контролем).
✅ Вывод:
Надёжность транзакций — это комбинация границ, блокировок и осознанной изоляции.
❌ Частые мифы
❌ Миф: Транзакции нужны только для банков.
✅ Как правильно: Они нужны в любом сценарии, где несколько изменений должны быть согласованы.
📎 Почему это важно: Частичные обновления ломают не только финансы, но и статусы, отчёты, остатки.
❌ Миф: Если запросы быстрые, транзакция не нужна.
✅ Как правильно: Скорость не гарантирует атомарность и защиту от конкуренции.
📎 Почему это важно: Инциденты возникают именно при редких ошибках в многопоточном режиме.
❌ Миф: Надо всегда ставить
SERIALIZABLE.
✅ Как правильно: Уровень изоляции выбирают под конкретный риск и нагрузку.
📎 Почему это важно: Избыточная строгость может ухудшить производительность без бизнес-пользы.❌ Миф: Deadlock — это «проблема БД», а не кода.
✅ Как правильно: Чаще всего deadlock вызывается разным порядком доступа к одинаковым данным в коде.
📎 Почему это важно: Архитектурное правило порядка блокировок резко снижает частоту deadlock.
🎤 Часто спрашивают на собеседованиях
❓ Вопрос: В чём разница между
COMMIT и ROLLBACK?
✅ Ответ: COMMIT фиксирует изменения транзакции, ROLLBACK полностью отменяет их.❓ Вопрос: Что такое частичное обновление?
✅ Ответ: Это состояние, когда часть шагов бизнес-операции сохранилась в БД, а часть нет.
❓ Вопрос: Зачем нужен ACID в практической разработке?
✅ Ответ: Он задаёт гарантии корректности и помогает проектировать безопасные операции под нагрузкой.
❓ Вопрос: Когда применять
SELECT ... FOR UPDATE?
✅ Ответ: Когда вы читаете значение и далее принимаете решение об обновлении этой же строки.❓ Вопрос: Почему возникает deadlock?
✅ Ответ: Из-за циклического ожидания блокировок, часто при разном порядке доступа к одним и тем же строкам.
❓ Вопрос: Какой уровень изоляции обычно стоит по умолчанию в PostgreSQL?
✅ Ответ:
READ COMMITTED.❓ Вопрос: Что важно в примере перевода денег?
✅ Ответ: Атомарность шагов, блокировка затрагиваемых счетов, проверка баланса и журналирование операции.
❓ Вопрос: Почему
SERIALIZABLE требует ретраи?
✅ Ответ: Потому что БД может отклонить транзакцию при конфликте сериализации, и её нужно повторить в приложении.❓ Вопрос: Зачем нужен
SAVEPOINT, если уже есть ROLLBACK?
✅ Ответ: SAVEPOINT позволяет откатить только часть шагов внутри транзакции, не отменяя операцию целиком.❓ Вопрос: Когда лучше optimistic locking, а не
FOR UPDATE?
✅ Ответ: Когда важна масштабируемость и допустим явный конфликт версий с повтором логики в приложении.❓ Вопрос: Для чего используют
FOR UPDATE SKIP LOCKED?
✅ Ответ: Для параллельной разборки очередей воркерами без взаимного ожидания одних и тех же строк.❓ Вопрос: Что решает outbox-паттерн?
✅ Ответ: Он гарантирует согласованность между изменениями в БД и последующей отправкой событий во внешние системы.
🚨 Типичные ошибки
- Выполнять связанные
UPDATE/INSERTвне транзакции. - Держать транзакцию открытой во время долгих внешних вызовов.
- Читать данные и обновлять их позже без блокировки в конкурентном сценарии.
- Выбирать уровень изоляции «по умолчанию» без проверки рисков.
- Не обрабатывать ретраи при
SERIALIZABLE. - Блокировать одни и те же сущности в разном порядке в разных сервисах.
- Не проверять
row countпосле критичных обновлений. - Не писать запись в журнал операций при финансовых изменениях.
- Не делать операцию идемпотентной и получать двойной эффект на ретраях.
- Пытаться отправлять внешнее событие сразу после SQL без outbox-гарантий.
- Полагаться на implicit-транзакции ORM и не видеть реальные границы
BEGIN/COMMIT.
✅ Вывод:
Большинство проблем с транзакциями предсказуемы и устраняются дисциплиной шаблонов.
✅ Best Practices
- Формулируйте бизнес-операцию до написания SQL и определяйте её транзакционные границы.
- Делайте транзакции короткими: не держите их открытыми дольше необходимого.
- Для критичных read-modify-write сценариев используйте явные блокировки.
- Внешние вызовы (HTTP, брокер, очередь) выполняйте вне транзакции.
- Фиксируйте единый порядок работы с сущностями (
from_id < to_id) для снижения deadlock. - Выбирайте изоляцию под риск: не минимальную «по привычке» и не максимальную «на всякий случай».
- Логируйте результат критичных транзакций (
transfer,payment,status_change). - Для
SERIALIZABLEзаранее реализуйте стратегию повторов в приложении. - Для денежных и retry-чувствительных операций добавляйте
idempotency_key. - Для интеграций «БД -> событие» используйте outbox + отдельный паблишер.
- Для очередей воркеров используйте
FOR UPDATE SKIP LOCKEDв PostgreSQL. - Для частичного отката неосновных шагов используйте
SAVEPOINT. - Явно фиксируйте границы транзакции в коде сервиса, не полагаясь на implicit ORM-поведение.
- Добавляйте мониторинг lock-wait/deadlock в проде.
✅ Вывод:
Production-ready транзакции — это не только SQL, но и операционная дисциплина команды.
🏁 Заключение
Транзакции и изоляция — это фундамент надёжной OLTP-разработки.
Если вы умеете правильно задавать границы операции, работать с блокировками и осознанно выбирать уровень изоляции, система остаётся предсказуемой даже при высокой конкуренции.
Если вы умеете правильно задавать границы операции, работать с блокировками и осознанно выбирать уровень изоляции, система остаётся предсказуемой даже при высокой конкуренции.
✅ Вывод:
Надёжная база данных = корректные транзакции + контроль конкуренции + явные инженерные правила.