0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Django×MySQL×Celery】select_for_updateの長時間ロックを1on1のLockモデルで隔離した話

0
Posted at

お久しぶりです。月歩人です。
転職してから、Qiitaを書く手がすっかり止まってました。

最近はAIの群雄割拠もあって「今さら自分が記事を書いてもなぁ…」みたいな気持ちになり、正直モチベが湧きませんでした。
とはいえ、現場でちゃんとハマって、ちゃんと解いた話は未来の自分(と誰か)を助けるはずなので、久々に書きます。

SpamがHamを参照している & CeleryでSpamを大量更新

今回の前提はこうです。

  • SpamHamForeignKey で参照している(Spam N件 : Ham 1件
  • ワーカー(Celery task)は Spam を大量に更新する
  • ただし、同じ ham_id に紐づく Spam が並列で更新されると、レースによる不整合が起きる
  • そこで「同一 ham_id 配下の処理は直列化したい」= Ham単位で排他したい
  • 重要:Ham自体は更新しない(Hamは“ロックのキー”として使いたいだけ)

モデル構造(ER図:Before)

方針: Hamを select_for_update でロックする

「同じHam配下のSpam更新は同時に走らせたくない」ので、当初は

  • Spam を処理するたびに、参照先の Hamselect_for_update() でロックする

という方針にしました。

ざっくりのイメージ(当初)

このやり方は、レースを消すという点ではちゃんと効きました。

大量タスクでHamロックが長時間化し、Hamが操作不能っぽくなる問題発生

ところが現場では、次の条件が重なりました。

  • 同一 ham_id に対する処理が偏る(同じHam配下のSpamが多い/同時投入される)
  • 2000件規模でタスクが走る
  • 1件あたりの処理が重く、トランザクションが伸びやすい

MySQL(InnoDB)は トランザクションが長いほどロック保持も長い ため、待ちが連鎖します。
そして「Hamをロックしている」せいで、

  • Hamテーブルへのアクセス(参照/更新)が待ちやすくなる
  • 管理画面や別機能が「Ham触れないんだけど…?」みたいになる

という状態になりました。

やりたいのは “HamをロックキーにしてSpam更新を直列化” することだけ。
なのに Ham本体が巻き添えで詰まるのがつらい。

解決:Hamはロックしない。Ham専用のLockモデルを1対1で作る

そこで、Ham1対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界隈みたいだなぁと思っています。
非エンジニアでも動くものを作れてしまう時代ですし、「これからエンジニアとして何を重要視すればいいのか」みたいな意見もたくさん流れてきます。

自分もまだ答えは探している途中です。
ただ少なくとも、今回みたいに「現場で詰まったものを、ちゃんと観測して、筋の良い形に直す」みたいな部分は、今後も価値が残ると思っています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?