データベースのロックとは?排他・共有・楽観ロックを初心者向けに図解で解説

データベース

この記事では、「ロック」とは何のためにあるのか、排他ロックと共有ロックの違い、悲観ロックと楽観ロックの使い分け、そして実務でよく出会うデッドロックまでを、例え話と図でひと通り解説します。

最後には、共有ロックが原因でエラーを自分の手で再現するテストの実例も紹介します。対象読者は、SELECT / UPDATE / INSERT / DELETE は書けるがロックは聞いたことがない初心者エンジニアです。

そもそも、なぜロックが必要なのか

SELECT や UPDATE といった SQL は、実は複数の利用者やプログラムから同時にデータベースへ投げ込まれます。1人で使っているうちは問題になりませんが、同じデータに同時に触れたとき、何の制御もないとデータが壊れることがあります。

たとえば、人気ライブのチケットが「残り1枚」だとします。AさんとBさんがほぼ同時に予約しようとすると、次のような事故が起きえます。

  1. Aさんが在庫を読む →「残り1枚」
  2. Bさんも在庫を読む →「残り1枚」(Aさんはまだ買い終えていない)
  3. Aさんが予約処理 → 在庫を 0 にする
  4. Bさんも予約処理 → 在庫を −1 にしてしまう(二重予約の成立)

この「同時アクセスによる事故」を防ぐ仕組みがロックです。ロックとは、ざっくり言えば「いま私がこのデータを使っているから、終わるまで待ってね」と札を立てる仕組みのこと。複数の処理が衝突しないよう、順番待ちをさせるための交通整理だと考えてください。


図解:ロックがあると何が変わるのか

先ほどのチケットの例で、Aさんが先にロックを取った場合の流れを見てみましょう。Bさんは「待たされる」ことで、Aさんの処理が終わったあとに正しい在庫を読み直せます。

ポイントは、Bさんが待たされたおかげで、Aさんの処理確定後に在庫を読み直し、正しく「在庫0」と判明すること。結果として二重予約を防げます。


排他ロックと共有ロック

ロックには大きく2種類あります。トイレの個室をイメージすると分かりやすいです。

排他ロック(exclusive lock)は「私が使うから誰も触らないで」という強いロックです。個室に入って鍵をかけた状態にあたり、後から来た人は鍵が開くまでドアの前で待つしかありません。UPDATE / DELETE / INSERT のようにデータを書き換える操作では、データベースが自動的にこの排他ロックをかけます。

共有ロック(shared lock)は「読むだけだから、他の人も一緒に読んでOK。でも書き込みは待って」という弱いロックです。複数の人が同時に同じ本を「読む」のは平気だけれど、誰かが「書き換えている」最中は読めない、というイメージです。読み込み同士は両立し、書き込みとは両立しないのが特徴です。

ロックはトランザクション(ひとまとまりの処理。BEGIN で始まり COMMIT か ROLLBACK で終わる単位)とセットで使います。通常、取得したロックはトランザクションの終わり(COMMIT / ROLLBACK)まで保持され、終了と同時に解放されます。

ロックがかかるタイミングには、次の2種類があります。

  • 暗黙的ロック:UPDATE や DELETE を実行すると、対象の行に自動で排他ロックがかかります。
  • 明示的ロック:「読んだあとに必ず更新したい」場合、SELECT ... FOR UPDATE と書くと、読み取った時点で排他ロックをかけられます。読み取り主体で他者の更新だけ止めたいときは SELECT ... FOR SHARE を使います。
-- 在庫を読んだ瞬間に排他ロック。COMMITするまで他は触れない
BEGIN;
SELECT qty FROM stock WHERE id = 1 FOR UPDATE;
UPDATE stock SET qty = qty - 1 WHERE id = 1;
COMMIT;  -- ここでロック解放

通常の SELECT(FOR UPDATE なし)は基本的にロックを待たずに読めます。例外的に、テーブル構造を変更する ALTER TABLE などの強いロック中は、ただの SELECT もブロックされます。


悲観ロックと楽観ロック

ここまで説明した排他・共有ロックは、まとめて悲観ロック(pessimistic lock)と呼ばれる考え方に属します。「衝突は起きるものだ」と悲観的に構えて、操作の前に鍵をかけて他人を待たせる方式です。

一方の楽観ロック(optimistic lock)は、「衝突なんてめったに起きないだろう」と楽観的に構える方式です。鍵はかけず、保存する瞬間に「自分が読んでから誰かが書き換えていないか?」を確認します。典型的には、テーブルに version(バージョン番号)という列を持たせて実装します。

-- ① まず読む(このとき version = 5 だったとする)
SELECT id, qty, version FROM stock WHERE id = 1;

-- ② 「読んだときの version のまま変わっていない」を条件に更新する
UPDATE stock
SET qty = qty - 1, version = version + 1
WHERE id = 1 AND version = 5;

もし読んだ後に他の誰かが先に更新していたら、version はすでに 6 に変わっているので WHERE version = 5 に一致せず、更新件数が 0 行になります。プログラム側は「0 行=衝突が起きた」と判断し、エラーにするか、読み直してやり直し(リトライ)します。

