Параллельные транзакции и уровни изоляции: как защитить данные от скрытых ошибок

Как защитить данные от скрытых ошибок: параллельные транзакции и уровни изоляции

Когда с одними и теми же данными одновременно работают несколько пользователей или сервисов, добавление и изменение информации происходят параллельно. Чтобы при этом данные оставались согласованными, базы данных используют транзакции.

Пока транзакции выполняются строго по очереди, проблем не возникает. На практике же они зачастую выполняются параллельно и могут влиять друг на друга. Последствия такого влияния не всегда очевидны: ошибок в журналах нет, каждая операция по отдельности отрабатывает корректно, но итоговое состояние данных может нарушать бизнес-правила. Степень защиты от такого влияния определяется уровнем изоляции транзакций.

В этой статье Java-разработчики MediaSoft разбирают, какие феномены и аномалии возникают при параллельном выполнении транзакций, какие гарантии дают уровни изоляции и как при этом ведет себя PostgreSQL. Выводы основаны на практических экспериментах, а скрипты, приведенные в статье, можно воспроизвести на PostgreSQL. Материал помогает увидеть, где скрываются риски для данных и бизнес-правил, и какой ценой достигаются строгие гарантии корректности.

Параллельные транзакции: что может пойти не так

Транзакция — это набор операций над данными в БД, рассматриваемых как одно целое, который переводит данные из одного согласованного состояния в другое. Выполнение транзакции требует определенного времени, в течение которого происходят открытие транзакции, выполнение операций с данными и фиксация или откат выполненных действий. Периоды выполнения нескольких транзакций могут пересекаться, т. е. транзакции могут выполняться параллельно. Параллельные транзакции могут влиять на выполнение друг друга.

Ситуация, когда транзакция видит данные не так, как она видела бы их, работая в полном одиночестве, без других параллельных транзакций, называется феноменом.

Помимо феноменов существует понятие аномалии. Обычно аномалию определяют так: «параллельный результат аномален, если его нельзя достичь ни одним последовательным (сериальным) выполнением тех же транзакций».
Далее рассмотрим, какие именно феномены выделяет стандарт SQL и как они проявляются при параллельном выполнении транзакций.

Три феномена по стандарту SQL

Стандарт SQL выделяет три феномена: грязное чтение, неповторяемое чтение и фантомное чтение. Ниже показано, как транзакция 1 (T1) может повлиять на транзакцию 2 (T2) в каждом из этих случаев. 

Грязное чтение

Транзакция T1 изменяет значение столбца 1 в строке 1 с a1 на a2, но еще не завершена: изменения уже внесены, однако не зафиксированы. Если в этот момент транзакция T2 читает это значение и получает a2, она видит данные, записанные T1 до фиксации.

Если затем T1 выполняет откат, оказывается, что T2 прочитала значение, которое не вошло в итоговое состояние базы данных.

Чтение одной транзакцией данных, записанных другой, но еще не завершенной транзакцией, называется грязным чтением.

Неповторяемое чтение

Транзакция T1 изменяет значение столбца 1 в строке 1 с a1 на a2 и фиксирует изменение в момент времени t1End. Транзакция T2 дважды читает значение этого столбца: первый раз — до t1End, второй — после t1End.

Если при первом чтении T2 получает a1, а при втором — a2, то одна и та же строка в рамках транзакции T2 возвращает разные значения, хотя сама T2 это значение не изменяла.

Такая ситуация называется неповторяемым чтением.

Фантомное чтение

Транзакция T1 вставляет в таблицу 1 новую строку и фиксирует изменение в момент времени t1End. Транзакция T2 дважды выполняет один и тот же запрос к этой таблице: первый раз — до t1End, второй — после t1End.

Если при первом выполнении запроса T2 получает 10 строк, а при втором — 11, то повторное чтение возвращает другой набор строк из-за изменений, зафиксированных транзакцией T1.

Такая ситуация называется фантомным чтением.

Уровни изоляции транзакций

Понятие уровня изоляции относится к выполнению конкретной транзакции. Уровень изоляции — это гарантия того, какое влияние на данную транзакцию могут оказать другие, параллельно выполняемые транзакции.

