Hassan Mohammadi
Hassan Mohammadi
خواندن ۶ دقیقه·۳ سال پیش

جنگو و N+1 کوئری

مشکل N+1 یه مسئله عملکردی توی دیتابیس ها هست و این داستان توی خیلی از ORM ها و کوئری های SQL که می نویسیم دیده میشه. توی این پست میخوایم در مورد N+1 کوئری تو جنگو و راه های تشخیص و رفعش صحبت کنیم.

سوالی که اینجا پیش میاد n+1 کوئری دقیقا چیه ؟

به طور خلاصه فرض کنید لیستی از نتایج یه کوئری داریم و بیایم برای هریک از این نتیجه ها یه کوئری دیگه بزنیم، به مثال زیر توجه کنید :

N+1 Queries

books = Book.objects.order_by(&quottitle&quot) for book in books: print(book.title, &quotby&quot, book.author.name)
  • توی خط اول ما یه QuerySets برای مدل Book ایجاد می کنیم ولی چون QuerySets ها معمولا موجوداتی تنبل هستن عملا هیچ کوئری اجرا نمیشه.
  • توی خط دوم با یه حلقه QuerySets ایجاد شده رو پیمایش می کنیم، همین امر باعث میشه که کوئری اجرا بشه واطلاعات مورد نیاز رو واکشی کنه.
  • توی خط سوم ما به فیلد های هر کدوم از کتاب ها دسترسی داریم، مثله اسم کتاب و... تا اینجا جنگو اینارو با همون یه کوئری گرفته، خوب تا اینجا هیچ مشکلی نیست ولی فیلد نویسنده یه کلید خارجیه و جنگو هم اطلاعات نویسنده رو با همون یه کوئری نگرفته پس جنگو دوباره اینجا یه کوئری جدید میزنه تا اطلاعات نویسنده رو بگیره.
  • بنابر این یه کوئری برای واکشی همه کتاب ها اجرا می کنیم و N تا کوئری هم برای گرفتن اسامی نویسنده ها برای مثال واسه گرفتن اطلاعات 100 کتاب ما 100+1 تا کوئری میزنیم 1 کوئری برای گرفتن کل کتاب ها و 100کوئری برای اسامی نویسندها، خوب طبیعتا این روی پرفورمنس دیتابیس تاثیر میزاره و زمان اجرا کوئری هم بالا میره.

کوئری هایی که کد ما ایجاد میکنه این شکلیه :

SELECT id, title, author_id, ... FROM book ORDER BY title

و برای گرفتن اسم هر نویسنده هم این کوئری اجرا میشه :

SELECT id, name, ... FROM author WHERE id = %s

2N+1 Queries

حالا فرض کنید که ما توی جدول A یه کلید خارجی داریم که مارو ارجا میده به جدول B دارم , توی جدول B یه کلید خارجی به جدول C داریم حالا با کوئری که روی جدول A میزنیم میخوایم اطلاعات مورد نظرمون که توی جدول C هست رو واکشی کنیم، پیچیده شد بزارید این مثالو بزنم، توی مثال بالا ما برای گرفتن اطلاعات نویسنده باید یه کوئری می زدیم حالا فرض کنید میخوایم ببینیم نویسنده توی چه کشوری زندگی میکنه و اطلاعات هر کشور هم توی جدول کشور ها ذخیره شده عملا کوئری ما این شکلی میشه :

books = Book.objects.order_by(&quottitle&quot) for book in books: print( book.title, &quotby&quot, book.author.name, &quotfrom&quot, book.author.country.name, )

برای دسترسی به اطلاعات کلید خارجی country اینجا خودش یه کوئری جدید تولید میشه و عملا کوئری های ما نسبت به قبل دو برابر میشه ?

NM+N+1 Queries

حالا فرض کنید مدل ها رو تغییر دادیم و این امکان وجود داره که یه کتاب رو چندیدن نویسنده نوشته باشه، کوئری به این شکل درمیاد :

books = Book.objects.order_by(&quottitle&quot) for book in books: print(book.title, &quotby: &quot, end=&quot&quot) names = [] for author in book.authors.all(): names.append(f&quot{author.name} from {author.country.name}&quot) print(&quot, &quot.join(names))
  • توی خط پنجم هر کوئری رو N بار اجرا میکنه تا اطلاعات نویسنده های هر کتاب رو دریافت کنیم
  • در خط ششم به ازای هر نویسنده ما یه کوئری میزنیم تا اطلاعات کشور اون نویسنده رو بگیریم، اگه به طور متوسط M تا نویسنده برای هر کتاب وجود داشته باشه N تا درخواست هم برای گرفتن اطلاعات کشور نویسنده ها وجود داره.

چطور این مشکلات رو حل کنیم ?

خوب همون طور که مشاهده می کنید این شکل از کوئری ها به شدت روی پرفورمنس پروژه تاثیر میزاره و با بالا رفتن تعداد درخواست ها و حجم دیتای ذخیره شده عملا بعضی جاها سرور دیگه پاسخگو نیست و ممکنه سایت یا پروژه به کلی پایین بیاد و از طرفی چون این شکل از کوئری زدن ها بهینه نیست ما داریم بیخودی منابع سرور رو هدر میدیم.

