Архитектура
Внутреннее устройство
12
Авторские права
© Postgres Professional, 2020 год.
Авторы: Егор Рогов, Павел Лузанов
Использование материалов курса
Некоммерческое использование материалов курса (презентации,
демонстрации) разрешается без ограничений. Коммерческое
использование возможно только с письменного разрешения компании
Postgres Professional. Запрещается внесение изменений в материалы
курса.
Обратная связь
Отзывы, замечания и предложения направляйте по адресу:
Отказ от ответственности
Компания Postgres Professional не несет никакой ответственности за
любые повреждения и убытки, включая потерю дохода, нанесенные
прямым или непрямым, специальным или случайным использованием
материалов курса. Компания Postgres Professional не предоставляет
каких-либо гарантий на материалы курса. Материалы курса
предоставляются на основе принципа «как есть» и компания Postgres
Professional не обязана предоставлять сопровождение, поддержку,
обновления, расширения и изменения.
2
Темы
Страницы
Версии строк
Снимки данных
3
Страницы
Структура страниц
Формат данных
4
Страницы
заголовок
страницы
указатели на
версии строк
заголовок
версии строки
специальная
область
версия
строки
табличная страница страница индекса
– 4 байта
Размер страницы составляет 8 Кбайт. Это значение можно увеличить
(вплоть до 32 Кбайт), но только при сборке. И таблицы, и индексы,
и большинство других объектов, которые в PostgreSQL обозначаются
термином relation, используют одинаковую структуру страниц, чтобы
пользоваться общим буферным кешем. В начале страницы идет
заголовок (24 байта), содержащий общие сведения и размер областей
страницы: указателей, свободного пространства, версий строк и
специальной области.
Версии строк содержит те самые данные, которые мы храним
в таблицах и других объектах БД, плюс заголовок. «Версия строки» по-
английски называется tuple; иногда мы будем говорить просто «строка».
Указатели имеют фиксированный размер (4 байта) и составляют
массив, позиция в котором определяет идентификатор строки (tuple id,
tid). Указатели ссылаются на версии строк (tuple), расположенные
в конце блока. Такая косвенная адресация удобна тем, что во-первых,
позволяет найти нужную строку, не перебирая все содержимое блока
(строки имеют разную длину), а во-вторых, позволяет перемещать
строку внутри блока, не ломая ссылки из индексов.
Между указателями и версиями строк находится свободное место.
Некоторые типы индексов нуждаются в хранении служебной
информации; для этого они могут использовать специальную область
в конце страницы.
5
Формат данных
Страницы читаются в оперативную память «как есть»
данные не переносимы между разными платформами
между полями данных возможны пропуски из-за выравнивания
Формат данных на диске полностью совпадает с представлением
данных в оперативной памяти. Страница читается в буферный кеш
«как есть», без преобразований.
Поэтому файлы данных на одной платформе (разрядность, порядок
байтов и т. п.) оказываются несовместимыми с другими платформами.
Кроме того, многие архитектуры предусматривают выравнивание
данных по границам машинных слов. Например, на 32-битной системе
x86 целые числа (тип integer, занимает 4 байта) будут выровнены по
границе 4-байтных слов, как и числа с плавающей точкой двойной
точности (тип double precision, 8 байт). А в 64-битной системе значения
double precision будут выровнены по границе 8-байтных слов.
Из-за этого размер табличной строки зависит от порядка расположения
полей. Обычно этот эффект не сильно заметен, но в некоторых случаях
он может привести к существенному увеличению размера. Например,
если располагать поля типов char(1) и integer вперемешку, между ними,
как правило, будет пропадать 3 байта.
7
Версии строк
Структура версий строк
Как работают операции над данными
HOT-обновления
8
100
Вставка
xmin xmax данные
100 0 42,FOO
committed
aborted
XACT
номер транзакции,
создавшей версию
номер транзакции,
удалившей версию
значения полей
табличной строки
версия строки
в таблице
Рассмотрим, как устроены версии строк (tuples) и как выполняются
операции со строками на низком уровне. Начнем со вставки.
В нашем примере — таблица с двумя столбцами (число и текст).
При вставке строки в табличной странице (heap page) появится
указатель с номером 1, ссылающийся на первую и единственную
версию строки. Каждый такой указатель помимо ссылки содержит длину
версии строки и несколько бит, определяющих ее статус.
Версии строк кроме собственно данных имеют также заголовок,
занимающий минимум 23 байта. Помимо прочего он содержит:
- xmin и xmax — поля, определяющие видимость данной версии строки
в терминах начального и конечного номеров транзакций;
- карту неопределенных значений (в ней отмечены поля, равные NULL);
- ряд признаков, показывающих, например, статус транзакций xmin и
xmax, если он уже известен (зафиксированы или оборваны).
В нашей версии строки поле xmin заполнено номером текущей
транзакции (100). Поскольку изменения еще не фиксировались и
транзакция активна, то в журнале статуса транзакций (XACT)
соответствующая запись заполнена нулями. XACT можно представить
себе как массив, в котором для каждой транзакции (начиная
с некоторой) отводится ровно два бита.
Поле xmax заполнено фиктивным номером 0. Транзакции не будут
обращать внимание на этот номер, поскольку установлен признак, что
эта транзакция оборвана.
9
100
Вставка
xmin xmax данные
100 0 42,FOO
ctid ключ
committed
aborted
(0,1) FOO
XACT
указатель на версию
строки в таблице
значения ключей
индексирования
строка
индекса
версия строки
в таблице
индекс не содержит информации о версионности
Пусть также имеется индекс B-дерево, созданный по текстовому полю.
Информация в индексной странице сильно зависит от типа индекса.
И даже у одного типа индекса бывают разные виды страниц. Например,
у B-дерева есть страница с метаданными и «обычные» страницы.
Тем не менее, обычно в странице имеется массив указателей и строки
(так же, как и в табличной странице).
Строки в индексах тоже могут иметь очень разную структуру
в зависимости от типа индекса. Например, для B-дерева строки,
относящиеся к листовым страницам, содержат значение ключа
индексирования и ссылку (ctid) на соответствующую строку таблицы
(структура B-дерева разбирается в теме «Классы операторов» и
в учебном курсе QPT «Оптимизация запросов»).
В общем случае индекс может быть устроен совсем другим образом, но
как правило он все равно будет содержать ссылки на версии строк.
В нашем примере в индексной странице также создается указатель
с номером 1, который ссылается на индексную строку, которая, в свою
очередь, ссылается на первую строку в табличной странице. Чтобы не
загромождать рисунок, указатель и строка объединены.
Важный момент состоит в том, что никакой индекс не содержит
информацию о версионности (нет полей xmin и xmax). Прочитав строку
индекса, невозможно определить видимость этой строки, не заглянув
в табличную страницу (для оптимизации служит карта видимости).
10
данныеxmaxxmin
100
committed
aborted
0
Фиксация изменений
при первом
обращении к строке
другой транзакцией
100 42,FOO
ctid ключ
(0,1) FOO
(0,1)
t
Когда транзакция фиксируется, в XACT для этой транзакции
выставляется признак committed. Это, по сути, единственная операция
(не считая журнала предзаписи), которая необходима.
Когда какая-либо другая транзакция обратится к версии строки, ей
придется ответить на вопросы:
1. завершилась ли транзакция 100 (надо проверить список активных
процессов и их транзакций; такая структура в общей памяти имеет
название ProcArray),
2. а если завершилась, то фиксацией или отменой (свериться с XACT).
Поскольку выполнять проверку по XACT каждый раз накладно,
выясненный однажды статус транзакции записывается в информа-
ционные биты-подсказки заголовка строки. Если один из этих битов
установлен, то состояние транзакции xmin считается известным и
следующей транзакции уже не придется обращаться к XACT.
Почему эти биты не устанавливаются той транзакцией, которая
выполняла вставку? В момент, когда транзакция фиксируется или
отменяется, уже непонятно, какие именно строки в каких именно
страницах транзакция успела поменять. Кроме того, часть этих страниц
может быть вытеснена из буферного кеша на диск; читать их заново,
чтобы изменить биты, означало бы существенно замедлить фиксацию.
Обратная сторона состоит в том, что любая транзакция (даже
выполняющая простое чтение — SELECT) может загрязнить данные
в буферном кеше и породить новые журнальные записи.
11
xmaxxmin данные
100 42,FOO
Удаление
100
committed
aborted
ctid ключ
(0,1) FOO
(0,1)
t
101
выступает
как блокировка
строки
101
При удалении строки в поле xmax текущей версии записывается номер
текущей удаляющей транзакции, а признак оборванной транзакции
сбрасывается. Больше ничего не происходит.
Заметим, что установленное значение xmax, соответствующее
активной транзакции (что определяется другими транзакциями по
ProcArray), выступает в качестве блокировки. Если другая транзакция
намерена обновить или удалить эту строку, она будет вынуждена
дождаться завершения транзакции xmax.
Подробнее блокировки рассматриваются в теме «Блокировки» этого
модуля. Пока отметим только, что число блокировок строк ничем не
ограничено. Они не занимают место в оперативной памяти,
производительность системы не страдает от их количества
(разумеется, за исключением того, что первый процесс, обратившийся
к странице, должен будет проставить биты-подсказки).
12
xmaxxmin данные
Отмена изменений
100 42,FOO
100
committed
aborted
ctid ключ
(0,1) FOO
(0,1)
t
101
101
при первом
обращении к строке
другой транзакцией
t
Отмена изменений работает аналогично фиксации, только в XACT для
транзакции выставляется бит оборванной транзакции. Отмена
выполняется так же быстро, как и фиксация — не требуется выполнять
откат выполненных действий.
Номер прерванной транзакции остается в поле xmax. Когда другая
транзакция обратится к версии строки, она проверит статус транзакции
101 и установит в версию строки признак-подсказку, что транзакция
оборвана. Это будет означать, что на значение в поле xmax смотреть
не нужно.
13
xmaxxmin данные
102 42,BAR0
Обновление
100 42,FOO
100
committed
aborted
ctid ключ
(0,2) BAR
(0,1)
t
101
102
t
(0,1) FOO
(0,2)
102
ссылки
на обе версии
строки
перезаписан
номер отмененной
транзакции
при любом обновлении строки надо изменять все индексы на таблице
в индексе накапливаются ссылки на неактуальные версии
Обновление работает так, как будто сначала выполняется удаление
старой версии строки, а затем — вставка новой.
Старая версия помечается номером текущей транзакции в поле xmax.
Обратите внимание, что новое значение 102 записалось поверх старого
101, так как транзакция 101 была отменена. Кроме того, биты-подсказки
сброшены, так как статус текущей транзакции еще не известен.
В индексной странице появляется второй указатель и вторая строка,
ссылающаяся на вторую версию в табличной странице.
Так же, как и при удалении, значение xmax в первой версии строки
служит признаком того, что строка заблокирована.
Чем плохо такое обновление?
Во-первых, при любом изменении строки приходится обновлять все
индексы, созданные для таблицы (даже если измененные поля не
входят в индекс). Очевидно, это снижает производительность.
Во-вторых, в индексах накапливаются ссылки на исторические версии
строки, которые потом приходится очищать.
Более того, есть особенность реализации B-дерева в PostgreSQL. Если
на индексной странице недостаточно места для вставки новой строки,
страница делится на две и все данные перераспределяются между
ними. Однако при удалении (или очистке) строк две индексные
страницы не «склеиваются» в одну. Из-за этого размер индекса может
не уменьшиться даже при удалении существенной части данных.
15
xmaxxmin данные
HOT-обновление
102 42,BAR0
100 42,FOO
100
committed
aborted
ctid ключ
(0,1) 42
(0,1)
t
101
102
t
(0,2)
102
ссылка
только на голову
цепочки
версии
строк объединены
в список
обновляемые столбцы не должен входить ни в один индекс
цепочка обновлений — только в пределах одной страницы
Однако если индекс создан по полю, значение которого не изменилось
в результате обновления строки, то нет смысла создавать
дополнительную строку в B-дереве, содержащую то же самое значение
ключа. Именно так работает оптимизация, называемая HOT-
обновлением — Heap-Only Tuple Update.
При таком обновлении в индексной странице находится лишь одна
строка, ссылающаяся на первую версию строки табличной страницы.
А внутри табличной страницы организуется цепочка из версий, на
которые гарантированно нет внешних ссылок (heap-only tuples).
Если при сканировании индекса PostgreSQL попадает в табличную
страницу и обнаруживает версию, начинающую цепочку, он проходит по
всей цепочке обновлений (для всех полученных таким образом версий
строк проверяется видимость, прежде чем они будут возвращены).
Подчеркнем, что HOT-обновления работают в случае, если
обновляемые поля не входят ни в один индексиначе в каком-либо
индексе оказалась бы ссылка непосредственно на новую версию
строки, что противоречит идее этой оптимизации. В частности, HOT-
обновление используется, если у таблицы вообще нет индексов.
Оптимизация действует только в пределах одной страницы, поэтому
дополнительный обход цепочки не требует обращения к другим
страницам и не ухудшает производительность. Однако если на
странице не хватит свободного места, чтобы разместить новую версию
строки, цепочка прервется. На версию строки, размещенную на другой
странице, будет сделана новая ссылка из индекса.
17
Снимки данных
Видимость версий строк
Снимок данных
«Горизонт событий» транзакции и базы данных
18
Снимок
Дает согласованную картину данных на момент времени
видны только зафиксированные на этот момент данные
Создается:
Read Committed —
в начале каждого оператора
Repeatable Read, Serializable —
в начале первого оператора транзакции
xid
снимок
Мы видели, что в страницах данных физически могут находиться
несколько версий одной и той же строки.
Транзакции работают со снимком данных, который определяет, какие
версии должны быть видны, а какие — нет, чтобы обеспечить
согласованную картину данных на определенный момент времени
(на момент создания снимка — показан на рисунке синим цветом).
Согласованность здесь понимается в обычном ACID-смысле: данные
должны быть корректны, что, по сути, означает, что видеть нужно
только те данные, которые были зафиксированы на момент создания
снимка. Это полностью исключает аномалию грязного чтения на любом
уровне изоляции.
На уровне изоляции Read Committed снимок создается в начале
каждого оператора транзакции. Снимок активен, пока выполняется
оператор. Таким образом каждый оператор видит согласованные
данные, но если между двумя операторами будут зафиксированы
изменения, возможны неповторяемое и фантомное чтения.
На уровне Repeatable Read (и Serializable) снимок создается один раз
в начале первого оператора транзакции. Такой снимок остается
активным до самого конца транзакции. В этом случае неповторяемое и
фантомное чтения исключается.
19
Видимость версий строк
Видимость версии строки ограничена xmin и xmax
Версия попадает в снимок, когда
изменения транзакции xmin видны для снимка,
изменения транзакции xmax не видны для снимка
Изменения транзакции видны, когда
либо это та же самая транзакция, что создала снимок,
либо она завершилась фиксацией до момента создания снимка
Отдельные правила для видимости собственных изменений
учитывается порядковый номер операции в транзакции (cmin/cmax)
Будет или нет данная версия строки видна в снимке, определяется
двумя полями ее заголовка — xmin и xmax,то есть номерами
создавшей и удалившей транзакций. Такие интервалы не
пересекаются, поэтому одна строка представлена в любом снимке
максимум одной своей версией.
Точные правила видимости довольно сложны и учитывают различные
«крайние» случаи. Чуть упрощая, можно сказать, что версия строки
видна, когда в снимке видны изменения, сделанные транзакцией xmin,
и не видны изменения, сделанные транзакцией xmax. Иными словами,
создание строки должно быть видно, а удаление (если оно было) —
нет.
В свою очередь, изменения транзакции видны в снимке, если либо это
та же самая транзакция, что создала снимок (она сама видит свои же
изменения), либо транзакция была зафиксирована до создания снимка.
Несколько усложняет картину случай определения видимости
собственных изменений транзакции. Здесь может потребоваться
видеть только часть таких изменений. Например, курсор, открытый
в определенный момент, ни при каком уровне изоляции не должен
увидеть изменений, сделанных после этого момента. Для этого
в заголовке версии строки есть специальное поле (псевдостолбцы cmin
и cmax), показывающее номер операции внутри транзакции, и этот
номер тоже принимается во внимание.
20
Снимок данных
xmin — номер самой ранней активной транзакции
xmax — номер, следующий за номером последней
зафиксированной транзакции
xip_list — список активных транзакций
xid
xmax = 5xmin = 2
снимок
1
2
3
4
5
список активных транзакций: 2, 4
На рисунке черные отрезки показывают транзакции. Нам известен
момент начала транзакции (xid = xmin или xmax из заголовка версии
строки), но момент завершения транзакции нигде не записывается. Мы
лишь можем узнать текущий статус транзакций при создании снимка.
Если транзакция завершилась фиксацией до создания снимка (имеет
статус committed в XACT), то ее изменения должны быть видны
в снимке (транзакции 1 и 3 на рисунке).
Если транзакция началась после создания снимка, то ее изменения не
должны попадать в снимок (транзакция 5).
Изменения транзакций, которые были начаты до создания снимка, но
в момент создания снимка еще не завершились, также не должны быть
видны (транзакции 2 и 4).
Поскольку после создания снимка уже невозможно понять, была ли
транзакций активна в момент создания, необходимо заранее
запомнить список текущих активных транзакций (используя структуру
ProcArray, которая содержит все активные сеансы и их транзакции).
Итак, снимок данных определяется несколькими параметрами:
- номером самой ранней из активных транзакций (xmin снимка);
- номером, после которого нет зафиксированных транзакций (xmax,
который можно считать «моментом создания» снимка);
- списком активных транзакций (xip_list = xid in progress).
Таким образом, в снимке видны транзакции xid: xmin <= xid < xmax,
не входящие при этом в xip_list.
21
«Горизонт событий»
xid
xmaxxmin
список активных транзакций
снимок
1
2
3
4
5
неактуальные
версии строк
не нужны снимку
горизонт
Номер самой ранней из активных транзакций (xmin снимка) имеет
важный смысл — он определяет «горизонт событий» транзакции.
А именно, за своим горизонтом транзакция всегда видит только
актуальные версии строк.
Действительно, видеть неактуальную версию требуется только в том
случае, когда актуальная создана еще не завершившейся транзакцией,
и поэтому не видна. Но за «горизонтом» все транзакции уже
гарантированно завершились.
22
«Горизонт событий» БД
xid
xmin
неактуальные
версии строк
можно очищать
неактуальные
версии строк
нельзя очищать
снимок
«долгоиграющей»
транзакции
активный
снимок
горизонт
Существует и «горизонт событий» на уровне базы данных. Для его
определения надо взять все активные снимки и среди них найти
минимальный (самый ранний) xmin. Он и будет определять горизонт,
за которым неактуальные версии строк в этой БД уже никогда не будут
видны ни одной транзакции. Такие версии строк могут быть очищены.
Если какая-либо транзакция удерживает снимок в течении долгого
времени, она тем самым удерживает и горизонт событий базы данных.
Более того, незавершенная транзакция удерживает горизонт самим
фактом своего существования (даже на уровне Read Committed).
Рисунок иллюстрирует такую ситуацию.
А это означает, что неактуальные версии строк в этой БД не смогут
быть очищены. При этом совершенно не важно, какие данные читала
или изменяла «долгоиграющая» транзакция. Она может вообще никак
не пересекаться по данным с другими транзакциями — горизонт базы
данных один на всех.
Это одна из причин, по которым транзакции не следует делать длиннее,
чем абсолютно необходимо.
24
Итоги
PostgreSQL использует многоверсионность и изоляцию
на основе снимков данных
Индексы не содержат информации о версионности
чтобы вернуть данные, нужно проверить видимость
(по таблице или карте видимости)
Лишние индексы не только влекут накладные расходы,
но и препятствуют HOT-обновлениям
Транзакции должны быть насколько длинными,
насколько необходимо, но не более
25
Практика
1. Функционал точек сохранения должен позволять откатывать
часть транзакции. Чтобы разобраться, как это работает,
выполните операции внутри одной транзакции:
– вставьте строку в таблицу;
– установите точку сохранения (SAVEPOINT);
– вставьте в таблицу еще одну строку;
– откатите изменения до точки сохранения;
– вставьте еще одну строку;
– зафиксируйте изменения.
При этом после каждой операции выводите номер текущей
транзакции и проверяйте значения столбцов xmin и xmax.