پست

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

راهنمای Sharding شامل تفاوت آن با Partitioning، انتخاب Shard Key، استراتژی‌های توزیع، چالش‌ها و نحوه بیان در مصاحبه

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

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

اما سرانجام به سقف توان یک ماشین می‌رسید. Queryها کند می‌شوند، نوشتن گلوگاه می‌شود و ذخیره‌سازی به حد خود می‌رسد. حتی پایگاه‌های داده‌ی قدرتمند ابری مثل Amazon Aurora هم در حدود ۲۵۶ ترابایت به سقف می‌رسند.

وقتی یک پایگاه داده دیگر نمی‌تواند همگام بماند، فقط یک گزینه‌ی واقعی دارید:

داده را بین چند ماشین تقسیم کنید.

این کار Sharding نام دارد. در مقیاس بالا ضروری است ولی چالش‌های جدیدی هم می‌آورد. در این مطلب می‌بینیم چطور و کی Shard کنیم و به چه چیزهایی باید توجه کرد.

مردم اغلب کلمه‌های «Partitioning» و «Sharding» را هم‌معنی به کار می‌برند. از نظر فنی کمی فرق دارند. Partitioning معمولاً به تقسیم داده داخل یک Instance پایگاه داده اشاره دارد، اغلب با بازه‌های جدول یا Hash Partition. Sharding یعنی تقسیم داده بین چند ماشین. در عمل بیشتر مهندس‌ها این دو واژه را شل به کار می‌برند، پس روی لفظ گیر نکنید. فقط روشن باشید که داده روی یک ماشین است یا چند ماشین.

اول، Partitioning چیست؟

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

جدول سفارش‌هایی را در نظر بگیرید با ۵۰۰ میلیون ردیف و ۲ ترابایت داده. Query برای سفارش‌های ماه گذشته باید کل جدول را اسکن کند. Indexها عظیم و نگهداری‌شان کند می‌شود، در حالی که عملیات روتین مثل Vacuum، Analyze یا بازسازی Index می‌توانند کل جدول را قفل کنند و روی عملکرد اثر بگذارند.

Partitioning این مشکل را با شکستن آن جدول بزرگ به Partitionهای کوچک‌تر حل می‌کند. داده از ماشین بیرون نمی‌رود و فقط به تکه‌های منطقی‌ای تقسیم می‌شود که پایگاه داده می‌تواند جدا مدیریت کند. حالا Query سفارش‌های ماه گذشته فقط Partition مربوط را اسکن می‌کند، نه کل جدول.

دو نوع رایج Partitioning:

  • Horizontal Partitioning: ردیف‌ها بین Partitionها تقسیم می‌شوند. مثلاً یک Partition برای هر سال سفارش‌ها. ستون‌ها همان‌اند، ردیف کمتر در هر Partition.
  • Vertical Partitioning: ستون‌ها بین Partitionها تقسیم می‌شوند. مثلاً ستون‌های پرمصرف در یک Partition و ستون‌های بزرگ یا کم‌استفاده در Partition دیگر. ردیف‌ها همان‌اند، ستون کمتر در هر Partition.

Sharding چیست؟

Sharding همان Horizontal Partitioning روی چند ماشین است. هر Shard زیرمجموعه‌ای از داده را نگه می‌دارد و با هم، Shardها مجموعه‌داده‌ی کامل را می‌سازند. برخلاف Partitioning که داخل یک Instance پایگاه داده می‌ماند، Sharding بار را بین چند پایگاه داده‌ی مستقل پخش می‌کند.

مثلاً اگر داده‌ی سفارش‌ها را بر اساس id پارتیشن کنیم، ممکن است به چنین چیزی برسیم: هر Shard یک پایگاه داده‌ی مستقل با CPU، حافظه، ذخیره‌سازی و Connection Pool مخصوص خودش است. هیچ ماشین واحدی کل داده را نگه نمی‌دارد یا همه‌ی ترافیک را مدیریت نمی‌کند، که اجازه می‌دهد هم ظرفیت ذخیره‌سازی و هم Throughput خواندن و نوشتن با افزودن Shard بیشتر رشد کنند.

