در اغلب آموزش‌ها، طراحی سیستم صرفاً مانند کاتالوگی از اجزای مختلف تدریس می‌شود: Load Balancerها، Cacheها، صف‌های پیام (Message Queues). اما حفظ کردن این فهرست قطعات، فرسنگ‌ها با درک واقعی آن‌ها فاصله دارد. سؤال عمیق‌تر و مهم‌تر همیشه این است: وقتی یک سیستم رشد می‌کند دقیقاً چه چیزی از کار می‌افتد، و هر یک از این مفاهیم چگونه به یک خرابی مشخص در مقیاس‌های بزرگ پاسخ می‌دهند؟

در این مقاله به بررسی ۲۸ مفهوم بنیادین می‌پردازیم؛ آن هم با تمرکز روی مشکلی که برای حلش آمده‌اند، سازوکاری که با آن مشکل را برطرف می‌کنند و سبک‌سنگین‌کردن‌ها و مصالحه‌هایی (Trade-off) که در ازای آن به سیستم تحمیل می‌کنند. در سیستم‌های توزیع‌شده (Distributed Systems) هیچ چیزی رایگان به دست نمی‌آید؛ هر تکنیکی که کارایی (Performance)، دردسترس‌بودن (Availability) یا مقیاس‌پذیری (Scale) را برایتان می‌خرد، هزینه‌اش را در جای دیگری از شما پس می‌گیرد.

مسیر پیش‌روی ما، رد پای یک درخواست (Request) ساده را دنبال می‌کند؛ از همان لحظه‌ای که کلاینت را ترک می‌کند، از لایه‌هایی که آن را هدایت و پردازش می‌کنند می‌گذرد، تا به ذخیره‌سازی داده‌ها و سازوکارهایی برسد که پایداری و یکپارچگی سیستم را زیر بار ترافیک سنگین حفظ می‌کنند. اصل اساسی این است که اغلب قطعی‌ها و خرابی‌ها به خاطر ندانستن تکنیک‌های عجیب‌وغریب و پیچیده نیست، بلکه از نادیده گرفتن اصول پایه‌ای آب می‌خورد. فهمیدن این‌که یک مفهوم چه زمانی به کار می‌آید، بسیار مهم‌تر از صرفاً دانستن این است که چنین مفهومی وجود دارد.

عضو Medium نیستید؟ این مقاله را می‌توانید به‌صورت رایگان از اینجا بخوانید.
سایر مطالب من: مفاهیم پایه توسعه (Git + SQL + Auth) · اصول احراز هویت (Sessions، JWTs و OAuth) · بسته مسیر شغلی (رزومه + مصاحبه‌ها + پیشرفت شغلی) · کیت مصاحبه System Design (نسخه رایگان در اینجا) · منتورشیپ ۱ به ۱.

بخش اول — ارسال درخواست (Sending the Request)

تصویر مقاله

۱. معماری Client-Server

یک کلاینت (Client) هر آن چیزی است که درخواستی ارسال می‌کند؛ از یک مرورگر وب و اپلیکیشن موبایل گرفته تا یک سرویس دیگر. در مقابل، یک سرور (Server) ماشینی همیشه روشن است که به این درخواست‌ها پاسخ می‌دهد. کلاینت تقاضا می‌کند، و سرور کار را انجام داده و جواب می‌دهد. این چرخه درخواست-پاسخ (Request-Response) همان سنگ بنایی است که تقریباً تمام سیستم‌های تحت شبکه بر پایه‌اش بنا شده‌اند.

بارزترین ویژگی این رابطه، عدم تقارن در مسئولیت است: کلاینت درخواست را شروع می‌کند و منتظر می‌ماند، در حالی که سرور باید همواره در دسترس باشد، هم‌زمان به کلاینت‌های متعددی رسیدگی کند و منابع مشترک را از تقاضاهای متضاد آن‌ها مصون بدارد. بیشتر مشکلات کارایی و قابلیت اطمینان (Reliability) دقیقاً از دل همین عدم تقارن بیرون می‌آیند؛ جایی که سرور دست‌وپا می‌زند تا پاسخگو بماند، در حالی که تعداد کلاینت‌ها، حجم داده‌ها و پیچیدگی کارها همگی مستقل از یکدیگر در حال رشد هستند.

اولین سؤالی که این ساختار پیش پای ما می‌گذارد این است: یک کلاینت اصلاً چگونه می‌تواند سرور را پیدا کند؟

۲. آدرس‌های IP

هر دستگاهی که در یک شبکه در دسترس باشد، یک آدرس IP دارد؛ شناسه‌ای عددی که درست مثل کد پستی عمل می‌کند. کلاینت برای ارسال هر چیزی به سرور، به آدرس آن نیاز دارد. دو استاندارد آدرس‌دهی وجود دارند که در کنار هم کار می‌کنند: IPv4 با همان فرمت آشنای چهاربخشی مثل 93.184.216.34 که حدود ۴.۳ میلیارد آدرس را پوشش می‌دهد؛ و IPv6 که دقیقاً به خاطر پایان یافتن ظرفیت IPv4 معرفی شد و ظرفیتی عملاً نامحدود ارائه می‌دهد.

اما تکیه مستقیم بر این آدرس‌های خام برای سیستم‌های کاربرمحور عملاً غیرممکن است؛ انسان‌ها نمی‌توانند آن‌ها را به خاطر بسپارند، جابه‌جایی یک سرویس آدرس آن را تغییر می‌دهد و اتصال تمام کلاینت‌هایی که آدرس قبلی را ذخیره کرده بودند می‌شکند. از طرفی یک سرویس به‌ندرت روی یک ماشین تکی قرار دارد و معمولاً مجموعه‌ای از سرورها پشت یک Load Balancer هستند که آدرس عمومی‌اش متعلق به هیچ سرور خاصی نیست. بنابراین برنامه‌ها هرگز نباید مستقیماً به آدرس‌های IP وابسته شوند. وجود یک لایه واسط ضروری است؛ نامی پایدار و کاربرپسند که بتوان با تغییرات زیرساختی، مقصد آن را به‌راحتی تغییر داد. این لایه واسط، همان DNS است.

۳. DNS

سامانه Domain Name System (DNS) نام‌های خوانا برای انسان مثل example.com را به آدرس‌های IP ترجمه می‌کند. این سامانه در عمل همان دفترچه تلفن توزیع‌شده اینترنت است — و نکته حیاتی اینجاست که DNS ساختاری توزیع‌شده و سلسه‌مراتبی دارد، نه یک جدول جستجوی ساده و متمرکز. تبدیل نام به آدرس (Resolution) از طریق زنجیره‌ای از مراجع مختلف انجام می‌شود که هر کدام مسئول بخشی از فضای نام‌ها هستند.

مراحلی که در ادامه می‌آید نشان می‌دهد وقتی یک نام دامنه برای اولین بار ترجمه می‌شود، در پشت صحنه چه اتفاقی می‌افتد:

تصویر مقاله

یک Recursive Resolver (که معمولاً توسط ISP یا ارائه‌دهندگان عمومی مدیریت می‌شود) از طرف کلاینت زحمت جستجو را به دوش می‌کشد: ابتدا از یک Root Server می‌پرسد که زون .com کجاست، سپس از TLD Server معرفی‌شده می‌پرسد که example.com در کجا قرار دارد، و در نهایت از Authoritative Server خود دامنه، آدرس اصلی را دریافت می‌کند. این پاسخ با یک مقدار Time-to-Live (TTL) بازمی‌گردد و تمام سرورهای Resolver در طول مسیر، آن را تا زمان انقضای TTL کش (Cache) می‌کنند — و درست به همین دلیل است که جستجوی دوم برای همان نام، تقریباً آنی انجام می‌شود.

با این حال، همین مکانیزم کش کردن اغلب غافلگیری‌های عملیاتی بزرگی در DNS رقم می‌زند. وقتی یک رکورد تغییر می‌کند، Resolverها در سراسر دنیا تا زمان پایان یافتن TTL ذخیره‌شده‌شان، همان پاسخ قدیمی را تحویل می‌دهند. نتیجه این اتفاق پدیده‌ای به نام تاخیر انتشار (Propagation Delay) است: سرویس برای برخی کاربران به‌درستی کار می‌کند و برای برخی دیگر قطع می‌شود، چون هرکدام با Resolverهایی صحبت می‌کنند که نسخه‌های متفاوتی از رکورد را نگه داشته‌اند. توصیه کاربردی و تجربی این است که قبل از اعمال هرگونه تغییر برنامه‌ریزی‌شده، حتماً مقدار TTL رکورد را از قبل کاهش دهید.

۴. Reverse Proxy

