100人が同時に書き換えても壊れないのはなぜか:データベースという「同時の管理者」【第7回】
残高が10,000円の口座から、2人が同時に8,000円を引き出そうとしたら何が起きるべきか。答えは「片方だけが成功する」です。ところがこの当たり前を守るには、仕組みが要ります。この記事を読み終えると、トランザクションが何を守っているのかを説明でき、N+1問題・デッドロック・索引の使いどころを、自分で判断できるようになります。ジュニアエンジニア向けコンピュータサイエンス入門シリーズ(全8回)の第7回です。
🎯 テーマの主役:「同時実行の管理者としてのデータベース」
今回の主役はデータベースです。一言で言えば、大量のデータを壊さずに保存し、多数の利用者からの同時アクセスを交通整理する仕組みです。
日常の例えで言うなら、銀行の窓口です。窓口が1つしかなく、行列ができているとします。窓口係は1人ずつ順番に対応するので、同じ口座の残高を2人で同時に書き換える事故は起きません。しかし1人ずつでは遅すぎます。そこで複数の窓口を開ける。すると今度は、同じ口座を見ている2人が同時に手続きを進めてしまう危険が出ます。だから「この口座は今使っています」という札を立て、終わったら外す。これがデータベースのやっていることです。
第4回で「ファイルを開くのは番号札を取ること」「書き込みはすぐにはディスクに届かない」を扱いました。第5回で「索引(B-tree)は先払いの投資」を扱いました。第6回で「往復の回数が総時間を決める」を扱いました。今回、その3つが一本につながります。データベースは、ファイル(第4回)の上に、索引(第5回)を載せ、ネットワーク越しの往復(第6回)を最小化するために存在する仕組みです。
この仕組みを理解すると、次の4つができるようになります。第一に、ACID という言葉が何を約束しているのかを説明できること。第二に、N+1問題がなぜ遅いのかを、往復回数から計算できること。第三に、索引がなぜ読み取りを速くし、書き込みを遅くするのかを説明できること。第四に、デッドロックや「更新が消える」事故に遭遇したとき、何が起きているかを読み解けることです。
ACID の4つの性質を、守られている内容と「これがないと何が起きるか」で整理します。4つはどれも、破れたときの姿から逆算すると理解しやすい性質です。
| 性質 | 約束 | これがないと起きること | 誰が守っているか |
|---|---|---|---|
| A:原子性(Atomicity) | 全部成功か、全部失敗か。中間状態を残さない | 引き落としだけ成功して入金が消える | ログ(何をしようとしたかの記録)と巻き戻し |
| C:一貫性(Consistency) | 宣言した制約を常に満たす | 残高がマイナス、合計が合わない | 制約(NOT NULL・UNIQUE・外部キー)と型(第2回) |
| I:独立性(Isolation) | 同時に実行しても、順番に実行したのと同じ結果になる | 片方の更新が消える、読んだ値が食い違う | ロックとバージョンの管理 |
| D:永続性(Durability) | 確定した変更は失われない | 電源断で「入金したはずのお金」が消える | ディスクへの同期(fsync。第4回) |
😓 動機:「たまに数字が合わない」の原因が、コードにない
データベースのトラブルは、コードを読んでも分からないという特徴があります。ロジックは正しい。テストも通る。それでも本番でだけ、数字が合わない。
よくある場面を4つ挙げます。ひとつ目は、同じ処理を2回実行したら二重に登録された(リトライしただけなのに)。ふたつ目は、同時に操作したら片方の更新が消えた。みっつ目は、アプリが突然止まる。ログに「デッドロック」と出ているが、何が起きているのか分からない。よっつ目は、一覧画面が異常に遅い。1件ずつ取っているらしいが、件数が多いと数十秒かかる。
これらはすべて、「同時実行」と「往復回数」という2つの軸で説明できます。そして厄介なことに、どちらもテストでは再現しにくい。同時実行の事故はタイミング次第でしか起きませんし、往復回数の問題はデータ量が少ないうちは見えません。
そして、この知識はAI時代に重要性が上がっています。AIが生成するコードは、ループの中で1件ずつクエリを投げる実装をよく出してきます(これがN+1問題です)。動きますし、テストも通ります。本番のデータ量になって初めて、往復回数が秒数として露呈します。
🧪 仮説:データベースの難しさは「同時」と「距離」の2つに集約される
仮説を立てます。データベースを使う側のトラブルは、ほぼ「同時実行の扱い」と「往復回数」の2つに分類できる。前者はACIDで守られ、後者は自分で設計する。
この仮説を支持する観察が3つあります。第一に、数字が合わない系のトラブルは、すべて独立性(Isolation)の理解不足から来ます。第二に、遅い系のトラブルは、ほぼ往復回数の問題です。第三に、この2つは対策が正反対です。同時実行の問題は「正しさを守るために待たせる」ことで解決し、遅さの問題は「待ちを減らすために往復を減らす」ことで解決します。待たせるか、待たせないか。この軸を意識すると、判断がぶれなくなります。
🔬 検証①:更新が消える瞬間を、順番に追う
まず、独立性(Isolation)が破れると何が起きるのかを、具体的に追います。題材は残高10,000円の口座に、2人が同時に8,000円を引き出す操作です。
正しい手順は、①残高を読む → ②足りるか確認する → ③減額して書き戻す。この3ステップを2人が同時に実行すると何が起きるか。
この現象をロストアップデート(更新の消失)と呼びます。ポイントは、アプリのコードが間違っていないことです。①読む→②確認→③書く、という手順は人間には自然です。問題はその3ステップの間に、他の人の手が入り込めることです。
回避策は2つあります。 第一に、読み取りから書き込みまでを1つのまとまり(トランザクション)として扱い、その間ロックをかける。後から来た人は待たされます。第二に、条件を書き込み文に含める。「残高を2,000に更新する」ではなく「残高が8,000以上のときだけ、残高を8,000減らす」。データベースが1つの操作として処理するので、間に割り込む隙がありません。
2つ目のほうが優れている理由は、待ち時間が生まれないことです。1つ目は順番待ちが発生しますが、2つ目は各操作が独立して進みます。「読んでから書くを1つにまとめる」という発想は、覚えておいて損がありません。
🔬 検証②:独立性の段階——待たせるか、待たせないか
次に、独立性(Isolation)の強さの段階を見ます。ここがこの記事でいちばん重要な部分です。
理想は「同時に実行しても、順番に実行したのと同じ結果になる」ことです。これを完全に守ろうとすると、すべての操作を完全に順番待ちにするしかありません。しかしそれでは並行度が落ちて遅くなります。だから実際のデータベースは段階を用意しています。
4つの段階を、許される現象と用途で整理します。名前を覚えるより「何を許すか」で捉えるのが実用的です。
| 段階 | 防げる現象 | まだ起きうる現象 | 向いている用途 |
|---|---|---|---|
| コミット前読み取り (最も緩い) | — | 確定前の値が読める、値が途中で変わる、行が増減する | 厳密さが不要な概算・モニタリング |
| コミット済み読み取り (多くのDBの既定) | 確定前の値が読める問題 | 同じ行を2回読むと値が変わる、行が増減する | 一般的な業務処理。多くの場面で十分 |
| 反復可能読み取り | 上記+読み直しの食い違い | 条件に合う行の増減(他の人の追加・削除) | 同一トランザクション内で集計を繰り返す処理 |
| 直列化可能 (最も厳しい) | 上記のすべて | —(代わりに待ちや再試行が増える) | 金額・在庫・予約枠の割り当て |
ここで3つの実務的なポイントがあります。第一に、既定値は「厳しすぎない」ことが多い。多くのデータベースの既定は「コミット済み読み取り」です(MySQLのInnoDBは「反復可能読み取り」が既定で、こちらは条件に合う行の増減も防ぎます)。これは性能を優先した妥当な選択ですが、金額や在庫を扱う処理では足りないことがあります。第二に、「読むだけの処理」に厳しい設定を使うのは無駄です。集計レポートは多少古くてもよいので、緩い設定で速く回すべきです。第三に、厳しくすると「待ち」と「再試行」が増えます。直列化可能にすると、衝突したトランザクションをやり直させる必要が出ます。だからアプリ側に「やり直す」前提の設計が要るのです。
「やり直す前提の設計」と聞いて、第6回の冪等性を思い出してください。リトライされる可能性があるなら、同じ操作を2回実行しても安全でなければいけません。たとえば「入金する」ではなく「この取引IDで入金する」という形にしておけば、2回実行しても1回しか効きません。第6回のリトライの話と、ここでの再試行の話は、同じ問題です。
🔬 検証③:デッドロック——お互いを待ち合う
次に、デッドロックを扱います。これは「2人以上が、互いが持っているものを待ち続けて、誰も進めなくなる」状態です。
日常の例えで言うなら、狭い通路で2人がすれ違えない状態です。Aさんが「先にそっちが譲れ」と言い、Bさんも「そっちが譲れ」と言う。どちらも動かないので、永遠に止まります。
データベースでは、ロックを取る順番が食い違うと発生します。Aさんが「行1をロック → 行2をロック」の順で、Bさんが「行2をロック → 行1をロック」の順で進むと、Aさんは行2を待ち、Bさんは行1を待ちます。
| 時刻 | Aさん | Bさん | 状態 |
|---|---|---|---|
| 1 | 行1をロック(成功) | — | 正常 |
| 2 | — | 行2をロック(成功) | 正常 |
| 3 | 行2をロックしようとする → 待つ | — | Aさんが待機 |
| 4 | — | 行1をロックしようとする → 待つ | デッドロック |
| 5 | データベースが片方を強制的に失敗させる | 片方はやり直しになる | |
重要なのは、対処法が3つとも「待たない・順番を揃える」という同じ方向を向いていることです。
第一に、ロックを取る順番を統一する。 すべての処理で「行1→行2」の順に統一すれば、待ち合いの輪ができません。最も確実で、最も基本的な対策です。
第二に、1つの操作でまとめて処理する。 検証①で見た「条件を書き込み文に含める」発想です。行を1つずつロックせず、1文で更新すれば、間に割り込む隙がありません。
第三に、データベースの検知に任せる。 多くのデータベースはデッドロックを検知して、片方を強制的に失敗させます。アプリ側は「失敗したらやり直す」前提で書く必要があります。ここでも冪等性が効きます。
そして、トランザクションを短くすることが最大の予防策です。長くロックを保持するほど、衝突の確率が上がります。「トランザクションの中に外部通信を入れない」は鉄則です。第6回で見たとおり、外部呼び出しは数十ミリ秒から数秒かかります。その間ずっとロックを保持すれば、その行を触りたい全員が待たされます。
🔬 検証④:索引——読み取りを速くし、書き込みを遅くする
第5回で「索引を持つのは、後で速く引くための前払い」と書きました。この「前払い」が、データベースでは書き込み側の負担として現れます。
索引がないと、データベースは全件走査します。第5回の用語でいえば O(n) です。100万件から1件を探すのに、100万行を読む必要があります。索引があれば O(log n) で、20回程度の比較で済みます。読み取りは桁で速くなります。
一方で、書き込みには負担がかかります。行を追加・更新・削除するたびに、索引も更新しなければならないからです。B-tree(第5回)の性質上、挿入は O(log n) の手間がかかり、ページの分割が起きるとさらに重くなります。
この構造から、実務の判断基準が出ます。「読み取りが多いか、書き込みが多いか」で決める。読み取りが大半を占める画面(一覧・検索)には索引が効きます。逆に、1件ごとに大量に追加する処理では、索引を増やすと遅くなります。
| よくあるアンチパターン | 何が起きているか | なぜ問題か | 打ち手 |
|---|---|---|---|
| 全列に索引を張る | 書き込みのたびに多数の索引を更新する | 書き込みが数倍遅くなる。記憶領域も膨らむ | 実際に検索条件で使われる列だけに絞る |
| 索引列に関数を適用して検索する | 索引が使えず全件走査になる | 索引があるのに効かない | 関数を使わない形に書き換える(範囲で絞るなど) |
| 先頭が一致しない部分一致検索 | 索引の並び順が使えない | 件数に比例して遅くなる | 前方一致にする、専用の検索機能を使う |
| 型が合わない比較をする | 暗黙の型変換が起きて索引が効かない | 第2回の型の話が、性能の問題として現れる | 型を揃える |
4行目は、第2回の話が効いてくる場面です。数値の列を「文字列として」検索すると、型変換が起きて索引が使えなくなることがあります。型を揃えることは、正しさだけでなく速度の問題でもあるのです。
🔬 検証⑤:N+1問題——第5回と第6回の合流点
ここが本記事のクライマックスです。N+1問題を、第5回(計算量)と第6回(往復回数)の両方から説明します。
シナリオはこうです。100件の注文一覧を表示したい。各注文には顧客情報が必要です。
まず、計算量の観点で見ます。 注文を1回のクエリで取ります(1回)。次に、各注文について顧客を取ります(100回)。合計で101回のクエリ。これが「N+1」の名前の由来です。注文が1,000件になれば1,001回。第5回の用語でいえば、データ量に対して線形に増える O(n) のクエリ回数です。
次に、往復回数の観点で見ます。 第6回で「総時間 = 往復の回数 × 1往復の時間」を学びました。同じデータセンター内なら1回の往復が1ms程度なので、101回で約0.1秒。これなら許容範囲に見えます。
ところが、データベースが別のマシンにある場合はどうでしょうか。往復が1msではなく5msなら、101回で約0.5秒。アプリとデータベースが大陸をまたいでいれば、往復100msで約10秒です。
ここが本記事の要点です。 N+1問題は「計算量の問題」であると同時に「往復回数の問題」です。そして往復1回あたりの時間は、環境によって100倍変わります。だから同じコードが、開発環境では問題なく、本番では致命的になります。
| 往復1回の時間 | 101回の合計 | 1回にまとめた場合 | 差 |
|---|---|---|---|
| 1ms(同一データセンター・ローカル) | 約0.1秒 | 数十ms | 小さい |
| 5ms(同一リージョンの別マシン) | 約0.5秒 | 数十ms | 約10倍 |
| 30ms(同一国内) | 約3秒 | 数十ms | 約60倍 |
| 100ms(大陸間) | 約10秒 | 数十ms | 約200倍 |
打ち手は3つあります。 第一に、結合して1回で取る。第二に、必要な分をまとめて取得する(「顧客IDの一覧を渡して、該当する顧客をまとめて返す」)。第三に、あらかじめ読み込んでおく(第3回で扱った先読み・キャッシュの発想です)。
そして厄介な点を挙げておきます。N+1はコードの見た目が自然なのです。「注文の一覧をループして、各注文の顧客を取る」は、人間が読むと素直なコードです。データベースの用語で「結合」と言われると身構えますが、やっていることは「まとめて取ってから、アプリ側で組み立てる」だけです。自然に見えるコードほど、往復を数える習慣が必要です。
📊 結果:症状から原因を引く
ここまでの内容を、切り分けの形にまとめます。「同時実行の問題か、往復回数の問題か、索引の問題か」を先に決めるのがポイントです。
| 症状 | 疑う原因 | 確認するもの | 打ち手 |
|---|---|---|---|
| 数字が合わない・更新が消える | 独立性の不足 | 読み→確認→書きの間に他の処理が入れるか | 条件付き更新にする、独立性の段階を上げる |
| リトライで二重登録される | 冪等性の欠如 | 同じ操作を2回実行したらどうなるか | 取引IDで重複を防ぐ(一意制約) |
| アプリが突然止まり「デッドロック」と出る | ロックの順番の食い違い | 複数の行を更新する順番 | 順番を統一する、1文にまとめる、短くする |
| 一覧画面が異常に遅い | N+1 | 1画面あたりのクエリ回数と件数 | 結合する、まとめて取る、先読みする |
| 件数が増えるほど遅くなる | 索引が効いていない | 検索条件の列に索引があるか、関数適用していないか | 索引を張る、条件の書き方を変える |
| 書き込みが遅い | 索引の張りすぎ | 1つの表にいくつ索引があるか | 使われていない索引を削る |
| トランザクションが長く、全体が待たされる | ロック保持が長い | トランザクションの中に外部通信が入っていないか | 外部通信を外に出す。処理を小さく分ける |
特に7行目は、最も多く見る設計ミスです。トランザクションの中で外部APIを呼ぶと、そのAPIの応答時間ぶんだけロックが保持されます。第6回で見たとおり、外部呼び出しは数十ミリ秒から数秒かかります。その間、同じ行を触りたい全員が待つ。「トランザクションの中では外部と話さない」は、覚えておく価値のある鉄則です。
💭 考察:データベースは「同時」を扱う初めての階である
ここまでの話を一段深く掘ります。第1回から数えてきた階のうち、データベースは初めて「複数の主体が同時に動く」ことを正面から扱う階です。
メモリ(第3回)もOS(第4回)も、扱う主体は本質的に1つでした。プロセスは複数ありますが、仮想メモリのおかげで互いに見えません(第4回)。ところがデータベースは違います。複数の利用者が、同じデータを見て、同時に書き換えます。見えないようにするわけにはいきません。共有していることが前提なのです。
この違いが、データベース特有の難しさを生みます。第4回までは「分ける」ことで問題を解決しました。データベースは「共有する」ことを前提に、それでも壊れない仕組みを作る必要があります。分離ではなく調停。これが新しい問題設定です。
ここから3つの深い見方が出ます。第一に、正しさの基準が「同時に実行しても順番に実行したのと同じか」になるということです。これは観測可能な振る舞いの等価性という考え方で、並行処理(第8回)とまったく同じ基準です。
第二に、データベースの機能は「速度と正しさのトレードオフを、選べる形で提供している」ということです。独立性の段階(検証②)がその代表で、緩ければ速く、厳しければ遅い。そして既定値は「そこそこ」に置かれている。自分が何を選んでいるかを知っているかどうかが、設計者の力量になります。
第三に、N+1問題は「見た目の素直さ」の問題だということです。遅いコードは往復回数で決まりますが、そのコードは往復しているようには見えません。order.customer という1行が、裏で1往復を発生させている。抽象が漏れる典型例です(第1回)。しかも漏れるのは本番のデータ量と距離の下でだけ。
そして、ここで全8回を貫く原則がもう1つ見えてきます。第2回で「型を選ぶとは将来の壊れ方を選ぶこと」、第3回で「データの置き場所を選ぶこと」、第5回で「データ構造を選ぶとは手間の増え方を選ぶこと」。今回も同じです。独立性の段階を選ぶとは、同時実行のときに何を許すかを選ぶこと。索引を選ぶとは、読み取りと書き込みのどちらを優先するかを選ぶこと。データベースの設計は、すべて「何を犠牲にするか」の選択なのです。
📌 注目ポイント
この記事の核心を4点に絞ります。
第一に、独立性(Isolation)は「読んでから書く」を1つにまとめることで守られます。 3ステップに分かれていると、その隙間に他の人の手が入ります。条件を書き込み文に含めるのが最も簡単で強力な対策です。
第二に、独立性には段階があり、緩めるほど速く、厳しくするほど待ちます。 既定値は「そこそこ」です。金額・在庫は厳しく、集計・レポートは緩く。用途で選ぶべきもので、常に厳しくすればよいわけではありません。
第三に、索引は読み取りと書き込みのトレードオフです。 読み取りは O(n) から O(log n) になり、書き込みはその分だけ遅くなります。実際に使われる列にだけ張るのが原則です。
第四に、N+1問題は「計算量」と「往復回数」の両方の問題です。 101回の往復は、同一データセンターなら0.1秒、大陸間なら約10秒。同じコードが環境で100倍変わります。
💡 活用事例:登録と更新を分けるという設計
ここまでの話が現実の設計でどう現れているかを見ます。イベントソーシングという設計手法です。
通常のデータベース設計では、現在の状態を上書き保存します。「残高は2,000円です」という行を持ち、更新のたびに書き換えます。ところがこの方式には弱点があります。「なぜその値になったのか」が失われるのです。誰がいつ何をして、その結果こうなったのかは、記録が残っていなければ分かりません。
イベントソーシングは、状態ではなく出来事を記録します。「入金8,000円」「出金8,000円」という事実を追記していく方式です。現在の残高は、記録された事実を積み上げて計算します。会計の帳簿と同じ発想です。
この設計からジュニアエンジニアが持ち帰れる教訓は3つあります。第一に、「上書き」と「追記」は別の設計であり、それぞれに強みと代金があります。第二に、追記だけの設計は同時更新に強い。過去を書き換えないので、ロックの衝突が起きにくいのです(第4回で扱った「ログは追記型」と同じ性質です)。第三に、現在の値が欲しいときは、追記から計算するか、別に保持するかの選択が生まれます。前者は正しさに強く、後者は速さに強い。また同じトレードオフです。
そして、この設計は第6回の冪等性とも噛み合います。「この取引IDの入金」という記録を残す設計なら、同じ記録が2回来ても1つしか受け付けないようにできます(一意制約)。リトライしても壊れない設計が、自然に得られるのです。
✅ 要点まとめ
読み終えたあなたが持ち帰るべきエッセンスを、6つに圧縮します。
- ACIDの4性質はすべて「破れたときの姿」から理解できる。原子性(半分だけ)・一貫性(ルール違反)・独立性(更新消失)・永続性(消える)
- 独立性は「読んでから書く」を1つにまとめることで守る。条件を書き込み文に含めるのが最も簡単で待ちが少ない
- 独立性には段階がある。緩めれば速く、厳しくすれば待つ。金額・在庫は厳しく、集計は緩く
- デッドロックはロックの順番の食い違いで起きる。順番を統一し、トランザクションを短くする。再試行に備えて冪等にする
- 索引は読み取りを
O(log n)に、書き込みを遅くする。実際に使われる列にだけ張る - N+1は往復回数の問題。101回の往復は、同一データセンターで0.1秒、大陸間で約10秒。同じコードが環境で100倍変わる
🚀 取り込み方
「明日から使うには何をすればいいか」を、期間ごとに分けて示します。
今日(5分でできること)
自分の担当アプリが1画面で何回クエリを投げているかを確認してください。回数を数えるだけで、この記事の話が自分の環境の話になります。
- アプリのログで、1リクエストあたりのSQL文の数を数える(多くのフレームワークはSQLのログを出せます)
- データベース側で、実行されたクエリの一覧を見る(PostgreSQLなら
pg_stat_statements、MySQLならスロークエリログとperformance_schema)
「一覧画面で数十〜数百回」という結果が出たら、それがN+1です。
今週(小さく試す)
担当コードから、次の4つを探してください。(1) ループの内側でクエリを投げている箇所、(2) トランザクションの中に外部API呼び出しがある箇所、(3) 「読んで確認して書く」の3ステップになっている更新、(4) 索引のない列で検索している箇所。見つけたら、すぐ直さずに「同時に実行されたら何が起きるか」「件数が100倍になったらどうなるか」を1行メモしてください。これが、レビューで指摘できる根拠になります。
今月(業務に組み込む)
チームに次の3点を提案できないか検討してください。第一に、一覧系の処理ではクエリ回数を数えて記録する(レビューの観点に加える)。第二に、金額・在庫・予約を扱う処理では独立性の段階を明示する(既定値に任せない)。第三に、外部呼び出しをトランザクションの外に出す。どれも「同時と距離を意識する」という1つの姿勢にまとまります。特に第一は効果が測定しやすく、合意も取りやすいので、最初の一歩に向いています。
🔥 ハマりポイント
つまずきやすい5つの落とし穴を、「〜と思いがちだが、実は〜」の形で整理します。
その1:読み書きは順番に実行されると思いがちだが、実は間に他人が入れる
症状は、たまに更新が消えること。原因は、「読む→確認→書く」の3ステップの隙間に他のトランザクションが入り込めること。対処法は、条件を書き込み文に含める、あるいは読み取り時にロックを取ることです。「読んでから書く」パターンを見たら、反射的に疑ってください。
その2:トランザクションは短いほうがよいと知っていても、外部通信を入れてしまいがち
症状は、全体が待たされて処理が詰まること。原因は、外部APIの応答を待つ間もロックを保持していること。対処法は、外部通信をトランザクションの前に済ませるか、結果を先に集めてから一括で更新することです。第6回で見たとおり、外部呼び出しは数十ミリ秒から数秒。その間、同じ行を触れません。
その3:リトライは安全だと思いがちだが、実は二重登録を生む
症状は、タイムアウト後に再送したら2件登録されたこと。原因は、最初のリクエストが実は成功していたのに、応答が届かなかったこと。対処法は、操作を冪等にすることです。取引IDを発行し、一意制約で重複を防ぐ。第6回で扱ったリトライの話が、そのままデータベースの設計問題になります。
その4:索引は多いほうが速いと思いがちだが、実は書き込みが遅くなる
症状は、参照は速いのに登録が遅いこと。原因は、書き込みのたびに全索引を更新していること。対処法は、実際に使われている索引を確認して削ることです。データベースによっては「使われていない索引」を調べる手段があります。不要な索引は、書き込みのたびに税金を払わせているのと同じです。
その5:ORMを使えばSQLを意識しなくてよいと思いがちだが、実はN+1を隠す
症状は、order.customer.name のような自然な1行が大量のクエリを生むこと。原因は、ORMが遅延読み込み(実際に触れた瞬間に取りに行く方式)を使っていること。対処法は、明示的にまとめて読み込むよう指示すること(Eager Loading・結合・一括取得)。抽象は漏れる(第1回)の、最も典型的な例です。
🔄 比較:5つの対策と、それぞれが犠牲にするもの
最後に、この記事で扱った対策を「何を得て、何を払うか」で整理します。すべてに代金があることが見えるはずです。
| 対策 | 得るもの | 払うもの | 向いている場面 |
|---|---|---|---|
| 独立性の段階を上げる | 同時実行でも矛盾しない | 待ち時間の増加と再試行 | 金額・在庫・予約枠 |
| 条件付き更新 | 待ちなしで正しさを保つ | 書き方の工夫。複雑な条件は書きにくい | カウンタ、在庫の減算、状態遷移 |
| 索引を張る | 読み取りが O(log n) になる | 書き込みの遅化と記憶領域 | 検索・絞り込みが多い表 |
| まとめて取得する(N+1回避) | 往復回数が件数に依存しなくなる | クエリが複雑になる。余分なデータを取ることも | 一覧画面、階層の展開 |
| 追記のみの設計 | 経緯が残り、衝突しにくい | 現在値の計算コスト、記録の増加 | 監査が必要な業務、状態遷移の多い処理 |
この表から持ち帰ってほしいのは、「どの対策も『ただ速くする』ものではない」ということです。必ず何かを差し出しています。だからこそ、何を優先するかを自分で決める必要がある。これが、データベース設計という仕事の中身です。
📅 今後の展望
データベースは、これからどうなるのでしょうか。方向性は3つ考えられます。
第一に、分散が既定になる方向です。1台で捌けなくなったデータを、複数のマシンに分けて置く設計が広がっています。ただし第6回で見たとおり、マシンをまたぐと往復が発生します。すると「強い一貫性を保つか、速さを取るか」という選択が、データベースの外側の設計問題として現れます。CAP定理として知られるこのトレードオフは、近年さらに実務的な重みを増しています。
第二に、独立性の設計がアプリ側に降りてくる方向です。「どのトランザクションがどこまで厳密であるべきか」は、アプリケーションの要件から決まるからです。だから開発者がデータベースの設定を理解している必要が増しています。既定値に任せるという選択肢は、少しずつ狭くなっています。
第三に、監査と説明責任の要求が強まる方向です。「なぜこの値になったのか」を説明できる設計(追記型・監査ログ)の価値が上がっています。これは技術の問題であると同時に、制度や法令の問題でもあります。第1回で「下の階ほど長寿」と書きましたが、ACIDとトランザクションの考え方は、1970年代から1980年代に確立されたものです。ハードウェアが何世代も変わるなかで、「同時実行をどう調停するか」という問いは変わっていません。
🗺️ 次回予告:最後に残った、いちばん厄介な問題
第7回では「同時実行」と「距離」という2つの軸でデータベースを読み解きました。そして、同時に書き換えると壊れるという問題が、調停の仕組みで解決されていることを見ました。
ところが、この問題はデータベースの中だけの話ではありません。アプリケーションのコードの中にも、複数の処理が同時に動く場面があります。スレッド、非同期処理、複数のリクエストを並行して受けるサーバー。同じメモリを2つの処理が同時に触ったら何が起きるか。
第8回(最終回)は並行と並列を扱います。第7回で見た「更新が消える」問題が、もっと小さなスケールで、もっと見えにくい形で再現します。await を付け忘れた1行が、なぜ本番でだけ壊れるのか。レース条件とは何か。非同期と並列は何が違うのか。そして、それでも同時に動かしたいときに、どう設計するか。
第7回の知識が、そのまま最終回の土台になります。データベースという大きな管理者が守ってくれたものを、今度は自分で守る番です。
まとめ
この記事を読んだあなたは、ACID を4つの単語として暗記せず、「破れたときの姿」から説明できるようになります。そして、更新が消えた現場を見たら「読んでから書くを分けたな」と気づき、一覧画面が遅ければクエリの回数を数えるようになります。
そして、データベースの設計判断が、すべてトレードオフの選択であることが見えてきます。厳しさ、索引、まとめ方、記録の持ち方。どれも「何かを差し出して何かを得る」取引です。データベースは、多数の利用者が同時に触る世界で、壊れないための交通整理をしてくれています。その整理の仕組みを知っている人は、「なぜ壊れたか」だけでなく「次にどう設計するか」まで語れます。それが、設計を任される側に回るための、最初の一歩です。
参考文献
- Jim Gray, Andreas Reuter, “Transaction Processing: Concepts and Techniques”, Morgan Kaufmann, 1992(トランザクション処理の原典的教科書) — https://www.sciencedirect.com/book/9781558601901/transaction-processing
- Theo Härder, Andreas Reuter, “Principles of Transaction-Oriented Database Recovery”, ACM Computing Surveys, 1983(ACID という用語を広めた論文) — https://dl.acm.org/doi/10.1145/289.291
- H. Berenson, P. Bernstein, J. Gray, J. Melton, E. O’Neil, P. O’Neil, “A Critique of ANSI SQL Isolation Levels”, ACM SIGMOD, 1995(独立性の段階と、それぞれで起きうる現象の整理) — https://www.microsoft.com/en-us/research/publication/a-critique-of-ansi-sql-isolation-levels/
- ISO/IEC 9075(SQL標準。独立性の段階の定義を含む) — https://www.iso.org/standard/76583.html
- PostgreSQL Global Development Group, “PostgreSQL Documentation: Transaction Isolation” — https://www.postgresql.org/docs/current/transaction-iso.html
- Oracle, “MySQL 8.0 Reference Manual: InnoDB Locking and Transaction Model” — https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-transaction-model.html
- Martin Kleppmann, “Designing Data-Intensive Applications”, O’Reilly Media, 2017(分散システムとデータ設計の現代的な教科書) — https://dataintensive.net/
- Martin Fowler, “Patterns of Enterprise Application Architecture”(ORMと遅延読み込みの問題を整理) — https://martinfowler.com/books/eaa.html
- Martin Fowler, “Event Sourcing”(出来事を記録する設計の解説) — https://martinfowler.com/eaaDev/EventSourcing.html
- Eric Evans, “Domain-Driven Design”(一意制約と取引IDによる冪等性の設計に関わる) — https://www.domainlanguage.com/ddd/
- Pat Helland, “Life Beyond Distributed Transactions: An Apostate’s Opinion”, CIDR 2007(同内容は ACM Queue でも公開されている) — https://queue.acm.org/detail.cfm?id=3025012
- Eric A. Brewer, “Towards Robust Distributed Systems”(CAP定理の元となった講演)、”CAP Twelve Years Later: How the ‘Rules’ Have Changed”, IEEE Computer, 2012 — https://www.infoq.com/articles/cap-twelve-years-later-how-the-rules-have-changed/
- Markus Winand, “Use The Index, Luke!”(索引が効く書き方・効かない書き方を実例で解説) — https://use-the-index-luke.com/
- PostgreSQL Global Development Group, “PostgreSQL Documentation: Using EXPLAIN”(実行計画を読んで索引の使用を確認する) — https://www.postgresql.org/docs/current/using-explain.html
- 独立行政法人情報処理推進機構(IPA), 「基本情報技術者試験 シラバス」(データベース・トランザクション処理・排他制御) — https://www.ipa.go.jp/
- ACM/IEEE-CS Joint Task Force, “Computer Science Curricula 2023 (CS2023)”(Information Management 領域) — https://csed.acm.org/
Rui Software