پست

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

چهار استراتژی مقیاس‌دهی نوشتن شامل مقیاس عمودی و انتخاب پایگاه داده، Sharding و پارتیشن‌بندی، صف و Load Shedding، و Batching و تجمیع سلسله‌مراتبی همراه با بررسی hot key و resharding

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

مقیاس‌دهی نوشتن (Scaling Writes)

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

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

چالش

خیلی از مسائل طراحی سیستم با الزامات مقیاس معمولی شروع می‌شوند، تا این‌که مصاحبه‌گر چالش را مطرح می‌کند: «این چطور مقیاس می‌گیرد؟». شاید برای سمت خواندن معادله ابزارهایی بشناسید (مثل read replica و کش)، اما سمت نوشتن اغلب چالش خیلی بزرگ‌تری است.

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

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

راه‌حل

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

  1. مقیاس عمودی و انتخاب‌های پایگاه داده.
  2. Sharding و پارتیشن‌بندی.
  3. مدیریت موج ناگهانی (burst) با صف و Load Shedding.
  4. Batching و تجمیع سلسله‌مراتبی.

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

مقیاس عمودی و بهینه‌سازی نوشتن

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

مقیاس عمودی

با سخت‌افزار یا همان «مقیاس عمودی» شروع می‌کنیم. نوشتن‌ها با I/O دیسک، CPU یا پهنای باند شبکه به گلوگاه می‌خورند. قبل از ادامه باید تأیید کنیم که واقعاً به این دیوارها می‌خوریم. اغلب یعنی باید کمی محاسبه پشت پاکت (back-of-the-envelope) انجام دهیم تا هم ببینیم (الف) throughput نوشتن ما واقعاً چقدر است و (ب) آیا با توانایی سخت‌افزار ما جور است یا نه.

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

احتمالاً سخت‌افزار بیشتر از چیزی که فکر می‌کنید جلو می‌رود! سیستم‌هایی با ۲۰۰ هسته CPU و کارت شبکه ۱۰ گیگابیتی غیرعادی نیستند. ارزش دارد چند عدد از «اعدادی که باید بدانید» را در ذهن داشته باشید تا مطمئن شوید پتانسیل زیادی را از دست نمی‌دهید.

می‌توانید این موضوع را مطرح کنید (و بیشتر مصاحبه‌گرها انتظار دارند یا قدردانی می‌کنند) که بعضی چالش‌های شما با سخت‌افزار مدرن حل شده است. این را اغلب از کاندیداهای سطح Staff به بالا می‌بینیم که با لبه‌های منحنی کارایی آشنایی مستقیم دارند.

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

انتخاب‌های پایگاه داده

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

نمونه عالی آن استفاده از پایگاه داده‌ای نوشتن‌سنگین مثل Cassandra است. Cassandra با معماری commit-log-only خودش به throughput نوشتن برتر می‌رسد. به‌جای به‌روزرسانی داده در محل (که جستجوهای گران دیسک می‌خواهد)، همه‌چیز را به‌صورت متوالی روی دیسک می‌نویسد. طبق متن اصلی، این کار اجازه می‌دهد روی سخت‌افزار معمولی بیش از ۱۰ هزار نوشتن در ثانیه انجام دهد، در مقایسه با شاید ۱۰۰۰ نوشتن در ثانیه برای پایگاه داده رابطه‌ای سنتی که همان کار را انجام می‌دهد.

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

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

پایگاه‌های داده دیگر هم به روش‌های مختلف مصالحه‌های مشابهی دارند.

  • پایگاه‌های داده سری زمانی (Time-Series) مثل InfluxDB یا TimescaleDB برای نوشتن‌های متوالی پرحجم با timestamp ساخته شده‌اند. (برای استفاده بهتر از فضا، delta encoding داخلی هم دارند.)
  • پایگاه‌های داده log-structured مثل LevelDB به‌جای به‌روزرسانی در محل، داده جدید را append می‌کنند.
  • Column storeها مثل ClickHouse می‌توانند نوشتن‌ها را برای بارهای کاری تحلیلی به‌صورت دسته‌ای (batch) انجام دهند.

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

