پست

بررسی عمیق Cassandra برای مصاحبه‌های طراحی سیستم

مدل داده، کلید اصلی، پارتیشن‌بندی با Consistent Hashing، replication، سازگاری قابل تنظیم، مدل ذخیره‌سازی LSM، gossip و تحمل خطا در Cassandra همراه با مثال‌های Discord و Ticketmaster و زمان مناسب استفاده در مصاحبه

بررسی عمیق Cassandra برای مصاحبه‌های طراحی سیستم

Cassandra

در این فصل یاد می‌گیریم چطور می‌توان از Cassandra برای حل تعداد زیادی از مسائل طراحی سیستم استفاده کرد.

پایگاه‌های داده جنبه بنیادین و اصلی طراحی سیستم‌اند و یکی از همه‌کاره‌ترین و محبوب‌ترین پایگاه‌های داده‌ای که می‌توانید در جعبه‌ابزارتان داشته باشید، Cassandra است. Cassandra در اصل توسط فیس‌بوک ساخته شد تا از قابلیت جستجوی صندوق ورودی (inbox search) که سریع مقیاس می‌گرفت پشتیبانی کند. از آن زمان، شرکت‌های بی‌شماری از Cassandra برای مقیاس دادن سریع ذخیره‌سازی، throughput و بازخوانی داده استفاده کرده‌اند. از Discord (که بعداً در همین متن بررسی می‌کنیم) تا Netflix، Apple و Bloomberg، Cassandra پایگاه داده NoSQL‌ای است که جا افتاده و شرکت‌های بسیار متنوعی برای کاربردهای گسترده از آن استفاده می‌کنند.

Apache Cassandra یک پایگاه داده NoSQL متن‌باز و توزیع‌شده است. مدل ذخیره‌سازی wide-column پارتیشن‌بندی‌شده را با معناشناسی سازگاری نهایی پیاده می‌کند. پایگاه داده توزیع‌شده‌ای است که روی یک cluster اجرا می‌شود و می‌تواند از طریق سخت‌افزار معمولی (commodity) افقی مقیاس بگیرد. عناصری از Dynamo (که در نوشته DynamoDB آمده) و Bigtable را ترکیب می‌کند تا ردپای داده عظیم، حجم کوئری و نیازهای ذخیره‌سازی انعطاف‌پذیر را مدیریت کند.

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

مبانی Cassandra

با درک کمی از اصول شروع کنیم.

مدل داده

Cassandra مجموعه‌ای از تعریف‌های پایه داده دارد که تعیین می‌کنند داده چطور ذخیره می‌شود و چطور با آن تعامل می‌کنیم.

  • Keyspace: keyspaceها اساساً ظرف‌های داده‌اند و می‌توان آن‌ها را به «پایگاه داده» در سیستم‌های رابطه‌ای مثل Postgres یا MySQL تشبیه کرد. جدول‌های زیادی دارند. همچنین مسئول نگه داشتن اطلاعات پیکربندی جدول‌ها هستند. مثلاً keyspaceها یک استراتژی replication پیکربندی‌شده دارند تا افزونگی و دسترس‌پذیری داده را مدیریت کنند (که بعداً بحث می‌شود). keyspace همچنین نوع‌های تعریف‌شده توسط کاربر (UDT) را که ممکن است برای پشتیبانی از کاربردتان بسازید نگه می‌دارد.
  • Table (جدول): جدول ظرفی برای داده‌های شما به شکل سطر است. یک نام دارد و اطلاعات پیکربندی درباره داده‌ای که در آن ذخیره می‌شود نگه می‌دارد.
  • Row (سطر): سطر ظرفی برای داده است. با یک کلید اصلی شناسایی می‌شود و ستون‌هایی دارد.
  • Column (ستون): ستون داده‌ای را نگه می‌دارد که متعلق به یک سطر است. ستون با نام، نوع و مقدار آن ستون برای یک سطر نشان داده می‌شود. لازم نیست همه ستون‌ها در هر سطر جدول Cassandra مشخص شوند. Cassandra یک پایگاه داده wide-column است، پس ستون‌های مشخص‌شده می‌توانند در هر سطر جدول فرق کنند. این Cassandra را از چیزی مثل پایگاه داده رابطه‌ای که برای هر ستون در هر سطر ورودی لازم دارد (حتی اگر آن ورودی NULL باشد) انعطاف‌پذیرتر می‌کند. علاوه بر این، هر ستون metadata ای از جنس timestamp دارد که نشان می‌دهد چه زمانی نوشته شده است. وقتی ستونی بین replicaها تعارض نوشتن داشته باشد، با «last write wins» (آخرین نوشتن برنده است) حل می‌شود.

در ابتدایی‌ترین سطح، می‌توانید ساختارهای داده Cassandra را به یک JSON بزرگ تشبیه کنید.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "keyspace1": {
    "table1": {
      "row1": {
        "col1": 1,
        "col2": "2"
      },
      "row2": {
        "col1": 10,
        "col3": 3.0
      },
      "row3": {
        "col4": {
          "company": "Hello Interview",
          "city": "Seattle",
          "state": "WA"
        }
      }
    }
  }
}

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

