ジュニアエンジニア向け コンピュータサイエンス入門シリーズ(全8回)
第7回:データベース:100人が同時に書き換えても壊れないのはなぜか

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問題がなぜ遅いのかを、往復回数から計算できること。第三に、索引がなぜ読み取りを速くし、書き込みを遅くするのかを説明できること。第四に、デッドロックや「更新が消える」事故に遭遇したとき、何が起きているかを読み解けることです。

データベースを銀行の窓口にたとえた概念イラスト 複数の窓口が同時に手続きを進めつつ、同じ口座には「使用中」の札を立てて順番を待たせ、途中で失敗したら手続きごと取り消す様子を描く図。 同時に受け付けつつ、同じ口座は順番待ちにする。途中で失敗したら、なかったことにする この3つが両立してはじめて、残高は正しく保たれる お客さん(同時アクセス) 3人が同時に来る。待たせすぎても困る 1人ずつなら安全だが、遅すぎる =複数接続を並行して受ける ① 複数の窓口を開ける 窓口1 窓口2 窓口3 別々のデータなら同時に進められる =読み取りは基本的に互いに影響しない だから本体は並行度を上げられる ② 同じ口座は順番待ち 口座A:使用中の札 窓口2が処理中。窓口3は待つ 読むだけの人は待たせない工夫もある =「強い一貫性」と「速さ」の調整 ③ 途中で失敗したら巻き戻す 手続きが途中で止まっても 「半分だけ反映」にはしない この3つを支えているのがトランザクション。名前は4つの性質に分かれている A:原子性 全部やるか、何もしないか 途中で止まっても半端にならない 「引き落としだけ成功」を防ぐ C:一貫性 決めたルールを破らない 残高がマイナスにならない等 制約として宣言しておく I:独立性 同時でも、干渉し合わない 結果は順番にやったのと同じ ここが「同時の管理者」の核心 D:永続性 確定したら消えない 電源が落ちても残る 第4回の「同期」が必要になる

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人が同時に実行すると何が起きるか。

同時に更新すると片方の変更が消える様子とその回避方法 2人が同時に残高を読み、それぞれが計算して書き戻すと片方の変更が失われること、ロックや条件付き更新で回避できることを示す図。 2人で同時に引き出すと、8,000円が消えることがある 残高10,000円から、2人が同時に8,000円を引き出す Aさんの手順 Bさんの手順 ① 残高を読む → 10,000円 ① 残高を読む → 10,000円 ② 足りるか確認する 10,000 − 8,000 = 2,000 → OK ② 足りるか確認する 10,000 − 8,000 = 2,000 → OK ③ 2,000円を書き戻す 残高を 2,000 に更新 ③ 2,000円を書き戻す 残高を 2,000 に更新 どちらも「2,000」を書いた 16,000円引き出したのに 残高は 2,000 円のまま 本来は 10,000 − 16,000 で 2人目の時点で失敗すべきだった Aの更新が B に上書きされた 回避策は3つ。「読んでから書く」を分割させない ① トランザクションで囲み、読み取り時にロックをかける(後から来た人は待つ) ② 条件を書き込み文に含める(「残高が10,000以上のときだけ減らす」=条件付き更新)

この現象をロストアップデート(更新の消失)と呼びます。ポイントは、アプリのコードが間違っていないことです。①読む→②確認→③書く、という手順は人間には自然です。問題はその3ステップの間に、他の人の手が入り込めることです。

回避策は2つあります。 第一に、読み取りから書き込みまでを1つのまとまり(トランザクション)として扱い、その間ロックをかける。後から来た人は待たされます。第二に、条件を書き込み文に含める。「残高を2,000に更新する」ではなく「残高が8,000以上のときだけ、残高を8,000減らす」。データベースが1つの操作として処理するので、間に割り込む隙がありません。

2つ目のほうが優れている理由は、待ち時間が生まれないことです。1つ目は順番待ちが発生しますが、2つ目は各操作が独立して進みます。「読んでから書くを1つにまとめる」という発想は、覚えておいて損がありません。

🔬 検証②:独立性の段階——待たせるか、待たせないか

次に、独立性(Isolation)の強さの段階を見ます。ここがこの記事でいちばん重要な部分です。