یک Reverse Proxy جلوی یک یا چند سرور قرار می‌گیرد و درخواست‌های ورودی را قبل از رسیدن به برنامه اصلی رهگیری می‌کند. این پراکسی اتصال را از طرف سرورها می‌پذیرد و سپس بر اساس تنظیمات خود، هر درخواست را به Backend مناسب هدایت می‌کند.

قرار دادن مستقیم سرورهای برنامه در معرض اینترنت عمومی، سطح حملات امنیتی (Attack Surface) را به شدت افزایش می‌دهد، آدرس‌های واقعی آن‌ها را لو می‌دهد و وظایف مشترک و عرضی را در تمام سرورها پراکنده می‌کند. یک Reverse Proxy این وظایف را در یک نقطه ورودی واحد و امن تجمیع می‌کند و معمولاً کارهای زیر را بر عهده می‌گیرد:

  • پایان‌دهی TLS یا TLS Termination — رمزگشایی ترافیک HTTPS در لبه شبکه (Edge) به گونه‌ای که سرورهای Backend در شبکه داخلی با HTTP ساده ارتباط برقرار کنند و مدیریت گواهی‌ها متمرکز شود.
  • مسیریابی درخواست‌ها (Request Routing) — هدایت مثلاً مسیر /api به یک سرویس و مسیر /static به سرویسی دیگر بر اساس Path، Host یا Header.
  • کشینگ (Caching) — پاسخ‌دهی مستقیم به درخواست‌های تکراری برای یک محتوا، بدون اینکه بار اضافی به Backend وارد شود.
  • فیلترینگ امنیتی (Security Filtering) — ایفای نقش به عنوان فایروال وب (WAF) و مهار بخش عمده‌ای از ترافیک مخرب پیش از رسیدن به برنامه اصلی.
  • توزیع بار (Load Balancing) — توزیع متوازن درخواست‌ها بین سرورهای Backend، که در بخش‌های بعدی به تفصیل به آن می‌پردازیم.
تصویر مقاله

جا دارد تفاوت این مفهوم را با Forward Proxy مشخص کنیم؛ پراکسی روبه‌جلو در مقابل کلاینت‌ها می‌نشیند و به نمایندگی از آن‌ها با دنیای بیرون ارتباط می‌گیرد (درست مانند پراکسی‌های خروجی در شبکه‌های سازمانی). در مقابل، Reverse Proxy نماینده سرورهاست — و کلمه «معکوس» دقیقاً به جهتی که این پراکسی رو به آن دارد اشاره می‌کند. ابزارهایی مثل NGINX، HAProxy و Envoy از نمونه‌های پرکاربرد در این حوزه هستند.

۵. تاخیر (Latency)

تاخیر (Latency) مدت‌زمانی است که از لحظه ارسال درخواست تا دریافت پاسخ طول می‌کشد. این مفهوم با توان پردازش (Throughput) متفاوت است؛ توان پردازش نشان می‌دهد یک سیستم در واحد زمان چه مقدار کار انجام می‌دهد. یک سیستم می‌تواند هم‌زمان Throughput بالا و Latency بالایی داشته باشد، درست مثل یک قطار باری غول‌پیکر که محموله عظیمی را جا‌به‌جا می‌کند اما رسیدنش به مقصد زمان زیادی می‌برد.

یکی از عوامل اساسی و اغلب نادیده‌گرفته‌شده در تاخیر، فاصله فیزیکی است. سیگنال‌ها در فیبر نوری تقریباً با دو‌سوم سرعت نور حرکت می‌کنند، که این یعنی قبل از هرگونه پردازش، صف‌بندی یا شلوغی شبکه، به‌ازای هر هزار کیلومتر چند میلی‌ثانیه تاخیر اجتناب‌ناپذیر تحمیل می‌شود. درخواستی که از اقیانوس عبور می‌کند و برمی‌گردد، در هر رفت‌وبرگشت (Round Trip) این هزینه را می‌پردازد و پروتکل‌هایی که به چندین رفت‌وبرگشت نیاز دارند (مثل هندشیک TLS) این تاخیر را چند برابر می‌کنند. نکته مهم اینجاست که این تاخیر را نمی‌توان با نوشتن کدهای سریع‌تر کم کرد، زیرا محدود به قوانین فیزیک است نه توان پردازشی. تنها راه‌حل واقعی، نزدیک‌تر کردن کارها به کاربر است:

  • توزیع جغرافیایی (Geographic Distribution) — مستقر کردن سرورها در چندین منطقه مختلف، تا کاربران به نزدیک‌ترین سرور متصل شوند.
  • کشینگ در لبه و شبکه‌های توزیع محتوا (Edge Caching & CDNs) — ارائه محتوا از طریق نقاط حضور (PoP) نزدیک به کاربر، که در بخش‌های بعدی به آن می‌پردازیم.
  • کاهش رفت‌وبرگشت‌ها (Reducing Round Trips) — تجمیع و دسته‌بندی درخواست‌ها (Batching)، استفاده مجدد از اتصالات موجود، و کاهش پروتکل‌های پر رفت‌وآمد (Chatty).

وقتی کاربرانی که از دیتاسنتر دور هستند از کُندی سیستم شکایت می‌کنند، دلیل آن اغلب فاصله فیزیکی است نه کوئری‌های ناکارآمد پایگاه داده — مشکلی که هیچ‌وقت با بهینه‌سازی کوئری (Query Tuning) حل نخواهد شد.

۶. پروتکل‌های HTTP و HTTPS

پروتکل HTTP (HyperText Transfer Protocol) پروتکل لایه کاربرد است که ارتباط بین کلاینت و سرور را در وب مدیریت می‌کند. این پروتکل بر پایه سازوکار بدون وضعیت (Stateless) و مدل درخواست-پاسخ کار می‌کند: هر درخواست شامل یک متد (Method) (مانند GET یا POST)، یک مسیر (Path)، مجموعه‌ای از هدرها (Headers) و یک بدنه اختیاری (Body) است؛ و در پاسخ نیز یک کد وضعیت (Status Code)، هدرها و محتوا بازگردانده می‌شود. این ویژگی بدون وضعیت بودن — یعنی سرور هیچ حافظه‌ای از درخواست‌های قبلی نگه نمی‌دارد — همان چیزی است که اجازه می‌دهد درخواست‌ها بدون دغدغه میان ناوگانی از سرورهای جایگزین توزیع شوند و اساس مقیاس‌پذیری افقی (Horizontal Scaling) را تشکیل می‌دهد.

این پروتکل به مرور زمان برای کاهش تاخیر دستخوش تغییرات مهمی شده است: HTTP/1.1 در هر اتصال عمدتاً به صورت ترتیبی و پشت سر هم کار می‌کرد؛ HTTP/2 امکان مالتی‌پلکس کردن (Multiplexing) و ارسال هم‌زمان چندین درخواست روی یک اتصال را فراهم کرد؛ و HTTP/3 لایه انتقال را از TCP به QUIC تغییر داد تا زمان برقراری اولیه اتصال را بیش از پیش کاهش دهد.

پروتکل HTTP ساده تمام داده‌ها را به شکل متن خوانا و بدون رمزنگاری ارسال می‌کند؛ این شیوه برای اطلاعات عمومی مشکلی ندارد، اما برای داده‌های حساس کاملاً ناامن است زیرا هر فردی در میانه مسیر می‌تواند داده‌ها را مشاهده یا دستکاری کند. در مقابل، پروتکل HTTPS این تبادلات را در قالب TLS (Transport Layer Security) بسته‌بندی و رمزنگاری می‌کند تا سه اصل رمزگذاری داده‌ها (Encryption)، یکپارچگی (Integrity - جلوگیری از دستکاری پنهانی) و اصالت‌سنجی (Authentication - اطمینان کلاینت از صحبت با سرور واقعی) تضمین شود. برای هر سرویسی که با اطلاعات کاربری یا داده‌های شخصی سروکار دارد، رمزنگاری یک الزام و پیش‌فرض قطعی است.

۷. APIها

یک رابط برنامه‌نویسی کاربردی یا همان Application Programming Interface (API) قراردادی است که به یک بخش از نرم‌افزار اجازه می‌دهد بدون نیاز به دانستن سازوکار داخلی بخش دیگر، از آن استفاده کند. در بستر شبکه، API مشخص می‌کند کلاینت چگونه می‌تواند از سرور درخواستی داشته باشد: چه عملیاتی در دسترس است، ساختار ورودی‌ها چگونه باید باشد و خروجی‌ها چه قالبی خواهند داشت. کلاینت یک درخواست ساختاریافته ارسال می‌کند؛ API سرور آن را پردازش کرده — و در صورت نیاز با پایگاه‌های داده یا سایر سرویس‌ها تعامل می‌کند — و در نهایت پاسخی ساختاریافته، معمولاً در قالب JSON، تحویل می‌دهد.

