データベース●●●●○

ストリーム処理

Stream Processing ・ すとりーむしょり

流れ続けるイベントを溜めてからではなく、到着するそばから継続的に処理し、結果を更新し続ける方式。

✦ 30秒で試す — 航海1分間の集計を出すのに、1分ちょうどで締めると数え漏れる漏れを0件にするには、待ち時間を何倍にする?予想してみる

概要

ストリーム処理は、絶え間なく発生し続けるデータ — クリック、決済、センサー値、ログの1行1行 — を、いったん溜めてからまとめて処理するのではなく、到着するそばから継続的に処理していく方式です。「1日分を貯めて夜間に集計する」のがバッチ処理だとすれば、ストリーム処理は「流れてくる1件1件をその場でさばき続ける」処理と言えます。

典型的な適用場面は、リアルタイムのダッシュボード(直近5分の売上・アクセス数)、クレジットカードの不正検知、在庫や価格の即時反映、メトリクスの集計とアラートなどです。共通するのは「答えが数時間後では価値が薄れる」こと。データの発生から反応までの遅延(レイテンシ)を、時間単位から秒〜ミリ秒単位へ縮めることがストリーム処理の存在意義です。メッセージキューで運ばれてくるイベントの流れを入力とし、変換・集計した結果を次の系へ流し込む、データ基盤の「動脈」にあたる語彙です。

なぜ生まれたか

長らくデータ処理の標準は夜間バッチでした。業務システムからETLでデータを吸い上げ、夜中に集計し、翌朝レポートができあがる。この方式は堅牢ですが、どうしても「昨日までのデータ」しか見えません。EC の不正注文を翌朝に検知しても商品は発送済みですし、障害の兆候を示すログを翌日に集計しても手遅れです。ビジネスがオンライン化するほど、「発生した瞬間に反応したい」要求とバッチの周期のギャップが広がっていきました。

素朴な解決策は「バッチの間隔をどんどん短くする」ことですが、1時間ごと、5分ごとと縮めていくと、ジョブの起動・突き合わせのオーバーヘッドが支配的になり破綻します。そこで発想を反転させ、「データが有限に区切られている」という前提を捨てて、終わりのないデータの流れをそのまま扱うエンジンが作られました。2011年に LinkedIn が公開した Apache Kafka が、イベントの流れを追記専用のログとして貯めて配る土台として普及し、Storm、Spark Streaming が続きます。しかし初期のエンジンは「届いた順」に数えるのが基本で、遅れて届くイベントを正しく扱えませんでした。Google は社内の MillWheel(2013年)で、どこまでのイベントが届き切ったかの見積もりである「ウォーターマーク」を導入し、2015年に Tyler Akidau らが Dataflow モデルの論文で、イベント時刻・ウィンドウ・ウォーターマーク・トリガー(いつ結果を出すか)という語彙に整理します。これを実装した Apache Flink や Apache Beam が「イベント時刻に基づく正確なウィンドウ集計」と「障害時も結果が壊れない状態管理」を実用にしたことで、ストリーム処理は特殊技術から標準的な選択肢になりました。かつては速報値をストリームで、正確な値を夜間バッチ(ETL)で別々に計算して重ねる「ラムダアーキテクチャ」が定石でしたが、同じロジックを2系統に実装する負担が大きく、現在はストリーム処理系に一本化する(バッチを「有限のストリーム」として同じエンジンで扱う)方向へ収束しつつあります。

詳細

バッチとの対比 — 有限か、終わりがないか

バッチ処理は「入力が有限」であることを前提にできます。全件読み終えてからソートも集計もできるし、失敗したら最初からやり直せばよい。一方ストリーム処理の入力には終わりがなく、「全部読み終えてから」が永遠に来ません。そのため、結果は一度きりの確定値ではなく「今この瞬間までの集計値」として継続的に更新され続けます。途中経過(状態)を持ち続けること、そして障害で処理が止まっても状態を復元して流れの途中から再開できることが、エンジンの中核機能になります。

イベント時刻と処理時刻 — 「いつ起きたか」と「いつ届いたか」

ストリーム処理を難しくしている最大の要因が、時刻が2種類あることです。イベント時刻はその出来事が実際に起きた時刻、処理時刻はそのイベントがシステムに到着して処理された時刻。ネットワークの遅延、モバイル端末のオフライン、リトライなどにより、イベントは発生順どおりには届かず、遅刻も順序の入れ替わりも日常的に起こります。「10時00分〜10時01分のクリック数」を正しく数えたいなら、到着順ではなくイベント時刻で数えなければなりません。

そこで登場するのがウィンドウ集計です。終わりのない流れをそのまま集計することはできないので、「1分ごと」のような時間の窓で区切って窓ごとに集計します。窓の区切り方には、重ならず並べるタンブリングウィンドウ、一定間隔でずらしながら重ねるスライディングウィンドウ、無活動期間で区切るセッションウィンドウなどがあります。

問題は、いつ窓を閉じて結果を出すかです。10:00〜10:01 の窓は、時計が 10:01 を回った瞬間に閉じてよいでしょうか。そうすると、10:00:50 に起きて 30秒遅れで届いたクリックは数えられません。かといって永遠に待てば、結果は永遠に出ません。この判断のための印がウォーターマークです。ウォーターマークは「イベント時刻がこの時刻より前のものは、もうおおむね届き切ったはず」という見積もりで、エンジンの中を流れと一緒に進んでいきます。よく使われる作り方は「これまでに見えた最大のイベント時刻から、許容する遅れ(たとえば30秒)を引く」もので、ウォーターマークが窓の終端を越えたときに窓を閉じ、結果を確定させます(イベントが途切れなく流れていれば「いまの時刻から待ち時間を引いた線」とほぼ同じになるので、下の図と部品ではそう描いています)。

