疑問
データベースをスケールさせるには、どのような戦略があるのでしょうか?垂直スケーリングと水平スケーリングについて一緒に学んでいきましょう。
導入
データベースのスケーリングは、データ量やトラフィックの増加に対応するための重要な技術です。垂直スケーリングと水平スケーリング、それぞれ異なるアプローチがあり、プロジェクトの要件に応じて選択します。
本記事では、スケーリング戦略の基本から、垂直スケーリング、水平スケーリング、シャーディングまで、実践的な手法を詳しく解説していきます。
解説
1. スケーリングの種類
データベースのスケーリングには、垂直スケーリング(Scale Up)と水平スケーリング(Scale Out)の2つの主要なアプローチがあります。それぞれ異なる特徴と用途があり、プロジェクトの要件に応じて選択する必要があります。
垂直スケーリング(Scale Up)
垂直スケーリングは、既存のサーバーのリソースを増やすことで、パフォーマンスを向上させる方法です。小さいサーバーから大きいサーバーへとアップグレードすることで、CPU、メモリ、ストレージを増やすことができます。シンプルで効果的ですが、物理的な上限があるという制約があります。
水平スケーリング(Scale Out)
水平スケーリングは、サーバーの台数を増やすことで、負荷を分散させる方法です。1台のサーバーから複数のサーバーに分散することで、スケーラビリティを向上させることができます。レプリケーションやシャーディングなどの技術を使用して実現します。
スケーリングの選択基準
スケーリング戦略を選択する際は、データ量、トラフィック、予算、運用の複雑さなどを考慮します。まず垂直スケーリングを検討し、それでも不十分な場合は水平スケーリングを検討します。水平スケーリングは、実装と運用が複雑になるため、必要な場合のみ使用します。
スケーリングの種類
垂直スケーリングでは、既存のサーバーのリソースを増やすことで、パフォーマンスを向上させます。水平スケーリングでは、サーバーの台数を増やすことで、負荷を分散させます。
垂直スケーリング(Scale Up)
小さいサーバー → 大きいサーバー
CPU: 2コア → 8コア
メモリ: 4GB → 32GB
ストレージ: 100GB → 1TB
水平スケーリング(Scale Out)
1台のサーバー → 複数のサーバー
サーバー1 + サーバー2 + サーバー3参考リンク: Database Scaling Strategies - データベーススケーリング戦略に関する詳細な情報
2. 垂直スケーリング
垂直スケーリングは、サーバーのリソースを増やすことでパフォーマンスを向上させる方法です。シンプルで効果的ですが、物理的な上限があるという制約があります。
メリット
- シンプル: 設定変更が少なく、実装が簡単です。既存のサーバーのリソースを増やすだけで済むため、アプリケーション側の変更がほとんど必要ありません。
- トランザクション: 単一サーバーで完結するため、トランザクションの管理が簡単です。複数のサーバー間でのデータ整合性を保つ必要がありません。
- 管理が容易: 1台のサーバーを管理するだけで済むため、運用が簡単です。複数のサーバーを管理する必要がなく、設定やメンテナンスが容易です。
- パフォーマンスの向上: CPUやメモリを増やすことで、クエリの実行速度を向上させることができます。特に、CPU集約的なクエリやメモリ集約的なクエリに効果的です。
デメリット
- コスト: 高スペックサーバーは高価です。リソースを増やすほど、コストが高くなります。また、リソースの増加に比例してコストが増加するため、コスト効率が低下する可能性があります。
- 限界: 物理的な上限があります。サーバーの最大スペックに達すると、それ以上スケールアップできません。また、ハードウェアの制約により、無限にスケールアップすることはできません。
- 単一障害点: サーバー1台が障害点となります。サーバーが故障すると、システム全体が停止してしまいます。高可用性を実現するためには、別の対策が必要です。
- ダウンタイム: リソースを増やす際に、サーバーの再起動が必要になる場合があります。これにより、一時的なダウンタイムが発生する可能性があります。
使用ケース
垂直スケーリングは、小規模から中規模のアプリケーション、データ量がそれほど多くない場合、シンプルな構成を維持したい場合に適しています。また、初期段階では垂直スケーリングから始め、必要に応じて水平スケーリングに移行するアプローチも有効です。
クラウドでの垂直スケーリング
クラウド環境では、データベースインスタンスのサイズを変更することで、垂直スケーリングを実現できます。AWS RDS、Azure Database、Google Cloud SQLでは、インスタンスタイプやサービスレベルを変更するだけで、CPUやメモリを増やすことができます。
# AWS RDS: インスタンスタイプの変更(垂直スケーリング)
# db.t2.micro → db.r5.2xlarge
# インスタンスタイプの変更
aws rds modify-db-instance \
--db-instance-identifier mydb \
--db-instance-class db.r5.2xlarge \
--apply-immediately
# Azure Database: サービスレベルの変更
az mysql flexible-server update \
--resource-group myResourceGroup \
--name mydb \
--sku-name Standard_D4s_v3
# Google Cloud SQL: マシンタイプの変更
gcloud sql instances patch mydb \
--tier=db-n1-standard-43. 水平スケーリング
水平スケーリングは、サーバーの台数を増やすことで負荷を分散させる方法です。レプリケーションを使用することで、読み取り性能を向上させることができます。
レプリケーションによる読み取りスケーリング
マスターサーバーで書き込みを行い、スレーブサーバーで読み取りを行うことで、読み取り性能を向上させることができます。複数のスレーブサーバーに読み取りリクエストを分散させることで、スケーラビリティを向上させます。読み取り専用のクエリは、スレーブサーバーで実行することで、マスターサーバーの負荷を減らすことができます。
読み取りレプリカの追加
読み取り負荷が増加した場合、読み取りレプリカを追加することで、読み取り性能を向上させることができます。クラウド環境では、読み取りレプリカを簡単に追加できます。ただし、レプリケーション遅延に注意する必要があります。
ロードバランシング
ロードバランサーを使用して、読み取りリクエストを複数のスレーブサーバーに分散させます。ラウンドロビン、重み付けラウンドロビン、最小接続数などのアルゴリズムを使用して、負荷を分散させます。
書き込みスケーリングの課題
書き込みのスケーリングは、読み取りよりも複雑です。マスター・マスターレプリケーションやシャーディングを使用することで、書き込みのスケーリングを実現できますが、データの整合性やトランザクションの管理が複雑になります。
レプリケーションによる読み取りスケーリング
この例では、マスターサーバーで書き込みを行い、複数のスレーブサーバーで読み取りを分散させています。ラウンドロビン方式でスレーブを選択することで、読み取り負荷を均等に分散できます。
// マスター: 書き込み
// スレーブ1, 2, 3: 読み取り
const mysql = require('mysql2/promise');
const master = mysql.createPool({
host: 'master.example.com',
user: 'app_user',
password: 'password',
database: 'mydb'
});
const slaves = [
mysql.createPool({ host: 'slave1.example.com', user: 'app_user', password: 'password', database: 'mydb' }),
mysql.createPool({ host: 'slave2.example.com', user: 'app_user', password: 'password', database: 'mydb' }),
mysql.createPool({ host: 'slave3.example.com', user: 'app_user', password: 'password', database: 'mydb' })
];
// 読み取りはラウンドロビンで分散
let slaveIndex = 0;
function getSlave() {
const slave = slaves[slaveIndex % slaves.length];
slaveIndex++;
return slave;
}
async function read(query, params) {
const slave = getSlave();
return await slave.query(query, params);
}
// 書き込みはマスターへ
async function write(query, params) {
return await master.query(query, params);
}
// 使用例
const users = await read('SELECT * FROM users WHERE id = ?', [userId]);
await write('UPDATE users SET name = ? WHERE id = ?', ['New Name', userId]);Python: マスター・スレーブ構成
この例では、Pythonでマスター・スレーブ構成を実装しています。接続プールを使用して、効率的に接続を管理しています。
import mysql.connector
from mysql.connector import pooling
# マスター接続プール
master_config = {
'host': 'master.example.com',
'user': 'app_user',
'password': 'password',
'database': 'mydb'
}
master_pool = mysql.connector.pooling.MySQLConnectionPool(
pool_name='master',
pool_size=5,
**master_config
)
# スレーブ接続プール
slave_configs = [
{'host': 'slave1.example.com', 'user': 'app_user', 'password': 'password', 'database': 'mydb'},
{'host': 'slave2.example.com', 'user': 'app_user', 'password': 'password', 'database': 'mydb'},
{'host': 'slave3.example.com', 'user': 'app_user', 'password': 'password', 'database': 'mydb'}
]
slave_pools = [
mysql.connector.pooling.MySQLConnectionPool(
pool_name=f'slave{i}',
pool_size=5,
**config
)
for i, config in enumerate(slave_configs)
]
import random
def get_slave():
return random.choice(slave_pools)
def read(query, params=None):
conn = get_slave().get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute(query, params)
result = cursor.fetchall()
cursor.close()
conn.close()
return result
def write(query, params=None):
conn = master_pool.get_connection()
cursor = conn.cursor()
cursor.execute(query, params)
conn.commit()
cursor.close()
conn.close()4. シャーディング
シャーディングは、データを複数のデータベースに分割する技術です。範囲ベース、ハッシュベース、ディレクトリベースなど、様々なシャーディング戦略があります。
シャーディングとは
シャーディングは、大規模なデータベースを複数の小さなデータベース(シャード)に分割する技術です。各シャードは独立して動作し、特定のデータのサブセットを保持します。これにより、データ量が増加しても、各シャードのサイズを管理可能な範囲に保つことができます。
範囲ベースシャーディング
範囲ベースシャーディングでは、データの値の範囲に基づいてシャードを分割します。例えば、ユーザーIDの範囲で分割することで、特定の範囲のユーザーを特定のシャードに配置できます。実装が簡単ですが、データの偏りが発生する可能性があります。
ハッシュベースシャーディング
ハッシュベースシャーディングでは、データのハッシュ値に基づいてシャードを分割します。これにより、データが均等に分散され、ホットスポットを防ぐことができます。ただし、シャードの追加や削除が困難になる場合があります。
ディレクトリベースシャーディング
ディレクトリベースシャーディングでは、シャードマッピングテーブルを使用して、データがどのシャードに配置されるかを決定します。柔軟性が高く、データの再配置が容易です。ただし、マッピングテーブルがボトルネックになる可能性があります。
シャーディングキーの選択
シャーディングキーは、データをどのシャードに配置するかを決定するキーです。適切なシャーディングキーを選択することで、データを均等に分散させ、クエリのパフォーマンスを向上させることができます。ユーザーID、地域、日付などが一般的なシャーディングキーです。
範囲ベースシャーディング
この例では、ユーザーIDの範囲に基づいてシャードを決定しています。特定の範囲のユーザーは、対応するシャードから取得されます。
// 範囲ベースシャーディング
// シャード1: ユーザーID 1-1000000
// シャード2: ユーザーID 1000001-2000000
// シャード3: ユーザーID 2000001-3000000
const shards = [
mysql.createPool({ host: 'shard1.example.com', database: 'mydb' }),
mysql.createPool({ host: 'shard2.example.com', database: 'mydb' }),
mysql.createPool({ host: 'shard3.example.com', database: 'mydb' })
];
function getShard(userId) {
if (userId <= 1000000) return shards[0];
if (userId <= 2000000) return shards[1];
return shards[2];
}
async function getUser(userId) {
const shard = getShard(userId);
return await shard.query('SELECT * FROM users WHERE id = ?', [userId]);
}
async function createUser(userId, name, email) {
const shard = getShard(userId);
return await shard.query('INSERT INTO users (id, name, email) VALUES (?, ?, ?)', [userId, name, email]);
}ハッシュベースシャーディング
この例では、ユーザーIDのハッシュ値に基づいてシャードを決定しています。ハッシュ関数を使用することで、データを均等に分散させることができます。
// ハッシュベースシャーディング
const crypto = require('crypto');
const shards = [
mysql.createPool({ host: 'shard1.example.com', database: 'mydb' }),
mysql.createPool({ host: 'shard2.example.com', database: 'mydb' }),
mysql.createPool({ host: 'shard3.example.com', database: 'mydb' })
];
function getShard(userId) {
// ハッシュ値を計算してシャードを決定
const hash = crypto.createHash('md5').update(String(userId)).digest('hex');
const shardIndex = parseInt(hash.substring(0, 8), 16) % shards.length;
return shards[shardIndex];
}
async function getUser(userId) {
const shard = getShard(userId);
return await shard.query('SELECT * FROM users WHERE id = ?', [userId]);
}ディレクトリベースシャーディング
この例では、シャードマッピングテーブルを使用してシャードを決定しています。柔軟性が高く、データの再配置が容易です。
// ディレクトリベースシャーディング
const shardMap = new Map();
// シャードマッピングテーブル(実際にはデータベースに保存)
const shardMapping = {
1: 'shard1',
2: 'shard1',
3: 'shard2',
4: 'shard2',
5: 'shard3'
};
const shards = {
'shard1': mysql.createPool({ host: 'shard1.example.com', database: 'mydb' }),
'shard2': mysql.createPool({ host: 'shard2.example.com', database: 'mydb' }),
'shard3': mysql.createPool({ host: 'shard3.example.com', database: 'mydb' })
};
function getShard(userId) {
const shardName = shardMapping[userId] || 'shard1';
return shards[shardName];
}
async function getUser(userId) {
const shard = getShard(userId);
return await shard.query('SELECT * FROM users WHERE id = ?', [userId]);
}参考リンク: MongoDB Sharding - MongoDBのシャーディング戦略に関する詳細なドキュメント
5. シャーディングの課題
シャーディングは強力な技術ですが、クロスシャードクエリやリバランシングなど、いくつかの課題があります。これらの課題を理解し、適切に対処することが重要です。
クロスシャードクエリ
クロスシャードクエリは、複数のシャードにまたがるクエリです。すべてのシャードにクエリを送信し、結果を結合する必要があるため、パフォーマンスに影響を与える可能性があります。可能な限り、単一のシャードで完結するクエリを設計することが重要です。
リバランシング
リバランシングは、シャード間でデータを再配置するプロセスです。シャードの追加や削除、データの偏りを解消するために必要になります。リバランシングは時間がかかり、システムに負荷をかける可能性があります。オンラインリバランシングを使用することで、サービスを停止せずにリバランシングを実行できます。
トランザクション
複数のシャードにまたがるトランザクションは、2フェーズコミットなどの分散トランザクション技術が必要になります。これにより、トランザクションの管理が複雑になり、パフォーマンスに影響を与える可能性があります。可能な限り、単一のシャードで完結するトランザクションを設計することが重要です。
データの整合性
シャーディング環境では、複数のシャード間でデータの整合性を保つことが困難になります。外部キー制約や参照整合性制約を維持することが難しくなります。アプリケーション側で整合性を保つ必要がある場合があります。
クロスシャードクエリの問題
この例では、クロスシャードクエリの問題を示しています。可能な限り、単一のシャードで完結するクエリを設計することが重要です。
// ❌ 問題: 複数のシャードにまたがるクエリ
// すべてのユーザーを取得
async function getAllUsers() {
const results = await Promise.all([
shards[0].query('SELECT * FROM users'),
shards[1].query('SELECT * FROM users'),
shards[2].query('SELECT * FROM users')
]);
return results.flat();
}
// ✅ 良い例: 単一のシャードで完結するクエリ
async function getUser(userId) {
const shard = getShard(userId);
return await shard.query('SELECT * FROM users WHERE id = ?', [userId]);
}
// ✅ 範囲指定で単一シャードに限定
async function getUsersByRange(startId, endId) {
const startShard = getShard(startId);
const endShard = getShard(endId);
// 同じシャードの場合のみ実行
if (startShard === endShard) {
return await startShard.query('SELECT * FROM users WHERE id BETWEEN ? AND ?', [startId, endId]);
}
// 複数のシャードにまたがる場合は、エラーまたは別の方法を検討
throw new Error('Query spans multiple shards');
}リバランシングの実装例
リバランシングは、シャード間でデータを再配置するプロセスです。シャードの追加や削除、データの偏りを解消するために必要になります。
// リバランシング: データの再配置
async function rebalance() {
// 1. 各シャードのデータ量を確認
const shardStats = await Promise.all(
shards.map(async (shard, index) => {
const result = await shard.query('SELECT COUNT(*) as count FROM users');
return { shardIndex: index, count: result[0].count };
})
);
// 2. データの偏りを検出
const avgCount = shardStats.reduce((sum, stat) => sum + stat.count, 0) / shardStats.length;
const threshold = avgCount * 0.2; // 20%の偏りを許容
// 3. データを再配置
for (const stat of shardStats) {
if (stat.count > avgCount + threshold) {
// データが多いシャードから、データが少ないシャードへ移動
await moveData(stat.shardIndex, avgCount);
}
}
}
async function moveData(fromShardIndex, targetCount) {
const fromShard = shards[fromShardIndex];
const toShard = shards.find((s, i) => i !== fromShardIndex);
// データを移動(実際の実装では、トランザクション管理が必要)
const users = await fromShard.query('SELECT * FROM users LIMIT ?', [targetCount]);
for (const user of users) {
await toShard.query('INSERT INTO users (id, name, email) VALUES (?, ?, ?)', [user.id, user.name, user.email]);
await fromShard.query('DELETE FROM users WHERE id = ?', [user.id]);
}
}6. 読み書き分離
読み書き分離は、マスター・スレーブ構成を使用して、書き込みと読み取りを分離する方法です。読み取り性能を向上させることができます。
マスター・スレーブ構成
マスター・スレーブ構成では、マスターサーバーで書き込みを行い、スレーブサーバーで読み取りを行います。マスターからスレーブへデータがレプリケーションされるため、読み取り性能を向上させることができます。複数のスレーブサーバーに読み取りリクエストを分散させることで、スケーラビリティを向上させます。
レプリケーション遅延の考慮
レプリケーション遅延により、スレーブサーバーのデータがマスターサーバーより古い可能性があります。リアルタイム性が重要な読み取りは、マスターサーバーから読み取る必要があります。それほど重要でない読み取りは、スレーブサーバーから読み取ることができます。
読み取り専用クエリの識別
アプリケーション側で、SELECTクエリなどの読み取り専用クエリを識別し、スレーブサーバーにルーティングします。書き込みクエリ(INSERT、UPDATE、DELETE)は、マスターサーバーにルーティングします。
読み書き分離の実装
この例では、マスターサーバーで書き込みを行い、複数のスレーブサーバーで読み取りを分散させています。リアルタイム性が重要な読み取りは、マスターサーバーから読み取ります。
const master = mysql.createPool({ host: 'master.example.com', database: 'mydb' });
const slaves = [
mysql.createPool({ host: 'slave1.example.com', database: 'mydb' }),
mysql.createPool({ host: 'slave2.example.com', database: 'mydb' })
];
let slaveIndex = 0;
// 書き込み: マスター
async function write(query, params) {
return await master.query(query, params);
}
// 読み取り: スレーブ(ラウンドロビン)
function getSlave() {
const slave = slaves[slaveIndex % slaves.length];
slaveIndex++;
return slave;
}
async function read(query, params) {
const slave = getSlave();
return await slave.query(query, params);
}
// リアルタイム性が重要な読み取り: マスター
async function readFromMaster(query, params) {
return await master.query(query, params);
}
// 使用例
// 書き込み
await write('INSERT INTO users (name, email) VALUES (?, ?)', ['John', 'john@example.com']);
// 通常の読み取り(スレーブ)
const users = await read('SELECT * FROM users WHERE id = ?', [userId]);
// リアルタイム性が重要な読み取り(マスター)
const latestUser = await readFromMaster('SELECT * FROM users WHERE id = ?', [userId]);7. キャッシング層の追加
キャッシング層を追加することで、データベースへの負荷を軽減し、パフォーマンスを向上させることができます。Redisなどのキャッシュシステムを使用して、頻繁にアクセスされるデータをキャッシュします。
キャッシングの活用
キャッシング層を追加することで、頻繁にアクセスされるデータをメモリに保存し、データベースへのアクセス回数を減らすことができます。これにより、パフォーマンスを大幅に向上させることができます。特に、読み取りが多いアプリケーションでは、キャッシングの効果が大きいです。
キャッシュ戦略
キャッシュの有効期限を設定し、定期的にキャッシュを更新します。データが更新された際は、キャッシュを無効化し、次回のアクセス時に最新のデータを取得します。Write-through、Write-behind、Cache-asideなどのキャッシュ戦略を選択します。
分散キャッシュ
Redis ClusterやMemcachedなどの分散キャッシュシステムを使用することで、複数のサーバー間でキャッシュを共有できます。これにより、スケーラビリティを向上させることができます。
キャッシング層の追加
この例では、Redisを使用してユーザーデータをキャッシュしています。キャッシュにデータがある場合は、データベースにアクセスせずにキャッシュから取得できます。書き込み時には、キャッシュを無効化または更新します。
const redis = require('redis');
const client = redis.createClient();
// キャッシュを活用した読み取り
async function getUser(userId) {
const cacheKey = `user:${userId}`;
// キャッシュから取得を試みる
const cached = await client.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// データベースから取得
const user = await read('SELECT * FROM users WHERE id = ?', [userId]);
// キャッシュに保存(1時間)
await client.setEx(cacheKey, 3600, JSON.stringify(user));
return user;
}
// 書き込み時にキャッシュを無効化
async function updateUser(userId, name, email) {
// データベースを更新
await write('UPDATE users SET name = ?, email = ? WHERE id = ?', [name, email, userId]);
// キャッシュを無効化
await client.del(`user:${userId}`);
// または、キャッシュを更新
const updatedUser = await read('SELECT * FROM users WHERE id = ?', [userId]);
await client.setEx(`user:${userId}`, 3600, JSON.stringify(updatedUser));
}8. ベストプラクティス
データベースのスケーリングには、いくつかのベストプラクティスがあります。まず垂直スケーリングを検討し、それでも不十分な場合は水平スケーリングを検討します。
- まず垂直スケーリングを検討: シンプルで効果的です。まずは既存のサーバーのリソースを増やすことで、パフォーマンスを向上させることができます。
- 読み取りは水平スケーリング: レプリケーションで分散させます。複数のスレーブサーバーに読み取りリクエストを分散させることで、読み取り性能を向上させることができます。
- シャーディングは最後の手段: 複雑性が増します。シャーディングは強力ですが、実装と運用が複雑になるため、最後の手段として検討します。
- キャッシングを活用: データベースへの負荷を軽減します。頻繁にアクセスされるデータをキャッシュすることで、データベースへの負荷を軽減できます。
- モニタリング: 各シャードの負荷を監視します。各シャードの負荷を監視し、ボトルネックを特定することで、適切な対策を講じることができます。
- リバランシング: データの偏りを解消します。データの偏りを解消するために、定期的にリバランシングを実施します。
- フェイルオーバー: 障害時の切り替えを準備します。サーバーが故障した場合に備えて、フェイルオーバーの仕組みを準備しておくことが重要です。
- 段階的なスケーリング: 一度にすべてを変更せず、段階的にスケーリングします。まず垂直スケーリングから始め、必要に応じて水平スケーリングに移行します。
- パフォーマンステスト: スケーリング前後でパフォーマンステストを実施します。スケーリングの効果を測定し、期待通りの結果が得られているか確認します。
- コストの考慮: スケーリングのコストを考慮します。垂直スケーリングと水平スケーリングのコストを比較し、予算に応じて選択します。
参考リンク: Database Sharding Strategies - MongoDBのシャーディング戦略に関する詳細なドキュメント
まとめ
データベースのスケーリング戦略には、垂直スケーリングと水平スケーリングがあります。まず垂直スケーリングを検討し、それでも不十分な場合は水平スケーリング(レプリケーション、シャーディング)を検討します。
シャーディングは強力ですが複雑性が増すため、最後の手段として検討します。キャッシング層の追加、読み書き分離など、様々な手法を組み合わせることで、スケーラブルなデータベースシステムを構築できます。
段階的なスケーリング、パフォーマンステスト、コストの考慮など、様々な要素を考慮することで、適切なスケーリング戦略を選択できます。実践的なプロジェクトでスケーリング戦略を実装し、経験を積むことで、より大規模なシステムに対応できるようになります。