TechHub

エンジニアの成長をサポートする技術情報サイト

← 記事一覧に戻る

データベースのスケーリング戦略とは?垂直スケーリングと水平スケーリング

公開日: 2024年2月26日 著者: mogura
データベースのスケーリング戦略とは?垂直スケーリングと水平スケーリング

疑問

データベースをスケールさせるには、どのような戦略があるのでしょうか?垂直スケーリングと水平スケーリングについて一緒に学んでいきましょう。

導入

データベースのスケーリングは、データ量やトラフィックの増加に対応するための重要な技術です。垂直スケーリングと水平スケーリング、それぞれ異なるアプローチがあり、プロジェクトの要件に応じて選択します。

本記事では、スケーリング戦略の基本から、垂直スケーリング、水平スケーリング、シャーディングまで、実践的な手法を詳しく解説していきます。

スケーリング戦略のイメージ

解説

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

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-4

3. 水平スケーリング

水平スケーリングは、サーバーの台数を増やすことで負荷を分散させる方法です。レプリケーションを使用することで、読み取り性能を向上させることができます。

レプリケーションによる読み取りスケーリング

マスターサーバーで書き込みを行い、スレーブサーバーで読み取りを行うことで、読み取り性能を向上させることができます。複数のスレーブサーバーに読み取りリクエストを分散させることで、スケーラビリティを向上させます。読み取り専用のクエリは、スレーブサーバーで実行することで、マスターサーバーの負荷を減らすことができます。

読み取りレプリカの追加

読み取り負荷が増加した場合、読み取りレプリカを追加することで、読み取り性能を向上させることができます。クラウド環境では、読み取りレプリカを簡単に追加できます。ただし、レプリケーション遅延に注意する必要があります。

ロードバランシング

ロードバランサーを使用して、読み取りリクエストを複数のスレーブサーバーに分散させます。ラウンドロビン、重み付けラウンドロビン、最小接続数などのアルゴリズムを使用して、負荷を分散させます。

書き込みスケーリングの課題

書き込みのスケーリングは、読み取りよりも複雑です。マスター・マスターレプリケーションやシャーディングを使用することで、書き込みのスケーリングを実現できますが、データの整合性やトランザクションの管理が複雑になります。

レプリケーションによる読み取りスケーリング

この例では、マスターサーバーで書き込みを行い、複数のスレーブサーバーで読み取りを分散させています。ラウンドロビン方式でスレーブを選択することで、読み取り負荷を均等に分散できます。

// マスター: 書き込み
// スレーブ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]);
}

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. ベストプラクティス

データベースのスケーリングには、いくつかのベストプラクティスがあります。まず垂直スケーリングを検討し、それでも不十分な場合は水平スケーリングを検討します。

  • まず垂直スケーリングを検討: シンプルで効果的です。まずは既存のサーバーのリソースを増やすことで、パフォーマンスを向上させることができます。
  • 読み取りは水平スケーリング: レプリケーションで分散させます。複数のスレーブサーバーに読み取りリクエストを分散させることで、読み取り性能を向上させることができます。
  • シャーディングは最後の手段: 複雑性が増します。シャーディングは強力ですが、実装と運用が複雑になるため、最後の手段として検討します。
  • キャッシングを活用: データベースへの負荷を軽減します。頻繁にアクセスされるデータをキャッシュすることで、データベースへの負荷を軽減できます。
  • モニタリング: 各シャードの負荷を監視します。各シャードの負荷を監視し、ボトルネックを特定することで、適切な対策を講じることができます。
  • リバランシング: データの偏りを解消します。データの偏りを解消するために、定期的にリバランシングを実施します。
  • フェイルオーバー: 障害時の切り替えを準備します。サーバーが故障した場合に備えて、フェイルオーバーの仕組みを準備しておくことが重要です。
  • 段階的なスケーリング: 一度にすべてを変更せず、段階的にスケーリングします。まず垂直スケーリングから始め、必要に応じて水平スケーリングに移行します。
  • パフォーマンステスト: スケーリング前後でパフォーマンステストを実施します。スケーリングの効果を測定し、期待通りの結果が得られているか確認します。
  • コストの考慮: スケーリングのコストを考慮します。垂直スケーリングと水平スケーリングのコストを比較し、予算に応じて選択します。

まとめ

データベースのスケーリング戦略には、垂直スケーリングと水平スケーリングがあります。まず垂直スケーリングを検討し、それでも不十分な場合は水平スケーリング(レプリケーション、シャーディング)を検討します。

シャーディングは強力ですが複雑性が増すため、最後の手段として検討します。キャッシング層の追加、読み書き分離など、様々な手法を組み合わせることで、スケーラブルなデータベースシステムを構築できます。

段階的なスケーリング、パフォーマンステスト、コストの考慮など、様々な要素を考慮することで、適切なスケーリング戦略を選択できます。実践的なプロジェクトでスケーリング戦略を実装し、経験を積むことで、より大規模なシステムに対応できるようになります。

データベースのセキュリティ対策とは?アクセス制御と暗号化