Learning Platform
Глоссарий Troubleshooting
Урок 09.04 · 30 мин
Продвинутый
String EncodingPrefix EncodingDELTA_BYTE_ARRAYDELTA_LENGTH_BYTE_ARRAYDictionary OverflowFSSTORC Direct

Строковые кодировки: за пределами словаря

В Уроке 02 мы разобрали, как dictionary encoding работает в пяти форматах — и где он ломается. Когда кардинальность превышает порог (1 MB Parquet page, stripe ratio в ORC), словарь становится бесполезным: overhead на хранение самого словаря превышает экономию на индексах.

Но строки — самый распространённый тип данных в аналитических хранилищах: URL, email, JSON-поля, user_agent, текстовые описания, log messages. Если словарь не работает — что делать? В Модуле 02 мы видели PLAIN и DELTA_BYTE_ARRAY как альтернативы. Теперь разберём всю палитру строковых кодировок и поймём, почему для строк всё ещё не было хорошего решения — до появления FSST.

PLAIN: length-prefixed byte arrays

Самая простая кодировка строк — PLAIN. Каждая строка хранится как пара (length, bytes):

PLAIN строковое кодирование: length + bytes
Входные строки4 URL-а. Каждый — произвольная строка переменной длины. Нет общих подстрок, кардинальность = 4 — словарь бесполезен.
Parquet PLAIN (enum 0)Каждая строка: 4 байта little-endian длина + UTF-8 байты. Нет трансформации — просто сериализация. Decode: O(1) — прочитать длину, прочитать байты. Но нет сжатия.
Overhead анализ4 байта overhead на каждую строку. Для 1 миллиона строк по 20 символов: data = 20 MB, overhead = 4 MB (20%). Для коротких строк (5 символов): data = 5 MB, overhead = 4 MB (80%).

PLAIN — это baseline, не кодировка. Никакого сжатия, никакой трансформации. Форматы используют PLAIN как fallback: Parquet — когда dictionary overflow, ORC — direct encoding для high-cardinality строк.

Prefix Encoding (общий префикс)

Когда строки отсортированы (или почти отсортированы), соседние значения часто делят общий префикс. Prefix encoding (Parquet: DELTA_BYTE_ARRAY) хранит только длину общего префикса и суффикс:

Prefix Encoding: общий префикс + суффикс

Без prefix encoding

Отсортированные URL4 URL отсортированы. Общий префикс 'https://api.example.com/' повторяется 3 раза. PLAIN сохранит его полностью в каждой строке — 27 байт × 3 = 81 байт впустую.

С prefix encoding

Prefix length + suffixКаждая строка: длина общего префикса с предыдущей + оставшийся суффикс. Первая строка = полная. Вторая: 27 общих байт → сохраняем только 'users/42'. Экономия на повторяющихся префиксах.

Prefix encoding эффективен только для отсортированных данных. Для случайного порядка общий префикс между соседями = 0, и кодировка вырождается в PLAIN с дополнительным overhead.

Parquet DELTA_BYTE_ARRAY (enum 7)

Parquet реализует prefix encoding как DELTA_BYTE_ARRAY:

Parquet DELTA_BYTE_ARRAY: два потока

Отсортированные строки: [s₁, s₂, s₃, …]

Входные строки, обычно отсортированные (после sort_columns или dictionary fallback). Без сортировки — prefix encoding бесполезен.

prefix_lengths → DELTA_BINARY_PACKED

Для каждой пары (sᵢ, sᵢ₊₁) вычисляем длину общего префикса. Массив prefix_lengths кодируется DELTA_BINARY_PACKED — дельты между длинами префиксов компактны.

suffixes → DELTA_LENGTH_BYTE_ARRAY

Суффиксы (то, что осталось после общего префикса) конкатенируются в один byte array. Их длины — тоже DELTA_BINARY_PACKED (для строк одинаковой длины дельты будут около нуля).

Decode: prefix от предыдущей + suffix

Decode: для i-й строки — прочитать prefix_length[i], скопировать первые prefix_length[i] байт из предыдущей строки, добавить suffix[i]. O(1) амортизированно, но зависимость от предыдущей строки.
NOTE

DELTA_BYTE_ARRAY имеет sequential dependency: чтобы декодировать строку N, нужно знать строку N-1 (для копирования префикса). Это не позволяет random access внутри page — нужно декодировать с начала. Для analytics workloads (full column scan) это не проблема, но для point lookups — да.

