【SQL】テーブル・行ロック完全ガイド|SELECT FOR UPDATE・共有ロック・楽観的ロック・デッドロック回避・MVCC・RDBMS別構文まで解説

複数のユーザーやアプリケーションが同時に同じデータを更新しようとしたとき、何も対策しないと「在庫が 1 個なのに 2 件の注文が成立してしまう」「ポイントの二重付与が起きる」といった整合性の崩れが発生します。

これを防ぐのが SQL のロック機能です。しかしロックを誤って使うとデッドロック・長時間待ち・パフォーマンス低下につながります。

この記事では共有ロック・排他ロック・行ロック・テーブルロックの違いから、楽観的ロック / 悲観的ロックの設計パターン、デッドロックの回避、MVCC の仕組みまで MySQL・PostgreSQL・SQL Server の実例で体系的に解説します。

サンプルデータ(以降の例で使用)
-- stocks テーブル(在庫)
-- id | product_id | quantity | version | updated_at
--  1 |       1001 |       50 |       1 | 2024-04-01 10:00:00
--  2 |       1002 |        0 |       3 | 2024-04-01 09:30:00
--  3 |       1003 |      200 |       1 | 2024-04-01 08:00:00

-- orders テーブル(注文)
-- id | customer_id | product_id | quantity | status    | created_at
--  1 |           1 |       1001 |        1 | pending   | 2024-04-01 10:05:00
--  2 |           2 |       1001 |        2 | pending   | 2024-04-01 10:05:01
スポンサーリンク

ロックが必要な理由:競合更新と整合性問題

ロックなしで複数のトランザクションが同時に読み書きすると、以下の整合性問題が発生します。

問題名 内容 具体例
ダーティリード コミット前のデータを別トランザクションが読む 在庫 50 を 49 に更新中(未コミット)に別トランザクションが 49 を読んで処理
非反復読み取り 同じ行を2回読んだら結果が違う 1回目: quantity=50 → 2回目: quantity=48(別トランザクションが更新・コミット済み)
ファントムリード 同じ条件で2回 SELECT したら行数が違う 在庫 > 0 の検索で 1回目: 3行 → 2回目: 2行(別トランザクションが削除)
ロストアップデート 2つの更新が互いを上書きする A: quantity=50 を読む → B: quantity=50 を読む → A: 49 に更新 → B: 49 に更新(A の更新が消える)
ロストアップデートが起きるケース(悪い例)
-- セッション A
BEGIN;
SELECT quantity FROM stocks WHERE id = 1;  -- 50 を取得
-- (セッション B も 50 を取得して更新する)
UPDATE stocks SET quantity = 49 WHERE id = 1;
COMMIT;

-- セッション B(同時実行)
BEGIN;
SELECT quantity FROM stocks WHERE id = 1;  -- 50 を取得(A の更新前)
UPDATE stocks SET quantity = 48 WHERE id = 1;  -- A の -1 が消える
COMMIT;
-- 結果: quantity = 48 (正しくは 48 でよいが A の更新が反映されていない状態が起きうる)

ロックの種類:共有ロック(S)と排他ロック(X)

SQL のロックは大きく 共有ロック(S ロック)排他ロック(X ロック) の 2 種類に分類されます。

種類 略称 取得した行の操作 他トランザクションの共有ロック取得 他トランザクションの排他ロック取得
共有ロック(Shared Lock) S ロック 読み取り専用(更新不可) 可(複数 S ロック共存可) 不可(待ち)
排他ロック(Exclusive Lock) X ロック 読み取り・更新とも可 不可(待ち) 不可(待ち)

UPDATE・DELETE・INSERT は自動的に X ロックを取得します。SELECT は通常ロックを取得しませんが、FOR UPDATE / LOCK IN SHARE MODE を付けることで明示的にロックを取得できます。

操作 自動取得ロック 他トランザクションへの影響
SELECT(通常) なし なし(MVCC スナップショット読み取り)
SELECT FOR UPDATE X ロック 他の X・S ロック取得が待ちになる
SELECT FOR SHARE / LOCK IN SHARE MODE S ロック 他の X ロック取得が待ちになる
INSERT / UPDATE / DELETE X ロック(自動) 同行への他の X・S ロック取得が待ちになる
LOCK TABLE … WRITE テーブル全体 X ロック テーブルへの全アクセスが待ちになる

