はじめに
今日は「並行アクセスの悪夢」から我々のデータを守る究極の武器、select_for_update()
について語ろうではないか。複数ユーザーが同時に同じレコードを更新する恐怖。そう、それはまるで一つのケーキを奪い合う5歳児たちのようなカオスを生み出すのである。
select_for_update()とは何か
select_for_update()
とは、Djangoが提供する強力な呪文である。これを唱えることにより、選択したレコードに「触るな、今は俺の時間だ」という標識を立てることができる。技術的には、これはデータベースの行レベルロックを取得するSQLのSELECT FOR UPDATE
句を実行するメソッドである。
# 他の開発者に宣言する:「このユーザーは今、私が占有している!」
with transaction.atomic():
user = User.objects.select_for_update().get(id=42)
user.last_login = timezone.now()
user.save()
# ロックは自動的に解放される。さあ、次の戦士よ、前へ!
実践的な実装パターン
基本パターン:シンプルなロック取得
from django.db import transaction
def update_user_points(user_id, points_to_add):
with transaction.atomic():
# ここで魔法が始まる
user = User.objects.select_for_update().get(id=user_id)
# 安全地帯で作業開始
user.points += points_to_add
user.save()
return user.points
待機しない猛者向けパターン
「待つのは時間の無駄だ」と考える者には、nowait=True
オプションがある。これにより、ロックが取得できない場合は即座に例外が発生する。まるでコーヒーショップで「並ぶ気はない。席が空いてなければ別の店に行く」という強気の客のようなものだ。
from django.db import transaction, DatabaseError
def update_stock_nowait(product_id, quantity):
try:
with transaction.atomic():
# 待つなんてしない。今すぐにロックを取得するか、さもなくば去る
product = Product.objects.select_for_update(nowait=True).get(id=product_id)
if product.stock < quantity:
return False
product.stock -= quantity
product.save()
return True
except DatabaseError:
# 他のトランザクションがロックを持っている場合
return False # 「また後で来ます」
複数テーブルにまたがる複雑な取引
複数のモデルをロックする必要がある場合、順序が重要である。デッドロックはプログラマーが夜中に冷や汗をかく原因となる。
def transfer_money(from_account_id, to_account_id, amount):
# 常に小さいIDから大きいIDの順にロックを取得することでデッドロックを回避
if from_account_id > to_account_id:
from_account_id, to_account_id = to_account_id, from_account_id
is_reversed = True
else:
is_reversed = False
with transaction.atomic():
from_account = Account.objects.select_for_update().get(id=from_account_id)
to_account = Account.objects.select_for_update().get(id=to_account_id)
# 向きを戻す
if is_reversed:
from_account, to_account = to_account, from_account
if from_account.balance < amount:
raise InsufficientFunds("銀行強盗でもしない限り、無いものは送れない")
from_account.balance -= amount
to_account.balance += amount
from_account.save()
to_account.save()
# トランザクション履歴を記録
Transfer.objects.create(
from_account=from_account,
to_account=to_account,
amount=amount,
description="スムーズな資金移動。Djangoの排他制御に感謝を"
)
高度なテクニック
特定のテーブル列のみをロック
完全な力を解放したくない場合は、特定の列だけをロックすることも可能だ。
# PostgreSQLの場合
from django.db import transaction
def update_specific_fields(product_id):
with transaction.atomic():
# 在庫と価格のみをロック
product = Product.objects.select_for_update(of=('self',)).get(id=product_id)
# ビジネスロジック...
タイムアウト付きロック
「永遠に待ちたくはない」という賢明な態度のために、タイムアウトを設定できる(PostgreSQL限定の機能)。
from django.db import transaction, OperationalError
def update_with_timeout(user_id):
try:
with transaction.atomic():
# 3秒待っても駄目なら諦める
user = User.objects.select_for_update(timeout=3).get(id=user_id)
# 処理...
except OperationalError:
return "時は金なり。これ以上待てぬ。"
実戦での注意点
-
トランザクション内でのみ使用可能 - トランザクションの外で使うとDjangoは「それは魔法の杖を逆さに持っているようなものだ」と笑うだろう。
-
パフォーマンスへの影響 - ロックは強力だが、使いすぎるとアプリケーションは亀のように遅くなる。適切な粒度で使おう。
-
デッドロックの危険 - 複数のロックを取得する場合は、常に同じ順序で行うこと。さもなければ、デッドロックという名の迷宮に迷い込むことになる。
-
テスト環境での注意 - SQLiteは
select_for_update()
をサポートしていない。テスト環境と本番環境で動作が異なる可能性に注意せよ。
結論
select_for_update()
は、並行アクセス問題に立ち向かう強力な武器である。賢く使えば、データの整合性を保ちながら、ユーザーに「なぜこの更新が消えたんだ!」と叫ばせることもない。
排他制御は技術であると同時に芸術でもある。力強く、しかし繊細に。広く、しかし必要な場所だけに。そして何よりも、コードレビューで同僚に「君は排他制御の真髄を理解している」と言わせることができれば、真の勝利と言えるだろう。
データベースとの戦いで幸運を祈る!そして覚えておいて欲しい。