はじめに:時系列データに潜む「ワイドパーティション」問題

Netflixは、視聴履歴や検索ログなどの膨大なイベントデータを処理するために、独自の「TimeSeries Abstraction」システムを構築し、その基盤ストレージとしてApache Cassandra 4.xを採用しています。Cassandraは、数百万件の低レイテンシーな書き込み処理と運用の成熟度という強力な利点を提供しますが、時系列ワークロードにおいては致命的な弱点も抱えています。

それが、時間の経過とともに特定のパーティションが肥大化する「ワイドパーティション(Wide Partition)」問題です。パーティションが過度に大きくなると、読み取りレイテンシーが数ミリ秒から数秒へと急増し、深刻な場合にはGC(ガベージコレクション)の一時停止やスレッドキューの詰まりを引き起こす可能性があります。

この記事では、Netflixの技術ブログで公開された内容に基づき、彼らがどのようにこの問題を解決したのか、そして私たちのプロジェクトにどのような示唆を得られるのかを詳しく見ていきます。(補足:この記事で紹介するテクニックはCassandraに限定されるものではなく、同様のデータモデルを持つ他のデータストアにも応用可能です。)

問題の本質:なぜパーティションが大きくなると問題なのか?

Cassandraは分散データベースですが、データはパーティションキーに基づいて特定のノードに保存されます。時系列データの場合、特定のID(例:プロフィールID)に対するイベントが蓄積され続けることで、1つのパーティションに数GB以上のデータが集中することがあります。

これにより、以下のような問題が発生します。

  1. 読み取り増幅(Read Amplification)の増加: 巨大な単一パーティションを読み取るために、ディスク上の多くのデータをスキャンする必要があります。
  2. タイムアウトとレイテンシーの急増: p99レイテンシーが数秒を超え、最終的にはリクエストがタイムアウトします。
  3. リソースの不均衡: 特定のノードにのみ負荷が集中し、クラスター全体の安定性を損ないます。

もちろん、クラスターを水平方向にスケールアップするという方法もありますが、Netflixは「単にお金をかけるよりも、よりスマートな解決策」を追求しました。

既存の戦略:タイムスライス(Time Slice)単位の分割

NetflixのTimeSeries Abstractionは、当初からこの問題をある程度緩和するように設計されていました。データをタイムバケット(Time Bucket)イベントバケット(Event Bucket)に分割するタイムスライス(Time Slice) という概念を導入したのです。

  • タイムバケット: データが生成された時間に基づいて、特定の期間(例:60秒、1時間)ごとにデータをまとめます。
  • イベントバケット: 同じタイムバケット内でもデータ量が多い場合、イベント数に基づいてさらに細分化します。

この戦略により、データを物理的に分散させてパーティションサイズを一定に保ち、時間ベースのクエリやデータ削除を効率的に行うことができます。(このパーティショニング戦略の詳細については、Netflixの以前の技術ブログで確認できます。)

限界:すべてのワークロードは時間とともに変化する

しかし、この固定された戦略は、以下のような状況で限界を露呈しました。

  • 初期設定の不正確さ: プロジェクト初期に予想したトラフィックが実際と異なる、またはパーティションサイズの設定を誤るケースが頻繁に発生します。
  • ワークロードの変化: 時間の経過とともに、特定のIDに対するイベント発生量が急増するなど、データの特性が変化します。
  • データの外れ値(Outlier): 一部の特定のID(例:人気コンテンツのID)が、他のIDよりも圧倒的に多くのイベントを受け取る場合があります。

そこでNetflixは、2段階の解決策を提示します。

解決策1:テーブル単位の動的再分割(Dynamic Time Slice Re-Partitioning)

最初の解決策は、バックグラウンドワーカー(Background Worker) を導入し、テーブル全体のパーティションサイズを定期的に監視し、設定された目標密度(例:2MiB〜10MiB)を外れた場合に、将来のタイムスライスに対するパーティショニング戦略を自動的に変更するというものです。

Cassandraのnodetool tablehistogramsなどのAPIを活用してパーティションサイズの分布を把握し、もしパーティションが細かく分割されすぎてオーバーヘッドが発生している場合は、time_bucketの間隔を広げるなどの調整を行います。

# DynamicTimeSliceConfigWorker 設定例
namespace: my_dataset_1
Observed: TimeSlices have p99 partitions below configured target of 10MB.
Proposed: time_bucket interval: 60s -> 604800s  # 60秒間隔を7日間隔に増加

この方法は、データの大部分が同様のパターンを示す場合に効果的です。しかし、一部のIDだけが肥大化している場合に、テーブル全体の戦略を変更するのは非効率的です。

解決策2:ID単位の動的パーティショニング(Dynamic Partitioning per ID)- 核心戦略

2つ目の解決策は、問題の範囲をさらに絞り込み、特定のTimeSeries IDに対してのみパーティションを動的に分割するというものです。これは、以下の3段階からなる非同期パイプラインで構成されています。

  1. 検出(Detection): 読み取りパス上で、特定のパーティションの読み取りバイト数がしきい値を超えた場合、Kafkaにイベントを送信します。
