目次を表示する

システム設計とCS概念

時系列データベース(Prometheus/InfluxDB)── 書き込み最適化と圧縮

時系列データベース(Prometheus/InfluxDB)── 書き込み最適化と圧縮

扱うCS概念:Gorilla 圧縮(XOR/Delta-of-Delta)、カラムナストレージ、ダウンサンプリング、WAL + 不変ブロック、プル型 vs プッシュ型


時系列DB — Gorilla圧縮・プル/プッシュ・ダウンサンプリング

この章で何ができるようになるか:時系列データがなぜ通常の RDBMS に向かないかを説明でき、Prometheus や InfluxDB が使っている圧縮技法とストレージ設計を理解できるようになる。


問題設定

サーバー 1万台のメトリクスを収集・保存・検索する。

データの性質:
  - CPU使用率、メモリ使用量、リクエストレイテンシ、エラー率...
  - 各メトリクスを10秒ごとに記録
  - 1万台 × 100メトリクス × 6回/分 × 60分 × 24時間 = 1日86.4億データポイント
  - 直近30日間を高精度で保持、1年分を低精度で保持

アクセスパターン:
  - 書き込み:圧倒的に追記のみ(過去データの更新はほぼない)
  - 読み取り:「直近1時間のCPU使用率」「過去1週間のP99レイテンシ」
  - 削除:古いデータの一括削除(リテンション)

なぜ RDBMS が向かないか:
  1. INSERT が秒間数十万件 → B-Tree インデックスの更新がボトルネック
  2. 過去データの一括削除が遅い(DELETE は高コスト)
  3. 時系列データの連続値は非常に圧縮しやすいが、RDBMS はそれを活かせない

時系列データの構造

metric: http_request_duration_seconds
labels: {method="GET", path="/api/users", status="200", instance="web-01"}
timestamps:  [1712534400, 1712534410, 1712534420, 1712534430, ...]
values:      [0.045,      0.047,      0.044,      0.046,      ...]

ラベル(tags)の組み合わせが「時系列(time series)」を一意に特定する

同じメトリクス名でもラベルが異なれば別の時系列:
  {method="GET",  path="/api/users"}  → 時系列A
  {method="POST", path="/api/users"}  → 時系列B
  {method="GET",  path="/api/orders"} → 時系列C

Gorilla 圧縮:Facebook が開発した時系列データ圧縮

Facebook の論文 “Gorilla: A Fast, Scalable, In-Memory Time Series Database”(2015)で発表された手法。

タイムスタンプの圧縮:Delta-of-Delta

メトリクスのタイムスタンプは等間隔(10秒ごとなど)に近い。

生データ:
  [1712534400, 1712534410, 1712534420, 1712534430, 1712534440]

Delta(差分):
  [10, 10, 10, 10]

Delta-of-Delta(差分の差分):
  [0, 0, 0]  ← ほぼ全て0!

0 は 1bit(フラグ「0」= “delta-of-delta is zero”)で表現できる。通常64bitのタイムスタンプが1bit/ポイントに圧縮される。

値の圧縮:XOR エンコーディング

連続する値は近い値を取ることが多い(CPU 使用率 45.2% → 45.3% → 45.1%)。

def xor_encode(prev_value: float, curr_value: float) -> bytes:
    """
    IEEE 754 浮動小数点のビット表現を XOR する。
    近い値同士の XOR は先頭と末尾に 0 が並ぶ → 少ないビットで表現できる。
    """
    import struct
    prev_bits = struct.unpack('>Q', struct.pack('>d', prev_value))[0]
    curr_bits = struct.unpack('>Q', struct.pack('>d', curr_value))[0]

    xor = prev_bits ^ curr_bits

    if xor == 0:
        return b'\x00'  # 同じ値:1bit

    # XOR の先頭ゼロ数と末尾ゼロ数を記録
    leading_zeros = count_leading_zeros(xor)
    trailing_zeros = count_trailing_zeros(xor)
    significant_bits = 64 - leading_zeros - trailing_zeros

    # 先頭ゼロ数 + 有効ビット数 + 有効ビットのみ を記録
    # → 64bit が 20〜30bit 程度に圧縮される
    return encode_compressed(leading_zeros, significant_bits, xor)

圧縮効果

非圧縮:16 bytes/point(timestamp 8B + value 8B)
Gorilla圧縮:平均 1.37 bytes/point(Facebook の実測値)
→ 約12倍の圧縮!

Prometheus のストレージ設計

