پست

طراحی کش توزیع‌شده مانند Redis

بررسی گام‌به‌گام طراحی کش توزیع‌شده شامل کش درون‌حافظه‌ای با TTL و Eviction از نوع LRU، تکثیر داده، Sharding با Consistent Hashing، مدیریت کلیدهای داغ خواندنی و نوشتنی و بهینه‌سازی عملکرد

طراحی کش توزیع‌شده مانند Redis

درک مسئله

کش توزیع‌شده (Distributed Cache) سیستمی است که داده را به‌صورت جفت‌های کلید-مقدار در حافظه، روی چند ماشین یک شبکه ذخیره می‌کند. برخلاف کش‌های تک‌گره که منابع یک ماشین آن‌ها را محدود می‌کند، کش‌های توزیع‌شده برای تحمل بارکارهای عظیم افقی در بسیاری از گره‌ها مقیاس می‌گیرند. خوشه کش با هم کار می‌کند تا داده را پارتیشن‌بندی و تکثیر کند و هنگام خرابی گره‌های مجزا، High Availability (دسترس‌پذیری بالا) و Fault Tolerance (تحمل خطا) را تضمین کند.

نیازمندی‌های عملکردی

نیازمندی‌های اصلی:

  • کاربران بتوانند جفت‌های کلید-مقدار را تنظیم (Set)، دریافت (Get) و حذف (Delete) کنند.
  • کاربران بتوانند زمان انقضا (TTL) را برای جفت‌های کلید-مقدار پیکربندی کنند.
  • داده‌ها طبق سیاست Least Recently Used (LRU) از کش خارج (Evict) شوند.

موارد خارج از محدوده:

  • کاربران بتوانند اندازه کش را پیکربندی کنند.

ما سیاست Eviction از نوع LRU را انتخاب کردیم، اما اگر مصاحبه‌کننده صراحتاً چیز دیگری در ذهن دارد، باید از او بپرسید. البته سیاست‌های Eviction دیگری هم هست که می‌توانید پیاده کنید، مثل LFU، FIFO و سیاست‌های سفارشی.

نیازمندی‌های غیرعملکردی

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

نیازمندی‌های اصلی:

  • سیستم باید بسیار در دسترس باشد. Eventual Consistency (سازگاری نهایی) قابل قبول است.
  • سیستم باید از عملیات با Latency (تأخیر) کم پشتیبانی کند (کمتر از 10 میلی‌ثانیه برای درخواست‌های Get و Set).
  • سیستم باید مقیاس‌پذیر باشد تا از 1 ترابایت داده مورد انتظار و 100 هزار درخواست در ثانیه پشتیبانی کند.

موارد خارج از محدوده:

  • دوام (Durability)، یعنی ماندگاری داده در طول ری‌استارت
  • تضمین Strong Consistency (سازگاری قوی)
  • قابلیت‌های پیچیده کوئری
  • پشتیبانی از تراکنش

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

آماده‌سازی

برنامه‌ریزی رویکرد

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

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

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

تعریف موجودیت‌های اصلی

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

API

با توجه به نیازمندی‌های عملکردی، سه عملیات کلیدی داریم که باید از طریق API در معرض استفاده قرار بدهیم: Set، Get و Delete. هرکدام باید نسبتاً ساده باشند، پس می‌توانیم سریع از آن‌ها رد شویم. شناسایی اینکه در مصاحبه طراحی سیستم کجا زمان بیشتر و کجا کمتر صرف شود، بر اساس محل چالش‌ها، مهارت مهمی است. صرف زمان روی بخش‌های ساده، فرعی یا کم‌اهمیت‌تر سیستم، دستور پخت تمام شدن وقت است.

تنظیم یک جفت کلید-مقدار:

1
2
3
4
POST /:key
{
  "value": "..."
}

دریافت یک جفت کلید-مقدار:

1
GET /:key -> {value: "..."}

حذف یک جفت کلید-مقدار:

1
DELETE /:key

طراحی سطح بالا

با ساختن یک MVP که برای برآورده کردن نیازمندی‌های عملکردی اصلی کار می‌کند شروع می‌کنیم. لازم نیست مقیاس‌پذیر یا کامل باشد. فقط پایه‌ای است که بعداً روی آن بنا می‌کنیم. نیازمندی‌های عملکردی را یکی‌یکی مرور می‌کنیم و مطمئن می‌شویم طراحی سطح بالا هرکدام را پوشش می‌دهد.

