jqに食わせる前に「見る」— 構造化データを生のまま読む4場面

技術解説

jqが12GBを食って落ちた。

入力は8GBのJSONログです。jq は1件ずつ流して処理してくれるはずなのに、なぜメモリを使い切ったのでしょうか。

原因を突き止めるのに、高度な解析は要りませんでした。要ったのは、そのファイルの最初の3行を見ることだけです。

構造化データの道具は強力です。ただしどれも「構造が想定どおりである」ことを前提に動きます。前提が崩れているとき、道具は遅くなるのではなく——止まります。そして止まった道具は、何が起きたかを教えてくれません。

以下、パースする前に生のまま見たくなる4つの場面を並べます。返ってこないjq・1行1オブジェクトのログ・巨大SQLダンプ・Parquet化前の生TSV。4つとも、詰まるのは同じ1点です。

先に結論: UwView なら、JSONもSQLダンプもTSVもパースせずに開いた瞬間から中身を表示・検索でき、47.73GB・約8.9億行のテキストまで同じ扱いで確認できます(無料版・特定環境での実測。環境により異なります。詳細は記事末尾)


1. jqが返ってこない — パースする前に、形を確かめる

状況

8GBの events.json を集計しようとしています。

jq '.[] | select(.status >= 500)' events.json

数分後、メモリ使用量が伸び続け、最後にプロセスが消えます。jq -c にしても同じ。--arg を減らしても同じ。ファイルは正しいJSONのはずなのに、道具の側が耐えられません。

なぜ起きるか

トップレベルが1つの巨大な配列だからです。

jq の既定の動作は「入力を1つの値として読み切ってから、フィルタを適用する」です。ファイル全体が [ {...}, {...}, ... ] という1個の配列なら、閉じ括弧に到達するまで値は完成しません。8GBのJSONをメモリ上のツリーに展開すれば、生バイトの数倍に膨らみます。12GBという数字はそこから来ています。

一方、同じ8GBでも1行1オブジェクト(NDJSON)なら jq は1件ずつ処理して捨てられるので、メモリはほとんど増えません。同じ拡張子・同じ「JSON」という呼び名で、まったく別の消費特性になります。

