پست

Designing Data Intensive Applications

طراحی سیستم داده محور

Designing Data Intensive Applications

توضیحات

یکی از مهم‌ترین کتاب‌های مهندسی نرم‌افزار دهه اخیر نوشته مارتین کلپمن که در سال ۲۰۱۷ توسط O’Reilly منتشر شد. کلپمن که سابقه ساخت سیستم‌های توزیع‌شده بزرگ در LinkedIn را دارد، اصول بنیادین طراحی سیستم‌هایی را توضیح می‌دهد که در آن‌ها داده - نه CPU - گلوگاه اصلی است. کتاب سه ستون اصلی معماری سیستم‌های مدرن را پوشش می‌دهد: Reliability (قابل‌اعتماد بودن در برابر خرابی)، Scalability (مقیاس‌پذیری زیر بار)، و Maintainability (قابلیت نگهداری در طول زمان).

نظر

  • امتیاز : 08/10
  • به دیگران توصیه می‌کنم : بله
  • دوباره می‌خوانم : بله
  • ایده برجسته : مثلث CAP Theorem و مسئله Consistency در سیستم‌های توزیع‌شده - وقتی چند نسخه از داده روی سرورهای مختلف داریم و دو write همزمان اتفاق می‌افتد، کدام برنده است؟ کلپمن نشان می‌دهد که هیچ راه‌حل کاملی وجود ندارد؛ فقط trade-off‌های مختلفی میان Consistency، Availability و Partition Tolerance وجود دارند. این نگاه به trade-off به‌جای جستجوی راه‌حل کامل، اساس تفکر معماری سیستم‌های بزرگ است.
  • تاثیر در من : رویکردم به انتخاب دیتابیس و معماری سیستم کاملاً تغییر کرد؛ قبل از این کتاب «چه ابزاری استفاده کنیم» می‌پرسیدم، بعد از آن «چه trade-off‌هایی قبول می‌کنیم» می‌پرسم. در پروژه‌های .NET Core هم الان هر بار که بین SQL Server و یک NoSQL انتخاب می‌کنم، با دید متفاوتی تصمیم می‌گیرم.
  • نکات مثبت : پوشش بی‌نظیر از پایه‌های نظری تا پیاده‌سازی عملی با مثال‌های واقعی از ابزارهای رایج (Kafka، PostgreSQL، Cassandra، Redis)؛ ساختار فصل‌بندی شده‌ای که هر فصل مستقل است؛ مرجع اصلی مصاحبه‌های System Design در شرکت‌های بزرگ فناوری
  • نکات منفی : روی مفاهیم اولیه Big Data مثل Hadoop/HDFS تمرکز دارد و ابزارهای مدرن‌تر مثل Databricks و Snowflake را پوشش نمی‌دهد؛ عنوان «طراحی» را یدک می‌کشد ولی Pattern های طراحی عملی کم است و بیشتر توضیح مفاهیم است؛ برای کسی که با دیتابیس آشنایی اولیه ندارد مناسب نیست

مشخصات

  • نویسنده : مارتین کلپمن (Martin Kleppmann)
  • انتشارات : oreilly
  • صفحه مشخصات :

بخش‌هایی از کتاب

بخش اول: چرا این کتاب؟ - مقدمه (Preface)

زمینه و انگیزه نوشتن کتاب

مارتین کلپمان این کتاب را برای مهندسانی نوشته که در دنیای واقعی با سیستم‌های پیچیده داده‌محور دست‌وپنجه نرم می‌کنند. فضای مهندسی نرم‌افزار طی یک دهه گذشته با انبوهی از اصطلاحات پرهیاهو بمباران شده است: NoSQL، Big Data، Sharding، Eventual Consistency، CAP theorem، MapReduce - اما درک عمیق این مفاهیم، فراتر از شعار، همان چیزی است که این کتاب به دنبال آن است.

نویسنده چند نیروی محرک اصلی را که این تحولات را شکل داده‌اند شناسایی می‌کند:

  • شرکت‌های اینترنتی بزرگ (Google، Amazon، Facebook، Twitter) با حجم داده‌ای روبه‌رو شدند که ابزارهای موجود را ناکافی کرد و آن‌ها را مجبور به ابداع ابزارهای جدید نمود.
  • کسب‌وکارها به Agility نیاز دارند - چرخه‌های کوتاه توسعه و مدل‌های داده انعطاف‌پذیر.
  • نرم‌افزارهای Open Source جایگزین محصولات تجاری گران‌قیمت شده‌اند.
  • سرعت CPU دیگر رشد چندانی ندارد؛ اما پردازنده‌های Multi-core و شبکه‌های سریع‌تر به معنای افزایش همزمانی (Parallelism) هستند.
  • Infrastructure as a Service (مثل AWS) حتی تیم‌های کوچک را قادر ساخته سیستم‌های توزیع‌شده در چندین Region جغرافیایی بسازند.
  • High Availability دیگر یک لوکس نیست؛ Downtime غیرقابل‌قبول شده است.

تعریف دقیق «برنامه Data-Intensive»

یکی از مهم‌ترین تمایزاتی که کلپمان از همان ابتدا مطرح می‌کند، تفاوت بین دو نوع برنامه است:

نوع برنامهگلوگاه اصلی
Compute-Intensiveقدرت پردازشی CPU
Data-Intensiveحجم داده، پیچیدگی داده، یا سرعت تغییر داده

این کتاب انحصاراً درباره نوع دوم است. اگر بزرگ‌ترین چالش برنامه شما «داده» است - نه محاسبات ریاضی سنگین - این کتاب برای شماست.

هدف کتاب: اصول پایدار، نه ابزارهای لحظه‌ای

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

بخش دوم: معرفی فصل اول - سه ستون اصلی سیستم‌های داده

نقطه شروع فصل ۱: اپلیکیشن‌های Data-Intensive

فصل اول با یک مشاهده ساده اما عمیق شروع می‌شود: اکثر اپلیکیشن‌های مدرن از «بلوک‌های سازنده استاندارد» (Standard Building Blocks) ساخته شده‌اند:

  • Database: ذخیره داده برای بازیابی بعدی
  • Cache: به‌خاطر سپردن نتیجه عملیات گران‌قیمت برای سرعت بخشیدن به Read
  • Search Index: جستجو بر اساس Keyword یا فیلتر کردن داده
  • Stream Processing: ارسال پیام به یک Process دیگر برای پردازش Asynchronous
  • Batch Processing: پردازش دوره‌ای حجم بزرگی از داده‌های انباشته

اما واقعیت پیچیده‌تر از فهرست بالاست. چون پیاده‌سازی‌های گوناگون این بلوک‌ها با هم ترکیب می‌شوند، شما در واقع یک Data System سفارشی می‌سازید - و در این نقطه، دیگر صرفاً Application Developer نیستید؛ بلکه Data System Designer هم هستید.

سه نگرانی محوری

کلپمان در این فصل روی سه مفهوم کلیدی تمرکز می‌کند که در اغلب سیستم‌های نرم‌افزاری اهمیت دارند:

۱. Reliability (قابلیت اطمینان) سیستم باید حتی در شرایط نامساعد - خرابی سخت‌افزار، باگ نرم‌افزار، خطای انسانی - به‌درستی کار کند. نکته ظریف اینجاست که نویسنده بین Fault و Failure تمایز می‌گذارد: Fault یعنی یک Component از Spec خود منحرف شود؛ Failure یعنی کل سیستم از ارائه سرویس باز بماند. هدف طراحی، جلوگیری از تبدیل Fault به Failure است.

۲. Scalability (مقیاس‌پذیری) Scalability به معنای توانایی سیستم در مدیریت رشد بار است - اما کلپمان تأکید می‌کند که این یک Label یک‌بعدی نیست. بهتر است بپرسید: «اگر بار به شکل مشخصی رشد کند، گزینه‌های ما چیست؟»

۳. Maintainability (نگهداری‌پذیری) اغلب بیشترین هزینه نرم‌افزار نه در توسعه اولیه، بلکه در نگهداری مستمر آن است. این شامل سه اصل فرعی است: Operability (آسان‌سازی کار تیم عملیات)، Simplicity (حذف پیچیدگی تصادفی) و Evolvability (سهولت اعمال تغییرات در آینده).


Reliability - قابلیت اطمینان

تعریف دقیق Reliability

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

  • اپلیکیشن آنچه کاربر انتظار دارد را انجام می‌دهد.
  • سیستم در برابر خطاهای کاربر یا استفاده غیرمنتظره از نرم‌افزار مقاوم است.
  • Performance در شرایط بار و حجم داده موردانتظار کافی است.
  • سیستم از دسترسی غیرمجاز و سوءاستفاده جلوگیری می‌کند.

تمایز کلیدی: Fault در برابر Failure

یکی از دقیق‌ترین نکات این فصل، تمایز مفهومی بین Fault و Failure است:

مفهومتعریف
Faultیک Component از Spec خود منحرف می‌شود
Failureکل سیستم از ارائه سرویس به کاربر باز می‌ماند

هدف طراحی این است که Fault به Failure تبدیل نشود. از آنجا که کاهش احتمال Fault به صفر غیرممکن است، باید مکانیزم‌های Fault-Tolerance طراحی کنیم که Fault را جذب کنند.

یک نکته ضدشهودی جالب: در سیستم‌های Fault-Tolerant، گاهی افزایش عمدی نرخ Fault منطقی است. نمونه واقعی آن Netflix Chaos Monkey است - ابزاری که به‌صورت تصادفی Processها را بدون اطلاع قبلی می‌کشد تا مکانیزم‌های Fault-Tolerance همواره تمرین شده و آزموده باقی بمانند.

سه منبع اصلی Fault

۱. Hardware Faults

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

یک آمار ملموس از کتاب: هارددیسک‌ها به‌طور میانگین MTTF (Mean Time to Failure) بین ۱۰ تا ۵۰ سال دارند. اما در یک Cluster ذخیره‌سازی با ۱۰,۰۰۰ دیسک، به‌طور میانگین روزی یک دیسک خراب می‌شود.

راه‌حل سنتی، اضافه کردن Redundancy است:

  • RAID برای دیسک‌ها
  • منابع تغذیه دوگانه (Dual Power Supply) برای سرورها
  • باتری و ژنراتور دیزلی برای Datacenter

اما با افزایش مقیاس، این رویکرد کافی نیست. پلتفرم‌هایی مثل AWS طراحی شده‌اند که یک VM می‌تواند بدون اطلاع قبلی از دسترس خارج شود. به همین دلیل صنعت به سمت Software Fault-Tolerance حرکت کرده - یعنی سیستم‌هایی که از دست دادن کل یک Machine را تحمل می‌کنند. مزیت عملیاتی این رویکرد هم قابل‌توجه است: می‌توانید سیستم را Rolling Upgrade کنید - یک Node در یک زمان - بدون هیچ Downtime.

۲. Software Errors

خطاهای سیستماتیک نرم‌افزاری از Hardware Faults خطرناک‌ترند، چون Correlated هستند - یعنی ممکن است همزمان روی تمام Node‌ها اثر بگذارند. نمونه‌های واقعی این دسته:

  • یک باگ که باعث می‌شود هر Instance از Application Server با یک Input خاص Crash کند (مثل باگ Leap Second در ۳۰ ژوئن ۲۰۱۲ در Linux Kernel که بسیاری از سرویس‌ها را به‌طور همزمان Hang کرد).
  • یک Process سرکش که منابع اشتراکی (CPU، Memory، Disk، Bandwidth) را می‌بلعد.
  • یک سرویس Dependency که کُند می‌شود، پاسخ نمی‌دهد، یا Response‌های خراب برمی‌گرداند.
  • Cascading Failures: یک خرابی کوچک در یک Component، خرابی در Component بعدی را Trigger می‌کند، و این زنجیره ادامه می‌یابد.

نکته مهم: این باگ‌ها اغلب مدت‌ها خفته می‌مانند و فقط در یک مجموعه شرایط غیرمعمول فعال می‌شوند. راه‌حل سریعی وجود ندارد، اما مجموعه‌ای از اقدامات کمک می‌کنند: بررسی دقیق Assumption‌ها، تست جامع، Process Isolation، Crash-and-Restart، و Monitoring دقیق رفتار سیستم در Production.

۳. Human Errors

انسان‌ها سیستم‌ها را طراحی و اجرا می‌کنند، و انسان‌ها ذاتاً خطاپذیرند. یک مطالعه روی سرویس‌های اینترنتی بزرگ نشان داد که خطاهای Configuration توسط اپراتورها مهم‌ترین دلیل Outage بوده‌اند، در حالی که خرابی سخت‌افزار تنها در ۱۰ تا ۲۵ درصد از Outageها نقش داشته است.

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

  • طراحی برای کاهش فرصت خطا: APIها و Admin Interfaceهایی که «کار درست» را آسان و «کار اشتباه» را دشوار کنند.
  • جداسازی محل خطا از محل تأثیر: محیط‌های Sandbox غیر Production با داده واقعی، جایی که می‌توان آزمایش کرد بدون اینکه کاربران واقعی تحت‌تأثیر قرار گیرند.
  • تست کامل در تمام لایه‌ها: از Unit Test تا Integration Test و تست دستی.
  • امکان Rollback سریع: بازگردانی سریع تغییرات Config، Deploy تدریجی کد جدید (Canary Release).
  • Monitoring دقیق: معیارهای Performance و نرخ خطا - همان چیزی که در مهندسی سنتی Telemetry نامیده می‌شود.

Scalability - مقیاس‌پذیری

Scalability چیست؟

کلپمان Scalability را توانایی سیستم در مدیریت افزایش بار تعریف می‌کند، اما بلافاصله یک هشدار مهم می‌دهد: Scalability یک Label یک‌بعدی نیست. گفتن «X مقیاس‌پذیر است» یا «Y مقیاس نمی‌دهد» بی‌معناست. سوال درست این است:

«اگر سیستم به شکل مشخصی رشد کند، گزینه‌های ما برای مدیریت آن رشد چیست؟»

توصیف بار: Load Parameters

برای بحث درباره Scalability، ابتدا باید بار فعلی سیستم را با دقت توصیف کنیم. کلپمان این اعداد را Load Parameters می‌نامد. بسته به معماری سیستم، Load Parameter می‌تواند باشد:

  • تعداد Request در ثانیه (Requests per Second) به یک Web Server
  • نسبت Read به Write در یک Database
  • تعداد کاربران همزمان فعال در یک Chat Room
  • نرخ Hit در یک Cache

مثال واقعی: Twitter و چالش Fan-out

کلپمان برای ملموس کردن این مفاهیم، از داده‌های واقعی Twitter در نوامبر ۲۰۱۲ استفاده می‌کند. دو عملیات اصلی Twitter:

عملیاتحجم بار
Post Tweetمیانگین ۴,۶۰۰ req/sec - اوج ۱۲,۰۰۰ req/sec
Home Timeline (خواندن)۳۰۰,۰۰۰ req/sec

مشکل اصلی Twitter نوشتن ۱۲,۰۰۰ توییت در ثانیه نیست - این کار نسبتاً ساده است. چالش واقعی Fan-out است: هر کاربر افراد زیادی را دنبال می‌کند و خودش هم توسط افراد زیادی دنبال می‌شود.

دو رویکرد معماری

رویکرد اول - Pull on Read: توییت جدید مستقیماً در یک Collection جهانی ذخیره می‌شود. وقتی کاربر Home Timeline را باز می‌کند، سیستم همه افرادی که او دنبال می‌کند را پیدا کرده، توییت‌های اخیر آن‌ها را واکشی کرده و مرتب می‌کند. این یک عملیات سنگین Join در زمان Read است.

رویکرد دوم - Push on Write (Fanout Write): هر کاربر یک Mailbox (در واقع یک Cache از Home Timeline) دارد. وقتی کسی توییت می‌کند، سیستم بلافاصله آن توییت را در Mailbox تک‌تک Followerهایش می‌نویسد. خواندن Home Timeline فقط یک Lookup ساده در این Cache است.

Twitter در ابتدا رویکرد اول را داشت، اما به رویکرد دوم مهاجرت کرد، چون بار Read بسیار بیشتر از Write بود و کاربران انتظار پاسخ سریع دارند.

نقطه پیچیدگی: کاربران با Follower زیاد

اما رویکرد Push on Write یک ضعف اساسی دارد: اگر یک کاربر ۳۰ میلیون Follower داشته باشد، یک توییت او باعث می‌شود سیستم ۳۰ میلیون نوشتن را انجام دهد - و اگر این کاربر در ثانیه چند توییت بزند، سیستم زیر فشار می‌رود.

راه‌حل نهایی Twitter ترکیبی از هر دو رویکرد است: برای اکثر کاربران Push on Write اعمال می‌شود، اما برای Celebrity Accounts (کاربران با Follower بسیار زیاد) رویکرد Pull on Read در زمان باز کردن Timeline اجرا می‌شود.

توصیف Performance: دو سوال مهم

پس از توصیف بار، نوبت به Performance می‌رسد. کلپمان دو سوال کلیدی مطرح می‌کند:

۱. اگر Load Parameter را افزایش دهیم و منابع سیستم ثابت بمانند، Performance چه تغییری می‌کند؟ ۲. اگر Load Parameter را افزایش دهیم، چقدر منابع اضافه باید اضافه کنیم تا Performance ثابت بماند؟

Percentile بهتر از Average است

برای سیستم‌های Online، Response Time معیار اصلی است. اما کلپمان به‌صراحت می‌گوید که میانگین (Average) معیار بدی است. به جای آن، باید از Percentile استفاده کنیم:

  • p50: نیمی از Requestها سریع‌تر از این زمان پاسخ می‌گیرند.
  • p95، p99، p999: به‌ترتیب ۵٪، ۱٪ و ۰.۱٪ از Requestها از این مقدار کندتر هستند.

این اعداد بالا را Tail Latencies می‌نامند. چرا اهمیت دارند؟ چون معمولاً کندترین Requestها متعلق به کاربرانی هستند که بیشترین داده دارند - یعنی اغلب مشتریان ارزشمندتر.

آمازون مثال واقعی این رویکرد است: آمازون Response Time Requirementهای داخلی را بر اساس p999 تعریف می‌کند، چون حتی ۰.۱٪ از Requestها هم در مقیاس آمازون میلیون‌ها کاربر را تحت‌تأثیر می‌گذارد.

Scaling Up در برابر Scaling Out

کلپمان دو استراتژی اصلی برای Scalability معرفی می‌کند:

استراتژینام دیگرتوضیح
Vertical ScalingScaling Upارتقاء به ماشین قدرتمندتر
Horizontal ScalingScaling Outتوزیع بار روی ماشین‌های بیشتر

توزیع بار روی چندین ماشین، Shared-Nothing Architecture نامیده می‌شود. در این معماری هر Node به‌صورت مستقل عمل می‌کند و هیچ حافظه یا دیسک مشترکی با Node دیگر ندارد. این معماری انعطاف بیشتری دارد اما پیچیدگی نرم‌افزاری را به‌شدت افزایش می‌دهد.

نکته عملی مهم کلپمان: معماری سیستم‌های با Scalability بالا معمولاً purpose-built است - یعنی بر اساس Load Parameters خاص آن سیستم طراحی شده. معماری که برای ۱۰۰,۰۰۰ req/sec با payload کوچک مناسب است، لزوماً برای ۱,۰۰۰ req/sec با payload بسیار بزرگ بهینه نیست.


Maintainability - نگهداری‌پذیری

چرا Maintainability از همه چیز مهم‌تر است؟

کلپمان با یک واقعیت تلخ شروع می‌کند: بیشترین هزینه نرم‌افزار نه در توسعه اولیه، بلکه در نگهداری مستمر آن است - رفع باگ، تطبیق با پلتفرم‌های جدید، اضافه کردن Feature جدید، و پرداخت بدهی فنی (Technical Debt). بسیاری از مهندسان از کار روی سیستم‌های Legacy بیزار هستند، و کلپمان می‌گوید این بیزاری اغلب به خاطر طراحی اولیه‌ای است که Maintainability را نادیده گرفته است.

هدف این است که از همان ابتدا طوری طراحی کنیم که تیم‌های آینده بتوانند با آن سیستم به‌راحتی و بهره‌وری کار کنند.

سه اصل محوری Maintainability

کلپمان Maintainability را به سه اصل فرعی تقسیم می‌کند:

اصل اول - Operability: آسان کردن کار تیم عملیات

تیم‌های Operations آن‌هایی هستند که سیستم را زنده نگه می‌دارند. یک سیستم با Operability خوب باید کارهای زیر را ساده کند:

  • Visibility: سیستم باید رفتار Runtime خود را شفاف نشان دهد - از طریق Monitoring، Logging و Distributed Tracing کافی.
  • Automation Support: پشتیبانی از Automation و Integration با ابزارهای استاندارد.
  • Avoidance of Vendor Lock-in: از وابستگی به پلتفرم یا ابزار خاصی که تغییر آن دشوار است خودداری شود.
  • Self-Healing با کنترل دستی: سیستم باید بتواند به‌طور خودکار بازیابی کند، اما در صورت نیاز، اپراتور باید بتواند دستی مداخله کند.
  • Predictable Behavior: رفتار پیش‌بینی‌پذیر که Surprise کمتری برای تیم Operation ایجاد کند.
  • مستندات خوب: مدل عملیاتی مشخص - «اگر X کردیم، Y اتفاق می‌افتد».

نکته کلیدی: Good Operations می‌تواند محدودیت‌های نرم‌افزار بد را جبران کند، اما نرم‌افزار خوب بدون Operations خوب به جایی نمی‌رسد.

اصل دوم - Simplicity: مدیریت پیچیدگی

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

کلپمان تمایز مهمی بین دو نوع پیچیدگی قائل می‌شود:

نوع پیچیدگیتوضیح
Complexity ذاتی (Essential)پیچیدگی‌ای که از ماهیت مسئله نشأت می‌گیرد و قابل حذف نیست
Complexity تصادفی (Accidental)پیچیدگی‌ای که توسط پیاده‌سازی ایجاد شده و می‌توان آن را حذف کرد

نشانه‌های پیچیدگی تصادفی عبارتند از:

  • وجود State انفجاری (Explosion of State) - جاهایی که ردیابی وضعیت سیستم دشوار است.
  • وابستگی‌های محکم (Tight Coupling) بین Module‌ها.
  • وجود Workaround برای محدودیت‌های ابزارها.
  • نامگذاری ناسازگار و اصطلاحات متناقض در بخش‌های مختلف Codebase.

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

اصل سوم - Evolvability: آسان کردن تغییر

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

کلپمان Evolvability را به نام‌های دیگری هم می‌شناسد: Extensibility، Modifiability یا Plasticity. این اصل در سطح کد معادل اصل Open/Closed در SOLID است - سیستم باید برای تغییر «باز» باشد بدون اینکه بخش‌های موجود و کارا «شکسته» شوند.

جمع‌بندی فصل اول: سه هدف به‌هم‌تنیده

کلپمان فصل را با این نکته تمام می‌کند که Reliability، Scalability و Maintainability اهدافی نیستند که بتوان آن‌ها را یک‌بار رسید و فراموش کرد - بلکه باید مداوم در هر تصمیم طراحی در نظر گرفته شوند.

1
2
3
Reliability   → سیستم حتی زمان خرابی درست کار کند
Scalability   → سیستم با رشد بار کنار بیاید
Maintainability → تیم‌های آینده بتوانند با آن کار کنند

فصل دوم: Data Models و Query Languages

چرا Data Model اهمیت دارد؟

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

هر لایه از یک سیستم، مدلی از لایه زیرین خود می‌سازد:

  • توسعه‌دهنده دنیای واقعی را به Object یا Struct تبدیل می‌کند.
  • آن Object‌ها را در قالب JSON، XML، جدول Relational یا گراف ذخیره می‌کند.
  • مهندسان Database آن فرمت را به بایت روی دیسک یا حافظه تبدیل می‌کنند.
  • مهندسان سخت‌افزار آن بایت‌ها را با جریان الکتریکی و میدان مغناطیسی نمایش می‌دهند.

هر لایه با ارائه یک Abstraction تمیز، پیچیدگی لایه زیر را پنهان می‌کند.

مدل Relational در برابر مدل Document

ریشه‌های مدل Relational

مدل Relational توسط Edgar Codd در سال ۱۹۷۰ پیشنهاد شد. ایده اصلی ساده بود: داده را در قالب Relation (همان Table) ذخیره کن - مجموعه‌ای از Tuple‌ها (Row‌ها) بدون هیچ ترتیب خاصی. برای دهه‌ها، این مدل بر دنیای ذخیره‌سازی داده حاکم بود.

ظهور NoSQL: انگیزه‌ها

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

  • نیاز به Scalability بیشتر از آنچه پایگاه‌داده‌های Relational می‌توانستند ارائه دهند - به‌ویژه برای Dataset‌های بسیار بزرگ یا Write Throughput بسیار بالا.
  • ترجیح به Open Source به‌جای محصولات تجاری گران‌قیمت.
  • نیاز به Query‌های تخصصی که در مدل Relational پشتیبانی نمی‌شدند.
  • ناامیدی از محدودیت‌های Schema در Relational Database - نیاز به مدل داده انعطاف‌پذیرتر.

نکته مهم: کلپمان می‌گوید احتمالاً در آینده، پایگاه‌داده‌های Relational و NoSQL در کنار هم استفاده خواهند شد - این پدیده را Polyglot Persistence می‌نامند.

مشکل Object-Relational Mismatch

یکی از جذاب‌ترین بحث‌های این فصل، مفهوم Impedance Mismatch است. اکثر برنامه‌های مدرن با زبان‌های شی‌گرا نوشته می‌شوند، اما داده را در جداول Relational ذخیره می‌کنند. این تفاوت در مدل باعث ایجاد یک لایه اصطکاک می‌شود که باید با ORM (مثل Hibernate یا Entity Framework) مدیریت شود.

مثال ملموس: رزومه کاری

کلپمان برای توضیح این مشکل از رزومه شغلی مثال می‌زند. یک رزومه شامل اطلاعاتی است که One-to-Many هستند:

  • یک نفر - چند شغل قبلی (Work Experience)
  • یک نفر - چند مدرک تحصیلی (Education)
  • یک نفر - چند مهارت (Skills)

در مدل Relational، این داده‌ها به جداول جداگانه با Foreign Key تقسیم می‌شوند که برای نمایش باید با Join ترکیب شوند.

در مدل Document (مثل MongoDB)، تمام این داده‌ها در یک Document JSON کنار هم قرار می‌گیرند:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
  "user_id": 251,
  "first_name": "Mohammad Hossein",
  "last_name": "Karami",
  "positions": [
    {
      "job_title": "Senior .NET Developer",
      "organization": "Tech Company"
    }
  ],
  "education": [
    {
      "school_name": "University of Tehran",
      "degree": "Computer Science"
    }
  ],
  "skills": ["C#", ".NET Core", "SQL Server", "Angular"]
}

این رویکرد Locality بهتری دارد - تمام داده‌های مرتبط در یک مکان هستند و نیازی به Join نیست.

روابط Many-to-One و Many-to-Many

نرمال‌سازی و ID به‌جای رشته متنی

فرض کنید در فیلد شهر، به‌جای ذخیره رشته "Karaj" یک ID عددی ذخیره کنیم که به یک جدول شهرها اشاره دارد. این نرمال‌سازی (Normalization) است و مزایای واضحی دارد:

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

اما این رویکرد نیاز به Join دارد. و اینجاست که تفاوت اصلی مدل‌ها نمایان می‌شود:

  • مدل Relational برای Join طراحی شده است.
  • مدل Document برای داده‌هایی که نیاز به Join کمی دارند بهینه است، اما با روابط Many-to-Many به‌خوبی کنار نمی‌آید.

تاریخچه: IMS و مدل Network

کلپمان یک گریز تاریخی جالب می‌زند: قبل از پیدایش مدل Relational، IMS (سیستم IBM در دهه ۱۹۶۰) از یک مدل Hierarchical استفاده می‌کرد که شباهت زیادی به مدل Document مدرن داشت. این مدل برای روابط One-to-Many خوب بود، اما برای Many-to-Many کاملاً ناکارآمد.

برای حل این مشکل، مدل Network (CODASYL) پیشنهاد شد: هر رکورد می‌توانست چندین Parent داشته باشد - نه فقط یکی. اما برنامه‌نویسی با این مدل بسیار پیچیده بود، چون برای یافتن یک رکورد باید مسیر دقیق دسترسی (Access Path) را می‌دانستید.

مدل Relational همین مشکل را با Query Optimizer حل کرد: شما فقط می‌گویید چه می‌خواهید، نه چگونه آن را پیدا کنید.

مقایسه مدل‌های Document و Relational

معیارDocument ModelRelational Model
Schemaانعطاف‌پذیر (Schema-on-read)سخت (Schema-on-write)
Localityعالی برای داده‌های تو در تونیاز به Join
روابط Many-to-Manyضعیفقوی
Scalability افقیمعمولاً آسان‌تردشوارتر
یکپارچگی دادهدر سطح Applicationدر سطح Database

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

  • اگر داده‌های شما ساختار Document-like دارند - یعنی درخت‌های One-to-Many که معمولاً با هم Load می‌شوند - مدل Document مناسب‌تر است.
  • اگر برنامه شما از روابط Many-to-Many زیادی استفاده می‌کند، مدل Relational قوی‌تر عمل می‌کند.

Query Languages و Graph Data Models

زبان‌های Query: Declarative در برابر Imperative

کلپمان یکی از مهم‌ترین تمایزات در دنیای Query Language را اینجا مطرح می‌کند - تفاوت بین رویکرد Declarative و Imperative:

رویکرد Imperative (مثل کد برنامه‌نویسی معمول): می‌گویید چگونه به نتیجه برسید - گام به گام، با حلقه و شرط.

1
2
3
4
5
6
var results = new List<Animal>();
foreach (var animal in animals)
{
    if (animal.Family == "Sharks")
        results.Add(animal);
}

رویکرد Declarative (مثل SQL): می‌گویید چه می‌خواهید - موتور Query خودش تصمیم می‌گیرد چگونه آن را پیدا کند.

1
SELECT * FROM animals WHERE family = 'Sharks';

چرا Declarative قوی‌تر است؟

کلپمان سه مزیت اصلی رویکرد Declarative را برمی‌شمارد:

  • پنهان کردن جزئیات پیاده‌سازی: Query Optimizer می‌تواند پشت صحنه از Index استفاده کند، ترتیب Join را عوض کند، یا از Parallel Execution بهره ببرد - بدون اینکه شما کدتان را تغییر دهید.
  • بهبود خودکار Performance: وقتی Database Engine بهبود پیدا می‌کند، Query‌های شما بدون هیچ تغییری سریع‌تر می‌شوند.
  • مناسب برای Parallelism: چون نحوه اجرا مشخص نشده، موتور می‌تواند روی چندین Core یا ماشین اجرا کند.

MapReduce: یک مدل Hybrid

MapReduce یک مدل جالب است که کلپمان آن را نه کاملاً Declarative و نه کاملاً Imperative معرفی می‌کند. این مدل توسط Google معرفی شد و بعدها در Apache Hadoop پیاده‌سازی شد.

MapReduce از دو تابع تشکیل می‌شود:

تابعکار
Mapروی هر رکورد اجرا می‌شود و Key-Value جفت تولید می‌کند
ReduceKey-Value جفت‌های مشترک را جمع‌آوری و تجمیع می‌کند

مثال: تعداد مشاهدات دریایی در هر ماه:

1
2
3
4
5
6
7
8
9
// Map: از هر رکورد، ماه و تعداد را استخراج کن
function map(observation) {
    emit(observation.month, observation.numAnimals);
}

// Reduce: تعداد‌های هر ماه را جمع کن
function reduce(month, counts) {
    return counts.reduce((sum, val) => sum + val, 0);
}

محدودیت اصلی MapReduce این است که نوشتن این توابع برای Query‌های پیچیده بسیار دشوار است. به همین دلیل MongoDB بعداً Aggregation Pipeline را معرفی کرد که رویکرد Declarative‌تری دارد.

Graph Data Models: وقتی همه چیز به هم وصل است

کِی به Graph نیاز داریم؟

کلپمان می‌گوید وقتی روابط Many-to-Many در داده‌های شما بسیار زیاد و پیچیده می‌شوند، نه مدل Relational و نه مدل Document پاسخگو هستند. اینجاست که Graph Database وارد می‌شود.

یک Graph از دو عنصر اصلی تشکیل می‌شود:

  • Vertex (گره): موجودیت‌ها - مثل کاربر، شهر، محصول
  • Edge (یال): روابط بین موجودیت‌ها - مثل «دوست است با»، «زندگی می‌کند در»، «خریده است»

جذابیت Graph این است که Vertex‌ها می‌توانند کاملاً ناهمگون باشند - یعنی در یک Graph می‌توان هم «افراد» و هم «شهرها» و هم «شرکت‌ها» را با هم مدل کرد.

نمونه‌های کاربردی واقعی

حوزهمدل Graph
شبکه‌های اجتماعیافراد به‌عنوان Vertex، روابط دوستی به‌عنوان Edge
وبصفحات به‌عنوان Vertex، لینک‌ها به‌عنوان Edge
راه‌آهنایستگاه‌ها به‌عنوان Vertex، مسیرها به‌عنوان Edge
موتورهای توصیهکاربر، محصول، خرید - همه در یک Graph

مدل Property Graph

رایج‌ترین مدل Graph، Property Graph است که در پایگاه‌داده‌هایی مثل Neo4j پیاده‌سازی شده. در این مدل:

هر Vertex دارد:

  • یک شناسه یکتا (Unique ID)
  • مجموعه‌ای از Edge‌های خروجی (Outgoing Edges)
  • مجموعه‌ای از Edge‌های ورودی (Incoming Edges)
  • مجموعه‌ای از Property‌ها (جفت‌های Key-Value)

هر Edge دارد:

  • یک شناسه یکتا
  • Vertex مبدأ (Tail)
  • Vertex مقصد (Head)
  • یک Label که نوع رابطه را توصیف می‌کند
  • مجموعه‌ای از Property‌ها

زبان Query: Cypher

برای Graph Database، زبان Cypher طراحی شده است (مخصوص Neo4j). این زبان کاملاً Declarative است و خواندن آن بسیار شهودی‌تر از SQL برای روابط پیچیده است:

1
2
3
4
5
6
// پیدا کردن همه افرادی که در ایران متولد شده‌اند
// و الان در اروپا زندگی می‌کنند
MATCH
  (person) -,
  (person) -
RETURN person.name

همین Query در SQL نوشتن آن بسیار پیچیده‌تر است، چون SQL برای Traversal (حرکت روی رابطه‌ها) طراحی نشده است.

مدل Triple-Store و SPARQL

کلپمان مدل سومی هم معرفی می‌کند: Triple-Store که بر اساس مفهوم ساده‌ای استوار است - تمام داده‌ها به شکل سه‌تایی (Subject, Predicate, Object) ذخیره می‌شوند:

1
2
3
(Mohammad Hossein, livesIn, Karaj)
(Mohammad Hossein, worksIn, .NET)
(Karaj, locatedIn, Iran)

این مدل پایه Semantic Web و RDF (Resource Description Framework) است. زبان Query آن SPARQL نام دارد و ساختاری شبیه به Cypher دارد.

جمع‌بندی فصل دوم: هیچ مدلی برنده مطلق نیست

کلپمان فصل را با یک نتیجه‌گیری عمل‌گرایانه تمام می‌کند:

1
2
3
Document Model  → داده‌های درختی، One-to-Many، Schema انعطاف‌پذیر
Relational Model → روابط Many-to-Many، یکپارچگی داده، Join قوی
Graph Model     → روابط بسیار پیچیده و به‌هم‌تنیده بین موجودیت‌های متنوع

انتخاب مدل داده نه یک تصمیم فنی صرف، بلکه یک تصمیم معماری است که باید بر اساس ماهیت داده‌ها و نوع Query‌هایی که می‌خواهید بنویسید اتخاذ شود.


فصل سوم: Storage Engines - چگونه Database داده را ذخیره می‌کند؟

چرا یک توسعه‌دهنده باید Storage Engine را بشناسد؟

کلپمان این فصل را با یک استدلال قوی شروع می‌کند: شما نیازی ندارید که خودتان یک Storage Engine بنویسید، اما باید بدانید که موتورهای مختلف چه تفاوت‌هایی دارند تا بتوانید برای Workload خود مناسب‌ترین را انتخاب کنید. انتخاب اشتباه Storage Engine می‌تواند تفاوت چشمگیری در Performance ایجاد کند.

ساده‌ترین Database دنیا

کلپمان با یک مثال فوق‌العاده آموزنده شروع می‌کند - یک Database دو‌تابعی در Bash:

1
2
3
4
5
6
7
8
9
#!/bin/bash

db_set() {
    echo "$1,$2" >> database
}

db_get() {
    grep "^$1," database | tail -n 1 | cut -d',' -f2
}

این Database واقعاً کار می‌کند! می‌توانید بنویسید:

1
2
3
db_set 123 '{"name":"Mohammad Hossein","city":"Karaj"}'
db_get 123
# خروجی: {"name":"Mohammad Hossein","city":"Karaj"}

تحلیل Performance این Database ساده

  • Write: سرعت بسیار بالا - فقط یک Append به انتهای فایل. این عملیات O(1) است.
  • Read: سرعت بسیار پایین - باید کل فایل را اسکن کند تا آخرین مقدار را پیدا کند. این عملیات O(n) است.

این تحلیل دقیقاً تنش اصلی بین Write Performance و Read Performance را نشان می‌دهد - تنشی که در طراحی تمام Storage Engineها وجود دارد.

Index: راه‌حل مشکل Read کند

برای سریع‌تر کردن Read، به یک ساختار داده کمکی نیاز داریم: Index. Index یک Metadata اضافه است که نگه می‌دارد کجا می‌توان داده را پیدا کرد.

اما Index یک Trade-off مهم دارد:

  • Read را سریع می‌کند.
  • Write را کُند می‌کند - چون هر بار که داده می‌نویسید، باید Index هم به‌روز شود.

این یکی از اصول بنیادی طراحی Database است: هیچ Index‌ای مجانی نیست.

Hash Index: اولین رویکرد

ساده‌ترین نوع Index، Hash Index است. ایده اصلی: یک HashMap در حافظه نگه می‌داریم که Key هر رکورد را به Offset بایتی آن در فایل نگاشت می‌کند.

1
2
3
4
Key    → Offset در فایل
"123"  → 0
"456"  → 64
"789"  → 128

وقتی می‌خوانید:

  1. Key را در HashMap جستجو می‌کنید → Offset پیدا می‌کنید.
  2. مستقیماً به آن Offset در فایل می‌روید → داده را می‌خوانید.

این دقیقاً رویکرد Bitcask (Storage Engine پیش‌فرض Riak) است.

مشکل: فایل بی‌نهایت بزرگ می‌شود

اگر فقط Append کنیم، فایل به‌مرور بی‌نهایت بزرگ می‌شود. راه‌حل: Compaction و Segmentation.

فایل را به Segmentهای ثابت تقسیم می‌کنیم. وقتی یک Segment پر شد، آن را می‌بندیم و Segment جدید باز می‌کنیم. سپس در پس‌زمینه، Compaction انجام می‌دهیم: فقط آخرین مقدار هر Key را نگه می‌داریم و بقیه را حذف می‌کنیم.

1
2
3
4
5
6
7
8
9
قبل از Compaction:         بعد از Compaction:
mew,1                      mew,4
woof,1                     woof,3
mew,2                      purr,1
mew,3
woof,2
purr,1
mew,4
woof,3

محدودیت‌های Hash Index

با وجود سادگی، Hash Index دو محدودیت اساسی دارد:

  • تمام Key‌ها باید در RAM جا بشوند - اگر Key‌های بیش از حد زیادی دارید، این رویکرد کار نمی‌کند.
  • Range Query کارآمد نیست - نمی‌توانید به‌راحتی بگویید «همه Key‌های بین A و B را بده»، چون HashMap هیچ ترتیبی ندارد.

SSTable و LSM-Tree: یک گام بزرگ

SSTables چیست؟

کلپمان یک بهبود اساسی نسبت به Hash Index معرفی می‌کند: Sorted String Table (SSTable). ایده ساده اما قدرتمند است: Key‌ها را مرتب‌شده نگه دار.

مزایای این رویکرد:

  • Merge کردن Segment‌ها بسیار کارآمد می‌شود - مثل MergeSort، فقط کافی است از ابتدای هر فایل بخوانید.
  • دیگر نیازی نیست تمام Key‌ها در RAM باشند - چون Key‌ها مرتب هستند، با یک Index Sparse می‌توانید موقعیت تقریبی را پیدا کنید.
  • Range Query به‌راحتی ممکن می‌شود.

LSM-Tree: ساختار پشت SSTable

اما یک سوال مطرح است: چطور می‌توانیم داده را مرتب‌شده بنویسیم در حالی که Write‌ها به ترتیب تصادفی می‌آیند؟ پاسخ: Log-Structured Merge-Tree (LSM-Tree).

مکانیزم کار به این شکل است:

گام ۱ - Memtable: داده جدید ابتدا در یک ساختار Balanced Tree در حافظه (مثل Red-Black Tree یا AVL Tree) نوشته می‌شود. این ساختار Memtable نام دارد. چون در RAM است، نوشتن در آن بسیار سریع است و به‌صورت خودکار مرتب نگه داشته می‌شود.

گام ۲ - Flush به دیسک: وقتی Memtable به یک آستانه اندازه رسید (معمولاً چند مگابایت)، آن را به‌صورت یک SSTable مرتب‌شده روی دیسک می‌نویسیم.

گام ۳ - Compaction در پس‌زمینه: به‌مرور، SSTableهای متعدد روی دیسک جمع می‌شوند. یک فرآیند پس‌زمینه آن‌ها را Merge و Compact می‌کند.

گام ۴ - خواندن: ابتدا Memtable را بررسی می‌کنیم، سپس جدیدترین SSTable روی دیسک، و به همین ترتیب به قدیمی‌ترین می‌رسیم.

این معماری پایه LevelDB، RocksDB، Cassandra و HBase است.

مشکل Crash و راه‌حل آن

اگر سیستم Crash کند، Memtable که در RAM بود از دست می‌رود. راه‌حل: یک Write-Ahead Log (WAL) جداگانه نگه می‌داریم - هر Write ابتدا در این Log ثبت می‌شود، سپس در Memtable. در صورت Crash، از روی WAL می‌توان Memtable را بازسازی کرد.

B-Tree: رویکرد رقیب

در حالی که LSM-Tree بر اساس Log-Structured است، B-Tree رویکرد کاملاً متفاوتی دارد و رایج‌ترین ساختار Index در پایگاه‌داده‌های Relational (مثل SQL Server، PostgreSQL، MySQL) است.

ساختار B-Tree

B-Tree داده را به Block یا Pageهای ثابت‌اندازه (معمولاً ۴ کیلوبایت) تقسیم می‌کند که مستقیماً با نحوه کار سخت‌افزار دیسک هماهنگ است. هر Page می‌تواند به Page‌های دیگر Pointer داشته باشد.

1
2
3
4
5
              [Root Page]
             /     |      \
      [Page A]  [Page B]  [Page C]
      /   \      /   \      /   \
  [L1] [L2]  [L3] [L4]  [L5] [L6]
  • Root: نقطه شروع هر جستجو
  • Internal Pages: Key‌ها و Pointer به Page‌های پایین‌تر
  • Leaf Pages: مقادیر واقعی داده یا Pointer به محل داده

برای پیدا کردن یک Key، از Root شروع می‌کنیم و در هر Page تصمیم می‌گیریم به کدام زیردرخت برویم - مثل جستجوی دودویی.

عمق B-Tree و تعداد I/O

یک B-Tree با n رکورد حداکثر O(log n) عمق دارد. هر سطح یک Disk I/O است. برای یک Database با یک میلیارد رکورد و Branch Factor معمول ۵۰۰، کافی است ۳ تا ۴ Page را بخوانیم تا هر Key را پیدا کنیم.

نوشتن در B-Tree: در جا، نه Append

تفاوت اصلی B-Tree با LSM-Tree اینجاست: B-Tree داده را در جا (In-Place) تغییر می‌دهد، نه Append می‌کند. وقتی یک Key را تغییر می‌دهید، مستقیماً آن Page روی دیسک بازنویسی می‌شود.

این رویکرد یک ریسک جدی دارد: اگر Crash کند در وسط نوشتن یک Page چه؟ برای مقابله، B-Tree هم از Write-Ahead Log (WAL) استفاده می‌کند.

مقایسه LSM-Tree و B-Tree

معیارLSM-TreeB-Tree
Write Performanceبسیار بالا (Sequential Write)متوسط (Random Write)
Read Performanceکمی کندتر (چند SSTable)سریع (یک مسیر مشخص)
Write Amplificationبالا (Compaction)کمتر
فضای دیسککارآمدتر (فشرده‌سازی)بیشتر (Fragmentation)
استفاده درCassandra، RocksDB، LevelDBSQL Server، PostgreSQL، MySQL
مناسب برایWrite-Heavy WorkloadRead-Heavy Workload

کلپمان نکته مهمی اضافه می‌کند: Write Amplification در LSM-Tree یعنی هر بار که Compaction انجام می‌شود، داده‌ها چندین بار روی دیسک نوشته می‌شوند. در SSD‌ها که تعداد Write محدود دارند، این موضوع اهمیت ویژه‌ای پیدا می‌کند.

Index‌های دیگر: فراتر از Key-Value

Multi-Column Index

وقتی می‌خواهیم روی چندین ستون همزمان Query بزنیم (مثل جستجوی رستوران‌ها در یک محدوده جغرافیایی بر اساس Latitude و Longitude)، به Multi-Column Index نیاز داریم. یک B-Tree معمولی برای این کار مناسب نیست، چون فقط یک بُعد را می‌فهمد.

راه‌حل: R-Tree که برای داده‌های چندبُعدی (مثل موقعیت جغرافیایی) طراحی شده و در PostGIS برای جستجوی فضایی استفاده می‌شود.

Full-Text Search و Fuzzy Index

Index‌های معمولی نمی‌توانند جستجوی متن کامل (Full-Text Search) انجام دهند. برای این کار به Inverted Index نیاز است که پایه موتورهای جستجو مثل Elasticsearch و Lucene است.

In-Memory Database: فراتر از دیسک

کلپمان در انتهای فصل به In-Memory Database می‌پردازد. سوال جالب: اگر همه چیز در RAM باشد، چرا این Database‌ها سریع‌ترند؟

پاسخ ضدشهودی کلپمان: نه فقط به خاطر اینکه از دیسک نمی‌خوانند - بلکه به خاطر اینکه نیازی به Encode کردن داده در فرمتی مناسب برای دیسک ندارند و می‌توانند از ساختارهای داده پیچیده‌تری در حافظه استفاده کنند.

نمونه‌ها: Redis، Memcached، VoltDB.

مشکل اصلی: در صورت Crash، داده از دست می‌رود. راه‌حل‌ها:

  • نوشتن Snapshot دوره‌ای روی دیسک.
  • استفاده از WAL برای بازیابی.
  • Replication روی چندین ماشین.

فصل چهارم: Encoding و Evolution - چگونه داده را ذخیره و منتقل کنیم؟

چرا Encoding اهمیت دارد؟

کلپمان این فصل را با یک واقعیت ساده شروع می‌کند: داده‌ای که در حافظه نگه می‌دارید (به شکل Object، Struct، List) با داده‌ای که روی دیسک می‌نویسید یا روی شبکه منتقل می‌کنید متفاوت است. تبدیل از یک فرمت به فرمت دیگر را Encoding (یا Serialization) می‌نامند و برعکس آن را Decoding (یا Deserialization).

اما چالش اصلی فراتر از فرمت است: سیستم‌های نرم‌افزاری تغییر می‌کنند. Feature جدید اضافه می‌شود، فیلدی حذف می‌شود، نوع داده‌ای عوض می‌شود. این تغییرات باید به شکلی مدیریت شوند که:

  • کد قدیمی بتواند داده جدید را بخواند → Backward Compatibility
  • کد جدید بتواند داده قدیمی را بخواند → Forward Compatibility

فرمت‌های Encoding: از ساده تا پیشرفته

فرمت‌های زبان‌محور: ساده اما خطرناک

هر زبان برنامه‌نویسی ابزار Serialization خودش را دارد:

  • Java: java.io.Serializable
  • Python: pickle
  • C#: BinaryFormatter

کلپمان این رویکرد را عمیقاً مشکل‌دار می‌داند و سه دلیل می‌آورد:

  • وابستگی به زبان: داده Serialize شده با Java را نمی‌توان در Python خواند. سیستم‌های دیگر نمی‌توانند با شما ارتباط برقرار کنند.
  • مشکل امنیتی: Deserialization داده دریافتی از منابع ناشناس یکی از بردارهای رایج حمله است. بارها دیده شده که مهاجمان از طریق Deserialization کنترل کامل سرور را به دست گرفته‌اند.
  • مشکل Versioning: این ابزارها معمولاً پشتیبانی ضعیفی از Forward و Backward Compatibility دارند.

JSON، XML و CSV: فرمت‌های متنی

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

مشکلتوضیح
ابهام در اعدادJSON بین Integer و Float تمایز قائل نمی‌شود. اعداد بزرگ‌تر از 2⁵³ در JavaScript دقت خود را از دست می‌دهند
Binary را پشتیبانی نمی‌کنندبرای داده Binary (مثل تصویر) باید از Base64 استفاده کرد که حجم را ۳۳٪ افزایش می‌دهد
Schema اختیاری استJSON و CSV هیچ Schema اجباری ندارند - مسئولیت اعتبارسنجی با Application است
XML پرحجم استنوشتن و خواندن XML دشوار است و حجم زیادی از فضا را مصرف می‌کند

با این حال، برای تبادل داده بین سازمان‌ها یا API‌های عمومی، JSON فرمت مسلط است - چون Human-Readable است و تقریباً همه زبان‌ها آن را پشتیبانی می‌کنند.

فرمت‌های Binary: کارآمد و دقیق

MessagePack: JSON باینری

ساده‌ترین بهبود روی JSON، MessagePack است. ساختار JSON را حفظ می‌کند اما داده را به شکل Binary Encode می‌کند. نتیجه: حجم کمتر، Parsing سریع‌تر، اما همچنان بدون Schema اجباری.