1) تنظیم، دریافت و حذف جفت‌های کلید-مقدار

با اصول مطلق شروع می‌کنیم. هنوز سیستم توزیع‌شده جذابی نداریم، فقط یک کش کارا که Get، Set و Delete را مدیریت می‌کند.

در هسته، کش فقط یک جدول هش (Hash Table) است. هر زبان برنامه‌نویسی یکی دارد: dict در Python، HashMap در جاوا، map در Go. این‌ها برای این کار عالی‌اند، چون جست‌وجو و درج را با O(1) می‌دهند.

مصاحبه‌کننده ممکن است از شما بخواهد شبه‌کد بکشید. چیزی که ممکن است به نظر برسد این است:

1
2
3
4
5
6
7
8
9
10
11
class Cache:
    data = {}  # Simple hash table

    get(key):
        return self.data[key]

    set(key, value):
        self.data[key] = value

    delete(key):
        delete self.data[key]

وقتی در مصاحبه طراحی از شما شبه‌کد خواسته می‌شود، نگران نحو (Syntax) نباشید. فقط روی منطق تمرکز کنید. توجه کنید که موارد گوشه‌ای مثل «کلید پیدا نشد» یا «کلید از قبل وجود دارد» را مدیریت نمی‌کنم. نگران نباشید، اینجا مهم نیست. همان‌طور که گفتیم، ارزش دارد این را با مصاحبه‌کننده تأیید کنید: «قصد دارم مقداری شبه‌کد بنویسم. اشکالی ندارد از نظر نحو درست نباشد یا بعضی جزئیات را نداشته باشد؟»

همین. سه عملیات، همه O(1). جدول هش Collision Resolution و تغییر اندازه پویا را برای ما مدیریت می‌کند.

می‌توانیم این کد را روی یک سرور واحد میزبانی کنیم. وقتی کاربر درخواست API می‌دهد، درخواست را Parse می‌کنیم، متد مناسب را روی نمونه Cache صدا می‌زنیم و پاسخ مناسب را برمی‌گردانیم.

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

2) پیکربندی زمان انقضا برای جفت‌های کلید-مقدار

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

شبه‌کد زیر نشان می‌دهد چطور کش را برای پشتیبانی از TTL (زمان زنده‌ماندن) تغییر می‌دهیم. تغییرهای کلیدی:

  • به‌جای ذخیره فقط مقدارها، حالا جفت (value, expiry_timestamp) را ذخیره می‌کنیم.
  • متد get() قبل از برگرداندن، منقضی بودن ورودی را بررسی می‌کند.
  • متد set() پارامتر اختیاری TTL را می‌گیرد و مهر زمانی انقضا را حساب می‌کند.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Check the expiry time of the key on get
get(key):
    (value, expiry) = data[key]
    if expiry and currentTime() > expiry:
        # Key has expired, remove it
        delete data[key]
        return null

    return value

# Set the expiry time of the key on set
set(key, value, ttl):
    expiry = currentTime() + ttl if ttl else null
    data[key] = (value, expiry)

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

برای رفع این، به یک فرایند پس‌زمینه (که اغلب «Janitor» یا سرایدار نامیده می‌شود) نیاز داریم که دوره‌ای ورودی‌های منقضی‌شده را اسکن و حذف کند:

1
2
3
4
5
cleanup():
    # Find all expired keys and delete
    for key, value in data:
        if value.expiry and current_time > value.expiry:
            delete data[key]

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

حالا از TTL پشتیبانی می‌کنیم، اما هنوز باید محدودیت‌های حافظه را مدیریت کنیم. اینجاست که نیازمندی بعدی وارد می‌شود: Eviction از نوع LRU.

3) Eviction داده طبق سیاست LRU

حالا باید آنچه را بعد از پر شدن کش رخ می‌دهد مدیریت کنیم. از سیاست Least Recently Used (LRU) استفاده می‌کنیم که ورودی‌هایی را حذف می‌کند که برای بیشترین مدت به آن‌ها دسترسی نشده است.

مطمئن شوید با مصاحبه‌کننده تأیید کرده‌اید که کش LRU پیاده می‌کنید. ممکن است سیاست Eviction دیگری در ذهن داشته باشد یا حتی بخواهد پیاده‌سازی شما قابل پیکربندی باشد.

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

راه‌حل ترکیب دو ساختار داده است:

  • جدول هش برای جست‌وجوی O(1). دسترسی فوری به هر آیتم کش می‌دهد.
  • لیست پیوندی دوطرفه (Doubly Linked List) برای ردیابی ترتیب دسترسی. به ما تاریخچه کامل آیتم‌های استفاده‌شده در زمان را می‌دهد.

این ساختارها با هم اجازه می‌دهند کش LRU کارآمدی بسازیم که در آن عملیات Get و Set در زمان O(1) انجام می‌شود.

وقتی آیتمی اضافه یا به آن دسترسی می‌شود:

  1. یک شیء Node می‌سازیم که کلید، مقدار و زمان انقضا را دارد.
  2. یک ورودی به جدول هش اضافه می‌کنیم که کلید را به این گره نگاشت می‌کند.
  3. گره را جلوی لیست پیوندی دوطرفه (درست بعد از Dummy Head) قرار می‌دهیم.
  4. اگر ظرفیتمان تمام شده باشد، آیتم کمتر اخیراً استفاده‌شده را هم از جدول هش و هم از لیست پیوندی حذف می‌کنیم.

لیست پیوندی دوطرفه ترتیب دقیق دسترسی را حفظ می‌کند: سر لیست آیتم‌های اخیراً استفاده‌شده را دارد و دم لیست آیتم‌های کمتر اخیراً استفاده‌شده را. وقتی باید آیتمی را Evict کنیم، به‌سادگی گره درست قبل از Dummy Tail را حذف می‌کنیم.

مثلاً اگر آیتم‌های A و B و C را به کشی با ظرفیت 2 اضافه کنیم:

  • افزودن A: [A]
  • افزودن B: [B -> A]
  • افزودن C: کش پر است، A را Evict می‌کنیم (که کمتر اخیراً استفاده شده)، در نتیجه [C -> B]

وقتی به آیتم موجودی دسترسی می‌شود:

  1. گره آن را در جدول هش جست‌وجو می‌کنیم (O(1)).
  2. گره را از موقعیت فعلی در لیست جدا می‌کنیم (O(1)).
  3. آن را به ابتدای لیست می‌بریم (O(1)).

به این ترتیب، آیتم‌هایی که زیاد به آن‌ها دسترسی می‌شود نزدیک سر می‌مانند و از Eviction در امان‌اند، در حالی که آیتم‌های کم‌استفاده به سمت دم کشیده می‌شوند و در نهایت وقتی به فضا نیاز باشد حذف می‌شوند.

جدول هش جست‌وجوی O(1) می‌دهد، در حالی که لیست پیوندی دوطرفه به‌روزرسانی O(1) برای ترتیب دسترسی. با ترکیب آن‌ها بهترین هر دو دنیا را می‌گیریم: دسترسی سریع و ردیابی کارآمد LRU. شبه‌کد پیاده‌سازی:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class Node # Node in our doubly-linked list storing values and expiry times

class Cache:
    get(key):
        # get the node from the hash table
        node = data[key]

        # Check if the entry has expired
        if node.expiry and currentTime() > node.expiry:
            # Remove expired node from both hash table and linked list
            delete data[key]
            delete node
            return null

        # Move node to front of list
        move_to_front(node)
        return node.value

    set(key, value, ttl):
        # Calculate expiry timestamp if TTL is provided
        expiry = currentTime() + ttl if ttl else null

        if key in data:
            # Update existing entry
            node = data[key]
            node.value = value
            node.expiry = expiry
            move_to_front(node)
        else:
            # Add new entry
            node = Node(key, value, expiry)
            data[key] = node
            add_node(node)

            # If over capacity, remove least recently used item
            if size > capacity:
                lru = tail.prev
                delete lru
                delete data[lru.key]

    cleanup():
        expired_keys = []

        # Scan from LRU end towards head
        current = tail.prev
        while current != head:
            if current.expiry and currentTime() > current.expiry:
                expired_keys.add(current.key)
            current = current.prev

        # Remove expired entries
        for key in expired_keys:
            node = data[key]
            delete data[key]
            delete node

مثال‌های کد اینجا برای نشان دادن مفاهیم اصلی‌اند و برای وضوح عمداً ساده شده‌اند. پیاده‌سازی تولیدی باید نگرانی‌های اضافه بسیاری را مدیریت کند، از جمله ایمنی در برابر Thread (Thread Safety)، مدیریت خطا، مانیتورینگ و بهینه‌سازی عملکرد.

بخش هوشمندانه این پیاده‌سازی این است که همه عملیات (Get و Set و حتی Eviction) O(1) می‌مانند. وقتی به آیتمی دسترسی می‌شود یا اضافه می‌شود، آن را به ابتدای لیست می‌بریم. وقتی باید Evict کنیم، از انتهای لیست حذف می‌کنیم.

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

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

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

1) تضمین دسترس‌پذیری بالا و تحمل خطا

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

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

  • چند کپی نگه داریم؟
  • کدام گره‌ها باید کپی‌ها را ذخیره کنند؟
  • چطور کپی‌ها را هماهنگ نگه داریم؟
  • وقتی گره‌ها از کار بیفتند یا نتوانند ارتباط برقرار کنند چه می‌شود؟

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

رویکرد بد: تکثیر هم‌زمان (Synchronous Replication)

ساده‌ترین روش نگه داشتن چند کپی از هر Shard است. وقتی نوشتنی می‌رسد، همه کپی‌ها را هم‌زمان به‌روز می‌کنیم و فقط وقتی پاسخ می‌دهیم که همه کپی‌ها نوشتن را تأیید کرده باشند. اگرچه ساده است، اگر هر کپی کند یا ناپایدار باشد، این روش می‌تواند روی Latency نوشتن و دسترس‌پذیری اثر بگذارد.

تکثیر هم‌زمان برای کش‌هایی که به تضمین Strong Consistency نیاز دارند ترجیح داده می‌شود (که ما نداریم).

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

رویکرد خوب: تکثیر ناهم‌زمان (Asynchronous Replication)

گزینه دیگر این است که فوراً یک نسخه اصلی (Primary) را به‌روز کنیم و بعد تغییرات را ناهم‌زمان به کپی‌ها (Replica) منتشر کنیم؛ فقط وقتی نسخه اصلی تغییر را تأیید کرد، نوشتن را Ack کنیم. این با مدل Eventual Consistency که بیشتر کش‌ها اتخاذ می‌کنند می‌خواند (و برای ما یک نیازمندی غیرعملکردی است)، و آن را برای کشی با نیازهای ما از تکثیر هم‌زمان مناسب‌تر می‌کند.

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

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

رویکرد خوب: تکثیر Peer-to-Peer

در تکثیر Peer-to-Peer هر گره برابر است و می‌تواند هم خواندن و هم نوشتن را بپذیرد. تغییرات با پروتکل‌های Gossip به گره‌های دیگر منتشر می‌شوند، که در آن گره‌ها دوره‌ای اطلاعات وضعیت خود را با همتایانی که تصادفی انتخاب می‌شوند مبادله می‌کنند.

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

چالش‌ها: اگرچه تکثیر Peer-to-Peer مقیاس‌پذیری و دسترس‌پذیری زیادی می‌دهد، چالش‌های مهمی هم دارد. پیاده‌سازی آن از بقیه پیچیده‌تر است، چون هر گره باید ارتباط با همتایان متعدد را حفظ کند و حل تعارض را مدیریت کند. مدل Eventual Consistency یعنی گره‌های مختلف ممکن است موقتاً مقدارهای متفاوتی برای یک کلید داشته باشند. علاوه بر این، وقتی به‌روزرسانی‌های هم‌زمان در گره‌های مختلف رخ می‌دهد، باید به استراتژی‌های حل تعارض دقت زیادی شود.

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

در مصاحبه، گزینه‌ها را با مصاحبه‌کننده در میان می‌گذارم. احتمالاً کمتر به تصمیم شما اینجا اهمیت می‌دهد و بیشتر به این اهمیت می‌دهد که از گزینه‌ها و مبادلات آگاه باشید. با توجه به نیازمندی‌ها، دو گزینه را با مصاحبه‌کننده بحث می‌کنم: تکثیر ناهم‌زمان برای تعادل خوب بین دسترس‌پذیری و سادگی، و Peer-to-Peer برای حداکثر مقیاس‌پذیری. واقعیت این است که اینجا پاسخ درست یگانه‌ای وجود ندارد.

2) مقیاس‌پذیری کش

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

نمی‌توانیم 1 ترابایت داده را بدون رد کردن محدودیت‌های حافظه و عملکرد به‌طور مؤثر روی یک گره جا بدهیم. باید جفت‌های کلید-مقدار را روی چند گره تقسیم (Shard) کنیم. اگر خوب انجام شود، هر گره بخش قابل مدیریتی از مجموعه داده را نگه می‌دارد و به ما اجازه می‌دهد فقط با اضافه کردن ماشین بیشتر افقی مقیاس بدهیم.

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