کلید اصلی

یکی از مهم‌ترین سازه‌ها در Cassandra «کلید اصلی» (primary key) جدول است. هر سطر با یک کلید اصلی به‌طور یکتا شناسایی می‌شود. کلید اصلی از یک یا چند partition key تشکیل شده و ممکن است clustering key هم داشته باشد. معنای این اصطلاحات را بشکافیم.

  • Partition Key: یک یا چند ستون که برای تعیین این‌که سطر در کدام partition قرار می‌گیرد استفاده می‌شوند. بعداً در همین بررسی عمیق درباره پارتیشن‌بندی داده بحث می‌کنیم.
  • Clustering Key: صفر یا چند ستون که برای تعیین ترتیب مرتب سطرها در جدول به کار می‌روند. ترتیب داده بسته به نیازهای مدل‌سازی داده مهم است، پس Cassandra از طریق clustering keyها این کنترل را به کاربران می‌دهد.

وقتی جدولی را در Cassandra با گویش Cassandra Query Language (CQL) می‌سازید، کلید اصلی را به‌عنوان بخشی از تعریف schema مشخص می‌کنید. این چند نمونه از کلیدهای اصلی مختلف با کامنت است:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- Primary key with partition key a, no clustering keys
CREATE TABLE t (a text, b text, c text, PRIMARY KEY (a));

-- Primary key with partition key a, clustering key b ascending
CREATE TABLE t (a text, b text, c text, PRIMARY KEY ((a), b))
WITH CLUSTERING ORDER BY (b ASC);

-- Primary key with composite partition key a + b, clustering key c
CREATE TABLE t (a text, b text, c text, d text, PRIMARY KEY ((a, b), c));

-- Primary key with partition key a, clustering keys b + c
CREATE TABLE t (a text, b text, c text, d text, PRIMARY KEY ((a), b, c));

-- Primary key with partition key a, clustering keys b + c (alternative syntax)
CREATE TABLE t (a text, b text, c text, d text, PRIMARY KEY (a, b, c));

مفهوم کلید اصلی و اجزای آن ممکن است شما را یاد تعریف کلید اصلی در DynamoDB (partition key و sort key) بیندازد. این مفهوم اساساً یک‌به‌یک بین این دو پایگاه داده مشترک است.

مفاهیم کلیدی

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

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

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

Cassandra با پارتیشن‌بندی داده بین گره‌های زیاد cluster به مقیاس‌پذیری افقی می‌رسد. برای پارتیشن‌بندی موفق داده، Cassandra از Consistent Hashing استفاده می‌کند. Consistent Hashing تکنیک بنیادینی در سیستم‌های توزیع‌شده برای پارتیشن‌بندی داده و بار بین ماشین‌هاست که یکنواختی توزیع را در اولویت می‌گذارد و وقتی گرهای وارد یا از سیستم خارج می‌شود، نگاشت مجدد داده را حداقل می‌کند.

در طرح hashing سنتی، تعدادی گره انتخاب می‌شود و برای ذخیره یک مقدار، گره با این محاسبه تعیین می‌شود: hash(value) % num_nodes. این کار حتماً مقدارها را به گره‌ها اختصاص می‌دهد، اما دو مشکل دارد.

  • اگر تعداد bucketها عوض شود (گره اضافه یا حذف شود)، مقدارهای زیادی به گره‌های جدید نگاشت می‌شوند. در سیستم توزیع‌شده‌ای مثل پایگاه داده، یعنی داده باید بیش از حد بین گره‌ها جابه‌جا شود.
  • اگر در طرح hash شانس نیاورید، ممکن است مقدارهای زیادی روی یک گره hash شوند و بار نامتوازنی بین گره‌ها بسازند.

برای بهتر کردن این طراحی، Consistent Hashing رویکرد متفاوتی را ترجیح می‌دهد.

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

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

با این حال این طرح مسئله بار نامتوازن بین گره‌ها را حل نمی‌کند. برای رفع آن، Cassandra تصمیم می‌گیرد چند گره روی حلقه را به گره‌های فیزیکی در سیستم توزیع‌شده نگاشت کند. گره‌های روی حلقه vnode (گره مجازی) نامیده می‌شوند و متعلق به گره‌های فیزیکی هستند. این کار بار را یکنواخت‌تر بین cluster توزیع می‌کند. همچنین به سیستم اجازه می‌دهد از منابع گره‌های فیزیکی مختلف استفاده کند. بعضی گره‌های فیزیکی ممکن است ماشین‌های بزرگ‌تر با منابع بیشتر باشند، پس می‌توانند مسئول vnodeهای بیشتری باشند. در تصویر نمونه، مقدارهایی به نام «token» (t1، t2 و مانند آن) روی حلقه نشان داده می‌شوند، با vnodeهایی که به این tokenها نگاشت شده‌اند، و رنگ‌های مختلفی که گره‌های فیزیکی مختلف را برای vnodeها نشان می‌دهند.

Replication

