Apache ShardingSphereによるMySQLデータベースシャーディング実践ガイド:水平スケーリングで大規模データを攻略する

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

午前2時15分、PagerDutyのアラートが鳴り響く。本番環境のMySQLクラスタのCPU使用率は99%に達し、コネクションプールは枯渇、注文キューには12,000件以上のリクエストが滞留している。ordersテーブルはちょうど4,500万レコードを超え、データサイズは85GBを突破したところだ。

インデックスは適切に設計されているにもかかわらず、ディスクI/Oのボトルネックにより、user_idを指定した単純なSELECTクエリにすら3.8秒もかかっている。この状況でRAMを64GBから128GBへと増強(垂直スケーリング / Vertical Scaling)しても、コストが高くつく上に数週間の時間稼ぎにしかならない。システムを長期的に安定稼働させるには、データを複数サーバーに分割するデータベースシャーディング(Database Sharding)が不可欠となる。そこで活躍するのがApache ShardingSphereだ。

1. クイックスタート:5分で立ち上げるShardingSphere-Proxyクラスタ

迅速な障害対応や導入が求められる場面では、ShardingSphere-Proxyが最適な選択肢となる。アプリケーションとMySQLクラスタの間に位置し、標準的なMySQLプロトコル準拠のプロキシレイヤーとして機能する。そのため、Spring BootやGo、Node.jsなどを扱うバックエンドチームは、接続コードを1行も修正する必要がない。

ポート3306で稼働する2つの物理MySQLインスタンスを用意する:

  • Node 1: 192.168.1.10:3306(データベース ds_0 を格納)
  • Node 2: 192.168.1.11:3306(データベース ds_1 を格納)

両方のノードでデータベースとテーブルを作成する:

CREATE DATABASE IF NOT EXISTS ds_0;
CREATE DATABASE IF NOT EXISTS ds_1;

