1. 現場の病理:「とりあえずFOR UPDATEしておけば安心」という思考停止
ECサイトの在庫引き当て、送金アプリの残高減算、ソーシャルゲームのアイテム消費。「複数リクエストが同時に来ても、残高がマイナスになったり二重決済が起きないようにしたい」と考えたとき、多くのエンジニアが教科書通りに思いつくのが 悲観的ロック(Pessimistic Locking / 行レベル排他ロック) です。
典型的なバックエンドコード(Go言語での例)を見てみましょう。コードレビューでも何気なく通りがちな実装です。
// 典型的なアンチパターン:思考停止のSELECT FOR UPDATE
func TransferMoney(ctx context.Context, db *sql.DB, fromID, toID int64, amount int64) error {
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err
}
defer tx.Rollback()
// 1. 送金元の残高を行ロック(悲観的ロック)で取得
var fromBalance int64
err = tx.QueryRowContext(ctx, "SELECT balance FROM accounts WHERE id = ? FOR UPDATE", fromID).Scan(&fromBalance)
if err != nil {
return fmt.Errorf("failed to lock account: %w", err)
}
// 2. 残高チェック
if fromBalance < amount {
return errors.New("insufficient balance")
}
// 3. 【最悪の地雷】ロックを保持したまま外部決済サービスへ通信
chargeResp, err := paymentGateway.ProcessTransfer(ctx, fromID, toID, amount)
if err != nil || !chargeResp.Success {
return fmt.Errorf("external payment failed: %w", err)
}
// 4. 送金元から減算
_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
if err != nil {
return err
}
// 5. 送金先へ加算
_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
if err != nil {
return err
}
return tx.Commit()
}
PRのコメントにはこう書かれます。「きちんとトランザクションを張り、FOR UPDATE で行をロックしているので競合状態(Race Condition)は起きません。安全です! LGTM!」。ローカルの単体テストも通り、ステージング環境でも何事もなく動作します。
しかし、これが本番にリリースされ、セールやキャンペーンでアクセスがスパイクした瞬間、悪夢が幕を開けます。
- DBのコネクション数が急激に跳ね上がり、上限値(
max_connections)に激突する。 - 無関係なログインや商品詳細ページの閲覧リクエストまで、DB接続待ちで全滅する。
- ALBから
504 Gateway Timeoutが大量噴出し、ヘルスチェック落ちしたサーバーが次々と切り離される。 - エラーログには
Error 1213: Deadlock found when trying to get lock; try restarting transactionが連打される。
「安全のためにロックをかけたはずなのに、なぜシステム全体が窒息死するのか?」——その裏には、教科書が教えてくれない3大構造的欠陥が潜んでいます。
2. 地雷その1:トランザクション内「外部I/O通信」によるロック保持時間の爆発
最初の致命的な罠は、「DBトランザクションの内側に、外部ネットワーク通信(HTTP API、RPC、S3アクセスなど)を同居させること」 です。
ロック保持時間は「DBクエリ時間」ではなく「外部APIの応答時間」になる
RDBMSのトランザクションは、COMMIT または ROLLBACK が完了するまで、獲得したすべての行ロックを保持し続けます。
データベース単体のインデックス引きクエリであれば、処理時間はわずか 数マイクロ秒〜数ミリ秒 です。しかし、外部の決済代行会社(StripeやPayPayなど)や通知サービスへのHTTPリクエストは、どれほど回線が良くても 50ms〜300ms、混雑時には 1秒〜3秒 平気で遅延します。
もし外部APIに1秒かかれば、そのユーザーのレコードは丸々1秒間ロックされたままになります。1秒間に同一ユーザーからの連続タップや、同一チケットへの予約リクエストが10件集中したらどうなるでしょうか?
- 2件目のリクエストは1秒待たされる。
- 3件目のリクエストは2秒待たされる。
- 10件目のリクエストは10秒待たされる。
コネクションプールの瞬殺
さらに深刻なのは、「ロックの解放を待っている後続スレッドも、DBコネクションを1本占有したままフリーズする」 という事実です。10本のリクエストが待機状態に入れば、10本のDBコネクションが完全に拘束されます。
これが重なると、DBのコネクションプールは一瞬で食い尽くされ、別テーブルを読みに行くだけの静的なリクエストすらDBに繋がらなくなります。これが「1つのテーブルでかけた行ロックが、システム全体の全停止を招く」メカニズムです。
DBトランザクションの中で外部ネットワークI/Oを絶対に行ってはならない。DB接続を握る時間は1ミリ秒でも短く切り詰めるのが高並行システムの絶対原則である。
3. 地雷その2:複数行ロック時の「ソート順欠落」とデッドロックの数学
次に頻発する地雷が、複数行のレコードをまとめてロック・更新する際に発生する デッドロック(Deadlock) です。
「IN句でまとめてロック」という錯覚
例えば、ショッピングカートに入っている2つの商品(ID: 105 と 42)の在庫を同時に確保する処理を考えます。
-- 2つの商品の在庫をまとめて悲観的ロック?
SELECT * FROM items WHERE id IN (105, 42) FOR UPDATE;
直感的には「105と42の2行を同時に原子的(アトミック)にロックしている」ように見えます。しかし、RDBMSの内部において「同時に複数行をまとめてロックする」というハードウェア的な機能は存在しません。InnoDBなどのストレージエンジンは、インデックスを走査しながら1行ずつ順番にロックを獲得していく のです。
ここで、別のユーザーが同時に商品(ID: 42 と 105)を購入しようとした場合、何が起きるでしょうか?
| タイミング | トランザクションA(購入A) | トランザクションB(購入B) | DB内部の状態 |
|---|---|---|---|
| T1 | id = 105 のロックを獲得 |
— | 105はAがロック中 |
| T2 | — | id = 42 のロックを獲得 |
42はBがロック中 |
| T3 | id = 42 のロックを要求 |
— | AはBの解放待ち(ブロック) |
| T4 | — | id = 105 のロックを要求 |
相互待ち(デッドロック発生!) |
お互いがお互いの持っている鍵を待ち合い、永遠に進まなくなる「循環待ち(Circular Wait)」が完成します。MySQLのInnoDBはこれを即座に検知し、コストの低い方のトランザクションを犠牲者(Victim)に選んで強制ロールバック(Error 1213)させます。
どれだけメモリやCPUを盛ろうと、同時実行時のロック獲得順序がバラバラである限り、デッドロックは数学的に不可避です。
解決策:主キー昇順ソートの鉄の掟
もし業務要件上、どうしても複数行の FOR UPDATE が必要な場合、「システム内の全クエリで、リソースをロックする順序(主キー昇順)を完全に統一する」 ことが唯一の防衛策になります。
// 複数IDをロックする前に、必ずID昇順にソートする
itemIDs := []int64{105, 42, 88}
sort.Slice(itemIDs, func(i, j int) bool {
return itemIDs[i] < itemIDs[j]
}) // 結果: [42, 88, 105]
// 常に小さいIDから順番に1行ずつロックしていく
for _, id := range itemIDs {
_, err := tx.ExecContext(ctx, "SELECT id FROM items WHERE id = ? FOR UPDATE", id)
if err != nil {
return err
}
}
すべてのトランザクションが常に 42 → 88 → 105 の順序でロックを奪い合えば、一方が待たされることはあっても「逆方向の奪い合い」は物理的に起きないため、デッドロックを完全に根絶できます。
4. 地雷その3:ギャップロック(Gap Lock)と無関係な行の巻き添え窒息
「自分は1行しか触っていないからデッドロックも関係ない」という油断も禁物です。MySQLのデフォルト分離レベルである REPEATABLE READ 環境では、SELECT ... FOR UPDATE は対象レコードだけでなく、その前後の「隙間」までロックする ネクストキーロック(Next-Key Lock = レコードロック + ギャップロック) を発動させます。
存在しない行を探すと広範囲が凍結する
例えば、新規登録前にユーザーの存在確認を行おうとして、誤って以下を実行したとします。
-- まだテーブルに存在しない user_id = 9999 に対してFOR UPDATE
SELECT * FROM users WHERE id = 9999 FOR UPDATE;
対象レコードが存在しない場合、InnoDBは「現在テーブルに存在する最大IDから、正の無限大(Supremum)までの全空間」にギャップロックをかけます。このトランザクションが開いている間、他のセッションから新しいユーザーを INSERT しようとするリクエストは、すべてギャップロック待ちでブロックされ、タイムアウト死します。
非ユニークインデックスの巻き添え事故
さらに危険なのが、主キー以外の通常カラム(例: status や created_at)を条件にした FOR UPDATE です。インデックスの該当範囲全体にギャップロックが張り巡らされ、「特定の1行をロックしたつもり」が、テーブル全体の新規書き込みをフリーズさせてしまう惨劇が日常的に起きています。
5. 処方箋1:行ロックを捨てよ。「アトミックUPDATE(条件付き更新)」の威力
では、どうすればよいのか? 結論から言えば、現代のWebアプリケーションにおいて、残高減算や在庫引き当てに SELECT ... FOR UPDATE を使う必要は 9割以上の場面で皆無 です。
「Read-Modify-Write」の呪縛を解く
エンジニアが FOR UPDATE を使いたくなるのは、次の思考プロセスに囚われているからです。
- DBから現在の残高を読み出す(Read)
- アプリケーションのメモリ上で「残高 ≧ 引き落とし額」かを判定する(Modify)
- 引き算した結果の新しい残高をDBに書き戻す(Write)
「ステップ1から3の間に他の処理が割り込んだら困るから、行全体を鍵で抱え込もう」——これが悲観的ロックの発想です。しかし、RDBMSは単なる受動的なストレージではありません。自身の中に強力な演算器と、条件を瞬時に判定するWHERE句の評価エンジンを備えています。
1本のSQLで判定と更新を完結させる
残高引き当ては、次のような 条件付きアトミックUPDATE(Conditional Atomic Update) の1行に集約できます。
UPDATE accounts
SET balance = balance - :amount
WHERE id = :id AND balance >= :amount;
このクエリの挙動を分解してみましょう。
- DB内部で
id = :idかつbalance >= :amountが真であるかをアトミックに評価します。 - 条件を満たしていれば減算を実行し、更新件数(Affected Rows)= 1 を返します。
- もし残高が1円でも不足していれば、WHERE句が不一致となり、更新件数 = 0 で何事もなかったかのように安全に終了します。
アプリケーション側の実装コード
アプリケーション側は、DBが返す RowsAffected を見るだけです。トランザクションの開始すら不要になります。
// トランザクション不要!アトミックUPDATEによるロックレス残高引き当て
func DeductBalance(ctx context.Context, db *sql.DB, accountID int64, amount int64) error {
res, err := db.ExecContext(ctx, `
UPDATE accounts
SET balance = balance - ?
WHERE id = ? AND balance >= ?
`, amount, accountID, amount)
if err != nil {
return fmt.Errorf("db execution failed: %w", err)
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
// 残高不足、または該当アカウントが存在しない
return errors.New("insufficient balance or account not found")
}
// 減算成功!二重引き当ての余地は1ミリ秒も存在しない
return nil
}
なぜこれが圧倒的に最強なのか?
- ロック保持時間が数マイクロ秒に縮退: 行ロックがかかるのは、このUPDATE文がInnoDB内部で実行される極小の瞬間だけです。外部通信中にロックを抱え込む余地は物理的にゼロになります。
- デッドロックの完全撲滅: 単一ステートメントのアトミック処理であるため、複数クエリにまたがる循環待ちは絶対に発生しません。
- スループットが10倍以上向上: アプリケーションとDB間のネットワーク往復(RTT)が3往復(BEGIN → SELECT → UPDATE → COMMIT)から 1往復のみ に激減します。
在庫の引き当てでも全く同一のパターンが適用できます。
UPDATE item_stocks
SET stock = stock - :qty
WHERE item_id = :item_id AND stock >= :qty;
6. 処方箋2:複雑な業務ロジックには「楽観的ロック(Versioning)」
「単なる数値の引き算ならアトミックUPDATEでいいが、複数テーブルの値を複雑に計算した結果を書き戻す場合はどうするのか?」
その場合に採用すべきパターンが、楽観的ロック(Optimistic Locking / Versioning) です。「衝突はめったに起きない」という前提に立ち、ロックを行わずに処理を進め、書き込みの瞬間にだけ整合性を検証します。
バージョン番号カラムによる整合性担保
対象テーブルに version INT NOT NULL DEFAULT 0 カラムを追加します。
-- 1. 普通のSELECTで取得(排他ロックは一切かけない!)
SELECT balance, status, version FROM accounts WHERE id = 123;
ロックを一切かけていないため、この後に外部APIを呼ぼうが、重いバリデーションを行おうが、他のリクエストをブロックすることは1ミリ秒もありません。
そして、すべての計算が完了し、DBへ書き戻す瞬間にだけ、読み込んだときの version を指定して更新します。
UPDATE accounts
SET balance = :new_balance,
status = :new_status,
version = version + 1
WHERE id = :id AND version = :current_version;
- 自分の計算中に誰もこの行を更新していなければ、
versionが一致し、更新が成功(RowsAffected == 1)してバージョンが上がります。 - もし隙をついて別のリクエストが更新を完了させていた場合、DB内の
versionは既に加算されているため、WHERE句が不一致となりRowsAffected == 0で弾かれます。
アプリケーション側は RowsAffected == 0 を検知したら、「競合が発生した」とみなして最新データを再取得してリトライするか、ユーザーに「他の更新と重複しました」と即座に返せば完了です。誰かを列に並ばせて待たせるコストをゼロにできます。
7. どうしてもFOR UPDATEが必要な場合の「2大安全弁」
業務要件によっては、どうしても悲観的ロックが不可避なケースもあります。例えば、非同期タスクワーカーがジョブテーブルから未処理レコードを奪い合うバッチ処理などです。その際、無印の FOR UPDATE を打つのは自殺行為です。必ず以下の安全弁を併用してください。
① FOR UPDATE NOWAIT(待たずに即座に失敗させる)
SELECT * FROM accounts WHERE id = 123 FOR UPDATE NOWAIT;
他セッションが既にロックを持っていた場合、解放を待ってスレッドをブロックするのではなく、即座にエラー(MySQL: Error 3572: Statement aborted because lock(s) could not be acquired immediately)を返して終了します。
これにより、コネクションプールがロック待ちスレッドで埋まるのを物理的に阻止し、クライアントに 409 Conflict や 429 Too Many Requests を即座に返して自律的にバックオフさせることが可能になります。
② FOR UPDATE SKIP LOCKED(神のジョブキュー処理)
ワーカープロセスで分散キューを処理する際、最大の武器になるのが SKIP LOCKED です。
-- 未処理のタスクを1件取得してロック(他ワーカーが処理中の行はスキップ)
SELECT id, payload
FROM job_queue
WHERE status = 'PENDING'
ORDER BY id ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;
SKIP LOCKED を付与すると、他のワーカーが既にロックしている行を「エラー」にも「待機」にもせず、自動的にスキップして、まだ誰にも触られていない次の未ロック行だけを即座に取得 してくれます。
これにより、数十台のワーカーが同時に同一テーブルをポーリングしても、ロックの取り合いによる待機やデッドロックが一切発生しなくなります。RedisやAmazon SQSなどの外部メッセージブローカーを導入せずとも、RDBMSだけで超高速かつ堅牢な分散ジョブキューが完成します。
8. まとめ:DBに行を抱え込ませるな、クエリに判断を委ねよ
高並行システムにおける排他制御手法の選択基準を整理します。
| 排他制御手法 | 推奨ユースケース | ロック保持時間 | デッドロックリスク | 並行スループット |
|---|---|---|---|---|
| アトミックUPDATE | 残高減算、在庫引き当て、カウンター加算 | 数マイクロ秒 | ゼロ(皆無) | ★★★★★(圧倒的最高) |
| 楽観的ロック (Versioning) | 複雑な業務判定、編集画面の競合防止 | 更新時のみ数マイクロ秒 | ゼロ(皆無) | ★★★★☆(極めて高い) |
| FOR UPDATE SKIP LOCKED | 非同期ワーカー、分散ジョブキュー処理 | トランザクション中 | 極小 | ★★★★☆(極めて高い) |
| FOR UPDATE NOWAIT | 二重実行を絶対に許さないクリティカル処理 | トランザクション中 | 低(即座にエラー) | ★★★☆☆(安全・Fail-Fast) |
| 無印 FOR UPDATE(悪手) | 思考停止の排他制御 | トランザクション全期間 | 極大(日常茶飯事) | ★☆☆☆☆(高負荷で即死) |
現場で徹底すべき4つの設計原則
- 数値の引き算に
FOR UPDATEを使うな:UPDATE ... WHERE balance >= :amountの1行に落とし込み、RowsAffectedで成否を判定せよ。 - トランザクション内から外部ネットワーク通信を完全追放せよ: 外部APIはDB接続の外で呼ぶ。状態遷移(Pending → Confirmed)で2フェーズに分割せよ。
- 複数行ロック時は主キー昇順ソートを義務化せよ: ソートの欠落は高負荷時に確実なデッドロックを引き起こす。
- ワーカーのタスク取得には
SKIP LOCKEDを使え: ロック待ちでスタックするワーカーをシステムから根絶せよ。
「ロックをかければ安心」というのは、並行処理の本質を理解していない者の幻想です。真にスケーラブルで堅牢なバックエンドとは、データを力任せに抱え込むシステムではなく、ロックを極限まで手放し、データベースの数理に判定を委ねるシステムなのです。