خوب راه حل چیه؟ جنگو دو شکل از QuerySet ها رو پیشنهاد میکنه که با این روش ها میتونیم N تا کوئری رو به یه کوئری تبدیل کنیم و عملا این مشکل رو حل کنیم، روش اول select_relatedو روش دوم prefetch_relatedهست که در ادامه به هرکدوم بیشتر میپردازیم.

اول بزار فرقشونو بگم بعد بریم قسمت بعدی select_relatedیک کوئری جوین می‌سازه و دیتا رو با یک کوئری می‌گیره ولی prefetch_relatedجوین کردن رو به دیتابیس نمیسپاره و این کارو توی خود پایتون انجام میده، خوب حالا میتونیم بریم قسمت بعدی...

select_related

اول مثال زیر رو مشاهده کنید :

books = Book.objects.order_by(&quottitle&quot).select_related(&quotauthor&quot) for book in books: print(book.title, &quotby&quot, book.author.name)

اینجا دیگه برای گرفتن اطلاعات نویسنده یه کوئری جدید نمیزنه و با استفاده از JOIN تو SQL این دوتا جدول رو باهم ادغام میکنه.

کوئری بالا توی SQL این شکلی میشه :

SELECT book.id, book.title, book.author_id, ..., author.id, author.name, ... FROM book INNER JOIN author ON (book.author_id = author.id) ORDER BY book.title

حالا اون 2N+1 رو چطور حل کنیم ? خیلی ساده میتونیم با chained relation این مشکلم حل کنیم مثال زیر رو مشاهده کنید :

Book.objects.order_by(&quottitle&quot).select_related(&quotauthor&quot, &quotauthor__country&quot)

باید بگم که متاسفانه select_related توی NM+N+1 Queries نمیتونه به ما کمکی کنه و select_related فقط توی روابط یک به یک یا یک به چند به کار میاد در حالی که NM+N+1 روابط زیادی داره ولی خوشبختانه prefetch_relatedمیتونه با روابط زیادی کار کنه.

prefetch_related

استفاده از این متد هم تقریبا شکل مثال بالا هست :

books = Book.objects.order_by(&quottitle&quot).prefetch_related(&quotauthor&quot) for book in books: print(book.title, &quotby&quot, book.author.name)

تفاوت این دوتا متد اینجا مشخص میشه که توی این روش دو کوئری اجرا میشه اولی برای گرفتن کتاب ها و دومی نویسنده ها رو بدون تکرار واکشی میکنه. جنگو نویسنده های واکشی شده رو توی حافظه با کتاب های مربوطه پیوند میزنه و توی خط سوم عملا به کوئری جدیدی نیاز نیست.

این کوئری هم داخل SQL به این شکله :

SELECT id, title, author_id, ... FROM book ORDER BY title

حالا ما لیستی از شناسه های نویسنده ها رو داریم و به این شکل میتونیم همه نویسنده های مورد نظر رو واکشی کنیم :

SELECT id, name, ... FROM author WHERE id IN (%s, %s, ...)

پ.ن به جای s% همون author_id رو قرار میدیم.

برای برطرف کردن 2N+1 از این روش استفاده می کنیم :

Book.objects.order_by(&quottitle&quot).prefetch_related(&quotauthor&quot, &quotauthor__country&quot)

و همچنین برای بر طرف کردن NM+N+1

Book.objects.order_by(&quottitle&quot).prefetch_related(&quotauthors&quot, &quotauthors__country&quot)
  • باید یکم دقت کنید که فرق این دوتا توی چیه... افرین توی s جمع authors، یعنی گفته اطلاعات کشور هریک از نویسنده ها

حالا چطور این مشکلات رو تشخیص بدیم ?

برای تشخیص N+1 کوئری یه سری ابزار وجود داره که هرکدوم رو توضیح میدیم

Django-debug-toolbar

این ابزار یکی از محبوب ترین پکیج های جنگو هست و برای دیباگ کردن پروژه یکی از بهترین ابزار ها هست

nplusone

این پکیج هشدار N+1 های احتمالی رو به ثبت میرسونه برای مثال :

Potential n+1 query detected on `Book.author` The Hound of the Baskervilles by Arthur Conan Doyle Potential n+1 query detected on `Book.author` The Hundred and One Dalmatians by Dodie Smith Potential n+1 query detected on `Book.author` The Lost World by Arthur Conan Doyle [30/Jul/2020 06:23:46] &quotGET / HTTP/1.1&quot 200 9495

و دیگر پکیج های که با یه سرچ ساده میتونید درموردشون اطلاعات کسب کنید توی این پست من سعی کردم N+1 رو به یه شکل ساده و قابل فهم توضیح بدم امید وارم که پسندیده باشید ❤️

References

پ.ن ترجمه از روی یه مقاله بوده + برداشت های خودم

درضمن این اولین پست من داخل ویرگول بود خوشحال میشم فیدبک بدید که چطور بود و چیکار کنیم که بهتر بشه?

pythondjangoormdatabasen 1 problem
A simple software student
شاید از این پست‌ها خوشتان بیاید