deadlock تو صف دیتابیس لاراول: جابهای اجراشدهای که سر از failed_jobs درمیارن
خلاصهٔ کاملتر
نویسنده تو یه اپ Laravel 13.33 دوهزار جاب رو با هشت worker روی MySQL 5.7 اجرا کرد. همهٔ جابها اجرا شدن، ولی ۷۶ تاشون تو جدول jobs موندن و لاگ پر از خطای deadlock با کد 1213 بود (یعنی دو تراکنش منتظر قفل همدیگه موندن و MySQL یکیشون رو برگردونده). حدود ۹۰ ثانیه بعد، هر ۷۶ تا با پیام «attempted too many times» رفتن تو failed_jobs، در حالی که هیچکدوم واقعاً شکست نخورده بودن.
دلیلش اینه که یه worker داره جاب بعدی رو با FOR UPDATE رزرو میکنه و یه worker دیگه داره ردیف جاب تمومشدهش رو پاک میکنه. این دو تا قفل رو با ترتیب برعکس میگیرن. MySQL معمولاً DELETE رو قربانی میکنه و چون DatabaseJob::delete() هیچ retryی نداره، ردیف رزروشده باقی میمونه. وقتی retry_after تموم بشه، با --tries=1 جاب بدون اجرا شکستخورده ثبت میشه و متد failed() هم براش صدا زده میشه. با --tries=3 هم جاب یه بار دیگه اجرا میشه.
لاراول فقط وقتی از FOR UPDATE SKIP LOCKED (یعنی رد شدن از ردیفهای قفلشده بهجای منتظر موندن) استفاده میکنه که سرور رو بشناسه: MySQL 8.0.1 به بالا، MariaDB 10.6، PostgreSQL 9.5 یا Vitess 19. روی MySQL 8.4 با SKIP LOCKED هیچ deadlockی روی DELETE دیده نشد، ولی وقتی همون سرور مجبور به FOR UPDATE ساده شد، هر اجرا ۵۴۸ تا ۸۱۰ deadlock داشت و تقریباً دو برابر طول کشید. نویسنده یه دستور tinker هم میده که نشون میده workerهای شما از کدوم قفل استفاده میکنن. اگه روی سروری که SKIP LOCKED رو پشتیبانی میکنه خروجی true بود، یعنی لاراول نسخه رو اشتباه خونده و باید کلید version رو تو config/database.php با نسخهٔ واقعی سرور ست کنی.
بخش دوم مقاله دربارهٔ آرگومان attempts تو DB::transaction() هست. نویسنده میگه این retry بدون هیچ مکثی (حدود ۲ میلیثانیه بعد) کل closure رو دوباره اجرا میکنه. یعنی اگه وسطش ایمیل بفرستی، دو تا ایمیل میره، مگه اینکه از DB::afterCommit() استفاده کنی. تراکنش تودرتو هم retry نمیشه و فقط بیرونیترین تراکنش دوباره امتحان میکنه. خطای lock wait timeout (کد 1205) هم مثل deadlock حساب میشه، پس با attempts: 3 ممکنه سه بار ۵۰ ثانیه صبر کنی و بعد از ۱۵۰ ثانیه خطا بگیری.
راهحل پیشنهادی نویسنده یه wrapper با retry() هست که بین تلاشها مکث میکنه و فقط روی SQLSTATE 40001 (deadlock) دوباره امتحان میکنه:
retry(3, fn () => DB::transaction(function () {
// your queries
}), 100, fn (\Throwable $e) => $e instanceof QueryException && $e->getCode() === '40001');تو تست، این نسخه حدود ۱۱۵ میلیثانیه صبر کرد و بار دوم commit شد. با lock wait timeout هم بعد از یه بار صبر کردن تسلیم میشه. ولی هنوز closure دوباره اجرا میشه، پس side effectها باید بعد از commit بیان.
نکات کلیدی:
- روی MySQL 5.7 و MariaDB قبل از 10.6، لاراول با FOR UPDATE ساده جاب رزرو میکنه و DELETE جاب تمومشده ممکنه deadlock بخوره.
- جاب گیرکرده بعد از retry_after (پیشفرض ۹۰ ثانیه) با --tries=1 بدون اجرا تو failed_jobs ثبت میشه و failed() هم صدا زده میشه.
- روی MySQL 8.4 با SKIP LOCKED، صفر deadlock در برابر ۵۴۸ تا ۸۱۰ deadlock با قفل ساده روی همون سرور.
- اگه لاراول نسخهٔ سرور رو اشتباه بخونه، کلید version تو config/database.php درستش میکنه.
- attempts تو DB::transaction() بدون مکث کل closure رو دوباره اجرا میکنه و lock wait timeout رو هم مثل deadlock retry میکنه.
- retry() با فیلتر روی کد 40001 هم مکث اضافه میکنه هم روی timeout زود تسلیم میشه.




