پست

مقیاس‌دهی خواندن در مصاحبه‌های طراحی سیستم

راهنمای مقیاس‌دهی خواندن شامل بهینه‌سازی پایگاه داده، ایندکس و denormalization، read replica، Sharding، لایه‌های کش و CDN و پاسخ به سوال‌های عمیق درباره hot key، cache stampede و cache invalidation

مقیاس‌دهی خواندن در مصاحبه‌های طراحی سیستم

مقیاس‌دهی خواندن (Scaling Reads)

در این فصل می‌بینیم در مصاحبه طراحی سیستم چطور خواندن را مقیاس بدهیم.

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

مسئله

فید اینستاگرام را در نظر بگیرید. وقتی اپلیکیشن را باز می‌کنید، بلافاصله با ده‌ها عکس روبه‌رو می‌شوید. هرکدام چند کوئری پایگاه داده لازم دارند، مثل metadata تصویر، اطلاعات کاربر، تعداد لایک و پیش‌نمایش نظرها. این به‌طور بالقوه بیش از ۱۰۰ عملیات خواندن فقط برای بارگذاری فید شماست. در همین حال ممکن است خودتان روزی فقط یک عکس پست کنید، یعنی یک عملیات نوشتن.

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

با زیاد شدن خواندن‌ها، پایگاه داده شما زیر بار به مشکل می‌خورد. بیشتر اوقات این مشکل نرم‌افزاری نیست که بتوان از آن فرار کرد، بلکه مشکل فیزیک است. هسته‌های CPU فقط تعداد مشخصی دستور در ثانیه اجرا می‌کنند، حافظه فقط مقدار مشخصی داده نگه می‌دارد و I/O دیسک به سرعت چرخش پلاتر یا چرخه‌های نوشتن SSD محدود است. وقتی به این محدودیت‌های فیزیکی می‌رسید، کد بیشتر مشکل را حل نمی‌کند.

پس راه‌حل چیست؟ تجزیه‌اش می‌کنیم.

راه‌حل

مقیاس‌دهی خواندن یک مسیر طبیعی از بهینه‌سازی ساده تا سیستم‌های توزیع‌شده پیچیده را دنبال می‌کند.

  1. کارایی خواندن را در پایگاه داده خودتان بهینه کنید.
  2. پایگاه داده را افقی مقیاس بدهید.
  3. لایه‌های کش خارجی اضافه کنید.

نحوه کار هرکدام را می‌بینیم.

بهینه‌سازی در پایگاه داده

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

ایندکس‌گذاری

ایندکس در اصل جدول جستجوی مرتب‌شده‌ای است که به سطرهای داده واقعی شما اشاره می‌کند. مثل فهرست پشت کتاب است. به‌جای اسکن همه صفحه‌ها برای پیدا کردن کلمه «پایگاه داده»، فهرست پشت کتاب را نگاه می‌کنید که دقیقاً می‌گوید کدام صفحه‌ها را ببینید.

وقتی بدون ایندکس کوئری می‌زنید، پایگاه داده کاری می‌کند که اسمش full table scan است. هر سطر را می‌خواند تا موارد منطبق را پیدا کند. با ایندکس می‌تواند مستقیم به سطرهای مربوط بپرد. این کار عملیات خطی O(n) را به عملیات لگاریتمی O(log n) تبدیل می‌کند، یعنی تفاوت بین اسکن ۱ میلیون سطر و بررسی شاید ۲۰ ورودی ایندکس.

انواع مختلفی از ایندکس هست و B-Tree رایج‌ترین آن برای کوئری‌های همه‌منظوره است. ایندکس هش برای تطبیق دقیق خوب کار می‌کند و ایندکس‌های تخصصی جستجوی متن کامل یا جغرافیایی را انجام می‌دهند. درباره انواع ایندکس و زمان استفاده از هرکدام در بررسی عمیق ایندکس‌گذاری پایگاه داده توضیح داده‌ایم.

اولین کاری که باید هنگام مواجهه با مسئله مقیاس‌دهی خواندن در نظر بگیرید این است که برای ستون‌هایی که مرتب کوئری، Join یا مرتب‌سازی می‌شوند ایندکس اضافه کنید. مثلاً اگر اپلیکیشن شبکه اجتماعی طراحی می‌کنید و کاربران اغلب پست‌ها را با هشتگ جستجو می‌کنند، ستون هشتگ را ایندکس کنید. اگر محصولات را بر اساس قیمت مرتب می‌کنید، ستون قیمت را ایندکس کنید.

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

ارتقای سخت‌افزار

زیاد اینجا نمی‌مانیم، اما ارزش اشاره دارد. گاهی جواب فقط سخت‌افزار بهتر (یا بزرگ‌تر) است. SSDها I/O تصادفی را ۱۰ تا ۱۰۰ برابر سریع‌تر از دیسک‌های چرخان ارائه می‌کنند. RAM بیشتر یعنی داده بیشتری در حافظه می‌ماند و از خواندن کامل دیسک جلوگیری می‌شود. CPU سریع‌تر و هسته بیشتر درخواست‌های هم‌زمان بیشتری را مدیریت می‌کند.

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

استراتژی‌های Denormalization

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

پایگاه داده معمول فروشگاه آنلاین را در نظر بگیرید. در طراحی normalize شده ممکن است جدول‌های جدا برای کاربران، سفارش‌ها و محصولات داشته باشید. برای نمایش خلاصه سفارش باید هر سه جدول را Join کنید:

1
2
3
4
5
6
SELECT u.name, o.order_date, p.product_name, p.price
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.id = 12345;

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

برای سیستم‌های خواندن‌سنگین، denormalization (برعکس normalization، یعنی ذخیره داده اضافی) فضای ذخیره‌سازی را با سرعت معاوضه می‌کند. به‌جای Join کردن سه جدول برای گرفتن اطلاعات پروفایل کاربر، داده را به‌صورت تکراری در یک جدول ذخیره کنید.

در نسخه denormalize شده ممکن است جدولی به نام order_summary بسازیم که نام کاربر، نام محصول و قیمت را کنار داده‌های سفارش تکرار می‌کند. حالا گرفتن خلاصه سفارش به یک کوئری ساده تک‌جدولی تبدیل می‌شود:

1
2
3
SELECT user_name, order_date, product_name, price
FROM order_summary
WHERE order_id = 12345;

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

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

تنش بین خواندن و نوشتن: denormalization نمونه کلاسیک بهینه‌سازی برای خواندن به قیمت نوشتن است. ذخیره داده تکراری خواندن را سریع‌تر می‌کند اما نوشتن را پیچیده‌تر، چون باید چند جا را به‌روز کنید. قبل از denormalize کردن همیشه نسبت خواندن به نوشتن را در نظر بگیرید. اگر نوشتن زیاد است، ممکن است پیچیدگی‌اش ارزش نداشته باشد.

Materialized View این رویکرد را با پیش‌محاسبه aggregateهای گران جلوتر می‌برد. به‌جای محاسبه میانگین امتیاز محصول در هر بارگذاری صفحه، آن را یک بار (با یک کار پس‌زمینه) حساب کنید و نتیجه را ذخیره کنید. این برای کوئری‌های تحلیلی که محاسبات پیچیده روی مجموعه داده بزرگ دارند به‌خصوص قدرتمند است.

1
2
3
4
5
6
7
8
9
10
11
12
-- Instead of this expensive query on every page load:
SELECT p.*, AVG(r.rating) as avg_rating
FROM products p
JOIN reviews r ON p.id = r.product_id
GROUP BY p.id;

-- Precompute and store the average:
CREATE MATERIALIZED VIEW product_ratings AS
SELECT p.*, AVG(r.rating) as avg_rating
FROM products p
JOIN reviews r ON p.id = r.product_id
GROUP BY p.id;

مقیاس افقی پایگاه داده

وقتی یک سرور پایگاه داده به حدش رسید، سرور بیشتری اضافه کنید. اینجا همه‌چیز هم جالب و هم پیچیده می‌شود. جزئیات را در بررسی عمیق «Numbers to Know» می‌گوییم، اما قاعده سرانگشتی این است که وقتی از ۵۰ هزار تا ۱۰۰ هزار درخواست خواندن در ثانیه گذشتید (با فرض این‌که ایندکس مناسب دارید)، پایگاه داده شما باید افقی مقیاس بگیرد (یا کش اضافه کنید، که بعداً درباره‌اش می‌گوییم).

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

Read Replica

اولین رویکرد برای مقیاس دادن بیش از یک پایگاه داده، اضافه کردن read replica است. داده را از پایگاه داده اصلی به سرورهای اضافه کپی می‌کنید. همه نوشتن‌ها به primary می‌روند، اما خواندن‌ها می‌توانند به هر replica بروند. این کار بار خواندن را بین چند سرور پخش می‌کند.

read replicaها علاوه بر حل مشکل throughput، به‌عنوان مزیت اضافه افزونگی (redundancy) هم می‌دهند. اگر primary شما خراب شود، می‌توانید یک replica را primary جدید کنید و زمان از کار افتادگی را حداقل کنید.

Leader-Follower Replication رویکرد استاندارد است. یک گره primary (leader) می‌نویسد و چند گره ثانویه (follower) می‌خوانند. replication می‌تواند هم‌زمان (کندتر اما سازگار) یا ناهمزمان (سریع‌تر اما احتمالاً کهنه) باشد.

چالش کلیدی replication lag است. وقتی روی primary می‌نویسید، انتشار به replicaها زمان می‌برد. اگر کاربران از replicaای که عقب است بخوانند، ممکن است تغییراتشان را فوراً نبینند.

replication lag سوال رایج مصاحبه است. باید مصالحه بین replication هم‌زمان و ناهمزمان را بفهمید. replication هم‌زمان سازگاری داده را تضمین می‌کند اما تأخیر اضافه می‌کند. replication ناهمزمان سریع‌تر است اما ناسازگاری احتمالی داده می‌آورد.

Sharding پایگاه داده

read replicaها بار را پخش می‌کنند اما اندازه مجموعه داده‌ای را که هر پایگاه داده باید مدیریت کند کم نمی‌کنند. اگر مجموعه داده آن‌قدر بزرگ شود که حتی کوئری‌های خوب ایندکس‌شده هم کند باشند، Sharding می‌تواند با تقسیم داده بین چند پایگاه داده کمک کند.

برای مقیاس‌دهی خواندن، Sharding از دو راه کمک می‌کند: مجموعه داده کوچک‌تر یعنی درخواست‌های تکی سریع‌تر، و می‌توانید بار خواندن را بین چند پایگاه داده پخش کنید.

Functional Sharding داده را بر اساس دامنه یا قابلیت کسب‌وکار تقسیم می‌کند، نه بر اساس رکورد. داده کاربران را در یک پایگاه داده بگذارید و داده محصولات را در پایگاه داده دیگر. حالا پروفایل کاربر فقط از پایگاه داده کوچک‌تر کاربران کوئری می‌گیرد و جستجوی محصول فقط به پایگاه داده محصولات می‌رسد.

Geographic Sharding برای مقیاس‌دهی خواندن در سطح جهانی به‌خصوص مؤثر است. داده کاربران آمریکا را در پایگاه‌های داده آمریکا و داده اروپا را در پایگاه‌های داده اروپا ذخیره کنید. کاربران از سرورهای نزدیک سریع‌تر می‌خوانند و هم‌زمان بار هر پایگاه داده کم می‌شود.

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

اضافه کردن لایه‌های کش خارجی

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

نکته مهم این است که بیشتر اپلیکیشن‌ها الگوهای دسترسی بسیار کج (skewed) دارند. در توییتر میلیون‌ها نفر همان توییت‌های وایرال را می‌خوانند. در سایت‌های تجارت الکترونیک هزاران نفر همان محصولات محبوب را می‌بینند. یعنی مرتب از پایگاه داده همان داده‌ها را می‌پرسید، داده‌هایی که بین درخواست‌ها به‌ندرت تغییر می‌کنند.

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

کش در سطح اپلیکیشن

کش‌های درون‌حافظه‌ای مثل Redis یا Memcached بین اپلیکیشن و پایگاه داده قرار می‌گیرند. وقتی اپلیکیشن به داده نیاز دارد، اول کش را چک می‌کند. در صورت hit، زمان پاسخ زیر میلی‌ثانیه می‌گیرید. در صورت miss، از پایگاه داده کوئری می‌گیرید و کش را برای درخواست‌های بعدی پر می‌کنید.

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

Cache invalidation همچنان چالش اصلی است. وقتی داده تغییر می‌کند، باید مطمئن شوید کش‌ها داده کهنه ارائه نمی‌دهند. استراتژی‌های رایج این‌هاست.

  • انقضای مبتنی بر زمان (TTL): طول عمر ثابتی برای ورودی‌های کش تعیین کنید. پیاده‌سازی ساده است اما یعنی تا زمان انقضا ممکن است داده کهنه ارائه شود. برای داده‌ای با الگوی به‌روزرسانی قابل‌پیش‌بینی خوب کار می‌کند.
  • Write-Through Invalidation: هنگام نوشتن در پایگاه داده بلافاصله ورودی کش را به‌روز یا حذف کنید. سازگاری را تضمین می‌کند، اما به عملیات نوشتن تأخیر اضافه می‌کند و به مدیریت دقیق خطا نیاز دارد.
  • Write-Behind Invalidation: رویدادهای invalidation را در صف بگذارید تا ناهمزمان پردازش شوند. تأخیر نوشتن را کم می‌کند اما پنجره‌ای می‌سازد که در آن ممکن است داده کهنه ارائه شود.
  • Tagged Invalidation: ورودی‌های کش را با تگ مرتبط کنید (مثل user:123:posts). وقتی داده مرتبط تغییر می‌کند، همه ورودی‌هایی را که تگ مشخصی دارند باطل کنید. برای وابستگی‌های پیچیده قدرتمند است اما به نگه داشتن روابط تگ‌ها نیاز دارد.
  • Versioned Keys: شماره نسخه را در کلید کش بگذارید. با هر به‌روزرسانی نسخه را زیاد کنید تا ورودی‌های کهنه کش به‌طور طبیعی باطل شوند. ساده و قابل‌اعتماد است اما به ردیابی نسخه نیاز دارد.

بیشتر سیستم‌های production این رویکردها را ترکیب می‌کنند. از TTL کوتاه (۵ تا ۱۵ دقیقه) به‌عنوان شبکه ایمنی استفاده می‌کنند و برای داده‌های مهم مثل پروفایل کاربر یا تعداد موجودی، invalidation فعال دارند. داده‌های کم‌اهمیت‌تر مثل امتیاز توصیه‌ها ممکن است فقط به انقضای TTL تکیه کنند.

در حالت ایده‌آل، TTL کش شما باید از الزامات غیرعملکردی (Non-Functional Requirements) درباره تازگی داده می‌آید. اگر الزامات می‌گویند «نتایج جستجو نباید بیش از ۳۰ ثانیه کهنه باشند»، همین TTL دقیق شما را می‌دهد. برای پروفایل کاربران که ۵ دقیقه کهنگی را تحمل می‌کنند، TTL پنج‌دقیقه‌ای بگذارید. این کار تضمین‌های سازگاری شما را محدود می‌کند و تصمیم‌های استراتژی کش را ساده می‌کند.

CDN و Edge Caching

شبکه‌های توزیع محتوا کش را فراتر از دیتاسنتر شما به edge locationهای جهانی گسترش می‌دهند. با این‌که ابتدا برای دارایی‌های ایستا طراحی شده بودند، CDNهای مدرن محتوای پویا، از جمله پاسخ API و نتایج کوئری پایگاه داده، را هم کش می‌کنند.

توزیع جغرافیایی بهبود چشمگیری در تأخیر می‌دهد. کاربری در توکیو به‌جای رفت‌وبرگشت به دیتاسنتر ویرجینیا، داده کش‌شده را از سرور edge توکیو می‌گیرد. این کار زمان پاسخ را از ۲۰۰ میلی‌ثانیه به کمتر از ۱۰ میلی‌ثانیه می‌رساند و بار سرورهای origin را برای درخواست‌های کش‌شده حذف می‌کند.

برای اپلیکیشن‌های خواندن‌سنگین، کش CDN می‌تواند بار origin را ۹۰٪ یا بیشتر کم کند. صفحه محصول، پروفایل کاربران، نتایج جستجو و هر چیزی که چند کاربر درخواست می‌کنند کاندیدای کش در edge است. مصالحه، مدیریت cache invalidation در edge locationهای زیاد است. با این حال معمولاً مزیت کارایی کار اضافه را توجیه می‌کند.

CDN فقط برای داده‌ای که چند کاربر به آن دسترسی دارند معنا دارد. داده مخصوص کاربر مثل تنظیمات شخصی، پیام‌های خصوصی یا تنظیمات حساب را کش نکنید. چنین داده‌هایی هیچ مزیت cache hit ندارند، چون فقط یک کاربر آن‌ها را می‌خواهد. کش CDN را روی محتوایی متمرکز کنید که الگوی اشتراک طبیعی دارد، مثل پست‌های عمومی، کاتالوگ محصول یا نتایج جستجو.

زمان استفاده در مصاحبه

تقریباً هر مصاحبه طراحی سیستم با بحث مقیاس‌دهی پایان می‌یابد، و مقیاس‌دهی خواندن معمولاً جایی است که برای اپلیکیشن‌های خواندن‌سنگین شروع می‌کنید. توصیه من: به هر کدام از درخواست‌های API خارجی‌تان نگاه کنید و برای آن‌هایی که حجم بالا دارند، راه بهینه کردن‌شان را پیدا کنید. از بهینه‌سازی کوئری شروع کنید و بعد به سراغ کش و read replica بروید.

کاندیدای قوی گلوگاه‌های خواندن را فعالانه شناسایی می‌کند. وقتی API خود را ترسیم می‌کنید، روی endpointهایی که زیر فشارند مکث کنید و چیزی شبیه این بگویید: «این endpoint پروفایل کاربر با هر بار دیدن یک پروفایل فراخوانی می‌شود. با میلیون‌ها کاربر، این به‌طور بالقوه میلیاردها خواندن در روز است. اجازه بدهید در بررسی عمیق به آن بپردازم.»

سناریوهای معمول مصاحبه

  • Bitly و URL Shortener: نسبت خواندن به نوشتن اینجا بسیار زیاد است. یک URL ممکن است یک بار کوتاه شود اما میلیون‌ها بار به آن دسترسی شود. این سناریوی رؤیایی کش است. نگاشت URL کوتاه به URL بلند را به‌صورت تهاجمی در Redis و بدون انقضا کش کنید (URLها تغییر نمی‌کنند). برای ترافیک جهانی از کش CDN استفاده کنید. پایگاه داده واقعی فقط برای cache miss روی لینک‌های ناشناخته فشار می‌بیند.
  • Ticketmaster: وقتی بلیت‌ها شروع به فروش می‌شوند، صفحه رویداد کاملاً کوبیده می‌شود. هزاران کاربر صفحه رویداد را مدام refresh می‌کنند و منتظر موجود شدن هستند. جزئیات رویداد، اطلاعات سالن و نقشه صندلی‌ها را تهاجمی کش کنید. اما مراقب باشید: موجودی واقعی صندلی را نمی‌توان کش کرد وگرنه بیش از موجودی می‌فروشید. برای مرور از read replica استفاده کنید و در عین حال برای خریدهای واقعی از primary بنویسید.
  • سیستم‌های News Feed: چه فیس‌بوک، چه لینکدین و چه توییتر، تولید فید خواندن‌سنگین است. فید کاربران فعال را از قبل محاسبه کنید، پست‌های اخیر کسانی را که دنبال می‌کنند کش کنید و برای پرهیز از بارگذاری کل فیدها یک‌جا از صفحه‌بندی هوشمند استفاده کنید. بینش کلیدی: کاربران معمولاً فقط چند مورد اول را می‌خوانند، پس کش تهاجمی محتوای اخیر نتیجه زیادی دارد.
  • YouTube و پلتفرم‌های ویدیو: metadata ویدیو بار خواندن شگفت‌انگیزی می‌سازد. هر صفحه خانه کوئری‌هایی برای ویدیوهای پیشنهادی، اطلاعات کانال و تعداد بازدید دارد. metadata ویدیو را تهاجمی کش کنید، چون عنوان، توضیحات و تصویر بندانگشتی (thumbnail) به‌ندرت عوض می‌شود. تعداد بازدید می‌تواند در نهایت سازگار باشد و هر چند دقیقه به‌روز شود، نه با هر بازدید. از CDN برای thumbnailها به‌طور گسترده استفاده کنید.

چه زمانی استفاده نکنیم

  • سیستم‌های نوشتن‌سنگین: اگر ردیابی موقعیت Uber را طراحی می‌کنید که رانندگان هر چند ثانیه موقعیت را به‌روز می‌کنند، اول روی مقیاس‌دهی نوشتن تمرکز کنید. نسبت خواندن به نوشتن ممکن است فقط ۲ به ۱ یا حتی ۱ به ۱ باشد.
  • اپلیکیشن‌های کوچک‌مقیاس: اگر مصاحبه‌گر «طراحی برای ۱۰۰۰ کاربر» را مشخص کرده، سراغ استراتژی‌های کش نروید. یک پایگاه داده تکی که خوب ایندکس شده هزاران QPS را به‌راحتی مدیریت می‌کند. قضاوتتان را با حل مسئله واقعی نشان دهید، نه مسئله خیالی.
  • سیستم‌های کاملاً سازگار: تراکنش‌های مالی، مدیریت موجودی یا هر سیستمی که داده کهنه مشکل واقعی می‌سازد نیاز به بررسی دقیق دارد. ممکن است باز هم از این الگوها استفاده کنید، اما با invalidation شدیدتر و TTL کوتاه‌تر.
  • سیستم‌های همکاری هم‌زمان: Google Docs و اپلیکیشن‌های مشابه بیشتر از مقیاس‌دهی خواندن، به به‌روزرسانی بلادرنگ نیاز دارند. وقتی هر ضربه کلید باید فوراً برای کاربران دیگر دیده شود، کش فعالانه ضرر می‌زند.

یادتان باشد مقیاس‌دهی خواندن درباره کم کردن بار پایگاه داده است، نه فقط سریع‌تر کردن کارها. اگر پایگاه داده شما بار را خوب مدیریت می‌کند اما به تأخیر کمتری نیاز دارید، این مسئله متفاوتی است با راه‌حل‌های متفاوت (مثل edge compute یا بهینه‌سازی service mesh).

بررسی‌های عمیق رایج

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

«وقتی مجموعه داده بزرگ می‌شود و کوئری‌ها کند می‌شوند چه می‌کنید؟»

اپلیکیشن شما با ۱۰ هزار کاربر راه افتاد و کوئری‌ها سریع بودند. حالا ۱۰ میلیون کاربر دارید و یک جستجوی ساده ۳۰ ثانیه طول می‌کشد. CPU پایگاه داده روی ۱۰۰٪ است، با این‌که کار پیچیده‌ای نمی‌کنید. فقط کوئری‌های پایه مثل پیدا کردن کاربر با ایمیل یا گرفتن تاریخچه سفارش‌ها را اجرا می‌کنید.

