چطور API مقیاس پذیر برای اپلیکیشن یا بازی موبایل بنویسیم که هزاران کاربر در ساعت را به راحتی پوشش دهد؟
از چه زبان/فریم ورکی برای نوشتن API استفاده کنم؟ پاسخ کوتاه: هر چیزی که بهش تسلط داری! پاسخ بلند: معمولا افراد وسواس زیادی برای انتخاب Stack و به خصوص زبان برنامهنویسی برای API خودشون دارن و از ابتدا فکر میکنن از چی میتونن بهره بیشتری بکشن. این اتفاق در بین برنامه نویسها خیلی دیده میشه و برای اثبات این قضیه به خیلی از Benchmark ها که وظیفشون مقایسه زبانها، فریمورکها و ... هست میشه اشاره کرد. معروف ترین اونها TechEmpower هست که هر چند وقت یکبار اجرا میشه و معیار خیلیها هست! این اواخر هم که زبان Rust حسابی سروصدا به پا کرده و خیلیها رو جذب خودش کرده. اما نکته ای که خیلی ها فراموش میکنن اینکه که در 90% زمان پردازش API هایی که برای بکاند بازی/اپلیکیشن موبایل رخ میده شامل IO هست که معمولا از جنس Disk I/O و بر روی دیتابیس هست. این عدد اینقدر تاثیر گذار هست که اگه شما API به شدت خوب با زبان Rust رو نوشته باشی ولی دیتابیس مثلا MySql کار کرده باشی به طور مثال چیزی در حد چند نانوثانیه کد Rust زمان میبره و چند میلی ثانیه خوندن اطلاعات از دیتابیس (عددها جهت مثال هست). حالا شما بیا و این کد رو با PHP و یا پایتون بنویس و زمان نانو ثانیه میشه مثلا 20 برابر و میشه 1 میلی ثانیه! حتی اگه پروژه تو گوگل بعدی هست که میلیاردها کاربر داره در سالهای اول (دقت کن نگفتم ساعتها یا روزها یا حتی ماهها) دغدغه اصلی شما بهینه کردن نحوه ذخیره اطلاعات یعنی همون IO هست!
از چه دیتابیسی/ORM برای ذخیره سازی اطلاعات استفاده کنم؟ پاسخ کوتاه: چیزی که بهش تسلط داری! پاسخ بلند: الان دیگه تفاوت بین دیتابیس ها چه از نوع SQL چه NoSql اونقدر کم هست و تقریبا دارای فیچرهای مشابه هم هستند که دیگه انتخاب رو تقریبا بی معنی کردن. هرچند برای چند سال پیش این قضیه خیلی فرق میکرد. بطور مثال ممکنه یاد Redis بیوفتید که اطلاعات رو توی حافظه ذخیره سازی میکنه و سرعت خیلی زیادی داره. اما شاید خیلی ها ندودن دیگه حتی اکثر دیتابیس های SQL شامل MSSQL هم برای قابلیت ذخیره اطلاعات بر روی حافظه رو دارن و توی اینکار خیلی پیشرفته شدن. درنهایت فراموش نکنید وقتی مثلا در مورد همون Redis نیاز به ذخیره دائمی اطلاعات دارید قضیه به شدت تغییر میکنه در یک پروژه بزرگ عملا بجز برای کش مرکزی اطلاعات تفاوت زیادی ایجاد میکنه. ضمن اینکه برای کش روی حافظه هم خود زبانها / فریمورک ها الان راهحل های خوبی ارائه دادن. در نهایت تجربه من نشون داده یه دیتابیس حتی MsSql که به شدت سنگین هست درصورت استفاده بهینه خودش به راحتی جواب خیلی سوالات رو میده!
تا اینجا قبول، اما چجوری API هایی که من تاحالا نوشتم کاربر خیلی کمی پوشش میده؟ پاسخ کوتاه: API بنویس که Scalable یا همون مقیاس پذیر باشه! پاسخ بلند: کدی که مینویسی باید دو تا ویژگی خوب رو داشته باشه. 1- رو هر سیستمی منابع رو به خوبی مصرف کنه (Vertical Scaling): این مورد رو تقریبا بسیاری از زبانها / فریمورک های امروزی پوشش دادن. هم توانایی استفاده از تمام هسته های CPU رو به صورت موازی دارن و هم هرچقدر رم بدی، برای هر ریکوئست جدید استفاده میکنن. اما ممکنه بعضی وقتها نیاز باشه ما هم توی کد حواسمون به این قضیه نباشه که میتونیم از منابع به خوبی استفاده کنیم! بطور مثال میشه از رم به عنوان کش یه سری موارد تکراری که میزان تغییرشون خیلی کم هست استفاده کرد و بجای خوندن هربار این اطلاعات از دیسک/دیتابیس هرچند ثانیه/دقیقه یکبار اونها رو بخونیم ولی جواب ریکوئست ها رو با مقدار کش شده توی رم بدیم! یا یه سری محاسبات رو دوبار انجام ندیم (کاهش پیچیدگی زمانی/حافظه با افزایش پیچیدگی زمانی/حافظه که احتمالا در درس طراحی الگوریتمها توی دانشگاه خوندید) 2- کد ما بتونه روی چندین سیستم کار کنه (Horizontal Scaling): کدی که مینویسید رو کاری کنید که به سیستمی که اجرا میشه وابسته نباشه. یعنی تا میتونید تنظیمات کد رو توی جای مرکزی قرار بدید. با اینکار میتونید کدتون رو روی چند سیستم (Server) یا ماشین مجازی اجرا کنید و درخواست کاربراتون رو به طور تصادفی (یا طبق یه الگوریتم) به یکی از سیستم ها هدایت کنید. اینکار همون Load Balancing درواقع هست. که پردازش اطلاعات شما همزمان روی چندین سیستم انجام میشه و اگه مثلا از فردا کاربراتون بیشتر شدن بجای بهینه کردن کد یه سیستم جدید روشن میکنید و به راحتی کاربرا رو با تقسیم روی سیستم های موجود مدیریت میکنید! برای اینکار تنها کافیه کد شما وابسته به سیستم/سروری که لازمه نباشه! این به معنی استفاده اجباری از Docker و یا kubernetes نیست API شما میتونه روی یه ماشین مجازی هم اجرا بشه و ماشین مجازی متعددی داشته باشید.
دیتابیس چی؟ مگه اون مشکل اصلی نیست؟ اون رو چطور میشه مقیاس پذیر کرد؟ پاسخ کوتاه: به راحتی تقریبا هر دیتابیسی شامل SQL و NOSQL مقایس پذیر میشه کرد! پاسخ بلند: دقیقا مشکل اصلی خیلی از API که مقیاس پذیر نیستن از اینجا نشات میگیره! بیشتر دیتابیسها الان خودشون روشهایی برای مقایس پذیری چه از نوع Vertical (یعنی افزایس منابع سیستم) و چه Horizontal (افزایش تعداد سیستم) دارن. اما مشکل بزرگ اینجاست که از یه طرف متخصص این زمینه کم هست و معمولا توی تیمهایی که ما داریم متخصصی برای دیتابیس وجود نداره و از یه طرف دیگه قابلیت های مقیاس پذیری (مثل Clustering, Always On و ...) جزو قابلیتهای پولی و البته گرون قیمت هستند و کمتر شرکت/تیم ایرانی از پس هزینههای اون بر میاد. اما شما به عنوان برنامهنویس تیم کار چندان سختی برای به عهده گرفتن این مسئولیت ندارید! چطوری؟ خیلی ساده با استفاده از تکنیک هایی مثال Sharding! این تکنیک برای انواع دیتابیس ها جواب میده. قبل از ادامه این رو بگم که معمولا نسخه رایگان/ارزان تمامی دیتابیس ها معمولا به صورت خودکار Vertical Scaling رو پوشش میدن یعنی هرچی رم و CPU به این دیتابیس ها بدی تا میتونن مصرف میکنن! موارد استثنایی مثل MsSQL هست که اون باید License بالاتر مثل مثلا Enterprise رو داشته باشی تا بتونی تعداد زیاد CPU/Ram رو در اختیار بگیره که البته چندان نیازی هم نیست چون نسخه رایگان/ارزون اون در کنار Horizontal Scaling که در ادامه میگم جوابگو خواهد بود (اگه دسترسی به Enterprise رو ندارید البته).
توی دیتابیس Sharding چی هست؟ پاسخ کوتاه: اطلاعات هر کابر/مشخصه رو توی دیتابیسها/سرورهای مختلف نگهداری کردن! پاسخ بلند: اطلاعاتتون رو اگه بشکونید و هر اطلاعاتی رو یک سرور/دیتابیس خاص ذخیره کنید و درموقع خوندن هم سراغ همون سرور برید برای خوندنش اینجوری شما Sharding رو انجام دادید. مثلا فرض کنید ما کلیه اطلاعات کاربری کسایی که username اونها با حرف A شروع میشه رو روی دیتابیس DB01 و سرور 1 ذخیره میکنیم و کسایی که username اونها با B شروع میشه رو روی دیتابیس DB02 و سرور 2 ذخیره میکنیم و .... اینطوری هروقت نیاز به اطلاعات کابری داشتیم از یه تابع ساده که Username رو میخونه و بر اساس اون میگه دیتای اون روی چه دیتابیس و چه سروری هست میریم سراغ اون دیتابیس و اون سرور!
همه اطلاعات رو میشه Shard کرد؟ پاسخ کوتاه: تقریبا بله! پاسخ بلند: بعضی وقتا اطلاعات مشترک داریم (مثلا Leaderboard، تنظیمات، اطلاعات پایه و ... ) که وابسته به هیچ کاربری نیستن. در اینجا دو تا راه حل داریم: 1- اطلاعات مشترک رو روی سرور اول دیتابیس اول (بطور مثال) بزاریم. وقتی نیاز به این اطلاعات داریم فقط سراغ یک دیتابیس و یک سرور میریم. اگه حجم اطلاعات کم و میزان خوانش اونها هم محدود باشه این روش بد جواب نمیده. یعنی تمام فشار و میزان پاسخ رو یک دیتابیس/سرور به عهده میگیره. این راه حل اگه کاربر زیادی دارید باید در کنار قابلیت کش توی اپلیکیشن پیاده سازی بشه وگرنه جواب نمیده. مثلا برای Leaderboard میتونید هر Instance از API که اجرا میشه هر 15 دقیقه یکبار به این سرور وصل بشه و اطلاعات Leaderboard رو بخونه و توی حافظه کش کنه و جواب کاربرها رو با همین کش بده)
2- اطلاعات توی تمام سرورها ذخیره بشن! این مورد یکم حساس تر هست. مثلا اینکه باید حواسمون باشه اطلاعات همگام باشن. مثلا نشه توی یه سرور یه اطلاعاتی باشه که توی اون یکی نیست. پس باید برای همگام کردنشون کاری بکنیم و توی همگام کردن اونها دقت کافی به خرج بدیم.
با چه فرمولی اطلاعات رو بشکونیم؟ پاسخ کوتاه: با فرمولی که میزان اطلاعات رو به خوبی پخش کنه! پاسخ بلند: بهتره اطلاعات به میزان یکسان و یکدست پخش بشن. اینطوری بار پردازش روی سرورها هم متناسب میوفته. مثلا بهترین روش استفاده از کد تولید شده هست. اگه 10 تا Shard (دیتابیس/سرور) دارید و Id دارید که به کاربرها به ترتیب از 1 تا بینهایت میدید باقیمونده اون بر 10 میتونه شماره Shard رو بده. اگه نام کاربری دارید حواستون باشه که بعضی چیزها مثل اول اسم خیلی خوب نیست. مثلا در مورد ما ایرانی ها اسمهای زیادی داریم که با A و یا M شروع میشن. نه اینکه از اون نمیشه استفاده کرد ولی مثلا A رو با M توی یه Shard گذاشتن خودش باعث میشه بیشتر اطلاعات روی یک دیتابیس/سرور قرار بگیرن. اگه شماره موبایل میگیرید استفاده از اولین رقم سمت راست گزینه بدی نیست وگرنه تمام موبایلهای ایران با 09 شروع میشن!
چه تعداد دیتابیس/سرور (یا همون Shard) داشته باشیم؟ پاسخ کوتاه: بسته به نیاز با یکم محاسبه! پاسخ بلند: محاسبه این عدد کار سختی نیست. کافیه سیستم رو راه بندازید و ببینید هر Shard (دیتابیس/سرور) چقدر منابع مصرف میکنن و چقدر کاربر پشتیبانی میکنن. من این شانس رو داشتم که برنامهنویس اپلیکیشنی باشم (بازی زندگی دیگران) که در ساعت تا 5 هزار کاربر داشته. در ابتدا برای این قضیه از چهار دیتابیس Postgres هرکدوم با 4 گیگ رم و 2 Core حافظه استفاده کردم (روی یک سرور ابری) و البته تقریبا به سیستم یکم فشار اومد (در زمان اوج، پاسخ ها با تاخیر یکی دو ثانیه ای مواجه میشد). اما متوجه شدم مشکل از سرویسدهنده به اصطلاح ابری هست. بعد از اون با مهاجرت به دو تا ماشین مجازی ویندوزی (هرکدوم با 32 گیگ رم و 8 هسته) "به همراه API ها و یه سری تولز دیگه" روی همون ماشین اجرا کردم دیدم نصف منابع هم مصرف نمیشه و تنها زنگ هشدار برای من میزان مصرف شبکه و پهنای باند بود. نکته اینکه این سرویس من برای بازی بود و با اینکه از نوع REST بود اما نسبت به اپلیکشن ها تقاضاهای زیادی در ثانیه به سرور ارسال میشد.
از اول باید دیتا ها رو بشکونیم یا میشه بعد از بالا اومدن سیستم هم Shard کرد؟ پاسخ کوتاه: میتونید یه سری چیزها رو به آینده بسپرید! پاسخ بلند: اگر کدی دارید که داره کار میکنه و الان به فکر شکوندن هستید خبر خوب اینکه دستتون بسته نیست و میتونید اینکار رو بکنید و خبر بد اینکه احتمالا نیاز به Downtime دارید. شما میتونید سیستم رو خاموش کنید. اطلاعات رو به دیتابیس های مجزا بر اساس فرمولتون بشکونید و API جدید که توش فرمول پیدا کردن دیتابیس جدید برای هر دیتا دارید رو پیاده کردید رو بالا بیارید. بسته به حجم اطلاعاتتون این ممکنه زمانبر بشه. در برخی مواقع راه حل موازی هم براش وجود داره و میتونید موازی با شروع انتقال اطلاعات، همزمان به کاربرهای جاریتون هم پاسخ بدید اما اینکار نیاز به دقت و بررسی زیادی داره. اما اگه اول کار هستید خبر خوب اینکه میتونید با یکم کد نوشتن خوب کد از ابتدا آماده افزایش Shard باشید که در سوال بعد جواب اون رو دادم!
چطوری با افزایش کاربرها، Shard (دیتابیس/سرور) جدید اضافه کنیم؟ پاسخ کوتاه: با یکم کد زدن! پاسخ بلند: سیستم تخصیص Shard رو پیاده سازی کنید. این سیستم در ابتدا همه کاربرها رو به دیتابیس اول میبره. وقتی دیدید داره سنگین میشه بار دیتابیس، تنظیم سیستم تخصیص Shard رو عوض میکنید و این سیستم از حالا به بعد کاربرها رو تقسیم میکنه به دیتابیس 1 و 2 (در ابتدا نسبت بیشتری به 2 میبره تا زمانی که کاربرهای 1 و 2 با هم برابر بشن و بعد از اون به هر دو بطور مساوی و تصادفی میفرسته) و هروقت Shard جدید اضافه کردید باز تنظیم این سیستم رو عوض میکنید و .... به نظر زیادی ساده میرسه؟ خوب نوشتن و انجام اون چندان ساده نیست. مثلا توی این حالت دیگه نمیتونید از Username کاربر برای شناسایی Shard استفاده کنید بلکه باید از عددی دیگه که سیستم تخصیص Shard تولید کرده استفاده کنید. اگه API شما برای اپلیکیشن موبایل هست میتونید این عدد تخصیص شارد رو به راحتی توی حافظه گوشی ذخیره کنید و هربار کاربر درخواست داره شماره Shard اون رو هم به API خبر میده اما اگه برای یه سایت اون رو پیاده سازی میکنید چون از کاربر username یا ایمیل میگیرید در هنگام لاگین میتونید به اطلاعات مشترک مراجعه کنید و تشخیص بدید اطلاعات اون در کدوم Shard قرار داده شده و این رو در کوکی و یا توکن به فرانت بفرستید تا در تقاضاهای بعد دیگه به راحتی بدونید اطلاعات توی کدوم Shard هست!