そしてやっかいなのは、その違いがファイルの1文字目にしか現れないことです。先頭が [{ か。それを確かめないまま、私たちは8GBを道具に渡しています。

汎用ツールでの対処と限界

まず先頭を見て、形に合わせた読み方を選びます。

head -c 300 events.json          # 先頭300バイトだけ見る
jq --stream -c '.' events.json   # イベント列として流し読みする
jq -c '.[]' events.json | head   # 巨大配列を1件ずつに割る(それでも読み切りは走る)

--stream は正解のひとつです。パスと値のペアとして流れてくるので、メモリは伸びません。

限界は3つ。ひとつめ、head は先頭しか見えないことです。先頭300バイトが健全でも、6,200万行目に混ざった1件の壊れたレコードで jq は止まります。そして返ってくるのは parse error: ... at line 1, column 4831838192 のようなバイト位置だけです。行が1本しかない巨大配列では、行番号は情報になりません。

ふたつめ、--stream は書き味が変わることです。.status と書けば済んだものが [["status"],500] の照合になります。障害調査の最中に思考の形を変えるのは、それ自体がコストです。

みっつめ、確かめるたびに全読みが要ることです。壊れた位置を探して、前後を見て、別の条件で確かめる。その反復回数だけ、8GBの走査が積みます。第8回の「3回目のzgrepも8分」と、構図はまったく同じです。


2. 1行1オブジェクトのログを、目で読む

状況

こんどはNDJSONです。1行が1つのJSONオブジェクト、キーは40個、1行あたり2KB。less で開くと、画面は折り返された波括弧で埋まります。

jq で整形すれば読めます。ただし整形すると1レコードが40行になり、画面には1件半しか映りません。時系列で流れを追いたいのに、スクロールの速度が実時間に追いつきません。

なぜ起きるか

JSONは機械のための書式で、人間の目のためには何も約束していないからです。

JSONオブジェクトのキー順は仕様上「順序なし」です。実装がたまたま保っているだけで、ライブラリのバージョンやシリアライザの設定で変わります。つまり同じフィールドが、行ごとに違う桁位置に現れます。人間が高速に読めるのは縦がそろっている表であって、位置が動く文字列ではありません。

さらに、非ASCII文字が エラー のようにエスケープされている場合があります。こうなると、「エラー」で検索してもヒットしません。文字コードが正しくても読めない、という状況は第3回で扱った罠と近い構図です。

汎用ツールでの対処と限界

見たい列だけをTSVに落とすのが定石です。

jq -r '[.ts, .level, .service, .msg] | @tsv' app.ndjson | less
grep '"level":"ERROR"' app.ndjson          # 生のまま絞る
jq -r '.msg' app.ndjson | sort | uniq -c   # 出現パターンを数える

1本目はよく効きます。縦がそろい、目で追える形になります。

限界は3つ。ひとつめ、見たい列を変えるたびに全件パースが走ることです。.service を足したくなる、.trace_id も要る、と気づくたびに数十GBを読み直します。調査は本来「見ながら次に見るものを決める」作業なのに、その1往復が数分単位になります

ふたつめ、生の grep は表記に依存することです。"level":"ERROR""level": "ERROR" はJSONとしては同じですが、文字列としては別物です。出力元が複数あると、片方だけヒットして「件数が合わない」が起きます。

みっつめ、壊れた1行でパイプが止まることです。jq は不正な行に当たると非ゼロで終了します。9,999万行が正しくても、1行の異常で出力が途切れる。しかもどこまで出たかは、出力の末尾を見るまで分かりません。


3. 4GBのSQLダンプから、1テーブルぶんだけ取り出す

状況

mysqldump で取った4GBのダンプがあります。欲しいのは orders テーブルの INSERT だけ。

丸ごとリストアすれば確実ですが、その空きがありません。そもそも本番相当のデータを手元に展開したくない、という事情もあります。

なぜ起きるか

ダンプは索引のない、先頭から実行される1本のスクリプトだからです。

ダンプファイルは「復元するために順に流す」形式です。目次はなく、テーブルの並び順も保証されません。目的のテーブルがどこにあるかは、読んでみるまで分かりません

そして行単位の道具にとって致命的なのが、拡張INSERT(extended insert)です。既定では数千行ぶんの値を1つの INSERT 文にまとめるため、1行が数百MBになることがありますgrep は行を単位に判定するので、ヒットすれば数百MBが1件として返ってきます。sedawk も、内部でその1行をバッファに載せます。行という単位が壊れた瞬間、行指向のツール群がまとめて使いにくくなります。

汎用ツールでの対処と限界

範囲指定で切り出すのが基本形です。

grep -n 'Table structure for table' dump.sql        # テーブルの境界を洗い出す
sed -n '/Table structure for table `orders`/,/Table structure for table `payments`/p' dump.sql > orders.sql
awk '/^INSERT INTO `orders`/' dump.sql > orders_insert.sql

3本目は素直で、実務でもよく使われます。

限界は3つ。ひとつめ、次のテーブル名を先に知っている必要があることです。sed の範囲指定は終端マーカーを要求します。境界を確かめて、範囲を書いて、外して、書き直す——当たるまでに3〜4回、そのたびに4GBの走査です。

ふたつめ、切り出した結果が正しいかを目で見たくなることです。orders.sql の中身を確認しようとして、こんどはそのファイルを開く問題に戻ります。数百MBの1行を含むファイルは、多くのエディタで開いた時点で固まります。

みっつめ、元の位置が失われることです。「ダンプの何行目から取ったか」は切り出した側には残りません。あとから同僚に「その範囲、本当に合ってる?」と聞かれたとき、答えるにはもう一度4GBを走査することになります。原本の上で位置を保ったまま確認できていれば、この往復は要りません。


4. Parquetにする前に、生TSVでスキーマ推定を裏取りする

状況

60GBのTSVをParquetに変換します。型推定にかけると、23列目が string と判定されました。設計上は整数のはずの列です。

変換自体は通ります。通ってしまうので、気づくのは数週間後、集計値が合わないときです。

なぜ起きるか

型推定が、先頭のN行しか見ていないからです。

多くの変換ツールは、先頭数千〜数万行をサンプリングして型を決めます。ファイル全体を2回読むのは高価だからで、設計としては妥当です。ただしこの前提が意味するのは、4,000万行目に1件だけ混ざった N/A を、推定は見ていないということです。

崩れ方はほかにもあります。値の中にタブが混ざっていて列数がずれる。CRLFとLFが混在していて第3回で触れたような行の数え違いが起きる。途中でヘッダ行が再挿入されている(ファイルを結合したときによく起きます)。どれも「1行だけ」の異常で、全体の型と行数を狂わせます。

汎用ツールでの対処と限界

全量を1回舐めて、異常な行の番号を出すのが確実です。

awk -F'\t' 'NF!=23 {print NR": "NF}' data.tsv | head        # 列数が違う行
awk -F'\t' '$23 !~ /^-?[0-9]+$/ {print NR": "$23}' data.tsv  # 23列目が整数でない行
sed -n '40123456,40123460p' data.tsv                         # その行の前後を見る

これで場所は分かります。awk は1パスで済むので、検品そのものは素直です。

限界は3つ。ひとつめ、行番号が分かっても、そこへ飛べないことです。3本目の sed は先頭から順に数えて4,000万行目に着きます。行番号を得た瞬間に、もう一度全読みが確定します。 前後をもう10行広げたくなったら、また全読みです。

ふたつめ、条件を先に決めないと書けないことです。awk の式は「何が異常か」を知っている前提で書きます。ところが実際は逆で、変な行を見てから「これが異常だ」と分かることのほうが多い。検品は仮説検証ではなく、まず観察です。

みっつめ、異常が1種類とは限らないことです。列数のずれを直したら、こんどは日付書式の混在が出てくる。そのたびに awk を書き直し、60GBを読み直す。確認の回数だけ、線形にコストが積みます。


4つに共通していたもの

場面 使った道具 道具が前提していたこと 前提が崩れると
jqが返ってこない jq 入力が想定した形のJSONである メモリを使い切り、バイト位置だけ残して止まる
NDJSONを目で読む jq -r / grep キー順と表記が一定である 列がそろわず、検索語も安定しない
SQLダンプの抽出 sed / awk 1行が人間の読める長さである 数百MBの1行で行指向の道具が総崩れになる
TSVのスキーマ検品 型推定 / awk 先頭N行が全体を代表する 4,000万行目の1行が推定を裏切る

3列目を縦に読むと、正体が見えます。構造化ツールはどれも「構造がすでに分かっている」前提で最適化されています。 速いのはそのおかげです。ところが私たちがファイルを開きたくなるのは、たいていその前提が崩れているときです。道具がいちばん強い場面と、私たちがいちばん困る場面が、きれいにすれ違っています。

止まる場所を集めると、要求は3つです。

  • パースせずに、まず見る — 形が分からないものを、形を決めてから読むことはできません。1文字目・先頭3行・4,000万行目の1行。それを見るのに、8GBのツリー構築は要らないはずです。
  • 行番号で直接飛び、位置を保つ — 検品が返すのは行番号です。行番号が「そこへ行ける座標」になっていないなら、検品結果は道半ばで終わります。切り出したファイルではなく、原本の上で位置を保ったまま確認できることが要ります。
  • 確かめる回数を増やしても、走査が積まない — 調査も検品も本質的に反復です。条件を変え、範囲を広げ、別の角度から確かめる。1回あたりのコストが全読みである限り、丁寧に確かめる人ほど時間を失います。

3つとも、構造化ツールの改良では解けません。パースの前段、「生のテキストとして開いて見る」という工程が抜け落ちているだけだからです。jq も型推定も、その工程が終わっている前提で始まる道具です。

冒頭の「jqが12GBを食って落ちた」に戻ります。あれは jq の欠陥ではありません。8GBのファイルの1文字目を、誰も見ていなかったのが原因です。見るのに要る時間は、1秒もありません。


使っている道具

私が開発している UwView(無料)は、巨大なテキストを開いた瞬間から全体を表示・スクロール・検索できるビューアです。索引はバックグラウンドで作られ、完成すると行番号が付きます(他のビューアの多くは索引が終わるまで先頭しか見えません)。分割せず、原本を1本のまま・無改変のまま扱えます。JSONもSQLダンプもTSVも、この道具にとっては同じ「テキスト」です。 パースしないので、壊れていても開けます。この記事の4場面が求めていた「パースの前に見る」が、そのまま用途にあたります。

  • 1文字目と、末尾と、4,000万行目: 開いた瞬間からどこへでも動けるので、[ で始まるか { で始まるかを確かめるのに head を打つ必要がありません。行番号が付いたあとは、検品で得た行番号にそのまま飛べます。第4章の「行番号を得た瞬間に全読みが確定する」が起きません。
  • 原本の上で位置を保つ: 切り出さずに原本を開いたまま確認するので、第3章の「切り出した先の行番号は原本の何行目か」という問い自体が発生しません。報告に貼る±N行の切り出しも、原本を汚さずにできます。
  • 文脈つきの検索: "level":"ERROR" のような生の文字列でそのまま探せます。表記ゆれがあるときは、共通部分だけで探して結果を目で確かめるほうが速いことがあります。→ 多段階検索の記事

正直に書いておくと、UwView はJSONを解釈しません。整形も、キーの抽出も、スキーマ検証もしません。それは jq や変換ツールの仕事で、置き換える意図はありません。この道具の役割は「その手前で見る」ところまでです。また、拡張INSERTのように1行が数百MBあるファイルは、どんなビューアでも「1行」として扱われます。UwView も例外ではなく、快適な閲覧はできません。効くのは検索で位置を突き止めるところまでです。加えて、無料版で使えるのは1段検索・±1文脈・結果の保存までで、多段階検索・シーケンシャル検索・可変の±N・索引と圧縮の保存は Pro の機能です。

そして、ストレージを専有している巨大な生データを圧縮して保管し、さらに高速に検索したいなら UwView Pro をどうぞ。永続索引・圧縮キャッシュ検索・約1/9保管で、開き直しも検索も一段速くなります(全OS対応・買い切り/月額プランあり)。毎月同じ入力ファイルを検品するような使い方では、2回目以降の開き直しが行番号付きで済みます(47.73GBのテキストで実測0.02〜0.07秒。環境により異なります)。

なお Pro には非破壊の差分編集を追加するライセンスもありますが、この記事の4場面はすべて「見る・探す・切り出す」の範囲なので、ここでは触れません。

リンク

  • 第1回: 巨大ファイルに沈む4つの定番ツールと、その先: https://uvp.y42u.net/blog/uwview-ps01-huge-file-tool-limits/
  • 第2回: ログの因果を遡る4つの技法: https://uvp.y42u.net/blog/uwview-ps02-log-causality-tracing/
  • 第3回: 文字コード4つの罠と切り分け手順: https://uvp.y42u.net/blog/uwview-ps03-japanese-encoding-traps/
  • 第4回: 消す・残す・圧縮するの判断基準: https://uvp.y42u.net/blog/uwview-ps04-log-retention-decision/
  • 第5回: 開発ログとの正しい付き合い方4選: https://uvp.y42u.net/blog/uwview-ps05-debug-log-practices/
  • 第6回: 認証ログ数千万行から痕跡を拾う4手順: https://uvp.y42u.net/blog/uwview-ps06-intrusion-triage-auth-logs/
  • 第7回: タイムスタンプを武器にする4つの読み方: https://uvp.y42u.net/blog/uwview-ps07-timestamp-driven-triage/
  • 第8回: 圧縮保管と検索の両立: https://uvp.y42u.net/blog/uwview-ps08-compressed-archive-search/
  • 多段階検索(ドリルダウン)— 9万件を2回の絞り込みで54件に: https://uvp.y42u.net/blog/uvp-drilldown-search/
  • 258GBのOSMデータを1ファイルとして開く: https://uvp.y42u.net/blog/uwview-osm-usa-258gb/
  • ソースコード(GitHub): https://github.com/amru195704/UwView

開発者より: アプリ・Kindle本・オープンソースの一覧は GitHub: amru195704 にまとめています。


お願い
本記事の情報は参考目的で掲載しており、正確性・完全性を保証するものではありません。jq のメモリ使用量・型推定のサンプリング範囲・mysqldump の既定オプションは、バージョン・実装・設定により異なります。記載した実測値は特定の1環境での測定例であり、同じ結果を保証するものではありません。コマンド例は環境(GNU/BSD、シェル、jqawksed の実装とバージョン等)により調整が必要です。誤記・不正確な情報がございましたら、コメント欄よりご指摘いただければ、確認のうえ修正いたします。

タイトルとURLをコピーしました