Thrift و Protocol Buffers: Binary با Schema

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

  • Apache Thrift: توسط Facebook ساخته شده.
  • Protocol Buffers (Protobuf): توسط Google ساخته شده.

هر دو از یک اصل مشترک استفاده می‌کنند: Schema اجباری با Field Tag.

1
2
3
4
5
6
// تعریف Schema در Protobuf
message Person {
    required string user_name       = 1;
    optional int64  favorite_number = 2;
    repeated string interests       = 3;
}

عدد کنار هر فیلد (۱، ۲، ۳) Field Tag نام دارد. وقتی داده Encode می‌شود، به‌جای نام فیلد (user_name)، فقط این عدد ذخیره می‌شود. این باعث می‌شود:

  • حجم داده به‌شدت کاهش یابد.
  • Schema تغییر کند، اما داده‌های قدیمی همچنان خوانده شوند.

چگونه Schema تغییر می‌کند؟

این مهم‌ترین بخش این دو فرمت است. کلپمان قوانین تغییر Schema را توضیح می‌دهد:

اضافه کردن فیلد جدید:

  • فیلد جدید باید optional باشد.
  • کد قدیمی این فیلد را نمی‌شناسد اما آن را نادیده می‌گیرد → Backward Compatibility حفظ است.
  • کد جدید که داده قدیمی می‌خواند، مقدار Default می‌گیرد → Forward Compatibility حفظ است.

حذف فیلد:

  • فقط فیلد optional قابل حذف است.
  • Field Tag آن فیلد حذف‌شده هرگز نباید دوباره استفاده شود - چون داده‌های قدیمی هنوز آن Tag را دارند.

Apache Avro: رویکرد متفاوت

Avro رویکرد جالب‌تری دارد که کلپمان آن را با دقت بررسی می‌کند. Avro هیچ Field Tag در داده Encode شده ذخیره نمی‌کند - نه نام فیلد، نه نوع داده. فقط مقادیر خام کنار هم قرار می‌گیرند.

سوال مطرح می‌شود: پس چطور داده را می‌خوانند؟

پاسخ: Avro از دو Schema جداگانه استفاده می‌کند:

  • Writer’s Schema: Schema‌ای که نویسنده برای Encode استفاده کرده.
  • Reader’s Schema: Schema‌ای که خواننده انتظار دارد.

این دو Schema لازم نیست یکسان باشند، فقط باید سازگار باشند. Avro Library در زمان Read، این دو Schema را با هم مقایسه کرده و تفاوت‌ها را به‌صورت خودکار Resolve می‌کند.

1
2
3
4
5
6
Writer's Schema:          Reader's Schema:
-----------------         ----------------
name: string              name: string
age: int                  (فیلد age حذف شده)
email: string             email: string
                          phone: string (اضافه شده، با default)

این معماری Avro را برای Hadoop و سیستم‌های Big Data ایده‌آل کرده، جایی که فایل‌های بزرگ توسط یک نویسنده تولید و توسط خوانندگان متعدد در آینده خوانده می‌شوند.

Dataflow: چطور داده بین سیستم‌ها جریان دارد؟

کلپمان در بخش پایانی فصل از مفهوم Dataflow صحبت می‌کند - یعنی مسیرهایی که داده Encode شده طی می‌کند. سه مسیر اصلی وجود دارد:

مسیر اول - Dataflow از طریق Database

وقتی داده را در Database می‌نویسید، در واقع به آینده پیام می‌دهید. نسخه آینده کد شما این داده را خواهد خواند. پس Backward Compatibility اجباری است.

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

راه‌حل: کد باید ناشناخته را حفظ کند و فقط فیلدهایی که می‌شناسد را تغییر دهد.

مسیر دوم - Dataflow از طریق Service (REST و RPC)

وقتی دو سرویس با هم ارتباط برقرار می‌کنند، Compatibility هر دو جهت مهم است. کلپمان دو رویکرد اصلی را مقایسه می‌کند:

معیارRESTRPC
پروتکلHTTPمعمولاً Binary روی TCP
فرمت دادهJSON/XMLProtobuf، Thrift، Avro
DiscoveryURL مشخصIDL (Interface Definition Language)
مناسب برایAPI عمومی، سرویس‌های خارجیارتباط داخلی Microservices

کلپمان نکته تیزبینانه‌ای درباره RPC مطرح می‌کند: تلاش برای اینکه فراخوانی سرویس از راه دور دقیقاً مثل فراخوانی محلی به نظر برسد، یک Abstraction Leak خطرناک است. چون:

  • یک فراخوانی محلی هرگز Timeout نمی‌شود.
  • یک فراخوانی محلی هرگز پاسخ تکراری نمی‌دهد.
  • یک فراخوانی محلی هرگز نیمه‌کاره نمی‌ماند.

شبکه هیچ‌کدام از این تضمین‌ها را ندارد.

مسیر سوم - Dataflow از طریق Message Passing

Asynchronous Message Passing یک مسیر میانه بین Database و RPC است. پیام از طریق یک Message Broker (مثل RabbitMQ یا Apache Kafka) منتقل می‌شود.

مزایا:

  • اگر سرویس گیرنده در دسترس نباشد، Message Broker پیام را نگه می‌دارد.
  • فرستنده منتظر پاسخ نمی‌ماند (Decoupling زمانی).
  • یک پیام می‌تواند به چندین گیرنده ارسال شود.
  • فرستنده آدرس IP گیرنده را نمی‌داند - Decoupling کامل.

جمع‌بندی فصل چهارم

کلپمان فصل را با یک اصل بنیادی تمام می‌کند:

1
2
Backward Compatibility  → کد جدید، داده قدیمی را می‌خواند
Forward Compatibility   → کد قدیمی، داده جدید را نادیده می‌گیرد

این دو اصل شرط لازم برای هر سیستمی است که می‌خواهد بدون Downtime و با Rolling Upgrade به‌روزرسانی شود - یعنی در هر لحظه، نسخه‌های مختلف کد می‌توانند همزمان داده یکسان را بخوانند و بنویسند.

Protobuf و Thrift با Field Tag، Avro با تطبیق Writer/Reader Schema، و JSON با قرارداد‌های نرم‌افزاری این اصل را پیاده‌سازی می‌کنند - هرکدام با Trade-off متفاوت.


بخش دوم کتاب: Distributed Data

چرا داده را توزیع می‌کنیم؟

کلپمان بخش دوم کتاب را با یک سوال بنیادی شروع می‌کند: چرا اصلاً می‌خواهیم داده را روی چندین ماشین توزیع کنیم؟ سه انگیزه اصلی وجود دارد:

  • Scalability: اگر حجم داده یا بار Read/Write از ظرفیت یک ماشین بیشتر شود، باید بار را روی چندین ماشین پخش کنیم.
  • High Availability: اگر یک ماشین خراب شود، سیستم باید همچنان کار کند.
  • Latency: اگر کاربران در نقاط مختلف جهان هستند، می‌خواهیم داده را به ماشین‌هایی نزدیک به آن‌ها منتقل کنیم.

اما توزیع داده پیچیدگی اساسی ایجاد می‌کند. دو رویکرد اصلی وجود دارد:

رویکردتوضیح
Replicationکپی یکسان داده روی چندین ماشین
Partitioning (Sharding)تقسیم داده به زیرمجموعه‌های کوچک‌تر روی ماشین‌های مختلف

فصل پنجم: Replication

مفهوم اصلی Replication

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

اگر داده هرگز تغییر نمی‌کرد، Replication ساده بود - یک‌بار کپی می‌کردید و تمام. چالش واقعی اینجاست: چگونه تغییرات را به همه Replicaها منتقل کنیم؟

کلپمان سه الگوی اصلی را معرفی می‌کند:

الگوی اول: Single-Leader Replication

نحوه کار

این رایج‌ترین الگوست. یکی از Replicaها Leader (یا Master/Primary) تعیین می‌شود. بقیه Follower (یا Replica/Standby) نامیده می‌شوند.

مکانیزم:

  1. تمام Write‌ها فقط به Leader می‌روند.
  2. Leader تغییر را در Replication Log ثبت می‌کند.
  3. هر Follower این Log را دریافت کرده و همان تغییرات را اعمال می‌کند.
  4. Read‌ها می‌توانند از Leader یا هر Follower خوانده شوند.

این معماری در PostgreSQL، MySQL، MongoDB، Kafka و بسیاری از سیستم‌های دیگر استفاده می‌شود.

Synchronous در برابر Asynchronous Replication

کلپمان یکی از مهم‌ترین Trade-offهای Replication را اینجا معرفی می‌کند:

Synchronous Replication: Leader منتظر می‌ماند تا Follower تأیید کند که تغییر را دریافت کرده، سپس به Client پاسخ می‌دهد.

1
2
3
4
Client → Write → Leader
Leader → Replicates → Follower (منتظر تأیید)
Follower → OK → Leader
Leader → OK → Client
  • ✅ تضمین می‌کند Follower همیشه داده به‌روز دارد.
  • ❌ اگر Follower کند یا ناموجود باشد، کل سیستم Block می‌شود.

Asynchronous Replication: Leader بلافاصله به Client پاسخ می‌دهد، سپس در پس‌زمینه به Follower ارسال می‌کند.

1
2
3
Client → Write → Leader
Leader → OK → Client (بدون انتظار)
Leader → Replicates → Follower (در پس‌زمینه)
  • ✅ Write سریع است.
  • ❌ اگر Leader قبل از Replicate کردن Crash کند، داده از دست می‌رود.

در عمل، اکثر سیستم‌ها از Semi-Synchronous استفاده می‌کنند: یک Follower Synchronous، بقیه Asynchronous. این تضمین می‌کند حداقل دو نسخه از داده (Leader + یک Follower) همیشه به‌روز باشند.

مشکل Replication Lag

در Asynchronous Replication، بین لحظه‌ای که Leader می‌نویسد و لحظه‌ای که Follower به‌روز می‌شود یک فاصله زمانی وجود دارد: Replication Lag. این باعث می‌شود کاربر در حالت Eventual Consistency باشد - داده در نهایت سازگار می‌شود، اما نه فوراً.

کلپمان سه ناسازگاری رایج را شرح می‌دهد:

ناسازگاری اول - Read Your Own Writes: کاربر یک پیام می‌نویسد، سپس صفحه را Refresh می‌کند. اگر Read از یک Follower عقب‌افتاده سرویس‌دهی شود، کاربر پیامی که خودش نوشته را نمی‌بیند. راه‌حل: بلافاصله بعد از Write، Read را از Leader بگیرید.

ناسازگاری دوم - Monotonic Reads: کاربر ابتدا داده به‌روز را از یک Follower می‌خواند، بعد همان داده را از یک Follower عقب‌افتاده می‌خواند. نتیجه: داده به عقب برگشته به نظر می‌رسد. راه‌حل: هر کاربر همیشه از Replica یکسان بخواند.

ناسازگاری سوم - Consistent Prefix Reads: در یک مکالمه چند پیام داریم. اگر Replicaها در ترتیب دریافت پیام‌ها اختلاف داشته باشند، کاربر ممکن است جواب را قبل از سوال ببیند. این در سیستم‌های Partitioned رایج است.

Failover: وقتی Leader خراب می‌شود

اگر Leader Crash کند، یکی از Followerها باید Leader جدید بشود. این فرآیند Failover نامیده می‌شود. در یک Failover خودکار سه گام طی می‌شود:

  1. تشخیص خرابی: معمولاً با Timeout - اگر Leader در مدت مشخصی پاسخ ندهد، مرده فرض می‌شود.
  2. انتخاب Leader جدید: معمولاً Replica‌ای که به‌روزترین داده را دارد انتخاب می‌شود (از طریق الگوریتم اجماع).
  3. Reconfigure کردن سیستم: تمام Client‌ها باید بدانند Leader جدید کجاست.

کلپمان هشدار می‌دهد که Failover پر از خطاهای پنهان است:

  • اگر از Asynchronous Replication استفاده کنید، Leader جدید ممکن است تمام Write‌های اخیر Leader قدیمی را نداشته باشد. اگر Leader قدیمی دوباره برگردد، تعارض داده ایجاد می‌شود.
  • بدترین سناریو: Split-Brain - دو Node هر دو فکر می‌کنند Leader هستند. اگر هر دو به‌صورت همزمان Write بپذیرند، داده ناسازگار و غیرقابل بازیابی می‌شود.

پیاده‌سازی Replication Log

کلپمان چهار روش برای انتقال تغییرات از Leader به Follower معرفی می‌کند:

روش اول - Statement-Based Replication

Leader هر SQL Statement (مثل INSERT، UPDATE) را Log کرده و برای Follower ارسال می‌کند.

مشکل: Statement‌هایی مثل NOW() یا RAND() روی Leader و Follower نتایج متفاوت تولید می‌کنند.

روش دوم - Write-Ahead Log (WAL) Shipping

Leader همان WAL که برای Storage Engine استفاده می‌کند را برای Follower ارسال می‌کند.

مشکل: WAL به جزئیات Storage Engine وابسته است. اگر نسخه‌های متفاوت Database داشته باشید، Replication ممکن است کار نکند - و Zero-Downtime Upgrade دشوار می‌شود.

روش سوم - Logical (Row-Based) Replication

به‌جای دستورات SQL یا WAL، تغییرات در سطح Row Log می‌شوند:

  • برای Insert: مقادیر تمام ستون‌های Row جدید
  • برای Delete: اطلاعات کافی برای شناسایی Row (معمولاً Primary Key)
  • برای Update: اطلاعات شناسایی Row + مقادیر جدید ستون‌های تغییر کرده

این روش از Storage Engine مستقل است و MySQL Binlog از همین روش استفاده می‌کند.

روش چهارم - Trigger-Based Replication

یک Trigger در Database تعریف می‌کنید که هر تغییر را در یک جدول جداگانه ثبت کند. سپس یک فرآیند خارجی این جدول را می‌خواند و به Replica منتقل می‌کند.

انعطاف‌پذیرترین روش است اما کمترین Performance را دارد.

الگوی دوم: Multi-Leader Replication

کِی استفاده می‌شود؟

در Single-Leader، تمام Write‌ها از یک نقطه می‌گذرند. اگر آن نقطه در یک Datacenter دیگر (از نظر جغرافیایی دور) باشد، Latency بالا می‌رود.

Multi-Leader (یا Master-Master) این مشکل را حل می‌کند: هر Datacenter Leader خودش را دارد و Write‌ها به همان Datacenter نزدیک می‌روند.

1
2
3
4
Datacenter تهران          Datacenter آمریکا
    Leader A    ←——————→    Leader B
   /        \              /        \
Follower  Follower     Follower  Follower

مشکل اصلی: Write Conflicts

اگر دو کاربر به‌صورت همزمان همان رکورد را در Datacenterهای مختلف تغییر دهند، یک Write Conflict ایجاد می‌شود. این مشکل را کلپمان یکی از بزرگ‌ترین ضعف‌های Multi-Leader می‌داند.

چند استراتژی برای حل این تعارض وجود دارد:

  • Last Write Wins (LWW): هر Write یک Timestamp دارد؛ آخرین Timestamp برنده است. ساده اما داده از دست می‌رود.
  • Merge کردن: هر دو مقدار را حفظ کرده و به Application واگذار کنید که تصمیم بگیرد.
  • CRDT (Conflict-free Replicated Data Type): ساختارهای داده‌ای که به‌صورت ریاضی تضمین می‌کنند همگرا می‌شوند.

الگوی سوم: Leaderless Replication

رویکرد Dynamo-Style

این الگو توسط Amazon Dynamo (پایه DynamoDB) معرفی شد و بعداً در Cassandra و Riak پیاده‌سازی شد. در این رویکرد هیچ Leader وجود ندارد - Client می‌تواند به هر Replica بنویسد.

Quorum: کلید کارکرد

برای اینکه Read و Write در این سیستم معنا داشته باشند، از Quorum استفاده می‌شود. اگر n Replica داشته باشیم:

  • w: تعداد Replicaهایی که باید Write را تأیید کنند.
  • r: تعداد Replicaهایی که باید برای Read پاسخ دهند.

شرط سازگاری: w + r > n

مثال با n=3:

  • w=2, r=2: سازگار - حداقل یک Replica مشترک بین Write و Read وجود دارد.
  • w=1, r=1: ناسازگار - ممکن است Replica متفاوت باشند.
1
2
3
4
n=3 Replica:
Write به 2 تا (w=2) ✓
Read از 2 تا (r=2) ✓
w + r = 4 > 3 = n  →  حداقل ۱ Replica مشترک وجود دارد

این معماری به سیستم اجازه می‌دهد حتی اگر برخی Replicaها Offline باشند، همچنان کار کند.


فصل ششم: Partitioning - تقسیم داده روی چندین ماشین

چرا Partitioning؟

Replication یک کپی کامل از داده را روی چندین ماشین نگه می‌دارد. اما اگر حجم داده آنقدر بزرگ باشد که حتی یک کپی هم روی یک ماشین جا نشود چه؟ اینجاست که Partitioning (که در دنیای MongoDB و Elasticsearch به آن Sharding می‌گویند) وارد می‌شود.