SELECT FOR UPDATE:行単位の排他ロック(悲観的ロック)

SELECT ... FOR UPDATE は指定した行に 排他ロック(X ロック)を取得します。ロックを取得したトランザクションがコミット or ロールバックするまで、他のトランザクションは同じ行の更新・ロック取得が待ちになります。

「先に読んで、後で更新する」一連の操作を安全に行う悲観的ロックの基本パターンです。

SELECT FOR UPDATE の基本(在庫の排他ロック)
-- セッション A: 在庫をロックして更新
BEGIN;
SELECT quantity FROM stocks WHERE id = 1 FOR UPDATE;
-- quantity = 50 を取得し、同時に排他ロックを取得

-- この時点でセッション B が同じ行を FOR UPDATE しようとすると「待ち」になる

UPDATE stocks SET quantity = quantity - 1 WHERE id = 1;
-- quantity: 50 → 49
COMMIT;  -- ロック解放

-- セッション B(セッション A と同時)
BEGIN;
SELECT quantity FROM stocks WHERE id = 1 FOR UPDATE;
-- セッション A のコミット後に続行: quantity = 49 を取得

UPDATE stocks SET quantity = quantity - 1 WHERE id = 1;
-- quantity: 49 → 48
COMMIT;
RDBMS 別の FOR UPDATE 構文
-- MySQL: FOR UPDATE
BEGIN;
SELECT * FROM stocks WHERE id = 1 FOR UPDATE;
UPDATE stocks SET quantity = quantity - 1 WHERE id = 1;
COMMIT;

-- MySQL 8.0+: NOWAIT(待たずにエラー)/ SKIP LOCKED(スキップ)
SELECT * FROM stocks WHERE id = 1 FOR UPDATE NOWAIT;
-- → ロック取得できない場合即エラー(ER_LOCK_NOWAIT)

SELECT * FROM stocks WHERE quantity > 0 FOR UPDATE SKIP LOCKED;
-- → ロック中の行をスキップして取得できる行だけ返す(キューの処理に便利)

-- PostgreSQL
BEGIN;
SELECT * FROM stocks WHERE id = 1 FOR UPDATE;
UPDATE stocks SET quantity = quantity - 1 WHERE id = 1;
COMMIT;

-- PostgreSQL: NOWAIT / SKIP LOCKED
SELECT * FROM stocks WHERE id = 1 FOR UPDATE NOWAIT;
SELECT * FROM stocks WHERE quantity > 0 FOR UPDATE SKIP LOCKED;

-- SQL Server: WITH (UPDLOCK, ROWLOCK)
BEGIN TRANSACTION;
SELECT * FROM stocks WITH (UPDLOCK, ROWLOCK) WHERE id = 1;
UPDATE stocks SET quantity = quantity - 1 WHERE id = 1;
COMMIT TRANSACTION;
SKIP LOCKED の実務活用(ジョブキュー):
複数ワーカーが同じキューテーブルから「未処理ジョブ」を取り出す場合、FOR UPDATE SKIP LOCKED を使うと各ワーカーが異なる行をロックして並列処理できます。待ち時間なしにスケールアウトできる設計で、バックグラウンドジョブの実装に有効です。

LOCK IN SHARE MODE / FOR SHARE:行単位の共有ロック

SELECT ... LOCK IN SHARE MODE(MySQL)/ SELECT ... FOR SHARE(MySQL 8.0+ / PostgreSQL)は指定行に 共有ロック(S ロック)を取得します。複数のトランザクションが同時に S ロックを持てますが、X ロックを取得しようとするトランザクションは待ちになります。

LOCK IN SHARE MODE の使い方
-- 在庫を「読み取り専用」でロック(他が更新できないようにする)
BEGIN;
SELECT quantity FROM stocks WHERE id = 1 LOCK IN SHARE MODE;
-- または MySQL 8.0+ / PostgreSQL:
SELECT quantity FROM stocks WHERE id = 1 FOR SHARE;

-- この間、他のトランザクションは:
-- ・ SELECT(ロックなし)→ OK(MVCC スナップショットを読む)
-- ・ SELECT ... FOR SHARE   → OK(S ロックは共存できる)
-- ・ SELECT ... FOR UPDATE  → 待ちになる
-- ・ UPDATE / DELETE        → 待ちになる