می‌توانیم تعداد گره‌های لازم را با در نظر گرفتن Throughput و ذخیره‌سازی برآورد کنیم. با Throughput شروع می‌کنیم. فرض کنیم یک میزبان تکی را Benchmark کرده‌ایم و می‌تواند 20 هزار درخواست در ثانیه را مدیریت کند. دست‌کم به 100000 / 20000 = 5 گره برای رسیدن به نیازهای Throughput نیاز داریم. با اضافه کردن مقداری بافر برای جهش ترافیک و خرابی احتمالی گره‌ها، باید برای دست‌کم 8 گره برنامه‌ریزی کنیم.

برای نیازهای ذخیره‌سازی، باید در نظر بگیریم یک نمونه AWS معمولی با 32 گیگابایت RAM بعد از احتساب سربار سیستم، می‌تواند به‌طور قابل اعتماد حدود 24 گیگابایت را برای داده کش استفاده کند. چون باید 1 ترابایت (1024 گیگابایت) داده ذخیره کنیم، تقریباً 1024 / 24 = 43 گره فقط برای ذخیره‌سازی لازم است. با اضافه کردن مقداری بافر برای رشد داده و سربار عملیاتی، باید برای حدود 50 گره برنامه‌ریزی کنیم.

توجه کنید برآوردهای Throughput (20 هزار درخواست در ثانیه) و ظرفیت حافظه (24 گیگابایت قابل استفاده از 32 گیگابایت RAM) که بالا استفاده شد، تقریب‌های سرانگشتی برای تصویر کردن‌اند. عملکرد واقعی بسته به مشخصات سخت‌افزار، پیکربندی کش، الگوی دسترسی و ماهیت داده ذخیره‌شده به‌طور قابل توجهی فرق می‌کند. برای تعیین نیازهای ظرفیت واقعی، همیشه با بارکار خودتان Benchmark کنید.

چون محاسبه مبتنی بر ذخیره‌سازی (50 گره) از محاسبه مبتنی بر Throughput (8 گره) بیشتر است، باید بر اساس نیاز ذخیره‌سازی تأمین کنیم. این تعداد گره بالاتر در واقع به نفع ماست، چون فضای زیادی برای تأمین نیاز Throughput می‌دهد. البته این‌ها برآوردهای تقریبی‌اند و اعداد واقعی بر اساس عوامل متعدد از جمله نوع نمونه‌ها، اندازه میانگین مقدارهای ذخیره‌شده، نسبت خواندن به نوشتن، نیازهای تکثیر و نرخ رشد مورد انتظار فرق می‌کنند. اما برای هدف ما می‌گوییم 50 گره در خوشه خواهیم داشت!

پس 50 گره داریم، اما چطور بفهمیم یک جفت کلید-مقدار مشخص باید روی کدام گره ذخیره شود؟

3) توزیع یکنواخت کلیدها بین گره‌ها

پاسخ؟ Consistent Hashing (هشینگ پایدار).

مطمئنم قبلاً درباره Consistent Hashing خوانده‌اید، اما سریع مفهوم اصلی را مرور می‌کنیم. بدون Consistent Hashing، راه‌حل ساده استفاده از عملیات مدول ساده برای تعیین گره ذخیره یک جفت کلید-مقدار است. مثلاً اگر 4 گره داشته باشید، می‌توانید از hash(key) % 4 استفاده کنید و نتیجه شماره گرهی است که آن جفت باید در آن ذخیره شود.

این وقتی تعداد گره‌ها ثابت است عالی است، اما وقتی یک گره را اضافه یا حذف می‌کنید چه می‌شود؟

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

پس به‌جای hash(key) % 4، از یک تابع هش ثابت مثل MurmurHash استفاده می‌کنیم تا موقعیتی روی حلقه بگیریم. از آن موقعیت در جهت عقربه‌های ساعت دور حلقه حرکت می‌کنیم تا اولین گره را پیدا کنیم. این گره همان است که باید جفت کلید-مقدار را ذخیره کند!

چرا باید این همه دردسر را کشید؟ چون حالا اگر گره‌ای اضافه یا حذف شود، فقط کلیدهایی که در محدوده آن گره می‌افتند باید دوباره نگاشت شوند، به‌جای اینکه مجبور باشیم همه کلیدها را روی همه گره‌ها دوباره توزیع کنیم، همان‌طور که اگر عملیات مدول از hash(key) % 4 به hash(key) % 3 تغییر می‌کرد لازم می‌شد.

4) کلید داغ با خواندن زیاد

