Skip to content

فصل ۹ — ذخیره‌سازی شیء مشابه S3 (S3-like Object Storage)

خلاصهٔ تحلیلی — این متن چکیدهٔ مفاهیم فصل به زبان فارسی است، نه ترجمهٔ واژه‌به‌واژه.

مبانی سیستم‌های ذخیره‌سازی

  • Block storage: بلاک‌های خام به‌عنوان volume (HDD/SSD متصل یا شبکه‌ای با iSCSI/FC)؛ منعطف‌ترین و سریع‌ترین؛ مناسب دیتابیس و VM.
  • File storage: روی block ساخته می‌شود؛ ساختار سلسله‌مراتبی فایل/پوشه با NFS/SMB؛ همه‌منظوره.
  • Object storage: مبادلهٔ عمدی کارایی با دوام بالا، مقیاس عظیم و هزینهٔ کم؛ ساختار مسطح (بدون دایرکتوری)، دسترسی REST، سازگاری نهایی/قوی بسته به سرویس؛ مناسب دادهٔ «سرد»، آرشیو و backup.

اصطلاحات: bucket (ظرف منطقی با نام سراسری یکتا)، object (payload + متادیتا؛ تغییرناپذیر — فقط حذف یا جایگزینی کامل)، versioning، URI و SLA (مثلاً دوام یازده-نه در S3).

گام ۱ — نیازمندی‌ها

قابلیت‌ها: ساخت bucket، آپلود/دانلود شیء، versioning، فهرست کردن اشیاء (مانند aws s3 ls). مقیاس: ۱۰۰ پتابایت در سال، دوام ۶ نه، دسترس‌پذیری ۴ نه، بهره‌وری هزینهٔ storage. برآورد: با توزیع ۲۰٪ کوچک/۶۰٪ متوسط/۲۰٪ بزرگ و اشغال ۴۰٪ ≈ ~۰٫۶۸ میلیارد شیء؛ متادیتا (~1KB هر شیء) ≈ ~۰٫۶۸ ترابایت.

گام ۲ — طراحی سطح بالا

بینش محوری: object storage مثل فایل‌سیستم UNIX است — inode = metadata store، دیسک = data store. جداسازی متادیتا (تغییرپذیر) از دادهٔ شیء (تغییرناپذیر) امکان بهینه‌سازی مستقل هر دو را می‌دهد. الگوی دسترسی: write once, read many (~۹۵٪ خواندن طبق LinkedIn).

اجزا: load balancer ← API service (stateless، هماهنگ‌کننده) ← IAM (احراز هویت/مجوز)، metadata store و data store.

  • آپلود: ساخت bucket (درج متادیتا) ← PUT /bucket/object؛ داده به data store می‌رود، UUID برمی‌گردد و ردیف متادیتا (object_id، bucket_id، نام) درج می‌شود.
  • دانلود: نام شیء ← UUID از metadata store ← واکشی داده از data store با UUID.

گام ۳ — بررسی عمیق

معماری data store

سه جزء: data routing service (stateless؛ خواندن/نوشتن به نودها)، placement service (نگهدارندهٔ نقشهٔ مجازی کلاستر با توپولوژی فیزیکی برای جداسازی replica ها؛ پایش heartbeat نودها با مهلت ~۱۵ ثانیه؛ کلاستر ۵ یا ۷ نوده با Paxos/Raft) و data node ها (نگهداری داده با replication group).

جریان ذخیره: routing برای شیء UUID می‌سازد ← placement نود primary را برمی‌گرداند (جست‌وجوی قطعی با consistent hashing) ← داده به primary ← replication به دو secondary ← پاسخ پس از تکمیل. انتخاب نقطهٔ پاسخ‌دهی مبادلهٔ سازگاری-تأخیر است: پاسخ بعد از هر ۳ نود (سازگاری قوی، کندترین)، بعد از ۲، یا فقط primary (سریع‌ترین؛ سازگاری نهایی).

سازمان‌دهی داده روی نود

فایل جدا برای هر شیء با میلیون‌ها شیء کوچک شکست می‌خورد: اتلاف بلاک‌های 4KB و ته‌کشیدن inode ها. راه‌حل: ادغام اشیاء کوچک در فایل‌های بزرگ (شبیه WAL) — append به فایل read-write جاری؛ با رسیدن به آستانه (چند GB) فایل read-only می‌شود. نوشتن باید سریال باشد ← یک فایل read-write اختصاصی به ازای هر core.

جست‌وجوی شیء: جدول object_mapping با (object_id، file_name، start_offset، object_size). چون read-heavy است، B+ tree بر LSM (RocksDB) ترجیح دارد و چون نگاشت هر نود محلی است، یک SQLite روی هر data node کافی است.