-- S ロックを保持したまま、参照した結果に基づいて処理を確認
-- この後 UPDATE する場合は X ロックへのエスカレーションが必要
UPDATE stocks SET quantity = quantity - 1 WHERE id = 1;
-- ※ S→X へのロックアップグレードが起きる(デッドロックに注意)
COMMIT;

-- ===== 主な用途:外部キー整合性の確認 =====
-- product を参照する stock を安全に INSERT する前に
-- 親テーブルの product が存在することを S ロックで確認
BEGIN;
SELECT id FROM products WHERE id = 1001 FOR SHARE;
-- product_id=1001 が削除されないように S ロックを取得
INSERT INTO stocks (product_id, quantity) VALUES (1001, 100);
COMMIT;

LOCK TABLE / LOCK TABLES:テーブルレベルのロック

行ロックではなくテーブル全体をロックする方法です。大量のバッチ処理や一括更新で行ロックより確実に排他制御したい場合に使います。ただし 影響範囲が大きく並行処理を著しく下げるため、使用は慎重に判断してください。

LOCK TABLES(MySQL)
-- テーブル全体を書き込みロック(WRITE)
LOCK TABLES stocks WRITE;
-- この間、他のセッションは stocks への SELECT・UPDATE・DELETE が待ちになる

UPDATE stocks SET quantity = 0 WHERE product_id = 1002;

UNLOCK TABLES;  -- 必ずロックを解放する

-- READ ロック(読み取り専用・他セッションも SELECT は可)
LOCK TABLES stocks READ;
SELECT * FROM stocks;
UNLOCK TABLES;

-- 複数テーブルのロック(必要なすべてを一度に指定)
LOCK TABLES stocks WRITE, orders READ;
-- 注意: ロックしていないテーブルはアクセス不可になる
UNLOCK TABLES;

-- ※ LOCK TABLES は InnoDB では行ロック(FOR UPDATE)が推奨
-- テーブルロックは MyISAM や一括バッチ処理時に限定的に使う
LOCK TABLE(PostgreSQL / SQL Server)
-- PostgreSQL: LOCK TABLE
BEGIN;
LOCK TABLE stocks IN EXCLUSIVE MODE;
-- EXCLUSIVE MODE: SELECT は可・UPDATE/INSERT/DELETE は待ち
-- ACCESS EXCLUSIVE MODE(最強): SELECT も含む全アクセスが待ち
LOCK TABLE stocks IN ACCESS EXCLUSIVE MODE;

UPDATE stocks SET quantity = 0 WHERE product_id = 1002;
COMMIT;  -- トランザクション終了でロック自動解放

-- PostgreSQL のロックモード一覧(強度順)
-- ACCESS SHARE        → 通常の SELECT が使用
-- ROW SHARE           → SELECT FOR UPDATE が使用
-- ROW EXCLUSIVE       → UPDATE/DELETE/INSERT が使用
-- SHARE UPDATE EXCLUSIVE
-- SHARE               → CREATE INDEX(非並行)が使用
-- SHARE ROW EXCLUSIVE
-- EXCLUSIVE
-- ACCESS EXCLUSIVE    → DROP TABLE/TRUNCATE など

-- SQL Server: TABLOCKX ヒント
BEGIN TRANSACTION;
SELECT * FROM stocks WITH (TABLOCKX);
-- または
UPDATE stocks WITH (TABLOCKX) SET quantity = 0 WHERE product_id = 1002;
COMMIT TRANSACTION;
テーブルロックの注意点:
テーブル全体をロックすると、そのテーブルへのすべてのアクセスが待ちになりアプリケーション全体のパフォーマンスが低下します。InnoDB(MySQL)では行ロック(FOR UPDATE)が推奨されます。テーブルロックが適切なのは「バッチ処理で全行を一括更新する」「テーブル構造変更前に書き込みを止める」などの限定的なケースです。

楽観的ロック vs 悲観的ロックの設計パターン

ロックには大きく分けて2つの設計方針があります。どちらを選ぶかはシステムの同時更新頻度や許容レイテンシによって決まります。

