本文へ移動

ホーム / Web / 第4部 バックエンド

第4部 バックエンド・第10章

データベース(アプリ開発者として)

★★★ 必須12トピック・面接 5問

Web アプリから DB を正しく速く使うための知識を押さえる。SRE の DB 章より「アプリから見た使い方」に寄せる。

別のページで開く →

DBラボを読み込んでいます…

ひとことで

列の値から行をすぐ探すための索引。複合インデックスは左の列から順にしか使えない。

インデックスは、本の索引のように、列の値から行の場所をすぐ探せるようにする仕組みです(多くは B-Tree)。インデックスがないと、DB は表を最初から最後まで読むフルスキャンをします。行が少ないうちは気づきませんが、数十万行を超えると急に遅くなります。

複合インデックスの順番

複数の列をまとめたインデックスは、左の列から順にしか使えません(電話帳が「姓→名」の順で並んでいて、名だけでは引けないのと同じ)。

インデックス (user_id, created_at) の中身user_idcreated_at412026-10-02412026-11-03422026-10-28422026-11-01422026-11-05432026-10-30442026-11-02452026-10-15WHERE user_id = 42 ORDER BY created_at→ 並んだ範囲だけ読めばよい(並べ替えも不要)WHERE created_at >= 11-01 だけだと、行があちこちに散らばり、このインデックスは使えない
複合インデックスの並び方(左の列から順)
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);

-- 使える
WHERE user_id = 42
WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20
-- 使えない(先頭の user_id がない)
WHERE created_at >= '2026-11-01'
  • 等号で絞る列を先、範囲(>・BETWEEN)や並べ替えに使う列を後にする。
  • 絞り込みの効く(値の種類が多い)列が向いている。is_deleted のような2値だけの列は単独では効きにくい。
  • 列に関数をかける(WHERE DATE(created_at) = …)、前方以外の LIKE '%abc'、型の違う比較はインデックスが使われにくい。
  • インデックスは書き込みを遅くし容量も使うので、使っていないものは消す。

実際にインデックスが使われているかは、次のトピックの EXPLAIN で確かめます。

SRE の「クエリとインデックスのチューニング」

面接ではこう答える

インデックスは、本の索引のように、列の値から行の場所をすぐ探せるようにする仕組みで、多くは B-Tree です。複数の列の複合インデックスは左の列から順にしか使えないので、等号で絞る列を先に、範囲や並べ替えの列を後に置きます。列に関数をかけたり、前方以外の LIKE を使ったりすると使われにくくなります。インデックスは書き込みを遅くするので、使っていないものは消します。

別のページで開く →

DBラボを読み込んでいます…

ひとことで

SQL の前に EXPLAIN を付けると、DB がどう実行するか(実行計画)が見られる。フルスキャンと、読む行数の見積もりを確かめる。

遅いクエリは、見つけてから EXPLAIN見つけるスロークエリログ・APMEXPLAIN計画と読む行数直すインデックス・書き方もう一度測るANALYZE で実測
遅いクエリを直す流れ

SQL の前に EXPLAIN を付けると、DB がどう実行するか(実行計画)が見られます。EXPLAIN ANALYZE は実際に実行して時間も出します(更新文では本当に更新されるので注意)。

見る所MySQLPostgreSQL
フルスキャンtype: ALLSeq Scan
インデックスを使ったtype: ref / range、key に名前Index Scan・Index Only Scan・Bitmap Index Scan
読む行数rowsrows(予想)と actual rows(実際)
余計な並べ替えExtra: Using filesortSort

予想の行数と実際が大きくずれていたら、統計情報が古い可能性があります(ANALYZE で更新)。遅いクエリは、スロークエリログや APM で見つけてから EXPLAIN で調べます。

インデックスと複合インデックス

面接ではこう答える