Выделяют четыре уровня изоляции транзакций: read uncommitted, read committed, repeatable read, serializable.

  • Read uncommitted. Для транзакции выполняемой в уровне изоляции read uncommitted нет никаких гарантий — во время выполнения транзакция может читать грязные данные, а также осуществлять неповторяемое и фантомное чтение.
  • Read committed. Гарантируется, что транзакция читает данные, измененные только зафиксированными транзакциями, т. е. не может прочитать грязные данные. При этом допускаются неповторяемое и фантомное чтение.
  • Repeatable read. Действуют ограничения уровня read committed, и дополнительно гарантируется повторяемость чтения: при неоднократном чтении транзакция получает одни и те же значения, если только сама их не меняла. При этом допускается фантомное чтение.
  • Отдельно стоит отметить уровень изоляции serializable. Он гарантирует, что транзакция не будет выполнять ни грязное, ни неповторяемое, ни фантомное чтение.

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

Может оказаться, что при параллельном выполнении транзакций феномены не возникли, но итоговый результат является аномалией (получен результат, который нельзя достичь никаким последовательным выполнением этих транзакций). И наоборот: для транзакции может возникнуть феномен, но результат параллельного выполнения транзакций аномалией не будет.

Аномалия без феноменов: на примере истории про двух врачей

Ситуация, в которой параллельное выполнение транзакций приводит к аномалии при отсутствии грязного, неповторяемого и фантомного чтения, может быть такой: представим, что есть два врача, каждый из которых решает, можно ли уйти домой, при условии что кто-то обязательно должен остаться на дежурстве. Логика врача представлена операциями: 1) посмотреть, сколько врачей находится в больнице; 2) если врачей больше одного — уйти домой, если только один — остаться на дежурстве.

При последовательном выполнении транзакций возможны две ситуации, в зависимости от того, кто первым попытается уйти домой: на дежурстве останется либо врач 1, либо врач 2.

Если же две транзакции (два врача, действующих по такой логике) выполняются параллельно, возможно, что никто не останется на дежурстве. Врач 1, увидев, что в больнице находятся два врача, решает уйти. Пока врач 1 собирается, врач 2 проводит такой же анализ, видит двух врачей и решает, что может уйти он. В результате уходят оба врача, и дежурить никто не остается. Такое было бы невозможно ни при каком порядке последовательного выполнения.

Это пример аномалии write skew: две транзакции параллельно читают одни и те же данные, принимают решение на основе этого чтения, а затем обновляют разные строки, нарушая общее бизнес-правило.

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

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

Что гарантирует стандарт SQL

Для наглядности ниже приведена матрица уровней изоляции по стандарту SQL (ISO 9075):

Уровень изоляции
Грязное чтение
(Dirty Read)
Неповторяемое чтение
(Non-repeatable Read)
Фантомное чтение
(Phantom Read)
Read Uncommitted
Грязное чтение: Разрешено
Неповторяемое чтение: Разрешено
Фантомное чтение: Разрешено
Read Committed
Грязное чтение: Запрещено
Неповторяемое чтение: Разрешено
Фантомное чтение: Разрешено
Repeatable Read
Грязное чтение: Запрещено
Неповторяемое чтение: Запрещено
Фантомное чтение: Разрешено
Serializable
Грязное чтение: Запрещено
Неповторяемое чтение: Запрещено
Фантомное чтение: Запрещено

 

Следует понимать, что «разрешено» не означает «обязано»: реальная база данных на уровне repeatable read может не допускать фантомного чтения, и это будет соответствовать стандарту. Кроме того, от «честного» serializable ожидается защита не только от указанных трех видов феноменов, но и гарантия отсутствия аномалии в зафиксированном результате.

Изоляция транзакций в PostgreSQL

PostgreSQL ограничивает влияние внешних транзакций строже, чем этого требует стандарт. В частности, в PostgreSQL невозможно грязное чтение, а уровень изоляции repeatable read защищает от феномена фантомного чтения. Уровень read uncommitted в PostgreSQL ведет себя так же, как read committed.

Уровень изоляции
«Грязное» чтение
Неповторяемое чтение
Фантомное чтение
Read uncommitted
«Грязное» чтение: Невозможно
Неповторяемое чтение: Возможно
Фантомное чтение: Возможно
Read committed
«Грязное» чтение: Невозможно
Неповторяемое чтение: Возможно
Фантомное чтение: Возможно
Repeatable read
«Грязное» чтение: Невозможно
Неповторяемое чтение: Невозможно
Фантомное чтение: Невозможно
Serializable
«Грязное» чтение: Невозможно
Неповторяемое чтение: Невозможно
Фантомное чтение: Невозможно

 

Несмотря на то что в PostgreSQL феномены грязного, неповторяемого и фантомного чтения невозможны не только на уровне serializable, но уже на уровне repeatable read, только использование serializable всеми параллельными транзакциями дает уверенность, что зафиксированный транзакцией результат не является аномалией. В частности, рассмотренная ранее аномалия write skew возможна на уровне repeatable read.

Для обеспечения уровней изоляции PostgreSQL использует механизм MVCC (Multi-Version Concurrency Control — многоверсионное управление конкурентным доступом), дополненный механизмами блокировок и отслеживания зависимостей.

На уровне serializable гарантия того, что параллельное выполнение транзакций не приведет к аномалии, строится не на реальной постановке транзакций в очередь и их последовательном выполнении, а на анализе графа зависимостей между данными, которые читаются и изменяются реально параллельно выполняющимися транзакциями (подход SSI — Serializable Snapshot Isolation).

Существует математическая основа, согласно которой параллельное выполнение транзакций эквивалентно некоторому последовательному выполнению тогда и только тогда, когда в графе зависимостей транзакций нет циклов. Таким образом, на уровне serializable PostgreSQL выполняет транзакции параллельно, но способен определить, что в итоге возникла бы аномалия. В этом случае PostgreSQL не позволяет зафиксировать транзакцию. Проверка носит консервативный характер: в ряде случаев транзакция может быть отклонена и при отсутствии фактической аномалии.

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

Для экспериментов используются PostgreSQL 17 и DBeaver. Перед каждым запуском будем создавать таблицу с данными, предварительно удаляя ее, если она уже существует. Эти же скрипты понадобятся для пересоздания таблицы между экспериментами: 

drop table if exists doctors;
create table doctors (
id int primary key,
name varchar(100),
is_active boolean
);
insert into doctors (id,name,is_active)
values 
(1, 'Айболит', true),
(2, 'Dr.House', true);

 

Для экспериментов откроем два окна для скриптов: по одному на каждую транзакцию. 

Эксперимент 1

Момент времени
Окно 1 (транзакция 1)
Окно 2 (транзакция 2)
1
begin isolation level read committed;
 
2
 
begin isolation level read committed;
3
select * from doctors d; 1 Айболит true
2 Dr.House true
 
4
 
select * from doctors d; 1 Айболит true
2 Dr.House true
5
update doctors set is_active = false where id = 1;
 
6
select * from doctors d; 2 Dr.House true
1 Айболит false
 
7
 
select * from doctors d; 1 Айболит true
2 Dr.House true
8
insert into doctors (id, name, is_active) values (3, 'Ватсон', true);
 
9
 
select * from doctors d; 1 Айболит true
2 Dr.House true
10
select * from doctors d; 2 Dr.House true
1 Айболит false
3 Ватсон true
 
11
commit;
 
12
 
select * from doctors d; 2 Dr.House true
1 Айболит false
3 Ватсон true
13
 
commit;

 

Что можно увидеть в эксперименте 1:

  1. Обе транзакции работают на уровне изоляции read committed.
  2. Транзакция 1 в моменты времени 6 и 10 видит собственные изменения, сделанные в моменты 5 и 8. Транзакция 2 в моменты 7 и 9 не видит этих изменений до тех пор, пока транзакция 1 не выполнит commit в момент 11. Это означает, что грязного чтения нет (незафиксированные изменения из окна 1 не читаются в окне 2).
  3. Транзакция 2 в моменты 9 и 12 читает разные значения is_active для Айболита, хотя сама это значение не меняла. Это феномен неповторяемого чтения.
  4. Транзакция 2 в моменты 9 и 12 читает разное количество строк таблицы doctors, хотя сама строки не добавляла и не удаляла. Это феномен фантомного чтения.
  5. Наблюдаются феномены, которые могут приводить к аномалии (неповторяемое и фантомное чтение), но в итоге аномалии нет: состояние таблицы doctors такое же, как при последовательном выполнении транзакций одна за другой.

Эксперимент 2

Момент времени
Окно 1 (транзакция 1)
Окно 2 (транзакция 2)
1
begin isolation level read committed;
 
2
 
begin isolation level repeatable read;
3
select * from doctors d; 1 Айболит true
2 Dr.House true
 
4
 
select * from doctors d; 1 Айболит true
2 Dr.House true
5
update doctors set is_active = false where id = 1;
 
6
insert into doctors (id, name, is_active) values (3, 'Ватсон', true);
 
7
 
select * from doctors d; 1 Айболит true 2 Dr.House true
8
select * from doctors d; 2 Dr.House true
1 Айболит false
3 Ватсон true
 
9
commit;
 
10
 
select * from doctors d; 1 Айболит true
2 Dr.House true
11
 
commit;

 