理想は「同時に実行しても、順番に実行したのと同じ結果になる」ことです。これを完全に守ろうとすると、すべての操作を完全に順番待ちにするしかありません。しかしそれでは並行度が落ちて遅くなります。だから実際のデータベースは段階を用意しています。

独立性の4つの段階と、それぞれで起きうる現象 独立性の段階が上がるほど守られる現象が増え、代わりに待ち時間が増えることを、4段階の階段として示す図。 守るものを増やすほど、待たせることになる 「どこまで矛盾を許すか」を選ぶのが独立性の段階。既定値ではなく、意味で選ぶ 厳しい (待つ) 緩い (速い) 直列化可能 反復可能読み取り コミット済み読み取り コミット前読み取り 緩めるほど起きうる現象 ダーティリード:まだ確定していない他人の値が読める 反復不能読み取り:同じ行を2回読むと値が変わる ファントム:条件に合う行が、途中で増減する 更新の消失:片方の変更が上書きされる(検証①) 直列化可能では、上の現象がすべて起きない 下の段階ほど、これらの一部を許して速くなる 実務の判断:金額・在庫・順番待ちの割り当ては厳しく。集計・レポートは緩くてよい 「読むだけの処理」に厳しい設定を使うのは、待ち時間を無駄に増やす行為

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) の手間がかかり、ページの分割が起きるとさらに重くなります。

索引が読み取りを速くし書き込みを遅くする仕組み 索引がない場合は全件を探すが、索引があれば少ない比較で目的の行に届く一方、書き込みのたびに索引も更新する必要があることを示す図。 索引は「読む速さ」を買い、「書く速さ」で払う 索引なし:全件を見る 読む量が件数に比例する(O(n)) 100万件なら100万行。遅い 索引あり:目次から辿る 索引 キー→行の位置 その行 比較の回数が log で増える(O(log n)) 100万件でも20回程度。速い その代金:書き込みのたびに索引も直す必要がある 追加するとき 行を足す + 索引に入口を作る(B-treeの分割が起きると重い) 更新するとき 索引対象の列が変われば、索引の位置も動かす必要がある その結果 索引を増やすほど読み取りは速く、書き込みは遅くなる

この構造から、実務の判断基準が出ます。「読み取りが多いか、書き込みが多いか」で決める。読み取りが大半を占める画面(一覧・検索)には索引が効きます。逆に、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問題と結合による解決を往復回数で比較した図 100件の注文と顧客情報を取得するとき、1件ずつ問い合わせると101回の往復が発生するのに対し、まとめて結合すれば1回で済むことを比較する図。 取得するデータは同じ。往復の回数だけが1回と101回で違う 100件の注文+顧客情報を取得する場合。往復1回あたりの時間を掛けると、秒数が決まる N+1:1件ずつ問い合わせる ① 注文を100件取る 往復1回 ② 顧客を1件ずつ取る 往復100回 合計 101回の往復 件数に比例して増える(第5回のO(n)) まとめる:結合して1回で取る ① 注文と顧客を結合して取る 往復1回 ② アプリ側で組み立てる 往復0回 合計 1回の往復 件数が増えても往復は増えない(O(1)) 同じ101回の往復でも、1回あたりの時間で結果は100倍変わる 同じデータセンター(1ms)→ 約0.1秒。許容範囲に見える 別マシン(5ms)→ 約0.5秒。体感に出る 大陸間(100ms)→ 約10秒。使い物にならない まとめれば、どの距離でも数十ms以内に収まる

ここが本記事の要点です。 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+11画面あたりのクエリ回数と件数結合する、まとめて取る、先読みする
件数が増えるほど遅くなる索引が効いていない検索条件の列に索引があるか、関数適用していないか索引を張る、条件の書き方を変える
書き込みが遅い索引の張りすぎ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円」という事実を追記していく方式です。現在の残高は、記録された事実を積み上げて計算します。会計の帳簿と同じ発想です。

