データの分散 — 増えた先で何が崩れるか
アプリケーションサーバーは増やせます。データベースは同じようにはいきません。
コピーを作れば読み取りは散らせますが、コピーには遅れが生じます。分割すれば容量と書き込みを散らせますが、分割をまたぐ操作が難しくなります。**データを分散させると、1 台のときに当たり前だったことが崩れます。**何が崩れるかを知ったうえで選ぶのが、この章の内容です。
この章で学ぶこと
- レプリケーションの同期と非同期、それぞれが失うもの
- 複製した先を読むと書いた直後に読めないこと
- シャーディングと分割キーの選び方、偏りが起きる仕組み
- 結果整合性が利用者に見える瞬間
- CAP 定理を「分断が起きたとき何を諦めるか」として読む
この章で扱わないこと
複数のサービスにまたがる更新の整合性は扱いません。データベースへの書き込みとメッセージの送信を取りこぼしなく揃える方式 (Outbox パターン) や、長い処理を分割して失敗時に打ち消す操作を並べる方式 (Saga パターン) は、サービス間の連携を扱うガイドの領域です。本章は 1 つのデータストアを複数台に広げたときの話に絞ります。
インデックスの設計とクエリの書き方は インデックス が、クラウドのデータベース製品の設定は AWS 実践ガイド — RDS が扱います。
2 つの分け方
データを複数台に置く方法は、目的によって 2 つに分かれます。
| 方法 | 何をするか | 解く問題 | 解かない問題 |
|---|---|---|---|
| レプリケーション | 同じデータのコピーを複数台に持つ | 読み取りの分散と、1 台が壊れたときの継続 | 容量。書き込みの量 |
| シャーディング | データを分割して別々の台に置く | 容量と書き込みの分散 | 1 台が壊れたときの継続 (別途複製が要る) |
インデックス の「似た用語との区別」で触れたとおり、**シャーディングは検索を速くする手法ではありません。**1 台に収まらない規模の問題を解きます。
**この 2 つは併用します。**分割した各断片をさらに複製する構成が一般的です。
レプリケーション — コピーを持つ
書き込みを受ける台 (プライマリ) の変更を、他の台 (スタンバイ、レプリカ) へ送ります。送り方は 2 つあります。
| 方式 | 応答を返す条件 | 失うもの |
|---|---|---|
| 同期 | レプリカが受け取って永続化したのを確認してから | **書き込みが遅くなる。**レプリカが遅れると書き込み全体が待たされる |
| 非同期 | プライマリに書けた時点で | プライマリが壊れると、送り終えていない分が失われる |
PostgreSQL の比較表でも、この対比がそのまま整理されています。非同期は「複数のサーバーを待たない」代わりに「プライマリの障害でデータを失わない」を満たさず、同期はその逆になります (PostgreSQL — Comparison of Different Solutions)。
**どちらを選ぶかは、失ってよいものが決めます。**注文や決済のように 1 件も失えないなら同期、閲覧履歴のように多少失っても業務が続くなら非同期、という分け方になります。
**「同期」が何をどこまで待つかは、設定で段階が分かれます。**PostgreSQL では、まず待つ相手を synchronous_standby_names に指定しないと同期レプリケーション自体が有効になりません。既定は空で、そのときはコミットが複製を待ちません (PostgreSQL — synchronous_standby_names)。有効にしたうえで synchronous_commit が既定の on なら、レプリカが受け取って永続化するまで待ちますが、レプリカ側のクエリから見えるようになるまでは待ちません。可視性まで揃えるには remote_apply を指定します (PostgreSQL — synchronous_commit)。同期にしたから複製先で最新が読める、とは限りません。
書いた直後に読めない
非同期レプリケーションで読み取りをレプリカへ振ると、自分が書いたものが読めない瞬間が生まれます。
利用者から見ると「保存したのに反映されていない」です。バグに見えますが、構成どおりの動作です。
対処は用途によって変わります。
| 対処 | 内容 | 向く場面 |
|---|---|---|
| 書いた直後だけプライマリを読む | 更新から一定時間、その利用者の読み取りをプライマリへ振る | 汎用的。実装は接続の振り分けに手を入れる |
| 画面に返す値を手元で使う | 保存した内容をそのまま表示し、読み直さない | 更新直後の画面表示 |
| 反映に時間がかかることを伝える | 「反映まで数秒かかります」と表示する | 集計やランキング |
**遅れの大きさは測れます。**PostgreSQL ならプライマリとレプリカの位置の差を監視できます。遅れが常時数秒あるなら、それは構成か負荷の問題です。
分離レベルとは別の遅れ
ロックと分離レベル で扱った分離レベルは、1 台のデータベースの中で、同時に走るトランザクションがどう見えるかの話でした。
レプリカをまたぐ遅れは、その上に乗る別の遅れです。分離レベルを最も強い設定にしても、非同期レプリカが遅れていれば古い値が返ります。2 つは別の層にあるので、片方を強めてももう片方は解決しません。
シャーディング — 分割して置く
データを分割し、断片ごとに別の台へ置きます。分割の基準になる値を分割キーと呼びます。
得られるものは容量と書き込みの分散です。代わりに 3 つが難しくなります。
- 分割をまたぐ検索 — 全シャードに問い合わせて結果を集める必要があります
- 分割をまたぐトランザクション — トランザクション の原子性が 1 台の中に閉じなくなります
- 分割の変更 — シャードを増やすとき、既存データの移動が要ります
だから分割キーの選び方が設計の中心になります。よく使う検索が 1 つのシャードに収まるキーを選びます。利用者ごとに閉じたサービスなら利用者 ID が候補になります。
偏りが起きるキー
分割キーは、値の種類が多く、アクセスが散るものを選びます。逆の性質を持つキーは、特定のシャードに負荷を集めます。
| キーの例 | 散り方 | 理由 |
|---|---|---|
| 利用者 ID (利用者が多い) | 良い | 値の種類が多く、アクセスが散る |
| 作成日を日付で丸めた値 | 悪い | その日の書き込みが 1 か所に集中する |
| 状態コード (数種類しかない) | 悪い | 値の種類が少なく、分割の意味が無い |
| 端末 ID (1 台だけ突出して使われる) | 悪い | 種類は多いが、アクセスが 1 つに偏る |
この分類は DynamoDB のパーティションキー設計の指針と同じ形です (Amazon DynamoDB — Designing partition keys to distribute your workload)。**値の種類の多さと、アクセスの散り方は別々に確認します。**種類が多くても 1 つに集中すれば偏ります。
特定の値だけが突出して読まれる場合 (人気商品、著名なアカウント) は、その値に接尾辞を足して複数のシャードへ散らす手が使えます。代わりに、その値を読むときは複数シャードから集める必要があります。
結果整合性
複製や分割をまたぐと、すべての台がいつも同じ値を持っている状態は保てなくなります。代わりに成立するのが「更新が止まれば、いずれ全部が同じ値に落ち着く」という保証です。これを結果整合性と呼びます。
問題は「いずれ」の間に何が見えるかです。利用者に見える形で現れます。
- 投稿した直後に一覧を開くと自分の投稿が無い
- いいねを押した数が、画面を更新するたびに増えたり減ったりする
- 決済は完了したのに、購入履歴にまだ出ない
設計で決めることは 2 つです。どの操作なら遅れが許されるかと、遅れているあいだ利用者に何を見せるか。3 つ目の例のようにお金が絡む表示は許されないことが多く、そこだけプライマリを読む設計にします。
CAP 定理 — 分断が起きたとき何を諦めるか
分散したデータについて、避けられない制約があります。CAP 定理です。
3 つの性質を言います (Gilbert and Lynch, "Perspectives on the CAP Theorem", 2012)。
| 性質 | 意味 |
|---|---|
| 一貫性 (Consistency) | 各操作が、要求と応答の間のある 1 つの瞬間に起きたかのように見える。1 台のサーバーが順に処理しているのと区別できない |
| 可用性 (Availability) | 動いているノードが受けたリクエストは、いずれ応答を返す。速さは問わない |
| 分断耐性 (Partition tolerance) | ノード間の通信が遅延したり失われたりしても動き続ける |
定理が言うのは、通信障害が起きうるネットワークでは、すべてのリクエストに応答しながら一貫性を保つことはできないということです (同論文 §2)。
ここで重要なのは読み方です。「3 つから 2 つを選ぶ」という説明が広まっていますが、実務ではそう選びません。分断は選べるものではなく、起きるものだからです。ネットワークは切れます。
だから実際の選択はこうなります。
分断が起きているあいだ、一貫性と可用性のどちらを諦めるか。
| 諦めるもの | 分断中の振る舞い | 向く用途 |
|---|---|---|
| 可用性 | 通信できない側は応答を拒む。エラーになるが、古い値は返さない | 残高、在庫、予約 |
| 一貫性 | 両側とも応答する。古い値が返る可能性があり、復旧後に食い違いを解消する | 閲覧数、通知、履歴 |
**同じシステムの中で機能ごとに選べます。**残高照会は拒み、おすすめ表示は古い値を返す、という設計は矛盾しません。
分断が起きていないときは、どちらも満たせます。CAP が効くのは分断中だけで、平常時の設計を縛るものではありません。
判断の手順
- 何が足りないかを決める — 読み取りの量なら複製、容量と書き込みの量なら分割
- 失ってよいものを決める — 1 件も失えないなら同期。遅延を許せるなら非同期
- 読み取りをどこへ振るか決める — レプリカへ振るなら、書いた直後の読み取りをどう扱うかまで決める
- 分割するなら、キーを検索の形から選ぶ — よく使う検索が 1 つの断片に収まるか
- キーの偏りを確認する — 値の種類の多さと、アクセスの散り方を別々に見る
- 遅れが見える箇所を洗い出す — 利用者に何が見えるか。お金が絡む表示はプライマリを読む
- 分断中の振る舞いを機能ごとに決める — 拒むのか、古い値を返すのか
よくある誤解
「レプリカを増やせば書き込みも速くなる」 — 速くなるのは読み取りだけです。書き込みは 1 か所で受けるうえ、**レプリカを増やすほど変更を送る相手が増えます。**書き込みの量が問題なら分割を検討します。
「同期レプリケーションなら遅れは無い」 — 2 つ違います。ひとつは、待つ範囲が設定で決まること。PostgreSQL なら待つ相手の指定が要りますし、指定しても既定では永続化までで、レプリカのクエリから見えるまでは待ちません。書いた直後にレプリカを読んで古い値が返ることは、同期でも起こりえます。もうひとつは、待つぶん書き込みが遅くなること。レプリカが遅い日は書き込み全体が遅くなり、レプリカの障害が書き込みの障害になる構成にもなりえます。
「CAP 定理は 3 つから 2 つを選ぶ」 — 分断は選べません。選ぶのは、分断が起きているあいだの振る舞いです。平常時に一貫性と可用性の両方を満たすことは、この定理と矛盾しません。
「結果整合性は一貫性が無いということ」 — 「いずれ揃う」という保証があります。無いのは「いつ揃うか」の約束です。問題になるのは、揃うまでの間に利用者が何を見るかであって、永久に食い違うわけではありません。
「分離レベルを上げればレプリカの遅れも解決する」 — 別の層の話です。分離レベルは 1 台の中の同時実行の見え方を決めます。レプリカの遅れはその外側にあります。
確認問題
問 1. 「投稿した直後に一覧を開くと、自分の投稿が表示されないことがある」と報告されました。構成は非同期レプリケーションで、読み取りはレプリカへ振っています。原因と対処を説明してください。
**原因は、書き込みがレプリカへ届く前に読み取りが走ったことです。**バグではなく、非同期レプリケーションの構成どおりの動作です。
対処の候補は 3 つです。
| 対処 | 効き方 | 代償 |
|---|---|---|
| 投稿直後の一定時間、その利用者の読み取りをプライマリへ振る | 確実に解決する | プライマリの読み取り負荷が増える |
| 投稿した内容を画面側で一覧の先頭に足す | 表示上は解決する | 他の要素とのずれが残りうる |
| レプリカの遅れを小さくする | 頻度が下がるだけ | ゼロにはならない |
1 つ目が本筋です。「自分が書いたものは自分には見える」という性質だけを満たせばよく、他人の投稿の遅れは許容できることが多いためです。
問 2. 利用者ごとにデータが閉じたサービスで、シャーディングの分割キーに「作成日」を選ぼうとしています。何が問題ですか。
2 つの問題があります。
- **書き込みが 1 つのシャードに集中します。**今日作られるデータはすべて同じ断片へ行きます。分割したのに、書き込みを分散できていません
- よく使う検索が分割をまたぎます。「この利用者のデータを全部出す」という検索が、その利用者の登録期間ぶんのシャードすべてに問い合わせることになります
**利用者 ID を選ぶのが素直です。**値の種類が多く、アクセスが散り、よく使う検索が 1 つのシャードに収まります。
ただし利用者 ID でも、**1 人だけ突出して大きい利用者がいると偏ります。**種類の多さとアクセスの散り方は別々に確認します。
問 3. 「CAP 定理があるので、可用性を取って一貫性は諦めます」と設計方針に書かれていました。この記述の何を直しますか。
適用の範囲と条件を書き足します。
この記述には 2 つの不足があります。
- **いつの話かが書かれていません。**CAP が効くのは分断が起きているあいだです。平常時は両方を満たせます。「常に一貫性を諦める」と読めてしまいます
- **どの機能の話かが書かれていません。**選択はシステム全体で 1 つに決める必要がありません。残高は拒み、閲覧数は古い値を返す、という機能ごとの設計ができます
書き直すなら、こうなります。
ノード間の通信が途絶しているあいだ、閲覧数と通知は古い値を返して応答を継続する。残高と在庫の参照はエラーを返し、古い値を返さない。
**こう書くと、実装で決めるべきことが明確になります。**どちらを選ぶかではなく、機能ごとにどちらかを決めることが設計です。
まとめ
- 分け方は 2 つ。レプリケーションは読み取りと継続性を、シャーディングは容量と書き込みを解きます
- 同期は遅くなり、非同期はプライマリの障害で未送信分を失います
- 複製先を読むと書いた直後に読めません。用途ごとに対処を決めます
- レプリカの遅れは分離レベルとは別の層にあります。片方を強めてももう片方は解決しません
- 分割キーは値の種類が多く、アクセスが散るものを選びます。日付で丸めた値は書き込みを 1 か所に集めます
- 結果整合性で問題になるのは「揃うまでに何が見えるか」です。お金が絡む表示はプライマリを読みます
- CAP は「3 つから 2 つ」ではありません。分断中に一貫性と可用性のどちらを諦めるかを、機能ごとに決めます
- トランザクション — 原子性が 1 台の中に閉じている前提
- ロックと分離レベル — 1 台の中の同時実行の見え方
- PostgreSQL — Comparison of Different Solutions — 同期と非同期で満たせる性質の対比
- Gilbert and Lynch — Perspectives on the CAP Theorem — 3 つの性質の定義と定理の主張
- Amazon DynamoDB — Designing partition keys — 分割キーの良し悪しの分類
次に読む
- 構成図とトレードオフ — ここまでの選択を図と言葉で説明する
- トランザクション — 1 台の中で原子性がどう保たれるか