Уровни изолированности транзакций для самых маленьких
Как показывает практика, многие люди, связанные с IT, в частности с работой с БД, слабо понимают зачем нужны эти уровни и как их можно использовать себе во благо. Сегодня хотел бы довести крайне интересный, но часто покрытый тайнами для обычных смертных программистов раздел базы данных (БД) — уровни изолированности транзакций.
Немного теории
Сами транзакции особых объяснений не требуют, транзакция — это N (N≥1) запросов к БД, которые выполнятся успешно все вместе или не выполнятся вовсе. Изолированность же транзакции показывает то, насколько сильно влияют друг на друга параллельно выполняющиеся транзакции.
Выбирая уровень транзакции, мы пытаемся прийти к консенсусу в выборе между высокой согласованностью данных между транзакциями и скоростью выполнения этих самых транзакций.
Стоит отметить, что самую высокую скорость выполнения и самую низкую согласованность имеет уровень read uncommitted. Самую низкую скорость выполнения и самую высокую согласованность — serializable.
Подготовка окружения
Для примеров была выбрана СУБД MySQL. PostgreSQL мог бы тоже использоваться, но он не поддерживает уровень изоляции read uncommitted, и использует вместо него уровень read committed. Да и как оказалось, разные СУБД по-разному воспринимают уровни изолированности. Могут иметь разнообразные нюансы в обеспечении изоляции, иметь дополнительные уровни или не иметь общеизвестных.
И заполним базу данными. Создадим окружение с помощью готового образа MySQL с Docker Hub.
docker-compose.yaml
version: '3.4'
services: db: image: mysql:8 environment: - MYSQL_ROOT_PASSWORD=12345 command: --init-file /init.sql volumes: - data:/var/lib/mysql - ./init.sql:/init.sql expose: - "3306" ports: - "3309:3306" volumes: data:
Заполнение базы данных
create database if not exists bank; use bank; create table if not exists accounts
( id int unsigned auto_increment primary key, login varchar(255) not null, balance bigint default 0 not null, created_at timestamp default now()
) collate=utf8mb4_unicode_ci; insert into accounts (login, balance) values ('petya', 1000);
insert into accounts (login, balance) values ('vasya', 2000);
insert into accounts (login, balance) values ('mark', 500);
Рассмотрим как работают уровни и их особенности.
Примеры будем выполнять на 2 параллельно исполняющихся транзакциях. Условно транзакция в левом окне будем называть транзакция 1 (Т1), в правом окне — транзакция 2 (Т2).
Read uncommitted
Уровень, имеющий самую плохую согласованность данных, но самую высокую скорость выполнения транзакций. Название уровня говорит само за себя — каждая транзакция видит незафиксированные изменения другой транзакции (феномен грязного чтения). Посмотрим какое влияние оказывают друг на друга такие транзакции.
Шаг 1. Начинаем 2 параллельные транзакции.
Шаг 2. Смотрим какая информация имеется у нас в начале.
Шаг 3. Теперь выполняем операции CREATE, DELETE, UPDATE в Т1, и посмотрим, что теперь видит другая транзакция.
Т2 видит данные другой транзакции, которые еще не были зафиксированы.
Шаг 4. И Т2 может получить какие-то данные.
Шаг 5. При откате изменений Т1, данные полученные Т2 окажутся ошибочными.
Результат COUNT(*) или MAX(*) можно использовать в каких-нибудь нестрогих отчетах.
Другой пример это режим отладки. На данном уровне нельзя использовать данные, на основе которых делаются важные для приложения выводы и критические решения т.к выводы эти могут быть далеки от реальности.
Данный уровень можно использовать, например, для примерных расчетов чего-либо. Когда во время транзакции, вы хотите видеть, что происходит с базой.
Read committed
Для этого уровня параллельно исполняющиеся транзакции видят только зафиксированные изменения из других транзакций. Таким образом, данный уровень обеспечивает защиту от грязного чтения.
Шаг 1 и Шаг 2 аналогичны предыдущему примеру.
Также выполним 3 простейшие операции с таблицей accounts (Т1) и сделаем полную выборку из этих таблиц в обеих транзакциях. Шаг 3.
И увидим, что феномен грязного чтения в Т2 отсутствует.
Зафиксируем изменения Т1 и проверим, что теперь видит Т2. Шаг 4.
Это так называемые феномен неповторяющегося чтения, когда мы видим обновленные и удаленные строки (UPDATE, DELETE), и феномен чтения фантомов, когда мы видим добавленные записи (INSERT). Теперь Т2 видит все, что сделала Т1.
Repeatable read
Уровень, позволяющий предотвратить феномен неповторяющегося чтения. Т.е. мы не видим в исполняющейся транзакции измененные и удаленные записи другой транзакцией. Но все еще видим вставленные записи из другой транзакции. Чтение фантомов никуда не уходит.
Снова повторяем Шаг 1 и Шаг 2.
В Т1 выполняем запросы CREATE, UPDATE и DELETE. Шаг 3. После, в Т2 пытаемся обновить ту же самую строку, которую обновили в Т1.
И получаем lock: T2 будет ждать, пока T1 не зафиксирует изменения или не откатится.
Зафиксируем изменения, которые сделала Т1. Шаг 4. И прочитаем снова данные из таблицы accounts в Т2.
Как же так, ведь по умолчанию, repeatable read позволяет нам предотвратить только феномен неповторяющегося чтения? Как видно, феноменов неповторяющегося чтения и чтения фантомов не наблюдается.
И в PostgreSQL от него тоже избавились для этого уровня. На самом деле в MySQL отсутствует эффект чтения фантомов для уровня repeatable read. Хотя в классическом представлении этого уровня, мы должны наблюдать этот эффект.
Например, злоумышленник сгенерировал себе код сертификата и пытается его активировать, пытаясь послать несколько запросов подряд на активацию купона. Небольшой абстрактный пример — сервис генерации подарочных сертификатов (кодов) и их использования. И в некоторых ситуациях может возникнуть двойная или даже тройная активация купона (пользователь получит 2x/3x бонусов). В таком случае у нас запустится несколько параллельно исполняемых транзакций, работающих с одним и тем же купоном. Подобную проблему можно также решить с помощью запроса SELECT FOR UPDATE, который также заблокирует обновляемую запись (купон). При repeatable read в данном случае возникнет lock и активация пройдет единожды, а в предыдущих 2 уровнях возможна многократная активация.
Serializable
Уровень, при котором транзакции ведут себя как будто ничего более не существует, никакого влияния друг на друга нет. В классическом представлении этот уровень избавляет от эффекта чтения фантомов.
Шаг 1. Начинаем транзакции.
Шаг 2. Т2 читаем таблицу accounts, затем Т1 пытаемся обновить данные прочитанные Т2.
Получаем lock: мы не можем изменить данные в одной транзакции, прочитанные в другой.
Шаг 3. И CREATE и DELETE ведет нас к lock'у в Т1.
Мы получаем максимальную согласованность данных, никакие лишние данные не зафиксируются. Пока Т2 не завершит свою работу, мы не сможем работать с данными, которые она прочитала. Цена за это медленная скорость транзакций из-за частых lock'ов поэтому при плохой архитектуре приложения это может сыграть с Вами злую шутку.
Выводы
В большинстве приложений уровень изолированности редко меняется и используется значение по умолчанию (например, в MySQL это repeatable read, в PostgreSQL — read committed).
Но периодически возникают, задачи, в которых поиск лучшего баланса между высокой согласованностью данных или скоростью выполнения транзакций может помочь решить некоторую прикладную задачу.