現在の状態を上書きする方式と出来事を追記する方式の比較 状態を上書きする方式は現在の値だけを残すのに対し、出来事を追記する方式は経緯がすべて残り、同時更新の衝突も起きにくいことを示す図。 「今いくらか」を保存するか、「何が起きたか」を保存するか 状態を上書きする 残高:2,000円 前の値は消える 強み 現在の値を読むのが一瞬(O(1)) 単純で、誰でも読める 弱み 経緯が残らない。「なぜ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つの単語として暗記せず、「破れたときの姿」から説明できるようになります。そして、更新が消えた現場を見たら「読んでから書くを分けたな」と気づき、一覧画面が遅ければクエリの回数を数えるようになります。

そして、データベースの設計判断が、すべてトレードオフの選択であることが見えてきます。厳しさ、索引、まとめ方、記録の持ち方。どれも「何かを差し出して何かを得る」取引です。データベースは、多数の利用者が同時に触る世界で、壊れないための交通整理をしてくれています。その整理の仕組みを知っている人は、「なぜ壊れたか」だけでなく「次にどう設計するか」まで語れます。それが、設計を任される側に回るための、最初の一歩です。

参考文献

  1. Jim Gray, Andreas Reuter, “Transaction Processing: Concepts and Techniques”, Morgan Kaufmann, 1992(トランザクション処理の原典的教科書) — https://www.sciencedirect.com/book/9781558601901/transaction-processing
  2. 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
  3. 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/
  4. ISO/IEC 9075(SQL標準。独立性の段階の定義を含む) — https://www.iso.org/standard/76583.html
  5. PostgreSQL Global Development Group, “PostgreSQL Documentation: Transaction Isolation” — https://www.postgresql.org/docs/current/transaction-iso.html
  6. 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
  7. Martin Kleppmann, “Designing Data-Intensive Applications”, O’Reilly Media, 2017(分散システムとデータ設計の現代的な教科書) — https://dataintensive.net/
  8. Martin Fowler, “Patterns of Enterprise Application Architecture”(ORMと遅延読み込みの問題を整理) — https://martinfowler.com/books/eaa.html
  9. Martin Fowler, “Event Sourcing”(出来事を記録する設計の解説) — https://martinfowler.com/eaaDev/EventSourcing.html
  10. Eric Evans, “Domain-Driven Design”(一意制約と取引IDによる冪等性の設計に関わる) — https://www.domainlanguage.com/ddd/
  11. Pat Helland, “Life Beyond Distributed Transactions: An Apostate’s Opinion”, CIDR 2007(同内容は ACM Queue でも公開されている) — https://queue.acm.org/detail.cfm?id=3025012
  12. 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/
  13. Markus Winand, “Use The Index, Luke!”(索引が効く書き方・効かない書き方を実例で解説) — https://use-the-index-luke.com/
  14. PostgreSQL Global Development Group, “PostgreSQL Documentation: Using EXPLAIN”(実行計画を読んで索引の使用を確認する) — https://www.postgresql.org/docs/current/using-explain.html
  15. 独立行政法人情報処理推進機構(IPA), 「基本情報技術者試験 シラバス」(データベース・トランザクション処理・排他制御) — https://www.ipa.go.jp/
  16. ACM/IEEE-CS Joint Task Force, “Computer Science Curricula 2023 (CS2023)”(Information Management 領域) — https://csed.acm.org/
ジュニアエンジニア向け コンピュータサイエンス入門 ── 全8回の一覧
いま読んでいるのは 第7回 です。読みたい回から始めても構いません。
  1. 第1回:全体地図:その1行は「5階建てのビル」で動いている
  2. 第2回:データの正体:0.1+0.2はなぜ0.3にならないのか
  3. 第3回:CPUとメモリ:1回のメモリアクセスは100回の計算より高くつく
  4. 第4回:OSの仕事:「ファイルを開く」は何をしているのか
  5. 第5回:アルゴリズムとデータ構造:100万件から1人を探すのに20回で足りる
  6. 第6回:ネットワーク:クリックした荷物はどうやって海を渡るのか
  7. ▶ 第7回:データベース:100人が同時に書き換えても壊れないのはなぜか(この記事)
  8. 第8回:並行と並列:awaitを1つ忘れただけで、なぜ本番だけ壊れるのか

© Copyright 2005-2026| Rui Software | All Rights Reserved