پست

طراحی پلتفرم Metrics Monitoring

طراحی پلتفرم پایش متریک برای ۵۰۰ هزار سرور و ۵ میلیون متریک در ثانیه همراه با داشبورد و هشداردهی

طراحی پلتفرم Metrics Monitoring

درک مسئله

پلتفرم Metrics Monitoring داده‌ی عملکرد (CPU، حافظه، Throughput، Latency) را از سرورها و سرویس‌ها جمع می‌کند، به‌صورت داده‌ی سری زمانی (Time-series) ذخیره می‌کند، روی داشبورد نمایش می‌دهد و هنگام عبور از آستانه‌ها هشدار (Alert) می‌فرستد. نمونه‌هایش Datadog، ترکیب Prometheus و Grafana، و AWS CloudWatch هستند. مهندس‌ها از این زیرساخت برای فهم سلامت سیستم و واکنش به حادثه‌ها استفاده می‌کنند.

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

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

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

  • پلتفرم بتواند متریک‌ها (CPU، حافظه، Latency و شمارنده‌های سفارشی) را از سرویس‌ها دریافت (Ingest) کند.
  • کاربر بتواند متریک‌ها را با فیلتر، تجمیع و بازه‌ی زمانی روی داشبورد Query و نمایش دهد.
  • کاربر بتواند قانون هشدار (Alert Rule) با آستانه روی پنجره‌ی زمانی تعریف کند، مثلاً «اگر p99 Latency به مدت ۵ دقیقه بیشتر از ۵۰۰ میلی‌ثانیه بود هشدار بده».
  • هنگام فعال شدن هشدار، اعلان (ایمیل، Slack، PagerDuty) به کاربر برسد.

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

  • تجمیع لاگ و جستجوی متنی کامل (دغدغه‌ی جداست).
  • Distributed Tracing (Span و Trace).
  • تشخیص ناهنجاری با ML.

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

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

ممکن است از مصاحبه‌کننده بپرسیم یا خودش بگوید «برای پایش ۵۰۰ هزار سرور طراحی کن». این ناوگان بزرگی است. اگر هر سرور هر ۱۰ ثانیه ۱۰۰ نقطه‌ی داده بفرستد، در اوج ۵ میلیون متریک در ثانیه می‌شود. هر نقطه‌ی داده کوچک است (Timestamp، مقدار و Label) و حدود ۱۰۰ تا ۲۰۰ بایت دارد، ولی در این حجم با حدود ۱ گیگابایت در ثانیه داده‌ی خام ورودی روبه‌رو هستیم. هسته‌ی مسئله همین است.

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

  • سیستم تا ۵ میلیون متریک در ثانیه از ۵۰۰ هزار سرور را دریافت کند.
  • Queryهای داشبورد در چند ثانیه جواب بدهند، حتی برای بازه‌های چندروزه یا چندهفته‌ای.
  • هشدارها با تأخیر کم ارزیابی شوند (کمتر از ۱ دقیقه از انتشار متریک تا فعال شدن هشدار).
  • سیستم Highly Available باشد. برای داشبورد، Eventual Consistency را تحمل می‌کنیم ولی ارزیابی هشدار باید قابل‌اعتماد باشد.
  • سیستم داده‌ی دیررسیده یا نامرتب را به‌خوبی مدیریت کند (تأخیر شبکه رایج است).

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

  • Replication چندمنطقه‌ای (پیچیدگی اضافه می‌کند).
  • تضمین Strong Consistency.

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

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

طراحی چنین متریک‌هایی هنر است ولی در مصاحبه‌ای مثل این به‌ندرت محور بحث است. شاید مصاحبه‌کننده‌هایی باشند که اصرار دارند سیستم رویدادمحور Streaming بسازید، ولی تمرکز ما اینجا روی آن نیست.

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

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

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

تعریف Entityهای اصلی

بهتر است بر سر چند Entity یا «اسم» اصلی مسئله با مصاحبه‌کننده هم‌نظر شویم. اغلب برای یک چیز از زبان‌های متفاوت استفاده می‌شود و این مرحله سردرگمی را از بین می‌برد. همچنین جایی است که شروع می‌کنید ذهنتان را با ابعاد مسئله درگیر کنید.

نکته‌ی مهم اینجا رابطه‌ی بین Metric، Label و Series است. Metric یک اندازه‌گیری نام‌دار است، مثل cpu_usage. ولی به‌ندرت می‌خواهیم همه‌ی برش‌های آن را یک‌جا ببینیم و معمولاً می‌خواهیم بر اساس Labelهایی که کاربر تعریف کرده برش بزنیم.

Labelها جفت‌های کلید-مقدار هستند که به متریک وصل می‌شوند تا مشخص کنند از کجا آمده، مثل host="server-1" یا region="us-east". Series ترکیب یکتای نام متریک به‌علاوه‌ی Labelها در طول زمان است. پس اگر ۵۰۰ هزار سرور هرکدام cpu_usage گزارش کنند، ۵۰۰ هزار Series جدا داریم. اگر Label مربوط به core را هم اضافه کنیم، ناگهان با میلیون‌ها Series روبه‌رو هستیم. این «انفجار Series» چالش مقیاس‌پذیری مرکزی کل سیستم است.

توجه کنید اضافه کردن Label جدید همیشه Series جدید نمی‌سازد. با ۵۰۰ هزار سرور، اگر host را Label بگیرم ۵۰۰ هزار Series دارم. ولی اگر region را اضافه کنم، تا وقتی سرورها در چند منطقه نباشند Series اضافه‌ای نمی‌سازد. چون فقط ترکیب‌های یکتای نام متریک و Labelها مهم است؛ ممکن است server-1 در us-east باشد ولی Label یا Series‌ای برای server-1 در us-west نداشته باشم.

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

  • Label: جفت کلید-مقدار وصل‌شده به متریک که امکان برش و فیلتر می‌دهد. مثلاً host="server-1" یا region="us-east".
  • Metric: اندازه‌گیری نام‌دار با Labelها و مقدار در یک لحظه. مثال: cpu_usage{host="server-1", region="us-east"} = 0.75.
  • Series: دنباله‌ی کامل جفت‌های (Timestamp، مقدار) برای یک ترکیب مشخص متریک و Label. یعنی cpu_usage{host="server-1"} در طول زمان یک Series است و cpu_usage{host="server-2"} Series دیگری.
  • Alert Rule: شرطی که با نقض شدنش اعلان فعال می‌شود. Query متریک، آستانه و مدت را ترکیب می‌کند. مثلاً «میانگین CPU در us-east به مدت ۵ دقیقه بالای ۹۰٪».
  • Dashboard: مجموعه‌ای از پنل‌ها که هرکدام نتیجه‌ی یک Query را به‌شکل نمودار یا جدول نشان می‌دهند.

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

