مفاهیم طراحی سیستم: راهنمای جامع
مفاهیم طراحی سیستم: راهنمای جامع
توی این مقاله میخوایم خیلی سریع و ساده بررسی کنیم که یه سیستم چطوری کار میکنه. یه سفر کوتاه داشته باشیم به دل طراحی سیستمها و با یکسری از مفاهیم کلیدی آشنا بشیم؛ مفاهیمی که تقریبا توی هر نرمافزاری وجود دارن و دونستنشون میتونه دید خیلی خوبی بهمون بده.
۱. معماری کلاینت-سرور (Client-Server Architecture)
خب بیایین با معماری کلاینت سرور شروع کنیم.
تقریباً هر برنامه وبی که استفاده میکنیم بر پایه این مفهوم ساده ولی قدرتمند ساخته شده.
در یک سمت، کلاینته که میتونه مرورگر وب، اپلیکیشن موبایل یا هر اپ فرانتاند دیگری باشه. در سمت دیگه، سرور قرار داره ؛ ماشینی که به طور مداوم در حال اجراست و منتظر دریافت درخواستهاست. کلاینت درخواست میفرسته برای ذخیره کردن، بازیابی یا تغییر اطلاعات. سرور درخواست رو دریافت میکنه، بعد پروسس میکنه و عملیات لازم رو انجام و پاسخ رو برمیگردونه. این کار ساده به نظر میرسه اما یک سؤال بزرگ وجود داره؛ چطوری کلاینت بدونه که سرور کجاست؟
۲. آدرس IP
اینجاست که میریم سراغ آدرس IP
کلاینت نمیدانه سرور کجاست، ولی خب نیاز داره آدرسی داشته باشه تا بتونه اون رو پیدا کنه و باهاش ارتباط برقرار کنه.توی اینترنت، کامپیوترها با استفاده از IP address همدیگه رو شناسایی میکنن؛ مثل شماره تلفنی واسه ی سرورها. هر سرور عمومی یه آدرس IP منحصر به فرد داره. وقتی کلاینت میخاد با یه سرویس ارتباط بگیره، باید درخواستش رو به آدرس IP مناسب بفرسته. اما یک مشکل وجود داره:
- وقتی وارد یه وبسایت میشیم، آدرس IP رو تایپ نمیکنیم ؛ بله آدرس دامنه رو میزنیم.
- نمیشه از کاربرا یا سیستمها انتظار داشته باشیم که IP رو حفظ کنن.
- اگه سرویس رو به یه سرور دیگه منتقل کنیم، آدرس IP ممکنه تغییر کنه و ارتباطات مستقیم خراب شن.
۳. DNS
تا اینجا فهمیدیم که برای ارتباط با سرور به آدرس IP نیاز داریم، ولی حفظ کردن IP نه برای کاربر راحته و نه برای سیستمها منطقیه. سؤال اینجاست: چطور میشه بدون حفظ کردن IP ، همیشه به سرور درست وصل شد؟
اینجاست که DNS وارد ماجرا میشه.
با استفاده از DNS، بهجای اینکه آدرسهای IP عددی و سخت رو حفظ کنیم، از آدرسهای دامین قابلخوندن مثل google.com استفاده میکنیم. اما یه سؤال دیگه: چطور این دامین به آدرس IP واقعی وصل میشه؟
DNS یا همون Domain Name System دقیقاً همینجا نقش خودش رو نشون میده. این سیستم، مثل یه دفترچه تلفن عظیم برای اینترنت عمل میکنه و نامهای سادهای مثلgoogle.comرو به آدرسهای IP واقعیشون وصل میکنه.
بریم یه نگاه ساده به پشت صحنه بندازیم:
- وقتی آدرسgoogle.comرو توی مرورگر تایپ میکنی، کامپیوتر از یه سرور DNS میپرسه: «آدرس IP این دامنه چیه؟»
- سرور DNS آدرس IP رو برمیگردونه و مرورگر از اون برای برقراری ارتباط با سرور استفاده میکنه و درخواست رو میفرسته.
حالا که درخواست آمادهست و آدرس سرور رو هم میدونیم، یه سؤال جدید پیش میاد: آیا این درخواست همیشه مستقیماً به همون سرور اصلی میرسه؟
۴. پروکسی / معکوسپراکسی (Proxy / Reverse Proxy)
نه همیشه! خیلی وقتها درخواست ما مستقیم به سرور هدف نمیرسه، بلکه از یه واسطه به اسم proxy یا reverse proxy رد میشه.
پروکسی مثل یه واسطه بین سیستم ما و اینترنت عمل میکنه. وقتی به یه سایت request میزنیم، پروکسی اون request رو میگیره، به سرور میفرسته، پاسخ رو دریافت میکنه و به ما برمیگردونه. این کار میتونه IP واقعی ما رو پنهان کنه یا حتی بعضی دادهها رو فیلتر و مدیریت کنه.
Reverse Proxy برعکس عمل میکنه: درخواستهای client رو دریافت میکنه و بر اساس قوانین از پیشتعریفشده، اونها رو به سرورهای پشتیبان مختلف هدایت میکنه.
مزایای این کار چیه؟
- از دید امنیتی، جلوی دسترسی مستقیم کاربرها به سرورهای واقعی گرفته میشه.
- آدرس و ساختار سرورهای اصلی مخفی میمونه.
- reverse proxy میتونه نقش Load Balancer رو هم بازی کنه و ترافیک رو بین چند سرور تقسیم کنه تا فشار به سرور وارد نشه.
ولی حتی اگر همهچیز درست کار کنه، چرا بعضی وقتها حس میکنیم ارتباط با سرور کُنده؟
۵. تأخیر (Latency)
هر زمان که client با سرور ارتباط میگیره، همیشه یکم تأخیر وجود داره و یکی از بزرگترین دلایلش هم، فاصله فیزیکیه. مثلاً اگر سرور توی نیویورک باشه و کاربر از هند request بفرسته، اطلاعات مسافت طولانی رو طی میکنه که باعث وجود تأخیر میشه. این تأخیر کلی رو اصطلاحا Latency میگن ؛ یعنی زمانی که دیتا بین کلاینت و سرور طی میشه. یکی از راهها برای کم کردنش اینه که سرویس رو روی چندتا دیتاسنتر تو نقاط مختلف دنیا بالا بیاریم، اینطوری کاربرا وصل میشن به نزدیکترین سرور.
خب وقتی که اتصال برقرار شد، سوال اینه که کلاینت و سرور دقیقا چطوری با هم حرف میزنن؟
۶. HTTP / HTTPS
هر بار که وارد یه وبسایت میشیم، مرورگر و سرور از مجموعهای قوانین به نام HTTP (Hypertext Transfer Protocol) استفاده میکنن. به همین دلیله که خیلی از URL ها با http:// یا نسخه امن ترشhttps:// شروع میشن. حالا بریم سراغ نحوه کارش:
- کلاینت یه درخواست HTTP میفرسته، که شامل هدر (Header) با اطلاعاتی مثل نوع درخواست، نوع مرورگر، کوکیها و گاهی Body برای دادههای اضافیه.
- سرور درخواست رو پردازش میکنه و پاسخ HTTP میفرسته ؛یا اطلاعات خواسته شده رو برمیگردونه و یا پیغام خطا ارسال میکنه.
- اما خب مشکل بزرگ HTTP اینه که اطلاعات رو به صورت متنی ساده (plaintext) ارسال میکنه، که خب برای اطلاعات حساس مثل پسورد، کارت بانکی یا داده شخصی خطرناکه.
- اینجاست که HTTPS وارد میشه ؛ نسخه امن HTTP که با استفاده از SSL / TLS اطلاعات رو رمزنگاری میکنه تا اگه یکی این وسط شنود کنه، نتونه اونا رو بخونه و یا عوض کنه.
- اما HTTP و HTTPS فقط پروتکل انتقال اطلاعات ان؛ و ساختار درخواستها، قالب پاسخها یا نحوه تعامل کلاینت و سرور رو تعیین نمیکنن.
اینجاست که API وارد بازی میشه.
۷. API
API رو میشه واسطهای فرض کرد که اجازه میده کلاینتها (مثلweb App یا mobile) با سرورها تعامل داشته باشن بدون اینکه نگران جزئیات سطح پایین باشن. تقریباً همه سرویسهای دیجیتال که استفاده میکنیم ؛ شبکههای اجتماعی، فروش آنلاین، بانکداری اینترنتی، اپهای حملونقل ؛ بر پایه ی همین APIها کار میکنن. روند معمول این شکلیه:
- کلاینت request میزنه به API.
- API درخواست را پردازش میکنه، با Database یا سرویسهای دیگه تعامل داره، و response رو آماده میکنه.
- API جواب رو در قالب ساختاریافته (معمولاً JSON یا XML) به کلاینت برمیگردونه، که اون رو میفهمه و نمایش میده.
- API درواقع لایهای از انتزاع رو ایجاد میکنه که کلاینت نیازی نداره بدونه سرور چطوری کار میکنه، فقط جواب مورد نظرش رو میگیره
- و اما سبکهای مختلفی از API وجود داره؛ دو سبک محبوب: REST و GraphQL.
۸. REST API
توی سبکهای مختلف API، REST (Representational State Transfer) رایجترین شونه. یه REST API از مجموعه قوانینی پیروی میکنه که مشخص میکنه چطوری کلاینت و سرور باید روی HTTP تعامل داشته باشن. REST یکسری ویژگی داره:
- Stateless: هر درخواست مستقله و سرور وضعیت کلاینت را ذخیره نمیکنه.
- Resources: هر چیز به عنوان منبع در نظر گرفته میشه (مثل /users, /orders, /products).
- استفاده از متدهای استاندارد HTTP:
- بازیابی داده ← GET
- POST → ایجاد داده
- PUT / PATCH → بهروزرسانی داده
- DELETE → حذف داده
REST ؛ ساده، مقیاسپذیر و قابلیت کش شدن داره، اما یکسری محدودیت داره، خصوصاً در مورد برگردوندن دیتاهای پیچیده. و یا ممکنه response اطلاعات بیشتری نسبت به نیاز واقعی برگردونه یا نیاز باشه چند تا درخواست ارسال بشه. برای حل این مشکل، GraphQL معرفی شد.
۹. GraphQL
برخلاف REST که کلاینت مجبوره مجموعه اطلاعات از پیش تعیینشده رو بگیره، GraphQL به کلاینت اجازه میده دقیقاً همون چیزی که نیاز داره رو درخواست کنه. نه بیشتر و نه کمتر.
ولی توی REST، اگه جزئیات کاربر، پروفایل و پستهای اخیر نیاز داشته باشیم، ممکنه بخواییم چند تا درخواست بفرستیم:
- GET /api/users/123 → جزئیات کاربر
- GET /api/users/123/profile → پروفایل
- GET /api/users/123/posts → پستها
اما توی GraphQL میتونیم همه رو تو یه ریکوعست ترکیب کنیم و فقط فیلدهای مورد نیاز را بگیریم. سرور هم فقط اون فیلدها رو برمیگردونه، که باعث کاهش انتقال داده غیرضروری و بهبود کارایی میشه. اما GraphQL معایبی هم داره: نیاز به پردازش بیشتر توی سرور داره و مثل REST به آسونی قابل کش کردن نیست و یه مقدار فرایندش سخت تره.
۱۰. دیتابیس ها (Databases)
وقتی کلاینت درخواست ذخیره یا بازیابی اطلاعات میکنه، سؤال اینه: اطلاعات دقیقاً کجا ذخیره شده؟ اگر برنامه با دیتای کمی کار کنه، میتونیم اونا رو تو حافظه (Memory) نگه داریم، ولی برنامههای مدرن با حجم زیادی از اطلاعات سروکار دارن که حافظه (Memory) به تنهایی پاسخگو نیست.
برای همین به دیتابیس نیاز داریم. سیستمی برای ذخیره، مدیریت و دسترسی کارآمد به اطلاعات. دیتابیس ستون فقرات هر برنامه ست، تضمین میکنه که اطلاعات به صورت مؤثر، امن، سازگار و با دوام ذخیره میشن. اما دیتابیس ها انواع و اقسام دارن و بسته به نیاز سیستم، باید نوع مناسب رو انتخاب کنیم.
۱۱. SQL در مقابل NoSQL
دیتابیس های SQL اطلاعات رو توی table با Schema از پیش تعریف شده ذخیره میکنن و ویژگیهای ACID رو رعایت میکنند:
Atomicity (اتمی بودن)
تعریف: یه تراکنش یا کامل انجام میشه یا کلا انجام نمیشه. چیزی به اسم نصفهنیمه نداریم. مثال: کاربر میخواد پول از کیف پولش برداره و سفارش ثبت کنه → یا هر دو با هم موفق میشن، یا اگه وسطش خطا خورد هیچکدوم انجام نمیشه.
Consistency (یکپارچگی)
تعریف: بعد از هر تراکنش، دیتابیس باید همچنان سالم و طبق قوانین تعریفشده بمونه. مثال: موجودی حساب کاربر هیچوقت نباید منفی بشه. اگه کسی بیشتر از موجودیش برداشت کنه، سیستم باید جلوی تراکنش رو بگیره.
Isolation (ایزوله بودن)
تعریف: تراکنشها نباید روی همدیگه قاطی بشن؛ هرکدوم باید انگار تنها اجرا بشن. مثال: وقتی دو نفر همزمان دنبال همون یه دونه صندلی آخر هواپیما هستن، سیستم باید مطمئن بشه فقط یکی موفق میشه، نه اینکه دوتا رزرو بشه.
Durability (پایداری)
تعریف: وقتی گفتیم تراکنش ذخیره شد، دیگه باید مطمئن باشیم حتی اگه سیستم کرش کنه یا برق بره، دادهها سر جاشون هستن. مثال: وقتی سفارش ثبت شد و پیام «موفق» به کاربر نشون دادیم، حتی اگه سرور همون لحظه خاموش بشه، سفارش باید تو دیتابیس باقی بمونه.
دیتابیسهای SQL برای برنامههایی مناسبن که نیاز به سازگاری قوی و روابط ساختیافته دارند (مثلاً سیستم بانکی).
از طرف دیگه، NoSQL برای مقیاسپذیری بالا طراحی شدن. اونا به یک Schema ثابت نیاز ندارن و از model های داده متفاوت استفاده میکنند:
- Key-Value Store (مثل Redis)
- Document Store (مثل MongoDB)
- Graph Database (مثل Neo4j)
- Wide-Column Store (مثل Cassandra)
انتخاب بین SQL یا NoSQL کاملاً وابسته به نیازای سیستمه.تو خیلی از اپلیکیشنهای مدرن، ترکیبی از هر دو استفاده میشود (مثلاً ذخیره سفارشها تو SQL و ذخیره پیشنهادات توی NoSQL).
حالا چالشی که پیش میاد اینه که اگر لود دیتابیس مون رفت بالا باید چکار کنیم؟
۱۲. مقیاسپذیری عمودی (Vertical Scaling)
وقتی تعداد کاربرا و request های در لحظه زیاد میشه، سرور فعلی ممکنه دچار bottleneck بشه. یکی از سریعترین راهها برای بالا بردن قدرت سیستم اینه که همون سرور فعلی رو ارتقا بدیم؛ مثلا CPU و رم قویتر بذاریم یا فضای ذخیرهسازی رو بیشتر کنیم. به این میگن Vertical Scaling یا همون Scaling Up. ولی خب، این کار یه سقفی داره و محدودیتاشم زیاده
- محدودیت سختافزاری — نمیشه تا بینهایت یک سرور را ارتقا داد.
- هزینه ؛ سرورهای خفن هزینه زیادی دارن.
- نقطه شکست واحد (Single Point of Failure) ؛ اگر سرور خراب شه، کل سیستم از کار میفته.
پس درسته که مقیاسپذیری عمودی میتونه راه حل سریع باشد، ولی برای بلندمدت گزینه خوبی نیست.
۱۳. مقیاسپذیری افقی (Horizontal Scaling)
به جای اینکه فقط همون یه سرور رو هی ارتقا بدیم، میتونیم چندتا سرور جدید اضافه کنیم تا بار بینشون تقسیم بشه. به این میگن Horizontal Scaling یا همون Scaling Out.
مزیتاش اینه که:
- هرچی سرور بیشتر باشه، ظرفیت سیستم هم بالاتر میره و راحتتر میتونه ترافیک زیاد رو هندل کنه.
- دیگه Single Point of Failure نداریم؛ یعنی اگه یه سرور از کار افتاد، بقیه ادامه میدن و سیستم stable میمونه.
- از نظر هزینه هم بهصرفهتره؛ به جای خرید یه سرور گرون، میتونی چندتا سرور ارزونتر بذاری کنار هم.
البته یه چالش جدید هم پیش میاد: اینکه کلاینت باید بدونه به کدوم سرور وصل بشه. اینجاست که Load Balancer وارد بازی میشه.
۱۴. لود بالانسرها (Load Balancers)
Load Balancer یه جورایی وسطِ کلاینتها و سرورای بکاند میشینه و مثل یه مدیر ترافیک عمل میکنه؛ یعنی درخواستا رو بین چندتا سرور پخش میکنه.
اگه یه سرور خراب بشه، Load Balancer خودش اتوماتیک درخواستا رو میفرسته سمت یه سرور سالم دیگه.
حالا سوال: از کجا میفهمه که هر درخواست باید بره به کدوم سرور؟ اینجا الگوریتمهای Load Balancing وارد میشن، مثلا:
- Round Robin → درخواستا یکییکی پشت سر هم میرن به سرورا (به ترتیب).
- Least Connections → درخواست میره به سروری که تعداد کانکشنهای فعالش کمتره.
- IP Hashing → درخواستهایی که از یه IP میان همیشه میرن به همون سرور، اینجوری سشنها ثابت میمونن.
خب تا اینجا بیشتر راجع به اسکیل کردن سرورای اپلیکیشن حرف زدیم. ولی وقتی ترافیک زیاد میشه، حجم داده هم میره بالا. اوایل میشه دیتابیس رو Vertical Scale کرد (CPU، رم و استوریج بیشتر بدی)، ولی اینم یه سقفی داره. پس باید بریم سراغ تکنیکهای دیگه برای Database Scaling که بتونیم حجم دیتای بزرگ رو بهتر مدیریت کنیم.
۱۵. ایندکس کردن (Indexing)
یکی از سریعترین و موثرترین راهها برای بالا بردن سرعت کوئریهای دیتابیس، Indexing هست.
اینکار مثل صفحهی فهرست کتابه ؛ به جای اینکه از اول تا آخر ورق بزنیم، مستقیم میریم سراغ بخش مورد نظر. ایندکس تو دیتابیس هم همین کارو میکنه؛ یه جدول lookup فوقالعاده سریع که کمک میکنه دیتابیس بدون اینکه کل جدول رو اسکن کنه، سریع دادهی مورد نیاز رو پیدا کنه.
ایندکس مقادیر ستونها رو همراه با اشارهگر به ردیفهای واقعی جدول نگه میداره. معمولا روی ستونهایی ایندکس میزنیم که زیاد پرسوجو میشن، مثل:
- Primary key
- Foreign key
- ستونهایی که تو شرطهای WHERE زیاد استفاده میشن
ولی حواسمون هم باید باشه: ایندکس فقط سرعت خوندن رو بالا میبره، سرعت نوشتن (INSERT, UPDATE, DELETE) رو پایین میاره، چون هر بار داده تغییر کنه، ایندکس هم باید آپدیت بشه. به همین خاطر فقط روی ستونهای پرکاربرد ایندکس میزنیم.
ایندکس خیلی کمک میکنه ولی اگه حتی با ایندکس هم دیتابیس نتونه تعداد زیاد درخواستهای خوندن رو هندل کنه، اینجاست که تکنیک بعدی Replication وارد بازی میشه.
۱۶. تکرار (Replication)
مثل اینکه برای مدیریت ترافیک سرورهای اپلیکیشن، چندتا سرور اضافه کردیم، میتونیم دیتابیس رو هم با Replication بزرگ کنیم و کپیهای زیادی ازش روی سرورهای مختلف داشته باشیم.
چطور کار میکنه:
- یه دیتابیس اصلی داریم (Primary Replica) که همه عملیات نوشتن (INSERT, UPDATE, DELETE) رو انجام میده.
- چندتا Read Replica داریم که فقط درخواستهای خوندن (SELECT) رو هندل میکنن.
- هر بار دادهای تو دیتابیس اصلی نوشته میشه، همون داده به همه Read Replicaها کپی میشه تا همه با هم Sync باشن.
این کار سرعت read رو بالا میبره چون بار بین چند تا replica تقسیم میشه و فشار روی هر کدوم کمتر میشه. همچنین Availability هم بهتر میشه؛ اگه Primary Replica خراب بشه، یکی از Read Replicaها میتونه جای اون رو بگیره.
Replication برای اپلیکیشنهایی که خیلًی درخواست read دارن عالیه، اما اگه بخوایم نوشتنها یا حجم دادهها رو هم زیاد کنیم، باید راهکارهای دیگهای در نظر بگیریم.
۱۷. تقسیمبندی داده (Sharding)
فرض کنیم سرویس ما حالا میلیونها کاربر داره و دیتابیس به ترابایت داده رسیده. یه سرور دیتابیس تنها دیگه نمیتونه همه این حجم داده رو درست و سریع هندل کنه.
راه حل؟ به جای اینکه همه چیز رو تو یه دیتابیس نگه داریم، دیتابیس رو به قسمتهای کوچیکتر تقسیم میکنیم و روی چندتا سرور پخش میکنیم. به این تکنیک میگن Sharding.
چطور کار میکنه:
- دیتابیس رو به بخشهای کوچیکتر به اسم Shard تقسیم میکنیم.
- هر Shard یه زیرمجموعه از کل دادهها رو نگه میداره.
- دادهها بر اساس یه Sharding Key (مثلا user ID) تقسیم میشن.
با این روش:
- فشار روی دیتابیس مون کمتر میشه، چون هر Shard فقط یه بخش از کوئریها رو هندل میکنه.
- سرعت خوندن و نوشتن بالاتر میره، چون کوئریها بین چندتا Shard تقسیم میشن، نه اینکه همه به یه دیتابیس برن.
حالا اگر مشکل تعداد ستونها باشه نه ردیفها، اون موقع از Vertical Partitioning استفاده میکنیم که دیتابیس رو بر اساس ستونها تقسیم میکنه. بریم که توی بعدی بررسیش کنیم.
۱۸. تجزیه عمودی (Vertical Partitioning)
فرض کنیم یه جدول User داریم که اطلاعات مختلفی توش ذخیره میکنه:
- جزئیات پروفایل (اسم، ایمیل، عکس پروفایل)
- تاریخچه ورود (آخرین لاگین، IPها)
- اطلاعات پرداخت (آدرس، جزئیات کارت)
وقتی جدول بزرگ میشه، کوئریها کند میشه، چون دیتابیس باید کلی ستون رو اسکن کنه حتی اگه فقط چند تا فیلد نیاز باشه.
برای بهینهسازی، از Vertical Partitioning استفاده میکنیم و جدول کاربر رو به چند جدول کوچیکتر و تمرکزی تقسیم میکنیم:
- User_Profile → اسم، ایمیل، عکس پروفایل
- User_Login → زمان لاگینها
- User_Billing → آدرس و جزئیات پرداخت
با این روش، هر کوئری فقط ستونهای مرتبط رو اسکن میکنه و سرعت خواندن بالا میره. همچنین I/O غیرضروری روی دیسک مون کمتر میشه و دادهها سریعتر بازیابی میشن.
اما با همه این بهینهسازیها، بازم خوندن از دیسک همیشه کندتر از خوندن از حافظه س (Memory). اینجاست که Caching وارد بازی میشه؛ یعنی دادههای پر استفاده رو تو حافظه کش نگه میداریم تا خیلی سریعتر بهشون دسترسی داشته باشیم.
۱۹. کش (Caching)
Caching برای بهینه کردن سرعت سیستم استفاده میشه؛ یعنی دادههایی که زیاد استفاده میشن رو تو حافظه نگه میداریم به جای اینکه هر بار از دیتابیس بخونیم.
یکی از رایجترین استراتژیها هم Cache Aside Pattern. چطوری کار میکنه:
- وقتی کاربر یه دادهای میخواد، اول اپلیکیشن سراغ کش میره.
- اگه داده تو کش باشه، همون لحظه برمیگرده و دیگه دیتابیس رو نمیخونه.
- اگه داده تو کش نباشه، از دیتابیس میخونه، تو کش ذخیره میکنه برای دفعات بعد، و به کاربر میده.
- دفعه بعد که همون داده خواسته شد، مستقیما از کش سرویس میشه و خیلی سریعتره.
برای اینکه دادههای قدیمی سرویس نشه، از Time-to-Live (TTL) استفاده میکنیم ؛ یعنی یه زمان انقضا روی دادههای کش تعیین میکنیم تا بعد از مدتی خودکار تازهسازی بشن.
ابزارهای معروف کش هم مثل Redis و Memcached هستن.
۲۰. نرمالسازی معکوس (Denormalization)
بیشتر دیتابیسهای رابطهای از Normalization استفاده میکنن تا دادهها رو به شکل بهینه و تو جدولهای جدا ذخیره کنن.
مثلا تو یه سیستم e-commerce:
- جدول Users جزئیات کاربران رو نگه میداره.
- جدول Orders سفارشها رو ذخیره میکنه.
- جدول Products اطلاعات محصولات رو نگه میداره.
این کار باعث میشه دادهها تکراری نباشن، ولی وقتی بخوایم اطلاعات چند جدول رو با هم بیاریم، باید از JOIN استفاده کنیم و این باعث کند شدن کوئریها میشه وقتی دیتاست بزرگ باشه.
SELECT o.order_id, u.name, u.email, o.product, o.amount
FROM orders o
JOIN users u ON o.user_id = u.user_id;
اینجاست که Denormalization وارد میشه. با دنورمال کردن، دادههای مرتبط رو تو یه جدول جمع میکنیم، حتی اگه کمی تکرار داده داشته باشیم.
مثال: به جای اینکه Users و Orders جدا باشن، یه جدول UserOrders درست میکنیم که هم جزئیات کاربر و هم سفارشهای آخرش رو نگه میداره.
حالا وقتی تاریخچه سفارش کاربر رو میخوایم، نیازی به JOIN نیست—دادهها قبلا کنار هم ذخیره شدن، کوئری سریعتر اجرا میشه و سرعت خواندن بهتر میشه.
SELECT order_id, user_name AS name, user_email AS email, product, amount
FROM orders;
دنورمال کردن معمولا برای اپلیکیشنهایی استفاده میشه که خواندن دادهها خیلی بیشتر از نوشتنه و سرعت مهمه، ولی مشکلش اینه که فضای ذخیرهسازی بیشتر میره و آپدیتها کمی پیچیدهتر میشه.
۲۱. قضیه CAP (CAP Theorem)
وقتی سیستممون رو روی چند تا سرور، دیتابیس و دیتاسنتر گسترش میدیم، وارد دنیای distributed system میشیم.
یکی از اصول پایهای این سیستمها CAP Theorem که میگه: هیچ سیستم توزیعشدهای نمیتونه همزمان سه مورد زیر رو کامل داشته باشه:
- Consistency (C): هر نود همیشه آخرین داده رو برمیگردونه.
- Availability (A): سیستم همیشه جواب درخواستها رو میده، حتی اگه بعضی نودها از کار افتاده باشن (ولی داده ممکنه قدیمی باشه).
- Partition Tolerance (P): سیستم حتی وقتی شبکه بین نودها قطع شد، به کارش ادامه میده.
چون شکست شبکه (Partition) اجتنابناپذیره، باید انتخاب کنیم:
- CP (Consistency + Partition Tolerance): همیشه داده بهروز هست، ولی ممکنه تو زمان قطعی شبکه بعضی درخواستها رد بشه. مثلا SQL دیتابیسها مثل MySQL اینجوری هستن.
- AP (Availability + Partition Tolerance): سیستم همیشه جواب میده، حتی اگه بعضی دادهها قدیمی باشن. مثلا NoSQLهایی مثل Cassandra و DynamoDB.
تو دیتابیسهای NoSQL، همزمان آپدیت کردن همه نودها خیلی کند میشه. به جای اون، از Eventual Consistency استفاده میکنیم:
- همه نودها همزمان آپدیت نمیشن، ولی بعد از مدتی همه با هم همگام میشن و دادهها یکی میشه.
- این باعث میشه سیستم سریع و همیشه در دسترس باشه، حتی وقتی بار خیلی زیاد باشه.
چطور Eventual Consistency کار میکنه:
- کاربر دادهای رو تو یه replica آپدیت میکنه.
- سیستم فوراً آپدیت رو تایید میکنه تا همیشه در دسترس باشه.
- بعد آپدیت به صورت asynchronous به بقیه replicaها فرستاده میشه.
- بعد از یه تاخیر کوتاه، همه replicaها آخرین داده رو دارن و در نهایت Consistency برقرار میشه.
۲۲. Blob (Blob Storage)
امروزه بیشتر اپلیکیشنها فقط نمیخوان متن ذخیره کنن، بلکه باید با عکس، ویدئو، PDF و فایلهای بزرگ دیگه هم کار کنن.
مشکل اینجاست که دیتابیسهای سنتی برای ذخیره فایلهای بزرگ و بدون ساختار مناسب نیستن.
راه حل؟ از Blob Storage مثل Amazon S3 استفاده میکنیم ؛ یه راه خیلی مقیاسپذیر و بهصرفه برای ذخیره فایلهای بزرگ و بدون ساختار تو کلود.
Blobs همون فایلهای منفرد مثل عکس، ویدئو یا سند هستن.
این فایلها تو کانتینرها یا bucketهای منطقی تو کلود ذخیره میشن.
هر فایل یه URL منحصر به فرد داره، که راحت میتونیم باهاش فایل رو بازیابی کنیم و تو وب سرو کنیم.
مثال: https://my-bucket-name.s3.amazonaws.com/videos/tutorial.mp4
مزیتهای Blob Storage:
- Scalability: میتونه پتابایتها داده رو راحت نگه داره.
- Pay-as-you-go: فقط برای چیزی که استفاده میکنی پول میدی.
- Replication اتوماتیک: دادهها تو چند دیتاسنتر و Availability Zone کپی میشن تا مطمئن باشه از دست نره.
- دسترسی راحت: فایلها رو میشه با REST API یا URL مستقیم گرفت.
یک کاربرد رایجش اینه که صدا یا ویدئو رو به صورت real-time به اپلیکیشن کاربر استریم کنیم. ولی مستقیم استریم کردن از blob storage ممکنه کند باشه، مخصوصا اگه داده تو یه منطقه دور ذخیره شده باشه.
۲۳. شبکه توزیع محتوا (CDN)
فرض کن تو هند هستی و میخوای یه ویدئوی YouTube رو ببینی که سرورش تو کالیفرنیا هست.
چون داده ویدئو باید از اون سر دنیا بیاد، ممکنه با بافرینگ و کندی لود مواجه شی.
اینجاست که Content Delivery Network (CDN) به کمک میاد ؛ CDN محتوا رو سریعتر به کاربرا میرسونه، بسته به موقعیت جغرافیاییشون.
یه CDN شبکهای جهانی از سرورهای توزیعشدهست که با هم کار میکنن تا محتوای وب (مثل صفحات HTML، فایلهای JS، CSS، عکس و ویدئو) رو به کاربرا برسونن بر اساس موقعیتشون.
به جای اینکه محتوا فقط از یه دیتاسنتر اصلی سرو بشه، CDN محتواهای استاتیک رو روی چندتا سرور edge در سرتاسر جهان کش میکنه.
وقتی کاربر درخواست محتوا میکنه، نزدیکترین سرور CDN اون محتوا رو میده، نه اینکه کل مسیر تا سرور اصلی طی بشه.
نتیجه؟ چون محتوا از نزدیکترین نود CDN سرو میشه، بارگذاری سریعتره و بافرینگ تقریبا صفر میشه.
۲۴. وبسوکتها (WebSockets)
بیشتر وب اپلیکیشنها از HTTP استفاده میکنن که یه مدل درخواست-پاسخ داره:
- کلاینت یه درخواست میفرسته.
- سرور درخواست رو پردازش میکنه و پاسخ میده.
- اگه کلاینت داده جدید بخواد، باید دوباره درخواست بده.
این برای صفحات وب استاتیک خوبه، ولی برای اپلیکیشنهای Real-time مثل چت زنده، داشبورد بورس یا بازیهای آنلاین، خیلی کند و ناکارآمده.
با HTTP، تنها راه گرفتن آپدیت لحظهای، Polling هست ؛ یعنی هر چند ثانیه یه درخواست دوباره بفرستی.
ولی Polling خیلی ناکارآمده، چون فشار روی سرور زیاد میشه و پهنای باند هدر میره، چون اکثر پاسخها خالی هستن (وقتی داده جدید نیست).
اینجاست که WebSockets به کمک میاد ؛ به ما اجازه میده یه ارتباط دوطرفه و دائمی بین کلاینت و سرور داشته باشیم، روی یه اتصال ثابت.
چطور کار میکنه:
- کلاینت اتصال WebSocket رو با سرور برقرار میکنه.
- وقتی اتصال برقرار شد، باز میمونه.
- سرور میتونه هر وقت بخواد آپدیتها رو به کلاینت بفرسته، بدون اینکه منتظر درخواست باشه.
- کلاینت هم میتونه پیامها رو سریع به سرور بفرسته.
با این روش تعامل لحظهای ممکن میشه و دیگه نیازی به Polling نیست.
حالا سوال اینه: اگه یه سرور بخواد یه سرور دیگه رو وقتی یه رویداد رخ داد مطلع کنه چی؟
مثال:
- وقتی یه کاربر پرداخت میکنه، Stripe باید فوراً اپلیکیشن تو رو مطلع کنه.
- وقتی کسی کد رو روی GitHub Push میکنه، سیستم CI/CD مثل Jenkins باید خودکار اجرا بشه.
برای این کار از Webhooks استفاده میکنیم.
۲۵. وب هوکها (Webhooks)
به جای اینکه دائم یه API رو Poll کنیم تا ببینیم یه رویداد اتفاق افتاده یا نه، Webhooks اجازه میده یه سرور به محض وقوع رویداد، یه درخواست HTTP به سرور دیگه بفرسته.
چطور کار میکنه:
- سرور دریافتکننده (اپلیکیشن تو) یه URL برای Webhook ثبت میکنه با سرویسدهنده (مثلا Stripe، GitHub، Twilio).
- وقتی یه رویداد اتفاق میافته (مثلا کاربر یه پرداخت انجام میده)، سرویسدهنده یه HTTP POST به اون URL میفرسته و جزئیات رو میذاره.
- اپلیکیشن تو درخواست ورودی رو پردازش میکنه و دادهها رو آپدیت میکنه.
با این روش منابع سرور صرفهجویی میشه و تعداد تماسهای اضافی با API کاهش پیدا میکنه.
۲۶. میکروسرویسها (Microservices)
قبلاً برنامهها رو به شکل Monolith میساختن، یعنی:
- همه امکانات (مثل احراز هویت، پرداخت، سفارشات، ارسال) تو یه کدبیس بزرگ جمع بودن.
- اگه یه بخش از سیستم خراب میشد یا نیاز به مقیاسپذیری داشت، کل سیستم تحت تأثیر قرار میگرفت.
- دیپلوی کردن ریسک داشت ؛ یه آپدیت اشتباه میتونست کل اپ رو از کار بندازه.
مثال: فرض کن یه اپ e-commerce داریم که ماژولهای سفارش، پرداخت، موجودی و ارسال همه به هم چسبیده و تو یه کدبیس هستن. اگه سیستم موجودی کرش کنه، ممکنه کل اپ از کار بیفته.
Monolith برای برنامههای کوچیک خوب کار میکنه، ولی برای سیستمهای بزرگ، مدیریت، مقیاسپذیری و دیپلوی کردنشون سخت میشه.
راه حل: اپلیکیشن رو به سرویسهای کوچیک و مستقل به اسم Microservices تقسیم کنیم که با هم کار میکنن.
هر میکروسرویس:
- فقط یه مسئولیت داره.
- دیتابیس و منطق خودش رو داره، پس میتونه مستقل scale بشه.
- با میکروسرویسهای دیگه از طریق API یا Message Queue ارتباط برقرار میکنه.
با این روش، سرویسها میتونن جداگانه scale و دیپلوی بشن بدون اینکه کل سیستم تحت تأثیر باشه.
ولی وقتی چند میکروسرویس باید با هم صحبت کنن، تماسهای مستقیم API همیشه بهینه نیست ؛ اینجاست که Message Queues وارد بازی میشن.
۲۷. صف پیام (Message Queue)
تو یه سیستم Monolith، فانکشنها مستقیم همو صدا میکنن و منتظر پاسخ میمونن.
ولی تو سیستم مبتنی بر Microservices، این روش بهینه نیست، چون:
- اگه یه سرویس کند باشه یا از کار بیفته، همه چیز معطل میشه.
- ترافیک بالا میتونه یه سرویس رو Overload کنه.
- ارتباط همزمان (Synchronous) وقتی همه منتظر پاسخ هستن، خوب Scale نمیشه.
اینجاست که Message Queue به کمک میان ؛ اجازه میده سرویسها غیرهمزمان با هم ارتباط داشته باشن و درخواستها بدون اینکه عملیات دیگه بلاک بشه، پردازش بشن.
چطور کار میکنه:
- یه Producer (مثلا سرویس Checkout) یه پیام میذاره تو Queue (مثلا "پرداخت رو پردازش کن").
- Queue پیام رو نگه میداره تا یه Consumer (مثلا سرویس Payment) آماده پردازش باشه.
- Consumer پیام رو میگیره و پردازش میکنه.
با Message Queue میتونیم سرویسها رو از هم جدا کنیم و همزمان مقیاسپذیری و تحمل خطا رو بهبود بدیم.
سیستمهای رایج Message Queue: Apache Kafka، Amazon SQS و RabbitMQ.
با این روش میتونیم از Overload شدن سرویسهای داخلی جلوگیری کنیم. ولی سوال اینه: برای Public APIها و سرویسهایی که ارائه میکنیم، چطور Overload رو کنترل کنیم؟ جوابش Rate Limiting
۲۸. محدودسازی نرخ (Rate Limiting)
فرض کن یه ربات شروع کنه به ارسال هزاران درخواست در ثانیه به سایتت.
بدون محدودیت، این میتونه:
- سرورها رو کرش کنه چون همه منابع استفاده میشه.
- هزینههای کلود رو بالا ببره به خاطر استفاده زیاد از API.
- عملکرد کاربران واقعی رو خراب کنه.
اینجاست که Rate Limiting وارد بازی میشه ؛ این محدودیت تعیین میکنه هر کلاینت تو یه بازه زمانی مشخص چقدر میتونه درخواست بفرسته.
چطور کار میکنه:
- به هر کاربر یا IP یه مقدار خاص درخواست اختصاص داده میشه (مثلا 100 درخواست در دقیقه).
- اگه از این حد عبور کنن، سرور درخواستهای اضافی رو موقتاً بلاک میکنه و یه خطا برمیگردونه (HTTP 429 Too Many Requests).
الگوریتمهای مختلفی برای Rate Limiting هستن، از جمله:
- Fixed Window: محدودیت روی یه بازه زمانی ثابت (مثلا 100 درخواست در دقیقه).
- Sliding Window: نسخه منعطفتر که محدودیت رو دینامیک تنظیم میکنه و Burstهای ناگهانی رو هموار میکنه.
- Token Bucket: کاربرها توکن دریافت میکنن برای هر درخواست، که با یه نرخ ثابت پر میشه.
لازم نیست خودمون Rate Limiting رو از صفر بسازیم ؛ این کار معمولاً با API Gateway انجام میشه.
۲۹. دروازه API (API Gateway)
یه API Gateway یه سرویس مرکزیه که کارهای مثل احراز هویت، Rate Limiting، لاگگیری ، مانیتورینگ و مسیردهی درخواستها رو انجام میده.
فرض کن یه اپلیکیشن میکروسرویس داریم با چند سرویس مختلف. به جای اینکه هر سرویس رو مستقیم در دسترس بذاریم، API Gateway یه نقطه ورود واحد برای همه درخواستهای کلاینت میشه.
چطوری کار میکنه:
- کلاینت یه درخواست به API Gateway میفرسته.
- Gateway درخواست رو اعتبارسنجی میکنه (مثلا احراز هویت، محدودیت تعداد درخواستها).
- درخواست رو به میکروسرویس مناسب میفرسته.
- پاسخ دوباره از طریق Gateway به کلاینت برمیگرده.
API Gateway مدیریت APIها رو ساده میکنه و مقیاسپذیری و امنیت سیستم رو بهتر میکنه.
از ابزار های محبوب میشه به NGINX، Kong و AWS API Gateway اشاره کرد.
۳۰. ایندمپوتنسی (Idempotency)
تو سیستمهای توزیعشده، خطاهای شبکه و تلاش مجدد سرویسها خیلی رایجه. مثلا اگه کاربر اشتباهی صفحه پرداخت رو رفرش کنه، سیستم ممکنه دو درخواست پرداخت دریافت کنه در حالی که فقط یه بار باید پردازش میشد.
Idempotency تضمین میکنه که درخواستهای تکراری همون نتیجه رو بدن که انگار فقط یه بار درخواست ارسال شده.
چطور کار میکنه:
- به هر درخواست یه ID یکتا میدیم (مثلا request_1234).
- قبل از پردازش، سیستم چک میکنه که آیا این درخواست قبلاً پردازش شده یا نه.
- اگه شده → درخواست تکراری نادیده گرفته میشه.
- اگه نشده → درخواست مثل همیشه پردازش میشه.
Idempotency جلوی تراکنشهای تکراری رو میگیره و یکنواختی دادهها تو سیستمهای توزیعشده رو تضمین میکنه.
نتیجهگیری
خلاصه اینکه تو این مقاله یه نگاه سریع و راحت انداختیم به دنیای طراحی سیستمها و دیدیم کلاینت و سرور چطور با هم کار میکنن. از چیزای پایهای مثل معماری کلاینت-سرور، IP و DNS شروع کردیم و رسیدیم به مفاهیم پیشرفتهتر مثل Load Balancer، مقیاسپذیری عمودی و افقی، Replication، Sharding، Caching و Rate Limiting.
همچنین فهمیدیم که حتی یه سیستم ساده هم کلی لایه و تکنیک داره تا هم سریع باشه، هم قابل اعتماد و هم بتونه با فشار ترافیک بالا کنار بیاد. مفاهیم API، REST و GraphQL، دیتابیسهای SQL و NoSQL و Microservices هم نشون میده طراحها چطور با ابزارای درست، نیاز کاربران رو جواب میدن.
در نهایت، وقتی این اصول و تکنیکها رو بفهمیم، نه تنها میتونیم سیستمهای کارآمد و پایدار بسازیم، بلکه مشکلات مقیاسپذیری، تأخیر و پیچیدگی دادهها رو هم راحتتر پیشبینی و حل میکنیم.
📌 رفرنس: این مطلب بر اساس مقالهی «30 System Design Concepts» از وبلاگ AlgoMaster نوشته شده: https://blog.algomaster.io/p/30-system-design-concepts