DDIA 第10章:バッチ処理(やさしい解説)
Tony Duong
5月 23, 2026 ・ 2 分
『Designing Data-Intensive Applications』の第10章は、その時々のデータベースから視点を引いて、別の問いを投げかけます。リアルタイムでの応答が不要なときに、ギガバイト、テラバイト級の膨大なデータをどう処理するか? これがバッチ処理の世界です。この記事では、章の大きなアイデアをシンプルな比喩で追っていきます。
3種類のシステム
本題に入る前に、章では便利な思考モデルを提示しています。
- サービス(オンライン): 到着するリクエストに応答する。レイテンシが重要。例:Webサーバー。
- バッチ処理(オフライン): 固定された大きな入力を処理して出力を生成する。スループットが重要で、レイテンシは分や時間単位。例:夜間レポート。
- ストリーム処理(ニアリアルタイム): その中間 — イベントを到着次第処理するが、ユーザーへの即時応答は不要。(これは次章の話題です。)
この章の残りは2番目について扱います。
小さく始める:Unixパイプ
DDIAは楽しい観察から始まります。数行のUnixシェルでかなりのことができる、という点です。
cat access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head
このパイプラインはログファイルの中で上位のURLを見つけ出します。各ツールは1つのことだけを行い、stdinから読み、stdoutに書き、シェルがそれらを繋ぎ合わせます。ファイルこそが普遍的なインタフェースです。
これがバッチ処理が受け継いだ哲学です。小さく組み合わせ可能なツール、不変な入力、決定的な出力。何か問題が起きたら再実行すればよい。入力ファイルは変わっていないのですから。
落とし穴は、「ログファイル」が100 TBになると1台のマシンには収まらないことです。
MapReduce:1000台のコンピュータのためのUnixパイプ
MapReduce(Googleが広め、その後オープンソースのHadoopで普及)は、本質的には同じアイデアをクラスタ全体にスケールアウトしたものです。
書くのは2つの関数だけです。
- マッパー (mapper): 1レコードずつ受け取り、0個以上の
(key, value)ペアを出力する。 - リデューサー (reducer): ある特定のキーに対する全ての値を受け取り、出力を生成する。
その間で、フレームワークが魔法をかけます。すべての(key, value)ペアをネットワーク越しにシャッフル (shuffle) し、同じキーを持つものが同じリデューサーに集まるようにします。このシャッフルが重い処理の中心です。
具体例:単語カウント
- マッパーは行を読み、各単語について
(word, 1)を出力する。 - シャッフルが
"the"に対する1を全てまとめ、"cat"に対する1を全てまとめる、というように。 - リデューサーは各キーの値を合計する。
同じ形は、ログ解析、検索インデックス構築、レコメンデーション計算など、「何かでグループ化して集約する」と定式化できる問題なら何にでも当てはまります。
MapReduceでのジョイン
ランダムなデータベースルックアップなしに2つのデータセット(例:ユーザーとクリック)を結合する話題は繰り返し登場します。
- ソートマージジョイン (sort-merge join): 両側が同じキー(例:
user_id)を出力し、リデューサーは全クリックとユーザーレコードを一緒に受け取る。 - ブロードキャストハッシュジョイン (broadcast hash join): 小さい側を全マッパーのメモリにロードし、大きい側をストリーム処理する。
- パーティションドハッシュジョイン (partitioned hash join): 両側があらかじめジョインキーでパーティション分割されている。
名前は大層ですが、考え方はラップトップでやるのと同じ — ただ分散しているだけです。
なぜMapReduceは愛されなくなったのか
MapReduceは革命的でしたが、書くのは苦痛です。
- どのジョブもただのmap + reduce。それ以上に複雑なものはジョブの連鎖になる。
- ジョブ間の中間結果はディスク(HDFS)に書かれる — 遅い。
- 10ステージのワークフローは、データセットを10回読み書きすることになる。
ここでApache Sparkが登場します。
Apache Spark:同じアイデア、よりスマートな実行
Sparkは同じ思考モデル — データをパーティション分割し、関数を適用し、必要に応じてシャッフルする — を保ちつつ、苦痛だった部分を解消しました。
- インメモリ実行: 中間データは可能な限りステージ間でRAMに保持される。大きな高速化。
- より豊かなオペレータ: map/reduceだけでなく、
filter、join、groupBy、reduceByKey、aggregateなどが流暢なAPIとして用意されている。 - DAGプランナー: Sparkは記述された変換すべてのグラフを構築し、実行前に計画全体を最適化する。ステージを融合し、ジョイン戦略を決定し、不要なシャッフルを回避できる。
- Resilient Distributed Datasets (RDD): 中心的な抽象 — 由来を知っているパーティション分割されたコレクション。パーティションが失われたら、Sparkはリネージ (lineage) から再計算する。レプリケーションは不要。
ユーザー視点の体験は、MapReduceジョブを書くというより、PythonやSQLを書くのに近いです。「CSVを読み、フィルタし、ジョインし、グループ化し、Parquetに書き出す」というパイプラインは、1つのプログラムとして読めます — 分散させ方はSparkが考えます。
これらのアイデアを手を動かしながら追えるよう、小さなリポジトリを作りました:learn-apache-spark。
データフローエンジンと高レベルAPI
Sparkは、章でデータフローエンジン (dataflow engines) と呼ばれる広いカテゴリの一例です(他にはFlink、Tez)。これらはMapReduceのアイデアを、任意のオペレータDAGに一般化したものです。
これらのエンジンの上には、データフロー計画にコンパイルされる高レベルAPIが乗っています。
- ビッグデータ上のSQL(Hive、Spark SQL、Presto)
- DataFrame API(Spark DataFrames、pandas-on-Spark)
- グラフ処理(Pregel流:GraphX、Giraph)
- 機械学習(MLlib)
教訓は、もはやほとんどの人は手書きでmap/reduceを書かない、ということです。SQLやDataFrameのコードを書き、分散の配管はエンジンが面倒を見ます。
バッチのための設計:繰り返し現れる原則
章の中で何度も登場するアイデアがいくつかあります。
- 不変な入力。 ソースデータを変更することは絶対にない。新しい出力を生成する。ジョブが間違っていたら、コードを直して再実行 — 入力はそのまま残っている。
- 決定的な関数。 マッパーとリデューサーは、同じ入力に対して同じ出力を生成すべき。これがリトライを安全にする。
- 再実行による耐障害性。 ノードが死んでも、フレームワークは作業の一部を別の場所で再実行するだけ。これは上の2点があってこそ可能。
- ストレージとコンピュートの分離。 データはHDFS / S3 / オブジェクトストレージに置かれ、コンピュートクラスタはその上で起動・停止する。
バッチ vs リアルタイムデータベース
立派なデータベースがあるのに、なぜバッチを使うのでしょうか?
- コスト: バッチジョブは安価で遅いストレージとバーストコンピュートを使う。トランザクショナルDBから配信するよりバイトあたりがずっと安い。
- スループット: 100 TBをシーケンシャルにスキャンするのは速い。同じ量をDBへの個別クエリ100 TB分でやるのはそうではない。
- 疎結合: バッチの出力(例:検索インデックス、レコメンデーションファイル)はその後、配信システムにロードされる。オフラインで構築し、オンラインで配信する — バッチパイプラインの障害がWebサイトを落とすことはない。
要点まとめ
- Unixパイプは哲学的な祖先:小さなツール、不変なファイル、組み合わせ可能性。
- MapReduceは、そのアイデアをクラスタ全体に分散させ、中間にシャッフルを置いたもの。
- MapReduceは苦痛である。ステージ間で全てがディスクを経由し、map+reduceしか提供されないため。
- Apache Sparkはそのモデルを保ちつつ、メモリ上で実行し、豊富なオペレータをサポートし、実行前にDAG全体を計画する。
- RDDはレプリケーションではなくリネージによってSparkを耐障害性のあるものにする。
- データフローエンジン + SQL/DataFrame API が、今日のバッチ作業のほとんどが行われる場所 — 生のmap/reduceを書くことはめったにない。
- 不変性と決定性こそが、分散バッチ処理のリトライを安全にする。
- バッチとオンラインシステムはパートナー: バッチが成果物(インデックス、モデル、レポート)を作り、オンラインシステムがそれを配信する。
🌐 Claudeによる翻訳