EXPLAIN は、DB がその SQL をどう実行するつもりかを見せてくれる命令です。まずフルスキャンになっていないか、MySQL なら type が ALL、PostgreSQL なら Seq Scan かを見ます。次に読む行数の見積もりと、余計な並べ替えがないかを確かめます。EXPLAIN ANALYZE なら実際に実行した時間と行数も出るので、見積もりと大きくずれていれば統計情報の更新を疑います。

別のページで開く →

DBラボを読み込んでいます…

10.3ORM と N+1

用語★★★ 必須
ひとことで

ループの中で関連データを1件ずつ取ると、クエリが 1+N 回になる。まとめて取る書き方にする。

N+1 問題は、一覧を1回取ったあと、各行の関連データを1件ずつ取りに行くことで、SQL が 1+N 回発行される問題です。ORM が関連を自動で読んでくれるために起こりやすく、1回 1ms でも 100 件なら 100ms 以上になります。

# Rails の例:投稿 100 件 → 著者の SQL が 100 回
Post.limit(100).each { |p| puts p.user.name }
# SELECT * FROM posts LIMIT 100
# SELECT * FROM users WHERE id = 1
# SELECT * FROM users WHERE id = 2 …(100 回)
投稿 100 件と、その著者を表示するN+1:SQL が 101 回includes・preload:SQL が 2 回SELECT … FROM posts LIMIT 100users WHERE id = 1users WHERE id = 2users WHERE id = 3…(あと 97 回)SELECT … FROM posts LIMIT 100users WHERE id IN (1, 2, …, 100)1 回 1ms でも、N+1 では 100 回で 100ms 以上。件数が増えるほど差が広がる
N+1 とまとめて読む場合の SQL の回数

検出方法

  • ログ:開発環境で SQL のログを出し、同じ形の SQL が並んでいないか見る。
  • APM(Datadog・New Relic など):1リクエストの中の SQL の回数と時間が見え、N+1 を自動で指摘するものもある。
  • Bullet(Rails):N+1 を見つけると警告する。テストで有効にして CI で落とすこともできる。Django なら django-debug-toolbar・nplusone などが使えます。
  • テストで「この API の SQL は N 回以下」と数を確かめる。

直し方

方法やることSQL
まとめて先に読む(Rails の preload、Django の prefetch_related)関連をまとめて取る2回:SELECT … FROM users WHERE id IN (1,2,…)
JOIN(eager_load・joins、select_related)1回の JOIN で取る。関連で絞り込みたいとき1回
includes(Rails)状況に応じて上の2つを ORM が選ぶ1〜2回
Post.includes(:user).limit(100).each { |p| puts p.user.name }  # SQL 2回

1対多を JOIN で取ると行数が掛け算で増えるので、子が多いときは IN でまとめるほうが軽いことがあります。件数だけ欲しいなら、関連を全部読まずに COUNT や集計列を使います。

SRE の「N+1問題」

面接ではこう答える

N+1 問題は、一覧を1回取ったあと、各行の関連データを1件ずつ取りに行くことで、SQL が1足すN回発行される問題です。ORM が関連を自動で読んでくれるので気づかずに起こり、件数が増えるほど遅くなります。SQL のログや APM、Rails の Bullet で見つけ、preload でまとめて IN で取るか、JOIN で1回で取るように直します。

別のページで開く →

DBラボを読み込んでいます…

ひとことで

複数の更新を全部成功か全部なしにまとめる仕組み。分離レベルで、同時に動く他の変更の見え方が決まる。

トランザクションは、複数の SQL を「全部成功するか、全部なかったことにするか」にまとめる仕組みです。在庫を減らして注文を作る、のように途中で止まると困る処理に使います。

BEGIN;
UPDATE stocks SET qty = qty - 1 WHERE item_id = 10;
INSERT INTO orders (item_id, user_id) VALUES (10, 42);
COMMIT;  -- 途中で失敗したら ROLLBACK
ACID:トランザクションが守る4つの性質A 原子性・全部成功か・全部なしかC 一貫性・制約を破った・状態にならないI 分離性・同時に動いても・混ざって見えないD 永続性・コミットしたら・落ちても消えない
ACID の4つの性質

