В этой статье я на простом примере покажу, как пронаблюдать аномалию несогласованное чтение (read skew) в postgresql, и как уровень изоляции транзакции Repeatable Read позволяет устранить эту аномалию.

Наблюдаем аномалию

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

id

name

active

1

Alex

true

2

Sam

true

3

Felix

false

Запустим консольную утилиту printer, которая будет печатать список активных (active=true) пользователей в таблице users и их количество.

~$ printer
1 Alex
2 Sam
total:  2

Чтобы напечатать такой список, printer делает следующие запросы в postgresql.

begin;

select id, name from users
where active = true
order by id;
 id | name  
----+-------
  1 | Alex
  2 | Sam

select count(*) from users
where active = true;
 count 
-------
     2

commit;

Теперь запустим консольную утилиту virus, которая нарушит работу утилиты printer. Все, что будет делать virus, - это с высокой частотой отправлять в postgresql следующий запрос.

update users set active = not active
where id = 3;

Если запустить printer, пока работает virus, то увидим несогласованные данные.

~$ printer
1 Alex
2 Sam
3 Felix
total:  2

Действительно, результаты чтения одной и той же таблицы в рамках одной транзакции противоречат друг другу. Это и есть несогласованное чтение. Причиной тому стал sql-запрос от virus, который "вклинился" между двумя sql-запросами printer и исключил из списка активных пользователей одну строку.

printer                           virus

begin;

select id, name from users
where active = true
order by id;
 id | name  
----+-------
  1 | Alex
  2 | Sam
  3 | Felix

                                  update users set active = not active
                                  where id = 3;

select count(*) from users
where active = true;
 count 
-------
     2

commit;

Утилита printer использует уровень изоляции транзакции Read Committed (читать зафиксированное) - уровень изоляции по умолчанию в postgresql, который активируется, если пишем просто begin. Как видно из названия, этот уровень изоляции позволяет читать зафиксированные изменения, что и сделала транзакция printer. Утилита printer прочитала зафиксированные изменения транзакции virus. Да, именно транзакции: если явно не написать begin/commit, то postgresql помещает команду в рамки индивидуальной транзакции. Иными словами, команду от virus postgresql воспринимает следующим образом.

begin;

update users set active = not active where id = 3;

commit;

Устраняем аномалию с помощью Repeatable Read

Воспользуемся уровнем изоляции Repeatable Read. Повторим эксперимент и убедимся, что чтение теперь всегда согласованно, и утилита virus больше не в силах помешать корректной работе printer.

begin isolation level repeatable read;

Каким образом чтение стало согласованным? Проведем еще один эксперимент, в котором разберемся, как видят таблицу users транзакция printer и транзакция virus. Для анализа результатов этого эксперимента, нужно упомянуть еще два понятия postgresql:

  • номер транзакции;

  • версия строки.

Каждой транзакции postgresql выдает порядковый номер. Этот номер можно увидеть, вызвав стандартную функцию pg_current_xact_id.

begin;

select pg_current_xact_id();
 pg_current_xact_id 
--------------------
                1043

commit;

Строка в postgresql - это логическое понятие. Физически postgresql хранит так называемые версии строк. Каждой строке может соответствовать сразу несколько версий. Причем транзакция в один момент времени видит только одну версию строки - логическую строку. Определить, какая версия видна транзакции, помогают два поля: xmin и xmax. Эти поля являются частью метаинформации версии строки и не выводятся в результате обычного select *. Чтобы вывести эти поля, их потребуется явно указать в запросе.

select *, xmin, xmax from users;

В поле xmin записывается номер транзакции, создавшей версию строки. В поле xmax записывается номер транзакции, удалившей версию строки. Фактически версия строки просто помечается как удаленная с помощью поля xmax. Версия строки, помеченная как удаленная, физически удаляется не сразу - ее некоторое время могут видеть транзакции, которые запустились раньше той транзакции, в которой эта версия была удалена.

Вернемся к эксперименту. Будем использовать уровень Repeatable Read для изоляции транзакции printer, а также выведем номера транзакций и информацию о версиях строк:

  • xmin (для удобства будем использовать псевдоним tx_created);

  • xmax (для удобства будем использовать псевдоним tx_deleted).

Создаем строки.

begin;