graph TD
    Scrape[スクレイプ<br/>10秒ごとにターゲットから pull] --> WAL[WAL<br/>先行書き込みログ]
    WAL --> Head[Head Block<br/>直近2時間分<br/>メモリ上]
    Head -->|2時間経過| Block1[Block 1<br/>不変・圧縮済み]
    Head -->|2時間経過| Block2[Block 2<br/>不変・圧縮済み]
    Block1 & Block2 -->|コンパクション| BigBlock[Compacted Block<br/>複数ブロックの統合]

    subgraph ディスク上のブロック構造
        Block[Block ディレクトリ]
        Block --> Chunks[chunks/<br/>圧縮された時系列データ]
        Block --> Index[index<br/>ラベル → チャンクのマッピング]
        Block --> Meta[meta.json<br/>ブロックのメタデータ]
        Block --> Tombstones[tombstones<br/>削除マーカー]
    end

Head Block(メモリ):直近2時間分のデータをメモリに保持。書き込みが高速。WAL でクラッシュ耐性を確保。

Persistent Block(ディスク):2時間ごとに Head Block が凍結されディスクに書き出される。ブロックは不変(immutable)。

コンパクション:小さなブロックを定期的に大きなブロックに統合。インデックス効率を改善し、削除済みデータを物理的に除去する。


プル型 vs プッシュ型の設計哲学

Prometheus(プル型):
  Prometheus サーバーが各ターゲットの /metrics エンドポイントを定期的にスクレイプ
  → ターゲットが生きているかを同時に確認できる(ヘルスチェック兼用)
  → ターゲット側は /metrics を公開するだけ(シンプル)
  → ネットワーク分断時にデータギャップが生じる

InfluxDB / Datadog(プッシュ型):
  各ホストのエージェントがデータを送信
  → ファイアウォール越しでも送信できる
  → 短命なジョブ(Lambda)のメトリクスを逃さない
  → エージェントが死んでいても気づかない可能性
プル型(Prometheus)プッシュ型(InfluxDB/Datadog)
ターゲットの生死プル失敗 = ダウンと検知エージェント停止に気づきにくい
NAT/ファイアウォール△ ターゲットに到達可能が前提✅ アウトバウンドのみ
短命ジョブ△ スクレイプ間隔に収集漏れ✅ ジョブ終了時にプッシュ
スケール△ 大量ターゲットだとスクレイプが重い✅ エージェント分散

PromQL:時系列クエリ言語

# 直近5分間の CPU 使用率の平均
avg(rate(node_cpu_seconds_total{mode!="idle"}[5m])) by (instance)

# P99 レイテンシ(ヒストグラムから計算)
histogram_quantile(0.99,
  rate(http_request_duration_seconds_bucket[5m])
)

# エラー率(5xx の割合)
sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))

rate() の内部動作

rate(metric[5m]) は:
  1. 過去5分間のサンプルを取得
  2. 最初と最後のサンプルの差分を計算
  3. 経過時間で割って「1秒あたりの変化率」を返す

カウンターのリセット(プロセス再起動)を自動検知して補正する。

ダウンサンプリング

1年分の10秒間隔データをそのまま保持するとストレージが膨大になる。

1万台 × 100メトリクス × 8640回/日 × 365日 × 1.37B/point
≈ 4.3 TB/年

ダウンサンプリング後:
  直近7日:10秒間隔(フル精度)
  7〜30日:1分間隔(6分の1)
  30〜365日:5分間隔(30分の1)
  
→ 約 200 GB/年(20倍の削減)
# 5分ウィンドウでダウンサンプリング
def downsample(points: list[tuple[int, float]], window_seconds: int = 300):
    """
    各ウィンドウの min, max, avg, count を保持する。
    avg だけだとスパイクが消えるため、min/max も必要。
    """
    result = []
    window_start = points[0][0]
    window_values = []

    for ts, value in points:
        if ts - window_start >= window_seconds:
            result.append({
                "timestamp": window_start,
                "min": min(window_values),
                "max": max(window_values),
                "avg": sum(window_values) / len(window_values),
                "count": len(window_values),
            })
            window_start = ts
            window_values = []
        window_values.append(value)

    return result

まとめ

課題解決策CS概念
高頻度の追記書き込み追記専用 + WALLSM-Tree 的設計
ストレージ効率Gorilla 圧縮(DoD + XOR)差分符号化
古いデータの削除ブロック単位の一括削除不変ブロック設計
長期保存ダウンサンプリングmin/max/avg の集約
ラベルによる検索転置インデックス(ラベル → 時系列ID)Ch.08 と同じ原理
データ収集プル型(Prometheus)/ プッシュ型監視哲学の違い