بخش دشوار سیستم پایش متریک مدیریت Series در مقیاس بزرگ است. بیشتر سیستم‌ها عمداً تلاش می‌کنند رشد تعداد Series در طول زمان را محدود کنند (مشکلی که اغلب Cardinality Explosion نامیده می‌شود). این را در Deep Dive بررسی می‌کنیم.

جریان داده

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

  1. سرویس‌ها نقاط داده‌ی متریک (CPU، حافظه، Latency و …) تولید می‌کنند و به پلتفرم می‌فرستند.
  2. پلتفرم متریک‌ها را دریافت، اعتبارسنجی و به‌صورت Time-series ذخیره می‌کند.
  3. کاربران متریک‌های ذخیره‌شده را از طریق داشبورد Query می‌کنند و روی بازه‌های زمانی فیلتر و تجمیع می‌زنند.
  4. قانون‌های هشدار به‌صورت دوره‌ای روی متریک‌های ذخیره‌شده ارزیابی می‌شوند.
  5. وقتی شرط هشدار نقض شد، اعلان به کانال‌های پیکربندی‌شده (Slack، PagerDuty، ایمیل) می‌رود.

خط لوله‌ی ساده‌ای است. نکته‌ی جالب این است که مراحل ۱ و ۲ سنگین نوشتنی و پیوسته‌اند (۵ میلیون متریک در ثانیه)، مرحله‌ی ۳ سنگین خواندنی و منقطع است (مهندس‌هایی که حادثه را دیباگ می‌کنند)، و مراحل ۴ و ۵ بیش از هر چیز باید قابل‌اعتماد باشند. این ویژگی‌های متفاوت خیلی از تصمیم‌های طراحی را شکل می‌دهند.

رابط سیستم و API

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

برای API از JSON استفاده می‌کنم چون نوشتنش راحت است، ولی در این مقیاس تقریباً قطعاً برای پروتکل Wire از Protobuf یا فرمت باینری مشابه استفاده می‌کنید. این را به مصاحبه‌کننده می‌گویم و بیشتر آن‌ها دقیقاً می‌دانند منظور چیست.

اول، راهی برای دریافت متریک‌ها لازم است. این عملیات حجم بالا دارد و معمولاً دسته‌ای (Batch) است، پس از Endpoint از نوع POST استفاده می‌کنیم:

1
2
3
4
5
6
7
POST /metrics/ingest
{
  "metrics": [
    { "name": "cpu_usage", "labels": {"host": "server-1"}, "value": 0.75, "timestamp": 1640000000 },
    ...
  ]
}

بعد، راهی برای Query متریک‌ها لازم است. این عملیات سنگین خواندنی است و با یک DSL مشخص می‌کنیم چه داده‌ای می‌خواهیم. PromQL (زبان Query در Prometheus) نمونه‌ی عالی DSL برای Query داده‌ی Time-series است، پس از آن الگو می‌گیریم:

1
GET /metrics/query?query=avg(cpu_usage{region="us-east"})&start=A&end=B&step=60 -> { "timestamps": [...], "values": [...] }

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

1
2
3
4
5
6
7
POST /alerts/rules
{
  "name": "High CPU Alert",
  "query": "avg(cpu_usage{region='us-east'}) > 0.9",
  "for": "5m",
  "notifications": ["slack:#oncall", "pagerduty:team-infra"]
}

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

۱) دریافت متریک از سرویس‌ها

طراحی سطح بالا را با مسیر دریافت شروع می‌کنیم: متریک‌ها چطور از سرورهای تولیدکننده به محل ذخیره‌ای می‌رسند که بتوانیم Query و هشدار را روی آن بسازیم. به مسیر دریافتی نیاز داریم که ۵ میلیون متریک در ثانیه را بدون گلوگاه شدن تحمل کند. سخت است!

رویکرد اول، ارسال مستقیم: ساده‌ترین راه این است که سرورها متریک‌ها را مستقیم به یک Ingestion Service POST کنند. این سرویس داده را اعتبارسنجی می‌کند و در Storage می‌نویسد. این روش در مقیاس کوچک جواب می‌دهد، ولی در ۵ میلیون متریک در ثانیه، سریع Ingestion Service و پایگاه داده را غرق می‌کند. باید جلوی تبدیل ۵ میلیون متریک در ثانیه به ۵ میلیون درخواست در ثانیه به سرویس را بگیریم.

رویکرد دوم، افزودن Instance: اولین غریزه‌ی طبیعی این است که Instanceهای بیشتری از Ingestion Service پشت Load Balancer بگذاریم. اگر یک سرور ۱۰۰ هزار نوشتن در ثانیه را تحمل کند، ۵۰ تا بالا می‌آوریم. سیاست‌های Auto-scaling هم می‌توانند هنگام اوج، ظرفیت اضافه کنند.

چالش‌ها: این به خود Ingestion Service کمک می‌کند ولی مشکل پایین‌دست را حل نمی‌کند. هر ۵۰ Instance هنوز مستقیم در پایگاه داده می‌نویسند که حالا ۵ میلیون نوشتن در ثانیه از ۵۰ منبع می‌بیند. گلوگاه را جابه‌جا کرده‌ایم، نه حذف. اگر پایگاه داده کند شود یا موقتاً از دسترس خارج شود، بافری نداریم و متریک‌های ورودی از بین می‌روند. نه Backpressure داریم، نه Durability، و نه راهی برای Replay داده بعد از شکست.

رویکرد سوم، Kafka بین Ingestion و Storage: Kafka را بین Ingestion Service و Storage قرار می‌دهیم. سرورها متریک را به Ingestion Service می‌فرستند و آن سرویس آن را اعتبارسنجی و در Topic ای از Kafka منتشر می‌کند که بر اساس نام متریک (یا Hash نام متریک و Labelها) پارتیشن شده است. این مزایا را می‌دهد:

  • مدیریت Backpressure: Kafka جهش‌ها را جذب می‌کند و Consumerها با سرعت خودشان پردازش می‌کنند.
  • Durability: متریک‌ها تا مصرف شدن در Kafka ماندگار می‌مانند.
  • موازی‌سازی: چند Partition به‌صورت موازی توسط Consumerها پردازش می‌شوند.