select pg_current_xact_id();
 pg_current_xact_id 
--------------------
                742

insert into users (id, name, active) values 
(1, 'Alex', true), 
(2, 'Sam', true), 
(3, 'Felix', true);

commit;

Запускаем транзакции printer и virus.

printer                                             virus

begin isolation level repeatable read;

select pg_current_xact_id();
 pg_current_xact_id 
--------------------
                743

select id, name, active,
  xmin as tx_created, 
  xmax as tx_deleted 
from users
order by id;
 id | name  | active | tx_created | tx_deleted 
----+-------+--------+------------+------------
  1 | Alex  | t      |        742 |          0
  2 | Sam   | t      |        742 |          0
  3 | Felix | t      |        742 |          0

                                                     begin;

                                                     select pg_current_xact_id();
                                                      pg_current_xact_id 
                                                     --------------------
                                                                     744
                                                     
                                                     update users set active = not active
                                                     where id = 3;
                                                     
                                                     select id, name, active,
                                                       xmin as tx_created, 
                                                       xmax as tx_deleted 
                                                     from users
                                                     order by id;
                                                      id | name  | active | tx_created | tx_deleted 
                                                     ----+-------+--------+------------+------------
                                                       1 | Alex  | t      |        742 |          0
                                                       2 | Sam   | t      |        742 |          0
                                                       3 | Felix | f      |        744 |          0
                                                     
                                                     commit;

select id, name, active,
  xmin as tx_created, 
  xmax as tx_deleted 
from users
order by id;
 id | name  | active | tx_created | tx_deleted 
----+-------+--------+------------+------------
  1 | Alex  | t      |        742 |          0
  2 | Sam   | t      |        742 |          0
  3 | Felix | t      |        742 |        744

commit;

Команда update отметила версию строки с пользователем Felix и полем active=true как удаленную своей транзакцией и создала новую версию этой строки с active=false. Теперь у строки с пользователем Felix целых две версии.

id

name

active

tx_created

tx_deleted

1

Alex

true

742

0

2

Sam

true

742

0

3

Felix

true

742

744

3

Felix

false

744

0

Транзакция printer с уровнем изоляции Repeatable Read вплоть до момента фиксации (commit) видит только актуальные версии строк, созданные до вызова первого select. Совокупность версий строк, которые видит транзакция, называется снимком данных (snapshot). Транзакция с уровнем Repeatable Read используют только один снимок данных. Соответственно, если транзакция использует один снимок данных, то она может сколько угодно раз читать из users и получать одни и те же данные. Об этом свойстве напоминает название Repeatable Read (читать повторно).

Напротив, транзакция printer с уровнем изоляции Read Committed в процессе работы создает новый снимок данных перед вызовом каждого select. Именно поэтому транзакция printer после фиксации транзакции virus видит новую версию строки с пользователем Felix - эта новая версия строки из нового снимка.

Упрощенно визуализировать все вышеописанное можно следующим образом.

Сравнение снимков
Сравнение снимков

Транзакция 742 создает три первых версии строки в таблице users, затем запускается транзакция 743 - читающая транзакция printer. В транзакции 744 команда update помечает одну версию строки с пользователем Felix как удаленную и создает новую версию.

Заключение

Использование Repeatable Read устранит аномалию несогласованное чтение, если вы столкнулись с ее проявлениями в процессе эксплуатации Read Committed, либо вообще не помещаете запросы на чтение в рамки транзакций. Repeatable Read - это решение, которое предоставляется средствами postgresql.

В зависимости от конкретной ситуации могут быть и другие решения. Например, в рассмотренном случае можно было бы сделать снимок данных средствами приложения: утилита printer могла бы не делать второй запрос select count(*), и в качестве общего количества активных пользователей рассматривать количество строк, которое вернул первый select. Этот набор строк - тоже снимок данных, но сделанный приложением.

У задачи может быть несколько решений - выбирайте оптимальное.

Список источников

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

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

  • Глава 2. Изоляция

  • Глава 4. Снимки данных

Все детали реализации postgresql можно найти в исходном коде. В частности функция GetTransactionSnapshot содержит реализацию алгоритма создания снимка данных для запроса в рамках транзакции.

Исходный код с приложениями для тестов и docker-compose.yml располагается здесь.

Комментарии (0)