Sharding مقیاس‌پذیری را حل می‌کند ولی مشکلات جدیدی می‌سازد. حالا باید Shard Key انتخاب کنید، Queryها را به Shard درست مسیریابی کنید، از Hot Spot اجتناب کنید و با بزرگ شدن Shardها داده را دوباره متعادل کنید. در ادامه می‌بینیم چطور این‌ها را مدیریت کنیم.

چطور داده را Shard کنیم؟

وقتی تصمیم به Sharding گرفتید، دو تصمیم هماهنگ لازم است:

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

انتخاب Shard Key

در مصاحبه جمله‌ی رایج این است که «بر اساس [فیلد] Shard می‌کنم». کلید کار دانستن این است که کدام فیلد را به‌عنوان Shard Key بگیرید و چرا.

Shard Key بد به توزیع نامتوازن داده، Hot Spot (جایی که یک Shard زیر فشار است و بقیه بیکارند) و Queryهایی منجر می‌شود که برای پیدا کردن داده باید به همه‌ی Shardها بزنند. Shard Key خوب داده را یکنواخت توزیع می‌کند، با الگوهای Query هم‌راستاست و با رشد سیستم مقیاس می‌گیرد.

ویژگی‌های Shard Key خوب:

  • Cardinality بالا: کلید باید مقادیر یکتای زیادی داشته باشد. Sharding بر اساس فیلد Boolean (true/false) یعنی حداکثر دو Shard دارید که هدف را از بین می‌برد. Sharding بر اساس شناسه‌ی کاربر وقتی میلیون‌ها کاربر دارید جای کافی برای توزیع داده می‌دهد.
  • توزیع یکنواخت: مقادیر باید یکنواخت بین Shardها پخش شوند. اگر بر اساس کشور Shard کنید و ۹۰٪ کاربران در آمریکا باشند، آن Shard بسیار بزرگ‌تر از بقیه می‌شود. شناسه‌ی کاربر معمولاً خوب توزیع می‌شود. Timestamp ساخت اگر رکوردهای جدید همه روی جدیدترین Shard جمع نشوند می‌تواند کار کند.
  • هم‌راستا با Queryها: رایج‌ترین Queryهای شما در حالت ایده‌آل باید فقط به یک Shard بخورند. اگر کاربران را با user_id Shard کنید، Queryهایی مثل «دریافت پروفایل کاربر» یا «دریافت سفارش‌های کاربر» به یک Shard می‌خورند. Queryهایی که همه‌ی Shardها را پوشش می‌دهند گران می‌شوند.

نمونه‌های Shard Key خوب:

  • user_id برای اپ کاربرمحور: Cardinality بالا (میلیون‌ها کاربر)، توزیع یکنواخت و بیشتر Queryها در هر حال به یک کاربر محدودند («داده‌ی این کاربر را نشان بده»). تناسب کامل.
  • order_id برای جدول سفارش‌های تجارت الکترونیک: Cardinality بالا (میلیون‌ها سفارش)، Queryها معمولاً به سفارش مشخصی محدودند («جزئیات سفارش را بگیر»، «وضعیت سفارش را به‌روز کن») و سفارش‌ها در طول زمان یکنواخت توزیع می‌شوند.

نمونه‌های بد:

  • is_premium (Boolean): فقط دو مقدار ممکن، یعنی فقط دو Shard. یک Shard همه‌ی کاربران Premium را می‌گیرد و دیگری کاربران رایگان را. اگر بیشتر کاربران رایگان باشند، آن Shard بیش از حد بار می‌گیرد.
  • created_at برای جدول در حال رشد: همه‌ی نوشتن‌های جدید به جدیدترین Shard می‌روند. آن Shard Hot Spot نوشتن می‌شود، در حالی که Shardهای قدیمی‌تر تقریباً ترافیکی ندارند.

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

