データベースシャーディング: 数百万件のレコードに対応する水平スケーリング

Database tutorial - IT technology blog
Database tutorial - IT technology blog

IT業界にいる多くの人にとって、よくあるシナリオです。アプリケーションがリリースされ、人気を集め、突然、データベースが成功の重みに耐えきれなくなります。かつては高速だったパフォーマンスが、遅いクエリ、タイムアウト、そしてユーザーの不満へと変わっていきます。

私も経験があります。ユーザーベースが数千人から数百万人に膨れ上がったり、データが数十テラバイトに蓄積されたりする中で、かつては応答性があったシステムが機能不全に陥るのを見てきました。症状は紛れもないものです。高レイテンシ、応答しないダッシュボード、そして常にダウンタイムの脅威があります。

しばらくの間は、より多くのリソースを投入することでこれらの問題を軽減できることがよくあります。高速なCPU、より多くのRAM、NVMeドライブを備えたサーバーにアップグレードするのです。これは垂直スケーリングであり、有効な最初のステップです。しかし、最も強力な単一サーバーでさえ限界に達するか、さらなるアップグレードのコストが法外になる時点が来ます。それが私が話している壁であり、そこで私たちはより根本的な解決策を探し始めます。

ボトルネックの理解: スケーリングが課題となる理由

パフォーマンスの問題に真にHを組むには、単一のデータベースサーバーが最終的に苦戦する理由を理解することが役立ちます。中核的な問題は、その限られた物理リソースにあります。単一のマシンには、クエリを処理するための有限のCPUコア、データをキャッシュするための有限のRAM、そして決定的に、有限のディスクI/O帯域幅があります。

たとえば、強力なサーバーは64CPUコア、512GBのRAM、そして約100万IOPSのトップティアNVMeスループットを提供するかもしれませんが、これらの印象的なスペックにも上限があります。データ量が増加するにつれて、データベースエンジンはより多くの情報をふるいにかける必要があり、より多くのディスク読み書きが発生します。同時ユーザーが増加するにつれて、より多くのクエリが同時にデータベースにヒットし、これらの限られたリソースをめぐって競合します。

さらに、単一サーバー上の大規模なデータセット全体でデータの一貫性(ACID特性)を確保することは、特に高い書き込み負荷時には、かなりのオーバーヘッドを発生させる可能性があります。ロック、トランザクション管理、インデックス作成のすべてが貴重なリソースを消費します。ある時点で、データ整合性にとって不可欠なこれらの内部メカニズムは、極限までプッシュされると問題の一部となります。

スケーリング戦略の比較

シャーディングの詳細に入る前に、データベーススケーリングの状況を理解しておくと役立ちます。私は長年にわたり、これらの課題に取り組むためにさまざまなアプローチを模索してきました。

レプリケーション: 第一の防衛線

レプリケーション(多くの場合、マスターレプリカまたはリーダーフォロワー設定)は、通常、最初に採用される戦略です。これには、データベースの複数のコピーを維持することが含まれます。マスターはすべての書き込み操作を処理し、その後、非同期または同期的にこれらの変更を1つ以上のレプリカにレプリケートします。その後、読み取りはこれらのレプリカ全体に分散できます。

これは、読み取り負荷の高いアプリケーションをスケーリングし、高可用性と災害復旧を大幅に向上させるための優れたソリューションです。マスターが失敗した場合、レプリカを昇格させることができます。ただし、書き込み負荷の高いアプリケーションの場合、レプリケーションの適用範囲には限界があります。すべての書き込みは依然として単一のマスターを介して行われるため、それが持続的なボトルネックとなります。

垂直スケーリング: より多くのパワー、一時的な緩和策

前述のように、垂直スケーリングとは、既存のサーバーをより強力にすることです。ハードウェアのアップグレードは簡単で、多くの場合、アプリケーションコードの変更を必要としません。最初は大幅なパフォーマンス向上をもたらすことができます。

しかし、垂直にどれだけスケーリングできるかには厳密な限界があります。プロセッサの速度は無限に向上するわけではなく、RAM/ディスクI/O機能は最終的に頭打ちになります。コストも、わずかな増加に対して天文学的なものになります。結局のところ、それは時間を稼ぐことになりますが、真に大規模なスケールに対応するためのアーキテクチャを根本的に変更するものではありません。

シャーディングの導入: 水平スケーリングのゲームチェンジャー

これが水平スケーリング、つまりシャーディングにつながります。単一のマシンをアップグレードするのではなく、シャーディングはデータを複数の独立したデータベースインスタンスに分散させます。それぞれが独自のサーバー上で実行されます。このアプローチはゲームを完全に変えます。データとトラフィックが増加するにつれてサーバーを追加することで、ほぼ無期限にスケールアウトできます。これは強力な概念ですが、慎重な検討を必要とする独自の複雑さをもたらします。

データベースシャーディング: 大規模データセットを扱うための私のアプローチ