Delta-Length Encoding (длины отдельно)

Когда строки не отсортированы, но имеют похожую длину (log messages, fixed-format records), выделение длин в отдельный поток даёт выигрыш:

DELTA_LENGTH_BYTE_ARRAY: длины отдельно от данных
Входные данные: log linesLog messages фиксированного формата. Длины: 45, 47, 44, 46. Дельты длин: +2, -3, +2 — всего 2 бита на дельту. А вот контент полностью уникален — словарь бесполезен.

Lengths: [45, 47, 44, 46]

Длины строк (45, 47, 44, 46) кодируются DELTA_BINARY_PACKED. Дельты: [45, +2, -3, +2]. Минимальный bit-width. Для строк одинаковой длины: все дельты = 0.

→ DELTA_BINARY_PACKED (12 бит)

DELTA_BINARY_PACKED: base=45, дельты в zigzag → [0, 4, 5, 4] → bit-packed 3 бит. 4 × 3 = 12 бит вместо 4 × 32 = 128 бит.

Bytes: конкатенация без разделителей

Байты строк конкатенированы подряд без разделителей. Длины определяют границы. Нет сжатия самих байтов — только устранение length overhead.

→ Raw bytes (182 байта)

182 байта контента записаны подряд. Для доступа к i-й строке: сложить lengths[0..i-1] — получить offset. O(n) для random access, O(1) амортизированно при sequential scan.

Выигрыш DELTA_LENGTH_BYTE_ARRAY:

  • Длины кодируются компактно: если длины похожи, дельты минимальны → несколько бит на строку вместо 32
  • Нет 4-byte length prefix на каждую строку: длины вычисляются из delta-encoded массива
  • Но: сам контент строк не сжимается. Для URL с высокой кардинальностью — это лучше PLAIN, но далеко от dictionary

ORC: Direct String Encoding

ORC использует другой подход к high-cardinality строкам. Вместо prefix/delta-length, ORC переключается на direct encoding — три отдельных потока:

ORC Direct vs Dictionary: потоки строковых данных

Dictionary mode (low cardinality)

PRESENT streamByte RLE bitmap: 1 = значение есть, 0 = NULL. Отсутствует для NOT NULL колонок. Кодирование: Byte RLE (8 boolean на байт).
DATA streamИндексы в словарь. Кодируются RLEv2 (4 подкодировки). Для 10 уникальных значений — 4 бита на индекс.
DICTIONARY_DATAУникальные строки, отсортированы лексикографически. Позволяет binary search для range predicates.
LENGTH streamДлины строк в словаре. RLEv2-encoded. Для фиксированных длин (country codes) — минимальный overhead.

Direct mode (high cardinality)

PRESENT streamТот же bitmap — одинаковый для обоих режимов. NULL handling не зависит от выбора encoding.
DATA streamВ direct mode — raw UTF-8 байты всех строк, конкатенированные. Нет словаря. Каждая строка хранится полностью.
LENGTH streamДлины каждой строки (не словарных записей). RLEv2-encoded. Для строк одинаковой длины — очень компактно (Short Repeat).

Ключевое отличие ORC от Parquet: ORC кодирует длины строк отдельно через RLEv2, что даёт компрессию длин. Parquet PLAIN хранит 4-byte length перед каждой строкой. Для миллиона строк одинаковой длины: ORC LENGTH stream → RLEv2 Short Repeat = десятки байт, Parquet PLAIN → 4 MB length overhead.

Проблема dictionary overflow

Самый болезненный сценарий для строковых колонок — dictionary overflow: колонка начинается с повторяющихся значений, но по мере записи кардинальность растёт и словарь переполняется.

Dictionary overflow: каскад проблем

Начало записи: 1000 unique values, dict = 15 KB

Writer начинает запись строковой колонки. Первые 500K строк — 1000 уникальных department names. Словарь: 1000 × ~15 байт = 15 KB. Отлично работает.

Рост кардинальности: 500K unique → dict = 10 MB

В середине column chunk появляются user_id-like значения. Кардинальность взрывается: 500K уникальных строк. Словарь растёт: 500K × 20 байт = 10 MB. Parquet dictionary page limit = 1 MB.
Порог превышен

Parquet: PLAIN для всего chunk. Первые 500K тоже теряют сжатие.

Parquet: весь column chunk переключается на PLAIN. Уже записанные dictionary-encoded pages переписываются. Потеря: CPU на перекодирование + потеря сжатия для первых 500K строк, которые отлично сжимались словарём.