وقتی Shard Key را می‌دانید، باید تصمیم بگیرید داده را چطور بین Shardها توزیع کنید. سه استراتژی اصلی با Trade-offهای متفاوت وجود دارد.

Range-Based Sharding: ساده‌ترین است. رکوردها را با بازه‌ی پیوسته‌ای از مقادیر گروه‌بندی می‌کند. Shard Key مثل user_id یا created_at انتخاب می‌کنید و بازه‌های مقدار را به Shardها تخصیص می‌دهید. مثلاً اگر بر اساس user_id Shard کنیم، ۱ میلیون کاربر اول را به Shard 1، ۱ میلیون بعدی را به Shard 2 و همین‌طور ادامه می‌دهیم:

1
2
3
Shard 1 → User IDs 1–1M
Shard 2 → User IDs 1M–2M
Shard 3 → User IDs 2M–3M

مزیت اصلی Range-Based Sharding سادگی و پشتیبانی از Range Scan کارآمد است. اگر همه‌ی سفارش‌های بین شناسه‌ی کاربر ۵۰۰ هزار تا ۶۰۰ هزار را بخواهید، فقط به یک Shard می‌خورید.

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

Range-Based Sharding وقتی بهترین عملکرد را دارد که کاربران مختلف طبیعتاً بازه‌های مختلف را Query کنند. مثلاً سیستم‌های Multi-tenant که به هر شرکت بازه‌ای از شناسه‌ها داده می‌شود. اپلیکیشن SaaS را در نظر بگیرید که هر مشتری بازه‌ای از شناسه‌ی کاربر دارد. کاربران شرکت A فقط بازه‌ی A را Query می‌کنند و کاربران شرکت B فقط بازه‌ی B را. این بار را بین Shardها پخش می‌کند.

Hash-Based Sharding (پیش‌فرض): از Hash Function برای توزیع یکنواخت رکوردها بین Shardها استفاده می‌کند. به‌جای تخصیص بازه، Shard Key مثل user_id را Hash می‌کنید و از نتیجه برای انتخاب Shard استفاده می‌کنید. مثلاً با ۴ Shard:

1
2
3
4
5
shard = hash(user_id) % 4

User 42  → hash(42) % 4 = Shard 2
User 99  → hash(99) % 4 = Shard 3
User 123 → hash(123) % 4 = Shard 1

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

عیب هنگام اضافه یا حذف Shard ظاهر می‌شود. اگر از ۴ Shard به ۵ بروید، عملیات Modulo از % 4 به % 5 تغییر می‌کند، یعنی تقریباً هر رکورد به Shard دیگری نگاشت می‌شود. باید حجم عظیمی داده را جابه‌جا کنید.

اینجاست که Consistent Hashing وارد می‌شود. به‌جای Modulo ساده، Consistent Hashing جابه‌جایی داده را هنگام افزودن یا حذف Shard کم می‌کند. در صفحه‌ی Consistent Hashing جزئیاتش را آورده‌ایم، ولی نکته‌ی کلیدی این است که Hash-Based Sharding تا وقتی برای Resharding برنامه دارید عالی کار می‌کند.

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

Directory-Based Sharding: از جدول Lookup برای تصمیم‌گیری درباره‌ی محل هر رکورد استفاده می‌کند. به‌جای فرمول، تخصیص Shardها را در جدول نگاشت یا سرویس ذخیره می‌کنید. مثلاً:

1
2
3
4
5
user_to_shard
---------------
User 15   → Shard 1
User 87   → Shard 4
User 204  → Shard 2

قدرت Directory-Based Sharding انعطاف‌پذیری است. اگر کاربر مشخصی ترافیک زیادی تولید کند، می‌توانید او را به Shard اختصاصی ببرید. اگر لازم باشد بار را دوباره متعادل کنید، فقط جدول نگاشت را به‌روز می‌کنید. می‌توانید منطق Sharding پیچیده‌ای پیاده کنید که با Hash Function ساده ممکن نیست.

عیب این است که هر درخواست نیاز به Lookup دارد. پیش از Query داده‌ی کاربر باید از سرویس Directory بپرسید آن کاربر روی کدام Shard است. این به هر درخواست Latency اضافه می‌کند و سرویس Directory را به وابستگی حیاتی تبدیل می‌کند. اگر Directory از کار بیفتد، کل سیستم متوقف می‌شود حتی اگر همه‌ی Shardهای داده سالم باشند.

Directory-Based Sharding وقتی منطقی است که بیشترین انعطاف را می‌خواهید و هزینه‌ی Lookup اضافه را تحمل می‌کنید. بیشتر سیستم‌ها با Hash-Based یا Range-Based شروع می‌کنند و فقط اگر نیازمندی مشخصی داشته باشند از Directory استفاده می‌کنند.

واقع‌بینانه، با اینکه Directory-Based Sharding راه‌حل معتبری برای موارد پویاست، به‌ندرت در مصاحبه‌ی طراحی سیستم جواب است. یک Single Point of Failure می‌آورد و به هر درخواست Latency اضافه می‌کند، که مصاحبه‌کننده را به پرسیدن سؤال‌های پیگیری زیادی می‌کشاند که می‌تواند گفتگو را از مسیر خارج کند.

چالش‌های Sharding

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

Hot Spot و عدم توازن بار

حتی با Shard Key خوب، بعضی Shardها ممکن است ترافیک بسیار بیشتری از بقیه بگیرند. به این Hot Spot می‌گویند و مزیت اصلی Sharding را از بین می‌برد، چون یک Shard بیش از حد بارگرفته گلوگاه می‌شود.

رایج‌ترین علت «مشکل سلبریتی» است. اگر کاربران را با user_id Shard کنید، Shard مربوط به Taylor Swift ۱۰۰۰ برابر ترافیک Shard یک کاربر معمولی را می‌گیرد. هر بار کسی پروفایلش را ببیند، پستش را لایک کند یا برایش پیام بفرستد، آن درخواست به همان Shard می‌خورد. توزیع مبتنی بر Hash اینجا کمک نمی‌کند، چون مسئله استراتژی توزیع نیست، بلکه این است که بعضی کلیدها ذاتاً فعال‌ترند.

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

Hot Spot را می‌توانید با پایش متریک‌های Shard مثل Latency Query، مصرف CPU و حجم درخواست تشخیص دهید. وقتی یک Shard مدام متریک‌های بالاتری از بقیه دارد، مشکل Hot Spot دارید.

راه مدیریت:

  • ایزوله کردن Hot Keyها روی Shard اختصاصی: اگر حساب Taylor Swift ترافیک زیادی تولید می‌کند، آن را به Shard اختصاصی ببرید که فقط حساب‌های سلبریتی را مدیریت می‌کند. به همین دلیل Directory-Based Sharding برای موارد خاص مفید است، هرچند احتمالاً از آن شروع نمی‌کنید.
  • Compound Shard Key: به‌جای Sharding فقط با user_id، آن را با بُعد دیگری مثل hash(user_id + date) ترکیب کنید. این داده‌ی یک کاربر را در طول زمان بین چند Shard پخش می‌کند، که اگر Hot Spot هم حجم بالا دارد و هم چند بازه‌ی زمانی را پوشش می‌دهد کمک می‌کند.
  • Dynamic Shard Splitting: بعضی پایگاه‌های داده تقسیم خودکار Shard را وقتی خیلی بزرگ یا داغ می‌شود پشتیبانی می‌کنند. مثلاً Balancer در MongoDB، Chunkهای مبتنی بر بازه را (از جمله هنگام استفاده از Hashed Shard Key) برای حفظ توازن تقسیم و جابه‌جا می‌کند. در مقابل، Vitess از Resharding آنلاین پشتیبانی می‌کند ولی عملیات آن توسط اپراتور شروع و مدیریت می‌شود، نه خودکار.

عملیات بین‌Shard (Cross-Shard)

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

مشکل با Queryهایی ظاهر می‌شود که با Shard Key هم‌راستا نیستند. اگر کاربران را با user_id Shard کنید، Queryی مثل «پروفایل کاربر 12345 را بگیر» به یک Shard می‌خورد. سریع و ساده. اما Queryی مثل «۱۰ پست محبوب سراسری را بگیر» باید همه‌ی Shardها را بررسی کند، چون پست‌ها بین همه‌ی Shardهای کاربران پخش شده‌اند. Query را به همه‌ی ۶۴ Shard می‌فرستید، منتظر ۶۴ پاسخ می‌مانید، نتایج را ادغام می‌کنید و بعد ۱۰ تای برتر را برمی‌گردانید. یعنی ۶۴ برابر فراخوانی شبکه و Latency.

نمی‌توانید Queryهای بین‌Shard را کاملاً حذف کنید ولی می‌توانید کمشان کنید:

  • کش کردن نتایج: اگر «۱۰ پست محبوب سراسری» به همه‌ی Shardها نیاز دارد، نتیجه را ۵ دقیقه کش کنید. Query اول گران است ولی هزار درخواست بعدی به‌جای Query ۶۴ Shard به کش می‌خورند. به‌خصوص برای Queryهایی که به دقت بلادرنگ نیاز ندارند (یعنی Eventual Consistency قابل‌قبول است) خوب جواب می‌دهد. Leaderboardها، محتوای ترند و آمار تجمیعی نامزدهای عالی‌اند.
  • Denormalize کردن برای نگه داشتن داده‌ی مرتبط کنار هم: اگر مکرراً پست‌ها را همراه داده‌ی کاربر Query می‌کنید، بخشی از اطلاعات پست را مستقیم روی Shard کاربر ذخیره کنید. بله، داده تکرار می‌شود. بله، به‌روزرسانی پیچیده‌تر می‌شود. اما اجازه می‌دهد همه‌چیز را از یک Shard Query کنید، که اغلب ارزش این Trade-off را دارد.
  • پذیرش هزینه برای Queryهای نادر: گاهی Queryای واقعاً باید به همه‌ی Shardها بزند و تا وقتی نادر است اشکالی ندارد. داشبورد ادمین که «مجموع کاربران در همه‌ی Shardها» را نشان می‌دهد اگر فقط چند بار در روز بارگذاری شود می‌تواند کند باشد.

در مصاحبه‌ها، عملیات بین‌Shard اغلب نشانه‌ی این است که چیزی در طراحی شما باید بازنگری شود. اگر برای Use Case رایجی می‌گویید «همه‌ی Shardها را Query و نتایج را تجمیع می‌کنیم»، مکث کنید و بسنجید: می‌توانم Denormalize کنم تا لازم نشود؟ می‌توانم کشش کنم؟ می‌توانم با Background Job از پیش محاسبه‌اش کنم؟ مصاحبه‌کننده‌ها انتظار دارند Queryهای بین‌Shard را کم کنید، نه اینکه آن‌ها را اجتناب‌ناپذیر بپذیرید.

حفظ Consistency

وقتی داده روی یک پایگاه داده است، تراکنش‌ها ساده‌اند. اگر باید موجودی را کم کنید و رکورد سفارش بسازید و این دو باید Atomic باشند، هر دو عملیات را در یک Database Transaction می‌گذارید. یا هر دو موفق می‌شوند یا هر دو شکست می‌خورند. پایگاه داده تضمین‌های Consistency را مدیریت می‌کند.

Sharding این را می‌شکند. اگر حساب کاربر روی Shard 1 و رکورد تراکنش روی Shard 2 باشد، دیگر نمی‌توانید از یک Database Transaction استفاده کنید. دارید نوشتن‌ها را بین دو پایگاه داده‌ی مستقل هماهنگ می‌کنید که از هم خبر ندارند.

راه‌حل کتاب‌درسی Two-Phase Commit یا 2PC است، که در آن Coordinator از همه‌ی Shardها می‌خواهد تراکنش را آماده کنند، منتظر تأیید آمادگی همه می‌ماند و بعد به همه می‌گوید Commit کنند. این Consistency را تضمین می‌کند ولی کند و شکننده است. اگر هر Shard یا Coordinator وسط تراکنش خراب شود، کل سیستم می‌تواند گیر کند. بیشتر سیستم‌های Production از 2PC پرهیز می‌کنند، چون Trade-offهای عملکرد و قابلیت اطمینان ارزشش را ندارد.

پس به‌جای آن چه می‌کنیم؟

  • طراحی برای اجتناب از تراکنش‌های بین‌Shard: بهترین راه‌حل همین است. اگر کاربران را با user_id Shard می‌کنید، همه‌ی داده‌ی یک کاربر را روی Shard او نگه دارید. مانده‌ی حساب، تاریخچه‌ی تراکنش، اطلاعات پروفایل، همه روی یک Shard. حالا همه‌ی تراکنش‌های شما تک‌Shard هستند که سریع و قابل‌اعتمادند.
  • استفاده از Saga برای عملیات چند‌Shard: وقتی مطلقاً باید بین Shardها هماهنگ کنید، از الگوی Saga استفاده کنید. عملیات را به دنباله‌ای از مراحل مستقل بشکنید که هرکدام یک اقدام جبرانی (Compensating Action) دارند. اگر مرحله‌ی ۳ شکست بخورد، اقدام‌های جبرانی مراحل ۲ و ۱ را برای خنثی کردن کار اجرا می‌کنید. این Eventual Consistency می‌دهد، بدون شکنندگی 2PC. مثلاً انتقال پول بین کاربران روی Shardهای مختلف:
    1. از حساب کاربر A پول کم کن (Shard 1).
    2. به حساب کاربر B پول اضافه کن (Shard 2).
    3. اگر مرحله‌ی ۲ شکست بخورد، به کاربر A پول را برگردان (اقدام جبرانی).
  • پذیرش Eventual Consistency: برای بسیاری از عملیات، Consistency سخت‌گیرانه لازم نیست. اگر تعداد فالوورهای کاربر را به‌روز می‌کنید و این عدد برای جستجوی سریع پروفایل بین چند Shard Denormalize شده، اشکالی ندارد که بعضی Shardها چند ثانیه عدد متفاوتی نشان دهند. سرانجام همه‌ی Shardها به عدد درست همگرا می‌شوند. این خیلی ساده‌تر از هماهنگی یک Distributed Transaction است و برای بیشتر اپلیکیشن‌ها، دوره‌ی کوتاه ناسازگاری قابل‌قبول است.

خلاصه اینکه بیشتر اپلیکیشن‌ها را می‌توان طوری طراحی کرد که از تراکنش‌های بین‌Shard کاملاً دوری کنند. اگر خودتان را مدام در نیاز به Distributed Transaction می‌یابید، احتمالاً Shard Key یا مرز Shardها را اشتباه انتخاب کرده‌اید.

Sharding در پایگاه‌های داده‌ی مدرن

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

پایگاه‌های داده‌ی NoSQL رایج مثل Cassandra، DynamoDB و MongoDB همه اجازه می‌دهند Partition Key مشخص کنید و بقیه را خودشان انجام می‌دهند، ولی همه از یک مکانیزم توزیع استفاده نمی‌کنند:

  • Cassandra: از Partitioner (مثل Murmur3Partitioner) با Virtual Node استفاده می‌کند، که شکلی از Consistent Hashing برای نگاشت Partition Keyها به بازه‌های Token روی Nodeهاست.
  • DynamoDB: Partition Key را Hash می‌کند تا آیتم‌ها را به Partitionهای داخلی مسیریابی کند و با رشد Partitionها آن‌ها را تقسیم یا ادغام می‌کند؛ این Consistent Hashing کلاسیک مبتنی بر حلقه که به کاربر نشان داده شود نیست.
  • MongoDB: داده را به Chunkهای مبتنی بر بازه روی Shard Key تقسیم می‌کند. اگر Hashed Shard Key انتخاب کنید، بازه‌ها روی فضای Hash هستند. یک Balancer پس‌زمینه خودکار Chunkها را تقسیم و جابه‌جا می‌کند تا Shardها متوازن بمانند. این Consistent Hashing کلاسیک نیست.

آن‌ها هنگام اضافه شدن ظرفیت خودکار دوباره متعادل می‌کنند و Queryها را به Shardهای درست مسیریابی می‌کنند، ولی مکانیزم‌ها فرق دارند.

یک نکته درباره‌ی دقت: صفحه‌ی Consistent Hashing در همین منبع می‌گوید DynamoDB «زیر کاپوت» از Consistent Hashing برای جایگذاری Partition استفاده می‌کند [web:14]، در حالی که متن بالا آن را Consistent Hashing کلاسیک نمی‌داند. این تناقض ظاهری از نظر عملی مهم نیست، چون همان صفحه هم تأکید می‌کند عبارت «Consistent Hashing» در عمل شل به کار می‌رود و اصل مهم کم کردن جابه‌جایی داده هنگام متعادل‌سازی است [web:14]. همان صفحه یادآوری می‌کند Redis Cluster مثلاً از رویکرد Hash Slot ثابت (۱۶٬۳۸۴ Slot) استفاده می‌کند، نه حلقه‌ی Consistent Hashing [web:14].

پایگاه‌های داده‌ی SQL هم بالغ شده‌اند و Sharding را از قبل ساده‌تر کرده‌اند. Vitess و Citus لایه‌های Sharding متن‌باز محبوبی هستند که جلوی PostgreSQL یا MySQL قرار می‌گیرند. آن‌ها مسیریابی Query، عملیات بین‌Shard و Resharding را بدون اینکه خودتان بسازید مدیریت می‌کنند. ارائه‌دهندگان ابری مثل AWS Aurora و Google Cloud Spanner، SQL توزیع‌شده با Sharding داخلی ارائه می‌دهند.

در مصاحبه کافی است بگویید «از DynamoDB با user_id به‌عنوان Partition Key استفاده می‌کنیم» یا «با Vitess روی user_id Shard می‌کنیم و برای Resharding آنلاین مدیریت‌شده توسط اپراتور برنامه می‌ریزیم». مگر اینکه مشخصاً از شما بخواهند، لازم نیست جزئیات داخلی Sharding را پیاده کنید.

Sharding در مصاحبه‌های طراحی سیستم

خوب، همه‌ی این‌ها عالی، ولی در مصاحبه واقعاً چه بگوییم و چه بکنیم؟

Sharding تقریباً هر وقت درباره‌ی مقیاس‌دهی صحبت می‌کنید مطرح می‌شود. کلید کار دانستن این است که کی مطرحش کنید، چه بگویید و از چه اشتباهاتی دوری کنید.

کی از Sharding بگوییم؟

مراقب باشید اشتباه Sharding زودهنگام نکنید. اول باید ثابت کنید چرا یک پایگاه داده جواب نمی‌دهد.

وقتی در بحث برنامه‌ریزی ظرفیت به یکی از این سقف‌ها رسیدید، Sharding را مطرح کنید:

  • ذخیره‌سازی: «۵۰۰ میلیون کاربر داریم که هرکدام ۵ کیلوبایت داده دارند، یعنی ۲٫۵ ترابایت. یک Instance از Postgres این را تحمل می‌کند، ولی اگر ۱۰ برابر رشد کنیم باید Shard کنیم.»
  • Throughput نوشتن: «در اوج ۵۰ هزار نوشتن در ثانیه انتظار داریم. یک پایگاه داده با این بار نوشتن مشکل پیدا می‌کند، پس باید Shard کنیم.»
  • Throughput خواندن: «حتی با Read Replica، اگر ۱۰۰ میلیون کاربر فعال روزانه هرکدام چند Query بزنند، باید بار خواندن را بین Shardها توزیع کنیم.»

