お久しぶりです。月歩人です。
転職してから、Qiitaを書く手がすっかり止まってました。
最近はAIの群雄割拠もあって「今さら自分が記事を書いてもなぁ…」みたいな気持ちになり、正直モチベが湧きませんでした。
とはいえ、現場でちゃんとハマって、ちゃんと解いた話は未来の自分(と誰か)を助けるはずなので、久々に書きます。
SpamがHamを参照している & CeleryでSpamを大量更新
今回の前提はこうです。
-
SpamがHamをForeignKeyで参照している(Spam N件 : Ham 1件) - ワーカー(Celery task)は
Spamを大量に更新する - ただし、同じ
ham_idに紐づくSpamが並列で更新されると、レースによる不整合が起きる - そこで「同一
ham_id配下の処理は直列化したい」= Ham単位で排他したい - 重要:Ham自体は更新しない(Hamは“ロックのキー”として使いたいだけ)
モデル構造(ER図:Before)
方針: Hamを select_for_update でロックする
「同じHam配下のSpam更新は同時に走らせたくない」ので、当初は
-
Spamを処理するたびに、参照先のHamをselect_for_update()でロックする
という方針にしました。
ざっくりのイメージ(当初)
このやり方は、レースを消すという点ではちゃんと効きました。
大量タスクでHamロックが長時間化し、Hamが操作不能っぽくなる問題発生
ところが現場では、次の条件が重なりました。
- 同一
ham_idに対する処理が偏る(同じHam配下のSpamが多い/同時投入される) - 2000件規模でタスクが走る
- 1件あたりの処理が重く、トランザクションが伸びやすい
MySQL(InnoDB)は トランザクションが長いほどロック保持も長い ため、待ちが連鎖します。
そして「Hamをロックしている」せいで、
- Hamテーブルへのアクセス(参照/更新)が待ちやすくなる
- 管理画面や別機能が「Ham触れないんだけど…?」みたいになる
という状態になりました。
やりたいのは “HamをロックキーにしてSpam更新を直列化” することだけ。
なのに Ham本体が巻き添えで詰まるのがつらい。
解決:Hamはロックしない。Ham専用のLockモデルを1対1で作る
そこで、Ham と 1対1 のロック用テーブル HamLock を作り、ロック対象を移しました。
モデル構造(ER図:After)
ポイントは以下です。
-
select_for_update()を掛けるのは HamではなくHamLock - HamLockは ham_idで1行(Ham 1件につきLock 1件)
- 同じ
ham_idに対する処理は HamLock 行ロックで直列化できる - Ham本体をロックしないので、Hamテーブルが巻き添えで詰まりにくい
実装例(Django)
models.py
from django.db import models
class Ham(models.Model):
updated_at = models.DateTimeField(auto_now=True)
class Spam(models.Model):
ham = models.ForeignKey(Ham, on_delete=models.CASCADE, related_name="spams")
state = models.CharField(max_length=32, default="new")
updated_at = models.DateTimeField(auto_now=True)
class HamLock(models.Model):
# Hamと1on1(ham_idをPKにして「Ham1件につきLock1件」にする)
ham = models.OneToOneField(Ham, on_delete=models.CASCADE, primary_key=True)
updated_at = models.DateTimeField(auto_now=True)
Celery task(Spamを更新するときだけ、ham_id単位で直列化)
タスクは spam_id を受け取り、そこから ham_id を引いてロックします。
重要:重い処理をトランザクション内に入れない
Lockモデルにしても、atomic内で重い処理をやるとロック保持が伸びて詰むので、最小区間だけにします。
tasks.py(例)
from celery import shared_task
from django.db import transaction
from .models import Spam, HamLock
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, max_retries=3)
def process_spam_task(self, spam_id: int) -> None:
# まず ham_id を取る(ここはロック不要)
spam = Spam.objects.only("id", "ham_id", "state").get(id=spam_id)
ham_id = spam.ham_id
# 1) ham_id単位の排他:HamLockをロックして「同じHam配下の処理」を直列化
with transaction.atomic():
HamLock.objects.select_for_update().get(ham_id=ham_id)
# 同一spamの二重実行が起き得るなら、Spam自体もここで軽くガード
spam = Spam.objects.select_for_update().get(id=spam_id)
if spam.state in ["done", "processing"]:
return
spam.state = "processing"
spam.save(update_fields=["state", "updated_at"])
# 2) 重い処理はatomicの外へ(外部API/計算/ファイルI/O等)
# ... heavy_work(spam_id)
# 3) 最後の確定だけ短く
with transaction.atomic():
HamLock.objects.select_for_update().get(ham_id=ham_id)
spam = Spam.objects.get(id=spam_id)
spam.state = "done"
spam.save(update_fields=["state", "updated_at"])
なぜLockモデルで解決できるのか
Before:Hamをロック(Hamへのアクセス全般を巻き込みやすい)
After:HamLockをロック(排他は維持しつつ、影響範囲を隔離)
Hamをロックせずに、Ham単位のレース対策だけ残せた
今回更新されるのはSpamで、Hamは「ham_id単位で直列化するための排他キー」として使っているだけです。
結果として、
- 同じham_id配下のSpam処理の直列化(レース対策)は維持できた
- でもロック対象は HamLock なので、Ham本体の巻き添えが減った
- 大量タスクでも「詰まり」を Lockテーブル側へ隔離できた
という状態になり、運用上の問題が解消しました。
注意点メモ
- Lockモデルにしても atomic内が長いとロックは伸びる
→ 「DB更新に必要な最小区間だけロック」が効く - 偏り(同一ham_idにタスクが集中)をどう扱うかは別問題
→ キュー設計/投入設計を見直す余地はある - DB以外で排他する手もある(Redisロック等)
ただ今回は「DBに寄せて一貫性を取りたい」事情があったので Lockモデル方式にした
最後に全く関係ない話
AI時代、移り変わりの激しさは一昔前のfrontend界隈みたいだなぁと思っています。
非エンジニアでも動くものを作れてしまう時代ですし、「これからエンジニアとして何を重要視すればいいのか」みたいな意見もたくさん流れてきます。
自分もまだ答えは探している途中です。
ただ少なくとも、今回みたいに「現場で詰まったものを、ちゃんと観測して、筋の良い形に直す」みたいな部分は、今後も価値が残ると思っています。