Все материалы
Продуктовая аналитикагайдстарт

ACID в базах данных: принципы транзакций и уровни изоляции

Что такое ACID-транзакции простыми словами: атомарность, согласованность, изоляция и долговечность на примерах перевода денег и оплаты подписки. BEGIN, COMMIT, ROLLBACK, уровни изоляции и аномалии в PostgreSQL с реальными выводами и что всё это значит для аналитика.

КейсПрактика24 сентября 2026 г.10 мин

В 03:00 ночная загрузка перезаливает витрину выручки: удаляет старые строки и вставляет пересчитанные. Финансовый директор в это время открывает дашборд из другого часового пояса и видит за сентябрь ноль дней и пустую выручку. Через минуту всё на месте, но скриншот уже в чате. Запрос дашборда попал ровно между удалением и вставкой. Если бы загрузка шла одной транзакцией, он увидел бы старые 23 дня целиком, а потом новые. Эта статья про свойства транзакций, которые обозначают аббревиатурой ACID, и про то, где они защищают отчёты аналитика, а где нет.

Коротко

Все примеры ниже выполнены на PostgreSQL 16.14. Сценарии с двумя одновременными сессиями запускались в двух реальных подключениях к одной базе, выводы приведены как есть.

  • ACID — четыре свойства транзакции: Atomicity (атомарность), Consistency (согласованность), Isolation (изоляция), Durability (долговечность).
  • Атомарность: все изменения транзакции применяются вместе или не применяются вовсе. Команды — begin, commit, rollback.
  • Согласованность: транзакция переводит базу из одного допустимого состояния в другое. Что допустимо, задают ограничения: check, unique, внешние ключи.
  • Изоляция: параллельные транзакции не должны видеть незавершённые изменения друг друга. Насколько строго — задаёт уровень изоляции. В PostgreSQL по умолчанию read committed.
  • Долговечность: после commit данные переживут падение сервера, потому что сначала записаны в журнал на диск.
  • Аналитику это важно при чтении витрин во время загрузки, при отчётах из нескольких запросов и при работе с репликами и хранилищами, где гарантии слабее.

Что такое транзакция в базе данных?

Транзакция — это группа команд, которую база выполняет как одно целое. Открывается она командой begin, завершается commit, если всё прошло хорошо, или rollback, если нужно всё отменить. Между ними можно выполнить сколько угодно insert, update и delete.

Если begin не написать, PostgreSQL работает в режиме автокоммита: каждая команда — отдельная транзакция, которая фиксируется сразу. Для одиночного запроса этого достаточно. Проблемы начинаются, когда одно действие в бизнесе — это несколько команд в базе: списать и зачислить деньги, принять оплату и продлить подписку, удалить старые строки витрины и вставить новые.

Атомарность: всё или ничего

Классический пример — перевод денег. У Алисы 300, у Бориса 200. Перевод — это две команды: уменьшить один баланс и увеличить другой. На таблице стоит ограничение check (balance >= 0). Попробуем перевести 500, сначала зачислив деньги Борису, потом списав у Алисы.

Внутри транзакции второе обновление падает на ограничении: у Алисы получилось бы −200. PostgreSQL помечает транзакцию как сломанную и на все следующие команды отвечает «current transaction is aborted, commands ignored until end of transaction block». Даже явный commit в этом состоянии выполняет откат: в ответ приходит ROLLBACK. Балансы остаются 300 и 200, как будто ничего не было.

Те же две команды без транзакции ведут себя иначе. Первая фиксируется сразу, вторая падает. В итоге у Бориса 700, у Алисы по-прежнему 300: в системе появилось 500 из ниоткуда. Никакой ошибки в логике перевода нет — не хватило одной пары слов begin и commit.

Балансы после неудачного перевода 500 (PostgreSQL 16.14)
Как выполнялиalicebobСумма денег в системе
До перевода300200500
begin … commit, второе обновление упало300200500
Без транзакции, второе обновление упало3007001000
Перевод, который не может выполниться наполовину (PostgreSQL)
begin;
update accounts set balance = balance + 500 where id = 'bob';
update accounts set balance = balance - 500 where id = 'alice';
-- ERROR: new row for relation "accounts"
--        violates check constraint "accounts_balance_check"
commit;
-- ROLLBACK

select * from accounts order by id;
-- alice | 300
-- bob   | 200

Согласованность: база не пускает недопустимые состояния

