TL

ストリーム結合と状態管理

終わりのない二つのストリームを、遅れて届くレコードも取りこぼさず突き合わせたい人へ。ストリーム同士・ストリームとテーブルの結合を状態ストアとRocksDBがどう支え、保持期間とTTLで状態の肥大化をどう抑えるかを原理から押さえられます。

応用ストリーム処理ストリーム結合状態管理RocksDBKafka StreamsFlink最終更新: 2026-06-21
TL;DR要点だけ先に
  • 1.ストリーム結合は片側のレコードを状態ストアに溜め、反対側の到着時に突き合わせる。相手はいつ来るか分からないので結合演算子は本質的にステートフル。無界データでは全件を保持できないため、ウィンドウと保持期間で「いつまで待つか」を必ず区切る。
  • 2.実務の状態ストアはヒープではなくRocksDB(LSMツリーの組込みKV)に置き、状態がメモリを超えてもディスクへ逃がしつつ、更新をチェンジログトピックへ複製して障害復旧を可能にする。結合の両側は同じキーで同数パーティションに整列(co-partition)していなければ突き合わない。
  • 3.ウィンドウ結合は保持期間ぶんの相手を状態に抱えるため肥大化しやすい。ストリーム-テーブル結合のようなウィンドウなし結合は、TTLを付けないと状態が単調増加し続ける。保持期間・TTL・パーティション設計で状態量を有界に保つのが設計の要。

なぜストリームの結合は「状態」を要求するのか

バッチの結合は簡単です。両テーブルが有界で全件そろっているので、ハッシュ結合なら片方でハッシュ表を作り、もう片方を流して突き合わせれば終わります。ところがストリーム処理が扱うのは終端の来ない無界データです。左ストリームのレコード L が到着した瞬間、それに対応する右ストリームのレコード R は、まだ来ていないかもしれないし、1秒後かもしれないし、10分後かもしれない。全件がそろう「その時」が原理的に存在しません。

だから結合演算子は、片側のレコードを内部状態に保持し、反対側が到着したときに照合するしかありません。これがストリーム結合が本質的に**ステートフル(状態を持つ)**である理由です。無界データを時間で区切る発想は集計と同じで、結合でも「いつまで相手を待つか」を必ず区切ります(/data-engineering/stream-watermarks-windowing/)。区切らなければ、状態は流入したレコードすべてを永久に抱え込み、いずれリソースを食い尽くします。

結合の相手は「過去」にも「未来」にもいる

バッチ結合との決定的な違いは、照合すべき相手が時間軸のどちら側にいるか分からない点です。L が来たとき相手の R が既に状態にいれば即座に結合できますが、いなければ L 自身を状態に入れて R の未来の到着を待ちます。つまり両ストリームは互いに「相手を待つバッファ」として状態を持ち合い、どちらが先に着いても対称に結合できるようにします。この双方向の待ち合わせが、結合が集計以上に状態を消費しやすい根本原因です。

三つの結合:ストリーム-ストリーム、ストリーム-テーブル、テーブル-テーブル

分散ストリーム処理での結合は、両辺が「変化し続けるイベントの列」なのか「最新値のスナップショット」なのかで、意味論が大きく変わります。ここは**ストリーム(追記される事実の列)テーブル(キーごとの最新状態)**の双対性が効くところです。

結合の種類状態に何を持つかウィンドウ典型例
ストリーム-ストリーム保持期間内の両辺レコードを相互バッファリング必須(時間で区切らないと無限増加)クリックとインプレッションの突き合わせ
ストリーム-テーブルテーブル側の最新値をキー単位で保持(マテリアライズ)なし(テーブルは常に最新で照合)取引イベントにマスタ属性を付与するエンリッチ
テーブル-テーブル両辺の最新値を保持し、片方更新で結果を再算出なし(結果自体も更新可能なテーブル)マスタ同士の結合ビューの維持

ストリーム-ストリーム結合は両辺がイベントの列で、片方が来たらもう片方の到着を待つため、両辺を状態に溜めます。無界どうしなのでウィンドウが必須です。ストリーム-テーブル結合は、テーブル側をキーごとの最新値として状態にマテリアライズしておき、ストリーム側のレコードが来るたびに現在のテーブル値を引いて結合します。ウィンドウは要りませんが、テーブル側の状態はキー空間ぶん常駐します。このテーブルの実体は、多くの場合変更ログ(changelog)を畳んで作った最新スナップショットであり、CDCで業務DBの変更を流し込んで組み立てるのが定石です(/data-engineering/cdc-log-based/)。

ストリーム-テーブル結合の「時間」の落とし穴

ストリーム側のイベントを、テーブルのいつの時点の値と結合すべきかは自明ではありません。素朴な実装はテーブルの「今の最新値」で照合しますが、これはイベント時刻ではなく処理時刻で引くことになり、遅れて届いたイベントが未来のテーブル値と結合される非決定を生みます。厳密には、イベント時刻に対応するテーブルのバージョンで引く**時間的結合(temporal / versioned join)**が要りますが、これは過去バージョンを保持するぶん状態が増えます。「最新で十分か、版管理が要るか」を要件で決める必要があります。