ارزش بنیادین یک API در انتزاع (Abstraction) نهفته است، مفهومی که بهتر است آن را مانند یک قول یا تعهد در نظر بگیریم: یک ساختار ورودی مشخص، خروجی تعریف‌شده‌ای تولید می‌کند؛ فارغ از این‌که پشت صحنه این قرارداد چه اتفاقاتی رخ می‌دهد. این جداسازی (Decoupling) باعث می‌شود دو طرف بتوانند کاملاً مستقل از هم به حیات خود ادامه دهند — سرور می‌تواند پیاده‌سازی خود را بازنویسی کند یا پایگاه داده‌اش را تغییر دهد، و تا زمانی که به قرارداد وفادار بماند، نیازی به ایجاد تغییر در هیچ کلاینتی نخواهد بود. بیشتر درسرها و چالش‌های یکپارچه‌سازی زمانی اتفاق می‌افتند که این قول شکسته شود: رفتارهای مستندنشده، فیلدهایی که معنایشان تغییر می‌کند، یا خطاهای ناهماهنگ و متناقض.

دو سبک غالب در وب برای پیاده‌سازی API عبارتند از REST و GraphQL.

۸. معماری REST

رویکرد REST (Representational State Transfer) پرکاربردترین سبک طراحی API در دنیاست. REST بیش از آنکه یک استاندارد سخت‌گیرانه باشد، یک رویکرد معماری است که بر پایه چند اصل کلیدی بنا شده است:

  • منبع‌محور (Resource-Oriented) است؛ یعنی دامنه و مفاهیم سیستم را به شکل اسم‌های آدرس‌پذیر مدل‌سازی می‌کند (مانند /orders یا /users/42
  • بدون وضعیت (Stateless) است؛ به این معنی که هر درخواست باید تمام اطلاعات لازم برای پردازش را همراه خود داشته باشد؛
  • و عملیات‌ها را به متدهای استاندارد پروتکل HTTP (افعال HTTP) نگاشت می‌کند.
Terminal / Bash

این چارچوب رفتاری کاملاً یکپارچه و قابل پیش‌بینی (Uniform) است (یک توسعه‌دهنده به راحتی می‌تواند حدس بزند یک API جدید مبتنی بر REST چگونه کار می‌کند)، قابلیت کش‌شدن (Cacheable) دارد (درخواست‌های امن GET را می‌توان در لایه‌های مختلف کش کرد)، و به شکلی کاملاً طبیعی با زیست‌بوم HTTP یعنی کدهای وضعیت، هدرها، پراکسی‌ها و کش‌ها هماهنگ می‌شود.

نقطه ضعف بنیادین معماری REST، مسئلهٔ سطح تفکیک‌پذیری یا همان Granularity داده‌هاست. از آنجا که هر Endpoint ساختار داده‌ای ثابتی را برمی‌گرداند، کلاینت‌ها معمولاً یا داده‌هایی بیش از نیازشان دریافت می‌کنند (over-fetching) یا برای نمایش یک نمای ساده مجبورند چندین Endpoint را فراخوانی کنند (under-fetching). برای مثال، بارگذاری یک صفحهٔ ساده ممکن است نیازمند واکشی اطلاعات کاربر، سپس پروفایل و در نهایت سفارش‌های اخیر او باشد؛ یعنی سه رفت‌وبرگشت به سرور (Round trip) که هر کدام شامل فیلدهای زائدی هستند که صفحه آن‌ها را دور می‌ریزد. این چالش‌ها و اصطکاک‌ها انگیزهٔ پیدایش مفهوم بعدی شدند.

۹. GraphQL

GraphQL یک زبان پرس‌وجو (Query Language) برای APIها است که کنترل شکل پاسخ را دگرگون می‌کند: به جای آنکه سرور ساختار ثابتی را تحمیل کند، کلاینت دقیقاً مشخص می‌کند به چه فیلدهایی نیاز دارد و سرور نیز — معمولاً تنها از طریق یک Endpoint واحد — دقیقاً همان موارد درخواستی را، نه کم و نه بیش، بازمی‌گرداند.

GraphQL

با این رویکرد، مشکلات over-fetching و under-fetching در REST تا حد زیادی محو می‌شوند؛ یعنی آن سه رفت‌وبرگشت در مثال قبلی، جای خود را به یک درخواست واحد با پاسخی کاملاً سفارشی‌سازی‌شده می‌دهند. علاوه بر این، GraphQL یک schema مبتنی بر انواع داده‌ای قوی (Strongly Typed) ارائه می‌دهد که بستری عالی برای توسعهٔ ابزارهای قدرتمند و مستندسازی خودکار فراهم می‌سازد.

البته این مزایا بدون هزینه به دست نمی‌آیند. اول اینکه Caching becomes harder؛ چرا که روش Caching بر اساس URL در REST، اینجا کاربردی ندارد چون هر کوئری می‌تواند منحصربه‌فرد باشد و این موضوع بار کشینگ را به لایهٔ اپلیکیشن منتقل می‌کند. دوم اینکه Server complexity rises؛ زیرا پردازش کارآمد کوئری‌های دلخواه — بدون بازتولید مشکل N+1 که جلوتر به آن می‌پردازیم — نیازمند پیاده‌سازی الگوهای دسته‌بندی داده‌ها (Batching) و Data-loaderها است. در نهایت، cost control is harder؛ چرا که یک کلاینت می‌تواند کوئری‌های عمیقاً تودرتو و سنگینی ارسال کند؛ بنابراین سرور باید محدودیت عمق و تحلیل هزینه روی کوئری‌ها اعمال نماید. در نتیجه، استفاده از GraphQL زمانی منطقی و توجیه‌پذیر است که با کلاینت‌های متنوعی مثل چندین فرانت‌اند مختلف، سرویس‌های شخص ثالث یا اپ‌های موبایلی با تغییرات سریع روبه‌رو باشید. در غیر این صورت، وقتی کلاینت‌ها یکدست و پایدارند، سادگی REST معمولاً برنده است. اصل بنیادین این است: ابزار را بر اساس تناسب با نیاز انتخاب کنید، نه صرفاً به خاطر جدید بودنش.

بخش دوم — ذخیره‌سازی داده‌ها

۱۰. پایگاه‌های داده (Databases)

یک database وظیفهٔ ذخیره، بازیابی و محافظت از داده‌ها را بر عهده دارد و در عین حال تضمین‌هایی ارائه می‌دهد که پیاده‌سازی آن‌ها درون خود اپلیکیشن بسیار دشوار است: پایداری (Durability: ماندگاری داده‌ها حتی پس از کرش سیستم)، یکپارچگی (Consistency: پیروی داده‌ها از قوانین تعیین‌شده)، کنترل هم‌زمانی (Concurrency Control: امکان خواندن و نوشتن هم‌زمان توسط چندین کلاینت بدون تخریب داده‌های یکدیگر) و اجرای کارآمد کوئری‌ها روی حجم عظیمی از داده‌ها.

دیتابیس مقاوم‌ترین بخش سیستم در برابر تغییرات آتی است؛ یک سرویس بدون وضعیت (Stateless) را می‌توان در یک بعدازظهر از نو نوشت چون داده‌ای در خود نگه نمی‌دارد، اما دیتابیس دربرگیرندهٔ تمام داده‌ها و وضعیت انباشته‌شدهٔ کسب‌وکار است و مهاجرت دادن آن زیر بار ترافیک زنده، مثل جابه‌جا کردن زمینی است که روی آن ایستاده‌اید. به همین دلیل است که انتخاب دیتابیس بیش از هر تصمیم اولیهٔ دیگری نیازمند تأمل و دوراندیشی است — تصمیمی که از خود مدل داده (Data Model) آغاز می‌شود.

۱۱. مقایسه SQL و NoSQL

پایگاه‌های دادهٔ SQL (مانند PostgreSQL و MySQL) داده‌ها را در قالب جدول‌هایی با یک schema مشخص و از پیش‌تعریف‌شده ذخیره می‌کنند و این جداول را از طریق کلیدها به یکدیگر پیوند می‌دهند. مشخصهٔ اصلی و تضمین اساسی آن‌ها، مجموعه‌ای از اصول چهارگانهٔ ACID است:

  • Atomicity — یک تراکنش یا به‌طور کامل انجام می‌شود یا هیچ اثری از آن ثبت نمی‌گردد؛ وضعیتی به نام انجام نیمه‌کاره وجود ندارد.
  • Consistency — هر تراکنش، دیتابیس را از یک وضعیت معتبر به وضعیتی معتبر دیگر هدایت می‌کند و هرگز قوانین و اعتبارسنجی‌های تعیین‌شده را نقض نمی‌نماید.
  • Isolation — تراکنش‌های هم‌زمان نمی‌توانند وضعیت‌های میانی و ثبت‌نشدهٔ یکدیگر را ببینند.
  • Durability — به محض ثبت قطعی (Commit) یک تراکنش، داده‌ها حتی در صورت کرش سیستم یا قطع برق، پایدار و ماندگار خواهند ماند.

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

NoSQL عنوانی عمومی برای پایگاه‌های داده‌ای است که برخی از قواعد رابطه‌ای را کنار می‌گذارند تا مقیاس‌پذیرتر، انعطاف‌پذیرتر و برای الگوهای دسترسی خاص بهینه‌تر شوند:

  • Key-value stores (مثل Redis و DynamoDB) — مانند یک دیکشنری ساده عمل می‌کنند و برای جستجو و بازیابی مستقیم کلیدها فوق‌العاده سریع هستند.
  • Document stores (مثل MongoDB) — اسناد شبهِ JSON با ساختار انعطاف‌پذیر ذخیره می‌کنند و برای داده‌هایی با فرمت و ساختار متغیر ایده‌آل هستند.
  • Wide-column stores (مثل Cassandra) — برای نرخ نوشتن فوق‌العاده بالا (Write Throughput) روی یک کلاستر بهینه‌سازی شده‌اند.
  • Graph databases (مثل Neo4j) — روابط داده‌ای را در درجهٔ اول اهمیت قرار می‌دهند و انتخابی بی‌نظیر برای تحلیل گراف شبکه‌های اجتماعی و موتورهای پیشنهاددهنده هستند.

نقطه شروع متداول برای اغلب پروژه‌ها دیتابیس رابطه‌ای است؛ چرا که بخش اعظم داده‌ها ذاتاً ماهیت رابطه‌ای دارند و می‌توانند از مزایای Joinها، محدودیت‌ها (Constraints) و تراکنش‌هایی بهره ببرند که بسیاری از سیستم‌های NoSQL فاقد آن‌ها هستند. استفاده از NoSQL زمانی منطقی است که نیازی شفاف و اثبات‌شده داشته باشید؛ مواردی نظیر حجم بسیار سنگین نوشتن، اسناد با ساختارهای بسیار متغیر، یا کوئری‌های گرافی پیچیده‌ای که پیاده‌سازی‌شان با Joinهای مکرر در SQL هزینه‌ای سرسام‌آور دارد. در عمل، تیم‌ها اغلب از هر دو بهره می‌برند: یک پایگاه دادهٔ رابطه‌ای برای ثبت تراکنش‌های اصلی و یک دیتابیس Key-value برای Caching یا مدل‌های خواندن غیرنرمال (Denormalized). در نهایت، این تصمیم ربطی به تعصب روی یک فناوری ندارد؛ مسئله انتخاب پایگاه داده‌ای است که با الگوهای دسترسی سیستم شما همخوانی داشته باشد.

بخش سوم — مقیاس‌پذیری سیستم (Scaling)

با رشد ترافیک و بار کاری، در نهایت توان یک سرور واحد به پایان می‌رسد. برای توسعه و ارتقای ظرفیت تنها دو مسیر بنیادی پیش رو دارید و ادامهٔ این بخش به تشریح ابزارها و سازوکارهایی می‌پردازد که مسیر دوم ایجاب می‌کند.

تصویر مقاله

۱۲. مقیاس‌پذیری عمودی (Vertical Scaling)

Vertical scaling (یا Scale Up) یعنی افزایش ظرفیت سخت‌افزاری همان سرور موجود — هسته‌های CPU بیشتر، حافظهٔ رم بیشتر و فضای ذخیره‌سازی پرسرعت‌تر.

بزرگ‌ترین مزیت این رویکرد، سادگی آن است. نیازی به تغییر کد اپلیکیشن نیست، ساختار معماری دست‌نخورده باقی می‌ماند و با پیچیدگی‌های هماهنگ‌سازی میان ماشین‌ها مواجه نمی‌شوید. برای بسیاری از سیستم‌ها، مقیاس‌پذیری عمودی نخستین و منطقی‌ترین واکنش است و با کمترین هزینهٔ مهندسی، زمان و فضای رشد قابل‌توجهی می‌خرد.

اما محدودیت‌های این روش نیز به همان اندازه آشکار است. اول اینکه یک hardware ceiling وجود دارد؛ یعنی سقفی سخت‌افزاری که بالاتر از آن هیچ ماشین واحدی تولید نشده است. دوم، هزینه‌ها به صورت non-linearly بالا می‌روند — چرا که سخت‌افزارهای رده‌بالا به ازای هر واحد ظرفیت، هزینهٔ سرسام‌آوری به همراه دارند. و مهم‌تر از همه، یک سرور منفرد همچنان یک single point of failure است؛ یعنی هر چقدر هم که قدرتمند باشد، در صورت خرابی، کل سیستم را با خود به زیر می‌کشد. همین محدودیت‌ها در نهایت ما را به سمت مسیر دوم هدایت می‌کنند.

۱۳. مقیاس‌پذیری افقی (Horizontal Scaling)

Horizontal scaling (یا Scale Out) یعنی اضافه کردن سرورهای بیشتر و توزیع بار کاری میان آن‌ها. این روش از نظر تئوری سقف محدودیت سخت‌افزاری را به طور کامل از میان برمی‌دارد — چون ظرفیت سیستم با افزودن نودهای متداول و استاندارد رشد می‌کند — و علاوه بر آن دسترس‌پذیری (Availability) را بهبود می‌بخشد، زیرا از کار افتادن یک نود لزوماً به معنای متوقف شدن کل سیستم نیست.

البته این مزایا به بهای افزایش چشمگیر پیچیدگی‌ها تمام می‌شوند و درک این چالش‌ها از دانستن تعریف این مفهوم بسیار حیاتی‌تر است. درست از لحظه‌ای که تعداد سرورها از یکی بیشتر می‌شود، دسته‌ای از مسائل جدید پدید می‌آیند: درخواست‌ها باید به درستی میان سرورها هدایت شوند (Load Balancing)؛ سرورها نباید برای داده‌هایی که فراتر از یک درخواست عمر می‌کنند به حافظهٔ محلی متکی باشند (وضعیت یا State باید به خارج از سرور منتقل شود)؛ و تمام داده‌های مشترک باید یکپارچه و هماهنگ باقی بمانند (Replication و چالش‌های یکپارچگی در قضیه CAP). در این میان، معماری بدون وضعیت (Stateless) که توسط HTTP تشویق می‌شود، همان عاملی است که مقیاس‌پذیری افقی را عملی می‌سازد: اگر هر سروری قادر به پردازش هر درخواستی باشد، سیستم می‌تواند آزادانه کوچک و بزرگ شود. بخش‌های بعدی، جعبه‌ابزار ضروری برای مقیاس‌پذیری افقی را معرفی می‌کنند.

۱۴. متعادل‌کننده‌های بار (Load Balancers)

یک load balancer میان کلاینت‌ها و مجموعه‌ای از سرورها می‌نشیند، ترافیک ورودی را بین آن‌ها پخش می‌کند و مسیر را از سرورهایی که دچار اختلال شده‌اند تغییر می‌دهد. این قطعه همان مؤلفه‌ای است که به پرسش مطرح‌شده در مقیاس‌پذیری افقی پاسخ می‌دهد: «کدام سرور باید به این درخواست پاسخ دهد؟»

وظیفهٔ اولیهٔ آن توزیع بار است که توسط الگوریتم‌های مشخصی مدیریت می‌شود:

  • Round Robin — درخواست‌ها را به صورت چرخشی و نوبتی واگذار می‌کند. ساده و عادلانه است، اما دیدی نسبت به میزان سنگینی هر درخواست ندارد.
  • Least Connections — هر درخواست جدید را به سروری می‌فرستد که کمترین اتصال فعال را دارد؛ رویکردی که در مواجهه با بارهای کاری نابرابر عملکرد هوشمندانه‌تری ارائه می‌دهد.
  • IP Hashing — با هش کردن آدرس IP کلاینت، سرور مشخصی را انتخاب می‌کند تا درخواست‌های یک کاربر خاص همواره به یک سرور یکسان ارسال شوند. این قابلیت session affinity (یا اصطلاحاً Stickiness) نام دارد.

وظیفهٔ دوم و به همان اندازه حیاتی آن، health checking است. متعادل‌کننده به طور مداوم وضعیت تک‌تک سرورهای پشت‌صحنه را پایش می‌کند و در صورت از کار افتادن هر یک، ارسال ترافیک به آن را متوقف می‌سازد؛ عاملی که مانع از آن می‌شود تا کاربران متوجه خرابی یک سرور شوند. این همان مکانیزمی است که پتانسیل مقیاس‌پذیری افقی در ارتقای دسترس‌پذیری را به واقعیتی عینی تبدیل می‌کند.

تصویر مقاله

دربارهٔ Session affinity باید هوشیار بود. اگر سرورهای اپلیکیشن وضعیت نشست (Session State) را در حافظهٔ محلی فرآیند خود نگه دارند و برای هدایت مجدد کاربر به همان سرور به Stickiness تکیه کنند، هر اتفاقی که این پیوند را قطع کند — مانند دیپلوی نسخهٔ جدید، کاهش سرورها یا خرابی یکی از آن‌ها — کاربران را به سروری می‌فرستد که هیچ شناختی از آن‌ها ندارد و منجر به خروج ناگهانی از حساب یا از بین رفتن داده‌هایشان می‌شود. راهکار اصولی این است که وضعیت نشست را در یک حافظه یا پایگاه دادهٔ مشترک ذخیره کنید، نه اینکه حفظ آن را به شانس و مسیریابی بسپارید. قابلیت Stickiness باید صرفاً نقش یک بهینه‌سازی را داشته باشد، نه الزامی برای صحت عملکرد سیستم.

۱۵. ایندکس‌گذاری پایگاه داده (Database Indexing)

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

بیشتر ایندکس‌ها از ساختارهای B-tree استفاده می‌کنند؛ ساختاری که داده‌ها را مرتب و متوازن نگه می‌دارد تا عملیات جستجو، درج یا حذف داده‌ها تنها نیازمند پیمایش تعداد محدودی نود بر اساس مقیاس لگاریتمی باشد.

این تفاوت صرفاً یک بهبود تدریجی نیست، بلکه یک جهش تمام‌عیار است: یک کوئری روی ستون بدون ایندکس باید یک full table scan انجام دهد و تک‌تک سطرها را در زمانی متناسب با اندازهٔ کل جدول بررسی کند؛ در حالی که همان کوئری روی ستونی با ایندکس، مستقیماً درخت داده را به سمت سطرهای هدف طی می‌کند. در جداول حجیم، این یعنی تفاوت بین چند ثانیه معطلی و چند میلی‌ثانیه پاسخ‌دهی.

تصویر مقاله

اما ایندکس‌ها رایگان نیستند و هزینهٔ ایجاد آن‌ها دلیلی است که مانع می‌شود همهٔ ستون‌ها را ایندکس‌گذاری کنیم:

  • Writes slow down. با هر عملیات INSERT، UPDATE و DELETE، ایندکس‌های وابسته نیز باید به‌روزرسانی شوند؛ از این رو جداول با حجم نوشتن بالا به ازای هر ایندکس هزینهٔ زمانی می‌پردازند.
  • Storage grows. هر ایندکس ساختار داده‌ای جداگانه‌ای است که روی دیسک ذخیره می‌شود و فضا اشغال می‌کند.

رویکرد اصولی این است که تنها ستون‌هایی را ایندکس کنید که واقعاً در فیلترها و Joinها به کار می‌روند — مانند کلیدهای اصلی، کلیدهای خارجی و شروط پرکاربرد در عبارت WHERE — و نه چیزی بیشتر.

ایندکس نکردن یک کلید خارجی (Foreign Key) یک اشتباه کلاسیک است: این ضعف در داده‌های کم به چشم نمی‌آید، اما با رشد جدول عملکرد سیستم را به شدت کُند می‌کند. پیش از آنکه تصور کنید گلوگاه اصلی در کد برنامه است، پلن اجرای کوئری را با ابزارهایی مانند EXPLAIN ANALYZE بررسی کنید تا دریابید پایگاه داده در حال اسکن کامل است یا پرش مستقیم به رکوردها.

۱۶. مشکل کوئری‌های N+1 (The N+1 Query Problem)

معضل N+1 query problem شایع‌ترین باگ عملکردی در برنامه‌های مبتنی بر داده به شمار می‌رود که به طرز فریبنده‌ای خود را پنهان می‌کند. این مشکل زمانی رخ می‌دهد که کُد ابتدا یک مجموعه داده را با یک کوئری دریافت کرده و سپس در حین پیمایش، برای تک‌تک آیتم‌ها یک کوئری مجزا ارسال می‌کند؛ اتفاقی که باعث می‌شود تعداد کوئری‌ها به جای مقداری ثابت، متناسب با حجم داده‌ها سر به فلک بکشد.

Ruby

نکته فریبنده اینجاست که چنین نقصی در طول توسعه تقریباً نامرئی است. در مواجهه با چند رکورد انگشت‌شمار، کوئری‌های اضافی اصلاً به چشم نمی‌آیند؛ اما همین کُد در محیط واقعی و زیر بار داده‌های Production، صدها کوئری تولید می‌کند و صفحه‌ای که باید ظرف چند میلی‌ثانیه لود می‌شد، چند ثانیه به طول می‌انجامد.

راهکار حل آن — یعنی استفاده از eager loading برای بارگذاری پیشاپیش وابستگی‌های مورد نیاز کد — به محض تشخیص مشکل بسیار ساده است، اما چالش اصلی در همان دیدن و شناسایی آن نهفته است. به همین دلیل ابزارهای مانیتورینگ ایجاد شده‌اند و بررسی تعداد کوئری‌ها در تست‌ها، سنگ محکی بسیار ارزشمند است. این موضوع نمونه‌ای بی‌نقص از ایده اصلی این مقاله است: خطایی ناشی از یک غفلت ساده، نه نبود یک نوآوری پیچیده.

17. Caching

مفهوم Caching نتایج پردازش‌های سنگین را در حافظه‌ای پرسرعت ذخیره می‌کند تا درخواست‌های تکراری، بدون نیاز به انجام دوباره محاسبات پاسخ داده شوند.

این سازوکار بر پایه دو واقعیت تجربی بنا شده است: نخست اینکه توزیع درخواست‌ها یکنواخت نیست (بخش کوچکی از داده‌ها، بیشترین ترافیک را دریافت می‌کنند) و دوم اینکه خواندن از حافظه Ram به مراتب سریع‌تر از خواندن از دیسک یا محاسبه مجدد نتایج است.

تصویر مقاله

رایج‌ترین استراتژی در این زمینه، الگوی cache-aside است که در آن برنامه به عنوان واسط میان Cache و دیتابیس عمل می‌کند:

Ruby

شاخص time-to-live (TTL) تعیین می‌کند که یک داده پیش از بازیابی مجدد از منبع اصلی، چه مدت مجاز است در کش باقی بماند (که معمولاً با استفاده از ابزارهایی مثل Redis و Memcached پیاده‌سازی می‌شود).

استفاده از Caching یک مصالحه (Trade-off) اساسی به همراه دارد: هر مقدار کش‌شده در واقع نسخه دوم داده است، در حالی که مرجع اصلی در جای دیگری نگهداری می‌شود. چالش اصلی، همگام نگه داشتن این دو نسخه با یکدیگر است؛ به همین خاطر، فرایند cache invalidation یکی از سخت‌ترین مسائل در علوم کامپیوتر نام گرفته است.

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

یکی دیگر از معضلات شایع، پدیده thundering herd (هجوم گله‌وار) است: هنگامی که زمان انقضای یک داده پرطرفدار در کش به سر می‌رسد، انبوهی از درخواست‌ها به طور همزمان با Cache Miss روبرو شده و یکباره به پایگاه داده هجوم می‌برند. این فشار را می‌توان با روش‌هایی مانند تنوع‌بخشی به مقادیر TTL و تکنیک Request Coalescing خنثی کرد.

بنابراین به Caching و زمان انقضای داده‌ها به عنوان تصمیماتی سنجیده در طراحی معماری نگاه کنید، نه صرفاً یک گزینه پیش‌فرض و همیشگی.

18. Replication

قابلیت Replication به معنای نگهداری چندین نسخه از پایگاه داده روی سرورهای مجزا است. متداول‌ترین ساختار در این روش، الگوی primary-replica (که Leader-Follower نیز نامیده می‌شود) است: در این حالت، یک سرور اصلی (Primary) تمامی درخواست‌های نوشتن را می‌پذیرد و یک یا چند نسخه رونوشت، جریانی مداوم از این تغییرات را دریافت کرده و به درخواست‌های خواندن پاسخ می‌دهند.

این رویکرد دو مزیت بنیادین به همراه دارد که باید آن‌ها را از هم تفکیک کرد؛ مزیت اول read scalability یا مقیاس‌پذیری خواندن است: از آنجا که در اغلب سیستم‌ها حجم خواندن بیشتر از نوشتن است، پخش کردن بار خواندن روی نسخه‌های رونوشت، فشار را از روی سرور اصلی برداشته و ظرفیت پاسخ‌دهی را چند برابر می‌کند.

مزیت دوم availability یا دسترسی‌پذیری است: اگر سرور اصلی از کار بیفتد، می‌توان یکی از نسخه‌های رونوشت را جایگزین آن کرد؛ بنابراین از دست رفتن یک سرور، منجر به از کار افتادن کل پایگاه داده نخواهد شد.

تصویر مقاله

چالش اصلی در این میان، پدیده replication lag (تأخیر در همگام‌سازی) است. از آنجا که داده‌ها به صورت ناهمگام (Asynchronous) منتقل می‌شوند، ممکن است یک نسخه رونوشت در شرایط عادی چند میلی‌ثانیه و زیر فشار کاری بالا حتی چند ثانیه از سرور اصلی عقب‌تر باشد.

این موضوع خطایی گیج‌کننده خلق می‌کند: کاربر مقداری را تغییر می‌دهد، اما بلافاصله درخواست خواندن او به یک نسخه رونوشت با تأخیر برخورد کرده و به او اعلام می‌شود که هیچ تغییری اعمال نشده است!

راهکار استاندارد برای این چالش، مسیریابی به روش read-your-writes است؛ یعنی هدایت درخواست‌های خواندنی که باید بازتاب آخرین تغییرات باشند به سرور اصلی، و واگذاری خواندن‌هایی که اندکی تأخیر را تاب می‌آورند به نسخه‌های رونوشت. در واقع، تکنیک Replication کمی از یکپارچگی لحظه‌ای داده‌ها را فدا می‌کند تا مقیاس‌پذیری و تاب‌آوری فوق‌العاده‌ای به دست آورد.

19. Sharding

وقتی حجم داده‌ها از توان ذخیره‌سازی یا پردازش یک سرور فراتر می‌رود، روش sharding وارد عمل شده و داده‌ها را میان چندین دیتابیس مجزا تقسیم می‌کند، به طوری که هر کدام زیرمجموعه‌ای جداگانه از سطرها را در خود نگه می‌دارند. در این ساختار، یک shard key (کلید شارد) — مثلاً شناسه کاربر — مشخص می‌کند که هر سطر به کدام شارد تعلق دارد. از آنجا که این تفکیک بر پایه سطرهاست، به آن horizontal partitioning یا پارتیشن‌بندی افقی نیز می‌گویند.

تصویر مقاله

فناوری Sharding سقف نهایی ظرفیت داده‌ها را حذف می‌کند، اما تعهدی سنگین در سطح معماری به شمار می‌آید که هزینه‌های آن خیلی زود نمایان می‌شوند.

اجرای Cross-shard queries and joins بسیار سنگین و پرهزینه است، زیرا داده‌هایی که باید در کنار هم باشند روی شاردهای مختلف قرار می‌گیرند و یک عملیات ساده را به پردازشی توزیع‌شده تبدیل می‌کنند. از طرف دیگر، بازتوزیع داده‌ها یا Rebalancing is hard فرایندی چالش‌برانگیز است؛ زیرا با رشد ناهمگون داده‌ها، جابه‌جا کردن سطرها میان شاردها بدون ایجاد وقفه (Downtime) در سیستم، پروژه‌ای طاقت‌فرساست. همچنین a poor shard key creates hotspots — انتخاب کلید شارد نامناسب نقاط تمرکز ترافیک ایجاد می‌کند؛ مثلاً شاردبندی بر اساس تاریخ ثبت‌نام، باعث می‌شود کاربران فعال‌تر تمام بار را روی یک شارد بیندازند و هدف اصلی از بین برود. توصیه ترتیب اجرا این است: تنها زمانی شاردبندی کنید که ظرفیت Indexing، Caching و Replication را تا انتها به کار گرفته باشید. بسیاری از سیستم‌ها پیش از موعد به سراغ آن می‌روند و بدون نیاز به چنین مقیاسی، زیر بار پیچیدگی‌هایش می‌مانند.

20. Vertical Partitioning

در حالی که Sharding داده‌ها را بر اساس سطرها تفکیک می‌کند، روش vertical partitioning بر پایه ستون‌ها تقسیم‌بندی را انجام می‌دهد. در این روش، یک جدول عریض که فیلدهای گوناگونی — از اطلاعات پروفایل گرفته تا تاریخچه ورود و جزئیات پرداخت — در آن تلنبار شده، به چندین جدول باریک‌تر و تفکیک‌شده بر اساس کاربرد تقسیم می‌شود.

مزیت این رویکرد مستقیماً به شیوه خواندن داده‌ها از دیسک بازمی‌گردد: کوئری‌هایی که صرفاً به چند فیلد خاص نیاز دارند، دیگر تمام سطر طویل و ستون‌های بلااستفاده را وارد حافظه نمی‌کنند. سطرهای باریک‌تر باعث می‌شوند رکوردهای بیشتری در هر Page دیسک و کش جا بگیرند، میزان I/O کاهش یابد و امکان ذخیره‌سازی مجزای ستون‌های پرکاربرد (Hot) و کم‌کاربرد (Cold) فراهم شود. این تکنیک برای جدول‌های جامع و عمومی در برنامه‌های قدیمی که به طور مداوم فیلدهای تازه دریافت کرده‌اند، بسیار کارساز است.

21. Denormalization

رویکرد Normalization (نرمال‌سازی) در دیتابیس‌های رابطه‌ای، داده‌های تکراری را از طریق تقسیم آن‌ها میان جداول مرتبط حذف می‌کند تا هر موضوع فقط یک‌بار ثبت شود. این ساختار یکپارچگی داده‌ها را حفظ کرده و به‌روزرسانی را ساده‌تر می‌کند، اما می‌تواند سرعت خواندن را کاهش دهد؛ زیرا تجمیع نتایج کامل اغلب به عملیات joins وابسته است که با بزرگ‌تر شدن جداول، بسیار پرهزینه‌تر می‌شود.

در نقطه مقابل، روش Denormalization عمداً مسیری برعکس را پیش می‌گیرد. این رویکرد مقداری داده تکراریِ حساب‌شده را وارد سیستم می‌کند تا از اجرای joins در مسیرهای خواندن پرکاربرد اجتناب شود.

SQL

این مصالحه نیازمند درک دقیق است: عملیات خواندن معمولاً سریع‌تر و سرراست‌تر می‌شود، اما عملیات نوشتن کندتر و پرریسک‌تر خواهد شد؛ زیرا یک مقدار مشخص باید در چند نقطه به‌روزرسانی شود و فراموش کردن حتی یک بخش، عدم یکپارچگی داده‌ها را رقم می‌زند.

نرمال‌زدایی نباید رویکرد پیش‌فرض شما باشد، بلکه تکنیکی هدفمند است که منحصراً برای مسیرهای خواندنی که گلوگاه عملکرد هستند استفاده می‌شود. از این منظر، این روش شبیه به Caching در دل پایگاه داده است: شما مصرف حافظه بیشتر و پیچیدگی نوشتن را می‌پذیرید تا به سرعت بالاتری در خواندن دست یابید.

Part IV — Keeping a Distributed System Honest

به محض آنکه داده‌ها و پردازش‌ها میان چندین سرور پخش می‌شوند، چالش‌های کاملاً جدیدی ظهور می‌کنند — مسائلی که هرگز روی یک سرور منفرد وجود نداشتند. مفاهیم پایانی به حفظ درستی داده‌ها و ایمن‌سازی سیستم در مواجهه با شبکه‌های غیرقابل اتکا می‌پردازند.

22. CAP Theorem

قضیه CAP theorem یک محدودیت بنیادین و قطعی را در سیستم‌های داده توزیع‌شده صورت‌بندی می‌کند. این نظریه بیان می‌دارد که در صورت بروز یک partition در شبکه — یعنی قطعی ارتباط میان نودها — سیستم می‌تواند فقط یکی از دو ویژگی consistency (یکپارچگی: اینکه هر خواندن آخرین مقدار نوشته‌شده را بازتاب دهد) یا availability (دسترسی‌پذیری: پاسخ دادن به هر درخواست) را حفظ کند، نه هر دو را به صورت همزمان.

تصویر مقاله

منطق پشت این قضیه بسیار شهودی است. فرض کنید ارتباط میان دو سرور قطع شود و درخواستی برای نوشتن به یکی از آن‌ها برسد. سیستم یا باید درخواست را رد کند تا از ارائه داده‌های احتمالاً قدیمی جلوگیری شود که در این حالت دسترسی‌پذیری فدای یکپارچگی می‌شود (یک سیستم CP)، یا درخواست را بپذیرد و پاسخ دهد، با این ریسک که طرف دیگر تا برطرف شدن مشکل داده‌های قدیمی تحویل دهد، که در این حالت یکپارچگی فدای دسترسی‌پذیری شده است (یک سیستم AP). از آنجا که رخ دادن قطعی شبکه امری اجتناب‌ناپذیر است، در عمل هر سیستم توزیع‌شده‌ای — چه طراحانش آن را شفاف کرده باشند یا نه — پیشاپیش یکی از این دو مسیر را انتخاب کرده است.

سیستم‌های AP معمولاً مدل eventual consistency (یکپارچگی نهایی) را پیاده می‌کنند: تغییر بلافاصله تأیید می‌شود و سپس به صورت ناهمگام در کل سیستم توزیع می‌گردد تا تمام نسخه‌ها همسان شوند. اگرچه این فاصله ناهماهنگی کوتاه است، اما کاملاً وجود دارد و کاربری که در این فاصله داده‌ای را بخواند، ممکن است باعث ایجاد تداخل یا رکوردهای تکراری شود.

به همین دلیل است که قضیه CAP فقط یک مفهوم تئوری نیست؛ بلکه به روشنی توضیح می‌دهد چرا قابلیتی که روی یک سیستم به درستی کار می‌کرد، پس از توزیع داده‌ها روی چند سرور رفتاری متفاوت نشان می‌دهد؛ نکته‌ای که ما را مستقیماً به موضوع بعدی هدایت می‌کند.

23. Blob Storage

پایگاه‌های داده رابطه‌ای و اکثر سیستم‌های NoSQL برای ذخیره‌سازی فایل‌های باینری حجیم — مثل تصاویر، ویدیوها، فایل‌های صوتی و اسناد — کارایی ضعیفی دارند. نگهداری چنین فایل‌هایی در دیتابیس حجم فایل‌های پشتیبان را سرسام‌آور می‌کند، سرعت کوئری‌ها را کاهش می‌دهد، فرایند Replication را پیچیده می‌سازد و قابلیت‌های گران‌قیمت تراکنشی را روی داده‌هایی هدر می‌دهد که هیچ نیازی به آن‌ها ندارند.

سرویس‌های Blob storage (ذخیره‌سازی باینری‌های حجیم) دقیقاً برای رفع این مشکل توسعه یافته‌اند. سیستم‌هایی نظیر Amazon S3 فایل‌ها را در قالب Object درون فضاهایی به نام buckets نگهداری می‌کنند که هر یک از طریق یک URL منحصر‌به‌فرد در دسترس است، پایداری آن‌ها از طریق تکثیر خودکار در چندین مرکز داده تضمین می‌شود و مدل هزینه آن بر اساس میزان مصرف واقعی است.

الگوی استاندارد، تفکیک دقیق وظایف است: خود فایل در Blob Storage ذخیره می‌شود، در حالی که پایگاه داده تنها متادیتا و URL دسترسی به آن را نگهداری می‌کند. این کار دیتابیس را سبک و سریع نگه می‌دارد، به سرویس Blob اجازه می‌دهد روی تخصص اصلی خود متمرکز بماند و در ترکیب با یک CDN، تحویل فایل از نزدیک‌ترین فاصله به کاربر را ممکن می‌سازد. قرار دادن محتوای فایل درون ستون‌های پایگاه داده به بهانه راحتی موقت، یک پادالگوی (Anti-pattern) رایج است که با هر بار بک‌آپ‌گیری، دردسرسازتر می‌شود.

24. CDN

یک شبکه توزیع محتوا یا Content Delivery Network (CDN) شبکه‌ای جهانی از edge servers (سرورهای لبه) است که محتوا را در نزدیک‌ترین فاصله مکانی به کاربران کش می‌کند. وقتی کاربری فایلی را درخواست می‌کند، محتوا به جای سرور اصلیِ دوردست، از نزدیک‌ترین سرور لبه تحویل داده می‌شود که این کار تأخیر رفت‌وبرگشت پیام‌ها (Latency) را به شکلی چشمگیر کاهش می‌دهد.

سرویس CDN در حقیقت همان اصل بهینه‌سازی تاخیر است که به شکلی ساختاریافته برای ارسال محتوا پیاده می‌شود. این سیستم بیشترین بازدهی را برای دارایی‌های استاتیک یا static assets دارد — از جمله تصاویر، اسکریپت‌ها، فونت‌ها و بخش‌های ویدیویی — که برای همه یکسان بوده و به ندرت تغییر می‌کنند؛ هرچند CDNهای مدرن امروزه محتوای داینامیک را هم کش کرده و کدهای پردازشی را در لبه شبکه اجرا می‌کنند. فراتر از کاهش تاخیر، یک CDN ترافیک سنگینی را که قرار بود مستقیماً به سرور اصلی اصابت کند جذب می‌نماید، فشار کاری را کاهش داده و مانند یک سپر در برابر جهش‌های ناگهانی ترافیک و حملات عمل می‌کند. به‌کارگیری آن نیز معمولاً تنها با یک تغییر در تنظیمات انجام می‌شود و نیازی به تغییر کدها ندارد — فرصتی کم‌نظیر که جهشی بزرگ در عملکرد را با کمترین هزینه ممکن فراهم می‌سازد.

25. WebSockets

پروتکل معمول HTTP اساساً مبتنی بر الگوی request-response and client-initiated (درخواست-پاسخ با شروع از سمت کلاینت) است؛ یعنی سرور تا زمانی که درخواستی دریافت نکند، نمی‌تواند پیامی بفرستد. این رویکرد برای باز کردن صفحات وب و ارسال فرم‌ها کاملاً ایده‌آل است، اما برای برنامه‌های بلادرنگ یا real-time — مانند چت‌های آنلاین، ویرایش اشتراکی اسناد، پنل‌های معاملاتی و بازی‌های چندنفره — که در آن‌ها سرور باید تغییرات را در همان لحظه وقوع به کلاینت اطلاع دهد، اصلاً مناسب نیست.

راهکار ابتدایی برای این موضوع، تکنیک polling است؛ یعنی کلاینت مدام بپرسد: «آیا پیام جدیدی هست؟» اما این رویکرد بسیار سنگین و پرهزینه است.

تصویر مقاله

همان‌طور که در دیاگرام مشخص است، روش Polling سیلی از تبادلات اکثراً خالی تولید می‌کند که هر کدام سربار کامل پروتکل HTTP را به همراه دارند، پهنای باند را تلف می‌کنند و با تمام این اوصاف، تغییرات را فقط با همان فاصله زمانی که کلاینت درخواست فرستاده مخابره می‌نمایند.

فناوری WebSockets این ساختار را با یک اتصال منفرد، همیشگی و bidirectional (دوطرفه) جایگزین می‌کند: پس از دست‌تکانی اولیه یا Handshake (که در قالب یک درخواست HTTP آغاز شده و سپس ارتقا می‌یابد)، اتصال باز می‌ماند و هر یک از طرفین می‌توانند در هر لحظه و با کمترین سربار، پیام خود را ارسال نمایند.

اما این رویکرد بهایی عملیاتی به همراه دارد: اتصالات پایدار (persistent connections) در تمام طول عمر خود منابع سرور را مصرف می‌کنند و مقیاس‌پذیری افقی (horizontal scaling) را با پیچیدگی مواجه می‌سازند؛ زیرا مدیریت و هماهنگی این حجم از اتصالات باز در میان مجموعه‌ای از سرورها، معمولاً نیازمند یک shared backplane مرکزی است.

۲۶. Webhooks

پروتکل WebSockets چالش ارسال بلادرنگ داده‌ها به کلاینت‌های متصل را حل می‌کند، در حالی که webhooks همین مسئله را میان سرورها برطرف می‌سازند. ساده‌انگارانه‌ترین راه برای باخبر شدن از رویدادهای یک سیستم خارجی این است که API آن را در بازه‌های زمانی منظم poll کنیم — کاری که درست مانند polling در سمت کلاینت، منابع را به هدر داده و باعث ایجاد تأخیر می‌شود.

سازوکار webhook این مسیر را معکوس می‌کند: سرویس‌گیرنده فقط یک‌بار URL خود را نزد ارائه‌دهنده (provider) ثبت می‌کند و سپس به محض وقوع یک رویداد مرتبط، ارائه‌دهنده یک HTTP request به همان URL می‌فرستد.

Ruby
تصویر مقاله

درگاه‌های پرداخت، سیستم‌های version-control و گیت‌وی‌های پیام‌رسان همگی با همین سازوکار کار می‌کنند. با این حال، رعایت دو نکتهٔ حیاتی الزامی است.

نخست این‌که چون این endpoint به صورت عمومی در دسترس است و مستقیماً بر اساس درخواست‌های دریافتی دست به اقدام می‌زند، تمام درخواست‌های ورودی باید authenticated و اعتبارسنجی شوند — که معمولاً این کار از طریق بررسی یک cryptographic signature ارسالی توسط ارائه‌دهنده انجام می‌گیرد.

دوم این‌که اگر ارائه‌دهنده در زمان مشخصی پاسخ 200 را دریافت نکند، فرض را بر عدم تحویل موفق می‌گذارد و عملیات را retries (مجدداً تلاش) می‌کند. بنابراین ممکن است — و در عمل حتماً پیش می‌آید — که یک رویداد یکسان بیش از یک‌بار به مقصد برسد. آماده نبودن handler برای درخواست‌های تکراری، یک نقص پنهان و خطرناک است؛ موضوعی که ما را به آخرین و مهم‌ترین مفهوم این مبحث می‌رساند.

۲۷. Idempotency

در سیستم‌های توزیع‌شده (distributed systems)، عملیات تکراری یک استثنای نادر نیست؛ بلکه امری کاملاً طبیعی و مورد انتظار است. ممکن است کاربر دکمهٔ submit را دو بار کلیک کند، کلاینت موبایل به دلیل ناپایداری ارتباط مجدداً درخواست بفرستد، ارائه‌دهندهٔ webhook به خاطر عدم دریافت تأییدیه رویداد را دوباره ارسال کند، یا حتی یک load balancer درخواستی را که به نظرش time out شده دوباره به جریان بیندازد.

ویژگی Idempotency یعنی اجرای چندین‌بارهٔ یک عملیات، دقیقاً همان اثری را داشته باشد که تنها یک‌بار اجرای آن ایجاد می‌کند. عملیات خواندن (Read) به‌طور ذاتی idempotent هستند؛ خطر واقعی در عملیات‌های تغییردهندهٔ وضعیت (state-changing) نهفته است — به‌ویژه عملیاتی که با پول و تراکنش‌های مالی سر و کار دارند.

روش استاندارد برای پیاده‌سازی این ویژگی، استفاده از یک idempotency key است: کلاینت یک شناسهٔ یکتا را به عملیات پیوست می‌کند و سرور با ثبت کلیدهایی که قبلاً پردازش کرده، از اجرای مجدد درخواست‌های تکراری جلوگیری می‌نماید.

Ruby
تصویر مقاله

این سازوکار در ظاهر ساده است؛ هنر اصلی در پایبندی به اجرای مداوم آن در تمام بخش‌هایی است که وضعیت سیستم ممکن است در صورت retry تغییر کند. نادیده گرفتن این اصل تفاوت میان «نادیده گرفتن امن یک تراکنش تکراری» و «دو بار کسر پول از حساب مشتری» است — اشتباهی مهلک و البته کاملاً قابل پیشگیری. هر عملیات تغییردهندهٔ وضعیتی که امکان تحریک مجدد آن وجود دارد، باید از ابتدا به‌صورت idempotent by design مهندسی شود، نه این‌که پس از بروز اولین باگ تکرار در محیط پروداکشن به فکر وصله‌پینه کردن آن بیفتید.

۲۸. Rate Limiting

قابلیت Rate limiting برای تعداد درخواست‌هایی که یک کلاینت می‌تواند در یک بازهٔ زمانی مشخص (مثلاً ۱۰۰ درخواست در دقیقه) ارسال کند سقف تعیین می‌کند و درخواست‌های فراتر از آن را معمولاً با وضعیت خطای HTTP معادل 429 Too Many Requests رد می‌کند.

هدف از این کار محافظت همه‌جانبه از سیستم است: محافظت از زیرساخت در برابر فشار بیش از حد و جلوگیری از خوابیدن سرویس؛ چه این بار ناشی از کلاینتی باشد که در یک حلقهٔ تکرار (retry loop) گیر کرده، چه یک ربات خودکار و مخرب، چه حملات denial-of-service، و چه کاربری (tenant) که بیش از سهم منصفانهٔ خود منابع اشتراکی را مصرف می‌کند.

تصویر مقاله

الگوریتم‌های متعددی برای پیاده‌سازی این رویکرد وجود دارند که هرکدام به نوعی میان دقت و سادگی تعادل برقرار می‌کنند:

  • Fixed Window — تعداد درخواست‌ها را در یک بازهٔ زمانی ثابت می‌شمارد و در مرز هر بازه صفر می‌شود. این روش ساده است، اما ممکن است در تقاطع دو بازه اجازه دهد ترافیک ناگهانی تا دو برابر حد مجاز عبور کند.
  • Sliding Window — درخواست‌ها را در یک پنجرهٔ متحرک دنبال می‌کند و با وجود پردازش و ذخیره‌سازی داده‌های بیشتر، ترافیک انفجاری در مرزهای بازه را هموار می‌سازد.
  • Token Bucket — توکن‌ها با نرخی ثابت به یک مخزن اضافه می‌شوند؛ هر درخواست یک توکن را مصرف می‌کند و خالی بودن مخزن به معنای رد درخواست است. این مدل اجازهٔ انفجارهای ترافیکی کوتاه‌مدت را تا سقف ظرفیت مخزن می‌دهد و هم‌زمان نرخ میانگین ثابتی را حفظ می‌کند؛ به همین دلیل فوق‌العاده پرکاربرد است.

مفهوم Rate limiting بازتاب‌دهندهٔ یک اصل حیاتی در طراحی سیستم (system design) است: این مکانیزم سیستم شما را سریع‌تر یا قوی‌تر نمی‌کند، اما جلوی این را می‌گیرد که رفتار اشتباه یک عامل — که خیلی اوقات کدهای باگ‌دار خودمان است — بار عادی سیستم را به خاموشی و قطعی کامل (downtime) بکشاند.

چگونه تمام این مفاهیم در کنار هم قرار می‌گیرند؟

تصویر زیر مسیر یک درخواست را از میان اکثر لایه‌های بررسی‌شده دنبال می‌کند؛ از کلاینت در بالاترین سطح تا پایگاه‌های داده در عمیق‌ترین لایه:

تصویر مقاله
یک درخواست ابتدا باید به سرور درست برسد — یعنی از طریق DNS آدرس‌دهی شود، به کمک یک reverse proxy بررسی و ایمن‌سازی گردد، و با یک load balancer میان منابع توزیع شود.
سپس باید به شکلی بهینه پردازش و پاسخ داده شود — در صورت امکان از حافظهٔ cache پاسخ داده شود، با استفاده از indexها سرعت گیرد و فایل‌های ایستا از طریق یک CDN به کاربر تحویل داده شوند.
داده‌های پشت این درخواست باید به شکلی مناسب ذخیره شوند — بسته به الگوی دسترسی به صورت relational یا non-relational سازمان‌دهی شوند، برای مقیاس‌پذیری در خواندن replicate شوند، و تنها در صورتی که حجم داده اقتضا کند، دست به sharding زده شود.
سیستم باید صحت عملکرد خود را در چندین ماشین حفظ کند — یعنی بده‌بستان‌های سازگاری که قضیهٔ CAP theorem به آن تحمیل می‌کند را بپذیرد، عملیات تغییر وضعیت را idempotent کند، و روی بخش‌های بیرونی خود Rate limiting اعمال نماید.
و در جاهایی که تعاملات باید کاملاً زنده و پویا باشند، باید از ارتباطات real-time از طریق پروتکل‌های WebSockets و webhooks پشتیبانی کند.

هر کدام از این مفاهیم، پاسخی متفاوت به یک سؤال همیشگی و کلیدی هستند: وقتی این سیستم بزرگ‌تر شود، دقیقاً کجای کار می‌لنگد و چه بخشی آسیب می‌بیند؟

هیچ‌کدام از این مفاهیم به‌تنهایی سخت یا دست‌نیافتنی نیستند. مهارت واقعی یک مهندس در قدرت تشخیص اوست: این‌که بداند یک مسئله در دنیای واقعی نیازمند کدام مفهوم است و تا زمانی که راه‌حل‌های ساده‌تر جواب می‌دهند، در برابر پیاده‌سازی سازوکارهای پیچیده مقاومت کند. استفاده از Sharding قبل از Indexing، پیاده‌سازی GraphQL بدون نیاز واقعی، یا بازنویسی کامل سیستم مبتنی بر پیام بدون انجام profiling — این‌ها دام‌هایی هستند که فقط پیچیدگی به سیستم تحمیل می‌کنند، بدون این‌که ذره‌ای به مقیاس‌پذیری آن بیفزایند.

اگر به دنبال ارتقای مهارت‌های مهندسی خود در بلندمدت هستید، من در زمینهٔ اصول طراحی نرم‌افزار و معماری سیستم نیز می‌نویسم.

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

✔️ اگر این مقاله برایتان مفید بود، می‌توانید از این طریق یک قهوه مهمانم کنید (Buy Me a Coffee). ✔️ همچنین خوشحال می‌شوم در Linkedin با یکدیگر در ارتباط باشیم. ✔️ با نگه داشتن دکمهٔ 👏 می‌توانید تا ۵۰ بار این مقاله را تشویق (Clap) کنید!