Согласованность в ACID означает, что после транзакции все правила данных выполняются. Правила бывают двух видов. Первые база проверяет сама: типы, not null, unique, check, внешние ключи. Вторые знает только приложение: например, что оплаченная подписка должна быть в статусе active и иметь дату окончания.

Второй пример — оплата подписки. Нужно записать платёж и перевести подписку пользователя из trial в active. Допустим, в коде опечатка, и статус пишется как activ. Ограничение check (status in ('trial', 'active', 'canceled')) отклоняет обновление, транзакция откатывается целиком. На PostgreSQL после отката в payments 0 строк, подписка по-прежнему trial. Без транзакции в базе остался бы платёж у пользователя без доступа: самый неприятный вид расхождения для поддержки и для аналитика, который потом сверяет выручку со статусами.

Отсюда практический вывод: согласованность держится на ограничениях. База обеспечивает её ровно настолько, насколько правила записаны в схеме. Правило, которое живёт только в голове разработчика, транзакция не защитит.

Оплата и статус подписки меняются только вместе (PostgreSQL)
begin;
insert into payments values (1001, 42, 29, '2026-09-24');
update subscriptions
set status = 'active', paid_until = '2026-10-24'
where user_id = 42;
commit;

Изоляция: что видят параллельные транзакции

В рабочей базе одновременно идут сотни транзакций. Изоляция определяет, что одна из них видит из изменений других. Полная изоляция означает, что результат такой же, как если бы транзакции шли строго по очереди. Она дорогая, поэтому стандарт SQL описывает четыре уровня и перечисляет аномалии, которые на каждом допустимы.

Грязное чтение — видно чужое незафиксированное изменение, которое потом могут откатить. Неповторяемое чтение — одна и та же строка при повторном чтении внутри транзакции даёт другое значение. Фантом — повторный запрос с тем же условием возвращает другой набор строк. Потерянное обновление — две транзакции читают значение, каждая записывает своё, и одно изменение бесследно пропадает.

PostgreSQL в некоторых местах строже стандарта. Грязного чтения в нём нет вообще: read uncommitted работает как read committed. На repeatable read не бывает и фантомов: транзакция видит снимок базы на момент первого запроса. Уровень по умолчанию — read committed, это видно по show transaction_isolation.

Уровни изоляции и аномалии в PostgreSQL
УровеньГрязное чтениеНеповторяемое чтениеФантомПотерянное обновление
read uncommittedнет (работает как read committed)возможновозможновозможно
read committed (по умолчанию)нетвозможновозможновозможно
repeatable readнетнетнетошибка сериализации, нужен повтор
serializableнетнетнетошибка сериализации, нужен повтор

Как аномалии выглядят на практике

Каждый сценарий ниже — две настоящие сессии PostgreSQL. Первая открывает транзакцию, читает, ждёт две секунды и читает снова. Вторая в эту паузу меняет данные.

Неповторяемое чтение. На read committed первая сессия прочитала баланс Алисы 300, а через две секунды — 200: вторая сессия успела списать 100. На repeatable read оба чтения вернули 300. Фантом: на read committed запрос count(*), sum(balance) сначала вернул 2 счёта и 500, а после вставки третьего счёта — 3 и 550. На repeatable read — оба раза 2 и 500. Грязное чтение: пока вторая сессия держала незафиксированный balance = 0, первая даже на read uncommitted прочитала 300.

Потерянное обновление опаснее всего, потому что не оставляет следов. Сессия A читает баланс 300, собирается списать 100 и записывает вычисленное значение 200. Пока она думала, сессия B списала 50 и записала 250. На read committed итог — 200: списание B пропало, хотя правильный ответ 150. На repeatable read сессия A получает ошибку «could not serialize access due to concurrent update», итог 250, и A должна повторить попытку. Самый простой способ избежать проблемы — не вычислять значение в приложении, а писать set balance = balance - 100: такие два параллельных обновления на read committed дали правильные 150.

serializable ловит то, что пропускает repeatable read. В сценарии с дежурствами два аналитика одновременно проверяют, что дежурных двое, и каждый снимает с дежурства себя. На repeatable read обе транзакции прошли, и дежурных не осталось ни одного. На serializable вторая упала с ошибкой «could not serialize access due to read/write dependencies among transactions», и один дежурный остался.

Результаты двух параллельных сессий (PostgreSQL 16.14)
Сценарийread committedrepeatable readserializable
Баланс при двух чтениях300, затем 200300 и 300
count и sum при двух чтениях2 и 500, затем 3 и 5502 и 500 оба раза
Списания 100 и 50 через чтение и записьитог 200, списание 50 потеряноошибка сериализации, итог 250
Списания через balance = balance − xитог 150
Двое снимают себя с дежурствадежурных 0ошибка сериализации, дежурных 1

