ログは圧縮して置いてあるのに、調べるときは毎回もとに戻している——という運用をしていませんか。私はしていました。
50GB のログを gzip -6 で固めると 5.75GB になります。ここから「東京」を探すと 120.67秒。内訳は解凍 65.05秒+rg 55.62秒です。zip -6 にすると同じ 5.76GB なのに 203.06秒。そして圧縮率がほぼ同じ 5.74GB でも、7.21秒で探せる置き方があります。
同じ 1/9 に縮んでいるのに、探す時間が 17倍 違う。その差がどこから来るのかを、DEFLATE の仕様まで下りて整理します。
実測
Apple M4 / メモリ32GB / 外付け USB SSD、ripgrep 15.2.0、対象は OpenStreetMap 日本データ(XML)です。
固める
| 元ファイル | gzip -6 | zip -6 | ブロック圧縮+索引 |
|---|---|---|---|
| 3.03GB | 0.30GB(16.8秒) | 0.30GB(19.4秒) | 0.25GB(1.2秒) |
| 10.26GB | 1.12GB(66.5秒) | 1.12GB(76.8秒) | 1.16GB(14.9秒) |
| 51.25GB | 5.75GB(347.2秒) | 5.76GB(394.7秒) | 5.74GB(65.0秒) |
探す(固定文字列1語)
| サイズ | gzip(解凍+rg) | zip(解凍+rg) | 索引つきを直接検索 |
|---|---|---|---|
| 3GB | 6.47秒(3.49+2.98) | 8.71秒(8.08+0.63) | 0.68秒 |
| 10GB | 24.70秒(13.12+11.58) | 40.48秒(29.28+11.20) | 1.74秒 |
| 50GB | 120.67秒(65.05+55.62) | 203.06秒(146.97+56.09) | 7.21秒 |
圧縮率は3方式ともほぼ同じです。違うのは探し方のほうです。
なぜ gzip は「全部展開」しかできないのか
gzip(DEFLATE)はひとつながりのストリームです。途中から読み始められないのは、次の2つが前を引きずるからです。
ひとつは LZ77 のスライディングウィンドウ。DEFLATE は「32KB 手前の、この位置から N バイトをコピー」という参照でデータを表します。したがって 40GB 目を復元するには、その手前 32KB が復元済みでなければならず、その 32KB を作るにはさらに手前が要る——と芋づる式に先頭まで遡ります。
もうひとつは ハフマン符号の境界がバイト境界に揃っていないこと。DEFLATE はビット単位で詰めるので、「ブロックの切れ目がファイルの何バイト目か」は解凍してみるまで分かりません。バイトオフセットで飛び込んでも、そこが符号の途中なのか先頭なのか判定できないわけです。
だから zgrep も gzip -dc | grep と同じことをしています。圧縮の中を検索しているのではなく、展開しながら検索しているだけです。そして展開は毎回発生します。
zip はどうか。zip はエントリ単位では独立しているので、100個のファイルを固めた zip から1個だけ取り出すのは高速です。しかし今回のように巨大な1ファイルを1エントリで固めた場合、中身は同じ DEFLATE ストリームなので、gzip と同じ制約を受けます。上の表で zip が gzip より遅いのは形式の差というより実装の差(unzip の展開が 146.97秒 対 gzip -dc の 65.05秒)ですが、構造的な問題は同じです。
途中から読めるようにする方法
対策は昔からあって、どれも発想は同じです。ストリームを切って、切れ目の位置を覚えておく。
- BGZF(bioinformatics の bgzip): 64KB ごとに独立した gzip メンバとして圧縮し、別ファイルに索引を持つ。gzip として展開できる互換性を保ったまま、ランダムアクセスができる
- dictzip: gzip の extra field にチャンク長の表を埋め込む。普通の gzip としても読める
- zstd の seekable format: フレームを分けて、末尾にシークテーブルを置く
- zlib の zran: 一度通しで読んで「一定間隔ごとのウィンドウ状態」を記録しておき、次回からそこへ飛ぶ
共通する代償は圧縮率の悪化です。ブロックごとに辞書がリセットされるので、ブロックをまたぐ繰り返しを拾えません。ブロックを小さくするほどランダムアクセスは細かくなり、圧縮率は落ちます。
検索を目的にすると、索引も要る
ブロック圧縮だけでは「途中から展開できる」までです。検索を速くしたいなら、展開の総量そのものを減らす必要があります。
私が作っている巨大ファイルビューア(UwView Pro)では、.uwvz という形式で次の3つを1つのファイルに入れています。
- ブロック単位に切った圧縮データ(ブロックごとに独立。展開はブロック単位)
- 行番号 → バイト位置の疎な索引(256行ごとに1点。2億行で約6MB)
- 文字コードや行数などのメタデータ
これで何が変わるか。50GB の検索で読むのは 5.74GB のブロックだけです。元の 51.25GB を読まないので、ディスクから流し込むバイト数が 1/9 になる。展開は CPU を食いますが、いまの CPU なら I/O の削減のほうが勝ちます。7.21秒という値はそこから来ています。
そして 2問目以降も 7秒のままです。gzip のほうは、展開したファイルを残しておけば2問目から rg の 55秒になりますが、それでは圧縮した意味がありません。
数字で並べるとこうなります。
| 圧縮率 | 展開せずに探せるか | 元ファイルを消せるか | |
|---|---|---|---|
| gzip / zip | 1/8.9 | ✕(毎回全展開) | ○(戻せる) |
| BGZF / dictzip / seekable zstd | やや落ちる | △(途中から読めるが、探すのは自前) | ○ |
ブロック圧縮+索引(.uwvz) |
1/8.9 | ○ | ○(バイト一致で復元) |
ログの保管形式を選ぶ3つの軸
一般化すると、選択の軸は3つです。
- 圧縮率 — 保管費用に直結する。gzip で 1/9、専用形式(OSM の PBF など)なら 1/20 まで行くこともある
- 展開せずに探せるか — 調べる回数が多いほど効く。「年に1回しか触らない」なら gzip で十分です
- 元に戻せるか — 監査や再処理のために原本が要るなら、可逆であることと、その復元にライセンスや特殊なツールが要らないことを確認しておく
3番目は見落としがちです。特殊な形式で固めた結果、「読むのに有償ツールが要る」状態になると、保管としては失格になりかねません。
再現
# 固める
time gzip -6 -c big.log > big.log.gz
time zip -6 big.log.zip big.log
# 探す(毎回の展開が入る)
time sh -c 'gzip -dc big.log.gz | rg -n -F "東京" > /dev/null'
time sh -c 'unzip -p big.log.zip | rg -n -F "東京" > /dev/null'
キャッシュの影響を外すには、macOS なら各計測の前に sudo purge、Linux なら sync && echo 3 | sudo tee /proc/sys/vm/drop_caches を挟んでください。挟まないと2回目以降がメモリ上の勝負になり、ディスクから読むバイト数の差が見えなくなります。実際、3GB のファイルはメモリに収まるので、温めてしまうと今回の差はほとんど消えます。
まとめ
- gzip が「途中から読めない」のは、LZ77 のウィンドウとハフマンのビット境界という形式の性質であって、ツールの手抜きではない
-
zgrepは圧縮の中を探しているのではなく、展開しながら探している - 途中から読みたいなら、ストリームを切って位置を覚える(BGZF / dictzip / seekable zstd / zran)。代償は圧縮率
- 検索を速くしたいなら、そこに索引を足して読むバイト数自体を減らす。50GB で 120秒 → 7秒 の差はここから出た
実測の全数値とスクリプトはこちらに置いてあります: https://uvp.y42u.net/blog/uwvz-compressed-archive-search-extract/