فراتر از این، کارهای دیگری هم هست که برای بهینه کردن هر پایگاه داده برای نوشتن می‌توانیم بکنیم.

  • غیرفعال کردن ویژگی‌های گران: مثل قیدهای کلید خارجی، trigger پیچیده یا ایندکس جستجوی متن کامل در دوره‌های نوشتن بالا.
  • تنظیم Write-Ahead Log: پایگاه‌های داده مثل PostgreSQL می‌توانند چند تراکنش را قبل از flush روی دیسک دسته‌ای کنند.
  • کم کردن سربار ایندکس: ایندکس کمتر یعنی نوشتن سریع‌تر، هرچند هزینه‌اش را هنگام خواندن می‌دهید.

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

در مصاحبه، نام بردن از گزینه‌های مشخص پایگاه داده نشان می‌دهد مصالحه‌ها را می‌فهمید. فقط نگویید «از پایگاه داده سریع‌تر استفاده کنید». توضیح دهید چرا نوشتن append-only در Cassandra از به‌روزرسانی B-Tree در MySQL سریع‌تر است، یا چرا ممکن است برای جمع‌آوری متریک پایگاه داده سری زمانی انتخاب کنید.

Sharding و پارتیشن‌بندی

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

اگر یک سرور بتواند ۱۰۰۰ نوشتن در ثانیه را مدیریت کند، ۱۰ سرور باید (باید به‌صورت پررنگ!) ۱۰ هزار نوشتن در ثانیه را مدیریت کنند. در حالت ایده‌آل می‌توانیم حجم نوشتن را بین چند سرور توزیع کنیم تا هرکدام بخش قابل‌مدیریتی را بردارد و مقیاس‌پذیری «رایگان» بگیریم.

معمولاً این سرورهای اضافه را «shard» می‌نامیم و بسیاری از shardها ممکن است در واقع بخشی از یک پایگاه داده منطقی باشند. ما آن را مثل یک پایگاه داده در نظر می‌گیریم. این‌که به چند سرور نیاز داریم (تا حد زیادی) از اپلیکیشن پنهان است.

یک مثال ساده و عالی از Sharding کاری است که Redis Cluster انجام می‌دهد. هر ورودی در Redis با یک رشته کلید ذخیره می‌شود. این کلیدها با یک تابع CRC ساده hash می‌شوند تا «شماره slot» تعیین شود. بعد این شماره slotها به گره‌های مختلف خوشه اختصاص داده می‌شوند.

کلاینت‌ها از Redis Cluster کوئری می‌گیرند تا بدانند چه سرورهایی در خوشه هستند و هر کدام مسئول کدام شماره slotها هستند. وقتی کلاینت می‌خواهد مقداری بنویسد، کلید را hash می‌کند تا شماره slot را بفهمد، سروری را که مسئول آن slot است پیدا می‌کند و درخواست نوشتن را به همان سرور می‌فرستد.

با این کار داده را بین همه سرورهای خوشه پخش کرده‌ایم. روش دیگر استفاده از طرح Consistent Hashing برای تعیین این است که روی کدام سرور بنویسیم.

انتخاب کلید پارتیشن خوب

در عمل مصاحبه‌گرها انتظار دارند بگویید «Sharding»، اما می‌خواهند بدانند چطور کار می‌کند. مهم‌ترین جزئیاتی که باید بگویید نحوه انتخاب کلید پارتیشن (partition key) است. اگر کلید خوبی انتخاب کنید (مثلاً hash کردن شناسه کاربر)، همه داده‌ها به‌طور مساوی بین خوشه پخش می‌شوند. به این ترتیب مسئله را حل کرده‌ایم و با ضرب کردن تعداد سرورها در ۱۰، سود فرضی ۱۰ برابری‌مان را گرفته‌ایم.

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

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

اصل در اینجا این است که کلیدی را انتخاب کنیم که واریانس تعداد نوشتن در هر shard را حداقل کند، یا در تصویر بالا، «تخت» باشد. اغلب می‌توانیم این کار را با hash کردن یک شناسه اصلی (مثل userId یا postId) انجام دهیم. معمولاً تعداد کمی شناسه کاندیدا هست که از بینشان انتخاب کنید.

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

برای داده‌هایی که بیشترین خواندن و نوشتن را دارند (که گاهی «داده داغ» نامیده می‌شوند)، انتظار داشته باشید با مصاحبه‌گر درباره چیدمان فیزیکی داده بحث کنید. می‌خواهید طرحی را انتخاب کنید که خواندن و نوشتن را تا جای ممکن یکنواخت بین داده‌هایتان پخش کند و هم‌زمان داده‌هایی را که معمولاً با هم به آن‌ها دسترسی دارید کنار هم نگه دارد. از خودتان بپرسید «این درخواست باید به چند shard بخورد؟» و «این درخواست چند بار رخ می‌دهد؟». این سوال‌ها کمک می‌کنند تصمیم بگیرید گلوگاه ساخته‌اید یا نه.

پارتیشن‌بندی عمودی

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

یک پست شبکه اجتماعی را فکر کنید. در رویکرد یکپارچه (monolithic) ممکن است یک جدول یا پایگاه داده واحد با همه داده‌های یک پست داشته باشید:

1
2
3
4
5
6
7
8
9
10
11
12
TABLE posts (
    id BIGINT PRIMARY KEY,
    user_id BIGINT,
    content TEXT,
    media_urls TEXT[],
    created_at TIMESTAMP,
    like_count INTEGER,
    comment_count INTEGER,
    share_count INTEGER,
    view_count INTEGER,
    last_updated TIMESTAMP
);

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

با پارتیشن‌بندی عمودی این جدول را به جدول‌های تخصصی تقسیم می‌کنید:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-- Core post content (write-once, read-many)
TABLE post_content (
    post_id BIGINT PRIMARY KEY,
    user_id BIGINT,
    content TEXT,
    media_urls TEXT[],
    created_at TIMESTAMP
);

-- Engagement metrics (high-frequency writes)
TABLE post_metrics (
    post_id BIGINT PRIMARY KEY,
    like_count INTEGER DEFAULT 0,
    comment_count INTEGER DEFAULT 0,
    share_count INTEGER DEFAULT 0,
    view_count INTEGER DEFAULT 0,
    last_updated TIMESTAMP
);

-- Analytics data (append-only, time-series)
TABLE post_analytics (
    post_id BIGINT,
    event_type VARCHAR(50),
    timestamp TIMESTAMP,
    user_id BIGINT,
    metadata JSONB
);

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

  • برای محتوای پست از ایندکس B-Tree سنتی استفاده می‌کنیم و برای کارایی خواندن بهینه می‌شود.
  • برای متریک‌های پست ممکن است از ذخیره‌گاه درون‌حافظه‌ای یا شمارنده‌های تخصصی برای به‌روزرسانی‌های پرتکرار استفاده کنیم.
  • برای تحلیل‌های پست می‌توانیم از ذخیره‌گاه یا پایگاه داده سری زمانی بهینه با فشرده‌سازی ستونی استفاده کنیم.

چالش مدل‌سازی داده به همان اندازه که درباره منطقی بودن داده است، درباره جزئیات فنی محل فیزیکی زندگی آن در طراحی شماست!

مدیریت موج ناگهانی با صف و Load Shedding

اگرچه پارتیشن‌بندی و Sharding ۸۰٪ راه را به مقیاس می‌رسانند، اغلب در production به مشکل می‌خورند. ترافیک نوشتن در دنیای واقعی ثابت نیست، و با این‌که مقیاس اغلب هموار است (حجم سفارش آمازون به‌طرز شگفت‌آوری پایدار است)، بعضی موج‌های ناگهانی (burst) رایج‌اند. مصاحبه‌گرها دوست دارند درباره مواردی مثل «در جمعه سیاه که ۴ برابر حجم سفارش می‌گیریم چه می‌شود» یا «در شب سال نو تعداد رانندگان روی جاده سه برابر می‌شود» بپرسند.

اگر باید بتوانیم در اوج، حجم نوشتن را ۴ برابر کنیم، در زمان‌های آرام فقط از ۲۵٪ ظرفیت کلی استفاده می‌کنیم. بیشتر سیستم‌ها این‌طور مقیاس نمی‌گیرند! بسیاری از کاندیداها فکر می‌کنند جواب «auto-scaling» است. auto-scaling می‌تواند ابزار عالی‌ای باشد، اما معجزه نیست. بزرگ و کوچک کردن مقیاس زمان می‌برد، و بدتر این‌که در سیستم‌های پایگاه داده اغلب هنگام مقیاس‌دهی خرابی یا افت throughput هم دارید. این دقیقاً برعکس چیزی است که معمولاً وقتی کسب‌وکارمان در آتش است می‌خواهیم.

یعنی ما یا باید (الف) نوشتن‌ها را بافر کنیم تا بتوانیم آن‌ها را با بیشترین سرعتی که بدون شکست ممکن است پردازش کنیم، یا (ب) نوشتن‌ها را به شیوه‌ای که برای کسب‌وکار قابل‌قبول است دور بریزیم. درباره هر دو صحبت می‌کنیم.

صف برای مدیریت Burst

اولین ایده‌ای که به ذهن بیشتر کاندیداها می‌رسد اضافه کردن صف به سیستم با چیزی مثل Kafka یا SQS است. این کار پذیرش نوشتن را از پردازش نوشتن جدا می‌کند و به سیستم اجازه می‌دهد نوشتن‌ها را با سریع‌ترین سرعت ممکن مدیریت کند.

چون صف‌ها ذاتاً ناهمزمان هستند، سرور اپلیکیشن فقط می‌داند نوشتن در صف ثبت شده، نه این‌که با موفقیت در پایگاه داده نوشته شده است. در بیشتر موارد یعنی کلاینت‌ها اغلب به راهی برای callback نیاز دارند تا بررسی کنند نوشتن در نهایت در پایگاه داده انجام شده است. برای بعضی کاربردها مشکلی نیست!

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

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

و هرچند ممکن است بتوانیم پایگاه داده را برای تحمل بار بیشتر (و backlogای که در صف جمع کرده‌ایم) مقیاس بدهیم، اغلب این کار مسئله را واقعاً بدتر می‌کند، چون کاربران ما مدام بی‌قرارتر می‌شوند.

صف‌ها ابزار قدرتمندی هستند، اما کاندیداها اغلب موقعیت‌هایی را در نظر نمی‌گیرند که صف یک مشکل بنیادین را پنهان می‌کند. وقتی انتظار burstهای کوتاه‌مدت دارید از صف استفاده کنید، نه برای وصله کردن پایگاه داده‌ای که نمی‌تواند بار حالت پایدار (steady state) را تحمل کند.

مهم است که در مرحله الزامات بفهمیم چه تحملی برای نوشتن با تأخیر یا خواندن ناسازگار داریم. در بسیاری از موارد سیستم‌ها می‌توانند کمی تأخیر را تحمل کنند، به‌خصوص برای موارد نادری که ترافیک در بالاترین حد است. اگر چنین است، صف می‌تواند راه خوبی باشد! اما مراقب باشید صفی معرفی نکنید که الزام کلیدی عملکردی یا غیرعملکردی را بشکند.

استراتژی‌های Load Shedding

گزینه دیگری برای کنترل burst ممکن است ظاهراً نوعی عقب‌نشینی به نظر برسد، اما در واقع ابزار قدرتمندی است. وقتی سیستم شما غرق می‌شود، باید تصمیم بگیرید کدام نوشتن‌ها را بپذیرید و کدام را رد کنید. این کار Load Shedding نام دارد و بهتر از این است که بگذاریم همه‌چیز از کار بیفتد.

Load Shedding سعی می‌کند تعیین کند کدام نوشتن‌ها برای کسب‌وکار مهم‌ترند و کدام نه. اگر بتوانیم نوشتن‌های کم‌اهمیت‌تر را دور بریزیم، می‌توانیم سیستم را سرپا نگه داریم و نوشتن‌های مهم‌تر همچنان پردازش شوند.

مسائلی مثل Strava یا Uber را در نظر بگیرید که کاربران در فاصله‌های زمانی منظم موقعیت‌شان را گزارش می‌کنند. اگر کاربر زیادی داشته باشیم، اضافه کردن صف به سیستم ما را برای صف شکسته آماده می‌کند. اما اگر یک قدم عقب بیاییم، می‌بینیم کاربران هر چند ثانیه یک بار تماس می‌گیرند تا موقعیتشان را بفرستند. اگر یک نوشتن را دور بریزیم، باید انتظار داشته باشیم نوشتن دیگری در چند ثانیه بعد برسد که از آنچه دور ریخته‌ایم تازه‌تر است!

یک راه‌حل ساده این است که در زمان‌های بار بیش از حد سیستم، کم‌ارزش‌ترین نوشتن‌ها را دور بریزیم. برای Uber این‌ها ممکن است به‌روزرسانی‌های موقعیتی باشند که چند ثانیه با به‌روزرسانی قبلی فاصله دارند. برای سیستم تحلیلی ممکن است مدتی بازدیدها (impression) را دور بریزیم تا کلیک‌های مهم‌تر را پردازش کنیم.

بسته به سیستم، گذاشتن چند شیر اطمینان (release valve) نشان می‌دهد می‌توانیم وضعیت بد (بار بیش از حد) را از تبدیل شدن به فاجعه (خرابی سیستم) نگه داریم، حتی اگر به معنای تجربه‌ای کمتر از حد مطلوب برای بعضی کاربران باشد.

Batching و تجمیع سلسله‌مراتبی

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

Batching

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

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

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

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

فرایند میانی: گزینه دیگر این است که فرایند میانی‌ای داشته باشیم که نوشتن‌ها را قبل از فرستادن به پایگاه داده دسته‌بندی کند. سیستمی را در نظر بگیرید که رویداد «لایک» را می‌پذیرد و سعی می‌کند تعداد لایک پست‌ها را نگه دارد.

Like Batcher ما می‌تواند تعدادی از این رویدادها را بخواند، تغییرات تعداد لایک هر پست را جمع‌بندی کند و بعد آن تغییرات را به پایگاه داده بفرستد. اگر یک پست در یک پنجره (مثلاً ۱ دقیقه) ۱۰۰ لایک بگیرد، تعداد نوشتن‌ها را از ۱۰۰ به ۱ کم کرده‌ایم!

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

لایه پایگاه داده: آخرین لایه‌ای که باید در نظر بگیریم لایه پایگاه داده است. بیشتر سیستم‌های پایگاه داده گزینه‌های قابل‌تنظیم دارند برای این‌که چند وقت یک بار نوشتن‌ها روی دیسک flush شوند، که برای بیشتر سیستم‌ها گلوگاه است.

مثلاً پیکربندی پیش‌فرض Redis، طبق متن اصلی، این است که هر ۱۰۰ میلی‌ثانیه نوشتن‌ها را روی دیسک flush کند. یعنی اگر ۱۰۰۰ نوشتن در یک دسته داریم، فقط ۱۰۰ میلی‌ثانیه بعد از آخرین نوشتن روی دیسک می‌نویسیم.

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

تجمیع سلسله‌مراتبی (Hierarchical Aggregation)

این آخرین استراتژی برای شدیدترین موارد کاربرد دارد. در داده‌های پرحجم مثل تحلیل (analytics) و پردازش استریم، اغلب لازم نیست رویدادهای تکی را ذخیره کنید و در عوض به نماهای aggregate نیاز دارید. بینش مهم این است که این نماها را می‌توان به‌صورت تدریجی ساخت. تجمیع سلسله‌مراتبی داده را در مرحله‌ها پردازش می‌کند و در هر مرحله حجم را کم می‌کند.

یک مثال مشخص ببینیم. در پخش زنده ویدیو، بینندگان اغلب می‌توانند هم کامنت بگذارند و هم کامنت‌ها را در stream لایک کنند. هر وقت بینندهای یکی از این رویدادها را انجام می‌دهد، همه کاربران دیگر باید باخبر شوند. اگر میلیون‌ها بیننده باشد، میلیون‌ها کاربر در حال نوشتن باشند و همه نوشتن‌ها باید برای همه همتایانشان فرستاده شود، وضعیت زشتی می‌شود! این مشکل fan-in و fan-out در کامنت‌های زنده است.

اما صبر کنید، می‌توانیم این را کمی ساده کنیم. همه بینندگان ما یک نمای مشابه و در نهایت سازگار می‌خواهند: همه آخرین کامنت‌ها و تعدادهای مربوط به آن‌ها را می‌خواهند ببینند. با یک طرح Consistent Hashing بینندگان را به گره‌های پخش (broadcast node) اختصاص می‌دهیم. به‌جای نوشتن مستقل برای هر بیننده، می‌توانیم برای گره‌های پخش بنویسیم که می‌توانند به‌روزرسانی را برای بینندگان مربوط بفرستند.

حالا به‌جای نوشتن برای N بیننده، فقط باید برای M گره پخش بنویسیم. عالی است! اما هنوز یک مشکل داریم: پردازشگر ریشه (root processor) ما رویدادهای ورودی را از همه بینندگان می‌گیرد. خوشبختانه می‌توانیم با همین روش با آن برخورد کنیم.

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

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

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

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

نمونه‌ای از پاسخ کاندیدای قوی:

  • «با میلیون‌ها کاربر که محتوا پست می‌کنند، خیلی زود به گلوگاه‌های نوشتن می‌خوریم. اجازه بدهید ببینم با چه throughput نوشتنی سروکار داریم… بسیار خب، این مهم است! در بررسی عمیق به آن برمی‌گردم.»
  • «برای نوشتن پست‌ها، فکر می‌کنم منطقی است پایگاه داده را بر اساس شناسه کاربر پارتیشن‌بندی کنیم. این کار بار را یکنواخت بین shardهایمان پخش می‌کند. هنوز باید حالتی را که کاربر تنهایی پست زیادی می‌گذارد مدیریت کنیم، اما می‌توانیم با صف و محدودیت نرخ (rate limit) کنترلش کنیم.»

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

  • Instagram و شبکه‌های اجتماعی: برای نمایش Sharding بر اساس شناسه کاربر برای پست‌ها، پارتیشن‌بندی عمودی برای انواع مختلف داده (پروفایل کاربر، پست و تحلیل‌ها) و ذخیره‌سازی سلسله‌مراتبی برای پست‌های قدیمی‌تر ایده‌آل است.
  • News Feed: فید خبری به تنظیم دقیق بین حجم نوشتن پست‌های سلبریتی‌ها، که باید برای میلیون‌ها دنبال‌کننده نوشته شود، و زمانی که میلیون‌ها نفر فیدشان را مصرف می‌کنند نیاز دارد.
  • اپلیکیشن‌های جستجو: اپلیکیشن‌های جستجو معمولاً نوشتن‌سنگین هستند و برای بازیابی سریع نتایج جستجو، پیش‌پردازش قابل‌توجهی لازم دارند. پارتیشن‌بندی و Batching برای فعال کردن این کار کلیدی‌اند.
  • کامنت‌های زنده: نمونه عالی سیستمی است که می‌تواند از تجمیع سلسله‌مراتبی برای پرهیز از مشکل غیرقابل‌حل «همه با همه» استفاده کند، جایی که میلیون‌ها بیننده به دید مشترک از فعالیت میلیون‌ها همتای خود نیاز دارند.

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

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

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

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

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

«وقتی لازم است shard بیشتری اضافه کنید، چطور resharding می‌کنید؟»

این چالش عملیاتی کلاسیک Sharding است. با ۸ shard شروع کرده‌اید، اما حالا به ۱۶ نیاز دارید. چطور داده را بدون downtime جابه‌جا می‌کنید؟

رویکرد ساده‌لوحانه این است که سیستم را آفلاین کنیم، همه داده را دوباره hash کنیم و به shardهای جدید ببریم. اما برای مجموعه داده‌های بزرگ این کار ساعت‌ها downtime می‌سازد.