دوام (durability)

  • Replication سه‌گانه: با نرخ خرابی سالانهٔ ~۰٫۸۱٪ دیسک ≈ ۶ نه دوام. توزیع replica ها در failure domain های جدا (rack، AZ) برای رخدادهای بزرگ (قطع برق، بلایا).
  • Erasure coding: داده به قطعات مساوی + parity های محاسباتی شکسته می‌شود (مثلاً 8+4: تحمل ۴ نود خراب، بازسازی ریاضی داده). مقایسه: دوام ~۱۱ نه و سربار storage فقط ۵۰٪ (در برابر ۲۰۰٪ replication) اما هزینهٔ CPU، تأخیر نوشتن (محاسبهٔ parity) و خواندن کندتر (به‌ویژه در خرابی که بازسازی لازم است). جمع‌بندی: replication برای کاربردهای حساس به تأخیر، erasure coding برای کمینه‌سازی هزینه؛ این طراحی روی replication می‌ماند.
  • صحت داده: خرابی خاموش حافظه/دیسک واقعی است ← checksum (مثل MD5) در انتهای هر شیء و هر فایل؛ در خواندن مقایسه می‌شود و در صورت عدم تطابق از failure domain دیگر بازیابی می‌شود.

متادیتا: اسکیما و شاردینگ

دو جدول: bucket (کوچک؛ ~۱۰GB برای ۱M مشتری؛ فقط read replica لازم دارد) و object. شاردینگ object: با bucket_id ← hotspot (یک bucket با میلیاردها شیء)؛ با object_id ← کوئری‌های URI-محور ناکارآمد؛ انتخاب درست: هش (bucket_name, object_name) چون اغلب عملیات بر اساس URI است.

فهرست کردن (listing): ساختار مسطح است و «پوشه» فقط prefix است. در تک-دیتابیس یک LIKE 'prefix%' کافی است؛ در دیتابیس شارد شده باید همهٔ شاردها کوئری و تجمیع شوند و صفحه‌بندی دردناک می‌شود (offset جدا برای هر شارد در cursor). راه‌حل عمل‌گرایانه: چون کارایی listing در object storage اولویت نیست، جدول listing جداگانه‌ای denormalize و بر اساس bucket_id شارد می‌شود تا کوئری تک-دیتابیسی شود.

Versioning

با versioning فعال، آپلود دوباره به‌جای بازنویسی، ردیف جدیدی با همان bucket_id/object_name اما object_id جدید و object_version از نوع TIMEUUID درج می‌کند؛ نسخهٔ جاری بزرگ‌ترین TIMEUUID است. حذف فقط یک delete marker به‌عنوان نسخهٔ جاری درج می‌کند؛ GET پس از آن 404 می‌دهد اما نسخه‌های قبلی سالم می‌مانند.

آپلود چندبخشی (multipart upload)

برای فایل‌های بزرگ: initiation ← دریافت uploadID ← آپلود مستقل قطعات (مثلاً 8×200MB) که هر یک ETag (چک‌سام md5) می‌گیرد ← درخواست completion با uploadID + شماره قطعات + ETag ها ← مونتاژ در سمت سرور. قطعات قدیمی پس از مونتاژ زباله‌اند.

Garbage collection

منابع زباله: حذف تنبل (فقط علامت‌گذاری)، دادهٔ یتیم (آپلودهای نیمه‌کاره) و دادهٔ خراب (رد چک‌سام). GC با compaction دوره‌ای کار می‌کند: اشیاء زنده از چند فایل read-only به فایل بزرگ جدیدی کپی و object_mapping در یک تراکنش به‌روزرسانی می‌شود؛ در replication از همهٔ نسخه‌ها و در erasure coding از هر ۱۲ نود حذف می‌شود.

نکات کلیدی فصل

  • جداسازی متادیتا از داده (الگوی inode) ستون فقرات طراحی object storage است.
  • «فایل کوچک زیاد» دشمن فایل‌سیستم است؛ append به فایل‌های بزرگ + جدول نگاشت راه‌حل استاندارد است.
  • دوام از سه چیز می‌آید: تکثیر بین failure domain ها، ریاضیِ erasure coding، و checksum برای خرابی خاموش.
  • کلید شاردینگ باید از الگوی کوئری (URI-محور) بیاید؛ برای کوئری ناسازگار (listing) جدول denormalize جدا بسازید.
  • تغییرناپذیری اشیاء versioning، GC و کش را ساده می‌کند.

یادداشت‌های مترجم

  • bucket، prefix، erasure coding، parity و multipart upload لاتین حفظ شده‌اند.