مشکل N+1 یه مسئله عملکردی توی دیتابیس ها هست و این داستان توی خیلی از ORM ها و کوئری های SQL که می نویسیم دیده میشه. توی این پست میخوایم در مورد N+1 کوئری تو جنگو و راه های تشخیص و رفعش صحبت کنیم.
به طور خلاصه فرض کنید لیستی از نتایج یه کوئری داریم و بیایم برای هریک از این نتیجه ها یه کوئری دیگه بزنیم، به مثال زیر توجه کنید :
N+1 Queries
books = Book.objects.order_by("title") for book in books: print(book.title, "by", book.author.name)
کوئری هایی که کد ما ایجاد میکنه این شکلیه :
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("title") for book in books: print( book.title, "by", book.author.name, "from", book.author.country.name, )
برای دسترسی به اطلاعات کلید خارجی country اینجا خودش یه کوئری جدید تولید میشه و عملا کوئری های ما نسبت به قبل دو برابر میشه ?
NM+N+1 Queries
حالا فرض کنید مدل ها رو تغییر دادیم و این امکان وجود داره که یه کتاب رو چندیدن نویسنده نوشته باشه، کوئری به این شکل درمیاد :
books = Book.objects.order_by("title") for book in books: print(book.title, "by: ", end="") names = [] for author in book.authors.all(): names.append(f"{author.name} from {author.country.name}") print(", ".join(names))
خوب همون طور که مشاهده می کنید این شکل از کوئری ها به شدت روی پرفورمنس پروژه تاثیر میزاره و با بالا رفتن تعداد درخواست ها و حجم دیتای ذخیره شده عملا بعضی جاها سرور دیگه پاسخگو نیست و ممکنه سایت یا پروژه به کلی پایین بیاد و از طرفی چون این شکل از کوئری زدن ها بهینه نیست ما داریم بیخودی منابع سرور رو هدر میدیم.
خوب راه حل چیه؟ جنگو دو شکل از QuerySet ها رو پیشنهاد میکنه که با این روش ها میتونیم N تا کوئری رو به یه کوئری تبدیل کنیم و عملا این مشکل رو حل کنیم، روش اول select_related
و روش دوم prefetch_related
هست که در ادامه به هرکدوم بیشتر میپردازیم.
اول بزار فرقشونو بگم بعد بریم قسمت بعدی select_related
یک کوئری جوین میسازه و دیتا رو با یک کوئری میگیره ولی prefetch_related
جوین کردن رو به دیتابیس نمیسپاره و این کارو توی خود پایتون انجام میده، خوب حالا میتونیم بریم قسمت بعدی...
select_related
اول مثال زیر رو مشاهده کنید :
books = Book.objects.order_by("title").select_related("author") for book in books: print(book.title, "by", 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("title").select_related("author", "author__country")
باید بگم که متاسفانه select_related توی NM+N+1 Queries نمیتونه به ما کمکی کنه و select_related فقط توی روابط یک به یک یا یک به چند به کار میاد در حالی که NM+N+1 روابط زیادی داره ولی خوشبختانه prefetch_related
میتونه با روابط زیادی کار کنه.
prefetch_related
استفاده از این متد هم تقریبا شکل مثال بالا هست :
books = Book.objects.order_by("title").prefetch_related("author") for book in books: print(book.title, "by", 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("title").prefetch_related("author", "author__country")
و همچنین برای بر طرف کردن NM+N+1
Book.objects.order_by("title").prefetch_related("authors", "authors__country")
برای تشخیص N+1 کوئری یه سری ابزار وجود داره که هرکدوم رو توضیح میدیم
این ابزار یکی از محبوب ترین پکیج های جنگو هست و برای دیباگ کردن پروژه یکی از بهترین ابزار ها هست
این پکیج هشدار 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] "GET / HTTP/1.1" 200 9495
و دیگر پکیج های که با یه سرچ ساده میتونید درموردشون اطلاعات کسب کنید توی این پست من سعی کردم N+1 رو به یه شکل ساده و قابل فهم توضیح بدم امید وارم که پسندیده باشید ❤️
پ.ن ترجمه از روی یه مقاله بوده + برداشت های خودم
درضمن این اولین پست من داخل ویرگول بود خوشحال میشم فیدبک بدید که چطور بود و چیکار کنیم که بهتر بشه?