チームはアプリサーバーを2台から10台にスケールした。ロードバランサーでトラフィックは問題なく処理できた。アプリサーバーのCPU使用率は20%に下がった。すべてが良く見えた。
ただし、レスポンスタイムは悪化した。
2台のときはデータベースの平均レスポンスが50msだった。10台にしたら200msに跳ね上がった。同じデータベース、同じクエリ — ただ5倍のコネクションが同じ行とロックを奪い合っているだけだ。
結局、10台のサーバーが1つのデータベースを叩くのは、2台が叩くより悪い。スケールしていなかった。ただボトルネックを移動しただけだった。
実際に壊れる順序はこうだ。
シングルサーバー — AppとDBが1台のマシンに同居。100〜500同時接続ユーザーまでは動くが、データベースのディスクI/Oが先に限界を迎える。
DB分離 — データベースを専用マシンに移す。1,000同時接続ユーザーあたりまでの余裕が生まれる。次に壊れるのはコネクション数だ。
ロードバランサーの後ろにアプリサーバー複数台 — ここが罠だ。アプリサーバーを追加してスケールしているつもりでも、追加したサーバーごとに同じデータベースへのコネクションが増える。データベースが天井になり、その天井をさらに強く押し上げているだけだ。
ほとんどのチームがここで詰まる。
垂直スケーリングはより大きいデータベースサーバーにすること。より多いRAM、より速いSSD、より多いコア。db.r5.largeの月$200からdb.r5.24xlargeの月$6,400まで上げていく。AWSの最大インスタンスか予算に達するまでは動く。
水平スケーリングは複数のデータベースサーバーにすること。ここが複雑になる。
リードレプリカは簡単な最初のステップだ。
ほとんどのアプリの80%は読み込みだ。リードレプリカでかなりの余裕が買える。
書き込みが多いワークロードはまだプライマリを叩く。そうなるとシャーディングが必要になる — ユーザーIDの範囲などのキーでデータを複数のデータベースクラスタに分割する。
シャーディングは痛い。クロスシャードクエリは高コストになる。シャード間のトランザクションには分散調整が必要。ここに行く前によく考えろ。
シャーディングの前に、これらを試せ。
インデックスが1つ欠けているだけでアプリケーション全体が落ちる。何度も見てきた。
コネクションプーリングももう一つの無料の勝ち筋だ。PgBouncerをアプリサーバーとデータベースの間に置けば、500のアプリケーションコネクションが20の実際のデータベースコネクションを共有できる。データベースがコネクションのオーバーヘッドで溺れなくなる。
キャッシングは3つ目のレバーだ。データベースに秒間1,000回叩いていて、その90%が同じホットキーなら、前にRedisを置け。データベースへのQPSは100まで下がる。
1つのインデックスで解決できたはずの問題にシャーディングに飛びつくチームを見てきた。順番に作業しろ。シンプルな解決策が失敗したときだけ複雑さを追加しろ。
データベースはほぼ常にボトルネックだ。それに応じて扱え。
サーバーを追加する前に、クエリをチェックしろ。
— blanho
# トラフィック分割: 書き込みはプライマリ、読み込みはレプリカ
def get_user(id):
return read_replica.query(
"SELECT * FROM users WHERE id = %s", id
)
def update_user(id, data):
return primary.query(
"UPDATE users SET ... WHERE id = %s", id, data
)def get_shard(user_id):
return f"shard_{user_id // 1_000_000}"-- クエリ最適化: 無料で10倍の改善
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 123;
-- "Seq Scan on orders" = インデックスなし = 災害
CREATE INDEX idx_orders_user_id ON orders(user_id);