Что можно увидеть в эксперименте 2:

  1. Транзакция 2 работает на уровне изоляции repeatable read.
  2. В момент времени 10 транзакция 2 видит то же значение is_active для Айболита, что и в предыдущие моменты, хотя в момент 9 транзакция 1 зафиксировала свои изменения. Следовательно, повторяемое чтение выполняется.
  3. В момент времени 10 транзакция 2 видит те же две строки таблицы doctors, что и до момента 9, когда транзакция 1 зафиксировала добавление в doctors третьего врача. Таким образом, в PostgreSQL транзакция защищена от фантомного чтения уже на уровне изоляции repeatable read.

Эксперимент 3

Момент времени
Окно 1 (транзакция 1)
Окно 2 (транзакция 2)
1
begin isolation level read committed;
 
2
 
begin isolation level read committed;
3
select * from doctors d; 1 Айболит true
2 Dr.House true
 
4
 
select * from doctors d; 1 Айболит true
2 Dr.House true
5
update doctors set is_active = false where id = 1;
 
6
commit;
 
7
 
update doctors set name = 'ДобрыйДокторАйболит' where id = 1;
8
 
select * from doctors d; 2 Dr.House true
1 ДобрыйДокторАйболит false
9
 
commit;

 

В эксперименте 3 на уровне read committed транзакция 2 в момент времени 7 смогла изменить ту же строку, которую в момент 5 изменила и в момент 6 зафиксировала транзакция 1. Несмотря на то что был феномен неповторяемого чтения (транзакция 2 видит изменение, сделанное транзакцией 1), именно он, по-видимому, позволил в итоге избежать аномалии: строка содержит изменения обеих транзакций, как если бы транзакции выполнялись строго последовательно.

Если бы мы поменяли действия в моментах 6 и 7, т. е. транзакция 2 попыталась бы выполнить изменение до того, как транзакция 1 сделала фиксацию, то в момент 6 транзакция 2 была бы приостановлена в ожидании освобождения блокировки строки Айболита, которую установила транзакция 1 при редактировании. Как только транзакция 1 зафиксировалась бы, транзакция 2 продолжила бы работу, увидела бы данные, обновленные транзакцией 1, и применила бы свое изменение поверх них.

Эксперимент 4 

Момент времени
Окно1 (транзакция1)
Окно2 (транзакция2)
1
begin isolation level read committed;
 
2
 
begin isolation level repeatable read;
3
update doctors set is_active = false where id = 1;
 
4
commit;
 
5
 
update doctors set name = 'ДобрыйДокторАйболит' where id = 1; SQL Error: ERROR: could not serialize access due to concurrent update
6
 
select * from doctors d; SQL Error [25P02]: ERROR: current transaction is aborted, commands ignored until end of transaction block
7
 
сommit;

 

В эксперименте 4 попытка транзакции 2, работающей на уровне изоляции repeatable read, обновить данные в момент времени 6 приводит к ошибке: строка уже изменена и зафиксирована транзакцией 1 после того, как транзакция 2 получила снимок данных. Все последующие команды транзакции 2 игнорируются до ее завершения через rollback или commit (который в данной ситуации отрабатывает как rollback — откатывает все изменения, выполненные транзакцией 2).

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

Эксперимент 5

Эксперимент 5 моделирует рассмотренную ранее ситуацию: два врача в больнице хотят уйти домой, но кто-то должен остаться на дежурстве. Бизнес-правило системы: как минимум один врач всегда должен находиться в статусе «активен».

Транзакция, описывающая действия врача, состоит из чтения количества активных врачей и, в зависимости от результата, либо изменяет статус одного из врачей, либо ничего не делает.

Обозначим транзакцию которая читает и изменяет данные как rwT, а транзакцию которая только читает rT.

Если перед началом выполнения транзакций активны только два врача, а транзакции выполняются последовательно, то у нас не может быть двух rwT: вторая транзакция не изменит статус врача и поэтому будет являться rT.

Запустим транзакции параллельно:

Момент времени
Окно 1 (транзакция 1)
Окно 2 (транзакция 2)
1
begin isolation level serializable;
 
2
 
begin isolation level repeatable read;
3
select count(*) from doctors where is_active; 2
 
4
 
select count(*) from doctors where is_active; 2
5
Так как в момент времени 3 получено значение 2, выполняется update: update doctors set is_active = false where id = 1;
 
6
 
Так как в момент времени 4 получено значение 2, выполняется update: update doctors set is_active = false where id = 2;
7
 
commit;
8
commit;
 

 