در Cassandra، partitionهایی از داده روی گره‌های حلقه تکثیر می‌شوند و این برای طراحی‌هایی که به این قابلیت تکیه می‌کنند آن را بسیار در دسترس می‌کند. keyspaceها تنظیمات replication دارند و این روی نحوه تکثیر داده در Cassandra اثر می‌گذارد.

در سطح بالا، Cassandra گره‌هایی را که داده را تکثیر می‌کنند با اسکن در جهت عقربه‌های ساعت از vnodeای که با مقدار hash در طرح Consistent Hashing مطابقت دارد انتخاب می‌کند. مثلاً اگر Cassandra بخواهد داده را روی ۳ گره تکثیر کند، مقداری را روی یک گره hash می‌کند و در جهت عقربه‌های ساعت اسکن می‌کند تا ۲ vnode دیگر را پیدا کند که به‌عنوان replica استفاده شوند. قابل‌توجه است که Cassandra هر vnodeای را که روی همان گره فیزیکی vnodeهای موجود در مجموعه replica باشد رد می‌کند، تا وقتی یک گره فیزیکی از کار بیفتد، چند replica هم‌زمان از دسترس خارج نشوند.

Cassandra دو «استراتژی replication» دارد که می‌تواند استفاده کند: NetworkTopologyStrategy و SimpleStrategy.

  • NetworkTopologyStrategy استراتژی پیشنهادی برای production است و از دیتاسنتر و rack آگاه است. به این ترتیب در صورت قطعی، replicaهای داده در چند دیتاسنتر ذخیره می‌شوند. همچنین اجازه می‌دهد اگر یک rack در دیتاسنتر از بین رفت، replicaها روی rackهای جداگانه ذخیره شده باشند. هدف اصلی این پیکربندی ایجاد جداسازی فیزیکی کافی بین replicaهاست تا یک قطعی یا حادثه دنیای واقعی روی چند replica اثر نگذارد.
  • SimpleStrategy استراتژی ساده‌تری است که replicaها را فقط با اسکن در جهت عقربه‌های ساعت تعیین می‌کند (همان چیزی که بالا توضیح دادیم). برای استقرار و تست ساده مفید است.

این CQL برای تعیین پیکربندی‌های مختلف استراتژی replication برای یک keyspace است:

1
2
3
4
5
-- 3 replicas
ALTER KEYSPACE hello_interview WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 3 };

-- 3 replicas in data center 1, 2 replicas in data center 2
ALTER KEYSPACE hello_interview WITH REPLICATION = {'class' : 'NetworkTopologyStrategy', 'dc1' : 3, 'dc2' : 2};

سازگاری

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

نکته: قابل‌توجه است که Cassandra، طبق متن اصلی، از تراکنش پشتیبانی نمی‌کند و مفهومی از تضمین‌های ACID ندارد. فقط نوشتن اتمیک و ایزوله در سطح سطر (row) در یک partition را پشتیبانی می‌کند، و بس.

Cassandra اجازه می‌دهد از فهرستی از «سطوح سازگاری» (consistency level) برای خواندن و نوشتن انتخاب کنید که تعداد پاسخ گره‌ها برای موفق بودن نوشتن یا خواندن را تعیین می‌کنند. این‌ها بسته به ترکیب استفاده‌شده، رفتار سازگاری در برابر دسترس‌پذیری متفاوتی اعمال می‌کنند. از ONE که یک replica باید پاسخ دهد تا ALL که همه replicaها باید پاسخ دهند.

یکی از سطوح سازگاری قابل‌توجه برای فهمیدن QUORUM است. QUORUM برای پاسخ دادن به اکثریت (n/2 + 1) replicaها نیاز دارد. اعمال QUORUM برای هم خواندن و هم نوشتن تضمین می‌کند نوشتن‌ها برای خواندن قابل مشاهده باشند، چون حداقل یک گره هم‌پوشان تضمین‌شده هم در نوشتن و هم در خواندن شرکت می‌کند. برای نشان دادن آن، مجموعه‌ای از ۳ گره را فرض کنید. 3/2 + 1 = 2، پس برای موفق بودن نوشتن و خواندن باید ۲ گره از ۳ گره روی آن نوشته و از آن خوانده شوند. یعنی نوشتن همیشه با خواندن دیده می‌شود، چون حداقل ۱ تا از آن ۲ گره نوشتن را هم دیده است.

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

مسیریابی کوئری

هر گره Cassandra می‌تواند به کوئری یک اپلیکیشن کلاینت پاسخ دهد، چون همه گره‌ها در Cassandra می‌توانند نقش «هماهنگ‌کننده» (coordinator) کوئری را بازی کنند. گره‌ها در Cassandra همه گره‌های زنده دیگر cluster را می‌شناسند. اطلاعات cluster را از طریق پروتکلی به نام «gossip» (که بعداً بحث می‌شود) به اشتراک می‌گذارند. گره‌ها در Cassandra همچنین می‌توانند با محاسبه Consistent Hashing و دانستن استراتژی replication و سطح سازگاری پیکربندی‌شده برای داده، بفهمند داده در cluster کجا قرار دارد. وقتی کلاینت کوئری می‌فرستد، گرهای را انتخاب می‌کند تا coordinator شود و coordinator کوئری‌ها را برای گره‌هایی که داده را ذخیره می‌کنند (مجموعه‌ای از replicaها) می‌فرستد.

مدل ذخیره‌سازی

فهمیدن مدل ذخیره‌سازی Cassandra مهم است، چون هسته یکی از نقاط قوت آن برای طراحی سیستم است: throughput نوشتن. Cassandra برای رسیدن به این سرعت از ساختار داده‌ای به نام ایندکس Log-Structured Merge Tree (LSM Tree) استفاده می‌کند. LSM Tree به‌جای B-Tree به کار می‌رود، که ایندکس انتخابی بیشتر پایگاه‌های داده (رابطه‌ای، DynamoDB) است.

قبل از ورود به جزئیات، مهم است توضیح بدهیم Cassandra نوشتن را در مقایسه با پایگاه‌های داده دیگر چطور مدیریت می‌کند. Cassandra رویکردی را انتخاب می‌کند که سرعت نوشتن را بر سرعت خواندن ترجیح می‌دهد. هر ایجاد، به‌روزرسانی و حذف یک ورودی جدید است (به‌جز بعضی موارد). Cassandra از ترتیب این به‌روزرسانی‌ها برای تعیین «state» یک سطر استفاده می‌کند. مثلاً اگر سطری ایجاد و بعد به‌روز شود، Cassandra state سطر را با نگاه کردن به ایجاد و بعد به‌روزرسانی، به‌جای دیدن فقط یک سطر، می‌فهمد. همین درباره حذف هم صدق می‌کند که می‌توان آن را «به‌روزرسانی حذف» دید. Cassandra برای حذف سطرها یک ورودی «tombstone» (سنگ قبر) می‌نویسد. LSM Tree به Cassandra اجازه می‌دهد state یک سطر را کارآمد بفهمد، در حالی که داده را تقریباً کامل به‌صورت نوشتن «append» در پایگاه داده می‌نویسد.

سه ساختار اصلی ایندکس LSM Tree این‌هاست.

  • Commit Log: اساساً یک write-ahead log برای اطمینان از دوام نوشتن‌ها در گره‌های Cassandra است.
  • Memtable: ساختار داده درون‌حافظه‌ای مرتب که داده‌های نوشتن را نگه می‌دارد. بر اساس کلید اصلی هر سطر مرتب است.
  • SSTable: مخفف «Sorted String Table». فایل تغییرناپذیر روی دیسک که داده‌هایی را دارد که از Memtable قبلی flush شده‌اند.

با کار کردن همه این ساختارها با هم، نوشتن‌ها این‌طور انجام می‌شوند.

  1. نوشتن برای یک گره صادر می‌شود.
  2. نوشتن در commit log نوشته می‌شود تا اگر گره حین پردازش نوشتن یا وقتی داده فقط در Memtable است از کار بیفتد، گم نشود.
  3. نوشتن در Memtable نوشته می‌شود.
  4. در نهایت، Memtable بعد از رسیدن به اندازه آستانه یا گذشت مدت زمان مشخص، به‌صورت SSTable تغییرناپذیر روی دیسک flush می‌شود.

وقتی Memtable flush می‌شود، برای صرفه‌جویی در فضا، هر پیام commit log که به آن Memtable مربوط باشد حذف می‌شود. حالا که Memtable به‌صورت SSTable تغییرناپذیر روی دیسک است، این‌ها اضافی هستند.

به طور خلاصه، Memtable نوشتن‌های اخیر را نگه می‌دارد، نوشتن‌های یک کلید را در یک سطر ادغام می‌کند و گاهی به‌صورت SSTable تغییرناپذیر روی دیسک flush می‌شود. commit log مثل write-ahead log عمل می‌کند تا اگر داده فقط در Memtable است و گره از کار بیفتد، داده گم نشود.

هنگام خواندن داده برای کلید مشخص، Cassandra اول Memtable را می‌خواند که آخرین داده را خواهد داشت. اگر Memtable داده کلید را نداشته باشد، Cassandra از Bloom Filter استفاده می‌کند تا بفهمد کدام SSTableهای روی دیسک ممکن است داده را داشته باشند. بعد SSTableها را از جدیدترین به قدیمی‌ترین می‌خواند تا آخرین داده سطر را پیدا کند. قابل‌توجه است که داده در SSTableها بر اساس کلید اصلی مرتب است و پیدا کردن کلید مشخص را آسان می‌کند.

بر پایه بالا، دو مفهوم اضافه هم هست که باید درونی کنید.

  • Compaction: برای جلوگیری از متورم شدن SSTableها با به‌روزرسانی‌ها و حذف‌های زیاد سطرها، Cassandra عملیات compaction اجرا می‌کند تا داده‌ها را در مجموعه کوچک‌تری از SSTableها ادغام کند که state یکپارچه داده را منعکس می‌کند. Compaction همچنین سطرهایی را که حذف شده‌اند پاک می‌کند و tombstoneهایی را که قبلاً برای آن سطر وجود داشتند هم حذف می‌کند. این فرایند به‌خصوص کارآمد است، چون همه این جدول‌ها مرتب‌اند.
  • ایندکس‌گذاری SSTable: Cassandra فایل‌هایی ذخیره می‌کند که به offset بایتی در فایل‌های SSTable اشاره می‌کنند تا بازیابی سریع‌تر داده روی دیسک ممکن شود. مثلاً Cassandra ممکن است کلید ۱۲ را به offset بایتی ۹۸۴ نگاشت کند، یعنی داده کلید ۱۲ در آن offset در SSTable پیدا می‌شود. این تا حدی شبیه نحوه اشاره B-Tree به داده روی دیسک است.

