LESSON 40分

「1台のDBで1億レコードを捌ける設計が理想だが、現実にはどこかで限界が来る」と佐藤CTOは語った。「パーティショニングはテーブルを論理的に分割する。シャーディングは物理的に複数ノードにデータを分散する。どちらもいつ、どの戦略で導入するかの判断が最も重要だ。」

1. パーティショニングの基礎

水平パーティショニング vs 垂直パーティショニング

方式説明適用場面
水平(行分割)行を条件で分割(例: 年月ごと)時系列データ、大量レコード
垂直(列分割)列を分割(例: BLOB を別テーブルに)幅の広いテーブル、アクセスパターンが列ごとに異なる
graph TD
    subgraph horizontal["水平パーティショニング"]
        H_A["orders テーブル
1億行"] -->|"年月で分割"| H_B["orders_2024
3000万行"] H_A -->|"年月で分割"| H_C["orders_2025_h1
2500万行"] H_A -->|"年月で分割"| H_D["orders_2025_h2
2000万行"] end subgraph vertical["垂直パーティショニング"] V_A["products
(id, name, price,
description, image_blob, ...)"] -->|"列で分割"| V_B["products_core
(id, name, price)"] V_A -->|"列で分割"| V_C["products_content
(id, description, image_blob)"] end style H_A fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e40af style H_B fill:#d1fae5,stroke:#059669,color:#065f46 style H_C fill:#d1fae5,stroke:#059669,color:#065f46 style H_D fill:#d1fae5,stroke:#059669,color:#065f46 style V_A fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e40af style V_B fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#92400e style V_C fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#92400e

パーティション戦略の比較

戦略分割キーメリットデメリット
レンジ日時、数値範囲範囲クエリが高速、古いデータの一括削除が容易ホットスポットが発生しやすい
リストカテゴリ、地域明示的な分類が可能カテゴリの偏りでサイズ不均等
ハッシュID のハッシュ値データが均等に分散範囲クエリが非効率
複合レンジ + ハッシュ等柔軟な分散設計が複雑

2. PostgreSQL のテーブルパーティショニング

-- レンジパーティショニング(時系列データ)
CREATE TABLE events (
    id          BIGSERIAL,
    event_type  VARCHAR(50) NOT NULL,
    payload     JSONB NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- 月ごとのパーティションを作成
CREATE TABLE events_2025_01 PARTITION OF events
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE events_2025_02 PARTITION OF events
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
CREATE TABLE events_2025_03 PARTITION OF events
    FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');

-- デフォルトパーティション(どのパーティションにも属さない行の受け皿)
CREATE TABLE events_default PARTITION OF events DEFAULT;

-- パーティションごとのインデックス(自動的に各パーティションに作成される)
CREATE INDEX idx_events_type ON events (event_type);
CREATE INDEX idx_events_created ON events (created_at);

-- ハッシュパーティショニング(均等分散)
CREATE TABLE user_activities (
    id          BIGSERIAL,
    user_id     BIGINT NOT NULL,
    action      VARCHAR(100) NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (id, user_id)
) PARTITION BY HASH (user_id);

CREATE TABLE user_activities_p0 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE user_activities_p1 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE user_activities_p2 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE user_activities_p3 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

パーティション管理の自動化

// パーティションの自動作成(月次バッチ)
class PartitionManager {
  constructor(private db: Pool) {}

  async ensureMonthlyPartitions(
    tableName: string,
    monthsAhead: number = 3
  ): Promise<string[]> {
    const created: string[] = [];
    const now = new Date();

    for (let i = 0; i <= monthsAhead; i++) {
      const date = new Date(now.getFullYear(), now.getMonth() + i, 1);
      const nextDate = new Date(now.getFullYear(), now.getMonth() + i + 1, 1);

      const partName = `${tableName}_${this.formatYearMonth(date)}`;
      const fromDate = date.toISOString().split('T')[0];
      const toDate = nextDate.toISOString().split('T')[0];

      try {
        await this.db.query(`
          CREATE TABLE IF NOT EXISTS ${partName}
          PARTITION OF ${tableName}
          FOR VALUES FROM ('${fromDate}') TO ('${toDate}')
        `);
        created.push(partName);
      } catch (error: any) {
        if (!error.message.includes('already exists')) throw error;
      }
    }

    return created;
  }

  // 古いパーティションの切り離しとアーカイブ
  async detachOldPartitions(
    tableName: string,
    retentionMonths: number
  ): Promise<string[]> {
    const cutoff = new Date();
    cutoff.setMonth(cutoff.getMonth() - retentionMonths);

    const partitions = await this.db.query(`
      SELECT inhrelid::regclass AS partition_name
      FROM pg_inherits
      WHERE inhparent = $1::regclass
      ORDER BY inhrelid::regclass::text
    `, [tableName]);

    const detached: string[] = [];
    for (const row of partitions.rows) {
      const name = row.partition_name;
      const match = name.match(/_(\d{4})_(\d{2})$/);
      if (!match) continue;

      const partDate = new Date(parseInt(match[1]), parseInt(match[2]) - 1, 1);
      if (partDate < cutoff) {
        await this.db.query(`ALTER TABLE ${tableName} DETACH PARTITION ${name}`);
        // オプション: S3 にエクスポート後に DROP
        detached.push(name);
      }
    }

    return detached;
  }

  private formatYearMonth(date: Date): string {
    const y = date.getFullYear();
    const m = String(date.getMonth() + 1).padStart(2, '0');
    return `${y}_${m}`;
  }
}

3. シャーディング戦略

ハッシュシャーディング

// ハッシュベースのシャーディング
import * as crypto from 'crypto';

class HashShardRouter {
  constructor(private shardCount: number) {}

  // 単純なモジュロシャーディング
  getShard(key: string): number {
    const hash = crypto.createHash('md5').update(key).digest();
    const value = hash.readUInt32BE(0);
    return value % this.shardCount;
  }

  // 問題: シャード数を変更すると大量のデータ移行が必要
  // 例: 4シャード → 5シャード → 約80%のデータが移動
}

コンシステントハッシュ

// コンシステントハッシュリング
class ConsistentHashRing {
  private ring: Map<number, string> = new Map(); // position → nodeId
  private sortedPositions: number[] = [];

  constructor(
    private virtualNodes: number = 150 // ノードあたりの仮想ノード数
  ) {}

  addNode(nodeId: string): void {
    for (let i = 0; i < this.virtualNodes; i++) {
      const virtualKey = `${nodeId}:vn${i}`;
      const position = this.hash(virtualKey);
      this.ring.set(position, nodeId);
    }
    this.sortedPositions = [...this.ring.keys()].sort((a, b) => a - b);
  }

  removeNode(nodeId: string): void {
    for (let i = 0; i < this.virtualNodes; i++) {
      const virtualKey = `${nodeId}:vn${i}`;
      const position = this.hash(virtualKey);
      this.ring.delete(position);
    }
    this.sortedPositions = [...this.ring.keys()].sort((a, b) => a - b);
  }

  // キーがどのノードに属するかを決定
  getNode(key: string): string {
    if (this.sortedPositions.length === 0) {
      throw new Error('No nodes in the ring');
    }

    const keyPosition = this.hash(key);

    // 時計回りに最初に見つかるノードを返す
    for (const pos of this.sortedPositions) {
      if (pos >= keyPosition) {
        return this.ring.get(pos)!;
      }
    }

    // リングを一周した場合、最初のノード
    return this.ring.get(this.sortedPositions[0])!;
  }

  // ノード追加時の影響範囲
  getAffectedKeys(newNodeId: string): {
    fromNode: string;
    keyRange: [number, number];
  }[] {
    // 新ノード追加で移動が必要なのは、
    // 新ノードの仮想ノード位置の直前のノードが管理していたキーの一部のみ
    // → 全体の約 1/N のデータのみ移動(N=ノード数)
    return [];
  }

  private hash(key: string): number {
    const md5 = crypto.createHash('md5').update(key).digest();
    return md5.readUInt32BE(0);
  }
}

// 使用例
const ring = new ConsistentHashRing(150);
ring.addNode('shard-1');
ring.addNode('shard-2');
ring.addNode('shard-3');

const targetShard = ring.getNode('user:12345'); // → 'shard-2'
// シャード追加時: shard-4 を追加しても約 1/4 のデータのみ移動

データ分布の可視化

graph TD
    P0["0"] --- S1V2["shard-1:vn2"]
    S1V2 --- S3V1["shard-3:vn1"]
    S3V1 --- S1V1["shard-1:vn1"]
    S1V1 --- S2V1["shard-2:vn1"]
    S2V1 --- S3V3["shard-3:vn3"]
    S3V3 --- S2V3["shard-2:vn3"]
    S2V3 --- PMAX["2^32-1"]
    PMAX -.->|"リング接続"| P0
    NOTE["仮想ノード150個/物理ノード
→ 均等な分散を実現"] style P0 fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e40af style PMAX fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e40af style S1V2 fill:#d1fae5,stroke:#059669,color:#065f46 style S1V1 fill:#d1fae5,stroke:#059669,color:#065f46 style S2V3 fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#92400e style S2V1 fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#92400e style S3V1 fill:#fee2e2,stroke:#dc2626,color:#991b1b style S3V3 fill:#fee2e2,stroke:#dc2626,color:#991b1b style NOTE fill:#f3f4f6,stroke:#9ca3af,color:#374151

4. MongoDB のシャーディング

// MongoDB シャーディング設定の概念
// mongos(ルーター) → config servers → shard replicas

// シャードキーの選択が最も重要
interface ShardKeyEvaluation {
  key: string;
  cardinality: 'low' | 'medium' | 'high';
  writeDistribution: 'uniform' | 'hotspot';
  queryIsolation: 'targeted' | 'scatter-gather';
  monotonic: boolean; // 単調増加か
}

const shardKeyOptions: ShardKeyEvaluation[] = [
  {
    key: '_id (ObjectId)',
    cardinality: 'high',
    writeDistribution: 'hotspot', // ObjectId は単調増加 → 最新シャードに集中
    queryIsolation: 'targeted',
    monotonic: true,
  },
  {
    key: 'userId',
    cardinality: 'high',
    writeDistribution: 'uniform', // ハッシュ化すれば均等
    queryIsolation: 'targeted',   // ユーザー単位のクエリは1シャードで完結
    monotonic: false,
  },
  {
    key: 'region',
    cardinality: 'low',         // 地域数は限られる
    writeDistribution: 'hotspot', // 人口が多い地域に集中
    queryIsolation: 'targeted',
    monotonic: false,
  },
  {
    key: '{ userId: 1, createdAt: 1 }', // 複合シャードキー
    cardinality: 'high',
    writeDistribution: 'uniform',
    queryIsolation: 'targeted',   // ユーザー+日時のクエリが1シャードで完結
    monotonic: false,
  },
];

// シャードキー選択のベストプラクティス
// 1. 高カーディナリティ(ユニーク値が多い)
// 2. 書き込みが均等に分散される
// 3. よく使うクエリがターゲットクエリになる(scatter-gather を回避)
// 4. 単調増加を避ける(ハッシュシャーディングで対処可能)

5. シャーディングのトレードオフ

// クロスシャードクエリの問題
class ShardedOrderRepository {
  private shards: Map<string, Pool>;
  private router: ConsistentHashRing;

  // 単一シャードクエリ(高速)
  async getOrdersByUser(userId: string): Promise<Order[]> {
    const shard = this.router.getNode(`user:${userId}`);
    const pool = this.shards.get(shard)!;
    return pool.query('SELECT * FROM orders WHERE user_id = $1', [userId])
      .then(r => r.rows);
  }

  // クロスシャードクエリ(低速)— 全シャードに問い合わせ
  async getOrdersByDateRange(
    startDate: Date,
    endDate: Date
  ): Promise<Order[]> {
    const allResults = await Promise.all(
      [...this.shards.values()].map(pool =>
        pool.query(
          'SELECT * FROM orders WHERE created_at BETWEEN $1 AND $2',
          [startDate, endDate]
        ).then(r => r.rows)
      )
    );

    // 全シャードの結果をマージしてソート
    return allResults
      .flat()
      .sort((a, b) => b.created_at.getTime() - a.created_at.getTime());
  }

  // クロスシャード集約(最も遅い)
  async getOrderStats(): Promise<OrderStats> {
    const shardStats = await Promise.all(
      [...this.shards.values()].map(pool =>
        pool.query(`
          SELECT
            COUNT(*) as count,
            SUM(total_amount) as total,
            AVG(total_amount) as avg
          FROM orders
        `).then(r => r.rows[0])
      )
    );

    // 各シャードの集計を統合
    const totalCount = shardStats.reduce((s, r) => s + parseInt(r.count), 0);
    const totalAmount = shardStats.reduce((s, r) => s + parseFloat(r.total), 0);

    return {
      orderCount: totalCount,
      totalRevenue: totalAmount,
      avgOrderAmount: totalAmount / totalCount,
    };
  }
}

interface OrderStats {
  orderCount: number;
  totalRevenue: number;
  avgOrderAmount: number;
}
コラム: シャーディングを始める前に

シャーディングは最後の手段である。導入前に以下を検討すべき:

  1. 垂直スケーリング: より大きなインスタンスへの移行(最も簡単)
  2. リードレプリカ: 読み取り負荷の分散
  3. パーティショニング: 単一DB内でのテーブル分割
  4. キャッシュ: Redis/Memcached でDB負荷を削減
  5. クエリ最適化: インデックス、クエリ改善

シャーディングの導入コスト:

  • アプリケーションの大幅な改修
  • クロスシャードトランザクションの制約
  • 運用の複雑化(バックアップ、マイグレーション、監視)
  • リシャーディング(シャード数の変更)の困難さ

「まだシャーディングしなくていい」という判断ができるのも、優れたエンジニアの能力。

まとめ

トピック要点
パーティショニング単一DB内でテーブルを論理分割(レンジ/リスト/ハッシュ)
PostgreSQL パーティションPARTITION BY で宣言的に設定、自動プルーニング
シャーディング複数DBノードへの物理分散、アプリケーション側でルーティング
コンシステントハッシュノード追加時に約 1/N のデータのみ移動
シャードキー設計高カーディナリティ、均等分散、ターゲットクエリ可能

チェックリスト

  • 水平パーティショニングと垂直パーティショニングの違いを説明できる
  • PostgreSQL のレンジ/ハッシュパーティションを設定できる
  • コンシステントハッシュの仕組みと利点を理解した
  • シャードキーの選択基準を3つ以上挙げられる
  • シャーディング導入前に検討すべき代替策を知っている

次のステップへ

シャーディングとパーティショニングを学んだ。次は 演習:DBパフォーマンスを最適化しよう で、ここまでの知識を総合的に活用してみよう。

推定読了時間: 40分