分離レベルの違い

同時に動く他のトランザクションの変更が、どこまで見えるかを決めるのが分離レベルです。

分離レベル起こりうること備考
READ COMMITTED同じ SELECT を2回すると、間に他がコミットした値に変わるPostgreSQL の既定
REPEATABLE READトランザクション中は最初に見た状態が続く。ただし「読んでから書く」処理で他の更新とぶつかることがあるMySQL(InnoDB)の既定
SERIALIZABLE1つずつ順番に実行したのと同じ結果になる。ぶつかると片方がエラーになるのでリトライが必要一番安全だが遅く、失敗も増える

分離レベルを上げても、「読んでから書く」処理の正しさは自動では守られません。在庫や残高のような更新は、次のトピックのロックで守ります。

面接ではこう答える

トランザクションは、複数の SQL を全部成功か全部なしにまとめる仕組みで、ACID という性質を守ります。分離レベルは、同時に動く他のトランザクションの変更がどこまで見えるかの決まりで、PostgreSQL の既定は READ COMMITTED、MySQL の InnoDB は REPEATABLE READ です。ただし分離レベルを上げても、読んでから計算して書く処理の取りこぼしは自動では防げないので、ロックや条件付き更新を使います。

別のページで開く →

DBラボを読み込んでいます…

ひとことで

悲観ロックは先に行をロックして待たせ、楽観ロックは版の番号で衝突を検出してやり直す。単純な増減は条件付き UPDATE が一番短い。

大事なのは、どの分離レベルでも「SELECT で在庫を読んで、アプリで計算して、UPDATE で書く」と、同時に動いた2つが同じ値を読んで両方とも成功してしまう(更新の取りこぼし)ことがある点です。次のロックや条件付きの UPDATE で防ぎます。

在庫 1 個に、同時に2人が注文するリクエスト ADB(在庫 qty = 1)リクエスト BSELECT qty → 1SELECT qty → 1UPDATE qty = 0(1−1)UPDATE qty = 0(1−1)2 個売れたのに在庫は 0。1 個足りない対策:UPDATE … SET qty = qty − 1 WHERE qty > 0
「読んでから書く」と起きる更新の取りこぼし
方式やり方向いている場面
悲観ロックSELECT … FOR UPDATE で行をロックし、終わるまで他を待たせる同じ行への更新がよくぶつかる(人気商品の在庫)
楽観ロックversion 列を持ち、UPDATE … SET version = version + 1 WHERE id = ? AND version = 読んだ値。0 行なら他が先に更新したので 409 か再試行ぶつかることが少ない(プロフィールの編集)
条件付き UPDATEUPDATE stocks SET qty = qty - 1 WHERE item_id = 10 AND qty > 0。更新した行数で成功を判定単純な数の増減。ロックの時間が一番短い
-- 楽観ロック:0 行なら、他の人が先に更新した
UPDATE articles SET body = ?, version = version + 1
WHERE id = 7 AND version = 3;

面接ではこう答える

同じ行を同時に更新するときは、ロックで守ります。悲観ロックは SELECT FOR UPDATE で先に行をロックし、終わるまで他を待たせる方法で、衝突が多い人気商品の在庫に向きます。楽観ロックは version 列を持ち、読んだときの版と同じときだけ更新して、0行なら衝突として扱う方法で、衝突が少ない編集画面に向きます。在庫を1減らすような単純な処理は、WHERE qty > 0 を付けた条件付き UPDATE が一番ロックの時間が短く済みます。

別のページで開く →

DBラボを読み込んでいます…

ひとことで

2つのトランザクションがお互いのロックを待ち続ける状態。DB が片方を失敗させるので、ロックの順番をそろえ、失敗したら全体をやり直す。

A は行2を、B は行1を待ち、どちらも進めないトランザクション A行1をロック済みトランザクション B行2をロック済み行1行2持つ持つ待つ待つ対策:ロックを取る順番をそろえる(ID の小さい順)・トランザクションを短く
お互いのロックを待ち続けるデッドロック