مطمئناً سؤال کلاسیک بعدی مصاحبه را می‌گیرید: کلیدهای داغ چطور؟ صادقانه بگویم، این معمولاً ویژگی کلاینت یا کاربر کش است، نه خود کش. با این حال اغلب در مصاحبه‌ها مطرح می‌شود، پس بهتر است با پاسخ سنجیده‌ای آماده باشید.

کلیدهای داغ (Hot Keys) چالش رایج در سیستم‌های کش توزیع‌شده‌اند. وقتی رخ می‌دهند که کلیدهای مشخصی در مقایسه با کلیدهای دیگر ترافیک نامتناسب بالایی می‌گیرند، مثلاً داده یک توییت وایرال یا شمارنده موجودی محصول در فروش ویژه. وقتی درخواست‌های زیادی روی یک Shard که این کلیدهای محبوب را نگه می‌دارد متمرکز شوند، کانونی می‌سازد که می‌تواند عملکرد کل آن Shard را کم کند.

دو نوع متمایز مشکل کلید داغ را باید مدیریت کنیم:

  • خواندن داغ (Hot Reads): کلیدهایی که حجم بسیار بالایی درخواست خواندن می‌گیرند، مثل داده یک توییت وایرال که میلیون‌ها کاربر می‌خواهند هم‌زمان ببینند.
  • نوشتن داغ (Hot Writes): کلیدهایی که درخواست‌های نوشتن هم‌زمان زیادی می‌گیرند، مثل شمارنده‌ای که رأی‌های هم‌زمان را ردیابی می‌کند.

الگو: مقیاس‌دهی خواندن (Scaling Reads). سناریوهای کلید داغ در کش توزیع‌شده، چالش‌های مقیاس‌دهی خواندن را خوب نشان می‌دهند. وقتی میلیون‌ها کاربر هم‌زمان محتوای وایرال یکسانی را درخواست می‌کنند، مفروضات سنتی کش از هم می‌پاشند.

اول استراتژی‌های مدیریت خواندن داغ و بعد رویکردهای نوشتن داغ را بررسی می‌کنیم.

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

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

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

رویکرد خوب: لایه کش اختصاصی برای کلیدهای داغ

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

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

رویکرد خوب: Read Replica

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

چالش‌ها: اگرچه Read Replica ها می‌توانند بار خواندن را مؤثر توزیع کنند، از نظر ذخیره‌سازی و پهنای باند شبکه سربار قابل توجهی دارند، چون کل گره‌ها باید تکثیر شوند. این رویکرد همچنین مدیریت دقیق تأخیر تکثیر و سازگاری بین گره‌های Primary و Replica را می‌خواهد. علاوه بر این، پیچیدگی عملیاتی بالا می‌رود، چون باید چند کپی از داده یکسان را نگهداری و مانیتور کنید، سناریوهای خرابی را مدیریت کنید و همگام‌سازی مناسب همه کپی‌ها را تضمین کنید. اگر فقط زیرمجموعه کوچکی از کلیدها واقعاً بار زیادی را تجربه کنند، ممکن است این راه‌حل بیش از حد باشد.

رویکرد عالی: تکثیر انتخابی کلیدهای داغ

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

  1. اول، سیستم الگوهای دسترسی کلید را برای شناسایی کلیدهای داغ که زیاد خوانده می‌شوند مانیتور می‌کند.
  2. وقتی کلیدی «داغ» می‌شود، به‌جای داشتن فقط یک کپی به نام user:123، سیستم چند کپی با پسوندهای مختلف می‌سازد: user:123#1 روی Node A، user:123#2 روی Node B و user:123#3 روی Node C.
  3. این کپی‌ها از طریق Consistent Hashing روی گره‌های مختلف توزیع می‌شوند.
  4. برای خواندن، کلاینت‌ها تصادفی یکی از کلیدهای پسوندی را انتخاب می‌کنند و بار خواندن را بین چند گره پخش می‌کنند.
  5. برای نوشتن، سیستم باید همه کپی‌ها را به‌روز کند تا سازگاری حفظ شود.

مثلاً اگر product:iphone هنگام فروش ویژه با 100 هزار خواندن در ثانیه ولی فقط 1 نوشتن در ثانیه به کلید داغ تبدیل شود: بدون کپی، یک گره همه 100 هزار خواندن در ثانیه را مدیریت می‌کند؛ با 4 کپی، هر کپی 25 هزار خواندن در ثانیه را مدیریت می‌کند، اما نوشتن باید هر 4 کپی را به‌روز کند.

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

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

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

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

