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

  • почему файл такой большой/маленький;

  • сколько групп строк и какой их размер;

  • какие кодировки для каких колонок используются;

  • можно ли файл эффективно отфильтровать по некому условию;

  • есть ли блюм фильтры и для каких колонок

  • и т.п.

В этой статье я постарался описать, как можно почитать метаданные parquet файла (спойлер: это оказалось не сложно). В последующих статьях планирую показать, как еще это можно сделать - более технологично, но без потери основного содержания.

Здесь пойдет речь о первоисточнике - thrift спецификации. Использовать его не очень удобно, но это точно первоисточник и в нем либо "что-то" есть, либо нет. Для удобства (прежде всего своего) использую python.

Поехали.

Thrift

Не буду даже пытаться цитировать и пропущу для скорости определения и большую часть деталей. Важно следующее: на guthub-е проекта parquet лежит thrift спецификация файлового формата parquet (один файл - parquet.thrift). По нему можно сгенерировать код, который однозначно сможет проинтерпретировать содержимое конкретного parquet файла. Возможно, я пошел не самым оптимальным способом. Но у меня получилось этим кодом достаточно быстро и более или менее удобно воспользоваться. И вот что мы имеем на выходе (процесса компиляции и моего самобытного творчества):

  • файл с определением всех типов данных формата parquet (на python) - он генерируется по thrift оригиналу, в нем нет ничего "моего"

  • функцию (decodeObject()), воссоздающую нужный нам объект из известного по смещению места parquet файла - это уже мое творчество, ей можно не пользоваться. Она - пример (как известно, любую задачу можно решить великим множеством разных способов)

Большего мне создавать не захотелось, особенно вследствие того, что есть и другие способы читать parquet файлы и их метаданные, о них я расскажу в следующих статьях. Весь код откомментирован и выложен в репозиторий. Уверен, что желающие смогут разобраться. Подчеркну - в этой статье пойдет речь о чтении "метаданных" parquet файла (т.е. данных о данных) на самом нижнем уровне.

Кратко про формат parquet

Не буду дублировать интернет - есть множество статей, есть оригинальная документация (см. ссылку выше на Github проекта parquet).

Очень кратко и верхнеуровнево, parquet - это колончатый формат. Файл состоит из:

  • одной или более группы строк (row group)

  • "футера" (footer), содержащего основные метаданные

Для работы с данными в parquet файле "читатель" должен

  • прочитать футер

  • проинтерпретировать его содержимое

  • для нужных колонок прочитать одну или более страниц (page), являющихся частью одной или более группы строк ( row group )

Технически файл может быть обработан параллельно:

  • каждая группа строк может быть обработана независимо от других

  • каждая страница в пределах группы строк также может быть обработана независимо от других - словарные кодировки немного усложняют (словарь бывает один на множество страниц в пределах группы строк), но это скорее техническая мелочь

Давайте пробовать, с (техническими) деталями познакомимся по ходу процесса.

Чуть-чуть теории и философии. Формат файла - это "план", "меню", он предлагает возможности (как корректно с точки зрения формата сериализовать объект данных в набор байт). Конкретный файл - это "факт": то, как конкретный "писатель" (программа, которая пишет файл) решила этими возможностями воспользоваться.

Яркий пример - кодировки (encoding). Данные каждой колонки могут быть сериализованы в байты одним из фиксированного множества способов (кодировок). Какую кодировку выбрать решает "писатель", основываясь на своих алгоритмах и данных конкретного файла (и его конкретной части).

О чем речь - примеры кодировок:

  • словарная кодировка: значения колонки заменяются индексами в "словаре", данные колонки в пределах группы строк представляются комбинацией "словарь + массив индексов в этом словаре"

  • дельта кодировка: значения колонки представляются в виде троек "начальное значение, шаг, количество повторений", таких троек для колонки в пределах группы строк может быть несколько

  • RLE кодировка (для чисел): "число в виде зигзаг представления + количество повторений"

и т.п. (кодировок существует больше, но не слишком). Использование кодировок существенно уменьшает размер файла.

Теперь собственно - к байтам и "потрогать" (как и обещал).

Footer

parquet файл заканчивается "magic" строкой "PAR1" (технически - начинается тоже с этой строки, но это важно только тем "читателям файла", кто не знает, что файл - именно parquet файл). Перед этими символами в 4 байтах записана длина футера.

Читаем ее и воссоздаем из нее объект FileMetaData. Для примера ниже я привел часть docstring этого объекта, чтобы был понятен уровень документированности формата в исходнике - он достаточно высок.

Docstring объекта FileMetaData

Attributes:

  • version: Version of this file *

  • schema: Parquet schema for this file. This schema contains metadata for all the columns. The schema is represented as a tree with a single root. The nodes of the tree are flattened to a list by doing a depth-first traversal. The column metadata contains the path in the schema for that column which can be used to map columns to nodes in the schema. The first element is the root *

  • num_rows: Number of rows in this file *

  • row_groups: Row groups in this file *

  • key_value_metadata: Optional key/value metadata *

  • created_by: String for application that wrote this file. This should be in the format version (build ). e.g. impala version 1.0 (build 6cf94d29b2b7115df4de2c06e2ab4326d721eb55)

  • column_orders: Sort order used for the min_value and max_value fields in the Statistics objects and the min_values and max_values fields in the ColumnIndex objects of each column in this file. Sort orders are listed in the order matching the columns in the schema. The indexes are not necessary the same though, because only leaf nodes of the schema are represented in the list of sort orders. Without column_orders, the meaning of the min_value and max_value fields in the Statistics object and the ColumnIndex object is undefined. To ensure well-defined behaviour, if these fields are written to a Parquet file, column_orders must be written as well. The obsolete min and max fields in the Statistics object are always sorted by signed comparison regardless of column_orders.

  • encryption_algorithm: Encryption algorithm. This field is set only in encrypted files with plaintext footer. Files with encrypted footer store algorithm id in FileCryptoMetaData structure.

  • footer_signing_key_metadata: Retrieval metadata of key used for signing the footer. Used only in encrypted files with plaintext footer.

Объект FileMetaData

Что полезного можно извлечь из этого объекта (этой части метаданных):

  • схема: о ней здесь говорить не буду, это пример того, что не все удобно делать на "низком уровне", схему можно воссоздать, но... я бы сделал это с помощью других инструментов (как я уже писал - без них все равно не обойтись)

  • количество строк в файле

  • метаданные групп строк файла (массив row_groups): главная часть метаданных

  • "автор" - программный продукт и его версия

Для примеров (здесь и в репозитории) я использовал файл store_sales.parquet - содержимое одноименной таблицы бенчмарка TPC-DS sf10.

Мне показалось удобным вывод��ть на печать содержимое верхнего уровня объекта (атрибуты верхнего уровня и их значения), и я написал соответствующую функцию (getLevelStr()). Для примера здесь и далее во врезке привожу результаты работы этой функции, а под врезкой - интерпретацию и обсуждение этих результатов.

Верхний уровень объекта FileMetaData
fLen = getFooterLen(PQ_FILE) 
MD = decodeObject(PQ_FILE,-(8+fLen),tt.FileMetaData,fLen) 
print(getLevelStr(MD))
---	
First level elements of <class 'ttypes.FileMetaData'> 
column_orders: None 
created_by: DuckDB version v1.3.2 (build 0b83e5d2f6) 
encryption_algorithm: None 
footer_signing_key_metadata: None 
key_value_metadata: 
None 
num_rows: 28800991 
read() 
row_groups[235] 
schema[24] 
thrift_spec# 
validate() 
version: 1 
write()

Интерпретируем информацию с верхнего уровня объекта FileMetaData - в файле:

  • 235 групп строк

  • 28 800 991 строка

  • 23 колонки (1 "корень" плюс 23 колонки)

  • автор (тот самый "писатель") - DuckDB

  • версия (спецификации parquet) - 1

Едем дальше - разбираемся с колонками.

Объект RowGroup

Каждая группа строк с точки зрения метаданных устроена одинаково (в каждой содержатся одни и те же колонки), длина массива row_groups равна количеству групп строк (в нашем случае - 235).

В любом parquet файле есть как минимум одна группа строк - первая, дальше погружаемся на ее примере (первой группы строк нашего файла store_sales.parquet). Метаданные колонок разложили по нескольким объектам:

  • объект RowGroup содержит общую информацию о группе строк

    • включая массив колонок - объектов типа ColumnChunk

  • элемент этого массива - объект ColumnChunk - содержит общую информацию о колонке (в пределах группы строк)

    • его атрибут (объект ColumnMetaData) содержит метаданные колонки (в пределах группы строк)

      • его атрибут (объект Statistics) содержит статистику данных колонки (в пределах группы строк)

Давайте разбираться по порядку

Не буду больше приводить doc string - их можно посмотреть в репозитории, покажу как выглядит этот объект для нашего файла.

Верхний уровень объекта RowGroup
print(getLevelStr(MD.row_groups[0]))
---	
First level elements of <class 'ttypes.RowGroup'> 
columns[23] 
file_offset: 4 
num_rows: 122880 
ordinal: None 
read() 
sorting_columns: None 
thrift_spec# 
total_byte_size: 9174924 
total_compressed_size: 
None 
validate() 
write()

Что полезного можно извлечь из объекта RowGroup (на примере нашего файла и его первой группы строк):

  • в группе (и файле) - 23 колонки

  • смещение первой страницы данных в файле - 4 (сразу после magic байтов "PAR1")

  • количество строк в группе строк - 122 800

Объект ColumnChunk

Посмотрим на метаданные первой колонки - первый элемент массива columns.

Верхний уровень объекта ColumnChunk
print(getLevelStr(MD.row_groups[0].columns[0]))
---	
First level elements of <class 'ttypes.ColumnChunk'> 
column_index_length: None 
column_index_offset: None 
crypto_metadata: None 
encrypted_column_metadata: None 
file_offset: 0 
file_path: None 
meta_data# 
offset_index_length: None 
offset_index_offset: None 
read() 
thrift_spec# 
validate() 
write()

Как это проинтерпретировать (с точки зрения пользы и понимания)

  • для колонки нет индексной страницы (про индексы напишу чуть ниже)

  • file_offset - не всегда заполняется и, видимо, не всегда берется отсюда (смещение первой страницы - page - данных данной колонки относительно первой страницы группы строк)

  • шифрование не использовалось

  • дополнительная информация (собственно - мета информация) содержится в атрибуте meta_data

Возможно, на месте авторов я бы этот объект отдельно не выделял, но... я не автор, им виднее. Едем дальше

Объект ColumnMetaData

Метаданные первой колонки первой группы строк:

Верхний уровень объекта ColumnMetaData
print(getLevelStr(MD.row_groups[0].columns[0].meta_data))
---	
First level elements of <class 'ttypes.ColumnMetaData'> 
bloom_filter_length: 4112 
bloom_filter_offset: 1459762393 
codec: 1 
data_page_offset: 7165 
dictionary_page_offset: 4 
encoding_stats: None 
encodings[1] 
geospatial_statistics: None 
index_page_offset: None 
key_value_metadata: None 
num_values: 122880 
path_in_schema[1] 
read() 
size_statistics: None 
statistics# 
thrift_spec# 
total_compressed_size: 46885 
total_uncompressed_size: 53102 
type: 1 
validate() 
write()

Интерпретируем и разбираемся - наконец-то добрались до "реальных" метаданных:

  • для этой колонки в этой группе строк был создан блюм-фильтр (об этом позднее)

  • codec: использовалось сжатие snappy (в parquet используется один и тот же кодек сжатия для всего файла, но информация об этом "закопана" глубоко, потенциально формат допускает использование разных кодеков сжатия для разных колонок в разных группах строк)

  • data_page_offset: смещение первой страницы данных (относительно начала файла - 7165)

  • dictionary_page_offset: для этой колонки в этой группе строк использована словарная кодировка, в этом атрибуте - смещение (относительно начала файла - 4)

    • эти цифры - 4 и 7165 - прокомментированы чуть ниже

  • encoding_stats - пусто (могла бы быть статистика - сколько страниц данных для этой колонки этой группы строк в каких кодировках, но у нас страница одна, поэтому статистика не нужна)

  • encodings: массив используемых кодировок (в нашем случае из одного элемента - для первой колонки в первой группе строк использовалась словарная кодировка)

  • num_values: количество значений колонки (дублирует информацию в объекте RowGroup) - совпадает с количеством строк в группе строк

  • path_in_schema: имя колонки, список из одного (в нашем случае файл хранит плоскую таблицу) элемента ('ss_sold_date_sk' - суррогатный ключ размерности "дат")