A が行1→行2、B が行2→行1の順でロックを取ると、お互いを待ち続けます。これがデッドロックで、DB は片方を自動で失敗させます(MySQL は Deadlock found、PostgreSQL は deadlock detected)。

  • ロックを取る順番をそろえる(ID の小さい順など)。
  • トランザクションを短くする。中で外部 API を呼んだり、ユーザーの入力を待ったりしない。
  • デッドロックや SERIALIZABLE の失敗は、トランザクション全体を少し待ってからやり直す(回数に上限)。
調べる方法MySQLPostgreSQL
直近のデッドロックSHOW ENGINE INNODB STATUS の LATEST DETECTED DEADLOCKサーバーのログ(deadlock detected と両方の SQL)
いまのロック待ちperformance_schema.data_lock_waitspg_locks・pg_stat_activity

面接ではこう答える

デッドロックは、AがBのロックを、BがAのロックを待ち続けて、どちらも進めなくなる状態です。DB はこれを検出して片方のトランザクションを失敗させます。対策は、ロックを取る順番を ID の小さい順などにそろえること、トランザクションを短くすること、そして失敗したトランザクションを少し待ってから全体ごとやり直すことです。どの SQL がぶつかったかは DB のログで確かめます。

ひとことで

重複を確実に防げるのは DB の一意制約だけ。アプリの事前チェックは案内用で、正しさは制約に任せる。

「同じメールアドレスで2人登録させない」を、アプリで先に SELECT して確かめるだけで済ませると、同時に来た2つのリクエストが両方とも「まだない」と判断して、両方とも INSERT できてしまいます。これはチェックと書き込みの間の競合で、テストでは起きにくく、本番で二重押しやリトライのときに起きます。

「確かめてから INSERT」では間に割り込まれるリクエスト ADBリクエスト BSELECT … email = 'a@x' → なしSELECT … email = 'a@x' → なしINSERT 'a@x' → 成功INSERT 'a@x'一意制約がないと → 成功してしまう(重複)一意制約があれば → エラー → 409 を返す
先に確かめてから書くと起きる二重登録

一意制約で DB に守らせる

ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email);

一意制約があれば、2つ目の INSERT は DB がエラーにします。アプリはそのエラー(MySQL は 1062、PostgreSQL は 23505)を捕まえて、409 や「すでに登録されています」に変えます。事前の SELECT は画面の案内のためだけに使い、正しさは制約に任せます。(user_id, item_id) のような複数列の組み合わせにも張れます。論理削除の行を除きたいときは、PostgreSQL なら部分インデックス(WHERE deleted_at IS NULL)が使えます。

面接ではこう答える

同じメールアドレスで2人登録させない、を先に SELECT で確かめるだけにすると、同時に来た2つが両方まだないと判断して、両方とも登録されてしまいます。これを確実に防げるのは DB の一意制約です。アプリは制約違反のエラーを捕まえて、409 やすでに登録済みという案内に変えます。事前の確認は画面の案内のためだけに使い、正しさは制約に任せます。

別のページで開く →

DBラボを読み込んでいます…

ひとことで

「なければ追加、あれば更新」を1つの SQL で競合なく行う。PostgreSQL・SQLite は ON CONFLICT、MySQL は ON DUPLICATE KEY UPDATE。

2回に分けると競合する。1つの SQL で書くSELECT → INSERT・あるか確かめてから書く・同時に来ると両方 INSERT・一意制約のエラーになるUPSERT・1つの SQL で判断と書き込み・DB が競合を処理する・ON CONFLICT・ON DUPLICATE KEY
2段階の処理と UPSERT の違い

「なければ作る、あれば更新する」を1つの SQL で、競合なく行うのが UPSERT です。

-- PostgreSQL・SQLite
INSERT INTO likes (user_id, post_id, created_at)
VALUES (42, 7, now())
ON CONFLICT (user_id, post_id) DO NOTHING;