چالش‌ها: Kafka پیچیدگی عملیاتی و تأخیر (معمولاً ۱۰ تا ۵۰ میلی‌ثانیه) اضافه می‌کند. همچنین باید شکست Consumer را مدیریت کنیم و معناشناسی تحویل At-least-once را تضمین کنیم. ولی در مقیاس ما این جداسازی ضروری است.

رویکرد چهارم، Agent روی هر سرور: به‌جای ارسال مستقیم سرورها به Ingestion Service مرکزی، روی هر سرور یک پروسس پس‌زمینه‌ی کوچک اجرا می‌کنیم (در سیستم‌های پایش اغلب «Collector» یا «Agent» نامیده می‌شود) که متریک‌ها را محلی جمع می‌کند. نمونه‌های واقعی آن Datadog Agent و Collectorهای مختلف OTEL هستند. این Collector:

  • متریک‌ها را محلی و با فرکانس بالا جمع می‌کند.
  • متریک‌ها را محلی بافر و دسته‌بندی (Batch) می‌کند.
  • دسته‌ها را به‌صورت دوره‌ای به Ingestion Service می‌فرستد که بعد از اعتبارسنجی آن‌ها را در Kafka منتشر می‌کند.

این کار بار را به لبه (Edge) منتقل می‌کند و بار Ingestion مرکزی را کم می‌کند. به‌جای ۵ میلیون درخواست در ثانیه، حالا فقط ۵۰ هزار درخواست در ثانیه به Ingestion Service می‌رسد. Agentها می‌توانند تجمیع محلی هم انجام دهند (مثلاً محاسبه‌ی Percentile محلی پیش از ارسال).

در نهایت Kafka به ما اجازه می‌دهد در برابر جهش‌ها یا مشکلات پایین‌دست بافر داشته باشیم. اگر Ingestion Consumer از کار بیفتد یا عقب بیفتد، می‌توانیم با Retention در Kafka جبران کنیم.

چالش‌ها: Agentها پیچیدگی استقرار اضافه می‌کنند. باید نسخه‌ها، پیکربندی‌ها و خرابی Agent را روی ۵۰۰ هزار سرور مدیریت کنید. ولی این الگوی استاندارد Datadog، Prometheus و سیستم‌های Production دیگر است، چون مقیاس‌پذیری و قابلیت اطمینان بهتری می‌دهد.

با استفاده هم‌زمان از Agent/Collector روی سرورها (برای پخش کردن مسئله) و Kafka (برای بافر در برابر جهش یا مشکل پایین‌دست)، شروعی برای مسیر دریافت داریم. ادامه می‌دهیم.

الگو، Scaling Writes: دریافت ۵ میلیون متریک در ثانیه یک چالش کلاسیک Scaling Writes است. راه‌حل ما سه استراتژی از چهار استراتژی اصلی را به کار می‌گیرد: انتخاب پایگاه داده‌ی بهینه برای نوشتن (Time-series DB)، بافر کردن جهش‌ها با صف (Kafka) و Batching در لبه (Agentها که پیش از ارسال محلی تجمیع می‌کنند). اگر مصاحبه‌کننده روی مقیاس Ingestion فشار آورد، این الگو جعبه‌ابزار کامل را به شما می‌دهد.

صف برای ما مفید است ولی می‌تواند نفرین هم باشد. اگر سیستم ۵ دقیقه از کار بیفتد، هنگام برگشت باید ۵ دقیقه متریک را جبران (Catch-up) کنیم تا دوباره به داده‌ی بلادرنگ برسیم. این یعنی یا باید ظرفیت زیادی داشته باشیم (اگر در حالت عادی ۵۰٪ ظرفیت باشیم، جبران ۵ دقیقه، ۵ دقیقه طول می‌کشد؛ ولی اگر ۷۵٪ باشیم، ۱۵ دقیقه، و …) یا باید تصمیم سخت بگیریم ضرر را قطع کنیم و داده را از دست بدهیم.

در بیشتر سیستم‌های پایش، از دست دادن مقداری داده بهتر از دائماً عقب بودن است. این موضوع بحث Trade-off جالبی با مصاحبه‌کننده می‌سازد.

۲) Query و نمایش متریک روی داشبورد

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

Queryهای داشبورد سخت‌گیرند. مهندسی که حادثه را دیباگ می‌کند ممکن است بپرسد: «p99 Latency همه‌ی Endpointهای API در us-east را در ۶ ساعت گذشته به تفکیک Endpoint نشان بده». این یعنی احتمالاً میلیون‌ها نقطه‌ی داده که باید اسکن، فیلتر، تجمیع و در کمتر از یک ثانیه برگردانده شود.

غریزه‌ی اول استفاده از چیزی است که می‌شناسیم: ریختن داده در پایگاه داده‌ی رابطه‌ای مثل Postgres. هر متریک یک ردیف می‌شود، روی Timestamp و نام متریک Index می‌گذاریم و SQL می‌نویسیم. این در مقیاس کوچک (چند صد سرور و چند هفته Retention) خوب جواب می‌دهد ولی سریع می‌شکند.

رویکرد اول، Postgres: هر نقطه‌ی داده‌ی متریک را به‌صورت یک ردیف ذخیره می‌کنیم:

| id | metric_name | labels | value | timestamp | |—|—|—|—|—|

و Queryهای ورودی را به SQL ترجمه می‌کنیم:

1
2
3
4
5
SELECT time_bucket('1 minute', "timestamp") as bucket, AVG("value")
FROM metrics
WHERE "metric_name" = 'cpu_usage' AND "labels" @> '{"region": "us-east"}'
  AND "timestamp" BETWEEN '2024-01-01' AND '2024-01-02'
GROUP BY bucket;

چالش‌ها: در ۵ میلیون نوشتن در ثانیه، Postgres نمی‌تواند همگام بماند. Sharding، Queryهای بین‌Shard را می‌شکند. با رشد داده، عملکرد خواندن بدتر می‌شود؛ Queryهایی که با یک هفته داده خوب بودند، با یک ماه داده غیرقابل‌استفاده می‌شوند. مدیریت Retention (با DELETE) باعث Write Amplification و فشار Vacuum می‌شود.