Немного про страницы данных (page) колонки в пределах группы строк (уже пора начинать с этим разбираться):

  • группа строк содержит часть строк файла (в нашей группе - 122 800 строк)

  • данные каждой колонки хранятся отдельно (parquet - колончатый формат) в пределах каждой группы строк

    • у нас 23 колонки - каждая группа строк будет содержать данные каждой колонки

  • данные колонки представлены в виде страниц - page (детали - байты - ниже)

  • если для колонки используется словарная кодировка, то словарь может быть только один (в пределах группы строк), к данным колонки в этом случае добавляется одна словарная страница (тоже page)

Про наши цифры:

  • смещение 7165: смещение первой страницы данных относительно начала файла (первая колонка первой группы строк)

  • смещение 4: смещение словарной страницы относительно начала файла (тоже первая колонка первой группы строк)

Про страницы это еще не конец - это только начало (страницы тоже имеют структуру - мы ее разберем ниже).

Ну и, наконец, статистика (первой колонки первой группы строк)

Объект Statistics

Этот объект содержит статистику данных - именно эту статистику чаще всего используют "читатели" для того, чтобы понять - нужно ли им "читать" соответствующую группу строк или нет.

Верхний уровень объекта Statistics
print(getLevelStr(MD.row_groups[0].columns[0].meta_data.statistics))
---	
First level elements of <class 'ttypes.Statistics'> 
distinct_count: 1785 
is_max_value_exact: True 
is_min_value_exact: True 
max: b'\xa2l%\x00' 
max_value: b'\xa2l%\x00' 
min: b'\x80e%\x00' 
min_value: b'\x80e%\x00' 
null_count: 5452 
read() 
thrift_spec# 
validate() 
write()

Что здесь видим (речь идет о колонке ss_sold_date_sk - ссылка в размерность дат, в календарь):

  • distinct_count: количество уникальных значений (1785 из общего количества 122 800)

  • max/min: максимальное и минимальное значение первой колонки первой группы строк (целое число)

    • моя функция показывает "как есть" (глазу не удобно :-), - поленился, опять надежда на другие инструменты) - на самом деле это числа 2450816 и 2452642

  • null_count: количество пропусков ("нулевых" значений) - "жизнь-боль": в Кимбале нулевой foreign key в таблице фактов (в количестве 5432 штук), такого быть не должно...

Что важно - бывают в интернете слова типа "статистика содержит минимальное и максимальное значение, информацию об уникальных значениях, количестве пропусков и т.д.". Все так, только "и т.д." в статистике нет, а есть то, что выше. Но и "оно" может быть не заполнено - это ответственность "писателя".

Прежде чем перейти к рассмотрению непосредственно данных (собственно групп строк), давайте немного обсудим еще два пункта "меню", которые предлагает формат parquet - индексы и блюм фильтры.

Индексы в parquet

Когда я первый раз увидел это слово в описании формата, оно меня заинтриговало ("индексы переехали из базы данных в файл"), но если чуть подумать (что такое индекс - то же самое, что в PostgreSQL?), то интрига усиливается ("неужели...").

Оказалось все проще: в parquet файле можно хранить информацию о минимальных и максимальных значениях в пределах группы строк (row group) или страницы данных (page). Вот и все индексы...

В первом случае (для группы строк) эта информация хранится в статистике (объект Statistics) выше, во втором случае (для страницы данных) - в специальных индексных страницах (информация о них находится в объекте ColumnChunk).