INSERT INTO counters (key, n) VALUES ('views', 1)
ON CONFLICT (key) DO UPDATE SET n = counters.n + 1;

-- MySQL・MariaDB
INSERT INTO counters (`key`, n) VALUES ('views', 1)
ON DUPLICATE KEY UPDATE n = n + 1;
  • ON CONFLICT には対象の一意制約(列)を指定する。MySQL の ON DUPLICATE KEY は、どの一意キーにぶつかっても発動するので、一意キーが複数ある表では意図しない行を更新することがある。
  • MySQL の INSERT IGNORE は重複以外のエラーまで握りつぶすことがあるので避ける。
  • 冪等キーの保存や「いいね」のような二重登録を防ぎたい所に向いている。

面接ではこう答える

UPSERT は、なければ追加し、あれば更新するという処理を1つの SQL で行う方法です。先に SELECT してから INSERT すると、同時に来た2つが両方「まだない」と判断して競合しますが、UPSERT なら DB が一意制約を使って正しく処理します。PostgreSQL と SQLite は ON CONFLICT で対象の列を指定し、MySQL は ON DUPLICATE KEY UPDATE を使います。MySQL のほうはどの一意キーにぶつかっても発動するので、一意キーが複数ある表では注意します。

別のページで開く →

DBラボを読み込んでいます…

ひとことで

スキーマの変更をコードで管理し、順に適用する仕組み。本番では ALTER のロックに注意する。

マイグレーションは、DB の表の形(スキーマ)の変更を、コードとして順番に管理する仕組みです(Rails の migration、Prisma Migrate、Flyway など)。本番では、動いているアプリを止めずに変えるのが難しい所です。

ALTER がロックを待つだけで、後ろの SELECT まで止まる長い SELECT実行中ALTER TABLE強いロックを待つSELECT待たされるSELECT待たされる対策:SET lock_timeout = '3s'(待ちすぎたら ALTER を諦めて再試行)インデックスは CREATE INDEX CONCURRENTLY。まず本番に近いデータ量で時間を測る
ロックを待つ ALTER が、後ろのクエリを止める(PostgreSQL)

稼働中の ALTER とロック

  • 大きな表への ALTER TABLE は、表を作り直したり強いロックを取ったりして、その間の読み書きを止めることがある。
  • PostgreSQL では、ALTER がロックを待っている間、後ろに来た普通の SELECT まで待たされて詰まる。SET lock_timeout = '3s' で待ちすぎたら諦めるようにする。インデックスは CREATE INDEX CONCURRENTLY で作る。
  • MySQL は多くの変更をオンライン(ALGORITHM=INPLACE・INSTANT)でできるが、できない変更もあり、大きな表では gh-ost・pt-online-schema-change を使う。
  • まず本番に近いデータ量の環境で、かかる時間とロックを確かめる。

SRE の「スキーマ変更の進め方」

面接ではこう答える

マイグレーションは、DB のスキーマの変更をコードとして順番に管理する仕組みです。本番では、大きな表への ALTER TABLE がロックを取って読み書きを止めることがあるので注意します。PostgreSQL では ALTER がロックを待つだけで後ろの SELECT まで詰まるので、lock_timeout を設定し、インデックスは CONCURRENTLY で作ります。まず本番に近いデータ量で時間とロックを確かめます。

別のページで開く →

DBラボを読み込んでいます…

ひとことで

アプリとスキーマは同時に切り替わらないので、新旧どちらのアプリでも動く状態を挟んで、広げてから縮める。

アプリとスキーマは同時には切り替わらないので、新旧どちらのアプリでも動く状態を挟みます(expand → contract)。