В результате обе транзакции rwT зафиксировались, и активных врачей не осталось. Возможен ли такой результат при последовательном выполнении этих транзакций? Ранее выяснено, что последовательное выполнение двух транзакций rwT невозможно, следовательно, такого же результата последовательным выполнением получить нельзя. Значит, полученный результат является аномалией.

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

Эксперимент 6

Момент времени
Окно 1 (транзакция 1)
Окно 2 (транзакция 2)
1
begin isolation level serializable;
 
2
 
begin isolation level serializable;
3
select count(*) from doctors where is_active; 2
 
4
 
select count(*) from doctors where is_active; 2
5
Так как в момент времени 3 получено значение 2, выполняется update: update doctors set is_active = false where id = 1;
 
6
 
Так как в момент времени 4 получено значение 2, выполняется update: update doctors set is_active = false where id = 2;
7
 
commit;
8
commit; SQL Error: ERROR: could not serialize access due to read/write dependencies among transactions
 

 

В эксперименте 6 использование serializable обеими параллельными транзакциями приводит к тому, что одна из транзакций не фиксируется, и врач остается на дежурстве.

Является ли такой результат аномалией? Нет, так как фактически зафиксирована только одна транзакция, и ее результат эквивалентен последовательному выполнению списка из этой единственной зафиксированной транзакции. Аномалией была бы ситуация, если бы обе транзакции успешно зафиксировались, а полученный результат нельзя было бы получить ни при каком последовательном выполнении этих транзакций.

Анализ результатов двух последних экспериментов позволяет уточнить определение аномалии, приведенное в начале статьи. Параллельный результат аномален, если его нельзя достичь ни одним последовательным выполнением тех же транзакций. К этому следует добавить: при условии, что каждая последующая транзакция видела бы данные такими же, какими она видела их при параллельном выполнении. Без этого уточнения можно предположить: «транзакция врача состоит из чтения и изменения статуса; что мешает выполнить чтение и изменение статуса два раза последовательно и получить ноль врачей на дежурстве». Препятствием служит именно это условие: при последовательном применении на этапе чтения видим тоже самое что и при параллельном выполнении. Когда говорится, что логику ухода двух врачей нельзя выстроить последовательно, имеется в виду, что их нельзя выстроить так, чтобы каждый из них видел обоих врачей в больнице так же, как при параллельном выполнении.

Придерживаться ли такого понимания аномалии, остается на усмотрение читателя.

Выводы

В статье предпринята попытка определить и осмыслить, что такое феномены, уровни изоляции и аномалии при параллельном выполнении транзакций. Основные выводы:

  • Наличие феноменов не всегда приводит к аномальному результату. И наоборот, наличие аномалии не всегда означает наличие феноменов.
  • Аномалия и состояние гонки — разные проблемы: serializable защищает от аномалии, но не от гонки.
  • Ошибки, которые PostgreSQL возвращает при работе на уровнях строже read committed (код 40001), по своей сути не являются сбоем, а представляют собой защиту данных от аномальности.
  • Возможность получения такой ошибки следует учитывать при разработке. Например, повторное выполнение транзакции может привести к другому, уже не аномальному результату, который удастся зафиксировать.
  • Все возможные аномалии могут быть неизвестны, однако использование уровня serializable всеми транзакциями гарантирует, что зафиксированный транзакцией результат не является аномалией.

Как защитить данные на практике 

Рассмотренные эксперименты позволяют сформулировать несколько практических рекомендаций по защите данных:

  • Целесообразно заранее определить бизнес-правила, которые должны соблюдаться при параллельной работе (например, «всегда как минимум один врач в статусе "активен"»), и выделить операции, которые читают данные и на основании прочитанного изменяют другие данные: именно такие операции подвержены аномалиям вида write skew.
  • Надежной защитой для таких операций служит уровень serializable, при условии что на нем работают все параллельные транзакции, затрагивающие эти данные (эксперимент 5).
  • Отсутствие грязного, неповторяемого и фантомного чтения само по себе не исключает аномалию: write skew возможен на уровне repeatable read.
  • Отказ в фиксации транзакции с кодом 40001 (serialization failure) — штатная защита данных, а не сбой. Приложению следует выполнять транзакцию повторно с начала, и такие повторные попытки составляют цену более строгих гарантий. Выбор уровня изоляции — это компромисс между строгостью гарантий и необходимостью обрабатывать подобные отказы.
  • Поскольку дефекты, связанные с аномалиями, проявляются нерегулярно, для критичных операций полезно включать в тестирование сценарии параллельного выполнения: это снижает риск, который трудно заметить до появления некорректных данных в рабочей системе.