はじめに
トランザクションは、複数のデータ操作を一つのまとまりとして扱い、
途中で問題が起きても整合性が崩れないようにするための仕組みです。
典型的には、次のようなケースで必要になります。
- 複数のテーブルをまたぐ更新がある場合
- 部分的な成功では困る一連の処理(例:お金の移動、在庫更新、予約処理)
- 並列で処理が走り、同時アクセスによる不整合が起きやすい場合
特にSQLの世界では、データの正しさを保つためにトランザクションは重要な役割を持ちます。
この記事では、SQLの基本動作をもとに、トランザクションがどのように使われ、
どのように処理の安全性を確保しているかを扱います。
1. トランザクションとは?
ざっくり言うと
"複数の処理をまとめて成功 or まとめて失敗にする仕組み" です。
途中でエラーが起きたら全部無かったことにできるし、
全部うまくいけばまとめて確定します。
■ 買い物かごの例
- 商品をかごに入れる
- 不要なら戻す
- レジで会計するまで確定しない
これがトランザクションとほぼ同じ動きです。
2. トランザクションの基本構造
BEGIN;
UPDATE ...;
INSERT ...;
COMMIT;
失敗したときは:
ROLLBACK;
■ 基本構造でやっていることは?
- BEGIN → かごを持つ(ここから “まとまり” が始まる)
- UPDATE/INSERT → 入れたり戻したり(まだ確定していない)
- COMMIT → お会計(買い物確定!)
- ROLLBACK → 全部キャンセル(かごの中身が空に戻る)
この “まとめて確定 or まとめて取り消し” が
データの整合性を守ります。
3. 並列処理とロック
アプリは複数処理が同時に DB にアクセスします。
すると ロック が必要となり、状況次第で デッドロック
が発生します。
アプリは一度に1つの処理だけが動くわけではありません。
- API が同時に呼ばれる
- バッチやワーカーが並行で動く
- 複数タスクが同じ行を触る
こういう状況になると ロック が必要になり、
やり方次第で デッドロック という現象が起きます。
デッドロックとは?
「お互いが相手のロックを待ち続けて進めなくなる状態」 のことです。
フォークの例でいえば、
- あなたはフォーク A → B の順で取りたい
- 友達はフォーク B → A の順で取りたい
- どちらも「相手が手放すのを待っている」
→ でも誰も手放さないので永遠に進まない
これがデッドロックです。
DB や並列処理でも同じで、
- 処理A → 行1 → 行2 の順でロック
- 処理B → 行2 → 行1 の順でロック
のように ロック順が食い違う と簡単に発生します。
ただしこれは 「異常」 ではなく、
DBがデータを安全に守るために厳密なロックを行うから起きる自然現象。
一番の対策は「ロックの順番を統一すること」
これだけで大半のデッドロックは避けられます。
4.
フォーク2本問題:デッドロック例
フォーク A と フォーク B の2本だけがあるとします。
-
食事には 2本とも必要
-
あなたと友達が同時に食べようとする
あなたはA → Bの順で取りたい
友達は B → A の順で取りたい
→ どちらも順序が違うので、デッドロックとなります。
5. Pythonでフォーク2本デッドロックを再現
Pythonでデッドロックが起こる処理を作成しました。
フォーク2本を同時に使用する=並列処理を実施すると、デッドロックが発生します。
直列処理の場合は、発生しません。(順番を待つイメージ)
import threading
import time
# フォークA・Bを「ロック(排他制御)」として表現
# → 誰かが使っている間は、他の人は使えない
fork_a = threading.Lock()
fork_b = threading.Lock()
def you():
# あなたはフォークAを先に取りたい
with fork_a:
print("You: got A")
time.sleep(0.1) # ちょっとだけ時間を空けて、デッドロックを起こしやすくする
print("You: want B")
# 次にフォークBを取りに行く
with fork_b:
print("You: got B")
def friend():
# 友達はフォークBを先に取りたい
with fork_b:
print("Friend: got B")
time.sleep(0.1) # こちらも間を空けることで、デッドロックが起きやすくなる
print("Friend: want A")
# 次にフォークAを取りに行く
with fork_a:
print("Friend: got A")
# 2人が同時に行動開始(=並列処理)
t1 = threading.Thread(target=you)
t2 = threading.Thread(target=friend)
t1.start()
t2.start()
# スレッドが終わるまで待つ
t1.join()
t2.join()
6. デッドロック回避:順番を揃えるだけ
回避策は単純です。
ロック順を揃えればOK。
def you():
# あなたは必ず「fork_a → fork_b」の順でフォークを取る
# ※この順番を全員で統一することがデッドロック回避のポイント
with fork_a:
with fork_b:
print("You: eating") # 2本とも取れたので食事できる
def friend():
# 友達も同じく「fork_a → fork_b」の順で取る
# 全員が同じ順序でロックを取りに行くので、にらみ合い(デッドロック)が起きない
with fork_a:
with fork_b:
print("Friend: eating")
7. DBでも同じ(行ロックとフォークの対応)
例として、DB側に主キー:ID=1 と ID=2 の2行があるとします
それぞれの行をロックしてから更新する場合となります。
def tx1(cur):
# トランザクション1は「1 → 2」の順でロックを取りに行く
cur.execute("SELECT balance FROM accounts WHERE id = 1 FOR UPDATE")
print("tx1: locked id=1")
# ここで別トランザクションに実行のチャンスを与えるイメージ
# (実際にはDB側のスケジューラ次第ですが、イメトレ用)
# time.sleep(0.1)
cur.execute("SELECT balance FROM accounts WHERE id = 2 FOR UPDATE")
print("tx1: locked id=2")
# …ここで残高更新などを行う
def tx2(cur):
# トランザクション2は「2 → 1」の順でロックを取りに行く
cur.execute("SELECT balance FROM accounts WHERE id = 2 FOR UPDATE")
print("tx2: locked id=2")
# こちらも途中でコンテキストが切り替わるイメージ
# time.sleep(0.1)
cur.execute("SELECT balance FROM accounts WHERE id = 1 FOR UPDATE")
print("tx2: locked id=1")
# …ここで残高更新などを行う
🔍 何が問題か?
- tx1 は id=1 → id=2
- tx2 は id=2 → id=1
というように、
同じ2行に対して「逆順」でロックを取りに行っている のがポイントです。
8. ロック順をそろえないと、なぜデッドロックするのか?
ここまで「ロック順を統一すると安全」と言ってきましたが、
逆に ロック順がバラバラだとどうなるか を見てみます。
例として、accounts テーブルの id=1 と id=2 の2行を更新するケースを考えます。
def tx1(cur):
# トランザクション1は 1 → 2 の順でロックを取りに行く
cur.execute("SELECT balance FROM accounts WHERE id = 1 FOR UPDATE")
cur.execute("SELECT balance FROM accounts WHERE id = 2 FOR UPDATE")
def tx2(cur):
# トランザクション2は 2 → 1 の順でロックを取りに行く
cur.execute("SELECT balance FROM accounts WHERE id = 2 FOR UPDATE")
cur.execute("SELECT balance FROM accounts WHERE id = 1 FOR UPDATE")
この2つが並列で動くと、タイミング次第で次のような状況になります。
- tx1: id=1 をロック
- tx2: id=2 をロック
- tx1: id=2 をロックしたい → でも tx2 が保持中なので待ち
- tx2: id=1 をロックしたい → でも tx1 が保持中なので待ち
→ お互いが相手のロック解除待ちになり、どちらも進めない
→ これがデッドロック
フォーク2本問題とまったく同じ構図になっているのが分かると思います。
9. ロック順を統一するとどうなるか?
そこでやることはシンプルで、
「ロックを取る順番を全トランザクションで統一する」 だけです。
# from_id / to_id の大小にかかわらず、
# ロックは必ず「小さいID → 大きいID」の順で取得する
a, b = sorted([from_id, to_id])
cur.execute("SELECT balance FROM accounts WHERE id = %s FOR UPDATE", (a,))
cur.execute("SELECT balance FROM accounts WHERE id = %s FOR UPDATE", (b,))
こうしておけば、
- どのトランザクションも 1 → 2 の順でロックを取りに行く
- 2 → 1 のような逆順ロックが出てこない
結果として、
「お互いに相手のロックを待ち合う」状況が起きにくくなります。
デッドロック対策というと難しそうに聞こえますが、
実際には 「みんなで同じ順番で並ぼうね」 というシンプルなルールを守るだけ、
というのがポイントです。
10. DBはデッドロック時に片方をROLLBACKする
PostgreSQL / MySQL / Oracle など多くのDBは、
デッドロックを検出すると、片方のトランザクションを強制ROLLBACKします。
アプリ側は、 リトライ処理 すればOK。
from psycopg2 import OperationalError
import time
def transfer_with_retry(..., max_retries=3):
# デッドロックは「起きて当然」なので、一定回数リトライするのが実践パターン
for i in range(max_retries):
try:
# 実際の振替処理(行ロックを取りながら更新するイメージ)
transfer(...)
return # 成功したら処理終了
except OperationalError:
# psycopg2 はデッドロック検出時に OperationalError を投げる
print("deadlock detected, retrying…")
# 少し待ってから再トライ
# ※すぐ再実行するとまた衝突する可能性があるため短いスリープを挟む
time.sleep(0.3)
# リトライ上限まで失敗したら例外にする(アプリ側に委ねる)
raise RuntimeError("failed after retries")
おわりに
フォークの例で理解すると、
トランザクションとデッドロックの本質はとてもシンプルです。
- ロック順が食い違うと詰まる
- 同じ順なら詰まらない
- DBも中で同じことをしている
この3つを押さえておけば、
トランザクション設計や並列処理の怖さがかなり減ります。
ぜひ実装して試してみてください。