
Чтобы транзакции в InnoDB работали предсказуемо, важно понимать внутреннюю логику InnoDB. Ошибки чаще всего возникают не из-за отсутствия транзакций, а из-за неверных ожиданий относительно уровней изоляции и работы блокировок.
В этой статье давайте разберём несколько распространённых заблуждений и на примерах посмотрим, как на самом деле работают транзакции.
Транзакция: основы
Транзакция в InnoDB – это группа последовательных операций с базой данных, которые применяются как единое целое: либо применяются полностью, либо не применяются.
По умолчанию в InnoDB каждая сессия начинается с включённым режимом autocommit, что означает автоматическую фиксацию сразу после выполнения каждого SQL-запроса.
Оператор START TRANSACTION начинает явную транзакцию, при которой фиксация изменений откладывается до COMMIT или ROLLBACK. Снаружи это выглядит как временное выключение autocommit.

На этом этапе важно понимать границы транзакции, какие именно запросы входят в неё, а также каким образом они взаимодействуют с механизмами InnoDB.
Не все запросы ведут себя одинаково внутри транзакции. Например, как только выполняется ALTER, текущая транзакция завершается, и все изменения фиксируются, даже если мы ожидали откат. Дело в том, что DDL не является частью пользовательской транзакции, прерывает её.

Заблуждение: ALTER TABLE можно откатить, если он внутри транзакции.
Реальность: DDL вызывает неявный COMMIT.
Отсутствие оператора START TRANSACTION не означает отсутствие транзакции. InnoDB работает через механизм транзакций даже для единичного SELECT.

Заблуждение: одиночный SELECT – это просто чтение, без транзакции.
Реальность: одиночный SELECT тоже выполняется внутри короткой неявной транзакции.
Уровни изоляции
Внутри транзакции данные видны по определённым правилам, которые задают уровни изоляции. Основные отличия между уровнями достигаются за счёт того, как создаётся и используется read view – снимок логически видимого состояния базы данных на момент запроса.
Вот схема, в которой я собрал ключевые элементы транзакции с учётом уровней изоляции. Она неофициальная и не претендует на исчерпывающее описание механики транзакций в InnoDB – я сделал её для наглядности, чтобы упростить разбор примеров в статье.

Снимок read view фиксирует список активных транзакций, и при чтении каждая строка проверяется на видимость. Если текущая транзакция не должна видеть последнюю версию строки, InnoDB достаёт предыдущую версию из undo log.
С помощью undo log в InnoDB строятся такие снимки – в журнале хранятся предыдущие версии строк, изменённые в результате операций UPDATE или DELETE.
При INSERT старой версии не существует, поэтому в журнале создаётся специальная запись для удаления вставленной строки. Фактически undo log нужен для возможности откатить транзакцию и для предоставления предыдущих версий строк другим транзакциям.
В каждой строке таблицы InnoDB есть несколько служебных полей, связанных с транзакциями. В частности, хранится идентификатор транзакции, которая последний раз изменила строку, и указатель на соответствующую запись в undo log. Благодаря этим полям движок может вернуть старую или актуальную версию строки в зависимости от того, что разрешает read view.
Заблуждение: START TRANSACTION сразу активирует всю логику транзакции.
Реальность: в InnoDB используется «ленивая» инициализация транзакции.
По умолчанию после START TRANSACTION никакого снимка данных ещё нет: read view создаётся только в момент первого консистентного чтения. Поэтому начав транзакцию заранее, но не выполнив запросов к данным, мы не фиксируем состояние базы. Это важно при сценариях, где ожидается воспроизведение состояния на момент старта.

Каждая транзакция видит таблицу не напрямую, а через особую модель видимости, которая зависит от уровня изоляции. Это определяет, какие изменения от других транзакций будут видны, а какие нет.
Именно уровни изоляции задают правила игры внутри транзакции.
READ UNCOMMITTED
Для самого быстрого уровня изоляции READ UNCOMMITTED обычные SELECT в рамках транзакции не создают read view и читают самую свежую версию строки, даже если она не закоммичена. На этом уровне возможно нежелательное поведение при конкурентных транзакциях, которое называют аномалиями.
Например, «грязное» чтение – это ситуация, когда внутри транзакции можно прочитать данные, которые другая транзакция еще может откатить.