رویکرد دوم، Time-series Database: از پایگاه داده‌ای که برای این بار کاری طراحی شده استفاده می‌کنیم: InfluxDB، TimescaleDB یا VictoriaMetrics. این پایگاه‌ها حول ویژگی‌های منحصربه‌فرد داده‌ی Time-series ساخته شده‌اند:

  • نوشتن Append-only: داده به ترتیب زمان نوشته می‌شود و (تقریباً) هرگز به‌روز نمی‌شود. این اجازه‌ی استفاده از موتورهای ذخیره‌سازی LSM-tree یا Append-only را می‌دهد که Throughput نوشتن بالایی دارند.
  • پارتیشن‌بندی زمانی: داده طبیعتاً بر اساس زمان (ساعتی، روزانه) تکه‌تکه می‌شود. Query «۶ ساعت گذشته» فقط به Chunkهای اخیر دست می‌زند. Chunkهای قدیمی را می‌توان بدون اثر روی داده‌ی فعال حذف کرد.
  • فشرده‌سازی ستونی (Columnar Compression): وقتی Timestampها و مقادیر کنار هم ذخیره شوند، بسیار خوب فشرده می‌شوند. یک روز متریک CPU برای یک هاست ممکن است از ۱۰۰ کیلوبایت به ۵ کیلوبایت برسد.
  • Rollup داخلی: پایگاه داده می‌تواند خودکار تجمیع‌های ۱ دقیقه‌ای، ۱ ساعته و ۱ روزه را محاسبه کند و جدا ذخیره کند تا Queryهای بازه‌ی بلند سریع شوند.

داده را هم بر اساس زمان و هم بر اساس Series پارتیشن می‌کنیم (Shard با Hash نام متریک و Labelها). داده‌ی خام ۱۰ ثانیه‌ای ۱۵ روز، Rollup ۱ دقیقه‌ای ۹۰ روز و Rollup ۱ ساعته یک سال نگه داشته می‌شود.

چالش‌ها: پایگاه‌های Time-series با داده‌ی High-cardinality مشکل دارند. میلیون‌ها ترکیب یکتای Label میلیون‌ها Series می‌سازد و هر Series سربار دارد. برای جلوگیری از رشد افسارگسیخته به کنترل‌های Cardinality نیاز داریم.

ما یک Time-series Database اختصاصی انتخاب می‌کنیم. با حل شدن Storage، به Query Service جلوی آن نیاز داریم. این سرویس Queryها را به DSL شبیه PromQL می‌پذیرد، به Query روی Storage ترجمه می‌کند و نتیجه را در قالب مناسب داشبورد برمی‌گرداند.

چرا سرویس جدا؟ مسیر خواندن ویژگی‌های کاملاً متفاوتی با مسیر نوشتن دارد. Queryهای داشبورد پراکنده، کاربرمحور و ممکن است گران باشند (اسکن هفته‌ها داده). نوشتن‌ها ثابت، قابل‌پیش‌بینی هستند و هرگز نباید از دست بروند. با جدا کردن آن‌ها می‌توانیم هرکدام را مستقل مقیاس بدهیم و تنظیم کنیم. همچنین می‌توانیم بدون پیچیده کردن مسیر نوشتن، لایه‌ی Caching به Query Service اضافه کنیم.

این یکی از معدود دفعاتی است که Time-series Database ابزار درست است. در بیشتر مسائل دیگر از آن‌ها استفاده نمی‌کنیم چون تخصصی‌اند؛ برای متریک عالی‌اند ولی برای داده‌ی عمومی محدودند. صرف اینکه داده‌ی دارای Timestamp دارید به این معنی نیست که به Time-series Database نیاز دارید. اما برای پلتفرم متریک در مقیاس بزرگ، تناسب آن واضح است.

برای درک عمیق‌تر نحوه‌ی کار پایگاه‌های Time-series در زیر کاپوت (LSM tree، فشرده‌سازی، Retention) به Deep Dive مربوط به Time Series Databases مراجعه کنید.

۳) تعریف قانون هشدار با آستانه

بعد باید به کاربران راهی بدهیم قانون هشدار تعریف کنند و در صورت برقرار شدن شرط‌ها فعال شود. اینجا خیلی از نامزدها شروع می‌کنند Flink یا Spark بالا بیاورند تا قانون‌ها را روی داده‌ی جریانی ارزیابی کنند، ولی فعلاً این کار زیادی است. یادتان باشد نیازمندی ما فعال شدن هشدار در ۱ دقیقه است و (فعلاً) نیاز به هشدار بلادرنگ نداریم. پس رویکرد ساده‌ی Polling را انتخاب می‌کنیم!

کاربران قانون‌های هشدار را از طریق Alerting API ثبت می‌کنند. این قانون‌ها در پایگاه داده (بگذارید Postgres استفاده کنیم!) نوشته می‌شوند. Alert Evaluator Service به‌صورت دوره‌ای قانون‌ها را برمی‌دارد و Query ای به Time-series Database می‌فرستد تا ارزیابی‌شان کند. وقتی هشدار فعال شد، رویدادی منتشر می‌کنیم که نشان می‌دهد هشدار نقض شده است.

این رویکرد Polling دقیقاً همان کاری است که Prometheus برای ارزیابی قانون‌های هشدار انجام می‌دهد. قانون‌ها در بازه‌ی ثابت (پیش‌فرض ۱ دقیقه) ارزیابی می‌شوند و از همان Storage که به داشبورد سرویس می‌دهد Query می‌گیرند. امتحان‌شده است و برای بیشتر سازمان‌ها خوب کار می‌کند. سادگی «هشدار فقط Queryهای زمان‌بندی‌شده است» استدلال و دیباگ سیستم را ساده‌تر می‌کند.

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

۴) رسیدن اعلان به کاربر هنگام فعال شدن هشدار

وسوسه‌انگیز است که Alert Service وقتی نقض را تشخیص داد مستقیم Slack API یا PagerDuty را صدا بزند، ولی ریسک دارد! یادتان باشد هدف کل سیستم رساندن به‌موقع این هشدارهاست. اگر Slack API ناپایدار باشد و هشدار ما به همین خاطر گم شود، شکست خورده‌ایم.

همچنین باید در مدیریت اعلان‌ها دقت کنیم. اگر ۱۰۰ سرور یک Cluster هم‌زمان آستانه‌ی CPU را نقض کنند، نمی‌خواهیم ۱۰۰ پیام PagerDuty جدا بفرستیم. مهندس On-call لازم ندارد برای چیزی که واضحاً یک حادثه است ۱۰۰ بار گوشی‌اش زنگ بخورد.

به‌جای آن، یک Notification Service بین Alert Service و کانال‌های اعلان می‌گذاریم. این سرویس مسائل کثیف دنیای واقعی را مدیریت می‌کند: Grouping، Deduplication، Silencing و Escalation.