Что позволяют сделать эти "индексы": пропустить часть групп строк или страниц в случае, если при чтении данных выполняется фильтрация. Нюанс: довольно часто каждая колонка имеет одну страницу данных. В этом случае оба индекса по своей сути совпадают и второй (индексные страницы - см. объект ColumnChunk) не создается (как в нашем случае и получилось). Индексы (точнее - статистика) для группы строк создается всегда (см. объект Statistics выше). Более правильно сказать - создается "как правило" (и это тоже - как и все в формате parquet - обязанность и решение "писателя").

Индексные страницы в файле (если они есть) располагаются между группами строк и футером (parquet не специфицирует их местоположение). Процесс чтения данных, который я описал выше, в случае наличия индексных страниц (и желания "читателя" ими воспользоваться - для того, кто читает файл это опция) чуть усложняется, но не меняется кардинально и остается по-прежнему достаточно параллельным).

Для краткости изложения пропущу описание того, как устроены индексные страницы - это не сложно, но и так получается много слов...

Блюм фильтры в parquet

И здесь тоже не буду погружаться в теорию (что такое bloom filter), это можно найти в интернете. Если кратко, то с помощью блюм фильтра можно понять - содержится ли заданное значение в множестве данных, для которых (или по которым) был создан блюм фильтр. При этом в отрицательном ответе можно быть уверенным, положительный ответ надо воспринимать как "может быть" (возможен false positive). Блюм фильтр - это некоторый набор данных, размер которого повышает "точность" ответов на вопрос (но увеличивает размер файла).

Зачем нужны блюм фильтры (в parquet): если мин/макс значения не позволяют "отсеять" группу строк (или страницу), можно воспользоваться блюм фильтром и попытаться с его помощью "отсеять" группу строк. Блюм фильтр создается для группы строк, ссылка на него содержится в объекте ColumnMetaData. Блюм фильтр - это опция (и для "писателя", и для "читателя").

Блюм фильтры хранятся не в виде страниц - используется специальная структура данных. В файле блюм фильтры располагаются после групп строк до индексных страниц (если таковые есть), т.е. между группами строк и футером.

Технически на основании thrift спецификации можно написать код, который сможет воспользоваться блюм фильтрами конкретного файла (вряд ли это слишком сложно), но один их готовых инструментов предоставляет такую возможность - зачем изобретать велосипед (об инструментах будет отдельная статья).

Разбираемся далее - мы уже познакомились с основными мета данными parquet файла (футер, чуть коснулись индексов и блюм фильтров), осталось разобраться с данными.

Группы строк

Как я уже говорил, parquet файл начинается с magic строки "PAR1" (для версии формата 1), за этой строкой со смещения 4 начинаются байты, относящиеся к первой группе строк. После них пойдут байты второй группы строк и т.д. Читать файл последовательно можно, но это вряд ли будет эффективно, поэтому обычно (повторюсь):

  • читают футер

  • читают (параллельно) группы строк (колонки)

    • потенциально не все группы строк (фильтрация) и не все колонки (проекция)

Отдельно взятую группу строк (байты) читать последовательно также не эффективно, собственно, давайте в этом разберемся.

Группа строк состоит из страниц (page), страница имеет заголовок страницы (объект PageHeader), за которым следуют непосредственно данные (страницы).

Страницы бывают трех типов:

  • страницы данных (содержат просто данные в некоторой кодировке, см. объект ColumnMetaData)

  • индексные страницы (как я уже писал выше - опустим для краткости, они не так часто встречаются)

  • словарные страницы (содержат значения словаря)

В заголовке страницы должно быть заполнено одно из полей, соответствующее типу страницы. По его заполненности можно определить тип страницы (поля data_page_header/data_page_header_v2, dictionary_page_header, index_page_header).

Если для кодирования колонки в пределах группы строк использовалась словарная кодировка - в данных колонки будет не более одной словарной страницы. Ссылка на нее содержится в объекте ColumnMetaData.

Зная это, нужно подчеркнуть следующее:

  • страницы сжимаются (или по-другому: сжимаются именно страницы) - то, что идет после заголовка (используется один codek на весь файл - см. объект ColumnMetaData)

  • для одной колонки в пределах одной группы строк может использоваться более одной кодировки

    • в этом случае список encodings в объекте ColumnMetaData будет иметь длину, большую 1 (для нашего файла это не так)

    • в этом случае должно быть заполнено поле encoding_stats этого же объекта (для первой колонки первой группы строк нашего файла это поле пусто)

    • под каждую "кодировку" для колонки будет создана своя страница

    • ссылка на первую такую страницу (данных) - поле data_page_offset объекта ColumnMetaData

    • последующие страницы "следуют" за первой, количество страниц - см. encoding_stats