MySQL、PostgreSQLMongoDBをさまざまなプロジェクトで扱ってきましたが、それぞれに独自の強みがあり、それぞれで異なるスケーリング課題がどのように現れるかを見てきました。MySQLとPostgreSQLは堅牢なリレーショナルデータベースですが、シャーディングの実装には、多くの場合、外部ツールや慎重なアプリケーションレベルの設計が必要です。

一方、MongoDBは、シャーディングを介した水平スケーリングをコア機能としてゼロから構築されており、運用上のオーバーヘッドはやや少なくて済みます。データベースに関係なく、シャーディングの原則は一貫しています。

シャーディングとは正確には何ですか?

シャーディングを、単一の巨大な百科事典をいくつかのより小さな独立した巻に分割するようなものだと考えてください。各巻(「シャード」)には合計情報の一部が含まれており、それぞれ異なる棚(サーバー)に保存できます。情報を検索する必要がある場合は、まずどの巻にあるかを特定し、次にその巻に直接アクセスします。

データベース用語では、シャードは完全で独立したデータベースインスタンスです。データの一部が含まれています。シャーディングの鍵となるのは「シャードキー」です。これは、特定のデータがどのシャードに格納されるかを決定する列またはフィールドです。このキーは、クエリを正しいシャードに効率的にルーティングするために重要です。

主要なシャーディング戦略とその影響

シャーディング戦略の選択は、データ分散、クエリパフォーマンス、および将来の運用上の容易さに直接影響するため、最も重要な決定の1つです。

範囲ベースのシャーディング

範囲ベースのシャーディングでは、シャードキーの連続する値の範囲に基づいてデータが分散されます。たとえば、ユーザーIDが1から1,000,000までの場合、シャードAに移動し、1,000,001から2,000,000までのIDはシャードBに移動します。

  • メリット:
  • 範囲クエリは非常に効率的です。なぜなら、範囲内のすべてのデータが単一のシャードに存在するからです。
  • データは論理的にグループ化されており、直感的です。
  • デメリット:
  • データが均等に分散されていない場合や、特定の範囲が不釣り合いに高いトラフィックを経験した場合(例: すべての新規ユーザーが同じシャードに割り当てられるなど)、「ホットスポット」が発生する可能性があります。
  • 範囲を調整する必要がある場合、リバランスが複雑になることがあります。

概念的なSQLの例を以下に示します。

-- アプリケーションロジックがuser_idに基づいてシャードを決定すると仮定します
-- 特定の範囲のユーザーに対するクエリをShard 1に直接送信します (例: IDが1から1,000,000までを保持する場合)
SELECT * FROM users.shard1 WHERE user_id BETWEEN 500000 AND 500010;

-- 別の範囲のユーザーに対するクエリをShard 2に直接送信します (例: IDが1,000,001から2,000,000までを保持する場合)
SELECT * FROM users.shard2 WHERE user_id BETWEEN 1500000 AND 1500010;

ハッシュベースのシャーディング

ハッシュベースのシャーディングは、シャードキーにハッシュ関数を適用してデータを分散させます。ハッシュ関数の出力によってシャードが決まります。これは、シャード全体でより均一なデータ分散を達成することを目的としています。

  • メリット:
  • データが均等に分散され、ホットスポットを回避するのに優れています。ハッシュ関数はデータを分散させる傾向があるからです。
  • デメリット:
  • 範囲クエリに問題が生じます。論理的に連続したデータが多くのシャードに分散され、クエリをすべてのシャードにファンアウトする必要がある場合があります。
  • シャードの追加または削除には、大量のデータの再ハッシュと再分散が必要になる場合があります。

シャードを決定するための簡単なPythonの例:

import hashlib

def get_shard_id(key_value, num_shards):
    # シャード決定にシンプルなハッシュ (MD5) とモジュロを使用
    # 実際のシステムでは、より洗練された一貫性ハッシュを使用する場合があります
    return int(hashlib.md5(str(key_value).encode()).hexdigest(), 16) % num_shards

# ユーザーIDの例
user_id_to_store = 987654321
number_of_database_shards = 4
shard_index = get_shard_id(user_id_to_store, number_of_database_shards)
print(f"ユーザー {user_id_to_store} のデータはシャード {shard_index} に格納されます")

# 別の例
order_id_to_store = "ORDER-XYZ-789"
shard_index_order = get_shard_id(order_id_to_store, number_of_database_shards)
print(f"注文 {order_id_to_store} のデータはシャード {shard_index_order} に格納されます")

ディレクトリベースのシャーディング

このアプローチでは、シャードキーを特定のシャードにマッピングするルックアップテーブルまたはサービス(しばしば設定サーバーまたはルーターと呼ばれる)を使用します。アプリケーションは、最初にこのディレクトリをクエリして、データピースの正しいシャードを見つけます。

  • メリット:
  • 非常に柔軟性があります。シャードの追加や削除、データの再ハッシュなしでのシャード間での移動が可能です。
  • 複雑なシャーディングロジックとデータの物理的な分離を可能にします。
  • デメリット:
  • 複雑さが一層増し、追加の単一障害点(ルックアップサービスは高可用性である必要があります)が生まれます。
  • 実際のデータシャードに到達する前のルックアップレイテンシが発生します。