Долговечность: что значит «данные сохранены»

Долговечность обещает, что зафиксированная транзакция не пропадёт, даже если сразу после commit отключится питание. PostgreSQL добивается этого через журнал предзаписи (WAL): прежде чем ответить клиенту на commit, он записывает изменения в журнал и сбрасывает его на диск. После падения база восстанавливается, проигрывая журнал.

Эту гарантию можно ослабить настройками. В тестовом экземпляре, на котором запускались примеры, synchronous_commit = on и fsync = on — значения по умолчанию. С synchronous_commit = off база отвечает на commit раньше записи журнала на диск: при сбое можно потерять последние транзакции, хотя сама база останется целой. Иногда так делают для загрузок, которые легко повторить.

Долговечность на одном сервере не означает, что данные уже есть на реплике. По умолчанию репликация в PostgreSQL асинхронная: реплика получает изменения с задержкой. Если аналитика читает с реплики, свежая оплата может появиться там через несколько секунд или минут.

Почему ACID важен аналитику

Аналитик редко пишет транзакции сам, но постоянно читает данные, которые кто-то в этот момент меняет. Три ситуации встречаются чаще всего.

Отчёт во время загрузки. Пример из начала статьи проверен на PostgreSQL: витрина из 23 дней перезаливается удалением и вставкой с паузой между ними. Без транзакции запрос читателя в середине загрузки вернул 0 дней. С begin … commit тот же запрос вернул старые 23 дня и сумму 2300, а после фиксации — новые 23 дня и 2530. Если ваш дашборд иногда показывает провалы, которые исчезают сами, спросите у инженеров, как устроена загрузка.

Отчёт из нескольких запросов. Выгрузка, где итоговая строка считается отдельным запросом от строк по сегментам, на read committed может не сойтись сама с собой: между запросами данные поменялись, как в сценарии с фантомом. Для таких выгрузок выполняйте все запросы внутри одной транзакции begin isolation level repeatable read и закрывайте её, как только данные прочитаны: долгая открытая транзакция мешает базе чистить старые версии строк.

Реплики и хранилища. Колоночные хранилища и потоковые системы часто дают более слабые гарантии, чем PostgreSQL: атомарной бывает отдельная вставка, а не цепочка команд, и данные из разных источников приезжают в разное время. Такой подход называют BASE (basically available, soft state, eventual consistency): система доступна всегда, а согласованной становится со временем. Практически это значит, что свежий день в хранилище почти всегда неполный. Сравнивайте только закрытые периоды и держите в витрине отметку о времени последней загрузки.

Как проверить у себя

Узнать уровень изоляции своей сессии в PostgreSQL можно командой show transaction_isolation. Чтобы потренироваться с откатом, откройте транзакцию на тестовой базе, выполните update и посмотрите результат через select в этой же сессии и в соседней: в своей сессии изменение видно, в чужой нет, пока вы не сделаете commit. Затем выполните rollback и убедитесь, что изменение исчезло и для вас.

В песочнице SQL-курса выполняются только запросы на чтение, поэтому транзакции там не открыть. Зато на её таблицах удобно тренировать запросы, которые проверяют согласованность данных: платежи без пользователя, подписки без платежей, события раньше регистрации.

Продолжить чтение
Вся библиотека
Продуктовая аналитика24 сентября 2026 г.13 мин
Поток одинаковых ячеек сжимается в короткий ряд уникальных значений.

DISTINCT в SQL: уникальные строки, COUNT(DISTINCT) и DISTINCT ON

Как работает DISTINCT в SQL на реальной учебной базе: уникальность по всей строке, DISTINCT по нескольким столбцам, COUNT(DISTINCT) для DAU, NULL, отличие от GROUP BY, DISTINCT ON в PostgreSQL и DuckDB, и почему DISTINCT после JOIN прячет ошибку в выручке.

Читать материал
Продуктовая аналитика24 сентября 2026 г.12 мин
Столбики разной высоты подрезаны по одной горизонтальной линии.

ROUND в SQL: округление, CEIL, FLOOR и ловушка целочисленного деления

Как округлять в SQL: ROUND до двух знаков и до сотен, CEIL, FLOOR и TRUNC, куда уходят половинки в PostgreSQL и DuckDB, целочисленное деление, деньги в DECIMAL и почему доли после округления дают 101%.

Читать материал