برای مطالعه بیشتر درباره LSM Treeها می‌توانید مقاله‌های مربوط را ببینید.

Gossip

گره‌های Cassandra اطلاعات را در cluster از طریق «gossip» به هم می‌رسانند، طرحی peer-to-peer برای توزیع اطلاعات بین گره‌ها. دانش gossip درباره cluster باعث می‌شود هر گره آگاه باشد و بتواند در همه عملیات پایگاه داده شرکت کند. این هر نقطه شکست را حذف می‌کند و به Cassandra اجازه می‌دهد پایگاه داده بسیار قابل‌اعتماد برای طراحی سیستم‌های دسترس‌پذیری باشد. چطور کار می‌کند؟

گره‌ها اطلاعات مختلفی درباره cluster ردیابی می‌کنند، مثل این‌که کدام گره‌ها زنده و در دسترس‌اند، schema چیست و مانند آن. شماره‌های generation و version را برای هر گرهای که درباره‌اش می‌دانند نگه می‌دارند. generation یک timestamp است که گره در آن bootstrap شده. version مقدار ساعت منطقی است که تقریباً هر ثانیه افزایش می‌یابد. در سراسر cluster این مقدارها یک ساعت برداری (vector clock) تشکیل می‌دهند. این ساعت برداری به گره‌ها اجازه می‌دهد وقتی اطلاعات قدیمی state cluster از طریق gossip می‌رسد آن را نادیده بگیرند.

گره‌های Cassandra معمولاً گره‌های دیگر را برای gossip انتخاب می‌کنند، با سوگیری احتمالی به سمت گره‌های «seed». گره‌های seed را Cassandra تعیین می‌کند تا cluster را bootstrap کنند و به‌عنوان «نقاط داغ» (hot spot) تضمین‌شده برای gossip عمل کنند، تا همه گره‌ها در cluster ارتباط بگیرند. با ساختن این «نقاط تجمع» (choke point)، Cassandra احتمال به‌وجود آمدن زیر-clusterهایی از گره‌ها را که اتفاقاً اطلاعات به کل cluster نمی‌رسد از بین می‌برد. قابل‌توجه است که Cassandra تضمین می‌کند گره‌های seed همیشه از طریق مکانیزم‌های service discovery قابل کشف باشند.

تحمل خطا

در سیستم توزیع‌شده‌ای مثل Cassandra، گره‌ها از کار می‌افتند و Cassandra باید خرابی‌ها را مؤثر تشخیص و مدیریت کند تا پایگاه داده بتواند داده را کارآمد بنویسد و بخواند. چطور می‌تواند در مقیاس به این الزامات برسد؟

Cassandra برای تشخیص شکست حین gossip از تکنیک Phi Accrual Failure Detector استفاده می‌کند. هر گره مستقل درباره زنده یا مرده بودن یک گره تصمیم می‌گیرد. وقتی گره‌ای با گرهای که پاسخ نمی‌دهد gossip می‌کند، منطق تشخیص شکست در Cassandra آن گره را «محکوم» می‌کند و مسیریابی به آن را متوقف می‌کند. گره محکوم‌شده می‌تواند دوباره به cluster بپیوندد، وقتی دوباره شروع به heartbeat کند. قابل‌توجه است که Cassandra هرگز گرهای را واقعاً «پایین» حساب نمی‌کند، مگر این‌که ادمین Cassandra گره را از رده خارج یا بازسازی کند. این کار برای جلوگیری از این است که قطعی‌های متناوب ارتباط یا راه‌اندازی مجدد گره باعث rebalance کردن داده در cluster شود.

برای تلاش‌های نوشتن به گره‌هایی که «آفلاین» حساب می‌شوند، Cassandra از تکنیکی به نام hinted handoff استفاده می‌کند. وقتی گرهی توسط گره coordinator ای که می‌خواهد روی آن بنویسد آفلاین تشخیص داده شود، coordinator موقتاً داده نوشتن را ذخیره می‌کند تا نوشتن ادامه یابد. به این داده موقت «hint» می‌گویند. وقتی گره آفلاین دوباره آنلاین تشخیص داده شود، گره (یا گره‌های) دارای hint آن داده را به گرهی که قبلاً آفلاین بود می‌فرستند.

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

نحوه استفاده از Cassandra

مدل‌سازی داده

هنگام استفاده از Cassandra در طراحی سیستم، مدل‌سازی داده برای بهره‌گیری از معماری و نقاط قوت آن حیاتی است.