5) کلید داغ با نوشتن زیاد

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

رویکرد خوب: دسته‌ای کردن نوشتن‌ها (Write Batching)

نوشتن‌ها را با جمع کردن چند عملیات نوشتن در یک پنجره زمانی کوتاه و اعمال آن‌ها به‌صورت یک به‌روزرسانی اتمی دسته‌ای کنید. به‌جای پردازش هر نوشتن جداگانه به محض رسیدن، کلاینت‌ها نوشتن‌ها را برای مدت کوتاهی (معمولاً 50 تا 100 میلی‌ثانیه) بافر می‌کنند و بعد آن‌ها را در یک عملیات واحد ادغام می‌کنند. این رویکرد به‌ویژه برای شمارنده‌ها، متریک‌ها و سناریوهای دیگری مؤثر است که وضعیت نهایی بیشتر از ردیابی تک‌تک به‌روزرسانی‌ها اهمیت دارد.

ویدیوی وایرالی را در نظر بگیرید که 10 هزار به‌روزرسانی در ثانیه می‌گیرد. به‌جای اجرای 10 هزار عملیات جدا برای تنظیم مقدارهای جدید (مثل views=1 و views=2 و views=3 و غیره)، دسته‌ای کردن نوشتن ممکن است این به‌روزرسانی‌ها را 100 میلی‌ثانیه جمع کند و بعد یک عملیات اجرا کند که مقدار نهایی را 1000 تا بالاتر ببرد. این فشار نوشتن روی گره کش را یک مرتبه بزرگی کم می‌کند و در عین حال دقت معقول را حفظ می‌کند. مبادله، تأخیر کوچک در دیده شدن نوشتن است، اما برای بسیاری از کاربردها، با توجه به مزایای چشمگیر عملکرد، قابل قبول است.

چالش‌ها: چالش اصلی دسته‌ای کردن نوشتن، مدیریت مبادله بین تأخیر دسته‌ای و قابل مشاهده بودن نوشتن است. پنجره‌های دسته‌ای بلندتر بار سیستم را کم می‌کنند اما زمان قابل مشاهده شدن نوشتن‌ها را زیاد می‌کنند. همچنین پیچیدگی مدیریت خرابی در طول پنجره دسته‌ای هست: اگر پردازشگر دسته‌ای از کار بیفتد، به مکانیزم‌هایی برای بازیابی یا پخش مجدد نوشتن‌های بافرشده نیاز دارید. علاوه بر این، دسته‌ای کردن ناسازگاری جزئی در عملیات خواندن می‌سازد، چون همیشه مقداری نوشتن معلق در بافر هست. این رویکرد برای متریک‌ها و شمارنده‌هایی که Eventual Consistency در آن‌ها قابل قبول است بهترین عملکرد را دارد، اما ممکن است برای سناریوهایی که نیاز به دیده شدن فوری نوشتن دارند مناسب نباشد.

رویکرد عالی: تقسیم کلید (Key Splitting)

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

به‌جای داشتن یک شمارنده یا مقدار که همه نوشتن‌ها را می‌گیرد، سیستم با استراتژی پسوند، نوشتن‌ها را روی چند Shard پخش می‌کند. مثلاً کلید شمارنده داغ views:video123 ممکن است به 10 Shard تقسیم شود: views:video123:1 تا views:video123:10. وقتی نوشتنی می‌رسد، سیستم تصادفی یکی از این Shard ها را برای به‌روزرسانی انتخاب می‌کند.

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

چالش‌ها: چالش اصلی تقسیم کلید، افزایش پیچیدگی عملیات خواندن است، که حالا باید داده را از چند Shard جمع کند. این می‌تواند Latency خواندن و مصرف منابع را بالا ببرد و عملاً عملکرد نوشتن را با عملکرد خواندن معاوضه می‌کند. همچنین چالش حفظ سازگاری بین Shard ها در سناریوهای شکست یا عملیات بالانس مجدد (Rebalancing) هست. تعداد Shard ها باید با دقت انتخاب شود: خیلی کم بار را به اندازه کافی توزیع نمی‌کند و خیلی زیاد خواندن را بی‌جهت پیچیده می‌کند. در نهایت، این رویکرد ممکن است برای عملیاتی که به‌راحتی قابل تجزیه نیستند، مثل عملیاتی که به حفظ ترتیب دقیق یا اتمی بودن روی همه به‌روزرسانی‌ها نیاز دارند، مناسب نباشد.