どの時点でも、新旧どちらのアプリでも動くexpand(増やす)移すcontract(減らす)① 新しい列を追加する② 新旧の両方に書く③ 古い行を移す1000 行ずつ④ 読む先を新しい列に⑤ 古い列を消す別のリリースでアプリを1つ前に戻しても動くよう、消すのは最後に、別のリリースで
列の名前の変更を安全に進める手順(expand → contract)
変更手順
列の追加NULL 可(または既定値つき)で追加 → アプリが書き始める → 既存の行を少しずつ埋める → 必要なら NOT NULL にする
列の削除アプリがその列を使わないようにしてリリース(ORM がキャッシュした列一覧にも注意)→ 列を消す
列の名前の変更新しい列を追加 → 両方に書く → データを移す → 読む先を新しい列に → 古い列を使わなくする → 消す

大量の行の更新は、1つの UPDATE ではなく 1000 行ずつなどに分け、間を空けて流します。ロールバックでアプリを戻しても動くように、削除は最後に、別のリリースで行います。

SRE の「Expand / Contract の手順」

面接ではこう答える

スキーマの変更とアプリのデプロイは同時には切り替わらないので、新旧どちらのアプリでも動く状態を間に挟みます。これが expand と contract の考え方です。たとえば列の名前を変えるなら、新しい列を足し、両方に書き、データを移し、読む先を変え、最後に古い列を消します。削除を最後の別のリリースにしておけば、途中でアプリを戻しても壊れません。

10.11コネクションプール

用語★★☆ 重要
ひとことで

DB の接続を何本か張っておいて使い回す仕組み。台数 × プールの大きさが DB の上限を超えないようにする。

台数 × プールの大きさ = DB の接続数アプリ 1(プール 10本)アプリ 2(プール 10本)アプリ 3(プール 10本)DB30本の接続。上限を超えると拒否
アプリの台数 × プールの大きさ が DB の接続数になる

DB への接続を張るのは時間がかかり、DB 側も接続ごとにメモリを使います。そこでアプリはコネクションプールに接続を何本か張っておき、使い回します。

よくある問題原因と対策
「too many connections」アプリの台数 × プールの大きさ が DB の上限(MySQL の max_connections など)を超えた。台数を増やす前に計算する
プールが空いていなくて待つ遅いクエリ・長いトランザクションが接続を抱えている。プールを大きくする前に原因を直す
サーバーレスで接続が増え続ける関数ごとに接続を張る。RDS Proxy・PgBouncer などの外部のプールを挟む
接続が切れていてエラーDB やロードバランサーのタイムアウト。プールの生存確認・最大寿命を設定

プールの大きさは「大きいほど速い」わけではありません。DB の CPU やディスクには限りがあるので、同時に流すクエリを増やしすぎると、かえって全体が遅くなります。

SRE の「接続プール・スレッド数・並列度」

面接ではこう答える

DB への接続は張るのに時間がかかり、DB 側も接続ごとに資源を使うので、アプリはコネクションプールに接続を張っておいて使い回します。注意するのは、アプリの台数かけるプールの大きさが DB の最大接続数を超えないことです。プールが足りなくて待つときは、大きくする前に、遅いクエリや長いトランザクションが接続を抱えていないかを見ます。サーバーレスでは RDS Proxy のような外部のプールを挟みます。

ひとことで

読み取りを複製先に分けて負荷を下げる仕組み。複製は遅れるので、書いた直後の読み取りや判断に使う読み取りはプライマリで行う。

書き込みはプライマリ、読み取りはレプリカ(少し遅れる)アプリプライマリ書き込みレプリカ読み取り専用書く・判断に使う読み一覧などの読み非同期の複製(詰まると秒単位以上も遅れる)
プライマリとレプリカの読み書きの分け方

読み取りの多いサービスでは、書き込みを受けるプライマリから、読み取り専用のレプリカにデータを複製し、読み取りをレプリカに分けます。複製は普通は非同期なので、レプリカは少し遅れています。

起きる問題対策
書いた直後に読むと、まだ反映されていない(自分の投稿が見えない)書いたユーザーのその後の数秒の読み取りはプライマリへ(read-your-writes)
一覧はレプリカ、詳細はプライマリで、内容が食い違う同じ画面の読み取り先をそろえる
在庫・残高をレプリカで読んで判断する判断に使う読み取りはプライマリで、トランザクションの中で
遅延が急に大きくなる大きな更新・長いトランザクションを分割する。遅延を監視する