項目 悲観的ロック 楽観的ロック
手法 SELECT FOR UPDATE で事前にロックを取得 バージョン列(version / updated_at)で衝突を検出
ロック取得のタイミング SELECT 時(早い) UPDATE 時のみ(遅い)
向いている状況 同時更新の競合が多い・確実に整合性を守りたい 競合がまれ・読み取りが多く更新が少ない
デメリット ロック待ちが発生・長時間保持するとパフォーマンス低下 衝突時にアプリ側でリトライが必要
代表的な使用例 在庫引き当て・ポイント加算・番号採番 記事の編集・設定変更・競合がまれな更新
楽観的ロック(version 列で衝突検知)
-- stocks テーブルに version 列を追加(整合性チェック用)
ALTER TABLE stocks ADD COLUMN version INT NOT NULL DEFAULT 1;

-- ① 読み取り時に version を取得
SELECT id, quantity, version FROM stocks WHERE id = 1;
-- → quantity = 50, version = 1

-- ② 更新時に version を WHERE 条件に含める
UPDATE stocks
SET quantity = quantity - 1,
    version  = version + 1
WHERE id = 1
  AND version = 1;  -- ← 読んだ時点の version と一致するか確認

-- UPDATE の影響行数を確認
-- 影響行数 = 1 → 更新成功(競合なし)
-- 影響行数 = 0 → 競合発生(別トランザクションが先に更新した)
--                → アプリ側でリトライまたはエラー処理が必要

-- ③ タイムスタンプを version 代わりに使う方法
UPDATE stocks
SET quantity   = quantity - 1,
    updated_at = NOW()
WHERE id = 1
  AND updated_at = '2024-04-01 10:00:00';  -- 読んだ時点の updated_at
悲観的ロック(SELECT FOR UPDATE で確実に保護)
-- 在庫引き当ての完全な例(悲観的ロック)
BEGIN;

-- ① 在庫行を排他ロックで取得
SELECT id, quantity
FROM stocks
WHERE product_id = 1001
FOR UPDATE;
-- 他のトランザクションはこの行を FOR UPDATE できない

-- ② 在庫チェック(アプリ側で判定)
-- quantity >= 注文数量 であれば以下に進む

-- ③ 在庫を減らす
UPDATE stocks
SET quantity = quantity - 1
WHERE product_id = 1001;

-- ④ 注文レコードを作成
INSERT INTO orders (customer_id, product_id, quantity, status)
VALUES (1, 1001, 1, 'confirmed');

COMMIT;
-- コミット時にロック解放 → 待っていた別トランザクションが続行

ロック待ちとタイムアウトの制御

ロック待ちが長くなるとアプリケーションが応答しなくなります。タイムアウト設定と NOWAIT を使ってロック待ちを制御しましょう。

ロック待ちタイムアウトの設定
-- ===== MySQL: innodb_lock_wait_timeout(デフォルト 50 秒)=====
-- セッション単位で変更(秒)
SET SESSION innodb_lock_wait_timeout = 5;

-- 確認
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

-- タイムアウト時のエラー
-- ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

-- ===== MySQL 8.0+: NOWAIT / SKIP LOCKED =====
-- NOWAIT: ロック取得できなければ即エラー
SELECT * FROM stocks WHERE id = 1 FOR UPDATE NOWAIT;

-- SKIP LOCKED: ロック中の行をスキップ(キュー処理向け)
SELECT * FROM stocks
WHERE quantity > 0
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED;

-- ===== PostgreSQL: lock_timeout =====
SET lock_timeout = '5s';
-- タイムアウト時: ERROR: canceling statement due to lock timeout

-- NOWAIT(PostgreSQL)
SELECT * FROM stocks WHERE id = 1 FOR UPDATE NOWAIT;

-- ===== SQL Server: ロックタイムアウト =====
SET LOCK_TIMEOUT 5000;  -- ミリ秒単位(5秒)
-- タイムアウト時: エラー 1222

デッドロックの仕組みと回避策

デッドロックは2つのトランザクションが互いに相手のロック解放を待ち合う状態です。RDBMS はデッドロックを自動検知して一方のトランザクションをロールバックします。

デッドロックが発生するパターン
-- セッション A と B が逆順でロックを取得するとデッドロックになる