ORC: direct mode для stripe. Длины через RLEv2.

ORC: stripe-level решение. Если ratio словарь/данные > порога — direct mode. Но ORC stripes = 256 MB, так что решение принимается раньше, на этапе buffer flush.

Mixed cardinality: ни dictionary, ни PLAIN не оптимальны

Результат: для mixed-cardinality данных (часть значений повторяется, часть уникальна) ни dictionary, ни PLAIN не оптимальны. Dictionary теряет на overhead, PLAIN теряет на повторах. Нужен гибрид.

DuckDB: multi-variant решение

DuckDB не ограничивается бинарным выбором dictionary/PLAIN. Его Analyzer перебирает варианты:

DuckDB: multi-variant string encoding
ВариантКодировка, которую DuckDB Analyzer рассматривает для строковых данных
Когда выбираетсяУсловие, при котором Analyzer предпочитает этот вариант
КомпрессияТипичный коэффициент сжатия для этого варианта
СкоростьСкорость декодирования (чтение)
DictionaryКлассический словарь с bit-packed индексами. Эффективен для <~100K уникальных строк в сегменте (122K rows).
Cardinality < thresholdDuckDB оценивает кардинальность по sample. Если ratio уникальных значений к общему количеству ниже порога — dictionary.
10–100xЗависит от кардинальности и длины строк. 100 unique × 20 байт = 2 KB словарь, 122K × 4 бит индексы = 61 KB.
БыстроDictionary lookup: один random access для decode. O(1) per value.
FSSTFast Static Symbol Table — compression для high-cardinality строк. Заменяет частые подстроки на 1-byte символы из таблицы 256 записей. Подробно — Модуль 09.
High cardinality, long stringsДлинные уникальные строки (URL, JSON, log messages). FSST выгоднее PLAIN: ~3x сжатие при ~1 GB/s decode.
~3xFSST сжимает ~3x на типичных текстовых данных. Для структурированных строк (JSON, URLs) — до 5x. Для random binary — ~1x.
~1 GB/sDecode: замена 1-byte символов на подстроки через lookup table. Без блочной декомпрессии — random access возможен.
ConstantВсе значения в сегменте одинаковы. Хранится одна строка + count. Trivially: 1 значение вместо 122K.
Все значения равныСегмент, где все 122K строк — одно значение. Встречается: default columns, status fields, partition columns.
122K:1Идеальная компрессия: одна строка вместо 122K. Для 20-byte строк: 20 байт вместо 2.4 MB.
O(1)Decode: вернуть одну и ту же строку для любого index. Максимальная скорость.
UncompressedFallback: строки хранятся как есть. Когда ни один вариант не даёт выигрыша (random binary data, very short strings).
Ничего не помогаетЕсли Dictionary, FSST и Constant не дают экономии, DuckDB сохраняет строки без encoding.
1xБез сжатия. Overhead: только metadata (offsets, nullmask).
memcpyКопирование байтов. Максимальная скорость decode, но максимальный размер на диске.

Кросс-форматное сравнение строковых кодировок