مسئله این است که بدون ایندکس درست، پایگاه داده در هر کوئری full table scan انجام می‌دهد. وقتی کسی با ایمیلش وارد می‌شود، پایگاه داده هر ۱۰ میلیون رکورد کاربر را می‌خواند تا تطبیق را پیدا کند. با ۲۰۰ بایت برای هر سطر، یعنی ۲ گیگابایت داده برای اسکن فقط برای پیدا کردن یک کاربر. اگر این را در صدها ورود هم‌زمان ضرب کنید، پایگاه داده شما همه وقتش را صرف خواندن دیسک می‌کند.

مشکل با Joinها بدتر می‌شود. گرفتن سفارش‌های کاربر بدون ایندکس یعنی اسکن کل جدول کاربران و بعد اسکن کل جدول سفارش‌ها برای تطبیق. این بالقوه میلیاردها مقایسه سطر برای کاری است که باید یک جستجوی ساده باشد.

خوشبختانه راه‌حل بسیار ساده است. فقط روی ستون‌هایی که مرتب کوئری می‌شوند ایندکس اضافه کنید. ایندکس روی ستون ایمیل آن ۱۰ میلیون سطر را به چند جستجوی ایندکس تبدیل می‌کند:

1
2
3
4
5
6
7
8
9
10
-- Before: Full table scan
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';
-- Seq Scan on users (cost=0.00..412,000.00 rows=1)

-- Add index
CREATE INDEX idx_users_email ON users(email);

-- After: Index scan
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';
-- Index Scan using idx_users_email (cost=0.43..8.45 rows=1)

برای کوئری‌های ترکیبی، ترتیب ستون‌ها در ایندکس مهم است. اگر کاربران بر اساس status و created_at جستجو کنند، ایندکس روی (status, created_at) هم به کوئری‌هایی که فقط روی status فیلتر می‌کنند کمک می‌کند و هم به کوئری‌هایی که روی هر دو فیلتر می‌کنند. اما به کوئری‌هایی که فقط روی created_at فیلتر می‌کنند کمکی نمی‌کند.

نکته حرفه‌ای: وقتی schema پایگاه داده را در مصاحبه ترسیم می‌کنید، همان لحظه اشاره کنید روی کدام ستون‌ها ایندکس می‌گذارید. این نشان می‌دهد به آینده فکر می‌کنید.

«چطور میلیون‌ها خواندن هم‌زمان را برای یک داده کش‌شده یکسان مدیریت می‌کنید؟»

یک سلبریتی در پلتفرم شما پست می‌گذارد و ناگهان میلیون‌ها کاربر هم‌زمان سعی می‌کنند آن را بخوانند. سرورهای کش شما که معمولاً ۵۰ هزار درخواست در ثانیه مدیریت می‌کنند، حالا ۵۰۰ هزار درخواست در ثانیه برای همان یک کلید می‌گیرند. کش شروع به timeout می‌کند، درخواست‌ها شکست می‌خورند و سایت شما از کار می‌افتد، حتی اگر فقط ترافیک خواندن باشد.

مشکل این است که کش سنتی فرض می‌کند بار روی کلیدهای زیادی پخش شده است. وقتی همه یک کلید را می‌خواهند، این فرض می‌شکند. سرور کش شما ظرفیت CPU و شبکه محدودی دارد. حتی با این‌که داده در حافظه است، serialize کردن و فرستادن آن روی شبکه ۵۰۰ هزار بار در ثانیه هر سروری را از پا درمی‌آورد.

اولین راه‌حل request coalescing است، یعنی ترکیب چند درخواست برای یک کلید در یک درخواست واحد. این کار وقتی کمک می‌کند که بک‌اند شما نتواند بار همه درخواست‌های یکسان را هم‌زمان تحمل کند.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Request coalescing pattern
class CoalescingCache:
    def __init__(self):
        self.inflight = {}  # key -> Future

    async def get(self, key):
        # Check if another request is already fetching this key
        if key in self.inflight:
            return await self.inflight[key]

        # No inflight request, we'll fetch it
        future = asyncio.Future()
        self.inflight[key] = future

        try:
            value = await fetch_from_backend(key)
            future.set_result(value)
            return value
        finally:
            del self.inflight[key]

نکته مهم درباره request coalescing این است که بار بک‌اند را از بالقوه بی‌نهایت (هر کاربری که همان درخواست را می‌دهد) به دقیقاً N کم می‌کند، که N تعداد سرورهای اپلیکیشن است. حتی اگر میلیون‌ها کاربر هم‌زمان همان داده را بخواهند، بک‌اند شما فقط N درخواست می‌گیرد، یعنی یک درخواست برای هر سروری که coalescing انجام می‌دهد.