در Partitioning، هر رکورد فقط روی یک Partition قرار می‌گیرد - نه همه. هدف این است که Query و Write بار به‌صورت یکنواخت روی ماشین‌ها توزیع شود. اگر این توزیع ناهموار باشد و برخی Partitionها بار بیشتری داشته باشند، آن‌ها را Hot Spot می‌نامند.

روش‌های Partitioning

روش اول - Partitioning by Key Range

داده را بر اساس بازه‌های Key تقسیم می‌کنیم. مثل یک دایره‌المعارف چاپی که هر جلد یک بازه حروف الفبا را پوشش می‌دهد:

1
2
3
4
Partition 1: A - F
Partition 2: G - M
Partition 3: N - S
Partition 4: T - Z

مزیت اصلی: Range Query بسیار کارآمد است - داده‌های مجاور در یک Partition هستند.

مشکل اصلی: اگر Key بر اساس زمان باشد (مثل Timestamp در IoT)، همه Write‌های جدید به یک Partition می‌روند و آن Hot Spot می‌شود. راه‌حل: به‌جای Timestamp خالص، با یک شناسه دستگاه Prefix کنید:

1
2
قبل:  2024-01-15:12:30:00  →  یک Partition
بعد:  device_7:2024-01-15  →  توزیع روی Partitionها

روش دوم - Partitioning by Hash of Key

به‌جای خود Key، از Hash آن برای تعیین Partition استفاده می‌کنیم. یک Hash Function خوب مقادیر را به‌صورت یکنواخت توزیع می‌کند:

1
2
3
hash("user_123")  →  Partition 2
hash("user_456")  →  Partition 5
hash("user_789")  →  Partition 1

Cassandra و MongoDB از این روش استفاده می‌کنند.

مزیت: Hot Spot به‌شدت کاهش می‌یابد.

مشکل: Range Query دیگر کارآمد نیست - کلیدهای مجاور حالا روی Partitionهای مختلف پراکنده شده‌اند. به همین دلیل Cassandra یک Compound Primary Key دارد: بخش اول برای Hash و انتخاب Partition استفاده می‌شود، بخش‌های بعدی ترتیب داده را داخل آن Partition تعیین می‌کنند.

Skewed Workloads و Hot Spots

حتی با Hash Partitioning، گاهی Hot Spot اجتناب‌ناپذیر است. مثال واقعی: یک Celebrity در شبکه اجتماعی با میلیون‌ها Follower. هر توییت او میلیون‌ها Write را به یک Key (همان User ID) تولید می‌کند. Hash هم کمکی نمی‌کند - همه Write‌ها به همان Partition می‌روند.

راه‌حل که کلپمان پیشنهاد می‌دهد: یک Random Number (مثلاً بین ۱ تا ۱۰۰) به ابتدای Key اضافه کنید. Write‌ها حالا بین ۱۰۰ Key مختلف توزیع می‌شوند. اما عیب دارد: وقتی می‌خوانید، باید از تمام ۱۰۰ Key بخوانید و نتایج را Merge کنید. این Trade-off را باید برای Key‌های خاصی که Hot هستند قبول کنید، نه برای همه.

Partitioning و Secondary Index

اگر فقط از Primary Key می‌خواندیم، همه چیز ساده بود. اما وقتی Secondary Index (مثل جستجو بر اساس رنگ ماشین، نه ID ماشین) وجود دارد، Partitioning پیچیده‌تر می‌شود.

روش اول - Document-Partitioned Index (Local Index)

هر Partition Index خودش را دارد - فقط برای داده‌هایی که در همان Partition هستند. این را Local Index می‌نامند:

1
2
3
4
5
Partition 0: cars 0–499
  Index: color:red → [car_12, car_89, car_456]

Partition 1: cars 500–999
  Index: color:red → [car_523, car_881]

مزیت: نوشتن سریع است - فقط یک Partition را به‌روز می‌کنید.

مشکل: برای خواندن همه ماشین‌های قرمز باید همه Partition‌ها را Query بزنید. به این Scatter/Gather می‌گویند و می‌تواند بسیار کند باشد، به‌خصوص اگر بعضی Partitionها کند باشند.

روش دوم - Term-Partitioned Index (Global Index)

یک Index جهانی داریم که خودش هم Partition شده - اما بر اساس Term (نه Document). مثلاً:

1
2
3
4
5
Partition A: color:a–r
  → car_12 (P0), car_89 (P0), car_523 (P1)

Partition B: color:s–z
  → car_456 (P0), car_881 (P1)

مزیت: Read کارآمدتر است - فقط Partition مربوط به آن Term را باید بخوانید.

مشکل: Write کندتر می‌شود - یک Write ممکن است چندین Partition از Index را تحت‌تأثیر قرار دهد. به همین دلیل، اکثر Database‌ها این Index را Asynchronous به‌روز می‌کنند - یعنی بعد از Write، Index فوراً به‌روز نمی‌شود.

Rebalancing Partitions

با گذر زمان، نیاز به تغییر تعداد Partitionها پیش می‌آید:

  • ماشین جدید اضافه شده، باید بار را با آن تقسیم کنیم.
  • یک ماشین خراب شده، Partition‌هایش باید به جای دیگری منتقل شوند.
  • داده رشد کرده، به Partition بیشتر نیاز داریم.

این فرآیند Rebalancing نامیده می‌شود و باید بدون قطع سرویس انجام شود.

چرا از Hash mod N استفاده نکنیم؟

یک راه ساده این است که hash(key) mod N محاسبه کنیم (N = تعداد ماشین‌ها). اما کلپمان این را اشتباه می‌داند: اگر N تغییر کند، اکثر Key‌ها باید به Partition جدیدی منتقل شوند - و این یعنی انتقال عظیم داده.

راه‌حل - Fixed Number of Partitions

یک رویکرد هوشمندانه‌تر: از ابتدا تعداد Partition بیشتر از ماشین بساز. مثلاً ۱۰۰۰ Partition برای ۱۰ ماشین - یعنی هر ماشین ۱۰۰ Partition دارد. وقتی ماشین جدید اضافه می‌شود، فقط چند Partition از ماشین‌های موجود به آن منتقل می‌شود. حجم داده منتقل‌شده کم، و اختلال در سرویس‌دهی حداقل است.

این رویکردی است که Elasticsearch و Riak از آن استفاده می‌کنند.

راه‌حل - Dynamic Partitioning

در این رویکرد (که HBase و MongoDB از آن استفاده می‌کنند)، Partitionها به‌صورت پویا تقسیم می‌شوند:

  • وقتی یک Partition به یک آستانه اندازه رسید، به دو Partition تقسیم می‌شود.
  • وقتی داده حذف شد و Partition خیلی کوچک شد، با Partition مجاور ادغام می‌شود.

مزیت: تعداد Partition متناسب با حجم داده رشد می‌کند، نه صرفاً تعداد ماشین.

Request Routing: Client چطور می‌داند به کجا برود؟

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

رویکرد اول - Client هوشمند: Client از Partition Assignment آگاه است و مستقیماً به ماشین درست وصل می‌شود. نیاز به مکانیزمی برای اطلاع از تغییرات دارد.

رویکرد دوم - Routing Tier: یک لایه میانی (Load Balancer هوشمند) تصمیم می‌گیرد Request به کجا برود. Client فقط با این لایه صحبت می‌کند.

رویکرد سوم - هر Node آگاه: Client به هر Node وصل می‌شود؛ اگر آن Node صاحب Key نباشد، Request را به Node درست Forward می‌کند.

برای هماهنگی در مورد اینکه «کدام Partition روی کدام Node است»، اکثر سیستم‌ها از ZooKeeper یا etcd استفاده می‌کنند - یک سرویس هماهنگی توزیع‌شده که تمام Node‌ها و Routing Tierها می‌توانند آن را Subscribe کنند و از تغییرات باخبر شوند.

جمع‌بندی فصل ششم

کلپمان فصل را با یک نقشه ذهنی شفاف تمام می‌کند:

1
2
3
4
Partitioning by Key Range   →  Range Query کارآمد، خطر Hot Spot
Partitioning by Hash        →  توزیع یکنواخت، Range Query ضعیف
Local Secondary Index       →  Write سریع، Read با Scatter/Gather
Global Secondary Index      →  Read کارآمد، Write پیچیده‌تر

انتخاب استراتژی Partitioning یک تصمیم معماری است که باید بر اساس الگوی دسترسی (Access Pattern) برنامه شما اتخاذ شود - نه بر اساس پیش‌فرض.


فصل هفتم: Transactions - ضمانت‌نامه Database

چرا Transaction وجود دارد؟

کلپمان این فصل را با یک استدلال محکم شروع می‌کند: در دنیای واقعی، بسیاری از چیزها می‌توانند اشتباه پیش بروند - شبکه قطع می‌شود، فرآیند Crash می‌کند، چندین Client همزمان یک داده را تغییر می‌دهند، داده جزئی نوشته می‌شود. Transaction مکانیزمی است که Application را از این پیچیدگی‌ها ایزوله می‌کند.

یک Transaction چندین عملیات Read و Write را در یک واحد منطقی گروه‌بندی می‌کند: یا همه اجرا می‌شوند، یا هیچ‌کدام.

مفهوم ACID: چهار ضمانت بنیادی

ACID مجموعه ضمانت‌هایی است که یک Database Transactional باید ارائه دهد. کلپمان با دقت بسیار نشان می‌دهد که این مفهوم در عمل اغلب اشتباه تفسیر می‌شود:

Atomicity - اتمی بودن

اتمیسیته درباره Concurrency نیست. درباره این است که اگر Transaction در وسط راه شکست بخورد - مثلاً بعد از چند Write اما قبل از Commit - چه اتفاقی می‌افتد.

تعریف دقیق: همه‌یا‌هیچ. اگر Transaction Commit شد، همه تغییرات دائمی می‌شوند. اگر به هر دلیلی Abort شد، هیچ تغییری روی Database اثر نمی‌گذارد و Application می‌تواند با خیال راحت دوباره امتحان کند.

کلید این مفهوم، قابلیت Abort است - نه Concurrency.

Consistency - سازگاری

کلپمان صریحاً می‌گوید که C در ACID یک جانشین ضعیف است و به سختی به سه مفهوم دیگر ربط دارد. Consistency در اینجا یعنی:

Database همیشه در یک وضعیت «خوب» است که Invariantهای شما نقض نمی‌شوند.

اما این Invariantها را Application تعریف می‌کند، نه Database. مثلاً «مجموع بدهی‌ها و اعتبارها باید صفر باشد» یک Invariant است - اما Database خودش این را نمی‌فهمد. Consistency بیشتر مسئولیت Application است تا Database.

Isolation - ایزولاسیون

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

تعریف ایده‌آل: Serializability - نتیجه اجرای Concurrent باید دقیقاً مثل این باشد که Transactionها یکی‌یکی و به‌ترتیب اجرا شده‌اند.

اما این ایده‌آل هزینه Performance دارد و در عمل اکثر Database‌ها از سطوح ضعیف‌تر Isolation استفاده می‌کنند.

Durability - ماندگاری

هر Transaction که Commit شد، داده آن هرگز از دست نمی‌رود - حتی اگر سیستم Crash کند. در عمل این یعنی:

  • نوشتن روی دیسک غیرفرار
  • استفاده از WAL
  • Replication روی چندین ماشین

تهدیدات Concurrency: Race Condition‌ها

کلپمان بخش عمده‌ای از فصل را به تشریح Race Conditionهایی اختصاص می‌دهد که در غیاب Isolation درست رخ می‌دهند. این بخش از مهم‌ترین و عمیق‌ترین بخش‌های کتاب است.

مشکل اول - Dirty Read

Transaction A یک مقدار را نوشته اما هنوز Commit نکرده. Transaction B همان مقدار را می‌خواند - یعنی داده‌ای که ممکن است هنوز Rollback شود:

1
2
3
4
5
6
Transaction A:               Transaction B:
balance = 500                
balance = 500 - 200 = 300    
(هنوز Commit نشده)           READ balance → 300  ❌
ROLLBACK                     
balance = 500 (برگشت)        

Transaction B مقدار ۳۰۰ خواند، اما این مقدار هرگز وجود نداشت.

مشکل دوم - Dirty Write

Transaction B داده‌ای را که Transaction A نوشته اما هنوز Commit نکرده را بازنویسی می‌کند:

1
2
3
Transaction A: خرید ماشین → owner = "Alice" (Commit نشده)
Transaction B: خرید ماشین → owner = "Bob"  (Commit شده)
Transaction A: Commit       → owner = "Alice"

نتیجه: Bob پول پرداخت کرد، Alice ماشین را گرفت!

مشکل سوم - Read Skew (Non-Repeatable Read)

Transaction A در وسط اجرا، داده‌ای می‌خواند که توسط Transaction B در همان لحظه تغییر کرده. یعنی در یک Transaction، یک داده دو بار خوانده می‌شود و نتیجه متفاوت است:

1
2
3
4
5
Transaction A می‌خواند:
  حساب ۱:  500  ✓
  (Transaction B: حساب ۱ → 400، حساب ۲ → 600 Commit می‌شود)
  حساب ۲:  600  ✓ (مقدار جدید!)
  مجموع:  1100  ❌ (باید 1000 باشد)

این مشکل برای Backup و Analytic Queryهایی که Snapshotی از داده می‌گیرند خطرناک است.

مشکل چهارم - Lost Update

دو Transaction هم‌زمان یک مقدار را می‌خوانند، تغییر می‌دهند، و می‌نویسند (الگوی Read-Modify-Write). نتیجه: تغییر اول از دست می‌رود:

1
2
3
4
Transaction A: READ counter = 42
Transaction B: READ counter = 42
Transaction A: WRITE counter = 43
Transaction B: WRITE counter = 43  ← تغییر A از دست رفت!

این مشکل در سناریوهای رایجی مثل Counter، افزودن به لیست، یا ویرایش مشترک Document پیش می‌آید.

راه‌حل‌های رایج:

  • Atomic Write: UPDATE counter SET value = value + 1 - Database خودش قفل می‌گذارد.
  • Explicit Locking: با SELECT FOR UPDATE ردیف را قفل کنید.
  • Compare-and-Swap (CAS): فقط بنویس اگر مقدار عوض نشده باشد.

مشکل پنجم - Write Skew

پیچیده‌ترین Race Condition. دو Transaction داده مرتبط را می‌خوانند، بر اساس آن تصمیم می‌گیرند، و می‌نویسند - به شکلی که Invariant نقض می‌شود:

مثال کلاسیک کلپمان - سیستم پزشکی:

1
2
3
4
5
6
قانون: حداقل یک دکتر باید On-Call باشد.

Transaction A (Alice): دو دکتر On-Call هستند → Alice می‌تواند برود
Transaction B (Bob):   دو دکتر On-Call هستند → Bob می‌تواند برود
Transaction A: Alice را Off-Call می‌کند → یک دکتر مانده
Transaction B: Bob را Off-Call می‌کند  → صفر دکتر! ❌

هر Transaction به‌تنهایی درست بود، اما ترکیب آن‌ها Invariant را نقض کرد. این مشکل را نه Atomic Write و نه CAS حل می‌کنند - تنها Serializable Isolation آن را متوقف می‌کند.

سطوح Isolation

کلپمان چهار سطح Isolation را که SQL Standard تعریف کرده بررسی می‌کند:

سطح IsolationDirty ReadNon-Repeatable ReadPhantom Read
Read Uncommitted❌ ممکن❌ ممکن❌ ممکن
Read Committed✅ محافظت❌ ممکن❌ ممکن
Repeatable Read✅ محافظت✅ محافظت❌ ممکن
Serializable✅ محافظت✅ محافظت✅ محافظت

کلپمان هشدار جدی می‌دهد: این استانداردها ناکافی و مبهم هستند. پایگاه‌داده‌های مختلف همین اسامی را با پیاده‌سازی‌های متفاوت ارائه می‌دهند.

Snapshot Isolation و MVCC

رایج‌ترین راه‌حل برای Read Skew، Snapshot Isolation است. ایده اصلی: هر Transaction یک Snapshot سازگار از Database در لحظه شروع خود می‌بیند. تغییرات بعدی Transactionهای دیگر برای آن نامرئی است.

پیاده‌سازی: Multi-Version Concurrency Control (MVCC). Database به‌جای بازنویسی داده، نسخه‌های متعدد از هر ردیف نگه می‌دارد:

1
2
3
4
ردیف شماره ۱۲:
  txid=1: balance=500  (نسخه اول)
  txid=4: balance=300  (نسخه دوم - Transaction 4 نوشته)
  txid=7: balance=400  (نسخه سوم - Transaction 7 نوشته)

Transaction جدید فقط نسخه‌ای را می‌بیند که در زمان شروعش Committed بوده. این اجازه می‌دهد Read و Write یکدیگر را Block نکنند.