-- セッション A
BEGIN;
SELECT * FROM stocks WHERE id = 1 FOR UPDATE;   -- id=1 を X ロック取得
-- (この後 id=2 を取ろうとする)
SELECT * FROM stocks WHERE id = 2 FOR UPDATE;   -- 待ち ← B が id=2 を持っている

-- セッション B(同時実行)
BEGIN;
SELECT * FROM stocks WHERE id = 2 FOR UPDATE;   -- id=2 を X ロック取得
-- (この後 id=1 を取ろうとする)
SELECT * FROM stocks WHERE id = 1 FOR UPDATE;   -- 待ち ← A が id=1 を持っている

-- → 互いに待ち合い → デッドロック
-- RDBMS がどちらかをロールバック(犠牲トランザクション)
-- MySQL: ERROR 1213 (40001): Deadlock found when trying to get lock
-- PostgreSQL: ERROR: deadlock detected
デッドロック回避のパターン
-- ===== 対策①: ロック取得の順序を統一する(最も有効)=====
-- 悪い例: セッション A は id 昇順、セッション B は id 降順でロック
-- 良い例: 全セッションで id 昇順(小さい id から)でロック

BEGIN;
SELECT * FROM stocks WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
-- ORDER BY id で昇順にロックを取得 → 全セッションで同じ順序になる
COMMIT;

-- ===== 対策②: ロック保持時間を最小化する =====
-- ロックを取得してから COMMIT するまでの時間を短くする
-- NG: ロック中に外部 API を呼ぶ(応答待ちの間ロックを保持)
BEGIN;
SELECT * FROM stocks WHERE id = 1 FOR UPDATE;
-- ← ここで外部 API を呼ぶのは NG
COMMIT;

-- ===== 対策③: NOWAIT でデッドロックを早期検知 =====
BEGIN;
SELECT * FROM stocks WHERE id = 1 FOR UPDATE NOWAIT;
-- 他がロック中なら即エラー → アプリ側でリトライ
COMMIT;

-- ===== 対策④: 楽観的ロックへの切り替え =====
-- 競合頻度が低い場合は SELECT FOR UPDATE をやめ version 列で楽観的ロックにする
-- → デッドロック自体が起きない

-- ===== MySQL: 最後のデッドロック情報を確認 =====
SHOW ENGINE INNODB STATUS;
-- LATEST DETECTED DEADLOCK セクションに詳細が出る
デッドロック発生時の対応方針:

  • RDBMS は自動でデッドロックを検知・片方をロールバックするため、アプリ側は必ずリトライを実装する
  • デッドロックが頻発する場合は SHOW ENGINE INNODB STATUS(MySQL)や pg_locks(PostgreSQL)でロック競合を分析する
  • 最も効果的な対策はロック取得順序の統一(全セッションで同じ順序でロックを取る)

MVCC(多版型同時実行制御)とロックの関係

MySQL InnoDB や PostgreSQL は MVCC(Multi-Version Concurrency Control) を採用しており、通常の SELECT はロックを取得せず「コミット済みスナップショット」を読みます。これにより読み取りと書き込みが互いをブロックしない設計になっています。

操作 MVCC ありの動作 ロックの取得
通常 SELECT トランザクション開始時点のスナップショットを読む なし(他の更新をブロックしない)
SELECT FOR UPDATE 最新のコミット済みデータを読む(スナップショット無視) X ロック取得
UPDATE / DELETE 最新コミット済みデータに対して操作・旧バージョンを保持 X ロック取得
MVCC の動作確認
-- セッション A: トランザクション開始
BEGIN;
-- この時点でのスナップショットを保持

-- セッション B: 在庫を更新してコミット
BEGIN;
UPDATE stocks SET quantity = 40 WHERE id = 1;  -- 50 → 40
COMMIT;

-- セッション A: スナップショット読み取り(MVCC)
SELECT quantity FROM stocks WHERE id = 1;
-- → 50 が返る(B のコミット後でも A のスナップショット時点 = 50)

-- セッション A: FOR UPDATE は最新データを読む
SELECT quantity FROM stocks WHERE id = 1 FOR UPDATE;
-- → 40 が返る(最新のコミット済みデータ)

-- ※ これが MVCC と FOR UPDATE の重要な違い
-- MVCC の SELECT は「絵葉書(スナップショット)」を読む
-- FOR UPDATE は「今の現実(最新コミット)」を読んでロックを取る