سیستم‌های production از مهاجرت تدریجی استفاده می‌کنند که در آن نوشتن به هر دو مقصد انجام می‌شود (مثلاً shardای که از آن مهاجرت می‌کنیم و shardای که به آن مهاجرت می‌کنیم). این کار اجازه می‌دهد داده را به‌تدریج جابه‌جا کنیم و هم‌زمان دسترس‌پذیری را حفظ کنیم.

مرحله dual-write تضمین می‌کند هیچ داده‌ای حین مهاجرت از دست نمی‌رود. روی هر دو shard قدیمی و جدید می‌نویسید، اما خواندن را با اولویت shard جدید انجام می‌دهید. این به شما اجازه می‌دهد داده را به‌تدریج و با حفظ دسترس‌پذیری جابه‌جا کنید.

«اگر hot key داشته باشید که حتی برای یک shard هم بیش از حد محبوب است چه؟»

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

دو گزینه اصلی برای مدیریت این مسئله داریم.

تقسیم همه کلیدها

اولین گزینه این است که همه کلیدها را به k قسمت ثابت تقسیم کنیم. این چکش بسیار بزرگی است، اما ساده‌ترین راه‌حل است. به‌جای این‌که لایک‌های هر توییت روی یک shard ذخیره شود، می‌توانیم آن‌ها را روی چند shard ذخیره کنیم.

برای کلید post1Likes می‌توانیم post1Likes-0 و post1Likes-1 و post1Likes-2 تا post1Likes-(k-1) داشته باشیم. یعنی هر shard فقط زیرمجموعه‌ای از داده یک پست مشخص را دارد، اما حجم نوشتن برای یک shard مشخص برای آن پست k برابر کم می‌شود.

این روش یک عیب بزرگ دارد.

  • اندازه کلی مجموعه داده را k برابر کرده‌ایم.
  • حجم خواندن را هم در k ضرب کرده‌ایم. برای گرفتن تعداد لایک یک پست، باید postId-0 و postId-1 و postId-2 تا postId-(k-1) را بخوانیم.

اما اگر k کوچکی بار کاری ما را به‌راحتی با ظرفیت نوشتن پایگاه داده‌مان هماهنگ کند، مشکلمان حل شده است!

تقسیم پویای کلیدهای داغ

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

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

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

دو راه‌حل اصلی داریم.

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

بیشتر سیستم‌های production از رویکرد اول استفاده می‌کنند، چون ساده‌تر است و هزینه سربار چک کردن کلیدهای فرعی در مقایسه با بهبود کارایی ناشی از مدیریت درست کلیدهای داغ بسیار کم است. در مصاحبه از بیش‌ازحد مهندسی کردن راه‌حل پرهیز کنید!

جمع‌بندی

مقیاس‌دهی نوشتن به چهار استراتژی اساسی خلاصه می‌شود که با هم کار می‌کنند: مقیاس عمودی و انتخاب‌های پایگاه داده، Sharding و پارتیشن‌بندی، صف و Load Shedding، و Batching و تجمیع سلسله‌مراتبی. موفق‌ترین مصاحبه‌ها این مفاهیم را بیش از حد پیچیده نمی‌کنند. دنبال جاهایی می‌گردند که لازم‌اند و آن‌ها را راهبردی به کار می‌برند.

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

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

اگر با حجم بالای تحلیل یا داده عددی سروکار دارید، Batching و تجمیع سلسله‌مراتبی می‌تواند فوراً بهبود ۵ تا ۱۰ برابری بدهد.

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

بینش کلیدی این است که مقیاس‌دهی نوشتن درباره کم کردن throughput هر جزء است. چه ۱۰ هزار نوشتن را بین ۱۰ shard پخش کنید، چه burstها را در صف‌ها هموار کنید، چه آن‌ها را در ۱۰۰ عملیات bulk دسته کنید، از همان اصل استفاده می‌کنید: هر جزء به‌تنهایی باید باری قابل‌مدیریت را تحمل کند.