Serializability: قوی‌ترین ضمانت

Serializability تنها سطح Isolation است که تمام Race Conditionها را حذف می‌کند. سه پیاده‌سازی اصلی وجود دارد:

روش اول - Actual Serial Execution

Transactionها واقعاً یکی‌یکی و به‌ترتیب اجرا می‌شوند - هیچ Concurrencyای وجود ندارد. این رویکرد VoltDB و Redis است.

ممکن است این ایده ابلهانه به نظر برسد، اما کلپمان توضیح می‌دهد چرا در موارد خاص کار می‌کند:

  • RAM ارزان شده - می‌توان Dataset کامل را در حافظه نگه داشت.
  • اگر Transaction کوتاه و سریع باشد، یک CPU Core می‌تواند Throughput بالایی داشته باشد.
  • برای OLTP (عملیات کوتاه) مناسب است، نه OLAP (Query‌های تحلیلی طولانی).

روش دوم - Two-Phase Locking (2PL)

قدیمی‌ترین و رایج‌ترین روش برای Serializability. قانون اصلی:

  • اگر Transaction A یک Object را خوانده، Transaction B نمی‌تواند آن را بنویسد.
  • اگر Transaction A یک Object را نوشته، Transaction B نمی‌تواند آن را بخواند یا بنویسد.

دو نوع Lock وجود دارد:

  • Shared Lock (S): برای Read گرفته می‌شود - چند Transaction می‌توانند هم‌زمان داشته باشند.
  • Exclusive Lock (X): برای Write گرفته می‌شود - فقط یک Transaction می‌تواند داشته باشد.

مشکل: احتمال Deadlock - دو Transaction منتظر یکدیگر می‌مانند. Database باید Deadlock را تشخیص دهد و یکی را Abort کند. همچنین در Workload سنگین، Lock‌ها Contention ایجاد می‌کنند و Performance به‌شدت کاهش می‌یابد.

روش سوم - Serializable Snapshot Isolation (SSI)

جدیدترین و هوشمندترین روش. SSI بر پایه Snapshot Isolation ساخته شده اما یک لایه تشخیص اضافه دارد:

  • Transactionها بدون Lock اجرا می‌شوند - Optimistic است.
  • Database تمام تصمیم‌گیری‌هایی که Transaction بر اساس داده‌های خوانده‌شده گرفته را ردیابی می‌کند.
  • وقتی Transaction می‌خواهد Commit کند، Database بررسی می‌کند آیا داده‌ای که خوانده تغییر کرده - اگر بله، Transaction Abort می‌شود و باید دوباره امتحان کند.

مزیت عظیم: Read‌ها هرگز Write‌ها را Block نمی‌کنند و بالعکس. در Workloadهایی که Conflict کم است، SSI بسیار بهتر از 2PL عمل می‌کند.

جمع‌بندی فصل هفتم

کلپمان فصل را با یک نقشه ذهنی جامع تمام می‌کند:

1
2
3
4
5
6
7
8
9
Dirty Read/Write     →  Read Committed حل می‌کند
Read Skew            →  Snapshot Isolation (MVCC) حل می‌کند
Lost Update          →  Atomic Write یا Explicit Lock حل می‌کند
Write Skew/Phantom   →  فقط Serializable Isolation حل می‌کند

پیاده‌سازی Serializable:
  Actual Serial Execution  →  ساده، فقط برای Dataset کوچک در RAM
  Two-Phase Locking        →  قوی، اما Deadlock و Performance ضعیف
  SSI                      →  بهترین Performance، مناسب Conflict کم

Transaction نه یک ویژگی اضافه، بلکه قراردادی است بین Database و Application - قراردادی که می‌گوید: «تو درباره Concurrency نگران نباش، من مراقبم.»


فصل هشتم: The Trouble with Distributed Systems - دردسرهای سیستم‌های توزیع‌شده

تغییر پارادایم: از خوش‌بینی به واقع‌گرایی

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

هدف این فصل ایجاد یک Mental Model واقع‌بینانه است - نه برای ترساندن، بلکه برای اینکه بتوانیم سیستم‌هایی بسازیم که با این خرابی‌ها کنار بیایند.

شبکه: غیرقابل‌اعتماد به‌شکل اساسی

مشکل اصلی: Partial Failure

در یک کامپیوتر منفرد، همه چیز Deterministic است: یا کار می‌کند یا نمی‌کند. اما در یک سیستم توزیع‌شده، Partial Failure امکان‌پذیر است - بعضی اجزا کار می‌کنند، بعضی نه، و بدتر از همه: نمی‌دانیم کدام.

کلپمان یک سناریو ساده می‌آورد: یک پیام به Node دیگری می‌فرستید. چه اتفاقاتی ممکن است بیفتد؟

  • پیام در شبکه گم شود.
  • پیام در یک صف منتظر بماند و با تأخیر برسد.
  • Node مقصد Crash کرده باشد.
  • Node مقصد پیام را پردازش کرده اما پاسخ گم شده باشد.
  • Node مقصد پیام را پردازش کرده اما با تأخیر پاسخ می‌دهد.

در تمام این حالات، فرستنده نمی‌داند چه اتفاقی افتاده - تنها چیزی که می‌داند این است که پاسخی نگرفته.

Timeout: تنها ابزار تشخیص

تنها روش عملی برای تشخیص خرابی در شبکه، Timeout است. اما Timeout دو مشکل اساسی دارد:

Timeout کوتاه: سریع تشخیص می‌دهد اما ممکن است یک Node را اشتباهاً مرده اعلام کند - در حالی که فقط کند بوده. این باعث می‌شود بار اضافی روی Node‌های دیگر بریزد که خودشان هم زیر فشار هستند، و این ممکن است یک Cascading Failure ایجاد کند.

Timeout طولانی: مطمئن‌تر است اما در صورت خرابی واقعی، زمان بیشتری طول می‌کشد تا سیستم واکنش نشان دهد.

کلپمان می‌گوید مقدار Timeout ایده‌آل ثابت نیست - به ترافیک شبکه، بار سیستم و قابل‌قبول بودن تأخیر بستگی دارد. برخی سیستم‌ها Timeout را به‌صورت پویا و بر اساس زمان پاسخ مشاهده‌شده تنظیم می‌کنند.

ساعت‌ها: غیرقابل‌اعتماد به‌شکل پنهان

دو نوع ساعت

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

نوعکاربردمشکل
Time-of-Day Clockزمان واقعی روز - System.currentTimeMillis()می‌تواند به عقب برگردد (NTP Sync)
Monotonic Clockاندازه‌گیری مدت زمان - System.nanoTime()فقط در یک Node معنا دارد

NTP و دقت ساعت

ساعت‌های کامپیوترها به‌مرور Drift می‌کنند - یعنی از زمان واقعی فاصله می‌گیرند. NTP (Network Time Protocol) برای همگام‌سازی استفاده می‌شود، اما دقت آن محدود است:

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

چرا ساعت نادقیق خطرناک است؟

کلپمان یک مثال ترسناک می‌آورد: فرض کنید برای تشخیص آخرین Write، از Timestamp استفاده می‌کنید (رویکرد Last Write Wins). اگر ساعت Node A چند صد میلی‌ثانیه جلوتر از Node B باشد، Write جدیدتری که روی Node B انجام شده قدیمی‌تر به نظر می‌رسد و حذف می‌شود.

Google Spanner این مشکل را با TrueTime API حل کرده - هر Node یک GPS Receiver یا Atomic Clock دارد و Spanner به‌جای یک Timestamp دقیق، یک بازه زمانی با حداقل و حداکثر برمی‌گرداند. این باعث می‌شود سیستم دقیقاً بداند چه زمانی می‌تواند مطمئن باشد که یک رویداد قبل از دیگری اتفاق افتاده.

Process Pause: دشمن پنهان

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

دلایل این توقف:

  • Garbage Collector: در زبان‌هایی مثل Java یا C#، GC می‌تواند برای چند ثانیه همه Thread‌ها را متوقف کند (Stop-the-World GC).
  • VM Suspension: در محیط‌های Virtualized، Hypervisor می‌تواند VM را Suspend کند.
  • Swap/Paging: اگر OS شروع به Swapping کند، فرآیند می‌تواند برای مدت طولانی Pause شود.
  • Scheduling: OS می‌تواند هر زمانی Thread را از CPU خارج کند.

مثال کلاسیک: Leader Lease

این سناریو را تصور کنید:

1
2
3
4
5
6
7
Node A: Leader است، یک "Lease" (اجاره) برای ۱۰ ثانیه دارد
Node A: Lease را بررسی می‌کند → هنوز ۶ ثانیه مانده ✓
Node A: (GC Pause برای ۱۵ ثانیه!)
Node A: از GC برمی‌گردد
Node A: فکر می‌کند هنوز Leader است ← اشتباه!
(Lease منقضی شده، Node B حالا Leader جدید است)
Node A و Node B هر دو فکر می‌کنند Leader هستند → Split-Brain!

هیچ کدی که در یک Process اجرا می‌شود نمی‌تواند از یک Pause طولانی در امان باشد. این یعنی هیچ Node نمی‌تواند با اطمینان مطلق بگوید «من الان Leader هستم».

حقیقت را چه کسی تعریف می‌کند؟

رأی اکثریت (Quorum)

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

مثال: یک Node که از شبکه جدا شده ممکن است فکر کند همه چیز خوب است. اما اگر بقیه Node‌ها توافق کنند که آن Node مرده است، آن Node واقعاً مرده محسوب می‌شود - حتی اگر خودش فکر کند زنده است.

Byzantine Faults: وقتی Node دروغ می‌گوید

تا اینجا فرض کردیم Node‌های خراب فقط ساکت می‌شوند - یا پاسخ می‌دهند یا نمی‌دهند. اما Byzantine Fault زمانی است که یک Node پاسخ غلط یا گمراه‌کننده می‌دهد.

مثال: یک Node که توسط مهاجم کنترل شده، عمداً داده غلط ارسال می‌کند.

مقابله با Byzantine Faults بسیار پیچیده‌تر و پرهزینه‌تر از Crash Faults است. در اکثر سیستم‌های داخلی (که Node‌ها تحت کنترل یک سازمان هستند)، این مشکل نادیده گرفته می‌شود. اما در سیستم‌هایی مثل Blockchain که Node‌ها ناشناس هستند، Byzantine Fault Tolerance (BFT) ضروری است.

اصول سیستم‌سازی با سخت‌افزار غیرقابل‌اعتماد

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

برای رسیدن به این هدف، باید:

  • هیچ فرضی درباره زمان‌بندی نداشته باشیم - هر عملیات ممکن است به‌صورت غیرمنتظره کند شود.
  • هیچ فرضی درباره ترتیب رویدادها نداشته باشیم - بدون مکانیزم صریح، نمی‌توانیم بگوییم رویداد A قبل از B بوده.
  • هیچ فرضی درباره یکتایی Node‌ها نداشته باشیم - همیشه ممکن است چندین Node فکر کنند Leader هستند.
  • الگوریتم‌هایی طراحی کنیم که به شکل Graceful Degrade کنند - نه اینکه یا کامل کار کنند یا کاملاً بشکنند.

جمع‌بندی فصل هشتم

کلپمان با یک تصویر روشن این فصل را تمام می‌کند:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
مشکلات شبکه:
  پیام گم می‌شود، تأخیر دارد، یا تکراری می‌رسد
  Timeout تنها ابزار تشخیص است، اما ناقص

مشکلات ساعت:
  Drift و NTP دقت را محدود می‌کنند
  نمی‌توان روی ترتیب رویدادها با Timestamp اعتماد کرد

مشکلات Process:
  هر Node ممکن است بدون اطلاع برای مدت نامشخص Pause شود
  هیچ Node نمی‌تواند با اطمینان مطلق بگوید «الان چه زمانی است»

نتیجه:
  در سیستم‌های توزیع‌شده، عدم‌قطعیت اساسی است
  سیستم باید با وجود این عدم‌قطعیت، درست رفتار کند

این فصل زمینه‌ساز فصل نهم است - جایی که کلپمان نشان می‌دهد با وجود تمام این مشکلات، چگونه می‌توان Consistency و Consensus را در سیستم‌های توزیع‌شده به دست آورد.


فصل نهم: Consistency and Consensus - سازگاری و توافق

هدف این فصل: ساختن چیز قابل‌اعتماد از اجزای غیرقابل‌اعتماد

کلپمان فصل نهم را با یک جمله محوری شروع می‌کند: بهترین راه برای ساختن سیستم‌های fault-tolerant این است که abstractions با ضمانت‌های قوی پیدا کنیم، آن‌ها را یک‌بار درست پیاده‌سازی کنیم، و به Application اجازه دهیم روی این پایه بنا کند.

مهم‌ترین این Abstractionها Consensus است - یعنی رسیدن چندین Node به توافق روی یک مقدار، با وجود خرابی‌های احتمالی.

Consistency Guarantees: از ضعیف تا قوی

Eventual Consistency: ضعیف‌ترین ضمانت

در اکثر Replicated Databaseها، اگر همزمان به دو Replica نگاه کنید، ممکن است داده متفاوت ببینید - چون Replication Lag وجود دارد. اما اگر صبر کنید و Write جدیدی نرسد، در نهایت همه Replicaها همگرا می‌شوند. این Eventual Consistency است.

مشکل: «در نهایت» یعنی چه؟ این مفهوم بسیار مبهم است - می‌تواند چند میلی‌ثانیه یا چند ساعت باشد. برای Application نوشتن Code درست با این ضمانت بسیار دشوار است.

Linearizability: قوی‌ترین ضمانت

تعریف دقیق

Linearizability (که به آن Atomic Consistency یا Strong Consistency هم می‌گویند) قوی‌ترین ضمانت Consistency است. ایده اصلی این است که سیستم را طوری نشان دهیم که انگار فقط یک کپی از داده وجود دارد.

تعریف دقیق: بعد از اینکه یک Write کامل شد، هر Read بعدی از هر Node باید همان مقدار جدید را برگرداند - حتی اگر Write و Read روی Replicaهای مختلف باشند.

1
2
3
4
Client A: ──── Write x=1 ────►|
Client B:                      |──── Read x ────► باید 1 برگردد
Client C:            ──── Read x ────► ممکن است 0 یا 1 برگردد
                     (همزمان با Write)

Client C چون Read او با Write همزمان است، ممکن است مقدار قدیمی یا جدید ببیند. اما Client B که بعد از پایان Write می‌خواند، حتماً باید مقدار جدید ببیند.

کِی Linearizability نیاز است؟

کلپمان سه سناریوی واقعی معرفی می‌کند:

سناریو اول - Leader Election: در یک سیستم با Single-Leader Replication، همه Node‌ها باید توافق کنند کدام Node Leader است. اگر دو Node هم‌زمان فکر کنند Leader هستند، Split-Brain ایجاد می‌شود. Linearizability اینجا ضروری است.

سناریو دوم - Unique Constraints: فرض کنید دو کاربر هم‌زمان می‌خواهند یک Username یکسان ثبت کنند. باید فقط یکی موفق شود. این نیاز به یک Atomic Compare-and-Set دارد که بدون Linearizability غیرممکن است.

سناریو سوم - Cross-Channel Timing: یک کاربر عکس آپلود می‌کند. سرویس ذخیره‌سازی به سرویس Resize اطلاع می‌دهد. اگر سرویس Resize قبل از اینکه Replication کامل شود عکس را بخواند، عکس قدیمی یا ناموجود می‌بیند. Linearizability این مشکل را حل می‌کند.

هزینه Linearizability: قضیه CAP

کلپمان قضیه CAP را اینجا معرفی می‌کند - اما با یک نقد جدی:

CAP: یک سیستم توزیع‌شده نمی‌تواند همزمان هر سه را داشته باشد:

  • Consistency (Linearizability)
  • Availability
  • Partition Tolerance

اما کلپمان می‌گوید این قضیه گمراه‌کننده است:

  • Network Partition یک انتخاب نیست - اجتناب‌ناپذیر است.
  • پس سوال واقعی این است: در هنگام Partition، بین Consistency و Availability کدام را انتخاب می‌کنیم؟
  • اگر Linearizability را انتخاب کنید، باید منتظر بمانید تا Partition برطرف شود - Availability از دست می‌رود.
  • اگر Availability را انتخاب کنید، ممکن است داده Stale برگردد - Linearizability از دست می‌رود.

Ordering و Causality

چرا ترتیب مهم است؟

کلپمان نشان می‌دهد که بسیاری از مشکلات Consistency که دیدیم، در واقع ریشه در نقض Causality دارند:

  • Read Skew: مشاهده داده در یک نقطه زمانی ناسازگار.
  • Write Skew: تصمیم بر اساس یک Premise که در زمان Commit دیگر درست نبود.
  • Replication Lag: دیدن جواب قبل از سوال.

Causal Order این است: اگر رویداد B به دلیل رویداد A اتفاق افتاده، پس A باید قبل از B دیده شود - در همه Node‌ها.

Sequence Numbers و Lamport Timestamps

برای ردیابی Causal Order، می‌توان از Sequence Number استفاده کرد. اما در یک سیستم توزیع‌شده با چند Node، چه کسی این اعداد را تولید می‌کند؟

Lamport Timestamp راه‌حل کلاسیک است:

  • هر Node یک Counter دارد.
  • هر عملیات Counter را یک واحد افزایش می‌دهد.
  • وقتی پیامی دریافت می‌شود، Counter به بزرگ‌ترین مقدار (بین Counter محلی و Counter پیام) به‌علاوه یک تنظیم می‌شود.
1
2
Node A: (1,A) → (2,A) → (3,A)
Node B:        (1,B) → پیام از A با (3,A) می‌رسد → (4,B)

نتیجه: یک ترتیب کلی (Total Order) از همه رویدادها داریم. اما یک مشکل باقی می‌ماند: Lamport Timestamp فقط بعد از واقعیت به ما می‌گوید ترتیب چیست - نه در لحظه تصمیم‌گیری. برای مثال Username یکتا، باید در لحظه بدانیم آیا کسی دیگری همین Username را گرفته یا نه.

این مشکل ما را به نیاز به Total Order Broadcast می‌رساند.

Total Order Broadcast

Total Order Broadcast یک پروتکل است که تضمین می‌کند:

  • هیچ پیامی گم نمی‌شود - اگر به یک Node تحویل داده شد، به همه تحویل داده می‌شود.
  • پیام‌ها به ترتیب یکسان به همه Node‌ها تحویل داده می‌شوند.

این دقیقاً همان چیزی است که یک Replicated State Machine نیاز دارد: اگر همه Node‌ها با همان وضعیت اولیه شروع کنند و عملیات‌ها را به ترتیب یکسان اعمال کنند، همه به نتیجه یکسان می‌رسند.

Total Order Broadcast ارتباط نزدیکی با Consensus دارد - در واقع اگر یکی را پیاده‌سازی کنید، دیگری را هم دارید.

Consensus: قلب این فصل

تعریف مسئله Consensus

Consensus به این معناست: چندین Node باید روی یک مقدار توافق کنند. این مسئله باید سه شرط را برآورده کند:

شرطتوضیح
Uniform Agreementهیچ دو Node نباید تصمیم‌های متفاوت بگیرند
Integrityهیچ Node نباید دو بار تصمیم بگیرد
Validityمقدار توافق‌شده باید یکی از مقادیر پیشنهادشده باشد
Terminationهر Node که Crash نکرده، در نهایت باید به یک تصمیم برسد

شرط Termination اهمیت ویژه‌ای دارد: یعنی الگوریتم نباید برای همیشه منتظر بماند - حتی اگر برخی Node‌ها Crash کنند.

قضیه FLP: یک محدودیت اساسی

کلپمان قضیه مهمی را ذکر می‌کند: قضیه FLP (Fischer، Lynch، Paterson) ثابت می‌کند که در یک سیستم کاملاً Asynchronous (بدون هیچ فرضی درباره زمان‌بندی)، هیچ الگوریتم Consensus‌ای نمی‌تواند در صورت خرابی حتی یک Node، Termination را تضمین کند.

اما در عمل، الگوریتم‌های Consensus این محدودیت را با استفاده از Timeout دور می‌زنند - یعنی فرض‌هایی درباره زمان‌بندی می‌کنند. این کاملاً عملی است.

الگوریتم‌های Consensus در دنیای واقعی

Paxos: الگوریتم کلاسیک که توسط Leslie Lamport طراحی شده. درک آن دشوار است و پیاده‌سازی عملی آن از Paper اصلی فاصله زیادی دارد.

Raft: الگوریتمی که با هدف قابل‌فهم بودن طراحی شده. پایه etcd و CockroachDB است. Raft کار را به سه مسئله مستقل تقسیم می‌کند:

1
2
3
۱. Leader Election:   چه کسی Leader است؟
۲. Log Replication:   Leader چطور Log را توزیع می‌کند؟
۳. Safety:            چطور تضمین می‌کنیم نتایج سازگار است؟

Zab: الگوریتمی که در Apache ZooKeeper استفاده می‌شود و پایه بسیاری از سیستم‌های توزیع‌شده بزرگ است.

ZooKeeper: Consensus به‌عنوان سرویس

کلپمان می‌گوید پیاده‌سازی مستقیم الگوریتم‌های Consensus بسیار دشوار است. به همین دلیل در عمل، از سرویس‌های آماده مثل ZooKeeper یا etcd استفاده می‌شود.

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

  • Linearizable Atomic Operations: مثل Compare-and-Set برای انتخاب Leader یا ثبت Username یکتا.
  • Total Ordering of Operations: هر عملیات یک Monotonically Increasing Transaction ID دارد.
  • Failure Detection: هر Client یک Session با ZooKeeper دارد؛ اگر Session منقضی شود، ZooKeeper Node را مرده اعلام می‌کند.
  • Change Notifications: Client‌ها می‌توانند تغییرات را Subscribe کنند.

نکته مهم: ZooKeeper برای کوچکی و کندی تغییرات طراحی شده - برای ذخیره Configuration، اطلاعات Membership، و Leader Election. نه برای ذخیره داده‌های بزرگ Application.

جمع‌بندی فصل نهم

کلپمان این فصل را با یک نقشه ذهنی کامل تمام می‌کند:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Linearizability
  → قوی‌ترین ضمانت Consistency
  → هزینه: Latency بالا، آسیب‌پذیری در Partition
  → کاربرد: Leader Election، Unique Constraints

Causal Consistency
  → ضعیف‌تر از Linearizability، قوی‌تر از Eventual
  → هزینه کمتر، عملی‌تر
  → با Lamport Timestamp یا Vector Clocks پیاده‌سازی می‌شود

Total Order Broadcast
  → پایه Replicated State Machine
  → معادل Consensus است

Consensus (Raft, Paxos, Zab)
  → حل مسئله Leader Election و Atomic Commit
  → در عمل از ZooKeeper یا etcd استفاده کنید

فصل نهم پایان بخش دوم کتاب است. بخش سوم - Derived Data - نشان می‌دهد چگونه از این پایه‌ها برای ساختن سیستم‌های تحلیلی قدرتمند استفاده کنیم.


بخش سوم کتاب: Derived Data - داده‌های مشتق‌شده

تمایز اساسی: System of Record در برابر Derived Data

کلپمان بخش سوم را با یک تمایز مفهومی مهم شروع می‌کند که اغلب نادیده گرفته می‌شود:

System of Record (Source of Truth): منبع اصلی و اقتدار نهایی داده. هر داده‌ای که از کاربر دریافت می‌شود اینجا ذخیره می‌شود. اگر تعارضی بین دو سیستم باشد، این سیستم حق دارد.

Derived Data: داده‌ای که با تبدیل یا پردازش داده دیگری ایجاد شده. Cache، Search Index، Denormalized View - همه اینها Derived Data هستند. اگر از دست بروند، می‌توان آن‌ها را از Source of Truth بازسازی کرد.

این تمایز به شما می‌گوید کجا می‌توانید Trade-off کنید و کجا نمی‌توانید.

فصل دهم: Batch Processing - پردازش دسته‌ای

سه نوع سیستم پردازش داده

کلپمان سه دسته‌بندی اصلی برای سیستم‌های پردازش داده ارائه می‌دهد:

نوع سیستمInputOutputمثال
Services (Online)یک Request از Clientپاسخ به همان ClientWeb Server، API
Batch Processing (Offline)مجموعه بزرگی از دادهخروجی پردازش‌شدهMapReduce، ETL
Stream Processing (Near-Real-Time)رویدادهای پیوستهخروجی پیوستهKafka Streams، Flink

Batch Processing کاربر منتظری ندارد - یک Job بزرگ روی داده‌های انباشته اجرا می‌شود. معیار اصلی آن Throughput است، نه Latency.

Unix Philosophy: ریشه‌های Batch Processing

یکی از جذاب‌ترین بخش‌های این فصل، ارتباطی است که کلپمان بین Unix Tools و سیستم‌های مدرن Batch Processing برقرار می‌کند.

Unix یک فلسفه قدرتمند دارد که در دهه ۱۹۷۰ شکل گرفت:

  • هر برنامه یک کار را خوب انجام دهد.
  • برنامه‌ها با stdin/stdout ارتباط برقرار کنند.
  • خروجی هر برنامه ورودی برنامه بعدی باشد (Pipeline).

یک مثال ساده: پیدا کردن پنج URL پراستفاده‌ترین در Log:

1
2
3
4
5
6
cat access.log \
  | awk '{print $7}' \
  | sort \
  | uniq -c \
  | sort -rn \
  | head -n 5

هر دستور یک کار انجام می‌دهد. Pipe آن‌ها را به هم وصل می‌کند. کلپمان می‌گوید این همان ایده پشت MapReduce است - فقط در مقیاس هزاران ماشین.

ویژگی مهم Unix: Immutability. هیچ‌کدام از این دستورات فایل Input را تغییر نمی‌دهند. این یعنی می‌توانید Pipeline را دوباره اجرا کنید، نتیجه یکسان خواهد بود.

MapReduce: Unix در مقیاس Distributed

معرفی

MapReduce در سال ۲۰۰۴ توسط Google در یک Paper معرفی شد و سپس در Apache Hadoop به‌صورت Open Source پیاده‌سازی شد. ایده اصلی: همان فلسفه Unix Pipeline، اما روی هزاران ماشین.

یک MapReduce Job از دو مرحله تشکیل می‌شود:

مرحله Map: یک تابع روی هر رکورد اجرا می‌شود و Key-Value جفت تولید می‌کند. این مرحله کاملاً Parallel است - هر Mapper فقط داده محلی خودش را پردازش می‌کند.

مرحله Reduce: تمام Key-Value جفت‌هایی که Key یکسانی دارند به یک Reducer تحویل داده می‌شوند. Reducer آن‌ها را جمع‌آوری و تجمیع می‌کند.

بین Map و Reduce، یک مرحله Sort and Shuffle وجود دارد که کلیدهای یکسان را به Reducer یکسان هدایت می‌کند.

مثال: شمارش URL‌ها

1
2
3
4
5
6
7
8
# Map Function
def map(log_line):
    url = extract_url(log_line)
    emit(url, 1)

# Reduce Function  
def reduce(url, counts):
    emit(url, sum(counts))
1
2
3
4
5
6
Input:          Map:           Shuffle:        Reduce:
/index   →      (/index, 1)    /index: 
/about   →      (/about, 1)    /about: 
/index   →      (/index, 1)
/index   →      (/index, 1)
/about   →      (/about, 1)

Fault Tolerance در MapReduce

یکی از قوی‌ترین ویژگی‌های MapReduce، مدیریت خرابی است. اگر یک Mapper یا Reducer در وسط کار Crash کند، فقط همان Task دوباره اجرا می‌شود - نه کل Job.

این کار ممکن است چون:

  • Input فایل‌ها Immutable هستند - هرگز تغییر نمی‌کنند.
  • خروجی هر Task در یک فایل موقت نوشته می‌شود و فقط بعد از کامل شدن Atomic Rename می‌شود.
  • Task‌ها Deterministic هستند - اگر دوباره اجرا شوند، نتیجه یکسان است.

Join در MapReduce

چرا Join سخت است؟

در یک Database، Join ساده است چون تمام داده در یک مکان است. اما در MapReduce داده روی هزاران ماشین پخش شده. کلپمان سه روش اصلی Join را معرفی می‌کند:

روش اول - Sort-Merge Join

هر دو Dataset روی همان Key مرتب می‌شوند. سپس Reducer رکوردهایی با Key یکسان را از هر دو Dataset با هم می‌بیند:

1
2
3
4
5
6
7
8
9
10
Dataset A (Users):
  user_id=5 → {name: "Alice"}

Dataset B (Events):
  user_id=5 → {event: "click"}
  user_id=5 → {event: "purchase"}

Reducer برای user_id=5:
  می‌بیند: User Alice + Events [click, purchase]
  می‌نویسد: {user: Alice, events: [click, purchase]}

روش دوم - Broadcast Hash Join

اگر یک Dataset کوچک باشد (مثل جدول Lookup)، می‌توان آن را در حافظه هر Mapper بارگذاری کرد. سپس هر Mapper بدون نیاز به Shuffle می‌تواند Join انجام دهد.

این سریع‌ترین روش است - هیچ داده‌ای روی شبکه منتقل نمی‌شود.

روش سوم - Partitioned Hash Join

اگر هر دو Dataset بزرگ باشند اما به شکل یکسانی Partition شده باشند، هر Mapper فقط با Partition مربوط به خودش کار می‌کند.

فراتر از MapReduce: Dataflow Engines

مشکلات MapReduce

کلپمان صادقانه می‌گوید MapReduce برای بسیاری از موارد کند و ناکارآمد است:

  • بعد از هر مرحله Map یا Reduce، خروجی روی HDFS نوشته می‌شود - سپس مرحله بعدی آن را می‌خواند. این I/O اضافی بسیار پرهزینه است.
  • برای یک Pipeline پیچیده با ۱۰ مرحله، داده ۱۰ بار روی دیسک نوشته و خوانده می‌شود.
  • هر Job باید از ابتدا شروع کند - هیچ اطلاعاتی از Job قبلی حفظ نمی‌شود.

Apache Spark: جایگزین هوشمند

Apache Spark این مشکلات را با یک رویکرد اساساً متفاوت حل می‌کند:

  • به‌جای نوشتن روی دیسک بین مراحل، نتایج در حافظه نگه می‌دارد.
  • کل Pipeline را به‌عنوان یک DAG (Directed Acyclic Graph) مدل می‌کند.
  • قبل از اجرا، یک Query Optimizer بهترین ترتیب اجرا را پیدا می‌کند.
  • اگر یک Node Crash کند، فقط همان بخش از DAG دوباره محاسبه می‌شود (Lineage).
1
2
3
4
5
MapReduce Pipeline:
Map → Write HDFS → Read HDFS → Reduce → Write HDFS → Read HDFS → ...

Spark Pipeline:
Map → Reduce → Map → Reduce  (همه در حافظه، یک بار Write نهایی)

سرعت Spark برای Workloadهای تکراری می‌تواند ۱۰۰ برابر از MapReduce بیشتر باشد.

خروجی Batch Processing: چه می‌سازیم؟

کلپمان می‌گوید خروجی یک Batch Job معمولاً یکی از دو چیز است:

ساختن Derived Data:

  • Search Indexes: Google و Bing از Batch Processing برای ساختن Inverted Index استفاده می‌کنند.
  • ML Models: یک Model پیش‌بینی که روی Dataset Training شده.
  • Aggregated Reports: آمار روزانه، هفتگی یا ماهانه.

ساختن Key-Value Stores: خروجی Job را به‌صورت یک Read-Only Database می‌سازند که Application می‌تواند آن را Query کند. چون این Database Immutable است و فقط خوانده می‌شود، می‌توان آن را روی هزاران Node توزیع کرد بدون هیچ Concurrency مشکلی.

فلسفه Batch Processing: Immutability

کلپمان در پایان فصل یک اصل فلسفی مهم مطرح می‌کند که Unix و MapReduce هر دو آن را رعایت می‌کنند:

Input را هرگز تغییر نده. فقط بخوان و خروجی جدید تولید کن.

این اصل مزایای عمیقی دارد:

  • اگر Bug داشتید، می‌توانید Job را دوباره اجرا کنید و خروجی درست تولید کنید.
  • می‌توانید چندین Job مختلف را همزمان روی یک Input اجرا کنید.
  • Audit Trail کامل دارید - همیشه می‌دانید داده از کجا آمده.
  • اشکال‌زدایی آسان‌تر است - می‌توانید هر مرحله را جداگانه بررسی کنید.

این همان اصلی است که در Event Sourcing و Immutable Logها (مثل Kafka) هم دیده می‌شود.

جمع‌بندی فصل دهم

1
2
3
4
5
6
7
8
9
10
11
Unix Tools        →  فلسفه Pipeline، Immutability، یک کار خوب
MapReduce         →  Unix در مقیاس Distributed، Fault Tolerant
Spark / Flink     →  DAG به‌جای Map-Reduce، In-Memory، سریع‌تر

انواع Join:
  Sort-Merge      →  هر دو Dataset بزرگ
  Broadcast Hash  →  یک Dataset کوچک در حافظه
  Partitioned Hash→  هر دو Partition‌شده یکسان

خروجی Batch:
  Search Index، ML Model، Key-Value Store، Aggregated Report

فصل یازدهم: Stream Processing - پردازش جریانی

تفاوت بنیادی با Batch Processing

کلپمان فصل را با یک سوال ساده شروع می‌کند: چرا باید منتظر بمانیم تا داده انباشته شود و سپس Batch Job اجرا کنیم؟ چرا نمی‌توانیم همان لحظه که داده تولید می‌شود آن را پردازش کنیم؟