状態ストアとRocksDB:メモリを超える状態をどう置くか

結合の状態は、キー付きのレコード集合を高速に読み書きできる**状態ストア(state store)**に置きます。素直にはJVMヒープ上のハッシュ表ですが、これには限界があります。数千万キー×保持期間ぶんのレコードは容易に数十GBに達し、ヒープに載せればGCが破綻し、OOMで落ちます。

そこで大きな状態を扱う実装は、状態バックエンドにRocksDBを選びます。Kafka Streams は永続的な状態ストアの既定がRocksDBで、Flink も既定こそヒープ上の HashMapStateBackend ですが、大規模状態やHA構成では EmbeddedRocksDBStateBackend が事実上の標準的な選択肢になります。RocksDBはFacebook製の組込みKVストアで、内部構造は**LSMツリー(Log-Structured Merge-tree)**です。書き込みをまずメモリ上の表(memtable)に貯め、満杯になるとディスクへソート済みファイル(SSTable)としてフラッシュし、バックグラウンドのコンパクションで階層をマージしていきます。この構造の利点はストリーム処理と噛み合っています。

  • 状態がメモリを超えても動く:ホットなデータはブロックキャッシュとmemtableに載り、それを超えたぶんはローカルディスクへ逃がされる(スピル)。状態量がRAMに収まる前提を外せる。
  • 書き込みに強い:ストリーム結合は到着ごとに状態を更新する書き込み主体のワークロード。LSMは追記中心で書き込みスループットが高い。
  • 範囲スキャンが速い:キーがソート保持されるため、ウィンドウ結合で「あるキーの、ある時間範囲の相手」を範囲スキャンで引ける。
ローカルRocksDBは高速だが揮発する

RocksDBは処理ノードのローカルディスクに置かれるので、ネットワークを介さず低遅延です。しかしノードが失われれば状態も消えます。だからローカルの状態は「高速な作業コピー」にすぎず、耐障害性は別の仕組み(次章のチェンジログ)で担保します。LSMの内部原理そのもの——memtable・SSTable・コンパクション・書き込み増幅——はストレージエンジンの領域なので、深掘りは /database/ に譲り、ここでは「なぜストリーム結合の状態ストアにLSM系KVが選ばれるか」に絞ります。

障害復旧:チェンジログとco-partition

ローカルRocksDBは落ちれば消えるので、状態の更新を別の永続ログへ複製しておき、障害時に読み直して再構築します。Kafka Streams はこれを**チェンジログトピック(changelog topic)**と呼ぶ専用のKafkaトピックで実現します。状態ストアへの各更新(キーと新しい値)を、同じ内容でチェンジログにも書き込むのです。

到着レコード ─▶ 結合演算子 ─▶ RocksDB(ローカル・作業コピー)
                     └────────▶ changelogトピック(Kafka・永続バックアップ)

障害 → 別ノードで、changelogを先頭から再生してRocksDBを再構築 → 処理再開

チェンジログはlog compaction(同一キーは最新値だけ残す圧縮)を有効にでき、キーごとに最新の状態値だけを保持するので、テーブルの最新スナップショットと等価になります。これがまさに「ストリーム(更新の列)とテーブル(最新状態)の双対性」の実装です。Flink では同じ役割をチェックポイント機構が担い、バリア方式の分散スナップショットで状態を整合的に永続化して、障害時にそこへ巻き戻します(/data-engineering/exactly-once-streaming/)。

結合を成立させるもう一つの前提がco-partition(同一分割)です。結合演算子は各パーティションで独立に走り、自分のパーティションに来たレコードしか見ません。したがって結合キーが同じレコードは、両ストリームで必ず同じパーティション番号に来なければ、状態上で出会えず結合が漏れます。これには両辺が (1) 同じ結合キーでパーティション分割され、(2) 同じパーティション数を持つことが要ります。満たさない場合は結合前にキーで再分割(リパーティション、内部的なシャッフル)が入ります。この「同じキーを同じ場所へ集める」設計は、分散データ処理でシャッフルを制御する話と地続きです(/data-engineering/partitioning-bucketing/)。

設計レビュー・試験の頻出論点

確認すべき勘所。(1) ストリーム結合はステートフルで、相手を待つため両辺を状態に保持する。(2) 実務の状態ストアはRocksDB(LSM系組込みKV)で、メモリを超える状態をディスクへスピルさせる。(3) ローカル状態は揮発するので、チェンジログトピック(またはチェックポイント)へ複製して障害時に再構築する。(4) 結合の両辺は同一キー・同一パーティション数でco-partitionしていないと突き合わない。(5) ウィンドウ結合は保持期間、ウィンドウなし結合はTTLで状態を有界に保つ——これを怠ると状態が無限に膨らむ。

状態の肥大化とTTL:保持期間で有界に保つ

ステートフルな結合の最大の運用リスクは、状態が際限なく膨らむことです。原因は結合の種類ごとに異なります。

ウィンドウ結合では、保持期間(retention)が状態量を直接決めます。結合ウィンドウを「イベント時刻±5分の相手と結合」と定義すると、演算子は各キーについて過去5分ぶんの相手レコードを状態に抱え続け、ウォーターマークがその範囲を追い越したぶんから期限切れとして掃除します。ここでウィンドウを広げるほど、あるいは遅延データを救うために保持期間を延ばすほど、同時に保持するレコード数が増え、状態が線形に膨らみます。遅延を救う完全性と、状態コストが正面から取引されるのです。

ウィンドウなしの結合(ストリーム-テーブル、テーブル-テーブル)はさらに危険です。テーブル側はキーごとの最新値を保持しますが、キーが二度と現れなくなっても、その状態は自動では消えません。ユーザーIDやデバイスIDのようにキー空間が時間とともに増え続ける(高カーディナリティ)と、状態は単調増加し続けます。これを止めるのが**TTL(Time To Live)**です。

ウィンドウ結合:      保持期間 = 結合ウィンドウ幅 + 許容遅延
                   → ウォーターマークが越えたら期限切れで削除(状態は有界)

ウィンドウなし結合:  TTLを設定しないと状態は単調増加
                   → 最終アクセス/更新から一定時間で状態エントリを退去(eviction)
                   → TTL経過後に来たキーは「相手なし」として扱われる点に注意

TTLは「そのキーが今後アクセスされる見込み」を時間で近似し、期限切れのエントリを退去させて状態量を有界に保ちます。代償は正確性で、TTLを短くしすぎると、まだ有効な相手を消してしまい結合が漏れます。逆に長くすればメモリ・ディスクを圧迫する。ウィンドウの保持期間もTTLも、本質は同じ「いつ相手を諦めるか」という時間の設計であり、完全性・遅延・状態コストの三者を天秤にかける点でウォーターマークの調整と同じ構図です。

状態の肥大化は静かに効いてくる

状態の膨張は、初日には問題になりません。数週間かけてローカルディスクを埋め、RocksDBのコンパクション負荷を押し上げ、チェンジログの再生時間を延ばし、あるとき障害復旧が間に合わなくなって顕在化します。高カーディナリティなキーでウィンドウなし結合を組むときは、必ずTTLを設定し、状態サイズ・コンパクション頻度・復旧時間を継続的に監視してください。「結合ロジックは正しいのに本番で徐々に不安定化する」障害の多くは、無制限に増える状態が原因です。

まとめ

  • ストリーム結合は、相手がいつ来るか分からないため片側を状態に保持して待つ。ゆえに本質的にステートフルで、無界データを扱う以上、ウィンドウと保持期間で「いつまで待つか」を必ず区切る。
  • 結合はストリーム-ストリーム(両辺バッファ・ウィンドウ必須)、ストリーム-テーブル(テーブル側を最新値でマテリアライズ・ウィンドウ不要)、テーブル-テーブルの三種で、ストリームとテーブルの双対性が意味論を決める。
  • 実務の状態ストアは**RocksDB(LSM系組込みKV)**で、状態がメモリを超えてもローカルディスクへスピルし、書き込み主体・範囲スキャンに強い。
  • ローカル状態は揮発するので、更新をチェンジログトピック(Flinkはチェックポイント)へ複製して障害時に再構築する。結合の両辺は同一キー・同一パーティション数でco-partitionしていないと突き合わない。
  • 最大の運用リスクは状態の肥大化。ウィンドウ結合は保持期間、ウィンドウなし結合はTTLで状態を有界に保つ。本質は「いつ相手を諦めるか」で、完全性・遅延・状態コストのトレードオフをウォーターマーク同様に明示的に設計する。

データ工学 Article

ストリーム結合と状態管理を実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

ストリーム処理

比較で見る軸

難易度: advanced / カテゴリ: データ工学 / タグ数: 6

導入後に効く点

実務の状態ストアはヒープではなくRocksDB(LSMツリーの組込みKV)に置き、状態がメモリを超えてもディスクへ逃がしつつ、更新をチェンジログトピックへ複製して障害復旧を可能にする。結合の両側は同じキーで同数パーティションに整列(co-partition)していなければ突き合わない。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
データ工学
タグ数
6

判断チェックリスト

  • 自社の用途が「ストリーム処理 / ストリーム結合」に近いか確認する。
  • 強みである「ストリーム結合は片側のレコードを状態ストアに溜め、反対側の到着時に突き合わせる。相手はいつ来るか分からないので結合演算子は本質的にステートフル。無界データでは全件を保持できないため、ウィンドウと保持期間で「いつまで待つか」を必ず区切る。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

ストリーム処理ストリーム結合状態管理RocksDBKafka Streamsストリーム処理ストリーム結合状態管理