シャードマップの概念的なJSON表現:

// シャードマップ設定(ルーター/設定サーバーによって管理されます)
{
  "users_collection": {
    "shard_key": "username",
    "distribution_method": "hash",
    "shards": [
      {"range_start": "a", "range_end": "g", "server": "user_shard_01.example.com"},
      {"range_start": "h", "range_end": "n", "server": "user_shard_02.example.com"},
      {"range_start": "o", "range_end": "z", "server": "user_shard_03.example.com"}
    ]
  },
  "products_collection": {
    "shard_key": "product_id",
    "distribution_method": "range",
    "shards": [
      {"range_start": 1, "range_end": 1000000, "server": "product_shard_01.example.com"},
      {"range_start": 1000001, "range_end": 2000000, "server": "product_shard_02.example.com"}
    ]
  }
}

運用上の現実: 私が遭遇した課題

シャーディングは絶大なパワーを提供しますが、魔法の解決策ではありません。複雑さが増し、確かにいくつかの難しい状況を乗り越えてきました。

  • シャードキーの選択: これは間違いなく最も重要な決定です。シャードキーが不適切だと、不均一なデータ分散(ホットスポット)、特定のクエリの極端な非効率化、または将来のリバランスの妨げになる可能性があります。シャードキーは慎重に、理想的には不変で均等に分散されるように選択する必要があります。
  • データのリバランス: データが増加したり、アクセスパターンが変化したりすると、一部のシャードが過負荷になる可能性があります。リバランスには、あるシャードから別のシャードにデータを移動させる作業が伴います。これは些細なプロセスではなく、リソースを大量に消費し、操作中にパフォーマンスに影響を与える可能性があります。
  • クロスシャードクエリ: 複数のシャードからのデータの結合または集計を必要とするクエリは、本質的に複雑で遅くなります。アプリケーションまたは専用のクエリルーターは、関連するすべてのシャードにクエリをファンアウトし、結果を収集してマージする必要があります。このプロセスは、特定のクエリタイプに対するシャーディングのパフォーマンスメリットの一部を打ち消す可能性があります。
  • 分散トランザクション: 複数のシャードにまたがるトランザクションのACID特性を維持することは極めて困難です。多くの場合、開発者は最終的な一貫性モデルに頼るか、複雑な2フェーズコミットプロトコルを実装しますが、これらはかなりの複雑さとレイテンシを追加します。
  • スキーマ変更: シャードクラスター全体にスキーマ移行を適用するには、ダウンタイムなしですべてのシャード間で一貫性を確保するために、慎重なオーケストレーションが必要です。
  • 運用上の複雑さ: 監視、バックアップ、災害復旧は、単一インスタンスではなくデータベースのクラスターを管理することになるため、より複雑になります。データベースバックアップ&リカバリも参照してください。

シャーディングを選択するタイミング: 実用的な見通し

シャーディングは、他のすべてのスケーリングオプションが尽きたとき、または真に大規模で持続的な成長が予想される場合に検討する手法です。本質的な複雑さのため、通常は最初の選択肢ではありません。

  • 垂直スケーリングが経済的または技術的に実行不可能になったとき。
  • アプリケーションが数百万(または数十億)のレコードを一貫して処理し、成長し続けているとき。
  • 読み取り操作、そしてさらに重要な書き込み操作が、単一ノードのパフォーマンスを常に限界を超えて押し上げているとき。
  • 地理的に分散したユーザーのために、低レイテンシでデータをグローバルに分散する必要があるとき。

MongoDBのような多くのNoSQLデータベースは、シャーディングを念頭に置いて設計されています。そのアーキテクチャは本質的にデータ分散をサポートしており、リレーショナルデータベースを手動でシャーディングするよりも設定と管理が簡素化されることがよくあります。リレーショナルデータベースの場合、PostgreSQL用のCitusDataやMySQL用のVitessのようなソリューションは、複雑さの一部を抽象化するシャードアーキテクチャを提供しますが、分散システムに関する深い理解は依然として必要です。

成長のためのスケーリングに関する最終考察

データベースシャーディングは、水平スケーラビリティを実現するための強力な手法であり、システムが膨大なデータ量と高いトランザクションレートを処理することを可能にします。これは、データベースエンジニアリングの創意工夫の証であり、垂直スケーリングが限界に達したときに前進する道を提供します。

しかし、これはシステム設計と運用に新たな複雑さのレイヤーを導入する、重要なアーキテクチャ上のコミットメントです。私の経験では、次のように言えます。シャーディングの成功は、パフォーマンスと回復力の点で計り知れない報酬をもたらしますが、綿密な事前計画、シャードキーの選択、および継続的な運用上の注意が成功の鍵となります。これは、アプリケーションの長期的なスケーラビリティへの戦略的な投資です。

Share: