«پا بزن آشغال» | چگونه API‌های سریع‌تر در DRF داشته باشیم

من تو این مقاله می‌خوام از تجربیاتی که تو بهبود پرفورمنس چند تا API داشتم بگم. این APIهایی که من درگیرشون بودم اکثرشون توسط سرویس‌های خودمون صدا زده می‌شدند و فقط یکی‌شون از طریق کلاینت(پنل تبلیغ‌کننده یکتانت) استفاده می‌شد. اکثر APIها حدود ۳ تا ۴ برابر سریع‌تر شدند.


مسئله چیه؟

پایتون، جنگو و جنگورست‌فریم‌ورک معمولا به اندازه‌ی کافی سریع هستند ولی وقتی محصول بزرگ‌تر می‌شه قضیه فرق می‌کنه و درگیر بهینه کردن هر کدوم از این تکنولوژی‌ها می‌شیم. به طور جزئی‌تر بخوام بگم ما در سریالایز شدن آبجکت‌ها در استفاده از ListSerializer جنگو‌رست‌فریم‌ورک مشکل داشتیم.

چرا کنده؟

در اکثر موارد مرسوم دلیل کند بودن دیتابیسه و خب با ایندکس گذاشتن و درست کوئری زدن خیلی از مشکلات حل می‌شه ولی مشکلی که ما داشتیم اکثرا از جنس دیتابیس نبود. من اول یه سری از مشکلات بدیهی‌تر تو جنگو که ممکنه بهش برخورد کنید رو می‌گم و بعدتر یه ذره با جنگورست‌فریم‌ورک وَر می‌رم.

بنابر حجم دیتا و ساختار مدل‌ها دلیل کند بودن می‌تونه متفاوت باشه، چند تا از مشکلات مرسوم رو من لیست کردم.


مشکل N+1 کوئری

اولین مشکلی که معمولا بهش برمی‌خوریم مشکل N+1 کوئریه. این مشکل وقتی پیش میاد که ما تو کوئری که اول می‌زنیم آبجکت از نوع X رو می‌گیریم ولی بعدا به ازای هر آبجکت از نوع X نیاز به کوئری زدن به جدول Y داریم. در این صورت داریم N+1 کوئری می‌زنیم که می‌تونستیم با یک کوئری جوین این مشکل رو حل کنیم.

راه‌حل‌هایی که جنگو واسه این مشکل داره select_related و prefetch_relatedئه. select_related یک کوئری جوین می‌سازه و دیتا رو با یک کوئری می‌گیره ولی prefetch_related این کار رو با پایتون انجام می‌ده یعنی جوین تو پایتون انجام می‌شه. select_related رو وقتی نیاز به یک آبجکت اضافه‌تر داریم استفاده می‌کنیم. یعنی استفاده‌ی مستقیم از ForeignKey و OneToOne یا استفاده‌ی برعکس از OneToOne. دلیل این کار هم اینه که وقتی مثلا ما یک جوین ManyToMany می‌زنیم آبجکت‌ها تکرار می‌شن و این تکرار هزینه‌بره. در این مواقع ما از prefetch_related باید استفاده کنیم که به جای یک کوئری جوین، یک کوئری به ازای هر رابطه می‌زنه و با این کار حواسش هست که ما آبجکت تکراری نسازیم. پس prefetch_related رو برای ManyToManyها و استفاده برعکس از ForeignKey می‌تونیم استفاده کنیم.


برگردوندن کامل آبجکت

ممکنه آبجکت‌هایی که ما داریم خیلی بزرگ باشن و خب برگردوندن کامل‌شون هزینه‌بر باشه و خب این مشکل با استفاده از تابع only و defer می‌تونه حل بشه. متد only فیلد‌هایی که ما نیاز داریم رو بهش می‌گیم و فقط همون‌ها رو برامون میاره و متد defer فیلد‌هایی که نیاز نداریم رو برامون نمیاره. دقت کنید که این تابع‌ها یه آبجکت از کلاس رو برمی‌گردونن و اگر بخوایم به فیلدی دسترسی داشته باشیم که از قبل مشخص نکردیم، دوباره به دیتابیس کوئری می‌زنند. متد values البته از دوتای بالا‌ سریع‌تره. values یک کوئری‌ست برمیگردونه که شامل دیکشنری‌هاست که کلید‌های هر دیکشنری آرگومان‌هایی هستند که ما به متد values پاس دادیم.


تبدیل کوئری به آبجکت و آبجکت به Json

روندی که دیتا حرکت می‌کنه به این صورته که اول از دیتابیس تبدیل به آبجکت‌های مدل جنگو میشه و بعدش تبدیل به دیتا‌تایپ‌های اولیه(Primitive) می‌شه تا به عنوان خروجی برگردونده بشه. ما برای پرفرومنس بهتر می‌تونیم با استفاده از va‍lues مستقیم کوئری رو تبدیل به دیکشنری کنیم تا هزینه‌ی کمتری در این فرایند بدیم.



اگه مشکل با این چیزها حل نشد شاید بد نباشه یه ذره با سریالایز‌های جنگو‌رست‌فریم بازی کنیم ببینیم چی میشه.

شاید یک چاره‌: محاسبه‌ی کل دیتا در View و پاس دادن دیتا به سریالایزر

ما می‌تونیم دیتا رو تو View به روشی که می‌دونیم سریعه بگیریم و به سرایالایز بگیم که با این دیتایی که ما به دست آوردیم کار کن به جای اینکه خودت بری حساب کنی. استفاده از متد values برای بدست آورن دیتا هر سه تا مشکل بالا رو حل می‌کنه. یا مثلا در حالت‌های خیلی خاص می‌تونیم از Raw کوئری استفاده کنیم.

برای همچین کاری نیازه که متد to_representtation کلاس ModelSerializer رو override کنیم. و در این حین می‌تونیم کارهای اضافه‌ای که جنگو‌رست‌فریم‌ورک می‌کنه و پرفورمنس رو کم می‌کنه حذف کنیم. :D

ما یه کلاس جدید به اسم ContextModelSerializer می‌سازیم و از ModelSerializer ارث می‌بریم.

https://gist.github.com/kamyab98/9b31773270f9018e8d1561c91fdeac1a

و اینجوری ازش استفاده کنیم.

https://gist.github.com/kamyab98/e90d04dd07e8afb59f06b88be82337cc
https://gist.github.com/kamyab98/4d6513758f86e1afdd4a24b8aa4b9803

می‌تونیم کار‌های جالب دیگه‌ای هم بکنیم، مثلا یه بخش دیتا رو از Context سریالایزر بگیریم و بقیه‌اش رو بذاریم خود DRF هندل کنه. واسه این کار می‌تونیم به Meta کلاس‌ سریالایزمون یه فیلد context_fields اضافه کنیم و بگیم که این فیلد‌ها رو از دیتا کانتکس بخون و بقیه رو همونجوری که قبلا بدست می‌آوردی به دست بیار.

https://gist.github.com/kamyab98/74b2861b52ff1cd4f86f189419484705


خیلی وقت‌ها ممکنه مدل‌های ما پیچیده‌تر باشند، مثلا یک رابطه‌ی ManyToMany داشته باشیم. در این موارد احتمالا نیاز داریم که از ساختارداده‌های مناسب هم استفاده کنیم.

این مثال رو در نظر بگیرید.

https://gist.github.com/kamyab98/b1ca925d5a3843e97bd6092b17e41ca0
https://gist.github.com/kamyab98/5b32e7a814d56ee4f235d9abe8660910

در مثال بالا داریم دو بار کوئری می‌زنیم. اگه کند بود برامون، می‌تونیم کمترش کنیم ولی اگه نیازی نبود پیچیده کردن کد ارزشش رو نداره به نظرم.

ولی خب در نهایت با استفاده از روش بالا ما خوشحال بودیم و از این ContextModelSerializer چند بار استفاده کردیم و:


چند نکته‌ی اضافه‌تر

  • آپدیت کردن پکیج‌ها رو همیشه در نظر داشته باشید، اگه ورژنی که استفاده می‌کنید قدیمیه ممکنه با آپدیت کردن، بدون هزینه‌ی خاصی به پرفورمنس بهتری برسید. من وقتی داشتم دنبال روش‌های بهبود پرفورمنس می‌گشتم، به چند تا «چیز» رسیدم که تو ورژن‌های جدیدتر جنگو‌رست‌فریم‌ورک معتبر نبود چون اون «چیز»ها حل شده بودن.
  • سعی کنید کوئری‌هایی که داره زده می‌شه رو به صورت Raw ببینید و بررسی کنید که جایی کار غیربهینه‌ای انجام نده. (برای این کار پراپرتی query رو روی کوئری‌ست می‌تونیم صدا بزنید.) همین‌طور مهمه که چک کنید چندبار دارید کوئری می‌زنید، برای مثال در صورتی که از django-filters و ContexModelSerializer استفاده کنید ممکنه که چند بار کوئری زده بشه که می‌تونید، یک بار بزنید و اون مقدار رو کش کنید.
  • معمولا تمرکز رو پرفورمنس کد باعث می‌شه کد کثیف‌تر بشه اگر نیازی بهش ندارید، انجام ندید! یا به قول Donald Knuth که می‌گه :«Premature optimization is the root of all evil» و وقتی به مشکل خوردید، با علم اینکه کجا پرفورمنس بدی داره برید سراغش.



راستی من کامیاب بودم، مهندس نرم‌افزار تو یکتانت. اگه خواستید می‌تونید فرصت‌های شغلی‌مون رو یه نگاه بندازید.