И еще раз повторю - довольно часто "писатели" решают создать по одной странице для колонки в пределах группы строк - использовать одну и ту же кодировку для всей колонки в пределах группы строк (наш файл - пример). В этом случае

  • не нужны индексные страницы (мин\макс содержится в статистике колонки в футере)

  • не нужна статистика кодировок (одна страница в одной кодировке)

  • не нужно читать дополнительные страницы данных (колонке соответствуют одна или две страницы данных - данные плюс словарь)

И еще один повтор: сжатие происходит на уровне страницы ("жмутся" данные, не заголовок), тем самым страница - это минимальная единица чтения данных.

Замечу, что другие инструменты, умеющие читать метаданные, "не опускаются" на уровень страниц (т.е. не показывают информацию о том, сколько страниц для колонки в пределах группы строк создано). Не думаю, что это принципиально важно. В конце концов авторы других инструментов продумали и это - их компромисс между удобством и глубиной, но стоит иметь в виду.

Косвенно можно судить про количество страниц по списку кодировок для колонки в пределах группы строк. Если в списке одна кодировка и отсутствует статистика кодировок, то страница одна (хотя и эту информацию другие инструменты тоже могут не показать).

В приложенном ноутбуке я рассмотрел пример словарной страницы (для колонки ss_store_sk - ссылка на размерность "магазинов"): в ней содержится столько элементов, сколько уникальных значений содержит эта колонка в пределах группы строк (например, в первой группе строк размер словаря - 51 элемент, минимальное значение - 0, максимальное - 100, всего в группе строк - 122 000 строк/значений колонки ss_store_sk. Существенная экономия за счет использования словаря!).

Данные в parquet файле

Читать содержимое страниц с помощью используемого инструментария можно, но тоже, на мой взгляд, не стоит. Формат parquet был создан для больших объемов данных, даже если поставить себе задачу "хочу прочитать колонку Х в группе строк У" - придется придумать, "куда класть" результат. За нас это уже придумали (те самые другие инструменты), и об этом я расскажу в отдельной статье.

Мы прошли по parquet файлу (футер и страницы данных групп строк), теперь нам должны быть более понятны картинки из документации (и сама документация - первоисточник):

Взаимное расположение частей файла
Взаимное расположение частей файла
Основные объекты и взаимосвязи между ними
Основные объекты и взаимосвязи между ними

Заключение

Что, собственно, я хотел сделать (когда "генерил" код, разбирался со структурами данных) - я хотел иметь возможность понять: что есть и чего нет в конкретном файле. До определенной степени я этого добился (см. также исходники, в том числе jupyter notebook, из которого сюда были скопированы примеры). При этом, разбираясь со структурами данных, я нашел другие инструменты, которые позволяют решить более эффективно/удобно часть задач (схема, данные колонки в пределах группы строк, блюм фильтры).

Зачем мне это ("что есть и чего нет"): для того, чтобы иметь возможность ответить на вопросы, возникающие в практике:

  • почему файл такой большой/маленький

  • сколько групп строк и какой их размер

  • какие кодировки для каких колонок используются

  • можно ли файл эффективно отфильтровать по некому условию

  • есть ли блюм фильтры и для каких колонок

  • (возможно и на другие вопросы)

Оказалось, что на бОльшую часть этих вопросов можно получить ответ с помощью других инструментов - я про это напишу отдельно (самый удобный - DuckDB). Но используя другой инструмент, мы не получаем понимания "а как оно на самом деле устроено", мы получаем то, что может показать нам инструмент. Возможно, это понимание ("как устроено") и не нужно - не настаиваю.

Я очень старался быть кратким, по-моему, это не очень получилось - не обессудьте. Статьи про инструменты и про практику (а действительно ли исключает "читатель" то, что можно исключить) последуют.