ROLLBACK;

-- ===== 分離レベルとの関係 =====
-- READ COMMITTED: コミット済みデータを常に最新で読む
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- REPEATABLE READ(MySQL のデフォルト): 同一トランザクション内では同じ値を返す
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- SERIALIZABLE: 全ての SELECT が自動的に FOR SHARE 相当のロックを取得
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

ロック状態の確認方法(MySQL / PostgreSQL / SQL Server)

ロック待ちやデッドロックが発生した際に、どのセッションが何をロックしているか確認する方法です。

MySQL: ロック状態の確認
-- ===== 現在のロック待ち状況 =====
SELECT
    r.trx_id AS waiting_trx_id,
    r.trx_mysql_thread_id AS waiting_thread,
    r.trx_query AS waiting_query,
    b.trx_id AS blocking_trx_id,
    b.trx_mysql_thread_id AS blocking_thread,
    b.trx_query AS blocking_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;

-- MySQL 8.0 以降: performance_schema を使用
SELECT
    OBJECT_SCHEMA, OBJECT_NAME,
    LOCK_TYPE, LOCK_MODE, LOCK_STATUS,
    PROCESSLIST_ID, PROCESSLIST_INFO
FROM performance_schema.data_locks dl
JOIN performance_schema.threads t ON dl.THREAD_ID = t.THREAD_ID;

-- ロック中のプロセスを確認して強制終了
SHOW PROCESSLIST;
KILL 123;  -- スレッド ID を指定して強制切断

-- 最後のデッドロック情報
SHOW ENGINE INNODB STATUS\G
PostgreSQL: ロック状態の確認
-- ロック待ちセッションの確認
SELECT
    a.pid,
    a.usename,
    a.state,
    a.wait_event_type,
    a.wait_event,
    a.query,
    age(now(), a.query_start) AS query_duration
FROM pg_stat_activity a
WHERE a.wait_event_type = 'Lock';

-- 詳細なロック情報(どの行・テーブルがロック中か)
SELECT
    l.pid,
    l.relation::regclass AS table_name,
    l.mode,
    l.granted,
    a.query
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE NOT l.granted;  -- 待ち状態のロックのみ

-- ブロックしているセッションを強制終了
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE pid = 456;
SQL Server: ロック状態の確認
-- ロック待ちの確認
SELECT
    r.session_id AS waiting_session,
    r.blocking_session_id AS blocking_session,
    r.wait_type,
    r.wait_time / 1000.0 AS wait_seconds,
    r.command,
    t.text AS query
FROM sys.dm_exec_requests r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE r.blocking_session_id > 0;

-- ロック中の全セッション
SELECT *
FROM sys.dm_exec_sessions s
JOIN sys.dm_exec_requests r ON s.session_id = r.session_id
WHERE r.blocking_session_id > 0;

-- セッションを強制切断
KILL 57;  -- session_id を指定
ロックされているテーブルの確認方法の詳細は【SQL】ロックされているテーブルを確認する方法で解説しています。

実務でよく使うロックパターン集

在庫引き当て(悲観的ロック)
-- 在庫不足時は注文を作らない完全なパターン
BEGIN;

SELECT quantity
FROM stocks
WHERE product_id = 1001
FOR UPDATE;
-- quantity を取得しつつ排他ロック

-- アプリ: quantity >= order_quantity なら続行

UPDATE stocks
SET quantity = quantity - 2  -- 2個注文
WHERE product_id = 1001
  AND quantity >= 2;  -- 念のためDBでも在庫チェック

-- UPDATE の影響行数 = 0 なら在庫不足 → ROLLBACK

INSERT INTO orders (customer_id, product_id, quantity, status)
VALUES (1, 1001, 2, 'confirmed');

COMMIT;
連番採番(テーブルを番号管理に使う)
-- 採番テーブル
-- CREATE TABLE sequences (name VARCHAR(50) PRIMARY KEY, next_val INT);
-- INSERT INTO sequences VALUES ('order_no', 1);

BEGIN;
SELECT next_val FROM sequences WHERE name = 'order_no' FOR UPDATE;
-- next_val = 1001 を取得・ロック

UPDATE sequences SET next_val = next_val + 1 WHERE name = 'order_no';