Deduplication را مثال بزنیم. Alert Evaluator ما هر دقیقه اجرا می‌شود و اگر CPU هنوز بالای ۹۰٪ باشد، همان هشدار را دوباره فعال می‌کند. بدون Dedup، مهندس On-call هر دقیقه برای حادثه‌ای که واضحاً همان حادثه‌ی در حال ادامه است Page می‌شود. Notification Service این را با ردیابی وضعیت هشدار حل می‌کند، به‌طوری که هر هشدار یا «firing» است یا «resolved». وقتی رویداد هشدار می‌رسد، سرویس بررسی می‌کند: این هشدار قبلاً firing بوده؟ اگر بله، اعلان را رد می‌کند. فقط در انتقال وضعیت‌ها اعلان می‌دهد: وقتی هشدار برای اول بار فعال می‌شود و وقتی حل می‌شود. به این ترتیب یک Page هنگام شروع مشکل می‌رود و یکی هنگام پایانش.

Grouping شبیه همین است. سرویس هشدارهای یک پنجره‌ی زمانی کوتاه (مثلاً ۳۰ ثانیه) را جمع می‌کند، آن‌ها را بر اساس Labelهایی مثل cluster یا service گروه‌بندی می‌کند و به‌جای یک اعلان برای هر سرور، یک اعلان برای هر گروه می‌فرستد. Silencing به کاربران اجازه می‌دهد هشدارهای مشخص را هنگام نگهداری (Maintenance) بی‌صدا کنند، و Escalation اگر کسی در زمان پیکربندی‌شده هشدار را تأیید (Acknowledge) نکند، از کانال دیگری دوباره اعلان می‌دهد.

اگر Prometheus را استفاده کرده باشید، این دقیقاً کاری است که Alertmanager انجام می‌دهد (و اسمش هم همین است). جداسازی «ارزیابی شرط‌های هشدار» (Prometheus یا Flink) از «مدیریت اعلان‌ها» (Alertmanager) الگوی جاافتاده‌ای است و دلیل خوبی هم دارد: این‌ها مسائل اساساً متفاوتی با ویژگی‌های مقیاس‌پذیری و قابلیت اطمینان متفاوت هستند.

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

  • پلتفرم می‌تواند متریک‌ها را از ۵۰۰ هزار سرور با ۵ میلیون متریک در ثانیه دریافت کند.
  • می‌توانیم متریک‌ها را Query و روی داشبورد نمایش دهیم.
  • می‌توانیم قانون هشدار تعریف کنیم و با برقراری شرط‌ها فعال شود.
  • هنگام فعال شدن هشدار، اعلان دریافت می‌کنیم.

حالا به Deep Dive هایی می‌پردازیم که مصاحبه‌کننده‌ها ممکن است بپرسند.

Deep Dive های احتمالی

۱) چطور Queryهای داشبورد را روی داده‌ی چند هفته با Latency کم سرویس بدهیم؟

Queryهای داشبورد سنگین خواندنی‌اند و می‌توانند بازه‌های زمانی بزرگی را پوشش دهند. Queryای مثل «مصرف CPU همه‌ی Podهای Production در ۳۰ روز گذشته را نشان بده» می‌تواند به میلیاردها نقطه‌ی داده برسد.

الگو، Scaling Reads: Queryهای داشبورد چالش‌های Scaling Reads را نشان می‌دهند: تجمیع‌های سنگین، اسکن بازه‌ی زمانی و نیاز به پاسخ زیر یک ثانیه برای بهره‌وری مهندس‌ها. غریزه‌ی شما باید به سمت (الف) Caching و (ب) پیش‌محاسبه (Pre-computation) برود. هر دو را بررسی می‌کنیم.

رویکرد اول، ذخیره‌ی همه‌چیز با رزولوشن کامل: همه‌ی متریک‌ها را با رزولوشن کامل (فاصله‌ی ۱۰ ثانیه) ذخیره می‌کنیم و برای هر پنل داشبورد مستقیم از پایگاه داده Query می‌گیریم. Query معمول مثلاً می‌گوید: «میانگین CPU همه‌ی Podهای Production را در ۳۰ روز گذشته نشان بده».

چالش‌ها: محاسبه را دنبال کنید. با فاصله‌ی ۱۰ ثانیه، ۳۰ روز برابر ۲۵۹٬۲۰۰ نقطه‌ی داده برای هر Series است. اگر ۱۰۰۰ Pod در Production داشته باشید، ۲۵۹ میلیون ردیف می‌شود که پایگاه داده باید اسکن، فیلتر و تجمیع کند. هر ردیف حدود ۱۰۰ بایت است، پس برای یک پنل داشبورد حدود ۲۵ گیگابایت داده می‌خوانید.

حتی با Indexهای خوب هم این دردناک است. پایگاه داده باید همه‌ی آن ردیف‌ها را ترتیبی اسکن کند، میانگین هر Time Bucket را حساب کند و نتیجه را برگرداند. زمان پاسخ به‌جای ثانیه با دقیقه اندازه‌گیری می‌شود. و این فقط یک پنل است. یک داشبورد معمولی ۶ تا ۱۰ پنل دارد و هرکدام Query خودش را اجرا می‌کند. مهندس‌هایی که حادثه را دیباگ می‌کنند منتظر نمی‌مانند.

رویکرد دوم، رزولوشن‌های چندگانه (Rollup از پیش محاسبه‌شده): گزینه‌ی دیگر ذخیره‌ی داده در چند رزولوشن (دانه‌بندی زمانی، یعنی هر نقطه‌ی داده چه مقدار زمان را پوشش می‌دهد) است:

  • خام: فاصله‌ی ۱۰ ثانیه، ۲ روز نگهداری.
  • Rollup ۱ دقیقه‌ای: ۲ هفته نگهداری.
  • Rollup ۱ ساعته: ۹۰ روز نگهداری.
  • Rollup ۱ روزه: ۲ سال نگهداری.

وقتی Query می‌رسد، Query Engine بر اساس بازه‌ی زمانی و دانه‌بندی درخواستی رزولوشن مناسب را انتخاب می‌کند. Query ۳۰ روزه از Rollup ساعتی (۷۲۰ نقطه برای هر Series) استفاده می‌کند، نه داده‌ی خام.

بیشتر پایگاه‌های Time-series از Rollup به‌صورت آماده پشتیبانی می‌کنند. اگر نکنند، به پروسس پس‌زمینه‌ای نیاز داریم که دوره‌ای آن‌ها را محاسبه کند. در هر دو حالت Query Service ما باید بتواند Queryها را با رزولوشن‌های مختلف (و Queryهایی که از آستانه‌ی رزولوشن عبور می‌کنند) مدیریت کند.

