پست

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

مبانی Redis، ساختارهای داده، پیکربندی زیرساخت و کاربردهای رایج آن در طراحی سیستم شامل کش، قفل توزیع‌شده، لیدربورد، Rate Limiting، جستجوی مجاورت، Streams و Pub/Sub همراه با مشکل hot key

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

Redis

در این فصل با نحوه استفاده از Redis برای حل تعداد زیادی از مسائل طراحی سیستم آشنا می‌شویم.

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

فراتر از انعطاف، Redis به‌خاطر سادگی‌اش هم عالی است. Redis ویژگی‌های زیادی دارد که شبیه ساختارهای داده‌ای هستند که احتمالاً از کدنویسی به آن‌ها عادت دارید (hash، set، sorted set، stream و مانند آن). و با چند اصل پایه‌ای می‌توان به‌راحتی درباره رفتار آن‌ها در سیستم توزیع‌شده استدلال کرد. بسیاری از پایگاه‌های داده جادوی زیادی دارند (optimizer، query planner و مانند آن)، اما Redis، با چند استثنای کوچک، بسیار ساده مانده و دقیقاً کاری را خوب انجام می‌دهد که در آن بهترین است: اجرای سریع عملیات ساده.

خب، Redis همه‌کاره، ساده و برای مصاحبه‌های طراحی سیستم مفید است. ببینیم چطور کار می‌کند.

مبانی Redis

Redis یک «data structure store» است که به زبان C نوشته شده. درون‌حافظه‌ای (in-memory) و تک‌رشته‌ای (single-threaded) است، که استدلال درباره آن را بسیار سریع و آسان می‌کند.

یکی از دلایل مهمی که ممکن است نخواهید از Redis استفاده کنید این است که به دوام (durability) نیاز دارید. با این‌که چند استراتژی معقول برای حداقل کردن از دست رفتن داده وجود دارد (مثل استفاده از Append-Only File یا AOF در Redis)، همان تضمین‌هایی را که از پایگاه داده رابطه‌ای برای نوشته شدن commitها روی دیسک می‌گیرید، دریافت نمی‌کنید. این مصالحه عمدی تیم Redis به نفع سرعت است. اما پیاده‌سازی‌های جایگزین (مثل MemoryDB در AWS) کمی از سرعت می‌گذرند تا دوام مبتنی بر دیسک بدهند. اگر به آن نیاز دارید، همان‌جاست!

بعضی از اساسی‌ترین ساختارهای داده‌ای که Redis پشتیبانی می‌کند:

  • رشته‌ها (String)
  • Hash (اشیا)
  • List
  • Set
  • Sorted Set (صف اولویت‌دار)
  • Bloom Filter
  • ایندکس جغرافیایی (Geospatial)
  • سری زمانی (Time Series)

علاوه بر ساختارهای داده ساده، Redis الگوهای ارتباطی مختلف مثل Pub/Sub و Streams را هم پشتیبانی می‌کند، که تا حدی در جایگاهی بین Apache Kafka و Amazon Simple Notification Service قرار می‌گیرند.

ساختار اصلی زیر Redis یک ذخیره‌گاه کلید-مقدار است. کلیدها رشته‌اند و مقدارها می‌توانند هر یک از ساختارهای داده‌ای باشند که Redis پشتیبانی می‌کند: داده باینری و رشته، set، list، hash، sorted set و مانند آن. همه اشیا در Redis یک کلید دارند.

انتخاب کلیدها مهم است، چون بسته به پیکربندی زیرساخت شما ممکن است این کلیدها روی گره‌های جداگانه ذخیره شوند. در واقع، روشی که کلیدها را سازمان می‌دهید همان روشی است که داده‌هایتان را سازمان می‌دهید و Redis cluster خود را مقیاس می‌دهید.

دستورات

پروتکل ارتباطی Redis (wire protocol) یک زبان کوئری سفارشی است که از رشته‌های ساده تشکیل شده و برای تمام قابلیت‌های Redis استفاده می‌شود. CLI واقعاً ساده است. می‌توانید به معنای واقعی کلمه به یک نمونه Redis وصل شوید و این دستورات را از CLI اجرا کنید.

1
2
3
4
SET foo 1
GET foo     # Returns 1
INCR foo    # Returns 2
XADD mystream * name Sara surname OConnor # Adds an item to a stream