وقتی coalescing برای بارهای شدید کافی نیست، باید خود بار را توزیع کنید. Cache key fanout کلید داغ را بین چند ورودی کش پخش می‌کند. به‌جای ذخیره پست سلبریتی زیر یک کلید، نسخه‌های یکسان را زیر ده کلید مختلف ذخیره می‌کنید. کلاینت‌ها تصادفی یکی را انتخاب می‌کنند و ۵۰۰ هزار درخواست در ثانیه بین ده کلید پخش می‌شود، یعنی ۵۰ هزار درخواست برای هر کدام، باری که سرورهای کش شما واقعاً می‌توانند تحمل کنند. این کار را می‌توان با افزودن یک عدد تصادفی به کلید انجام داد، مثل feed:taylor-swift:1 و feed:taylor-swift:2 و مانند آن.

مصالحه fanout مصرف حافظه و سازگاری کش است. یک داده را چند بار ذخیره می‌کنید و چون باید همه نسخه‌ها را پاک کنید، invalidation پیچیده‌تر می‌شود. اما برای سناریوهای خواندن‌سنگین که مشکل hot key دسترس‌پذیری را تهدید می‌کند، این افزونگی هزینه کمی برای آنلاین ماندن است.

«وقتی چند درخواست هم‌زمان می‌خواهند یک ورودی منقضی‌شده کش را بازسازی کنند چه می‌شود؟»

داده صفحه اصلی شما ۱۰۰ هزار درخواست در ثانیه دریافت می‌کند که همه از کش جواب داده می‌شوند. ورودی کش TTL یک‌ساعته دارد. وقتی آن ساعت می‌گذرد و ورودی منقضی می‌شود، همه ۱۰۰ هزار درخواست ناگهان در یک لحظه cache miss می‌بینند. هرکدام سعی می‌کند از پایگاه داده بگیرد.

پایگاه داده شما که ممکن است در cache missهای معمولی ۱۰۰۰ کوئری در ثانیه مدیریت کند، ناگهان ۱۰۰ هزار کوئری یکسان می‌گیرد. مثل حمله DDoS از طرف خود اپلیکیشن شماست. پایگاه داده تا حد خزیدن کند می‌شود یا کاملاً از کار می‌افتد و سایت شما را با خودش پایین می‌کشد.

این cache stampede (یا thundering herd) به این دلیل رخ می‌دهد که انقضای کش دودویی است: یک لحظه داده هست و لحظه بعد نیست. محتوای محبوب‌تر استمپید بزرگ‌تری می‌سازد. وقتی بازسازی گران باشد، مشکل چند برابر می‌شود. اگر برای ساختن صفحه اصلی باید ۱۰ جدول را Join کنید یا API خارجی فراخوانی کنید، تازه ۱۰۰ هزار عملیات گران را به‌صورت موازی راه انداخته‌اید.

یک رویکرد از قفل توزیع‌شده برای سریال کردن بازسازی‌ها استفاده می‌کند. فقط اولین درخواستی که cache miss را می‌بیند اجازه بازسازی دارد و بقیه منتظر می‌مانند تا کامل شود. این بک‌اند را محافظت می‌کند، اما معایب جدی دارد. اگر بازسازی شکست بخورد یا زیاد طول بکشد، هزاران درخواست معلق می‌مانند. باید timeout و منطق fallback پیچیده مدیریت کنید که این رویکرد را زیر بار شکننده می‌کند.

رویکرد هوشمندانه‌تر از probabilistic early refresh استفاده می‌کند، یعنی داده کهنه را ارائه کنید و هم‌زمان در پس‌زمینه تازه‌اش کنید. این ورودی‌های کش را قبل از انقضا تازه می‌کند، اما نه همه را یک‌جا. وقتی ورودی کش تازه است (تازه ساخته شده)، درخواست‌ها فقط از آن استفاده می‌کنند. اما هرچه ورودی مسن‌تر می‌شود، هر درخواست شانس کوچکی دارد که تازه‌سازی پس‌زمینه را راه بیندازد. اگر ورودی شما در ۶۰ دقیقه منقضی شود، درخواستی در دقیقه ۵۰ ممکن است ۱٪ شانس تازه کردن آن را داشته باشد. در دقیقه ۵۵ شانس به ۵٪ می‌رسد. در دقیقه ۵۹ شاید ۲۰٪. به‌جای این‌که ۱۰۰ هزار درخواست در دقیقه ۶۰ پایگاه داده شما را بکوبند، این تازه‌سازی‌ها در ۱۰ تا ۱۵ دقیقه آخر پخش می‌شوند. بیشتر کاربران هنوز داده کش‌شده می‌گیرند و فقط چند نفر بدشانس تازه‌سازی را راه می‌اندازند.