6) تضمین عملکرد کش

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

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

خبر خوب این است که با هر کدام از راه‌حل‌های قبلی قبلاً بخشی از این مشکل را حل کرده‌ایم. دسته‌ای کردن درخواست (Request Batching) که به نوشتن داغ کمک کرد، یک تکنیک کلی مؤثر برای کم کردن Latency هم هست، چون تعداد رفت‌وبرگشت‌ها بین کلاینت و سرور را کم می‌کند. Consistent Hashing که در راه‌حل Sharding درباره‌اش صحبت کردیم هم تکنیک کلی مؤثری برای کم کردن Latency است، چون یعنی برای فهمیدن اینکه کدام گره داده ما را دارد، نیازی به کوئری از یک سرویس مسیریابی مرکزی نداریم، و یک رفت‌وبرگشت صرفه‌جویی می‌شود.

تنها مورد دیگری که اگر این سؤال پرسیده شود ممکن است در مصاحبه ذکر کنم، Connection Pooling (ادغام اتصال) است. مدام بستن و دوباره باز کردن اتصال‌های شبکه بین کلاینت و سرورها دستور پخت اتلاف وقت است. به‌جای ساختن اتصال جدید برای هر درخواست، کلاینت‌ها باید مجموعه‌ای از اتصال‌های باز و پایدار نگه دارند. این تضمین می‌کند همیشه کانال آماده‌ای برای درخواست‌ها هست، Handshake های گران رفت‌وبرگشتی را حذف می‌کند و تأخیرهای دم (Tail Latency) را به‌شدت کم می‌کند، مثل زمان‌های پاسخ p95 و p99 که می‌توانند تجربه کاربری را بسازند یا خراب کنند.

جمع‌بندی طراحی

با کنار هم گذاشتن همه این‌ها، روی هر گره دو ساختار داده خواهیم داشت:

  • یک جدول هش برای ذخیره جفت‌های کلید-مقدار
  • یک لیست پیوندی برای سیاست Eviction از نوع LRU

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

  • تکثیر ناهم‌زمان برای دسترس‌پذیری بالا و مدیریت خواندن کلید داغ
  • Consistent Hashing برای Sharding و مسیریابی
  • پسوندهای تصادفی برای توزیع نوشتن‌های کلید داغ بین گره‌ها
  • Batching و Connection Pooling برای کم کردن Latency و سربار شبکه

طراحی نهایی شما ممکن است چیزی شبیه این باشد: کلاینت‌ها با Connection Pool و Request Batching، لایه مسیریابی با Consistent Hashing، Shard هایی (حدود 50 گره) که هرکدام Primary با Replica های ناهم‌زمان دارند و روی هر گره جدول هش و لیست پیوندی LRU، به‌علاوه کپی‌های پسوندی برای کلیدهای داغ و Shard های فرعی برای شمارنده‌های پرنوشتن.

انتظارات در هر سطح

سطح Mid-level

برای داوطلبان Mid-level معمولاً زمان بیشتری را روی بخش طراحی سطح بالا (که همان طراحی سطح پایین است) و زمان کمتری را روی بخش مقیاس صرف می‌کنیم. انتظار دارم بتوانند درباره ترکیب درست ساختارهای داده برای رسیدن به نیازمندی‌های Latency ما استدلال کنند و بتوانند راهشان را به پاسخ‌های پایه درباره مقیاس‌پذیری و دسترس‌پذیری باز کنند. مثلاً فهمیدن نیاز به تکثیر و کشف راه‌های مختلفی که می‌توان آن را پیاده کرد.

سطح Senior

برای داوطلبان Senior انتظار دارم بخش طراحی سطح پایین نسبتاً سرراست باشد و در عوض بیشتر وقتمان را صرف صحبت درباره مقیاس کنیم. می‌خواهم توانایی آن‌ها را در سنجیدن مبادلات و تصمیم‌گیری با توجه به نیازمندی‌های سیستم ببینم. باید استراتژی‌های پایه برای مدیریت خواندن و نوشتن داغ را بدانند و بتوانند درباره چیزهایی مثل Consistent Hashing طوری صحبت کنند که انگار طبیعت دومشان است.

سطح Staff به بالا

من معمولاً این سؤال را از مهندسان Staff نمی‌پرسم، چون تا وقتی Staff هستید این مفاهیم به‌طور طبیعی پیش می‌آیند، یعنی این بهترین سؤال برای ارزیابی یک مهندس Staff نیست.