فرمول ساده است:

  1. گلوگاه را شناسایی کنید.
  2. توضیح دهید چرا یک پایگاه داده مقیاس نمی‌گیرد.
  3. Sharding را پیشنهاد دهید.

برای تخمین اینکه کی به سقف‌های معقول یک پایگاه داده می‌رسید می‌توانید از «اعداد مهمی که باید بدانید» (Numbers to Know) استفاده کنید.

با اختلاف، اشتباه شماره‌ی یک Sharding در مصاحبه‌ها این است که نامزدها پیش از اثبات ضرورت، Sharding را معرفی می‌کنند. آرام باشید، محاسبه کنید و پیش از توضیح اینکه چطور Shard می‌کنید، مطمئن شوید واقعاً لازم است.

چه بگوییم؟

نحوه‌ی پیش بردن Sharding در مصاحبه با مثال اپ شبکه‌ی اجتماعی:

۱) Shard Key را بر اساس الگوهای دسترسی پیشنهاد دهید. «برای این اپ شبکه‌ی اجتماعی، بیشتر Queryها کاربرمحورند. وقتی کسی Feed خودش را بارگذاری می‌کند، پست‌ها، فالوورها و لایک‌هایش را Query می‌کنیم. همه به یک کاربر محدود است. پس بر اساس user_id Shard می‌کنم.»

۲) استراتژی توزیع را انتخاب کنید. «از Hash-Based Sharding با Consistent Hashing استفاده می‌کنم. user_id را Hash می‌کنم تا کاربران یکنواخت بین Shardها پخش شوند.»

۳) Trade-offها را بگویید. «Trade-off این است که Queryهای سراسری گران می‌شوند. اگر به «پست‌های ترند بین همه‌ی کاربران» نیاز داشته باشیم، باید همه‌ی Shardها را Query و نتایج را تجمیع کنیم. می‌توانیم با کش کردن محتوای ترند و از پیش محاسبه کردن آن با Background Job، به‌جای محاسبه در هر درخواست، این را مدیریت کنیم.»

۴) نحوه‌ی مدیریت رشد را توضیح دهید. «با ۶۴ Shard شروع می‌کنیم که جا برای رشد می‌دهد. Consistent Hashing افزودن Shard را بعداً بدون Reshard کردن همه‌ی داده ساده‌تر می‌کند. اگر ظرفیت بیشتری بخواهیم، Shard اضافه می‌کنیم و فقط کسری از داده جابه‌جا می‌شود.»

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

نتیجه‌گیری

Sharding کاری است که وقتی یک پایگاه داده دیگر نمی‌تواند مقیاس شما را تحمل کند انجام می‌دهید. داده را بین چند ماشین تقسیم می‌کنید تا ظرفیت ذخیره‌سازی و Throughput بالا برود.

دو تصمیم اصلی اهمیت دارند: Shard Key‌ای انتخاب کنید که با الگوهای Query شما هم‌راستا باشد، و استراتژی توزیعی را که بار را یکنواخت پخش می‌کند. این‌ها را اشتباه بگیرید، Hot Spot و Queryهای بین‌Shard گران خواهید داشت.

در مصاحبه، وقتی گلوگاه پایگاه داده را شناسایی کردید Sharding را مطرح کنید. انتخاب Shard Key را توضیح دهید، Trade-offها را بگویید و نشان دهید به Queryهای بین‌Shard و Resharding فکر کرده‌اید. از همه مهم‌تر، زود Shard نکنید. یک پایگاه داده‌ی خوب تنظیم‌شده می‌تواند شما را به‌طرز شگفت‌آوری جلو ببرد.