// 検出イベント例
{
  "time_slice": "data_20260328",
  "time_series_id": "profileId:123",
  "time_bucket": 7,
  "event_bucket": 2,
  "immutable": true, // もはや書き込みが発生しないパーティションかどうか
  "version": "0"
}
  1. 計画と分割(Planning & Splitting): 検出されたワイドパーティションを読み取って全体のデータを確認し、最適なサイズに分割する計画を立てます。このプロセスではチェックポイントを記録し、失敗時に途中から再開できるようにします。分割後は、チェックサム(Checksum)を比較してデータの整合性を検証します。

  2. 読み取りの提供(Serving Reads): 分割が完了すると、サーバーは分割されたパーティションのキーをブルームフィルター(Bloom Filter) にロードします。以降、すべての読み取りリクエストはブルームフィルターを確認し、分割されたパーティションへのクエリを迂回させます。ブルームフィルターの参照は数マイクロ秒しかかからないため、パフォーマンスの低下はほとんどありません。

# 読み取りパスの迂回 擬似コード (Python)
def read_events(ts_id, start_time, end_time):
    if bloom_filter.contains(ts_id):  # 分割されたIDかどうかを高速に確認
        # 分割されたパーティションのメタデータを取得し、並列読み取り
        split_info = wide_row_metadata_lookup(ts_id)
        return read_from_split_partitions(split_info)
    else:
        # 従来のパスで読み取り
        return read_from_original_partition(ts_id, start_time, end_time)

ここで重要なのは、既存のワイドパーティションを削除しないという点です。これにより、部分的な障害や一貫性の問題が発生した場合に安全なフォールバック(Fallback)パスを提供し、運用の安全性を大幅に高めています。

結果:数秒から数十ミリ秒へ

この動的パーティショニング手法を適用した結果は劇的でした。

  • 平均読み取りレイテンシー: 数秒 → 数十ミリ秒(10ms台)
  • テールレイテンシー(Tail Latency): 数秒 → 約200ms以下
  • 読み取りタイムアウト: 大幅に減少
  • クラスターの安定性: CPU使用率とスレッドキューの詰まりが減少

さらに、極端に大きなパーティション(500MB以上)の場合でも、サービスを停止することなく、レイテンシーが多少増加してもページング(paging)方式でデータを処理できるようになりました。

実務適用時の注意点と限界

この戦略は非常に優れていますが、銀の弾丸ではありません。 適用前に以下の点を必ず確認する必要があります。

  • 書き込み中のパーティション(Mutable Partition)の問題: Netflixはまず不変(Immutable)パーティションにのみ分割を適用しました。書き込みが進行中のパーティションを分割することははるかに複雑な問題であり、まだ解決すべき課題として残っています。
  • ブルームフィルターのメモリ: 分割されたパーティションのキーをメモリに保持する必要があるため、パーティション数が多くなるとメモリ使用量が増加する可能性があります。Netflixはパーティションキーのサイズが小さく大きな問題ではないと述べていますが、データの特性によっては監視が必要です。
  • 初期検出の遅延: 読み取りパスで問題を検出するため、問題のあるパーティションへの最初の読み取りは依然として遅い可能性があります。(通常は数秒以内に検出・分割が完了します。)

まとめ:進化するデータに合わせて設計する

Netflixのこの事例は、単なるCassandraのパフォーマンス最適化を超えて、「データの変化」をシステム設計にどのように反映するかについての優れた教訓を与えてくれます。

  1. 問題の表面積を減らす: 最初からすべての問題を解決しようとせず、影響力が大きくても実装が比較的簡単な不変パーティションの分割から始めました。
  2. 信頼構築の重要性: チェックサム比較、シャドウモード(Shadow Mode)による新旧パスの比較など、様々な段階を経てシステムへの信頼を確保しました。
  3. 安全網の確保: 既存のパーティションを削除せずに保持することで、予期しない障害が発生した場合でも迅速にロールバックできる安全装置を用意しました。

時系列データを扱うシステムを設計する際には、初期のパーティショニング戦略が完璧であるとは考えず、データが成長するにつれてパーティショニング戦略も進化できる柔軟な構造を検討することが重要です。

次のステップとしての学習方向性

  • Cassandraパーティショニングの深掘り: nodetool tablehistograms以外にも、様々な診断ツールを学んでみてください。
  • ブルームフィルターの最適化: メモリとCPU使用率のトレードオフを理解し、大規模データで効率的に活用する方法を研究してみてください。
  • データモデリング: 時系列データに特化した様々なデータモデリング手法(例:1時間バケット vs 1日バケット)の長所と短所を比較分析してみてください。

合わせて読みたい記事

注記: 本記事は、Netflixテックブログの技術分析を基に再構成されています。

Netflix engineers monitoring Cassandra cluster performance metrics and read latency graphs on large server racks Programming Illustration

本コンテンツは、信頼性の高い情報源をもとにAIツールを活用して作成され、編集者によるレビューを経て公開されています。専門家によるアドバイスの代替となるものではありません。