چالش‌ها: Rollupها Lossy هستند. نمی‌توانید p99 Latency را از میانگین‌های از پیش تجمیع‌شده به دست بیاورید و نمی‌توانید بفهمید در آن بازه‌ی ۱۰ ثانیه‌ای چه رخ داده. برای متریک‌های Percentile باید در هر سطح Rollup، Histogram یا Sketch ذخیره کنید. مهندس‌های مسئول پایش باید تاریخچه‌ی Queryهای کاربران را ببینند تا تشخیص دهند کدام Rollupها بیشترین ارزش را دارند.

رویکرد سوم، Caching و Query Splitting: بهترین رویکرد از این واقعیت استفاده می‌کند که بیشتر Queryها همان داده را پوشش می‌دهند. مثل مسئله‌ی Sliding Window، Queryهایی که ۱۰ ثانیه فاصله دارند تقریباً داده‌ی کاملاً هم‌پوشان را می‌خوانند و تنها تفاوت آخرین ۱۰ ثانیه است.

برای بهره‌گیری از این موضوع، لایه‌ی Caching (Redis) جلوی Time-series Database اضافه می‌کنیم:

  • Query Splitting: Query ۳۰ روزه را به تکه‌هایی می‌شکنیم. داده‌ی اخیر (۲ ساعت آخر) برای تازگی مستقیم از پایگاه داده Query می‌شود. داده‌ی تاریخی اول از Cache پرسیده می‌شود.
  • Pre-computation: Queryهای محبوب داشبورد شناسایی و طبق برنامه از پیش محاسبه می‌شوند. نتایج با TTLهایی هم‌راستا با نیاز تازگی داده Cache می‌شوند.
  • Result Caching: نتایج Query با کلیدی مبتنی بر Query و بازه‌ی زمانی Cache می‌شوند. Queryهای یکسان بعدی به Cache می‌خورند.

برای داشبوردهایی که به داده‌ی بلادرنگ نیاز ندارند (که بیشترشان هستند) می‌توانیم کاملاً از Cache با Latency زیر ۱۰۰ میلی‌ثانیه سرویس بدهیم.

چالش‌ها: Cache Invalidation دشوار است. Backfill یا اصلاح داده باید ورودی‌های Cache تحت تأثیر را Invalidate کند. اندازه‌ی Cache باید پایش شود تا حافظه تمام نشود.

۲) چطور Latency هشدار را زیر ۱ دقیقه ببریم؟

Alert Evaluator مبتنی بر Polling ما هر دقیقه اجرا می‌شود، که برای بیشتر هشدارها Latency زیر یک دقیقه می‌دهد. اما بعضی سازمان‌ها تشخیص سریع‌تر می‌خواهند، به‌خصوص برای سرویس‌های حیاتی که هر ثانیه Downtime پول خرج دارد.

گلوگاه Polling این است که طبق برنامه از پایگاه داده Query می‌گیریم، نه اینکه به محض رسیدن داده واکنش نشان دهیم. اگر آستانه‌ای ۱ ثانیه بعد از آخرین ارزیابی نقض شود، تا چرخه‌ی ارزیابی بعد، یعنی حداکثر ۵۹ ثانیه بعد، متوجه نمی‌شویم.

رویکرد اول، ارزیابی مکرر‌تر: لازم نیست زیاد هوشمندانه عمل کنیم. می‌توانیم Alert Evaluator را مکررتر اجرا کنیم، هر ۱۵ یا ۳۰ ثانیه به‌جای هر دقیقه. برای ۱۰ هزار قانون با بازه‌ی ۱۵ ثانیه، حدود ۶۷۰ Query در ثانیه می‌شود.

چالش‌ها: این بار قابل‌توجهی روی Time-series Database اضافه می‌کند. از جایی به بعد Queryهای هشدار با Queryهای داشبورد و نوشتن‌های Ingestion رقابت می‌کنند. و هنوز اساساً به بازه‌ی ارزیابی محدودیم؛ Polling هر ۱۵ ثانیه یعنی حداکثر ۱۴ ثانیه Latency.

رویکرد دوم، Stream Processing با Flink: جایگزین، استفاده از فریم‌ورک پردازش جریان مثل Flink برای ارزیابی هشدارها روی جریان زنده‌ی داده است. چون متریک‌ها از قبل از Kafka عبور می‌کنند، Flink را به‌عنوان Consumer دوم اضافه می‌کنیم:

  1. Flink متریک‌ها را از Kafka می‌خواند (همان Topicی که Consumerهای Ingestion می‌خوانند).
  2. برای هر Series متریک، Flink یک State پنجره‌ای نگه می‌دارد (مثلاً بافر غلتان ۵ دقیقه‌ای از مقادیر).
  3. قانون‌های هشدار به Operatorهای Flink کامپایل می‌شوند که شرط‌ها را مدام روی آن پنجره‌ها ارزیابی می‌کنند.
  4. وقتی آستانه برای مدت پیکربندی‌شده نقض شود، Flink رویداد هشدار منتشر می‌کند.

در این راه‌حل، ارزیابی هشدار با رسیدن داده انجام می‌شود، نه طبق برنامه‌ی Polling. هیچ Query پایگاه داده‌ای در کار نیست و Flink روی State پنجره‌ی داخل حافظه ارزیابی می‌کند. Latency از «حداکثر ۶۰ ثانیه» به «چند ثانیه بعد از رسیدن متریک» می‌رسد. Alert Evaluator مبتنی بر Polling موجود همچنان می‌تواند هشدارهای غیرحیاتی را مدیریت کند و Flink هشدارهای حساس به زمان را.

چالش‌ها: Flink پیچیدگی عملیاتی اضافه می‌کند. قانون‌های هشدار باید به Operatorهای جریانی ترجمه شوند و وقتی کاربران قانون را به‌روز می‌کنند، باید بدون از دست دادن State آن را مدیریت کنیم. Checkpointing حیاتی است تا اگر یک Node در Flink بیفتد، ردیابی ارزیابی‌های در جریان هشدار را از دست ندهیم.

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

۳) چطور در جهش‌ها و خرابی‌ها High Availability را تضمین کنیم؟

اگر سیستم پایش هنگام حادثه از کار بیفتد، دقیقاً همان لحظه‌ای که به دید نیاز دارید کور می‌شوید. HA در اینجا از بیشتر سیستم‌ها مهم‌تر است. باید دو مسیر را جدا بررسی کنیم:

  • دریافت متریک: آیا می‌توانیم هنگام خرابی به جمع‌آوری و ذخیره‌ی داده ادامه دهیم؟
  • هشدار و اعلان: آیا هنوز می‌توانیم وقتی چیزی خراب می‌شود تشخیص دهیم و خبر بدهیم؟

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

