時系列データベース(Prometheus/InfluxDB)── 書き込み最適化と圧縮
扱うCS概念:Gorilla 圧縮(XOR/Delta-of-Delta)、カラムナストレージ、ダウンサンプリング、WAL + 不変ブロック、プル型 vs プッシュ型

この章で何ができるようになるか:時系列データがなぜ通常の 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概念 |
|---|---|---|
| 高頻度の追記書き込み | 追記専用 + WAL | LSM-Tree 的設計 |
| ストレージ効率 | Gorilla 圧縮(DoD + XOR) | 差分符号化 |
| 古いデータの削除 | ブロック単位の一括削除 | 不変ブロック設計 |
| 長期保存 | ダウンサンプリング | min/max/avg の集約 |
| ラベルによる検索 | 転置インデックス(ラベル → 時系列ID) | Ch.08 と同じ原理 |
| データ収集 | プル型(Prometheus)/ プッシュ型 | 監視哲学の違い |