10:0010:0110:0210:03確定 3件集計中 2件集計中 1件10:00:50 に起き80秒遅れで到着確定済み → 捨てる10:01:30 に起きまだ間に合うウォーターマーク 10:01:40いま 10:02:10(処理時刻)待ち 30秒ウォーターマークが 10:02 を越えたら、真ん中の窓も確定する
ウォーターマークで窓を閉じる — いまの時刻から待ち時間だけ手前の線が、窓の右端を越えたら確定する

どこまで遅刻を待つかは、正確さと速報性のトレードオフそのものです。待ちを短くすれば結果は早く出ますが、遅れて届いたイベントを取りこぼします。長くすれば取りこぼしは減りますが、どの窓も終わってからその時間だけ結果が出ません。下の部品で、ウォーターマークの線をドラッグして待ち時間を変え、同じイベントの流れを何度か流して比べてください。

⚡ 体験: ウォーターマークを動かして、窓を閉じる時刻を決める
開始前10:0010:0110:0210:0310:0410:0510:0610:0710:08起きた待ち 20秒ウォーターマーク
待ち時間20秒取りこぼし0件いま—
流した記録(同じ流れ・待ち時間だけ変える)
まだ流していません
    ○ 早く閉じて取りこぼした○ 待って全部数えた(確定は遅れる)○ 2つの待ち時間で比べた

    金の線(ウォーターマーク)を左右にドラッグしてどれだけ遅刻を待つかを決め、「流す」を押してください。線が窓の右端を越えると、その窓は閉じて件数が確定します。

    実際のエンジンは、この二択を和らげる仕組みも持っています。ウォーターマークより前に途中経過を速報として出しておくトリガー、窓を閉じた後もしばらくは遅刻イベントを受け付けて訂正版の結果を出し直す許容遅延(allowed lateness)、それでも間に合わなかったイベントを捨てずに別の出力へ回すサイド出力などです。いずれにせよ、下流は「同じ窓の結果が後から更新されることがある」前提で作る必要があります。

    現代の典型的な構成では、まず Apache Kafka がイベントの受け口になります。Kafka はメッセージキューの一種ですが、読んだら消える伝統的なキューと違い、イベントを追記専用のログとして一定期間保持し、複数の消費者がそれぞれ自分の読み位置(オフセット)で読み進められるのが特徴です。過去に巻き戻して再処理できるこの性質は、イベントソーシングとも相性がよく、ストリーム処理基盤の土台として事実上の標準になっています。その下流で Apache Flink や Kafka Streams、Spark Structured Streaming といった処理エンジンが、フィルタ・変換・結合・ウィンドウ集計を実行します。エンジンは集計途中の状態を内部に保持し、定期的なチェックポイント(状態のスナップショット)とオフセットの巻き戻しを組み合わせることで、障害が起きても「結果への反映がちょうど1回」に見える実効的 exactly-once を実現します。処理結果はデータレイクやデータウェアハウス、検索インデックス、あるいは常に最新へ更新され続けるマテリアライズドビューのような形で下流へ届けられます。

    実務の勘所と落とし穴

    ストリーム処理は本質的に分散システムであり、バッチにはなかった運用課題を持ち込みます。第一に流量の変動です。セールやバズで入力が急増したとき、処理が追いつかなければ遅延(ラグ)が積み上がります。下流の処理能力に合わせて流れを堰き止めるバックプレッシャーと、ラグの監視は必須です。第二に重複です。exactly-once とうたわれていても、外部システムへの出力まで含めると「少なくとも1回」に落ちる境界がどこかに残るため、出力側を冪等に設計しておくのが安全です。第三に状態と再処理です。集計ロジックを直したら過去分を流し直したくなりますが、状態のスキーマ変更や巻き戻し再処理は手順を誤ると結果を汚します。「Kafka に生イベントが残っているうちは、いつでも計算し直せる」という設計 — 導出結果を使い捨てにできる構え — を保つことが、ストリーム基盤を長く運用する鍵です。逆に言えば、日次で十分な集計まで無理にストリーム化する必要はありません。レイテンシ要件が本当に厳しい処理だけを流れに乗せ、残りはバッチに任せるのが健全な使い分けです。

    試問

    10:00〜10:06 の6分間に54件のイベントが起き、それぞれネットワークの遅れのぶんだけかけて、下の「1分ごとの窓」の棒に落ちてきます。金の線(ウォーターマーク)をドラッグすると、窓が終わってから何秒待って締めるか(待ち時間)を変えられます。締めた後に届いたイベントは捨てられ、取りこぼしになります。

    ✦ 命題挑戦中

    確定の遅れ(待ち時間)を90秒以内に抑えたまま、取りこぼしを2件以下にして流しきれ

    • イベントを最後まで流す
    • 取りこぼしが 2件以下(全54件のうち)
    • 待ち時間は流している間ずっと 90秒以下
    ⚡ 体験: ウォーターマークを動かして、窓を閉じる時刻を決める
    開始前10:0010:0110:0210:0310:0410:0510:0610:0710:08起きた待ち 20秒ウォーターマーク
    待ち時間20秒取りこぼし0件いま—
    流した記録(同じ流れ・待ち時間だけ変える)
    まだ流していません
      ○ 早く閉じて取りこぼした○ 待って全部数えた(確定は遅れる)○ 2つの待ち時間で比べた

      金の線(ウォーターマーク)を左右にドラッグしてどれだけ遅刻を待つかを決め、「流す」を押してください。線が窓の右端を越えると、その窓は閉じて件数が確定します。