-- 取得した 1001 を注文番号として使用
INSERT INTO orders (order_no, customer_id, amount)
VALUES (1001, 1, 12000);

COMMIT;
-- 次のトランザクションは next_val = 1002 から始まる
ジョブキュー処理(SKIP LOCKED)
-- job_queue テーブル(status: pending/processing/done)
-- MySQL 8.0+ / PostgreSQL

-- ワーカーが「処理中でない」ジョブを 1 件取得
BEGIN;

UPDATE job_queue
SET status = 'processing',
    started_at = NOW()
WHERE id = (
    SELECT id FROM job_queue
    WHERE status = 'pending'
    ORDER BY created_at
    LIMIT 1
    FOR UPDATE SKIP LOCKED  -- ロック中のジョブをスキップ
)
RETURNING id, payload;  -- PostgreSQL の場合

COMMIT;

-- 複数ワーカーが並行して実行しても、各ワーカーが別のジョブを取得する
-- SKIP LOCKED がなければワーカーが同じジョブを取り合いデッドロックになりやすい

よくある質問(FAQ)

QSELECT FOR UPDATE と UPDATE(ロックなし)の違いは何ですか?
AUPDATE は更新対象行に自動的に X ロックを取得しますが、「SELECT してアプリで値を確認してから UPDATE する」パターンではSELECT と UPDATE の間に別トランザクションが割り込む可能性があります。SELECT ... FOR UPDATE で SELECT 時にロックを取得することで、読み取りから更新までの一連の操作を安全に行えます。
Qロックはいつ解放されますか?
ASELECT FOR UPDATE / LOCK TABLE で取得したロックはトランザクションの COMMIT または ROLLBACK 時に解放されます。MySQL の LOCK TABLESUNLOCK TABLES または新しい LOCK TABLES の実行で解放されます。セッションが切断された場合も自動的に解放されます。
Q楽観的ロックと悲観的ロックはどちらを使えばよいですか?
A競合(同時に同じデータを更新しようとすること)が頻繁に発生する場合は悲観的ロック(SELECT FOR UPDATE)、競合がまれな場合は楽観的ロック(version 列)が向いています。在庫引き当てやポイント加算など「絶対に重複させてはいけない」操作には悲観的ロック、記事の編集など「たまに競合しても再送信で解決できる」操作には楽観的ロックが一般的です。
Qデッドロックが起きたらどうすればよいですか?
ARDBMS は自動でデッドロックを検知し、どちらか一方のトランザクションをロールバックします。アプリ側はこのエラーを検知してリトライするロジックを実装してください。デッドロックが頻発する場合は、ロック取得順序を統一する(常に id 昇順でロック取得するなど)ことが最も効果的な対策です。
Q行ロックとテーブルロックはどう使い分けますか?
A通常は行ロック(SELECT FOR UPDATE)を使います。影響範囲が最小限で並行処理性能を維持できます。テーブルロックが適切なのは「全行を一括で更新するバッチ処理」「テーブル構造を変更する前に書き込みを完全に止めたい」など、意図的にテーブル全体の操作を一時的に直列化したい限定的なケースです。

まとめ

SQL のロックを正しく使うための要点をまとめます。

ポイント 内容
共有ロック(S) 複数同時取得可・X ロックと競合。FOR SHARE / LOCK IN SHARE MODE
排他ロック(X) 1 つしか取得できない・S・X とも競合。FOR UPDATE / UPDATE/DELETE で自動取得
SELECT FOR UPDATE 悲観的ロックの基本。NOWAIT / SKIP LOCKED で待ち制御
楽観的ロック version 列で衝突検知。競合がまれな場合に有効・デッドロックが起きない
テーブルロック 影響大。通常は行ロックを使い、バッチ処理など限定的に使用
デッドロック回避 ロック取得順序を統一・ロック保持時間を最小化・NOWAIT でリトライ
MVCC 通常 SELECT はロックなしでスナップショットを読む。FOR UPDATE は最新データを読む
タイムアウト MySQL: innodb_lock_wait_timeout / PostgreSQL: lock_timeout

ロックはシステムの整合性を保つ重要な機能ですが、誤用するとパフォーマンス低下やデッドロックを招きます。行ロックを基本とし、楽観的ロックと悲観的ロックを状況に応じて使い分けましょう。