ORM やフレームワークには、読み書きで接続先を自動で分ける機能があります(Rails の複数 DB の切り替えなど)。どの処理がどちらに行くかを把握しておきます。

SRE の「レプリケーション遅延の原因と対策」

面接ではこう答える

読み取りレプリカは、プライマリのデータを複製した読み取り専用の DB で、読み取りの負荷を分けるのに使います。複製は普通は非同期なので、レプリカは少し遅れています。そのため、投稿した直後に自分の投稿が見えないといった問題が起きます。書いたユーザーの直後の読み取りはプライマリに送る、在庫のような判断に使う値はプライマリで読む、といった使い分けをします。

この章のまとめ

🖨 まとめシート(印刷・PDF)
  • インデックス・トランザクション・ロックをアプリの実装と結びつけて説明できる
  • ORM の N+1、マイグレーション、UPSERT・一意制約の使い方
  • インデックスと複合インデックス:列の値から行をすぐ探すための索引。複合インデックスは左の列から順にしか使えない。
  • EXPLAIN で実行計画を読む:SQL の前に EXPLAIN を付けると、DB がどう実行するか(実行計画)が見られる。フルスキャンと、読む行数の見積もりを確かめる。
  • ORM と N+1:ループの中で関連データを1件ずつ取ると、クエリが 1+N 回になる。まとめて取る書き方にする。
  • トランザクションと分離レベル:複数の更新を全部成功か全部なしにまとめる仕組み。分離レベルで、同時に動く他の変更の見え方が決まる。
  • 悲観ロック・楽観ロック・条件付き UPDATE:悲観ロックは先に行をロックして待たせ、楽観ロックは版の番号で衝突を検出してやり直す。単純な増減は条件付き UPDATE が一番短い。
  • デッドロックとリトライ:2つのトランザクションがお互いのロックを待ち続ける状態。DB が片方を失敗させるので、ロックの順番をそろえ、失敗したら全体をやり直す。
  • 一意制約で重複を防ぐ:重複を確実に防げるのは DB の一意制約だけ。アプリの事前チェックは案内用で、正しさは制約に任せる。
  • UPSERT(あれば更新・なければ追加):「なければ追加、あれば更新」を1つの SQL で競合なく行う。PostgreSQL・SQLite は ON CONFLICT、MySQL は ON DUPLICATE KEY UPDATE。
  • マイグレーションと稼働中の ALTER:スキーマの変更をコードで管理し、順に適用する仕組み。本番では ALTER のロックに注意する。
  • 列の追加・変更・削除の手順(expand → contract):アプリとスキーマは同時に切り替わらないので、新旧どちらのアプリでも動く状態を挟んで、広げてから縮める。
  • コネクションプール:DB の接続を何本か張っておいて使い回す仕組み。台数 × プールの大きさが DB の上限を超えないようにする。
  • 読み取りレプリカと遅延:読み取りを複製先に分けて負荷を下げる仕組み。複製は遅れるので、書いた直後の読み取りや判断に使う読み取りはプライマリで行う。

この章のクイズ

面接ではこう聞かれる

まず答えを見ずに声に出して答え、そのあと開いて比べる。

Q1N+1 問題とは何ですか?どう解決しますか?

模範回答

一覧を1回の SQL で取ったあと、各行の関連データを1件ずつ取りに行って、SQL が 1+N 回出てしまう問題です。ORM が関連を自動で読み込むので気づきにくく、件数が増えると急に遅くなります。

見つけ方は、開発環境の SQL ログで同じ形の SQL が並んでいないかを見ること、APM で1リクエストあたりの SQL の回数を見ること、Rails なら Bullet を入れてテストで検出することです。直すときは、Rails なら includes や preload で関連を IN 句でまとめて取り、関連で絞り込みたいときは eager_load や JOIN にします。子が多いときに JOIN すると行が増えるので IN のほうが軽いこともあります。直したあとは SQL の回数をテストで確かめて、戻らないようにしています。