مجموعه کامل دستورات وقتی بر اساس ساختار داده دسته‌بندی شوند، به‌طرز شگفت‌آوری خوانا است. مثلاً setهای Redis عملیات ساده‌ای دارند، مثل اضافه کردن عنصر به set (SADD)، گرفتن تعداد عناصر یا cardinality (SCARD)، فهرست کردن عناصر (SMEMBERS) و بررسی وجود یک عنصر (SISMEMBER). شباهت زیادی با چیزی دارد که در هر زبان برنامه‌نویسی همه‌منظوره با set می‌کنید.

پیکربندی زیرساخت

Redis می‌تواند به‌صورت گره تکی، با یک replica برای دسترس‌پذیری بالا (HA)، یا به‌صورت cluster اجرا شود. وقتی به‌صورت cluster اجرا می‌شود، کلاینت‌های Redis مجموعه‌ای از «hash slot» را نگه می‌دارند که کلیدها را به یک گره مشخص نگاشت می‌کنند. به این ترتیب کلاینت‌ها می‌توانند مستقیم به گرهای وصل شوند که داده موردنظرشان را دارد.

هر گره از طریق یک پروتکل gossip مقداری آگاهی از گره‌های دیگر نگه می‌دارد. پس در موارد محدود، اگر کلیدی را از گره اشتباه بخواهید، می‌توانید به گره درست هدایت شوید. اما تأکید Redis روی کارایی است، پس رسیدن به endpoint درست در اولین تلاش اولویت دارد.

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

کارایی

Redis واقعاً سریع است. Redis می‌تواند در حد O(100k) نوشتن در ثانیه را مدیریت کند و تأخیر خواندن اغلب در محدوده میکروثانیه است. این مقیاس باعث می‌شود بعضی ضدالگوها برای سیستم‌های پایگاه داده دیگر، با Redis عملاً ممکن باشند. مثلاً فرستادن ۱۰۰ درخواست SQL برای ساختن فهرستی از آیتم‌ها در پایگاه داده SQL ایده وحشتناکی است، و بهتر است یک کوئری SQL بنویسید که همه داده لازم را در یک درخواست برگرداند. در مقابل، سربار انجام همین کار با Redis بسیار کم است. هرچند اگر بتوانید از آن اجتناب کنید، همچنان بهتر است.

این کاملاً تابعی از ماهیت درون‌حافظه‌ای Redis است. برای هر کاربردی مناسب نیست، اما برای بسیاری از کاربردها هست.

قابلیت‌ها

Redis به‌عنوان کش

رایج‌ترین سناریوی استقرار Redis کش است. در این حالت، کلیدها و مقدارهای Redis به کلیدها و مقدارهای کش ما نگاشت می‌شوند. Redis می‌تواند این hash map را به‌سادگی بین همه گره‌های cluster ما توزیع کند و بی‌دردسر مقیاس بدهد. اگر ظرفیت بیشتری لازم داشتیم، فقط گره‌ها را به cluster اضافه می‌کنیم.

وقتی Redis را به‌عنوان کش استفاده می‌کنید، اغلب برای هر کلید از TTL (زمان زنده ماندن) استفاده می‌کنید. Redis تضمین می‌کند بعد از منقضی شدن TTL، مقدار آن کلید را نخواهید خواند. برای تصمیم‌گیری درباره حذف (evict) آیتم‌ها وقتی می‌خواهید آیتم‌های بیشتری از ظرفیت را کش کنید هم سیاست‌های eviction دارد.

استفاده از Redis به این شکل یکی از مهم‌ترین مشکل‌های کش‌ها، یعنی مشکل «hot key» را حل نمی‌کند، هرچند Redis از این نظر در مقایسه با جایگزین‌هایی مثل Memcached یا پایگاه‌های داده بسیار مقیاس‌پذیر مثل DynamoDB چیز خاصی نیست.

Redis به‌عنوان قفل توزیع‌شده

کاربرد رایج دیگر Redis در طراحی سیستم، قفل توزیع‌شده است. گاهی در سیستم داده‌ای داریم که باید در طول به‌روزرسانی‌ها سازگاری‌اش را حفظ کنیم (مثلاً سوال بسیار رایج طراحی Ticketmaster)، یا باید مطمئن شویم چند نفر هم‌زمان کاری را انجام نمی‌دهند (مثلاً طراحی Uber).

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

یک قفل توزیع‌شده بسیار ساده با timeout می‌تواند از INCR اتمیک همراه با TTL استفاده کند. وقتی می‌خواهیم قفل را بگیریم، INCR را اجرا می‌کنیم. اگر پاسخ ۱ بود (یعنی ما صاحب قفل هستیم)، ادامه می‌دهیم. اگر پاسخ بیشتر از ۱ بود (یعنی کس دیگری قفل را دارد)، صبر می‌کنیم و بعداً دوباره امتحان می‌کنیم. وقتی کارمان با قفل تمام شد، می‌توانیم کلید را DEL کنیم تا فرایندهای دیگر بتوانند از آن استفاده کنند.

