In den letzten Jahren ist Parquet zu einem Standardformat für die Datenspeicherung in geworden Huge Information Ökosysteme. Das säulenorientierte Format bietet mehrere Vorteile:
- Eine schnellere Abfrageausführung, wenn nur eine Teilmenge von Spalten verarbeitet wird
- Schnelle Berechnung von Statistiken über alle Daten hinweg
- Reduziertes Speichervolumen dank einer effizienten Komprimierung
In Kombination mit Speicher -Frameworks wie Delta Lake oder Apache Iceberg integriert es sich nahtlos in Question -Engines (z. B. Trino) und Information Warehouse Compute Cluster (z. B. Snowflake, BigQuery). In diesem Artikel wird der Inhalt einer Parquetdatei unter Verwendung hauptsächlich Commonplace -Python -Instruments analysiert, um ihre Struktur besser zu verstehen und wie sie zu solchen Leistungen beiträgt.
Schreiben von Parkettdateien (en)
Um Parkettdateien zu erzeugen, verwenden wir Pyarrow, eine Python -Bindung für Apache -Pfeil, die Datenframes im Speicher im Spaltenformat speichert. Pyarrow ermöglicht eine feinkörnige Parameterabstimmung beim Schreiben der Datei. Dies macht Pyarrow perfect für die Manipulation des Parketts (man kann auch einfach verwenden Pandas).
# generator.py
import pyarrow as pa
import pyarrow.parquet as pq
from faker import Faker
pretend = Faker()
Faker.seed(12345)
num_records = 100
# Generate pretend information
names = (pretend.identify() for _ in vary(num_records))
addresses = (pretend.tackle().exchange("n", ", ") for _ in vary(num_records))
birth_dates = (
pretend.date_of_birth(minimum_age=67, maximum_age=75) for _ in vary(num_records)
)
cities = (addr.break up(", ")(1) for addr in addresses)
birth_years = (date.12 months for date in birth_dates)
# Forged the info to the Arrow format
name_array = pa.array(names, kind=pa.string())
address_array = pa.array(addresses, kind=pa.string())
birth_date_array = pa.array(birth_dates, kind=pa.date32())
city_array = pa.array(cities, kind=pa.string())
birth_year_array = pa.array(birth_years, kind=pa.int32())
# Create schema with non-nullable fields
schema = pa.schema(
(
pa.area("identify", pa.string(), nullable=False),
pa.area("tackle", pa.string(), nullable=False),
pa.area("date_of_birth", pa.date32(), nullable=False),
pa.area("metropolis", pa.string(), nullable=False),
pa.area("birth_year", pa.int32(), nullable=False),
)
)
desk = pa.Desk.from_arrays(
(name_array, address_array, birth_date_array, city_array, birth_year_array),
schema=schema,
)
print(desk)
pyarrow.Desk
identify: string not null
tackle: string not null
date_of_birth: date32(day) not null
metropolis: string not null
birth_year: int32 not null
----
identify: (("Adam Bryan","Jacob Lee","Candice Martinez","Justin Thompson","Heather Rubio"))
tackle: (("822 Jennifer Area Suite 507, Anthonyhaven, UT 98088","292 Garcia Mall, Lake Belindafurt, IN 69129","31738 Jonathan Mews Apt. 024, East Tammiestad, ND 45323","00716 Kristina Path Suite 381, Howelltown, SC 64961","351 Christopher Expressway Suite 332, West Edward, CO 68607"))
date_of_birth: ((1955-06-03,1950-06-24,1955-01-29,1957-02-18,1956-09-04))
metropolis: (("Anthonyhaven","Lake Belindafurt","East Tammiestad","Howelltown","West Edward"))
birth_year: ((1955,1950,1955,1957,1956))
Die Ausgabe spiegelt im Gegensatz zu Pandas eindeutig einen spaltenorientierten Speicher wider, in dem normalerweise eine herkömmliche „zeilenweise“ Tabelle angezeigt wird.
Wie wird eine Parquetdatei gespeichert?
Parquetdateien werden in der Regel in billigen Objektspeicherdatenbanken wie S3 (AWS) oder GCS (GCP) gespeichert, um durch Datenverarbeitungspipelines leicht zugänglich zu werden. Diese Dateien werden normalerweise mit einer Partitionierungsstrategie organisiert, indem Verzeichnisstrukturen eingesetzt werden:
# generator.py
num_records = 100
# ...
# Writing the parquet information to disk
pq.write_to_dataset(
desk,
root_path='dataset',
partition_cols=('birth_year', 'metropolis')
)
Wenn birth_year
Und metropolis columns
werden als Partitionierungsschlüssel definiert, Pyarrow erstellt eine solche Baumstruktur im Verzeichnisdatensatz:
dataset/
├─ birth_year=1949/
├─ birth_year=1950/
│ ├─ metropolis=Aaronbury/
│ │ ├─ 828d313a915a43559f3111ee8d8e6c1a-0.parquet
│ │ ├─ 828d313a915a43559f3111ee8d8e6c1a-0.parquet
│ │ ├─ …
│ ├─ metropolis=Alicialand/
│ ├─ …
├─ birth_year=1951 ├─ ...
Die Strategie ermöglicht das Partitionsbeschneiden: Wenn eine Abfrage in diesen Spalten filtert, kann die Engine Ordnernamen verwenden, um nur die erforderlichen Dateien zu lesen. Aus diesem Grund ist die Partitionierungsstrategie entscheidend für die Begrenzung von Verzögerungs-, E/A- und Berechnung von Ressourcen, wenn große Datenmengen behandelt werden (wie es seit Jahrzehnten mit herkömmlichen relationalen Datenbanken der Fall ist).
Der Beschneidungseffekt kann leicht überprüft werden, indem die Dateien gezählt werden, die durch ein Python -Skript eröffnet werden, das das Geburtsjahr filtert:
# question.py
import duckdb
duckdb.sql(
"""
SELECT *
FROM read_parquet('dataset/*/*/*.parquet', hive_partitioning = true)
the place birth_year = 1949
"""
).present()
> strace -e hint=open,openat,learn -f python question.py 2>&1 | grep "dataset/.*.parquet"
(pid 37) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Boxpercent201306/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
(pid 37) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Boxpercent201306/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Boxpercent201306/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Boxpercent203487/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Boxpercent203487/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Clarkemouth/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Clarkemouth/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=DPOpercent20APpercent2020198/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=DPOpercent20APpercent2020198/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Eastpercent20Morgan/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Eastpercent20Morgan/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=FPOpercent20AApercent2006122/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=FPOpercent20AApercent2006122/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Newpercent20Michelleport/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Newpercent20Michelleport/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Northpercent20Danielchester/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Northpercent20Danielchester/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Portpercent20Chase/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Portpercent20Chase/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Richardmouth/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Richardmouth/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Robbinsshire/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
(pid 39) openat(AT_FDCWD, "dataset/birth_year=1949/metropolis=Robbinsshire/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
Nur 23 Dateien werden von 100 ausgelesen.
Lesen einer Rohparkettdatei
Lassen Sie uns eine Rohparkettdatei ohne spezialisierte Bibliotheken dekodieren. Der Einfachheit halber wird der Datensatz ohne Komprimierung oder Codierung in eine einzelne Datei abgeladen.
# generator.py
# ...
pq.write_table(
desk,
"dataset.parquet",
use_dictionary=False,
compression="NONE",
write_statistics=True,
column_encoding=None,
)
Das erste, was Sie wissen sollten, ist, dass die Binärdatei von 4 Bytes umrahmt wird, deren ASCII -Darstellung „Par1“ ist. Die Datei ist beschädigt, wenn dies nicht der Fall ist.
# reader.py
with open("dataset.parquet", "rb") as file:
parquet_data = file.learn()
assert parquet_data(:4) == b"PAR1", "Not a sound parquet file"
assert parquet_data(-4:) == b"PAR1", "File footer is corrupted"
Wie in der angegeben DokumentationDie Datei ist in zwei Teile unterteilt: die „Zeilengruppen“, die tatsächliche Daten enthalten, und die Fußzeile mit Metadaten (Schema unten).

Die Fußzeile
Die Größe der Fußzeile ist in den 4 Bytes vor dem Endmarker als unsignierte Ganzzahl angegeben, die im „Little Endian“ -Format geschrieben ist (angegeben „unpack Funktion).
# reader.py
import struct
# ...
footer_length = struct.unpack("<I", parquet_data(-8:-4))(0)
print(f"Footer dimension in bytes: {footer_length}")
footer_start = len(parquet_data) - footer_length - 8
footer_data = parquet_data(footer_start:-8)
Footer dimension in bytes: 1088
Die Fußzeileninformationen werden in einem Cross-Sprach-Serialisierungsformat genannt Apache -Sparsamkeit. Die Verwendung eines menschlich lesbaren, aber ausführlichen Codecs wie JSON und das dann in binäre Übersetzung wäre in Bezug auf die Speicherverwendung weniger effizient. Mit Sparsamkeit kann man Datenstrukturen wie folgt deklarieren:
struct Buyer {
1: required string identify,
2: non-obligatory i16 birthYear,
3: non-obligatory checklist<string> pursuits
}
Auf der Grundlage dieser Deklaration kann Thrift Python -Code generieren, um Byte -Zeichenfolgen mit einer solchen Datenstruktur zu dekodieren (er generiert auch Code, um den Codierungsteil durchzuführen). Die Sparsamkeitsdatei, die alle in einer Parquetdatei implementierten Datenstrukturen enthält, kann heruntergeladen werden Hier. Nachdem wir die Sparsamkeits -Binärin installiert haben, rennen wir:
thrift -r --gen py parquet.thrift
Der generierte Python-Code wird im Ordner „Gen-Py“ platziert. Die Datenstruktur der Fußzeile wird durch die Filemetadata -Klasse dargestellt – eine Python -Klasse, die automatisch aus dem Thriftschema generiert wird. Unter Verwendung von Thrifts Python -Dienstprogrammen werden Binärdaten analysiert und in eine Instanz dieser Filemetadata -Klasse gefüllt.
# reader.py
import sys
# ...
# Add the generated lessons to the python path
sys.path.append("gen-py")
from parquet.ttypes import FileMetaData, PageHeader
from thrift.transport import TTransport
from thrift.protocol import TCompactProtocol
def read_thrift(information, thrift_instance):
"""
Learn a Thrift object from a binary buffer.
Returns the Thrift object and the variety of bytes learn.
"""
transport = TTransport.TMemoryBuffer(information)
protocol = TCompactProtocol.TCompactProtocol(transport)
thrift_instance.learn(protocol)
return thrift_instance, transport._buffer.inform()
# The variety of bytes learn is just not used for now
file_metadata_thrift, _ = read_thrift(footer_data, FileMetaData())
print(f"Variety of rows in the entire file: {file_metadata_thrift.num_rows}")
print(f"Variety of row teams: {len(file_metadata_thrift.row_groups)}")
Variety of rows in the entire file: 100
Variety of row teams: 1
Die Fußzeile enthält umfangreiche Informationen über die Struktur und den Inhalt der Datei. Zum Beispiel verfolgt es genau die Anzahl der Zeilen im generierten Datenrahmen. Diese Zeilen sind alle in einer einzigen „Zeilengruppe“ enthalten. Aber was ist eine „Zeilengruppe“?
Reihengruppen
Im Gegensatz zu rein säulenorientierten Formaten verwendet Parquet einen hybriden Ansatz. Vor dem Schreiben von Spaltenblöcken wird der Datenrahmen zunächst vertikal in Zeilengruppen unterteilt (die von uns generierte Parquetendatei ist zu klein, um in mehreren Zeilengruppen aufgeteilt zu werden).

Diese Hybridstruktur bietet mehrere Vorteile:
Parquet berechnet die Statistiken (z. B. min/max -Werte) für jede Spalte in jeder Zeilengruppe. Diese Statistiken sind für die Abfrageoptimierung von entscheidender Bedeutung, sodass Abfrage -Engines ganze Zeilengruppen überspringen können, die nicht mit Filterkriterien übereinstimmen. Zum Beispiel, wenn eine Abfrage nach filtert birth_year > 1955
Das maximale Geburtsjahr einer Zeilengruppe beträgt 1954, der Motor kann den gesamten Datenabschnitt effizient überspringen. Diese Optimierung wird als „Prädikat -Pushdown“ bezeichnet. Parquet speichert auch andere nützliche Statistiken wie unterschiedliche Wertzahlen und Nullzählungen.
# reader.py
# ...
first_row_group = file_metadata_thrift.row_groups(0)
birth_year_column = first_row_group.columns(4)
min_stat_bytes = birth_year_column.meta_data.statistics.min
max_stat_bytes = birth_year_column.meta_data.statistics.max
min_year = struct.unpack("<I", min_stat_bytes)(0)
max_year = struct.unpack("<I", max_stat_bytes)(0)
print(f"The beginning 12 months vary is between {min_year} and {max_year}")
The beginning 12 months vary is between 1949 and 1958
- Zeilengruppen ermöglichen die parallele Verarbeitung von Daten (besonders wertvoll für Frameworks wie Apache Spark). Die Größe dieser Zeilengruppen kann basierend auf den verfügbaren Computerressourcen konfiguriert werden (mit dem mit dem
row_group_size
Eigenschaft in Funktionwrite_table
Bei Verwendung von Pyarrow).
# generator.py
# ...
pq.write_table(
desk,
"dataset.parquet",
row_group_size=100,
)
# /! Preserve the default worth of "row_group_size" for the subsequent elements
- Auch wenn dies nicht das Hauptziel eines Säulenformats ist, behält die Hybridstruktur des Parquets bei der Rekonstruktion vollständiger Zeilen eine angemessene Leistung bei. Ohne Zeilengruppen erfordert das Wiederaufbau einer gesamten Zeile möglicherweise das Scannen der gesamten Spalte, die für große Dateien äußerst ineffizient wäre.
Datenseiten
Die kleinste Unterstruktur einer Parkettdatei ist die Seite. Es enthält eine Sequenz von Werten aus derselben Spalte und daher vom gleichen Typ. Die Auswahl der Seitengröße ist das Ergebnis eines Kompromisses:
- Größere Seiten bedeuten weniger Metadaten zum Speichern und Lesen, was für Abfragen mit minimaler Filterung optimum ist.
- Kleinere Seiten reduzieren die Menge an unnötigen Daten, die besser sind, wenn Abfragen auf kleine, verstreute Datenbereiche abzielen.

Lassen Sie uns nun den Inhalt der ersten Seite der Spalte dekodieren, die Adressen gewidmet sind, deren Ort im Fußzeile gefunden werden kann (angegeben durch die data_page_offset
Attribut des Rechts ColumnMetaData
). Jede Seite geht von einer Sparsamkeit voraus PageHeader
Objekt mit Metadaten. Der Offset verweist tatsächlich auf eine Sparsamkeitsdarstellung der Seitenmetadaten, die der Seite selbst vorausgeht. Die Secondhand -Klasse heißt a PageHeader
und kann auch in der gefunden werden gen-py
Verzeichnis.
💡 Zwischen dem PageHeader und den tatsächlichen Werten, die in der Seite enthalten sind Dremel Format, das eine Codierung ermöglicht verschachtelte Datenstrukturen. Da unsere Daten ein regelmäßiges tabellarisches Format haben und die Werte nicht nullbar sind, werden diese Bytes beim Schreiben der Datei übersprungen (https://parquet.apache.org/docs/file-format/data-pages/).
# reader.py
# ...
address_column = first_row_group.columns(1)
column_start = address_column.meta_data.data_page_offset
column_end = column_start + address_column.meta_data.total_compressed_size
column_content = parquet_data(column_start:column_end)
page_thrift, page_header_size = read_thrift(column_content, PageHeader())
page_content = column_content(
page_header_size : (page_header_size + page_thrift.compressed_page_size)
)
print(column_content(:100))
b'6x00x00x00481 Mata Squares Suite 260, Lake Rachelville, KY 874642x00x00x00671 Barker Crossing Suite 390, Mooreto'
Die generierten Werte erscheinen schließlich im Klartext und nicht codiert (wie beim Schreiben der Parkettdatei). Um das Säulenformat zu optimieren, wird jedoch empfohlen, einen der folgenden Codierungsalgorithmen zu verwenden: Wörterbuchcodierung, Auslauflänge -Codierung (RLE) oder Delta -Codierung (letztere werden für INT32- und INT64 -Typen reserviert), gefolgt von der Kompression mit GZIP oder SNAppy (verfügbare Codecs sind verfügbar Hier). Da codierte Seiten ähnliche Werte (alle Adressen, alle Dezimalzahlen usw.) enthalten, können Komprimierungsverhältnisse besonders vorteilhaft sein.
Wie in der dokumentiert SpezifikationWenn Charakterzeichenfolgen (byte_array) nicht codiert werden, wird jedem Wert seine Größe als 4-Byte-Ganzzahl vorausgeht. Dies kann in der vorherigen Ausgabe beobachtet werden:

Um alle Werte zu lesen (zum Beispiel die ersten 10), ist die Schleife ziemlich einfach:
idx = 0
for _ in vary(10):
str_size = struct.unpack("<I", page_content(idx : (idx + 4)))(0)
print(page_content((idx + 4) : (idx + 4 + str_size)).decode())
idx += 4 + str_size
481 Mata Squares Suite 260, Lake Rachelville, KY 87464
671 Barker Crossing Suite 390, Mooretown, MI 21488
62459 Jordan Knoll Apt. 970, Emilyfort, DC 80068
948 Victor Sq. Apt. 753, Braybury, RI 67113
365 Edward Place Apt. 162, Calebborough, AL 13037
894 Reed Lock, New Davidmouth, NV 84612
24082 Allison Squares Suite 345, North Sharonberg, WY 97642
00266 Johnson Drives, South Lori, MI 98513
15255 Kelly Plains, Richardmouth, GA 33438
260 Thomas Glens, Port Gabriela, OH 96758
Und da haben wir es! Wir haben auf sehr einfache Weise erfolgreich nachgebaut, wie eine spezialisierte Bibliothek eine Parquetendatei lesen würde. Durch das Verständnis der Bausteine, einschließlich Header, Fußzeilen, Reihengruppen und Datenseiten, können wir besser schätzen, wie Funktionen wie Prädikat Pushdown und Partition-Beschneidung so beeindruckende Leistungsvorteile in datenintensiven Umgebungen bieten. Ich bin überzeugt zu wissen, wie Parquet unter der Haube arbeitet, hilft, bessere Entscheidungen über Speicherstrategien, Komprimierungsentscheidungen und Leistungsoptimierung zu treffen.
Alle in diesem Artikel verwendeten Code finden Sie in meinem Github -Repository unter https://github.com/kili-mandjaro/anatomy-parquetwo Sie weitere Beispiele untersuchen und mit unterschiedlichen Konfigurationen der Parquetdatei experimentieren können.
Unabhängig davon, ob Sie Datenpipelines erstellen, die Abfrageleistung optimieren oder einfach nur neugierig auf Datenspeicherformate sind, hoffe ich Daten Engineering Reise.
Alle Bilder stammen vom Autor.