深掘りされたら

  • GraphQL ではどう防ぎますか?

見られているポイント検出方法と ORM での直し方

Q2同じ商品を同時に購入されたとき、在庫をどう守りますか?

模範回答

一番単純で確実なのは、条件付きの UPDATE です。UPDATE stocks SET qty = qty - 1 WHERE item_id = ? AND qty > 0 として、更新できた行数が1なら成功、0なら売り切れと判断します。DB が1行ずつ順番に処理するので、同時に来ても在庫がマイナスになりません。注文の作成と同じトランザクションにします。

在庫の確認と複数の処理をまとめて行う必要があるときは、SELECT … FOR UPDATE で行をロックしてから処理する悲観ロックにします。ロックを取る順番をそろえてデッドロックを防ぎ、トランザクションは短くします。さらに同じ注文の二重送信は、冪等キーや注文番号の一意制約で防ぎます。アクセスが集中する限定販売では、Redis で先に数を減らしたりキューで順番に処理したりして、DB の同じ行にロックが集中しないようにします。

深掘りされたら

  • 楽観ロックを選ぶのはどんなときですか?

見られているポイントロック・条件付き UPDATE・一意制約

Q3複合インデックスの列の順番はどう決めますか?

模範回答

インデックスは左の列から順にしか使えないので、検索でよく使う条件に合わせて決めます。基本は、等号で絞る列を先に、範囲の条件や並べ替えに使う列を後に置きます。例えば「あるユーザーの注文を新しい順に20件」なら (user_id, created_at) で、絞り込みと並べ替えの両方にインデックスが使えます。

逆に (created_at, user_id) にすると、ユーザーで絞る検索には効きにくくなります。決めたら EXPLAIN で、インデックスが使われているか、余計な並べ替えが出ていないかを確かめます。インデックスは書き込みを遅くするので、使っていないものは消します。

深掘りされたら

  • EXPLAIN で何を見ますか?

見られているポイント左から使われる性質と、等号→範囲の順

Q4デッドロックが起きたというエラーが出ています。どう対応しますか?

模範回答

デッドロックは、2つのトランザクションがお互いの持っているロックを待ち合う状態で、DB が片方を自動的に失敗させます。まずアプリ側で、そのエラーのときはトランザクション全体を少し待ってからやり直すようにして、ユーザーへの影響を抑えます。

根本の対策は、どの処理同士がぶつかっているかを DB のログ(MySQL の SHOW ENGINE INNODB STATUS など)で調べ、ロックを取る順番をそろえることです。例えば複数の行を更新するなら ID の小さい順にします。また、トランザクションの中で外部 API を呼ばない、範囲を小さくする、必要なインデックスを張って余計な行をロックしない、といった工夫で起きにくくします。

深掘りされたら

  • 悲観ロックと楽観ロックはどう使い分けますか?

見られているポイントリトライと、順番をそろえる根本対策

Q5大きなテーブルに列を追加するとき、何に気をつけますか?

模範回答

一番の心配は、ALTER TABLE がロックを取って、その間の読み書きを止めてしまうことです。まず本番と同じくらいのデータ量の環境で、かかる時間とロックの種類を確かめます。PostgreSQL では、ALTER がロックを待つ間に後ろの SELECT まで詰まるので、lock_timeout を短くして、取れなければ諦めてやり直すようにします。MySQL で、オンラインでできない変更なら gh-ost などのツールを使います。

手順としては、NULL を許す列として追加し、アプリが書き始めてから既存の行を少しずつ埋め、必要なら最後に NOT NULL にします。新旧どちらのアプリでも動く状態を保ち、すぐ戻せるようにします。

深掘りされたら

  • 列を削除するときの手順は?

見られているポイントロック・事前の確認・段階的な手順