Внутренности PostgreSQL: механизм TOAST

14 сентября 2022

Ранее мы разобрались, как PostgreSQL хранит страницы на диске, и как можно посмотреть их содержимое при помощи pageinspect. Но возникает вот какой вопрос. Если размер страницы составляет несколько килобайт, и кортежи не могут занимать несколько страниц, то как СУБД хранит атрибуты с типом TEXT? Ведь строки явно могут быть длиннее пары тысяч символов. Это возможно благодаря штуке под названием The Oversized-Attribute Storage Technique, или сокращенно TOAST.

Идея простая. Если в таблице есть атрибуты с типом TEXT / JSONB / так далее, то рядом с таблицей создается служебная TOAST-таблица. Когда размер кортежа превышает 1/4 размера страницы, СУБД смотрит на атрибуты кортежа и пытается их сжать. Затем сжатые данные нарезаются на части (chunks) заведомо меньше размера страницы и складываются в TOAST-таблицу. Атрибуты в исходном кортеже заменяются на TOAST-указатели. Кортеж становится меньше и теперь помещается в страницу. Это если в двух словах. Есть еще ряд нюансов. О них будет рассказано ниже по тексту.

Рассмотрим пример:

CREATE TABLE phonebook(
  "id" SERIAL PRIMARY KEY NOT NULL,
  "name" TEXT NOT NULL,
  "phone" INT NOT NULL);

Имеем атрибут с типом TEXT. Значит, где-то должна быть TOAST-таблица:

=# SELECT c1.oid, c1.reltoastrelid, c2.relname
   FROM pg_class AS c1
   LEFT JOIN pg_class AS c2
          ON c1.reltoastrelid = c2.oid
   WHERE c1.relname = 'phonebook';

-[ RECORD 1 ]-+---------------
oid           | 39542
reltoastrelid | 39546
relname       | pg_toast_39542

Что же это за таблица такая:

=# \d pg_toast.pg_toast_39542

TOAST table "pg_toast.pg_toast_39542"
   Column   |  Type
------------+---------
 chunk_id   | oid
 chunk_seq  | integer
 chunk_data | bytea
Owning table: "public.phonebook"
Indexes:
    "pg_toast_39542_index" PRIMARY KEY, btree (chunk_id, chunk_seq)

Заметьте, что таблица живет в схеме pg_toast. По умолчанию схема отсутствует в search_path, поэтому ее нужно указывать явно.

Спрашивается, откуда PostgreSQL знает, какие типы являются TOAST-able, а какие — нет? Конечно же, из описания типа в таблице каталога pg_type:

=# SELECT typlen, typstorage
   FROM pg_type
   WHERE typname = 'text';

-[ RECORD 1 ]----
typlen     | -1
typstorage | x

Значение typlen = -1 говорит о том, что TEXT является типом переменного размера. Или, как принято говорить в мире PostgreSQL, varlena типом. Данная информация нам еще пригодится. В typstorage может быть записано одно из четырех значений: p = PLAIN, x = EXTENDED, e = EXTERNAL, m = MAIN. Это называется стратегией хранения.

PLAIN означает, что атрибуты хранятся в кортежах как есть, и TOAST для них выключен. Это стратегия для типов INT4, CHAR, и так далее. EXTENDED означает, что TOAST включен. Данная стратегия используется для TEXT, JSONB, и многих других типов. EXTERNAL — то же самое, что EXTENDED, только отключает сжатие данных. По умолчанию эта стратегия не используется ни для каких типов. Наконец, MAIN означает сжимать данные, но пытаться хранить их в самом кортеже. Сжатые данные будут помещены в TOAST-таблицу только если это единственный способ уместить кортеж в страницу. Это стратегия для типов INET, CIDR и NUMERIC.

Может показаться, что EXTERNAL — какая-то бесполезная стратегия, если она ни для чего не используется. На самом деле, это не так. Во-первых, пользователь может указать ее для самостоятельно объявленных типов: CREATE TYPE foo STORAGE = external. Во-вторых, стратегия хранения может быть переопределена для конкретной колонки конкретной таблицы:

ALTER TABLE phonebook ALTER COLUMN name SET STORAGE external;

EXTERNAL имеет смысл использовать, например, когда вы знаете, что данные будут плохо сжиматься. Также эта стратегия ускоряет выделение подстрок из длинных значений с типом TEXT и BYTEA.

Также можно переопределить и используемый алгоритм сжатия:

ALTER TABLE phonebook ALTER COLUMN name SET COMPRESSION lz4;

На момент написания этих строк поддерживалось два алгоритма — PGLZ и LZ4. Первый был в PostgreSQL испокон веков, и он используется по умолчанию. Алгоритм LZ4 доступен, если PostgreSQL был собран с флагом --with-lz4. Обычно LZ4 сжимает данные похуже других алгоритмов, зато он очень быстрый. Из особенностей PGLZ стоит отметить тот факт, что если ему не удается сжать первые 1024 байта входных данных, алгоритм сдается и говорит, что эти данные несжимаемые. Если у вас какие-то особые данные, первые байты которых плохо сжимаются, может иметь смысл дописать к ним в начале нулевых байт.

Узнать, какие стратегии хранения и какие алгоритмы сжатия используются для колонок заданной таблицы, можно при помощи \d+.

Когда срабатывает правило про 1/4 страницы, PostgreSQL не пытается сжать и нарезать сразу все атрибуты кортежа. На самом деле, используется алгоритм из четырех проходов:

  1. Только для EXTENDED и EXTERNAL атрибутов. EXTENDED атрибуты сжимаются. Если размер атрибута превышает 2 Кб, он нарезается в TOAST-таблицу;
  2. Только для EXTENDED и EXTERNAL атрибутов. В этот раз все атрибуты нарезаются в TOAST-таблицу, безотносительно их размера;
  3. Только для MAIN атрибутов. Они сжимаются, но не нарезаются в TOAST-таблицу;
  4. Только для MAIN атрибутов. Если дело дошло до этого прохода, значит мы в беде. MAIN атрибуты нарезаются в TOAST-таблицу;

На каждом проходе атрибуты рассматриваются начиная с самого длинного и заканчивая самым коротким. Алгоритм останавливается в любой момент, как только кортеж умещается в 1/4 страницы. Полную реализацию можно посмотреть в heap_toast_insert_or_update().

На самом деле, по поводу 1/4 страницы — это не совсем правда. Значение по умолчанию задается макросом TOAST_TUPLE_TARGET. Для стандартного размера страницы 8 Кб значение макроса равно 2032 байта:

>>> page_size = 8192      # размер страницы
>>> page_header_size = 24 # размер заголовка страницы
>>> itemiddata_size = 4   # размер ItemIdData
>>> tuples_per_page = 4   # желаемое число кортежей на страницу
>>>
>>> temp = page_size
>>> temp -= page_header_size + itemiddata_size * tuples_per_page
>>> temp /= tuples_per_page
>>> temp = int(temp / 8) * 8
>>> temp
2032

Это число можно переопределить на уровне таблицы:

-- Значение 4080 посчитано, как показано выше, с tuples_per_page = 2
ALTER TABLE phonebook SET (toast_tuple_target = 4080);

-- Чтобы вернуть значение по умолчанию:
ALTER TABLE phonebook RESET (toast_tuple_target);

Но тут есть важный момент! Переопределенный toast_tuple_target лишь говорит, до какого размера TOAST должен пытаться ужать кортеж. Он не определяет, когда TOAST должен сработать. Это значение вычисляется так же, как было показано выше, но пользователь не может его переопределить. В коде оно задается макросом TOAST_TUPLE_THRESHOLD.

Ни TOAST_TUPLE_THRESHOLD, ни toast_tuple_target не являются жестким ограничением на максимальный размер кортежа. Например, такой запрос:

=# SELECT format(
  'CREATE TABLE foo AS SELECT %s',
  array_to_string(
    array_agg(format('1 AS c%s', col_num)),
    ', '
  )
) FROM generate_series(1, 1024) col_num
\gexec

… завершится вполне успешно, несмотря на то, что 1024 столбца с типом INT4 занимают больше половины страницы.

Есть еще одна тонкость, заслуживающая внимания. Рассмотрим такой пример:

CREATE TABLE test(val VARCHAR(16));

SELECT c1.oid, c1.reltoastrelid, c2.relname
   FROM pg_class AS c1
   LEFT JOIN pg_class AS c2
          ON c1.reltoastrelid = c2.oid
   WHERE c1.relname = 'test';

Здесь TOAST-таблица не будет создана, несмотря на то, что в исходной таблице есть EXTENDED атрибут. PostgreSQL видит, что размер кортежа не может превысить TOAST_TUPLE_THRESHOLD, а значит TOAST-таблица не нужна. Если вы смените тип столбца на VARCHAR(4096), то TOAST-таблица появится. Если снова смените тип на VARCHAR(16), СУБД удалит TOAST-таблицу. Констрейнты предсказуемым образом не влияют на наличие или отсутствие TOAST-таблицы.

Выше говорилось про TOAST-указатели. Давайте разберемся, что конкретно они собой представляют:

typedef struct varatt_external
{
  int32   va_rawsize;    /* исходный размер данных */
  uint32  va_extinfo;    /* размер сжатых данных + алгоритм сжатия */
  Oid     va_valueid;    /* chunk_id в TOAST-таблице */
  Oid     va_toastrelid; /* Oid TOAST-таблицы */
} varatt_external;

Алгоритм сжатия (или его отсутствие) кодируется двумя старшими битами в поле va_extinfo.

Указатели хранятся в кортеже не просто так. Они являются частным случаем представления varlena типа. Всего же возможных представлений четыре:

xxxxxx00 xxxxxxxx xxxxxxxx xxxxxxxx, несжатые данные
xxxxxx10 xxxxxxxx xxxxxxxx xxxxxxxx, сжатые данные
00000001 xxxxxxxx, TOAST-указатель
xxxxxxx1, несжатые данные длиной до 126 байт

Это иллюстрация для little-endian машин. На big-endian данные кодируются чуть иначе, но принцип тот же.

Здесь xxx… — это биты, хранящие длину атрибута. Длина включает длину varlena заголовка. В первых двух случаях, когда длина кодируется 30-ю битами, varlena заголовок хранится с выравниванием до четырех байт. Для выравнивания используются нулевые байты. Таким образом, данные трактуются однозначно. Во втором случае, который со сжатыми данными, первый четыре байта полезной нагрузки хранят длину распакованных данных. Поскольку длина кодируется 30-ю битами, в varlena типе можно сохранить не более 1 Гб данных, минус 4 байта на длину заголовка. На практике это число несколько меньше, поскольку раньше возникнет ошибка «invalid memory alloc request size».

Заинтересованным читателям предлагается взять pageinspect, и проверить, что varlena данные действительно хранятся так, как описано выше. Подсказка — воспользуйтесь функцией tuple_data_split(). Занимательный факт: PostgreSQL хранит атрибуты с типом CHAR как varlena значения. Убедитесь в этом при помощи pageinspect. Как вы думаете, почему так сделано?

Таким образом, вместе с varlena заголовком TOAST-указатель занимает 18 байт. В битах xxx… будет записано именно это значение. Вспомним, что по умолчанию размер страницы составляет 8 Кб, минус накладные расходы на заголовок страницы и тд. Легко видеть, что создавать таблицы с сотнями столбцов varlena типов — не лучшая затея. Если увлечься, то при вставке новой строки можно получить ошибку «row is too big».

В настоящее время предпринимаются различные попытки улучшения TOAST. Предлагается добавить возможность инкрементального обновления данных, частичной распаковки и переиспользования общих данных между атрибутами. Есть также предложение сделать TOAST расширяемым. Это серьезные изменения, и на данном этапе остается много открытых вопросов. Например, не совсем понятно, как все это должно работать с table access methods. Тем не менее, что-то из названного может появиться в будущих версиях PostgreSQL.

Дополнение: Внутренности PostgreSQL: ProcArray и CLOG

Метки: , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.