برای حیاتی‌ترین داده‌های کش شما، شاید هیچ‌کدام از این روش‌ها قابل‌قبول نباشد. نمی‌توانید خطر stampede را بپذیرید یا منتظر تازه‌سازی احتمالاتی بمانید. فرایندهای تازه‌سازی پس‌زمینه این ورودی‌ها را قبل از انقضا مدام به‌روز می‌کنند و تضمین می‌کنند هرگز کهنه نمی‌شوند. کش صفحه اصلی شما مثلاً هر ۵۰ دقیقه یک بار تازه می‌شود و تضمین می‌کند کاربران هیچ‌وقت بازسازی را شروع نمی‌کنند. هزینه آن پیچیدگی زیرساخت و هدر رفتن تازه‌سازی داده‌ای است که ممکن است هیچ‌وقت درخواست نشود. اما برای محبوب‌ترین محتوای شما، این بیمه ارزشمند است.

«وقتی به‌روزرسانی داده باید فوراً دیده شود، cache invalidation را چطور مدیریت می‌کنید؟»

سازمان‌دهنده رویدادی آدرس سالن را به‌روز می‌کند، اما شرکت‌کنندگان تا یک ساعت بعد، تا انقضای کش، همچنان مکان قدیمی را می‌بینند. برای بعضی داده‌ها سازگاری نهایی قابل‌قبول نیست. کاربران باید تغییرات را فوراً ببینند، اما داده کش‌شده شما در چند جا وجود دارد: کش اپلیکیشن، edgeهای CDN و حتی کش مرورگر.

رویکرد ساده‌لوحانه این است که ورودی‌های کش را هنگام به‌روزرسانی حذف کنیم. ساده به نظر می‌رسد اما پیچیدگی معناداری دارد. کدام کش‌ها این داده را دارند؟ اگر کسی را فراموش کنید چه؟ با race condition چه می‌کنید، وقتی کسی میکروثانیه بعد از invalidation شما، داده کهنه را دوباره کش می‌کند؟

Cache versioning این مشکل‌ها را کاملاً دور می‌زند، اما به دو بار خواندن از کش برای هر درخواست نیاز دارد. به‌جای باطل کردن کش، هنگام به‌روزرسانی داده کلید کش را عوض می‌کنید:

  1. شماره نسخه را از کش بخوانید (یا اگر در کش نبود، از پایگاه داده).
  2. با آن شماره نسخه، کلید نسخه‌دار را بسازید.
  3. داده واقعی را با کلید نسخه‌دار از کش بخوانید.
  4. در صورت cache miss، از پایگاه داده بگیرید و با کلید نسخه‌دار کش کنید.

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

وقتی نسخه‌گذاری عملی نیست (مثلاً نتایج جستجو یا داده aggregate را کش می‌کنید)، به استراتژی‌های invalidation صریح نیاز دارید. Write-through invalidation بلافاصله بعد از به‌روزرسانی پایگاه داده، کش را به‌روز می‌کند و به قیمت تأخیر نوشتن سازگاری را تضمین می‌کند.

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

برای سیستم‌های جهانی با کش CDN، invalidation حتی پیچیده‌تر می‌شود. دیگر فقط یک کش را پاک نمی‌کنید، بلکه صدها edge location در سراسر دنیا را پاک می‌کنید. API مربوط به CDN کمک می‌کند، اما انتشار زمان می‌برد. به‌روزرسانی‌های حیاتی ممکن است از هدرهای کش برای کنار گذاشتن کش CDN استفاده کنند و کارایی را با سازگاری معاوضه کنند. داده‌های کم‌اهمیت‌تر می‌توانند TTL کوتاه‌تری در edge داشته باشند و TTL بلندتری در کش اپلیکیشن شما.

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

جمع‌بندی

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

مسیر موفقیت یک روند روشن را دنبال می‌کند: اول در پایگاه داده با ایندکس و denormalization درست بهینه‌سازی کنید، بعد با read replica افقی مقیاس بدهید و در نهایت برای بهترین کارایی لایه‌های کش اضافه کنید. بیشتر کاندیداها بدون استفاده از راه‌حل‌های ساده‌تر مستقیم سراغ کش توزیع‌شده پیچیده می‌روند. با آنچه دارید شروع کنید. پایگاه‌های داده مدرن، اگر درست ایندکس و پیکربندی شده باشند، می‌توانند بار خیلی بیشتری از آنچه بیشتر مهندسان تصور می‌کنند تحمل کنند.

در مصاحبه نشان دهید هم مزایای کارایی و هم پیچیدگی عملیاتی هر رویکرد را می‌فهمید. نشان دهید می‌دانید کی برای محتوایی که به‌ندرت تغییر می‌کند کش تهاجمی استفاده کنید و کی برای داده‌ای که باید تازه بماند به read replica تکیه کنید.