-- ds_0 と ds_1 の両ノードでこのDDLを実行:
CREATE TABLE t_order (
    order_id BIGINT PRIMARY KEY,
    user_id INT NOT NULL,
    amount DECIMAL(10, 2),
    status VARCHAR(50),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

config-sharding.yaml ファイルにルーティングルールを設定する:

databaseName: sharding_db

dataSources:
  ds_0:
    url: jdbc:mysql://192.168.1.10:3306/ds_0?serverTimezone=UTC&useSSL=false
    username: root
    password: SecretPassword123
  ds_1:
    url: jdbc:mysql://192.168.1.11:3306/ds_1?serverTimezone=UTC&useSSL=false
    username: root
    password: SecretPassword123

rules:
- !SHARDING
  tables:
    t_order:
      actualDataNodes: ds_${0..1}.t_order
      databaseStrategy:
        standard:
          shardingColumn: user_id
          shardingAlgorithmName: inline_db
  shardingAlgorithms:
    inline_db:
      type: INLINE
      props:
        algorithm-expression: ds_${user_id % 2}

DockerコンテナでProxyを起動する:

docker run -d --name shardingsphere-proxy \
  -p 3307:3307 \
  -v $(pwd)/config-sharding.yaml:/opt/shardingsphere-proxy/conf/config-sharding.yaml \
  apache/shardingsphere-proxy:latest

この手順が完了したら、アプリケーションの接続先を 127.0.0.1:3307、データベースを sharding_db に向ける。user_id = 101 のレコードを書き込むと、データは自動的に ds_1 へ振り分けられる(101 % 2 = 1)。user_id = 100 の場合は ds_0 へルーティングされる。アプリケーション側からは、あたかも単一のデータベースとやり取りしているように見える。

2. シャーディングとShardingSphereの技術的本質

データ肥大化によってインデックスが効かなくなる理由

レコード数が500万件未満のテーブルであれば軽快に動作する。しかし5,000万件に達すると、B-Treeインデックスツリーが肥大化し、<a href="https://itfromzero.com/ja/database/mysql-ja-database/mysql%e5%86%8d%e8%b5%b7%e5%8b%95%e5%be%8c%e3%81%ae%e6%bf%80%e9%87%8d%e5%95%8f%e9%a1%8c%e3%82%92%e8%a7%a3%e6%b6%88%ef%bc%81buffer-pool%e3%82%92%e5%b8%b8%e3%81%ab%e3%80%8c%e3%83%9b%e3%83%83%e3%83%88.html">innodb_buffer_pool_size</a>の容量を超過してしまう。MySQLはインデックス全体をメモリ(RAM)上に保持できなくなり、クエリごとにランダムディスクI/Oが発生して、I/O waitが40〜60%にまで跳ね上がる。

MySQLのリードレプリカ構成は読み取りクエリの分散にしか寄与しない。書き込み負荷(Writeスループット)やストレージ容量の限界は、依然としてマスターノードに集中する。水平シャーディング(Horizontal Sharding)は、読み取り負荷、書き込み負荷、ストレージ容量のすべてを複数の独立したサーバーに均等分散することで、この課題を根本から解決する。

Apache ShardingSphereにおける2つのアーキテクチャ形態

ShardingSphereエコシステムでは、以下の2つのデプロイモードが提供されている:

  • ShardingSphere-JDBC: カスタムJDBCドライバとしてアプリケーションに直接組み込むライブラリ。追加のネットワークホップが発生しないため、オーバーヘッドはほぼゼロ。デメリットとして、JVM(Java、Kotlinなど)環境に依存する点が挙げられる。
  • ShardingSphere-Proxy: MySQLクラスタの前に配置される独立したデータベースプロキシ。Go、Python、PHP、C#など、あらゆる言語から標準MySQLポート経由で透過的に接続可能。異なる技術スタックが混在するマイクロサービス環境に最適。

データルーティングアルゴリズムの仕組み

設定例の INLINE ルールでは、ds_${user_id % 2} という数式を使用している。ここで user_id カラムがシャーディングキー(Sharding Key)としての役割を果たす。以下のSQLを受け取った場合:

SELECT * FROM t_order WHERE user_id = 100;

ProxyはクエリをAST(抽象構文木)にパースし、100 % 2 = 0 を即座に計算して、クエリを ds_0 に直接転送する。ノード ds_1 は一切関与せず、CPUサイクルやI/Oリソースを消費することもない。

3. 分散IDの採番と多層シャーディング

SnowflakeアルゴリズムによるID衝突の回避

シャーディング環境では、MySQL標準の <a href="https://itfromzero.com/ja/database/mysql-ja-database/mysql-uuid%e3%81%8bauto_increment%e3%81%8b%ef%bc%9fuuid-v7%e3%81%ab%e3%82%88%e3%82%8b%e6%9c%80%e9%81%a9%e5%8c%96%e6%88%a6%e7%95%a5.html">AUTO_INCREMENT</a> を絶対に使用してはならない。そのまま利用すると、ds_0 と ds_1 の双方が同じ id = 1 を生成してしまい、データの整合性が崩壊する。ShardingSphereには Snowflake アルゴリズムが標準組み込みされており、クラスタ全体で重複のない一意な64ビット整数のIDを自動生成できる。

ルール設定にジェネレータを定義する:

rules:
- !SHARDING
  tables:
    t_order:
      actualDataNodes: ds_${0..1}.t_order_${0..1}
      keyGenerateStrategy:
        column: order_id
        keyGeneratorName: snowflake_gen
  keyGenerators:
    snowflake_gen:
      type: SNOWFLAKE

2層シャーディング戦略(データベース分割 + テーブル分割)

データ規模が数億レコード規模に達した場合は、データベース分割と物理テーブル分割を組み合わせた構成が推奨される。例えば、2つのデータベース(ds_0、ds_1)を用意し、各データベース内に2つの子テーブル(t_order_0、t_order_1)を配置することで、合計4つの物理テーブルに分散させる:

rules:
- !SHARDING
  tables:
    t_order:
      actualDataNodes: ds_${0..1}.t_order_${0..1}
      databaseStrategy:
        standard:
          shardingColumn: user_id
          shardingAlgorithmName: inline_db
      tableStrategy:
        standard:
          shardingColumn: order_id
          shardingAlgorithmName: inline_tbl
  shardingAlgorithms:
    inline_db:
      type: INLINE
      props:
        algorithm-expression: ds_${user_id % 2}
    inline_tbl:
      type: INLINE
      props:
        algorithm-expression: t_order_${order_id % 2}

4. 本番運用における実践的プラクティス

シャーディングキーを指定しないクエリの大きな代償

以下のクエリを見てみよう:

SELECT * FROM t_order WHERE status = 'COMPLETED';

このクエリには user_id が含まれていないため、Proxyはどのノードにデータがあるかを判別できない。その結果、すべてのノードへクエリを一斉送信(Scatter-Gatherパターン)し、数百万行ものデータをProxyのメモリ上に集約してソートやマージを試みることになる。これにより、Proxyは即座にOut-Of-Memory(OOM)を引き起こす。注文検索APIではシャーディングキーの指定を必須とするなど、設計段階でこのリスクを確実に遮断しておく必要がある。

分散トランザクションへの適切な対処法

複数の物理データベースをまたぐデータ書き込みでは、MySQL本来のローカルACIDトランザクションが効かなくなる。ShardingSphereはXA仕様(2フェーズコミット)やApache Seata経由のSagaパターンをサポートしている。しかし、XAはリソースロックの保持時間が長くなるため、スループットが4〜5分の1に激減するトレードオフがある。本番運用のベストプラクティスは、単一の業務処理における操作が同一のシャーディングキー内に収まるようテーブル設計を工夫することだ。

マスタテーブルにはブロードキャストテーブル(Broadcast Table)を活用

変更頻度は低いものの頻繁に JOIN される provinces(都道府県)や product_categories(商品カテゴリ)などのテーブルは、ブロードキャストテーブル(Broadcast Table)として設定すべきである。ShardingSphereがこれらのテーブルを全ノードに自動レプリケーションするため、注文データとマスタデータの JOIN が同一ノード内で完結し、ネットワーク経由のデータ転送コストを完全に排除できる。

Share: