PostgreSQL хранит таблицы страницами фиксированного размера (обычно 8 kB) и не позволяет кортежу занимать несколько страниц. Поэтому большие значения полей нельзя хранить в строке напрямую — их сжимают и/или разбивают на несколько физических строк. Этим занимается TOASTThe Oversized-Attribute Storage Technique, механизм хранения слишком больших значений полей вне основной строки. Разбираем, при каких порогах срабатывает его код, как устроены вытеснение, сжатие и чтение, и почему большие текстовые колонки могут как ускорять, так и замедлять запросы.
Когда срабатывает TOAST
Код управления TOAST включается, только когда значение строки шире TOAST_TUPLE_THRESHOLD — обычно 2 kB. После этого он сжимает и/или выносит значения полей out-of-lineза пределы основной строки, во внешнюю TOAST-таблицу, пока строка не станет короче TOAST_TUPLE_TARGET (тоже обычно 2 kB, настраивается) или пока это не перестанет давать выигрыш.
В коде предел данных кортежа считается как целевой размер минус накладные расходы заголовка:
maxDataLen = RelationGetToastTupleTarget(rel, TOAST_TUPLE_TARGET) - hoff;Здесь hoff — выровненный заголовок кортежа (плюс битмап NULL, если в строке есть NULL-значения); он вычитается, потому что заголовок тоже занимает место в строке, и полезным данным остаётся только остаток от целевого размера.
Цикл выполняется, пока размер данных кортежа превышает этот предел. Внутри выбирается самое большое поле: для стратегии EXTENDEDстратегия хранения, при которой значение сначала пробуют сжать, а затем при необходимости выносят во внешнюю TOAST-таблицу сначала пробуется сжатие, а если поле само по себе больше предела — оно сразу выносится во внешнюю TOAST-таблицу. Вынесенные значения делятся на куски не более TOAST_MAX_CHUNK_SIZE байт и хранятся отдельными строками в связанной TOAST-таблице.
Два бита varlena и предел в 1 ГБ
TOAST занимает два бита в слове длины varlenaформат хранения значений переменной длины в PostgreSQL, где слово длины предшествует самим данным. На big-endian машинах это старшие биты, на little-endian — младшие. Из-за этого логический размер любого значения TOAST-совместимого типа ограничен 1 ГБ (2^30 − 1 байт).
Комбинации этих двух битов задают один из трёх режимов хранения значения. Первый режим — обычное, не-TOAST-значение: оба бита нулевые, а оставшиеся биты слова длины дают полный размер в байтах. Второй режим — короткий однобайтовый заголовок: установлен старший (на big-endian) или младший (на little-endian) бит, и такой заголовок экономит место для значений короче 127 байт. Третий режим — сжатое значение: старший/младший бит сброшен, но установлен соседний с ним бит, и содержимое должно быть распаковано перед использованием.
Порядок вытеснения и сжатия
Функция heap_toast_insert_or_update разбивает кортеж на поля, инициализирует контекст обработки и вычисляет предел данных. Дальше работа идёт раундами.
Режим хранения атрибута задаёт, что с ним можно делать. EXTENDED разрешает и сжатие, и вынос. EXTERNALрежим, при котором значение можно вынести во внешнюю TOAST-таблицу, но не сжимать разрешает только вынос. MAINрежим, при котором значение сжимают, но выносят во внешнюю таблицу только в крайнем случае разрешает сжатие, а вынос — лишь когда иначе не обойтись. PLAINрежим, при котором значение не сжимается и не выносится, а всегда хранится в строке не допускает ни того, ни другого.
В первом раунде, пока данные больше предела, выбирается самый крупный атрибут. Если его режим — EXTENDED, он сжимается на месте; если EXTERNAL, атрибут помечается несжимаемым, чтобы не тратить на него сжатие в дальнейшем. Если после сжатия атрибут всё ещё больше предела и есть TOAST-таблица, он сразу выносится наружу.
Во втором раунде оставшиеся inline-атрибуты EXTENDED или EXTERNAL выносятся внешне. В третьем сжимаются атрибуты MAIN, а затем выносятся — но уже с увеличенным лимитом, чтобы MAIN не покидал строку без реальной необходимости:
maxDataLen = TOAST_TUPLE_TARGET_MAIN - hoff;PLAIN не участвует ни в сжатии, ни в выносе: отбор идёт только по MAIN либо по EXTENDED/EXTERNAL, а внешние и уже сжатые значения пропускаются.
toast_tuple_target
toast_tuple_target — параметр хранения таблицы, задающий целевой размер строки, до которого TOAST сжимает и/или выносит поля. По умолчанию это около 2 kB сжатых данных, всё сверх этого переносится в TOAST автоматически. Настраивается для каждой таблицы через ALTER TABLE ... SET (toast_tuple_target = N).
Как устроено хранение значения
Out-of-line on-disk TOAST-указатель создаётся при сохранении слишком большого кортежа и хранит OID TOAST-таблицы и OID конкретного значения, а также логический размер, физический размер и метод сжатия; его полный размер — 18 байт. OID конкретного значения — это chunk_idидентификатор вынесенного значения, общий для всех его чанков в TOAST-таблице; в TOAST-таблице он играет роль первой колонки, по которой скан находит все чанки одного значения, и совпадает с valueid, по которому настраивается поиск при удалении.
In-memory указатели указывают на данные в памяти текущего процесса, недолговечны и никогда не попадают на диск. Среди них indirect-указатели просто ссылаются на не-indirect varlena в памяти, а expanded-указательуказатель на развёрнутое представление сложного типа в памяти бывает read-write (значение можно менять на месте) и read-only (нужно копировать). Перед записью на диск in-memory указатели автоматически разворачиваются в обычные inline varlena.
On-disk значения после сжатия делятся на чанки не более TOAST_MAX_CHUNK_SIZE байт — по умолчанию около 2000, чтобы четыре строки чанков помещались на страницу. Каждый чанк — отдельная строка TOAST-таблицы с колонками chunk_id, chunk_seq и chunk_data. Уникальный индекс по chunk_id и chunk_seq обеспечивает быстрый поиск; при вставке индекс обновляется для каждого индекса, помеченного как готовый, а при удалении скан настраивается по первой колонке (valueid).
Чтение и распаковка
Когда запросу нужно прочитать внешне хранимое значение, Postgres не обязан поднимать его целиком: при выборке части данных (например, при доступе к отдельному фрагменту значения) он восстанавливает только запрошенный фрагмент. При этом извлекаются метаданные, полный размер, OID TOAST-отношения и идентификатор значения, проверяется, что датум действительно on-disk, и при необходимости корректируются границы среза. Сжатые датумы поддерживаются только для префикса — смещение среза обязано быть нулевым.
Для несжатых external-дат есть быстрый путь: распаковывать нечего, и фрагмент читается сразу. Для сжатых приходится либо тянуть достаточно сжатых чанков для распаковки префикса (для PGLZ вычисляется максимальный размер сжатых данных под префикс, для LZ4 тянется всё целиком), либо весь датум.
Чанки читаются по индексу (valueid, chunkidx), поэтому приходят по порядку. Цикл извлекает очередной чанк и проверяет, что его номер совпадает с ожидаемым, что он в диапазоне и что его размер соответствует ожидаемому — последний чанк короче остальных. Копирование в результат учитывает границы среза относительно размера чанка. В конце проверяется, что все ожидаемые чанки получены, иначе выдаётся ошибка о пропущенном чанке.
Выбор метода сжатия
Метод сжатия при записи выбирается по значению параметра: для PGLZ вызывается pglz_compress_datum, для LZ4 — lz4_compress_datum, иное значение даёт ошибку. Имя метода переводится в код сравнением строк "pglz" и "lz4"; при сборке без LZ4 для lz4 выдаётся ошибка.
Оба метода умеют сообщать, что данные несжимаемы: PGLZ — когда размер значения вне допустимого диапазона или сжатие не удалось, LZ4 — когда сжатие не уменьшило данные. Общая обёртка в этом случае сразу сообщает о несжимаемости, а даже при успехе требует экономии более 2 байт — иначе результат освобождается и значение считается несжимаемым. Атрибут, который не удалось сжать, помечается несжимаемым и пропускается при последующих проходах сжатия.
Как было раньше
Параметр default_toast_compression по умолчанию равен pglz. Компрессию для конкретного столбца можно задать декоратором COMPRESSIONуказанием COMPRESSION в определении столбца при CREATE TABLE или в команде ALTER TABLE, которое выбирает метод сжатия для этого столбца при CREATE TABLE или ALTER TABLE, например ALTER TABLE squeeze ALTER payload SET COMPRESSION pglz, а затем переключить на lz4 той же командой.
У pglz есть особенности: он пропускает значения меньше 32 байт, требует экономии не менее 25% и полностью отказывается от сжатия, если в первых 1 KB ничего не находит для сжатия.
Почему большие текстовые колонки тормозят
TOAST-указатель заменяет вынесенное значение в основной строке, поэтому строки с выносом заметно меньше строк без него. Это уменьшает размер heap. Но при чтении любое вынесенное значение требует как минимум одного обращения к TOAST-таблице: чтения по TOAST-индексу и чтения самой TOAST-таблицы на каждый чанк. Чанки нужно собирать обратно — дополнительный расход памяти, а сжатые данные ещё и распаковывать — расход памяти и CPU. Heap читается быстрее, но затем Postgres тратит много времени на поиск по TOAST-таблице.
Отсюда асимметрия. Когда Postgres работает напрямую с heap, производительность сильно страдает, а TOAST-указатели могут служить оптимизацией — особенно если вынесенный столбец не читается, потому что делают каждую строку в heap меньше.
Что показывают замеры
Автор статьи сравнивает две таблицы: event_log, где большой столбец вынесен в TOAST, и non_toast без выноса. Размеры — 47 MB против 318 MB. Время подсчёта числа строк с обращением к большому столбцу: 26ms против 91ms. Выборка с OFFSET 99999: 16ms против 53ms. UPDATE ... SET shares = shares + 1 по одной строке: 62ms против 128ms.
По числу чтений буферов картина зависит от того, трогают ли запросы TOAST-колонку. При обращении к ней (event_data->>'field_999') агрегат показывает shared hit=246472 read=41655, тогда как Seq Scan по самой таблице — только shared read=5969: основная часть чтений приходится не на heap, а на извлечение и распаковку toasted-значений. Суммарно toasted-колонка требует 288127 чтений буферов против 40754 у non-toasted версии, хотя сама toasted-таблица существенно меньше. Без обращения к колонке (при подсчёте строк) toasted-таблица дешевле: shared hit=282 read=5567 против shared hit=282 read=40670.
pglz против lz4
На 100k строках по 10k сжатых значений lz4 при декомпрессии немного медленнее — примерно на 20% в этом случае, но при сжатии более чем в 30 раз быстрее. Вставка 100000 строк с repeat('Postgres!', 10000) заняла 52179.203 ms для pglz и 1774.048 ms для lz4. Последующий SELECT sum(length(payload)) — 17108.284 ms для pglz и 20929.714 ms для lz4. Автор отмечает, что скорости сжатия драматически выше у тех, кто переходит на lz4.
Альтернатива: вынос в отдельную таблицу
Предлагаемая схема — вынести большой столбец в отдельную таблицу event_log_data с тем же первичным ключом event_id, а объединённый доступ давать через VIEW event_log_all, соединяющий обе таблицы через LEFT JOIN по event_id. Это убирает необходимость тащить TOAST-указатели в основной таблице приложения и переселяет даже не-Toast-значения, которые попали в основной heap из-за порога toast_tuple_target.
По размеру: основной heap event_log занимает 5096 kB, отдельная таблица event_log_data — 46 MB. По скорости: LEFT JOIN делает данные необязательными, и запросы, не указывающие столбец event_data, отсекают JOIN — например, запрос по event_id = 42 идёт через Index Scan с Buffers: shared hit=3.
Ограничения и открытые вопросы
Авторы признают, что разделение таблицы требует вводить дополнительный join для тех частей приложения, которые реально работают с громоздким столбцом. Обходной приём с представлением они называют некрасивым хаком по сравнению с сохранением таблицы целиком.
Приём нельзя применять при каждом появлении большого столбца, иначе схема превратится в хаос. И не все столбцы достаточно «необязательны», чтобы их выносить. Трюк с LEFT JOIN делает данные о событии необязательными, хотя значение есть в каждой строке, — и запросы, не указывающие этот столбец, отсекают join.