ファイルの置かれ方が読み取り費用を決める — パーティションとコンパクション
目標
ファイルシステム上のパーティションレイクを扱うツールpq.pyを作ります。キーを変えながらファイル数とサイズの分布を測り、パーティションキーにかけた条件が、開くファイル数をどれだけ減らすかを測り、コンパクションを回し、その途中で読む側が何を見るかを確認し、キーを変えるコストを測ります。
なぜ重要なのか
同じ行を入れても、ファイルがどう置かれているかによって、クエリが開くファイル数が数十倍変わります。パーティションは、値ごとにディレクトリを分けておくことで、開かなかったファイルはコストが0です。ただし、その利点は、ディレクトリ名に入っている欄で絞るときにだけ生じます。だからといって、よく使う欄をすべてキーに入れると、パーティション数が値の積の分だけ増え、ファイル1つが数行になります。ファイルを開く固定コストが、内容を読むコストより大きくなり、一覧のエントリ数が爆発し、圧縮が効かなくなります。そのため、キーを選ぶことは、常にトレードオフです。小さなファイルが積み重なると、コンパクションを回します。難しいのは、コンパクションそのものではなく、その途中で読む人が何を見るかです。ディレクトリをたどってファイルを集める読み手は、古いファイルと新しいファイルの両方を拾って、同じ行を2回数えます。読む側がマニフェストを見るようにし、一覧の置換を最後に1回だけ行えば、その間はなくなります。Parquetは使いません。ラボのイメージにpyarrowがなく、Podはランタイムでのインストールができません。行グループのような概念は、読み物で公式ドキュメントとして扱いました。ここでは、パーティションディレクトリとマニフェストとJSON Linesで、同じ構造を手で作ります。採点ツールは、提出された文言を信じません。一時作業フォルダに、採点ツールが作った元データを用意して、自分で作ったツールを実際に動かし、マニフェストに書かれたサイズがディスク上の実際のサイズと合っているか、行が保存されているか、切り落としたファイル数が合っているかを見ます。元データと目標サイズは、実行ごとに変わります。
ステップ
- /root/parts/gen_orders.pyを作成して実行し、/root/parts/work/raw.jsonlを作ってください。
- /root/parts/pq.pyに
writeを作り、キー1つで分けたレイクとマニフェストを作らせてください。 layoutを追加して、ファイル数とサイズの分布と、小さなファイルの個数を出力させてください。writeがキーを複数受け取り、1ファイルの行数を制限できるようにして、細かく分けたレイクを作ってください。queryを追加して、パーティションキーにかけた条件で、開くファイルを切り落とさせてください。compactを追加して、1つのパーティションの中の小さなファイルを、目標サイズに近づけてまとめさせてください。--crash=before-swapと--via=globを追加して、コンパクションの途中で読む側が何を見るかを確認してください。repartitionを追加して、キーを変えて書き直し、/root/parts/work/partition_report.mdにコストを書いてください。
参考
- 作業はすべて
/root/partsの下で行います。作業フォルダは/root/parts/workで、元データはその中のraw.jsonlです。 - レイク1つは、作業フォルダの下のディレクトリ1つです。その中に、パーティションディレクトリとマニフェスト
_manifest.jsonが入ります。 - パーティションディレクトリの名前は
칸=값(プレースホルダーは欄と値です)で、キーが複数なら、キーの順に重ねて積みます。例:day=2026-01-03/region=seoul/。 - パーティションファイルの名前は
part-で始まり.jsonlで終わります。1行が、元データの1行です。 - マニフェスト:
{"lake": 이름, "key": [칸 이름], "generation": 정수, "files": [{"path": 레이크 기준 상대 경로, "partition": {칸: 값}, "rows": 정수, "bytes": 정수}]}(プレースホルダーは、名前、欄名、整数、レイク基準の相対パス、欄、値です)。filesはpathの昇順で、bytesはディスク上の実際のファイルサイズである必要があります。 - 実行の契約:
python3 /root/parts/pq.py <명령> <작업폴더> [...](プレースホルダーは、コマンドと作業フォルダです)。答えは、JSONの1つの塊として標準出力に出します。成功すれば終了コード0、作業フォルダやマニフェストがなければ3、使い方が間違っていれば2、わざと落ちる経路に入れば9です。 write <작업폴더> --lake=<이름> --key=<칸[,칸]> [--rows=<줄 수>]の応答は{"lake": 이름, "key": [칸], "partitions": 정수, "files": 정수, "rows": 정수, "bytes": 정수}です(プレースホルダーは、作業フォルダ、名前、欄、行数、整数です)。--rowsは1ファイルに入れる最大行数で、ないか0なら、パーティションごとにファイル1つです。同じ名前のレイクがすでにあれば、消して新しく書きます。layout <작업폴더> --lake=<이름> --small=<바이트>の応答は{"lake": 이름, "files": 정수, "partitions": 정수, "rows": 정수, "bytes": 정수, "avg_bytes": 정수, "p50_bytes": 정수, "min_bytes": 정수, "max_bytes": 정수, "small_files": 정수}です(プレースホルダーは、作業フォルダ、名前、バイト、整数です)。avg_bytesは全体のバイト数をファイル数で割った商(切り捨て)で、p50_bytesはファイルサイズの最近傍順位の中央値で、small_filesはサイズが--small未満のファイル数です。query <작업폴더> --lake=<이름> --where=<칸=값[,칸=값]> [--via=manifest|glob]の応答は{"lake": 이름, "via": 문자열, "files_total": 정수, "files_scanned": 정수, "rows": 정수, "amount": 정수}です(プレースホルダーは、作業フォルダ、名前、欄と値、文字列、整数です)。値は文字列として比べます。パーティションキーにある欄でだけ、ファイルを切り落とせます。キーにない欄が条件にあれば、その条件では何も切り落とせず、ファイルを開いてみないとわかりません。--via=globは、マニフェストを無視して、ディレクトリからpart-*.jsonlをたどる読み取りです。compact <작업폴더> --lake=<이름> --target=<바이트> [--crash=before-swap]の応答は{"lake": 이름, "before_files": 정수, "after_files": 정수, "partitions": 정수, "rows": 정수, "bytes": 정수}です(プレースホルダーは、作業フォルダ、名前、バイト、整数です)。1つのパーティションの中のファイルを、pathの順につなげますが、次のファイルを足すと目標を超えるときは、そこで区切ります(ファイル1つは、どれだけ大きくても、単独で1つになります)。新しいファイルをすべて書いたあとで、マニフェストを付け替え、そのあとで古いファイルを消します。新しいファイルの名前は、古い名前と重なってはいけません。--crash=before-swapは、新しいファイルを書き終えたあと、マニフェストを付け替える直前に、終了コード9で落ちます。古いファイルも古いマニフェストも、そのままにしておきます。repartition <작업폴더> --from=<이름> --to=<이름> --key=<칸[,칸]>の応答は{"from": 이름, "to": 이름, "rows_read": 정수, "rows_written": 정수, "files_before": 정수, "files_after": 정수, "partitions_after": 정수, "bytes_read": 정수, "bytes_written": 정수}です(プレースホルダーは、作業フォルダ、名前、欄、整数です)。元のファイルではなく、--fromレイクのマニフェストをたどって読みます。- レポートMDの節の見出しは、
## 무엇을 어떻게 쪼갰나、## 작은 파일 문제、## 묶기、## 파티션을 바꾸는 비용です(韓国語の見出しで、順に「何をどう分けたか」「小さなファイル問題」「コンパクション」「パーティションを変えるコスト」を意味します)。 - 公式ドキュメント: Parquet Concepts・Parquet Configurations・Parquet File Format・os.replace
- よくある間違い: マニフェストのbytesを、実際のサイズではなく計算値で書く、キーにない欄で切り落としたと数える、マニフェストを先に付け替えてからファイルをあとで書く、コンパクションした新しいファイルに古い名前をそのまま使う。
- ファイルがどう置かれたかを目で見るには、
find /root/parts/work/<레이크> -name 'part-*' | head(プレースホルダーはレイク名です)とdu -aを使ってください。
パーティションキーの候補が複数ある元データを作る
/root/parts/gen_orders.pyを作成して実行し、/root/parts/work/raw.jsonlを作ってください。200行以上で、1行はorder_id・day・region・channel・整数のamountを含み、dayは4種類以上、regionは3種類以上、channelは3種類以上である必要があります。
値の種類数が重要です。3つをすべてキーにしたときに、パーティションがいくつに増えるかが、このラボで見るものだからです。種類数を掛けて、元データの行数で割ってみると、ファイル1つに何行残るかが、あらかじめ見えます。シードを固定しておかなければ、キーを変えながら比べる間に、元データが揺れます。
キー1つで分けて一覧を残す
/root/parts/pq.pyにwrite <작업폴더> --lake=<이름> --key=<칸>(プレースホルダーは、作業フォルダ、名前、欄です)を作り、値ごとに칸=값(プレースホルダーは欄と値です)ディレクトリを作って、その下にpart-0000.jsonlを書き、レイクの中に_manifest.jsonを残させてください。
マニフェストのbytesは、ディスク上の実際のファイルサイズである必要があります。行の長さを足して計算した値を書くと、改行やエンコーディングのためにずれ、そのずれは、あとでコンパクションで、おかしな場所で区切ります。ファイルを書いたあとで、サイズをもう一度測ってください。マニフェストも、一時的な名前で書いてから付け替えれば、一覧が途中まで書かれた状態を読むことがなくなります。
ファイルがどんなサイズで置かれているかを測る
layout <작업폴더> --lake=<이름> --small=<바이트>(プレースホルダーは、作業フォルダ、名前、バイトです)を追加して、ファイル数・パーティション数・行数・全体のバイト数と、平均・中央値・最小・最大のサイズ、そして--small未満のファイル数を出力させてください。
平均は嘘をつきます。大きなファイル1つと、小さなファイル数百個が混ざると、平均は無事に見えます。中央値は最近傍順位でとり、補間しないでください。avg_bytesは、全体のバイト数をファイル数で割った商(切り捨て)です。これらの値は、すべてマニフェストから読んで計算します。
細かく分けて小さなファイルを作ってみる
writeが--key=day,region,channelのようにキーを複数受け取るようにし、--rows=<줄 수>(プレースホルダーは行数です)で、1ファイルの最大行数を制限できるようにしてください。そのうえで、細かく分けたレイクを作って、layoutで前のレイクと比べてください。
キーを1つ加えるたびに、パーティション数は、その欄の値の種類数だけ掛け算されます。ディレクトリは、キーの順に重ねて積んでください。day=2026-01-03/region=seoul/channel=app/です。--rowsがないか0なら、パーティションごとにファイル1つです。2つのレイクのsmall_filesを並べて見ると、何を失ったかが数字で見えます。
開かなかったファイルはコストが0
query <작업폴더> --lake=<이름> --where=<칸=값[,칸=값]>(プレースホルダーは、作業フォルダ、名前、欄と値です)を追加して、条件に合う行数と金額を出力しますが、パーティションキーにある欄でだけ、開くファイルを切り落とすようにしてください。応答には、files_totalと、実際に開いたfiles_scannedが、一緒に入ります。
キーにない欄は、ディレクトリ名に入っていないため、その条件では何も切り落とせません。そのとき、files_scannedは、files_totalと同じである必要があります。これを正直に数えることが、このステップのすべてです。ここで水増しすると、あとで何が遅いのかを、永遠に見つけられません。値は文字列として比べてください。
小さなファイルをまとめる
compact <작업폴더> --lake=<이름> --target=<바이트>(プレースホルダーは、作業フォルダ、名前、バイトです)を追加して、1つのパーティションの中のファイルをpathの順につなげ、次のファイルを足すと目標を超えるときに区切るようにしてください。新しいファイルをすべて書いたあとでマニフェストを付け替え、そのあとで古いファイルを消します。
順序が重要です。マニフェストを先に付け替えると、読む側が、まだないファイルを開きます。新しいファイルの名前は、古い名前と重なってはいけません。重なると、読んでいるファイルを上書きすることになり、そのパーティションが丸ごと空になります。世代番号を名前に入れれば、重なることはありません。行数は、コンパクションの前後で同じである必要があります。
コンパクションの途中で読む人が見るもの
compactに--crash=before-swapを追加して、新しいファイルを書き終えたあと、マニフェストを付け替える直前に、終了コード9で落ちるようにし、queryに--via=globを追加して、マニフェストを無視してディレクトリからpart-*.jsonlをたどる読み取りを作ってください。落としたあとで、2つの読み取りの答えを比べてください。
これが、このラボの核心の場面です。マニフェストで読む側は、何の変化も見ず、ディレクトリをたどる側は、同じ行を2回数えます。落ちたあとも、古いファイルと古いマニフェストは、そのままである必要があります。そのあと、コンパクションを正しく終えると、古いファイルが消え、2つの読み取りが、また同じになります。
キーを変える代償を見積もる
repartition <작업폴더> --from=<이름> --to=<이름> --key=<칸[,칸]>(プレースホルダーは、作業フォルダ、名前、欄です)を追加して、元データではなく、--fromレイクのマニフェストをたどってすべて読み、新しいキーで書き直すようにしてください。そのうえで、/root/parts/work/partition_report.mdに、## 무엇을 어떻게 쪼갰나、## 작은 파일 문제、## 묶기、## 파티션을 바꾸는 비용の4つの節で書いてください(韓国語の見出しで、順に「何をどう分けたか」「小さなファイル問題」「コンパクション」「パーティションを変えるコスト」を意味します)。
パーティションキーは、ディレクトリ構造そのものなので、変えることは、すべての行を書き直すことです。rows_readとrows_writtenが同じかどうかが最初の確認で、bytes_readとbytes_writtenが、その作業の実際の値です。レポートには、そのバイト数を数字で書いてください。次の会議で、キーを変えようという話が出たときに必要な、唯一の数字です。