「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;
}
コラム: シャーディングを始める前に
シャーディングは最後の手段である。導入前に以下を検討すべき:
- 垂直スケーリング: より大きなインスタンスへの移行(最も簡単)
- リードレプリカ: 読み取り負荷の分散
- パーティショニング: 単一DB内でのテーブル分割
- キャッシュ: Redis/Memcached でDB負荷を削減
- クエリ最適化: インデックス、クエリ改善
シャーディングの導入コスト:
- アプリケーションの大幅な改修
- クロスシャードトランザクションの制約
- 運用の複雑化(バックアップ、マイグレーション、監視)
- リシャーディング(シャード数の変更)の困難さ
「まだシャーディングしなくていい」という判断ができるのも、優れたエンジニアの能力。
まとめ
| トピック | 要点 |
|---|---|
| パーティショニング | 単一DB内でテーブルを論理分割(レンジ/リスト/ハッシュ) |
| PostgreSQL パーティション | PARTITION BY で宣言的に設定、自動プルーニング |
| シャーディング | 複数DBノードへの物理分散、アプリケーション側でルーティング |
| コンシステントハッシュ | ノード追加時に約 1/N のデータのみ移動 |
| シャードキー設計 | 高カーディナリティ、均等分散、ターゲットクエリ可能 |
チェックリスト
- 水平パーティショニングと垂直パーティショニングの違いを説明できる
- PostgreSQL のレンジ/ハッシュパーティションを設定できる
- コンシステントハッシュの仕組みと利点を理解した
- シャードキーの選択基準を3つ以上挙げられる
- シャーディング導入前に検討すべき代替策を知っている
次のステップへ
シャーディングとパーティショニングを学んだ。次は 演習:DBパフォーマンスを最適化しよう で、ここまでの知識を総合的に活用してみよう。
推定読了時間: 40分