در Batch Processing، Input یک Dataset محدود و ثابت است - می‌دانیم کجا تمام می‌شود. اما در Stream Processing، Input یک جریان بی‌پایان از رویدادها است که هرگز تمام نمی‌شود.

Event، Stream و Message Broker

رویداد (Event) چیست؟

یک Event یک رکورد کوچک و Immutable است که یک چیز را ثبت می‌کند: «این اتفاق در این لحظه افتاد.» مثال‌ها:

  • کاربر روی یک لینک کلیک کرد.
  • یک سنسور دما را اندازه گرفت.
  • یک تراکنش مالی انجام شد.
  • یک کاربر Login کرد.

Event‌ها توسط یک Producer تولید و توسط یک یا چند Consumer پردازش می‌شوند.

Message Broker: واسطه‌ای ضروری

به‌جای اینکه Producer مستقیماً به Consumer بفرستد، از یک Message Broker (یا Message Queue) استفاده می‌شود. این معماری مزایای مهمی دارد:

  • Decoupling زمانی: اگر Consumer در لحظه‌ای Offline باشد، Message Broker پیام را نگه می‌دارد.
  • Load Balancing: چند Consumer می‌توانند از یک Queue بخوانند.
  • Fan-out: یک پیام می‌تواند به چند Consumer مختلف برسد.

دو مدل Message Broker

مدل اول - Queue سنتی (مثل RabbitMQ): پیام‌ها بعد از خوانده شدن حذف می‌شوند. هر پیام فقط یک بار به یک Consumer تحویل داده می‌شود. مثل یک صف کار.

مدل دوم - Log-Based (مثل Apache Kafka): پیام‌ها روی یک Append-Only Log نوشته می‌شوند و حذف نمی‌شوند. هر Consumer موقعیت خودش (Offset) را در Log نگه می‌دارد و می‌تواند هر زمان به گذشته برگردد.

Apache Kafka: Log-Based Message Broker

کلپمان توجه ویژه‌ای به Apache Kafka دارد چون معماری آن از نظر فلسفی بسیار عمیق است.

ساختار Kafka

Kafka داده را در Topicها سازماندهی می‌کند. هر Topic به چندین Partition تقسیم می‌شود. در هر Partition، پیام‌ها به‌صورت Append-Only نوشته می‌شوند و هر پیام یک Offset عددی دارد:

1
2
3
4
Topic: user-events
  Partition 0: [0: click] [1: login] [2: purchase] [3: logout] ...
  Partition 1: [0: click] [1: click] [2: logout] ...
  Partition 2: [0: login] [1: purchase] ...

Consumer Group و Offset

هر Consumer Group موقعیت خودش در هر Partition را با یک Offset ردیابی می‌کند. وقتی Consumer یک پیام را پردازش کرد، Offset را Commit می‌کند.

مزیت بزرگ: اگر Consumer Crash کند و دوباره راه‌اندازی شود، از همان جایی که مانده بود ادامه می‌دهد. همچنین می‌توان یک Consumer Group جدید ایجاد کرد که از ابتدای Log شروع کند - یعنی همه Event‌های گذشته را دوباره پردازش کند.

این ویژگی Kafka را به یک Source of Truth تبدیل می‌کند، نه صرفاً یک صف موقت.

پردازش Stream: عملیات اصلی

Stateless Transformation

ساده‌ترین عملیات: هر Event را مستقل از بقیه تبدیل می‌کنیم:

  • Filter: فقط Event‌هایی که شرط دارند را نگه بدار.
  • Map: هر Event را به فرمت دیگری تبدیل کن.
  • Split: یک Stream را به چند Stream بر اساس نوع تقسیم کن.

Stateful Transformation: Join و Aggregation

وقتی نیاز داریم چند Event را با هم ترکیب کنیم، به State نیاز داریم. کلپمان سه نوع Join در Stream Processing را معرفی می‌کند:

Stream-Stream Join: دو Stream را بر اساس یک Key Join می‌کنیم - اما هر دو طرف پیوسته هستند. مثال: برای هر Search Query، اگر در بازه ۱ ساعته بعد از آن یک Click رخ داد، آن‌ها را به هم وصل کن (برای محاسبه Click-Through Rate).

Stream-Table Join (Enrichment): هر Event از Stream با داده ثابتی از یک Database غنی‌سازی می‌شود. مثال: هر Event که User ID دارد، با جدول Users Join می‌شود تا نام کاربر اضافه شود.

Table-Table Join: دو Changelog Stream (جریانی که تغییرات یک جدول را دارد) با هم Join می‌شوند. نتیجه یک Materialized View به‌روز است.

Windows: مدیریت زمان در Stream

چون Stream بی‌پایان است، نمی‌توان گفت «روی همه داده‌ها Aggregation کن.» باید یک پنجره زمانی (Window) تعریف کنیم.

کلپمان چهار نوع Window معرفی می‌کند:

نوع Windowتوضیحمثال
Tumbling Windowپنجره‌های ثابت و غیرهمپوشانهر ۵ دقیقه یک بار
Hopping Windowپنجره‌های ثابت با همپوشانیهر ۵ دقیقه، پنجره ۱۰ دقیقه‌ای
Sliding Windowهمه Event‌های ۱۰ دقیقه گذشتهپنجره پیوسته
Session WindowActivity یک کاربر تا زمان سکوتSession کاربر

مشکل Event Time در برابر Processing Time

یکی از چالش‌های اصلی Stream Processing این است که زمان رسیدن Event با زمان وقوع Event ممکن است متفاوت باشد:

1
2
Event در ساعت ۱۴:۰۰ رخ داده
Event در ساعت ۱۴:۰۵ به Broker رسیده (به خاطر شبکه یا موبایل Offline)

اگر Window بر اساس Processing Time باشد، این Event در Window اشتباه قرار می‌گیرد. اگر بر اساس Event Time باشد، باید برای Event‌های دیرهنگام (Late Events) صبر کنیم - اما چقدر؟

Watermark راه‌حل این مشکل است: یک نشانه‌ای که می‌گوید «تمام Event‌هایی که قبل از زمان T رخ داده‌اند احتمالاً رسیده‌اند.» بعد از Watermark، Window بسته می‌شود و نتیجه Emit می‌شود. اما Event‌های خیلی دیرهنگام که بعد از Watermark می‌رسند، یا نادیده گرفته می‌شوند یا یک Correction ارسال می‌کنند.

Fault Tolerance در Stream Processing

چالش: دقیقاً یک بار (Exactly-Once)

در Batch Processing، اگر Job شکست بخورد، کافی است دوباره اجرا شود. اما در Stream Processing، اگر یک Operator بعد از پردازش اما قبل از Commit Offset مشکل پیدا کند، Event دوباره پردازش می‌شود - ممکن است دوبار به حساب اضافه شود.

کلپمان سه سطح ضمانت را معرفی می‌کند:

ضمانتتوضیحهزینه
At-Most-Onceممکن است Event گم شودکمترین
At-Least-OnceEvent حداقل یک بار پردازش می‌شودمتوسط
Exactly-OnceEvent دقیقاً یک بار پردازش می‌شودبیشترین

Exactly-Once در عمل با دو روش پیاده‌سازی می‌شود:

Idempotent Operations: عملیات را طوری طراحی کنیم که اجرای چندباره همان نتیجه اجرای یک‌باره را بدهد. مثلاً به‌جای balance += 10، بنویسیم SET balance = 310 WHERE version = 5. اگر دو بار اجرا شود، هیچ تفاوتی ایجاد نمی‌کند.

Distributed Transactions و Checkpointing: Apache Flink از یک مکانیزم Checkpoint استفاده می‌کند: به‌صورت دوره‌ای State تمام Operator‌ها را ذخیره می‌کند. اگر سیستم Crash کند، از آخرین Checkpoint بازیابی می‌شود.

Stream Processing در عمل: Lambda Architecture

معرفی معماری

Lambda Architecture یک الگوی معروف است که Batch و Stream را با هم ترکیب می‌کند:

1
2
3
4
5
6
7
8
                 ┌──────────────────┐
Input ──────────►│   Batch Layer    │──────────► Batch Views
  │              │   (Hadoop/Spark) │
  │              └──────────────────┘
  │                                              ┌──────────┐
  └─────────────►│   Speed Layer    │───────────►│  Serving │◄── Query
                 │   (Kafka/Flink)  │            │  Layer   │
                 └──────────────────┘            └──────────┘
  • Batch Layer: داده‌های تاریخی را با دقت بالا پردازش می‌کند.
  • Speed Layer: فقط داده‌های اخیر را پردازش می‌کند - سریع اما ممکن است تقریبی باشد.
  • Serving Layer: نتایج هر دو لایه را ترکیب می‌کند.

نقد کلپمان به Lambda Architecture

کلپمان صریحاً می‌گوید Lambda Architecture پیچیدگی زیادی تحمیل می‌کند: همان Logic باید در دو سیستم مختلف (Batch و Stream) نوشته و نگهداری شود. اگر این Logic تغییر کند، باید در هر دو جا تغییر داده شود.

Kappa Architecture به‌عنوان جایگزین پیشنهاد می‌شود: فقط از یک Stream Processing System استفاده کنید. اگر نیاز به پردازش مجدد تاریخی دارید، یک Consumer جدید بسازید که از ابتدای Log شروع کند. Kafka با نگه داشتن Log‌های بلندمدت این را ممکن می‌کند.

جمع‌بندی فصل یازدهم

کلپمان فصل را با یک مقایسه نهایی تمام می‌کند:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Batch Processing:
  Input: Dataset محدود و ثابت
  Output: بعد از پایان کامل Job
  Latency: دقیقه تا ساعت
  ابزار: Hadoop MapReduce، Spark

Stream Processing:
  Input: جریان بی‌پایان از Event‌ها
  Output: پیوسته در لحظه پردازش
  Latency: میلی‌ثانیه تا ثانیه
  ابزار: Apache Kafka، Flink، Kafka Streams

اشتراک عمیق:
  هر دو از Partition برای Parallelism استفاده می‌کنند
  هر دو نیاز به Fault Tolerance دارند
  هر دو می‌توانند Join و Aggregation انجام دهند
  Stream Processing = Batch Processing روی داده بی‌نهایت

فصل دوازدهم: The Future of Data Systems - آینده سیستم‌های داده

جمع‌بندی کلپمان: از توصیف به تجویز

کلپمان در آخرین فصل کتاب لحن خود را تغییر می‌دهد. تا اینجا توصیف می‌کرد که سیستم‌ها چگونه کار می‌کنند. حالا می‌گوید سیستم‌ها چگونه باید طراحی شوند - و مسئولیت ما به‌عنوان مهندس چیست.

ترکیب ابزارها: هیچ ابزاری همه‌کاره نیست

واقعیت سیستم‌های بزرگ

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

1
2
3
4
5
PostgreSQL       → داده اصلی و Transactional
Elasticsearch    → جستجوی Full-Text
Redis            → Cache و Session
Kafka            → Event Log و پیام‌رسانی
Spark            → پردازش تحلیلی و Batch

مشکل: چطور این‌ها را همگام نگه داریم؟

وقتی داده در PostgreSQL تغییر می‌کند، باید در Elasticsearch هم به‌روز شود. اگر این همگام‌سازی با دو Write جداگانه انجام شود، خطر ناسازگاری وجود دارد - اگر اولی موفق شود اما دومی شکست بخورد چه؟

راه‌حل کلپمان: داده را از یک منبع واحد Derive کنید.

Derived Data: پایه معماری آینده

Unbundling the Database

کلپمان یک ایده جسورانه مطرح می‌کند: به‌جای اینکه یک Database بزرگ همه کارها را انجام دهد، می‌توانیم وظایف Database را جدا کنیم و هر کدام را با بهترین ابزار انجام دهیم:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
یک Database سنتی انجام می‌دهد:
  → ذخیره‌سازی دائمی (Storage)
  → ایندکس‌گذاری (Indexing)
  → Query Processing
  → Caching
  → Replication
  → Search

معماری Unbundled:
  Storage     → Kafka (Immutable Log as Source of Truth)
  Indexing    → Elasticsearch
  Cache       → Redis
  Analytics   → Spark / Flink
  Search      → Lucene

Kafka در این معماری نقش محوری دارد - یک Immutable Append-Only Log که منبع حقیقت است و تمام سیستم‌های دیگر از آن Derived Data می‌سازند.

مزیت این رویکرد

اگر یک سیستم Downstream (مثل Elasticsearch) خراب شود یا Bug داشته باشد، می‌توانید آن را دور بیندازید و از ابتدای Log دوباره بسازید. این همان قدرت Immutability است که در فصل قبل دیدیم - اما در مقیاس کل معماری.

Dataflow: به‌جای Request/Response

مشکل State توزیع‌شده

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

رویکرد Dataflow

به‌جای اینکه اپلیکیشن درخواست به Database بفرستد و منتظر پاسخ بماند، می‌توان این رویکرد را معکوس کرد:

روش سنتی (Request/Response):

1
کاربر → اپلیکیشن → Database (Query) → نتیجه → اپلیکیشن → کاربر

روش Dataflow:

1
Database تغییر می‌کند → Event به Kafka → اپلیکیشن React می‌کند → State به‌روز می‌شود

در این رویکرد، اپلیکیشن State خودش را Derive می‌کند از جریان Event‌ها. به‌جای Query در زمان نیاز، State همیشه آماده و به‌روز است.

Correctness در سیستم‌های توزیع‌شده

End-to-End Argument

کلپمان یکی از عمیق‌ترین نکات فصل را اینجا مطرح می‌کند. بسیاری از ضمانت‌هایی که Database ارائه می‌دهد (مثل Exactly-Once Delivery) فقط در مرز Database معنا دارند - نه در کل سیستم.

مثال: فرض کنید یک Payment Request دو بار به سرور می‌رسد (به خاطر Retry شبکه). حتی اگر Database خودش Exactly-Once داشته باشد، دو تراکنش جداگانه ثبت می‌شوند.

راه‌حل: Idempotency Key - هر Request یک شناسه یکتا دارد. اگر همان شناسه دوباره رسید، درخواست نادیده گرفته می‌شود. این منطق باید در سطح اپلیکیشن پیاده‌سازی شود، نه فقط Database.

Apologies Instead of Constraints

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

مثال واقعی: یک هواپیما ممکن است Overbooking داشته باشد - بیشتر از ظرفیت بلیط فروخته شود. به‌جای یک Lock پیچیده برای جلوگیری از این وضع، ایرلاین Compensation پیشنهاد می‌دهد اگر مشکل پیش آمد.

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

مسئولیت اخلاقی مهندسان داده

بخش پایانی: Ethics

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

تجمیع داده و Privacy: وقتی داده‌های کوچک و به‌ظاهر بی‌ضرر از منابع مختلف ترکیب می‌شوند، می‌توانند یک تصویر بسیار دقیق و مزاحم از یک فرد بسازند. موقعیت جغرافیایی + خرید + جستجو = پروفایل کامل.

سوگیری در ML: یک مدل Machine Learning که روی داده‌های تاریخی آموزش دیده، سوگیری‌های تاریخی را یاد می‌گیرد. اگر در گذشته گروه خاصی تبعیض دیده، مدل همان تبعیض را به‌صورت خودکار و در مقیاس بزرگ ادامه می‌دهد.

کلپمان می‌گوید مهندس نرم‌افزار نمی‌تواند بگوید «من فقط کد می‌نویسم، مسئولیت نتیجه با من نیست.» ما ابزارهایی می‌سازیم که قدرت دارند و قدرت مسئولیت می‌آورد.

جمع‌بندی نهایی کتاب

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
بخش اول - Data Models & Storage:
  چگونه داده را مدل‌سازی و ذخیره می‌کنیم؟
  Relational، Document، Graph، Column-Store
  B-Tree، LSM-Tree، OLTP، OLAP

بخش دوم - Distributed Data:
  چگونه داده را روی چندین ماشین توزیع می‌کنیم؟
  Replication، Partitioning
  Transactions، Consistency، Consensus

بخش سوم - Derived Data:
  چگونه از داده اصلی، داده‌های مشتق می‌سازیم؟
  Batch Processing، Stream Processing
  معماری آینده بر پایه Dataflow و Immutability

آنچه کتاب واقعاً می‌آموزد

مهم‌ترین درسی که کلپمان در طول ۵۰۰ صفحه می‌دهد، نه یک فناوری خاص است - بلکه یک روش فکر کردن است:

  • هیچ راه‌حل جادویی وجود ندارد - هر ابزار Trade-off دارد.
  • همیشه بپرسید: «این ضمانت دقیقاً چه می‌گوید؟» - کلمات مبهمی مثل «Consistent» یا «Reliable» به‌تنهایی معنا ندارند.
  • سیستم‌های پیچیده از اجزای ساده با ترکیب درست ساخته می‌شوند.
  • Immutability و Dataflow پایه‌های یک معماری قابل‌اعتماد هستند.
  • مهندس نرم‌افزار هم مسئولیت فنی دارد، هم مسئولیت اخلاقی.