悲観ロック楽観ロック
考え方衝突は起きる前提で先に鍵をかける衝突は稀な前提で鍵をかけない
仕組みDBのロック(FOR UPDATE 等)version 列などで後からチェック
向く場面競合が多い/確実に待たせたい競合が少ない/画面編集など長い処理
弱点待ち時間・デッドロック衝突時にやり直しが必要

Web アプリのフレームワーク(Hibernate や JPA の @Version、Rails の lock_version など)は、楽観ロックを標準で備えています。「編集画面を開いて保存したら、別の人が先に保存していてエラー」という挙動が、まさに楽観ロックです。また楽観ロックは物理的なロックを取らないため、後述のデッドロックが原理的に起きないという利点もあります。


デッドロックとは

デッドロックとは、2つ以上のトランザクションがお互いに相手の持つロックの解放を待ち続け、永久に進めなくなる状態です。たとえば AさんがX→Yの順、BさんがY→Xの順でロックを取ろうとすると、お互いが相手待ちになって動けなくなります。

幸い、PostgreSQL や MySQL(InnoDB)はデッドロックを自動的に検知し、片方のトランザクションを強制的に巻き戻して(ロールバックして)エラーにすることで、もう片方を進ませます。つまりデッドロックは「放置されて永久に止まる」のではなく、「片方がエラーで落ちる」という形で現れます。


実例:共有ロックでロックエラーを再現してみる

「共有ロックが原因でエラーが起きる」典型例が、まさにこのデッドロックです。2つのトランザクションが同じ行に共有ロックを取り、その後どちらも書き込み(=排他ロックへの昇格)を試みると、お互いが相手の共有ロック解放を待ち、デッドロックになります。

PostgreSQL で、ターミナルを2つ開いて並べて実行すると再現できます。

-- セッション1
BEGIN;
SELECT * FROM stock
  WHERE id = 1 FOR SHARE;   -- 共有ロック取得

-- (セッション2 が共有ロックを取得したあと)

UPDATE stock SET qty = qty - 1
  WHERE id = 1;             -- 排他へ昇格したい → セッション2の解放を待つ

-- ★ ここでデッドロック! 片方が次のエラーで落ちる
-- ERROR:  deadlock detected
-- DETAIL:  Process ... waits for ShareLock ... ; blocked by process ...
-- セッション2
BEGIN;
SELECT * FROM stock
  WHERE id = 1 FOR SHARE;   -- 共有なのでOK

UPDATE stock SET qty = qty - 1
  WHERE id = 1;             -- セッション1待ち → デッドロック

MySQL(InnoDB)でも同じ流れで、共有ロックは SELECT ... LOCK IN SHARE MODE を使い、ERROR 1213 (40001): Deadlock found when trying to get lock が発生します。

実際のテストでは、2本のコネクション(またはスレッド)を同時に走らせ、片方が必ずデッドロック例外を投げることを確認します。Python(psycopg2)での雛形は次のようになります。

import threading, psycopg2
from psycopg2 import errors

caught = []

def worker():
    conn = psycopg2.connect("dbname=test")
    cur = conn.cursor()
    try:
        cur.execute("BEGIN;")
        cur.execute("SELECT * FROM stock WHERE id = 1 FOR SHARE;")
        # 2スレッドが共有ロックを取り終えるまで待つ仕掛け(バリア)を挟む
        cur.execute("UPDATE stock SET qty = qty - 1 WHERE id = 1;")
        conn.commit()
    except errors.DeadlockDetected as e:   # ← 期待するエラー
        caught.append(e)
        conn.rollback()
    finally:
        conn.close()

t1, t2 = threading.Thread(target=worker), threading.Thread(target=worker)
t1.start(); t2.start(); t1.join(); t2.join()

assert len(caught) == 1   # どちらか一方がデッドロックで落ちる

「デッドロックではなく、ただ待たされて時間切れになるエラー」を出したいだけなら、片方のセッションが共有ロックを握ったまま離さない状態で、もう片方で UPDATE を投げます。MySQL では既定で50秒(innodb_lock_wait_timeout)待つと ERROR 1205 (HY000): Lock wait timeout exceeded が、PostgreSQL では SET lock_timeout = '2s'; を設定しておくと ERROR: canceling statement due to lock timeout が発生します。テストで挙動を検証したいときは、こちらの方が時間を制御しやすく扱いやすいです。


まとめ

  • ロックは、同時アクセスによるデータ破壊(二重予約など)を防ぐための交通整理。
  • 排他ロックは「私が使うから触らないで」、共有ロックは「読むのはOK、書き込みは待って」。
  • 書き込み(UPDATE 等)は自動でロックがかかり、SELECT ... FOR UPDATE で明示的にもかけられる。ロックは COMMIT / ROLLBACK で解放される。
  • 先に鍵をかけるのが悲観ロック、保存時に version で衝突を確認するのが楽観ロック。
  • お互いが待ち合って止まるのがデッドロック。DB が自動検知し、片方をエラーで巻き戻す。

最初のうちは、「書き込み中のデータには鍵がかかり、他の人は待たされる」というイメージさえ掴めれば十分です。次回は、ロックの解放スイッチでもある COMMIT / ROLLBACK を中心に、トランザクションの仕組みを掘り下げます。


参考リソース

コメント

タイトルとURLをコピーしました