Строковые кодировки: 5 форматов × 4 параметра
Параметр сравнения строковых кодировок
ParquetApache Parquet: PLAIN, RLE_DICTIONARY, DELTA_LENGTH_BYTE_ARRAY, DELTA_BYTE_ARRAY
ORCApache ORC: Dictionary + Direct encoding с LENGTH stream
ArrowApache Arrow: StringArray (offsets + data) или DictionaryArray
DuckDBDuckDB: Dictionary, FSST, Constant, Uncompressed
AvroApache Avro: только varint length + UTF-8 bytes
Low cardinalityСтроки с небольшим количеством уникальных значений (department, country, status)
RLE_DICTIONARYСловарь + RLE/bit-packed индексы. Fallback на PLAIN при overflow. Решение per-column-chunk.
Dictionary + RLEv2Отсортированный словарь + RLEv2 индексы. Range predicate pushdown. Решение per-stripe.
DictionaryArrayТип массива, не кодировка. Явно создаётся пользователем. Int8/16/32 индексы для O(1) lookup.
DictionaryBit-packed индексы. Analyzer автоматически выбирает при низкой кардинальности.
Avro не имеет dictionary encoding. Enum-тип в schema — фиксированный список строк.
High cardinalityСтроки с большим количеством уникальных значений (UUID, email, URL, free text)
DELTA_BYTE_ARRAYPrefix encoding для отсортированных строк. Для неотсортированных: PLAIN (no compression). DELTA_LENGTH_BYTE_ARRAY для строк одинаковой длины.
Direct (DATA+LENGTH)Raw UTF-8 + RLEv2 длины. Лучше Parquet PLAIN: длины сжаты отдельно. Но сам контент — raw bytes.
StringArrayLargeStringArray: int64 offsets + contiguous buffer. В памяти — без сжатия. При IPC — может применяться compression.
FSSTSymbol table compression: ~3x на текстах, random access, equality на сжатых данных. Единственный формат с нетривиальной string compression.
varint + UTF-8Avro: varint (1–5 байт) длина + raw UTF-8 bytes. Нет сжатия строк.
Sorted stringsОтсортированные строки — лучший сценарий для prefix encoding
DELTA_BYTE_ARRAYМаксимальная эффективность: длинные общие префиксы → короткие суффиксы. Для sorted URLs: 3–5x сжатие.
DictionarySorted strings = sorted dictionary. Отличная компрессия + range predicate pushdown.
Arrow не оптимизирует для sorted strings. В памяти — всегда offsets + buffer.
Dictionary / FSSTDuckDB может использовать dictionary (если кардинальность в пределах) или FSST (если нет). Sort order не влияет на выбор.
Avro не оптимизирует sorted строки.
Random accessВозможность прочитать строку по индексу без декодирования предыдущих
Только PLAIN/DictPLAIN: O(n) — нужно сложить длины. Dict: O(1) — index → lookup. DELTA_BYTE_ARRAY: O(n) — зависимость от предыдущей.
Direct: O(n)Direct: сложить LENGTH[0..i]. Dict: O(1) lookup. PRESENT: bit-адресация.
O(1)Arrow StringArray: offset[i] даёт позицию. O(1) random access. DictionaryArray: O(1) index + O(1) lookup.
FSST: O(1)FSST — random access без блочной декомпрессии. Каждая строка декодируется независимо. В отличие от LZ4/Zstd, где нужно декодировать весь блок.
varint: O(n)Varint length — переменная длина. Чтобы найти строку N, нужно прочитать N-1 длин. O(n).

Почему нужен был FSST

Подведём итог проблемы строковых кодировок к 2020 году:

Gap в строковом кодировании: до и после FSST
DictionaryРаботает для low-cardinality (< ~100K unique): 10–100x сжатие. Ломается на high-cardinality: overhead словаря > экономии на индексах. Порог: Parquet 1 MB page, ORC ratio-based.
Prefix / Delta-LengthPrefix: работает только для sorted строк (общий префикс → короткие суффиксы). Delta-Length: экономит на длинах, но не сжимает контент. Оба — нишевые решения.
Block Compression (LZ4/Zstd)Сжимает страницы/блоки целиком. Работает для любых данных, но: (1) нет random access — декодируй весь блок, (2) нельзя сравнивать сжатые строки, (3) CPU overhead на decode.
FSST (2020): заполнение gapFSST (Fast Static Symbol Table) — первая кодировка, которая даёт ~3x сжатие строк с random access и возможностью сравнения сжатых данных. Детально — в Модуле 09 (Урок 06).

Ключевые выводы

  1. PLAIN — не кодировка, а baseline. Length-prefixed bytes без сжатия. Используется как fallback, когда ничего лучше не работает.
  2. Prefix encoding (DELTA_BYTE_ARRAY) эффективен только для отсортированных строк — общий префикс соседей даёт 2–5x. Для случайного порядка — вырождается в PLAIN + overhead.
  3. DELTA_LENGTH_BYTE_ARRAY отделяет длины от данных и кодирует их delta — экономит на length overhead, но не сжимает контент строк.
  4. ORC direct encoding выделяет LENGTH stream с RLEv2 — компактнее Parquet PLAIN для строк одинаковой длины.
  5. Dictionary overflow — главная проблема: колонка, которая начинается с повторов, но потом взрывается уникальными значениями. Parquet теряет сжатие для всего chunk. DuckDB пробует FSST как альтернативу.
  6. До 2020 года у строк не было хорошего ответа на high-cardinality + random access. FSST (Модуль 09) заполняет этот gap: ~3x сжатие, random access, equality на сжатых данных.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Parquet DELTA_BYTE_ARRAY кодирует строки через prefix sharing. Для отсортированных URL ['api.com/a', 'api.com/b', 'api.com/c'] — что хранится?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6