قفل‌های پیچیده‌تر در Redis می‌توانند از الگوریتم Redlock استفاده کنند.

Redis برای لیدربورد

sorted setهای Redis داده مرتب‌شده نگه می‌دارند که می‌توان در زمان لگاریتمی در آن‌ها جستجو کرد. این ویژگی آن‌ها را برای اپلیکیشن‌های لیدربورد مناسب می‌کند. throughput نوشتن بالا و تأخیر خواندن کم، این کار را به‌خصوص برای اپلیکیشن‌های بزرگ‌مقیاسی که چیزی مثل SQL DB در آن‌ها شروع به دشواری می‌کند مفید می‌کند.

در طراحی جستجوی پست (Post Search)، باید پست‌هایی را پیدا کنیم که کلمه کلیدی مشخصی (مثلاً «tiger») دارند و بیشترین لایک را گرفته‌اند (مثلاً پست «Tiger Woods appeared…» با ۵۰۰ لایک).

می‌توانیم از sorted setهای Redis برای نگه داشتن فهرستی از پست‌های پرلایک برای هر کلمه کلیدی استفاده کنیم. به‌صورت دوره‌ای می‌توانیم برای صرفه‌جویی در فضا، پست‌های کم‌رتبه را حذف کنیم.

1
2
3
ZADD tiger_posts 500 "SomeId1" # Add the Tiger woods post
ZADD tiger_posts 1 "SomeId2" # Add some tweet about zoo tigers
ZREMRANGEBYRANK tiger_posts 0 -6 # Remove all but the top 5 posts

Redis برای Rate Limiting

Redis به‌عنوان سرور ساختار داده، پیاده‌سازی طیف گسترده‌ای از الگوریتم‌های Rate Limiting را ممکن می‌کند. یک الگوریتم رایج، Rate Limiter با پنجره ثابت (fixed window) است که در آن تضمین می‌کنیم تعداد درخواست‌ها در یک بازه زمانی ثابت W از N بیشتر نشود.

پیاده‌سازی این در Redis ساده است. وقتی درخواستی می‌رسد، کلید Rate Limiter را با INCR زیاد می‌کنیم و پاسخ را بررسی می‌کنیم. اگر پاسخ بیشتر از N بود، منتظر می‌مانیم. اگر کمتر از N بود، می‌توانیم ادامه دهیم. روی کلیدمان EXPIRE را فراخوانی می‌کنیم تا بعد از زمان W مقدار ریست شود.

Redis برای جستجوی مجاورت

Redis به‌صورت بومی از ایندکس جغرافیایی با دستوراتی مثل GEOADD و GEOSEARCH پشتیبانی می‌کند. دستورات پایه ساده‌اند:

1
2
GEOADD key longitude latitude member # Adds "member" to the index at key "key"
GEOSEARCH key FROMLONLAT longitude latitude BYRADIUS radius unit # Searches the index at key "key" at specified position and radius

دستور جستجو در این مثال در زمان O(N+log(M)) اجرا می‌شود، که N تعداد عناصر در شعاع و M تعداد آیتم‌های داخل شکل است.

Redis برای Event Sourcing

Redis Streams لاگ‌های append-only هستند که شبیه topicهای Kafka عمل می‌کنند. ایده اصلی Redis Streams این است که می‌خواهیم آیتم‌ها را به‌صورت پایدار به یک log اضافه کنیم و بعد مکانیزمی توزیع‌شده برای مصرف آیتم‌ها از این logها داشته باشیم. Redis این مسئله را با Streams (که با دستوراتی مثل XADD مدیریت می‌شوند) و consumer groupها (دستوراتی مثل XREADGROUP و XCLAIM) حل می‌کند.

یک مثال ساده صف کار (work queue) است. می‌خواهیم آیتم‌ها را به صف اضافه کنیم و پردازششان کنیم. در هر لحظه ممکن است یکی از workerهای ما شکست بخورد و در این موارد می‌خواهیم بعد از تشخیص خرابی، آیتم‌ها را دوباره پردازش کنیم. با Redis Streams آیتم‌ها را با دستوراتی مثل XADD به صف اضافه می‌کنیم و یک consumer group تکی برای workerهایمان به stream وصل می‌کنیم. این consumer group آیتم‌های در حال پردازش از طریق stream را ردیابی می‌کند و اگر worker شکست بخورد، راهی به worker جدید می‌دهد که پیام را claim (XCLAIM) کند و پردازشش را دوباره شروع کند.