اگر از دنیای پایگاه داده رابطه‌ای آمده‌اید، مدل‌سازی داده در Cassandra در ابتدا ممکن است کمی عجیب به نظر برسد. مدل‌سازی داده در پایگاه داده رابطه‌ای روی داده «normalize شده» تمرکز دارد، جایی که از هر موجودیت یک نسخه دارید و روابط بین این موجودیت‌ها را با کلید خارجی و Join مدیریت می‌کنید. به طور خلاصه، مدل‌سازی داده در پایگاه داده رابطه‌ای از رابطه موجودیت‌ها (entity relationship) مشتق می‌شود. اما Cassandra مفهوم کلید خارجی یا یکپارچگی ارجاعی و Join ندارد. Cassandra normalization داده را هم پشتیبانی نمی‌کند. در عوض، مدل‌سازی داده برای Cassandra مبتنی بر کوئری (query-driven) است.

کارایی کوئری در Cassandra شدیداً به نحوه ذخیره داده وابسته است. Cassandra همچنین انعطاف کوئری پایگاه داده رابطه‌ای را ندارد. از Join و کوئری روی چند جدول پشتیبانی نمی‌کند. پس هنگام فکر کردن به مدل‌سازی داده برای پایگاه داده Cassandra، اول باید «الگوهای دسترسی» (access pattern) اپلیکیشن را در نظر گرفت. همچنین مهم است بفهمیم چه داده‌ای در هر جدول لازم است تا در صورت نیاز بتوان داده را بین جدول‌ها denormalize (تکثیر) کرد. حوزه‌های اصلی که باید در نظر گرفته شوند:

  • Partition Key: چه داده‌ای تعیین می‌کند داده در کدام partition قرار بگیرد.
  • اندازه Partition: در حالت شدید partition چقدر بزرگ می‌شود؟ آیا partitionها ظرفیت رشد نامحدود دارند؟ و مانند آن.
  • Clustering Key: داده چطور باید مرتب شود (اگر باید).
  • Denormalization داده: آیا بعضی داده‌ها باید برای پشتیبانی از کوئری‌های اپلیکیشن در جدول‌ها denormalize شوند؟

برای رسیدن به این نقطه، مرور چند مثال مفید است.

مثال، پیام‌های Discord

یکی از بهترین راه‌ها برای یاد گرفتن استفاده از ابزاری مثل Cassandra استفاده از مثال واقعی مثل Discord است. Discord خلاصه خوبی از استفاده‌اش از Cassandra برای ذخیره داده پیام‌ها در پست‌های وبلاگ خودش منتشر کرده و این مدل خوبی برای رویکرد به ذخیره پیام‌ها در اپلیکیشن‌های چت به‌طور کلی است.

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

برای برآورده کردن نیازهای بالا، Discord در ابتدا تصمیم گرفت جدول پیام‌ها را با این schema بسازد:

1
2
3
4
5
6
7
CREATE TABLE messages (
  channel_id bigint,
  message_id bigint,
  author_id bigint,
  content text,
  PRIMARY KEY (channel_id, message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);

ممکن است تعجب کنید چرا به‌جای ستون timestamp مثل created_at از message_id استفاده شده. Discord با اختصاص شناسه Snowflake به پیام‌ها، احتمال برخورد کلید اصلی Cassandra را از بین برد. شناسه Snowflake اساساً UUID قابل مرتب‌سازی بر اساس زمان است. این بر created_at ترجیح داده می‌شود، چون برخورد شناسه Snowflake غیرممکن است (UUID است)، در حالی که timestamp حتی با دقت میلی‌ثانیه هم احتمال برخورد دارد.

schema بالا به Cassandra اجازه می‌دهد پیام‌های یک کانال را از یک partition سرویس دهد. partition key، یعنی channel_id، تضمین می‌کند یک partition مسئول پاسخ به کوئری است و از نیاز به اجرای کوئری «scatter-gather» روی چند گره برای گرفتن داده پیام یک کانال جلوگیری می‌کند، که ممکن است کند و پرمصرف باشد.

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

برای حل مشکل partition بزرگ، Discord مفهوم bucket را معرفی کرد و آن را به بخش partition key در کلید اصلی Cassandra اضافه کرد. هر bucket نماینده ۱۰ روز داده است که با پنجره ثابتی تعریف می‌شود و با DISCORD_EPOCH تعریف‌شده خود Discord (اول ژانویه ۲۰۱۵) تراز شده است. پیام‌های حتی شلوغ‌ترین Discordها قطعاً در ۱۰ روز در Cassandra جا می‌شوند. این کار مسئله رشد نامحدود partitionها را هم حل کرد. با گذشت زمان، وقتی bucket جدیدی ساخته می‌شود، partition جدیدی معرفی می‌شود. در نهایت Discord می‌تواند بیشتر اوقات برای خواندن از یک partition استفاده کند، چون جدیدترین پیام‌های یک کانال معمولاً در یک bucket قرار می‌گیرند. تنها وقتی این‌طور نبود که (۱) bucket جدیدی با گذشت زمان ساخته شد، یا (۲) برای Discordهای غیرفعال، که اقلیت قابل‌توجهی از کوئری‌ها به جدول messages در Cassandra را تشکیل می‌دادند.

schema اصلاح‌شده این‌طور است:

1
2
3
4
5
6
7
8
CREATE TABLE messages (
  channel_id bigint,
  bucket int,
  message_id bigint,
  author_id bigint,
  content text,
  PRIMARY KEY ((channel_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);

قابل‌توجه است که Discord از الگوهای دسترسی به کانال‌هایش برای تعیین طراحی schema استفاده کرد، که نمونه عالی مدل‌سازی داده مبتنی بر کوئری است. انتخاب کلید اصلی آن‌ها، شامل partition key و اجزای clustering key، شدیداً به نحوه دسترسی به داده در اپلیکیشنشان گره خورده است. در نهایت هنگام طراحی schema باید به اندازه partition هم فکر می‌کردند. همه این عوامل به schema خوب Cassandra برای طراحی سیستم به‌طور کلی کمک می‌کنند.

مثال، Ticketmaster

مثال دیگری از Cassandra را در نظر بگیریم: رابط کاربری مرور بلیت Ticketmaster. این رابط کاربری است که صندلی‌های موجود در سالن رویداد را نشان می‌دهد، اجازه می‌دهد کاربر صندلی انتخاب کند و بعد وارد جریان رزرو صندلی و پرداخت شود.

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

نکته: هدف این مثال ساده بودن و تمرکز روی مدل‌سازی داده است، پس پیچیدگی‌ای را که رویدادهای فوق‌العاده محبوب بر سیستم تحمیل می‌کنند (که به آن «مشکل Taylor Swift» می‌گویند) نادیده می‌گیریم.

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

1
2
3
4
5
6
7
8
CREATE TABLE tickets (
  event_id bigint,
  seat_id bigint,
  price bigint,
  -- seat_id is added as a clustering key to ensure primary key uniqueness; order
  -- doesn't matter for the app access patterns
  PRIMARY KEY (event_id, seat_id)
);

چون کلید اصلی partition key به نام event_id دارد، با schema بالا اپلیکیشن می‌تواند برای پاسخ به درخواست‌های مربوط به رویداد از یک partition کوئری بگیرد. اپلیکیشن می‌تواند partition را برای اطلاعات قیمت رویداد، مجموع موجودی بلیت و مانند آن کوئری کند.

با این حال این schema مشکلاتی دارد. برای رویدادهایی با بیش از ۱۰ هزار بلیت، پایگاه داده باید کاری انجام دهد تا اطلاعات را بر اساس درخواست کاربر (مجموع قیمت، مجموع بلیت) خلاصه کند. علاوه بر این، این کار ممکن است برای رویدادهای بسیار محبوب و کاربرانی که مرتب وارد رابط کاربری مرور بلیت می‌شوند زیاد انجام شود. چطور می‌توانیم این مشکلات را حل کنیم؟

یک سرنخ برای بهتر کردن schema در تجربه کاربری (UX) مرور بلیت Ticketmaster است. UX را وقتی کاربر شروع به مرور بلیت‌ها می‌کند در نظر بگیرید. نقشه سالن را با چند بخش (section) می‌بیند. هر بخش ممکن است یک popover با اطلاعات سطح بالا درباره موجودی بلیت و قیمت آن بخش داشته باشد.

اگر کاربر روی بخش موردنظرش کلیک کند، رابط کاربری Ticketmaster تک‌تک صندلی‌ها و اطلاعات بلیت را نشان می‌دهد.

این UX نشان می‌دهد می‌توانیم مفهوم section_id را به جدول بلیت اضافه کنیم و section_id را بخشی از partition key کنیم. یعنی جدول بلیت حالا درخواست‌های دیدن بلیت صندلی‌های تکی برای یک بخش مشخص را سرویس می‌دهد. schema جدید این‌طور است:

1
2
3
4
5
6
7
CREATE TABLE tickets (
  event_id bigint,
  section_id bigint,
  seat_id bigint,
  price bigint,
  PRIMARY KEY ((event_id, section_id), seat_id)
);

schema بالا نسبت به schema اولیه بهتر است. یک رویداد را بین چند گره cluster Cassandra توزیع می‌کند، چون هر بخش از رویداد در partition متفاوتی است. همچنین یعنی هر partition مسئول سرویس‌دهی داده کمتری است، چون تعداد بلیت‌های هر partition کمتر است. در نهایت، این schema با نیازهای داده و الگوهای دسترسی رابط کاربری مرور بلیت Ticketmaster بهتر نگاشت می‌شود.

ممکن است بپرسید: حالا چطور داده بلیت کل رویداد را نشان دهیم؟ برای سرویس‌دهی به رابط کاربری‌ای که همه بخش‌ها و اطلاعات سطح بالا درباره موجودی و قیمت بلیت را نشان می‌دهد، می‌توانیم جدول جداگانه‌ای به نام event_sections در نظر بگیریم:

1
2
3
4
5
6
7
8
9
CREATE TABLE event_sections (
  event_id bigint,
  section_id bigint,
  num_tickets bigint,
  price_floor bigint,
  -- section_id is added as a clustering key to ensure primary key uniqueness; order
  -- doesn't matter for the app access patterns
  PRIMARY KEY (event_id, section_id)
);

جدول بالا ایده denormalize کردن داده در Cassandra را نشان می‌دهد. به‌جای این‌که پایگاه داده ما روی یک جدول aggregate کند یا برای سرویس‌دهی به اپلیکیشن چند جدول یا partition را کوئری کند، بهتر است اطلاعاتی مثل تعداد بلیت و حداقل قیمت (price floor) را در سطح بخش denormalize کنیم تا الگوی دسترسی اپلیکیشن کارآمد شود. به‌علاوه، آمار بخش‌هایی که کوئری می‌شوند لازم نیست دقت خیلی بالایی داشته باشند، پس سازگاری نهایی قابل‌قبول است. در واقع Ticketmaster حتی تعداد دقیق بلیت‌ها را در رابط کاربری نشان نمی‌دهد و فقط مجموعی مثل «+۱۰۰» نشان می‌دهد.

جدول بالا با event_id پارتیشن‌بندی شده است. Cassandra باید بخش‌های زیادی را در یک کوئری بخواند، اما رویدادها تعداد کمی بخش دارند (معمولاً کمتر از ۱۰۰) و این کوئری از یک partition سرویس داده می‌شود. یعنی Cassandra می‌تواند داده را برای نمایش نمای سطح بالای سالن کارآمد کوئری کند.

به طور کلی، موارد بالا نشان می‌دهد الگوهای دسترسی و UX یک اپلیکیشن چقدر روی نحوه مدل‌سازی داده در Cassandra اثر می‌گذارند.

ویژگی‌های پیشرفته

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

  • Storage-Attached Indexes (SAI): SAIها ویژگی جدیدتری در Cassandra هستند که ایندکس ثانویه سراسری (global secondary index) روی ستون‌ها فراهم می‌کنند. کوئری انعطاف‌پذیر داده را با کارایی بدتر از کوئری سنتی مبتنی بر partition key، اما همچنان خوب، ممکن می‌کنند. این‌ها کاربران Cassandra را قادر می‌کنند وقتی الگوهای کوئری کم‌تکرار دارند، از denormalize کردن بیش از حد داده بپرهیزند. کوئری‌های کم‌تکرار معمولاً هزینه سربار جدول جداگانه و denormalize شده را توجیه نمی‌کنند.
  • Materialized Viewها: materialized view راهی برای کاربر است تا Cassandra را طوری پیکربندی کند که جدول‌هایی را بر اساس یک جدول منبع materialize کند. تا حدی با view در SQL هم‌پوشانی دارند، با این تفاوت که واقعاً یک جدول را «materialize» می‌کنند، که نامشان از همین می‌آید. این راحت است، چون به‌عنوان کاربر می‌توانید از Cassandra بخواهید داده را به‌طور خودکار برایتان denormalize کند. این کار پیچیدگی را در سطح اپلیکیشن کم می‌کند، چون اگر داده denormalize شده تغییر کند، لازم نیست اپلیکیشن را طوری بنویسید که روی چند جدول بنویسد.
  • ایندکس جستجو: Cassandra را می‌توان از طریق افزونه‌های مختلف به موتور جستجوی توزیع‌شده مثل Elasticsearch یا Apache Solr وصل کرد. یک نمونه ایندکس Stratio Lucene است.

Cassandra در مصاحبه

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

Cassandra می‌تواند انتخاب عالی برای سیستم‌هایی باشد که نقاط قوتش را به کار می‌گیرند. Cassandra در سیستم‌هایی که دسترس‌پذیری را بر سازگاری ترجیح می‌دهند و به مقیاس‌پذیری بالا نیاز دارند انتخاب عالی است. Cassandra می‌تواند نوشتن و خواندن سریع را در مقیاس انجام دهد، اما با توجه به لایه ذخیره‌سازی بهینه برای نوشتن که بر اساس ایندکس LSM Tree است، انتخاب خوبی برای سیستم‌های با throughput نوشتن بالا هم هست. علاوه بر این، طراحی wide-column در Cassandra آن را به انتخاب عالی برای schemaهای انعطاف‌پذیر یا schemaهایی که ستون‌های زیاد و احتمالاً پراکنده دارند تبدیل می‌کند. در نهایت، Cassandra وقتی می‌درخشد که برای اپلیکیشن یا کاربرد خود چند الگوی دسترسی روشن دارید که schema بتواند حول آن‌ها طراحی شود.

شناخت محدودیت‌های آن

Cassandra پایگاه داده عالی برای هر سیستمی نیست. Cassandra برای طراحی‌هایی که سازگاری دقیق را در اولویت می‌گذارند خوب نیست، به‌خاطر سوگیری شدیدش به سمت دسترس‌پذیری. Cassandra همچنین برای سیستم‌هایی که الگوهای کوئری پیشرفته می‌خواهند، مثل Joinهای چندجدولی، aggregation ad hoc و مانند آن، گزینه خوبی نیست.

جمع‌بندی

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