رویکرد اول، یک Instance از همه‌چیز: یک Ingestion Service، یک Broker در Kafka، یک Node در Time-series Database و یک Alerting Service داشته باشیم. هشدارها مستقیم به Slack یا PagerDuty می‌روند.

چالش‌ها: این یک Single Point of Failure کلاسیک است. اگر هر قطعه‌ای بیفتد، متریک‌ها گم می‌شوند و هشدارها فعال نمی‌شوند. هنگام جهش ترافیک، Ingestion Service غرق و دچار Timeout می‌شود. هشدارها به تأخیر می‌افتند یا کلاً رد می‌شوند، و اگر ارائه‌دهنده‌ی اعلان قطعی موقت داشته باشد، اعلان‌ها شکست می‌خورند. دقیقاً داده‌ای را از دست می‌دهید که برای دیباگ حادثه لازم دارید.

رویکرد دوم، Redundancy و بافر: هر مرحله را Redundant می‌کنیم و هرجا ممکن است Durability اضافه می‌کنیم:

  • Ingestion: چند Instance از Ingestion Service پشت Load Balancer.
  • Kafka: Partitionهای Replicate‌شده با Leader Election و ISR.
  • Storage: Time-series Database با Replication و نوشتن چند-Node.
  • Alerting: چند Consumer از Flink در یک Consumer Group.
  • Notification: Notification Service که تحویل‌های ناموفق را Retry و صف‌بندی می‌کند.

بهبود کلیدی اینجا بافر کردن است. اگر پایگاه داده کند شود، Kafka جهش را جذب می‌کند. اگر Consumer شکست بخورد، Consumer دیگری در گروه Partitionهایش را برمی‌دارد. اگر ارائه‌دهنده‌ی اعلان از کار بیفتد، سرویس تا بازیابی مدام Retry می‌کند.

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

رویکرد سوم، هر مرحله قابل‌ازسرگیری: طراحی را ساده نگه دارید ولی هر مرحله را Resumable کنید:

مسیر دریافت:

  • Agentها محلی بافر می‌کنند و اگر شبکه یا لایه‌ی دریافت از کار بیفتد، Retry می‌کنند.
  • Kafka بین Zoneها Replicate می‌شود تا از دست رفتن یک Broker داده را حذف نکند.
  • نوشتن‌ها Idempotent هستند تا Retry نقطه‌ی داده‌ی تکراری نسازد.

مسیر هشدار و اعلان:

  • State ارزیابی هشدار Checkpoint می‌شود تا Crash پردازشگر قابل‌ازسرگیری باشد.
  • رویدادهای هشدار پیش از هر اعلان خارجی در Kafka نوشته می‌شوند.
  • Notification Service تحویل را Retry می‌کند و می‌تواند به کانال ثانویه Failover کند.

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

چالش‌ها: قطعات متحرک زیادی است. برای هر مسیر به SLO روشن نیاز دارید و برای اطمینان از سلامت خود سیستم پایش به Meta-monitoring احتیاج دارید. اگر ممکن است هشداری را از دست بدهید چون Query طولانی شده یا داده دیر رسیده، احتمالاً سرویس Watchdog می‌خواهید که فعالانه و برای احتیاط به مشتریان هشدار بدهد. در مصاحبه معمولاً کافی است بافرها، Redundancy و Idempotency را نام ببرید، بدون اینکه در هر جزئیات پیاده‌سازی غرق شوید.

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

۴) چطور Cardinality Explosion را مدیریت کنیم؟

قبلاً به Cardinality Explosion اشاره کردیم و ارزش یک بحث درست را دارد، چون یکی از موذی‌ترین مشکلات سیستم‌های متریک است. هر ترکیب یکتای نام متریک و Labelها یک Series جدید می‌سازد. متریکی مثل http_requests{host, region, endpoint, status_code, method} روی ۱۰۰۰ هاست، ۵ منطقه، ۲۰۰ Endpoint، ۱۰ کد وضعیت و ۵ متد HTTP در تئوری می‌تواند ۵۰ میلیون Series یکتا بسازد. در عمل کمتر است (همه‌ی ترکیب‌ها وجود ندارند) ولی سریع و غیرقابل‌پیش‌بینی رشد می‌کند.

چرا مشکل است؟ دو طرف دارد:

  • سمت نوشتن: هر Series در Time-series Database سربار دارد، مثل Index، Metadata و ردیابی در حافظه. وقتی تعداد Series منفجر شود، عملکرد نوشتن افت می‌کند و مصرف حافظه جهش می‌کند.
  • سمت خواندن: اگر بخواهیم روی تعداد زیادی Series تجمیع کنیم (مثلاً مجموع کل http_requests)، باید همه‌ی Seriesها را بخوانیم و تجمیع کنیم. جمع کردن تعداد بسیار زیادی Series زمان زیادی می‌برد.

می‌توانیم مرحله‌ی اجرای کنترل Cardinality را به Ingestion Service اضافه کنیم، بین اعتبارسنجی متریک و انتشار در Kafka. این کار دو جزء جدید می‌خواهد:

  • Policy Store (در Postgres): بگذارید همان پایگاه داده‌ی Alerts را که قانون‌های هشدار را نگه می‌دارد به پایگاه کلی پیکربندی تبدیل کنیم. این Store برای هر نام متریک، کلیدهای Label مجاز، حداکثر تعداد Series و محدودیت تعداد مقدار برای هر Label را نگه می‌دارد. مثلاً http_requests ممکن است Labelهای host، region، endpoint، status_code و method را با سقف ۵۰۰ هزار Series اجازه دهد.
  • Cardinality Tracker (در Redis): شمارنده‌ی سریعی که تعداد Seriesهای یکتا برای هر متریک را ردیابی می‌کند. وقتی Ingestion Service نقطه‌ی داده‌ای می‌بیند، با یک Set برای هر نام متریک بررسی می‌کند آیا آن ترکیب Label از قبل وجود دارد. اگر Series جدید است، پیش از پذیرفتنش با سقف مقایسه می‌کند.

جریان:

  1. نقطه‌ی داده به Ingestion Service می‌رسد.
  2. هر کلید Label که در فهرست مجاز نیست حذف می‌شود.
  3. Labelهای باقی‌مانده Hash می‌شوند تا شناسه‌ی Series به دست آید.
  4. Redis بررسی می‌شود که این Series وجود دارد یا نه.
  5. اگر جدید بود، با سقف Series آن متریک مقایسه می‌شود.
  6. اگر زیر سقف بود، پذیرفته و در Kafka منتشر می‌شود؛ اگر بالای سقف بود، حذف می‌شود و شمارنده‌ی dropped_metrics افزایش می‌یابد.

وقتی به سقف می‌رسیم، Ingestion Service از طریق Notification Service موجود هشدار می‌دهد تا تیم بداند مشکلی هست. خود شمارنده‌ی متریک‌های حذف‌شده هم متریک مفیدی برای پایش می‌شود. باز هم پایش بیشتر برای سیستم پایش!

Policyها باید برای هر متریک تنظیم شوند، که نیازمند فهمیدن کار واقعی کاربران است. اگر بیش از حد سخت‌گیر باشید، داده‌ی مفید را حذف می‌کنید. اگر بیش از حد سهل باشید، از مشکل جلوگیری نمی‌کنید. جستجوی Redis هم به مسیر دریافت Latency اضافه می‌کند (هرچند سریع است، بررسی عضویت در Set برای هر نقطه‌ی داده با ۵ میلیون در ثانیه جمع می‌شود). می‌توانید این بررسی‌ها را Batch کنید یا از Bloom Filter محلی در مرحله‌ی اول استفاده کنید تا رفت‌وبرگشت به Redis کم شود.

طراحی نهایی

طراحی نهایی با همه‌ی اجزای بحث‌شده: Agentها متریک را محلی جمع و دسته‌بندی می‌کنند و به Ingestion Service می‌فرستند. این سرویس اعتبارسنجی و کنترل Cardinality را با Policy Store و Redis انجام می‌دهد و متریک‌ها را در Kafka منتشر می‌کند. Consumerها آن‌ها را در Time-series Database می‌نویسند (با Rollup و Retention). Query Service با لایه‌ی Cache در Redis به داشبوردها سرویس می‌دهد. Alert Evaluator (Polling) و در صورت نیاز Flink (برای هشدار سریع) رویدادهای هشدار را تولید می‌کنند و Notification Service با Dedup، Grouping، Silencing و Escalation اعلان‌ها را به Slack، PagerDuty و ایمیل می‌رساند.

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

Mid-level:

  • عرض در برابر عمق: نامزد این سطح بیشتر روی عرض تمرکز می‌کند (حدود ۸۰٪ عرض در برابر ۲۰٪ عمق). باید بتوانید طراحی سطح بالایی بسازید که جریان اصلی دریافت، ذخیره، Query و هشدار را پوشش دهد. بسیاری از اجزا برایتان انتزاعی و سطحی خواهند بود. احتمالاً به موضوعاتی مثل Cardinality Explosion یا Stream Processing نمی‌رسید.
  • بررسی مبانی: مصاحبه‌کننده مطمئن می‌شود هر جزء را می‌فهمید. اگر Kafka را مطرح کنید، انتظار سؤال درباره‌ی دلیل مفید بودنش را داشته باشید (جداسازی، Durability، موازی‌سازی). اگر Time-series Database را مطرح کنید، آماده باشید توضیح دهید چرا برای این بار کاری از Postgres بهتر است.
  • انتظار برای Metrics Monitoring: از نامزد Mid-level انتظار دارم نیاز به Message Queue برای مقیاس دریافت را تشخیص دهد، نوعی ذخیره‌سازی Time-series پیشنهاد دهد و درک پایه‌ای از نحوه‌ی کار هشدارها داشته باشد (حتی اگر فقط «پایگاه داده را Poll کن» باشد). ممکن است Cardinality را فعالانه مطرح نکند ولی هنگام سؤال باید بتواند درباره‌اش بحث کند.

Senior:

  • عمق تخصص: انتظارات به دانش عمیق‌تر تغییر می‌کند، حدود ۶۰٪ عرض و ۴۰٪ عمق. باید Trade-offهای ذخیره‌سازی Time-series را بفهمید، با مفاهیم Stream Processing آشنا باشید و توضیح دهید چرا بعضی رویکردها در مقیاس شکست می‌خورند. احتمالاً قبلاً از سیستم متریک استفاده کرده‌اید و ایده‌هایی درباره‌ی مشکلات Cardinality و راه‌حل‌هایش دارید.
  • طراحی پیشرفته: باید با الگوهایی مثل Stream Processing پنجره‌ای و چالش‌های داده‌ی High-cardinality آشنا باشید و بتوانید درباره‌ی فناوری‌های مشخص (Flink، Kafka، InfluxDB) با مقداری عمق صحبت کنید.
  • انتظار برای Metrics Monitoring: نامزد Senior باید Cardinality را فعالانه به‌عنوان چالش حیاتی شناسایی و کنترل‌هایی پیشنهاد کند. باید بفهمد چرا Stream Processing برای هشدار از Polling بهتر است. باید درباره‌ی Rollup و Retention برای عملکرد Query بحث کند. ممکن است همه‌ی Deep Diveها را پوشش ندهد ولی باید درباره‌ی ۲ تا ۳ چالش اصلی واضح فکر کند.

Staff و بالاتر:

  • تأکید بر عمق: از نامزد Staff+ تخصص عمیق انتظار می‌رود، حدود ۴۰٪ عرض و ۶۰٪ عمق. باید بتواند درباره‌ی چالش‌های عملیاتی بحث کند: Meta-monitoring، آبشار Backpressure، Alert Fatigue و ایزوله‌سازی Multi-tenancy. مصاحبه‌کننده انتظار دارد در مسائل عملکردی ذاتی این مسئله و دغدغه‌های Fault Tolerance و Availability باقی‌مانده عمیق شوید.
  • فعال‌بودن در سطح بالا: باید گفتگو را هدایت کنید و چالش‌ها را پیش از پرسیده شدن شناسایی کنید. ممکن است Trade-off بین مدل جمع‌آوری Pull مثل Prometheus و Push مثل Datadog را بحث کنید، یا به چالش‌های تجمیع Histogram برای متریک‌های Percentile بپردازید.
  • انتظار برای Metrics Monitoring: از نامزد Staff+ انتظار دارم درباره‌ی انتخاب‌های فناوری نظرهایی مبتنی بر تجربه داشته باشد. باید دغدغه‌های Production را بحث کند، مثل اینکه وقتی خود سیستم پایش از کار می‌افتد چه می‌شود، چطور تغییرات Schema را مدیریت کنیم، یا چطور بین Storage Backendها مهاجرت کنیم. باید قضاوت درباره‌ی اینکه چه چیزی را بهینه کند و چه چیزی را به تعویق بیندازد نشان دهد.