Redis برای Pub/Sub

Redis به‌صورت بومی از الگوی پیام‌رسانی publish/subscribe (Pub/Sub) پشتیبانی می‌کند که اجازه می‌دهد پیام‌ها بلادرنگ برای چند مشترک پخش شوند. این برای ساخت سیستم‌های چت، اعلان‌های بلادرنگ یا هر سناریویی مفید است که می‌خواهید تولیدکننده‌های پیام را از مصرف‌کننده‌ها جدا کنید (درباره آن در الگوی «به‌روزرسانی‌های بلادرنگ» بیشتر بخوانید).

مردم اغلب محدودیت‌هایی از Redis Pub/Sub را مطرح می‌کنند که دیگر درست نیستند. مثلاً Redis Pub/Sub حالا sharded است، که مقیاس‌پذیری‌ای را ممکن می‌کند که در نسخه‌های قدیمی‌تر ممکن نبود!

دستورات اصلی ساده‌اند:

1
2
SPUBLISH channel message   # Sends a message to all subscribers of 'channel' (the S prefix means "sharded")
SSUBSCRIBE channel        # Listens for messages on 'channel'

وقتی کلاینت در کانالی مشترک می‌شود، تا وقتی اتصال باز است پیام‌های منتشرشده در آن کانال را دریافت می‌کند. این Pub/Sub را برای ارتباطات زودگذر و بلادرنگ عالی می‌کند. اما مهم است که پیام‌ها ماندگار نیستند. اگر مشترکی هنگام انتشار پیام آفلاین باشد، آن پیام را کاملاً از دست می‌دهد.

کلاینت‌های Pub/Sub از یک اتصال واحد به هر گره در cluster استفاده می‌کنند (به‌جای یک اتصال برای هر کانال). به‌طور کلی یعنی در بیشتر موارد به اندازه تعداد گره‌های cluster اتصال استفاده می‌کنید. همچنین یعنی حتی با میلیون‌ها کانال، به میلیون‌ها اتصال نیاز ندارید!

Redis Pub/Sub ساده و سریع است، اما بادوام نیست. تحویل پیام‌ها «حداکثر یک‌بار» (at-most-once) است. یعنی اگر مشترکی هنگام انتشار پیام آفلاین باشد، آن پیام را کاملاً از دست می‌دهد. اگر به ماندگاری پیام، تضمین‌های تحویل یا توانایی replay پیام‌های از دست‌رفته نیاز دارید، از Redis Streams یا یک message broker اختصاصی مثل Kafka یا RabbitMQ استفاده کنید.

Pub/Sub برای سناریوهای مصاحبه که باید الگوهای ارتباطی بلادرنگ را نشان دهید مناسب است، اما آماده باشید درباره محدودیت‌های آن و زمانی که ممکن است به راه‌حل مقاوم‌تری نیاز داشته باشید صحبت کنید.

کاستی‌ها و راه‌حل‌ها

مشکل Hot Key

اگر بار ما به‌طور مساوی بین کلیدهای Redis cluster توزیع نشده باشد، ممکن است به مشکلی به نام «hot key» بخوریم. برای نشان دادن آن، فرض کنید از Redis برای ذخیره جزئیات آیتم‌های فروشگاه آنلاین‌مان استفاده می‌کنیم. آیتم زیادی داریم، پس cluster را به ۱۰۰ گره تقسیم می‌کنیم و آیتم‌هایمان به‌طور مساوی بین آن‌ها پخش می‌شوند. تا اینجا خوب است. حالا تصور کنید یک روز علاقه زیادی به یک آیتم مشخص پیدا شود، آن‌قدر که حجم درخواست‌های این آیتم با حجم همه بقیه آیتم‌ها برابری کند.

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

راه‌حل‌های بالقوه زیادی برای این مسئله هست و همه مصالحه دارند.

  • می‌توانیم یک کش درون‌حافظه‌ای به کلاینت‌هایمان اضافه کنیم تا درخواست‌های زیادی برای داده یکسان به Redis نفرستند.
  • می‌توانیم همان داده را روی چند کلید ذخیره کنیم و درخواست‌ها را تصادفی کنیم تا در cluster پخش شوند.
  • می‌توانیم read replica اضافه کنیم و آن‌ها را بر اساس بار به‌صورت پویا مقیاس بدهیم.

برای مصاحبه، نکته مهم این است که hot keyهای بالقوه را تشخیص دهید و فعالانه راه‌حل طراحی کنید.

جمع‌بندی

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