В первой транзакции обновляем данные, но не делаем коммит. Во второй транзакции видим измененное значение, хотя коммита в первой транзакции еще не было.
На практике этот уровень используют крайне редко, в основном для ручной отладки или очень специальных кейсов. Выигрыш по скорости по сравнению с READ COMMITTED обычно минимальный и редко оправдывает риск грязных чтений.
READ COMMITTED
От «грязного» чтения спасает более строгий уровень изоляции READ COMMITTED. На этом уровне каждый SELECT видит только те данные, которые были закоммичены до начала именно этого SELECT, а не всей транзакции.
Рассмотрим тот же пример:

В первой транзакции обновляем данные, но не делаем коммит. Во второй транзакции видим старое значение, поскольку в первой коммита ещё не было. Получается, движку фактически нужно строить снимок на каждый SELECT, чтобы определить, какие данные были закоммичены на данный момент.
Может возникнуть ситуация, когда повторный SELECT в одной и той же транзакции даёт разный результат. Этот эффект называется неповторяемое чтение.
Пример:

В первой транзакции прочитали баланс. Во второй транзакции баланс поменялся, произошёл коммит. В первой делаем ещё один запрос и видим уже измененный баланс, так как для второго SELECT будет новый снимок, и на этот момент данные уже изменились.
На практике этот уровень изоляции используется при высокой конкурентности, а также когда внутри транзакции нужны актуальные закоммиченные данные и неповторяемое чтение не мешает. В большинстве баз данных, ориентированных на highload (Oracle, PostgreSQL), по умолчанию используется как раз этот уровень, но в MySQL это не так. В MySQL по умолчанию выбран REPEATABLE READ.
Заблуждение: READ COMMITTED – это упрощенная версия REPEATABLE READ.
Реальность: не упрощенная версия, а альтернатива с другим балансом свойств.
Он дает меньше гарантий консистентности, выигрывает там, где REPEATABLE READ начинает тормозить систему.
REPEATABLE READ
От неповторяемого чтения защищает уровень изоляции REPEATABLE READ. Здесь снимок создаётся один раз при первом SELECT к таблице InnoDB и используется для всех последующих запросов внутри транзакции.
Поэтому транзакция продолжает видеть данные такими, какими они были на момент первого чтения, даже если другие транзакции уже изменили и закоммитили новые версии строк.
Пример:

В начале первой транзакции запрашиваем баланс, для этого делается снимок. Вторая транзакция меняет баланс. Первая транзакция читает баланс повторно, но так как она читает из того же снимка, то видит старый баланс.
Тут может возникнуть справедливый вопрос, почему тогда REPEATABLE READ медленнее, чем READ COMMITTED? Навскидку это кажется неочевидным, так как READ COMMITTED строит снимки каждый раз, а REPEATABLE READ – только один раз на всю транзакцию.
При REPEATABLE READ снимок живёт дольше. Чем дольше транзакция, тем больше изменений от других транзакций могут накапливаться в undo log, и тем дороже обход лога для проверки видимости. Кроме того, REPEATABLE READ должен игнорировать «невидимые» изменения, значит, требуется дополнительная логика и проверки при каждом чтении.
Заблуждение: READ COMMITTED медленнее, ведь он строит снимок каждый раз.
Реальность: read view в REPEATABLE READ существует дольше, из-за этого каждое чтение становится дороже.
Для REPEATABLE READ в транзакции лучше не смешивать обычные неблокирующие SELECT с блокирующими SELECT, UPDATE или INSERT. Это приведёт к использованию двух разных несовместимых друг с другом состояния таблиц, которые будет трудно анализировать.
Дальше становится ещё интереснее: по стандартам ANSI SQL уровень REPEATABLE READ не защищает от фантомного чтения, но в InnoDB защищает. На этом уровне InnoDB строже стандартов.
Важно запомнить, что REPEATABLE READ делает только один снимок в начале первого запроса на согласованное чтение в транзакции и фактически далее не видит новых строк закомиченных другими транзакциями, так как их нет в снимке.
Пример:

Первая транзакция делает снимок, читает все записи больше 150 и видит, что есть подходящие записи 200 и 300. Вторая транзакция вставляет запись 400, происходит коммит. При чтении в первой транзакции данные будут браться из того же единственного снимка, значит, фантома 400 в нём не будет.
Заблуждение: REPEATABLE READ в InnoDB не защищает от фантомного чтения.
Реальность: реализация REPEATABLE READ в InnoDB строже стандарта.
В InnoDB оба REPEATABLE READ и SERIALIZABLE защищают от фантомов, но делают это по-разному, из-за чего конкурентность в системе различается.
SERIALIZABLE
Уровень изоляции SERIALIZABLE – самый строгий и фактически заставляет транзакции выполняться последовательно с точки зрения видимости данных. Для достижения такого поведения движок не ограничивается работой только со снимками read view.
В InnoDB используются блокировки чтения и записи, а диапазонные запросы превращаются в диапазонные блокировки. Благодаря этому предотвращается появление фантомных строк.
Заблуждение: используем SERIALIZABLE и не знаем проблем.
Реальность: SERIALIZABLE действительно предотвращает фантомы, но достигает этого в первую очередь за счёт агрессивных блокировок, что резко ухудшает конкурентность.
На практике это означает, что даже обычный SELECT может блокировать другие транзакции, которые пытаются изменять данные, подходящие под условие. В результате параллелизм резко снижается, и система начинает работать так, как будто все запросы идут по очереди. Это очень медленно.
Блокировки
В целом это не очень приятная ситуация, когда кто-то может поменять данные, пока мы на них опираемся в рамках своей транзакции. Здесь на помощь приходят блокировки.

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

Первая транзакция читает данные и ставит разделяемую блокировку FOR SHARE. Вторая транзакция может читать эти же данные, но при попытке вставить запись будет заблокирована, то есть будет ждать. При этом первая транзакция может продолжить работу и прочитать данные еще раз.
После коммита в первой транзакции блокировка снимается, во второй транзакции вставляются данные. Если бы первая транзакция была долгой, то вторая могла бы отвалиться по таймауту.
Заблуждение: разделяемая блокировка при чтении ничего не блокирует.
Реальность: FOR SHARE допускает чтение, но блокирует изменения в этом же диапазоне, включая вставку, обновление и удаление.
Эксклюзивная блокировка не позволит другой транзакции изменить строку или поставить свою блокировку на эту строку. При этом обычный SELECT сможет прочитать данные через MVCC, без ожидания завершения первой транзакци��.

Одно из применений такого подхода – это корректная конкурентная работа.
Например, у нас в проекте эксклюзивные блокировки используются для получения последовательности при генерации номера счета, чтобы два разных пользователя не получили один и тот же номер.
Самое полезное применение эксклюзивных блокировок – когда нужно блокировать строки на запись в рамках транзакции, особенно если изменения происходят в разных таблицах.
Уровни изоляции, блокировки – и бизнес-логика
Транзакции защищают данные, но не защищают бизнес-логику от ошибок проектирования.
Например, внутри транзакции запросы могут ждать блокировки, при этом параллельная транзакция может:
успеть выполнить изменения быстрее;
частично изменить данные (привет от уровня изоляции);
застрять в циклическом ожидании и вызвать дедлок.
На таких примерах мы видим: важно не только использовать транзакции, но и понимать, чего от них можно ожидать на самом деле.
Надеюсь, эта статья поможет лишний раз обратить внимание на распространенные заблуждения насчёт транзакций и потенциально избежать соответствующих ошибок в коде.
