Webアプリケーションのスケーラビリティに対応したデータベースシャーディング戦略
Daniel Hayes
Full-Stack Engineer · Leapcell

はじめに
Webアプリケーションの人気とユーザーベースが拡大するにつれて、基盤となるデータベースはしばしばボトルネックとなります。単一のデータベースサーバーは、増大するデータ量と同時リクエストの処理に苦労し、パフォーマンスの低下、応答時間の遅延、そして満足のいくユーザーエクスペリエンスの低下につながります。この課題は、特にEコマースプラットフォーム、ソーシャルネットワーク、リアルタイム分析ダッシュボードのような高トラフィックアプリケーションにおいて顕著です。これらの制限を克服するためには、データベースのスケーリングが不可欠です。リードレプリカやキャッシュのようなアプローチは一時的な緩和を提供できますが、持続的な成長のためには、より根本的なアーキテクチャの変更がしばしば必要となります。ここでデータベースシャーディングが登場します。シャーディングは、単一の論理データベースを複数の物理サーバーに分散させる技術であり、水平スケーラビリティとパフォーマンスの向上を可能にします。この記事では、Webアプリケーションのための2つの主要なシャーディング戦略、すなわち垂直シャーディングと水平シャーディングについて、その原則、実装、および実践的な意味合いを解説します。
シャーディングのコアコンセプトの理解
垂直シャーディングと水平シャーディングの詳細に入る前に、これらの戦略を支えるいくつかのコア用語を理解することが重要です。
- シャード: シャードは、データセット全体の一部を保持する独立したデータベースサーバーです。各シャードは、完全で機能的なデータベースインスタンスです。
- シャーディングキー(またはパーティションキー): 特定のデータ行がどのシャードに格納されるかを決定するために使用される、テーブルのカラムまたはカラムのセットです。効果的なシャーディングキーの選択は、バランスの取れたデータ分散と効率的なクエリルーティングにとって極めて重要です。
- シャードマップ(またはルーティングロジック): シャーディングキーに基づいてどのデータがどのシャードにあるかを決定するメカニズムです。これはルーティングレイヤーとして機能し、適切なシャードにクエリを送信します。
- 分散クエリ: 複数のシャードにまたがるクエリであり、しばしば異なるサーバーからの結果の集計を必要とします。これらは、単一シャードクエリよりも複雑で遅くなる可能性があります。
垂直シャーディング:機能による分割
機能シャーディングとも呼ばれる垂直シャーディングは、データベースを機能またはドメインによって分割することを含みます。単一サーバーに1つのデータベースのすべてのテーブルを配置しようとするのではなく、アプリケーションの異なる機能領域に異なるサーバーを割り当てます。
原理
垂直シャーディングのコア原理は、モノリシックなデータベースを、それぞれがアプリケーションの特定のサービスを提供する、より小さく管理しやすい複数のデータベースに分解することです。例えば、ユーザー認証データは1つのサーバーに、製品カタログデータは別のサーバーに、注文処理データは3番目のサーバーに格納されることがあります。
実装
垂直シャーディングの実装には、通常、以下が含まれます。
- 機能境界の特定: アプリケーションを分析し、明確で疎結合なモジュールまたはサービスを特定します。
- 個別のデータベースの作成: 特定された各機能領域に対して、個別のデータベーススキーマを作成し、独自のサーバーにデプロイします。
- アプリケーションロジックの更新: 機能コンテキストに基づいて適切なデータベースにクエリをルーティングするようにアプリケーションコードを変更します。
Eコマースアプリケーションを考えてみましょう。シャーディングされていないデータベースには、ecommerce_db
内に Users
、Products
、Orders
、Payments
、Carts
のようなテーブルが含まれているかもしれません。
垂直シャーディングを使用すると、以下のような構成になります。
user_db
サーバー:Users
テーブル、UserProfiles
テーブルを格納します。catalog_db
サーバー:Products
テーブル、Categories
テーブル、Reviews
テーブルを格納します。order_db
サーバー:Orders
テーブル、OrderItems
テーブル、Payments
テーブルを格納します。cart_db
サーバー:Carts
テーブルを格納します。
簡略化されたアプリケーションロジックは、次のようになるかもしれません(例としてPythonとSQLAlchemyを使用)。
# 各シャードの個別のデータベース接続が設定されていると仮定 from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker # 異なる機能シャードのデータベース接続 user_engine = create_engine('mysql+pymysql://user:pass@user_db_host/user_db') catalog_engine = create_engine('mysql+pymysql://user:pass@catalog_db_host/catalog_db') order_engine = create_engine('mysql+pymysql://user:pass@order_db_host/order_db') UserSession = sessionmaker(bind=user_engine) CatalogSession = sessionmaker(bind=catalog_engine) OrderSession = sessionmaker(bind=order_engine) class User: # user_db にマッピングされた SQLAlchemy モデル # ... class Product: # catalog_db にマッピングされた SQLAlchemy モデル # ... class Order: # order_db にマッピングされた SQLAlchemy モデル # ... def get_user_details(user_id): session = UserSession() user = session.query(User).filter_by(id=user_id).first() session.close() return user def get_product_details(product_id): session = CatalogSession() product = session.query(Product).filter_by(id=product_id).first() session.close() return product def place_order(user_id, product_id, quantity): # これは、catalog_db から製品を取得し、user_db からユーザーを取得し、 # その後、order_db に注文を作成することを含むかもしれません。これはクロスシャード操作の可能性を示唆しています。 user_session = UserSession() catalog_session = CatalogSession() order_session = OrderSession() user = user_session.query(User).filter_by(id=user_id).first() product = catalog_session.query(Product).filter_by(id=product_id).first() if user and product: new_order = Order(user_id=user.id, product_id=product.id, quantity=quantity, total_price=product.price * quantity) order_session.add(new_order) order_session.commit() order_session.close() catalog_session.close() user_session.close()
アプリケーションシナリオ
垂直シャーディングは、以下の場合に適しています。
- アプリケーションの異なる部分で、データアクセスパターンまたはパフォーマンス要件が大きく異なる場合。
- 異なる機能ドメイン間で強力な分離を望む場合。
- 特定のサービスを個別にスケーリングする必要がある場合。
- 異なる機能ドメイン間のテーブルの関係があまりにも複雑でない場合、またはすべての操作でドメイン間の強い一貫性保証が常に不可欠ではない場合。
利点
- シンプルさ: 初期実装において水平シャーディングよりも容易です。シャーディングキーやシャード間の複雑なルーティングロジックを必要としません。
- 分離: 1つの機能データベースでの障害や高負荷は、直接他のデータベースに影響しません。
- リソース最適化: 各機能領域の特定のニーズに合わせてリソースを調整できます。
欠点
- 単一機能のスケーラビリティの制限: 1つの機能領域(例:製品カタログ)が極端な成長を経験した場合、その専用サーバーが依然としてボトルネックになる可能性があります。
- クロスシャード結合/トランザクションの複雑さ: 複数の機能シャードからのデータを必要とするクエリまたはトランザクションは、効率的に実装し、ACID特性を維持するのが困難になる可能性があります。
- データの冗長性/重複: 場合によって、結合の効率化のために、少量のデータ(
user_id
やproduct_id
のような)がシャード間で重複し、一貫性の課題につながることがあります。
水平シャーディング:行による分割
単にシャーディングと呼ばれることが多い水平シャーディングは、単一テーブルの行を複数のデータベースサーバーに分割することを含みます。このモデルでは、各シャードはテーブル(またはテーブル)の総行数の一部を格納します。
原理
コア原理は、選択されたシャーディングキーに基づいて大規模テーブルの行を分散することです。例えば、Users
テーブルは user_id
でシャーディングされ、特定の範囲の user_id
を持つユーザーは1つのシャードに、別の範囲のIDを持つユーザーは別のシャードに格納されることがあります。
実装
水平シャーディングの実装には、以下が必要です。
- シャーディングキーの選択: これが最も重要なステップです。キーは、均等なデータ分散を保証し、クロスシャードクエリを最小限に抑える必要があります。
- 範囲ベースシャーディング: データはシャーディングキーの範囲に基づいて分散されます(例:Shard A に
user_id
1-1000、Shard B に 1001-2000)。実装は簡単ですが、データアクセスが特定のキー範囲に集中している場合、ホットスポットが発生する可能性があります。 - ハッシュベースシャーディング: シャーディングキーがハッシュ化され、ハッシュ値がシャードIDを決定します(例:
shard_id = hash(sharding_key) % num_shards
)。データをより均等に分散する傾向がありますが、範囲クエリを困難にします。 - リストベースシャーディング: データは、シャーディングキーの値のリストに基づいて明示的にシャードに割り当てられます(例:Shard A に特定の国のユーザー)。
- 範囲ベースシャーディング: データはシャーディングキーの範囲に基づいて分散されます(例:Shard A に
- 複数のシャードの作成: 各シャードとして機能する複数のデータベースインスタンスを設定します。
- シャードマップ/ルーティングロジックの実装: このレイヤー(多くの場合、アプリケーション外のプロキシ、またはアプリケーション内に埋め込まれる)は、シャーディングキーに基づいてクエリを正しいシャードにルーティングします。
- スキーマ変更の管理: 複数のシャードにまたがるスキーマ移行は、より複雑になる可能性があります。
Eコマース例の Orders
テーブルを考えてみましょう。垂直シャーディングからの order_db
が大きくなりすぎた場合、order_id
をシャーディングキーとして使用して、さらに水平シャーディングすることができます。
Orders
テーブルの3つのシャードを仮定します:order_shard_0
、order_shard_1
、order_shard_2
。
一般的なハッシュベースのルーティングロジック:shard_id = order_id % num_shards
from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker SHARD_COUNT = 3 SHARD_ENGINES = { 0: create_engine('mysql+pymysql://user:pass@order_shard_0_host/order_db_0'), 1: create_engine('mysql+pymysql://user:pass@order_shard_1_host/order_db_1'), 2: create_engine('mysql+pymysql://user:pass@order_shard_2_host/order_db_2'), } def get_session_for_order_id(order_id): shard_id = order_id % SHARD_COUNT engine = SHARD_ENGINES[shard_id] Session = sessionmaker(bind=engine) return Session() class Order: # 各シャードの orders テーブルにマッピングされた SQLAlchemy モデル # ... def get_order_details(order_id): session = get_session_for_order_id(order_id) order = session.query(Order).filter_by(id=order_id).first() session.close() return order def create_order(user_id, product_id, quantity): # 実際には、まず一意の order_id を生成し、それを使用してシャードを決定します。 # 例として、シーケンスジェネレータが新しい order_id を提供すると仮定します。 # この ID がシャードを決定します。 new_order_id = generate_unique_order_id() # この ID がシャードを決定します。 session = get_session_for_order_id(new_order_id) new_order = Order(id=new_order_id, user_id=user_id, product_id=product_id, quantity=quantity) session.add(new_order) session.commit() session.close() return new_order
アプリケーションシナリオ
水平シャーディングは、以下の場合に理想的です。
- 単一のテーブルまたは密接に関連するテーブルのセットが、1つのサーバーに収まらない、またはそのワークロードを効率的に処理できないほど大きくなった場合。
- 個々のテーブル(例:ユーザー、注文、イベント)を、大量のデータとスループットを収容するためにスケーリングする必要がある場合。
- データセットが大幅に増加しても、一貫したパフォーマンスが必要な場合。
利点
- 極端なスケーラビリティ: より多くのシャードを追加することで、事実上無制限のデータ量とクエリ負荷を処理できます。
- パフォーマンス向上: 複数のサーバーに負荷を分散し、競合を減らし、クエリ応答時間を改善します。
- 耐障害性: 1つのシャードの障害は、データベース全体ではなく、データの一部にのみ影響します(ただし、シャード内での適切なレプリケーションは依然として必要です)。
欠点
- 複雑さ: 垂直シャーディングよりも設計、実装、保守が大幅に複雑です。
- クロスシャードクエリ: シャーディングキーを含まない、または複数のシャードからのデータを必要とするクエリは、困難でコストがかかります(例:
Orders
がorder_id
でシャーディングされ、Users
が異なる方法でシャーディングされている場合、「名前が 'A' で始まるユーザーのすべての注文を取得する」)。 - リシャーディング: シャーディングキーの変更やシャード数の増加(リシャーディング)は、非常に困難で、しばしばダウンタイムを伴う操作です。
- データスキュー: シャーディングキーの選択が不十分だと、データが不均等に分散され(ホットスポット)、一部のシャードが過負荷になり、他のシャードが未使用のままになる可能性があります。
結論
垂直シャーディングと水平シャーディングは、どちらもWebアプリケーションデータベースを単一サーバーの制限を超えてスケーリングするための強力な方法を提供します。垂直シャーディングは、よりシンプルな機能的な分解を提供し、明確に区切られたアプリケーション部分の分離に理想的です。水平シャーディングは、より複雑ですが、多数のサーバーに行データを分散させることで比類のないスケーラビリティを提供し、特定のエンティティの大量のデータとトラフィックを管理するために不可欠です。多くの場合、両方の戦略の組み合わせ—まずサービスごとに垂直にパーティション化し、次に特定のサービス内で水平にシャーディングする—は、要求の厳しいWebアプリケーションにとって最も堅牢でスケーラブルなソリューションを提供します。データベースのスケーリングは、単にリソースを追加するだけでなく、最適なパフォーマンスと回復力のためにワークロードとデータをインテリジェントに分散することです。