پست

پایگاه داده‌های Time-series

بررسی مفاهیم پشت پایگاه داده‌های سری زمانی شامل Append-only، LSM Tree، Delta Encoding، Bloom Filter و Downsampling

پایگاه داده‌های Time-series

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

پیش از شروع، یک هشدار مهم درباره‌ی پایگاه داده‌های Time-series: صرف اینکه داده‌ی سری زمانی دارید، به این معنی نیست که به پایگاه داده‌ی Time-series نیاز دارید! مسئله‌ی Top-K نمونه‌ی کلاسیک است که در نگاه اول به نظر می‌رسد TSDB کمک می‌کند، ولی در واقع می‌تواند کار را سخت‌تر کند، چون باید روی تعداد عظیمی Series مرتب‌سازی و تجمیع کنیم، کاری که بیشتر TSDBها برایش طراحی نشده‌اند.

مراقب باشید وقتی پایگاه داده‌ی همه‌منظوره مثل Postgres یا DynamoDB مناسب‌تر است، سراغ پایگاه داده‌ی Time-series نروید. توصیه‌ی من این است که راه‌حل‌های همه‌منظوره را تا حد ممکن برای نیازتان بکشید و فقط وقتی به گلوگاه واقعی رسیدید که با راه‌حل دیگری حل نمی‌شود، سراغ فناوری تخصصی مثل پایگاه داده‌ی Time-series بروید. شناخت محدودیت‌های آن‌ها (در این راهنما) کمک می‌کند بفهمید کی کاربرد دارند.

یک مثال انگیزه‌بخش

تصور کنید سیستم پایش برای یک ارائه‌دهنده‌ی ابری طراحی می‌کنید. ۱۰۰ هزار سرور دارید که هرکدام هر ۱۰ ثانیه ۵ متریک می‌فرستند: مصرف CPU، حافظه، I/O دیسک و ترافیک شبکه. این ۵۰ هزار متریک در ثانیه است، یعنی ۴٫۳ میلیارد نقطه‌ی داده در روز. کاربران می‌خواهند این داده را Query کنند تا داشبورد ببینند، هشدار تعریف کنند و مشکلات هفته‌ی گذشته را دیباگ کنند.

بیایید این را در Postgres معمولی ذخیره کنیم:

1
2
3
4
5
6
CREATE TABLE metrics (
    timestamp TIMESTAMP,
    host VARCHAR(255),
    metric_name VARCHAR(255),
    value DOUBLE PRECISION
);

با ۴٫۳ میلیارد ردیف در روز، در هفته حدود ۳۰ میلیارد ردیف دارید. حتی با Index، Queryهای ساده مثل «میانگین مصرف CPU برای host-42 در یک ساعت گذشته» دردناک کند می‌شوند و با اضافه کردن Index، عملکرد نوشتن هم افت می‌کند. بدتر اینکه Throughput نوشتن لازم (حداقل ۵۰ هزار نوشتن در ثانیه، با جهش‌های بسیار بالاتر) یک Instance از Postgres را له می‌کند. ذخیره‌سازی هم به‌شدت ناکارآمد است: هر ردیف نام کامل هاست و نام متریک را مدام تکرار می‌کند و به ۵۰ تا ۱۰۰ بایت برای هر نقطه‌ی داده می‌رسد، در حالی که اطلاعات واقعی (یک Timestamp و یک Float) فقط ۱۶ بایت است.

می‌توانیم خیلی بهتر عمل کنیم.

پایگاه داده‌های Time-series مثل InfluxDB، TimescaleDB و Prometheus دقیقاً برای این بار کاری ساخته شده‌اند. حالا ببینیم چطور کار می‌کنند.

بلوک‌های سازنده

اجزایی را بررسی می‌کنیم که پایگاه داده‌ی Time-series را سریع می‌کنند. این پایگاه‌ها معمولاً با حجمی از داده سروکار دارند که ذخیره‌سازی روی دیسک تنها گزینه‌ی عملی است. پس از همین شروع می‌کنیم.

ذخیره‌سازی Append-only

اولین بینش ظاهراً ساده است: اگر داده‌ی زیادی می‌نویسید، داده را در محل به‌روز نکنید. به‌جای آن همیشه داده‌ی جدید را به انتهای فایل اضافه (Append) کنید.

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

  1. محل ردیف روی دیسک را پیدا کند.
  2. داده‌ی فعلی را بخواند.
  3. آن را در حافظه تغییر دهد.
  4. داده‌ی به‌روزشده را دوباره بنویسد.

این یعنی I/O تصادفی (Random I/O)، که رایج‌ترین علت مشکلات عملکردی است. در دیسک‌های چرخان، هد دیسک باید به محل مشخصی برود. این یک بازوی فیزیکی است که روی دیسک حرکت می‌کند و با اینکه دیسک‌ها برای این کار بهینه شده‌اند، هنوز فقط ۱۰۰ تا ۲۰۰ عملیات در ثانیه انجام می‌دهند. حتی SSDها، که در عملیات تصادفی بسیار سریع‌ترند، به‌خاطر معماری‌شان با الگوهای دسترسی ترتیبی (Sequential) به‌طور قابل‌توجهی بهتر عمل می‌کنند.

ذخیره‌سازی Append-only این را برعکس می‌کند. هر نقطه‌ی داده‌ی جدید به انتهای فایل فعلی نوشته می‌شود. بدون جستجو (Seek)، بدون خواندن پیش از نوشتن، فقط نوشتن ترتیبی. SSDها می‌توانند صدها هزار نوشتن ترتیبی در ثانیه انجام دهند و حتی دیسک‌های چرخان ده‌ها هزار.

1
2
3
4
5
Traditional DB write:
[Seek to block 4752] → [Read] → [Modify] → [Write] → [Seek to block 9201] → ...

Append-only write:
[Write to end] → [Write to end] → [Write to end] → ...

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

LSM Tree (درخت ادغام با ساختار لاگ)

LSM Tree راز بسیاری از پایگاه‌های داده با Throughput نوشتن بالاست، از جمله InfluxDB، Cassandra و LevelDB. شاید این ایده را از Deep Dive مربوط به Cassandra یا مفهوم Database Indexing به یاد داشته باشید. ایده‌ی اصلی این است که نوشتن‌های تصادفی گران را به نوشتن‌های ترتیبی ارزان تبدیل کنیم و بعد به‌صورت دوره‌ای در پس‌زمینه داده را بازسازمان‌دهی کنیم تا خواندن کارآمدتر شود.

نحوه‌ی کار:

مرحله‌ی ۱، نوشتن در حافظه (مثل Memtable): وقتی داده می‌رسد، به بافر درون‌حافظه‌ای مثل Memtable می‌رود. Memtable معمولاً با ساختار داده‌ی مرتب (مثل Red-black Tree یا Skip List) پیاده می‌شود تا داده بر اساس کلید مرتب بماند. نوشتن‌ها فوق‌العاده سریع‌اند چون فقط به RAM دست می‌زنند.

چرا مرتب نگه می‌داریم؟ چون وقتی روی دیسک Flush می‌کنیم، می‌خواهیم فایل حاصل هم مرتب باشد. فایل‌های مرتب اجازه می‌دهند برای جستجوی نقطه‌ای از Binary Search استفاده کنیم، Queryهای بازه‌ای را کارآمد می‌کنند (کلیدهای مجاور کنار هم ذخیره می‌شوند) و، مهم‌تر، اجازه می‌دهند چند فایل را هنگام Compaction با Merge Sort کارآمد ادغام کنیم.

مرحله‌ی ۲، Flush روی دیسک (مثل SSTable): وقتی Memtable پر می‌شود، به‌صورت فایل مرتب و تغییرناپذیر (Immutable) به نام SSTable (Sorted String Table) روی دیسک نوشته می‌شود. چون Memtable از قبل مرتب بود، این Flush فقط یک نوشتن ترتیبی از ابتدا تا انتهاست، بسیار سریع. بعد Memtable برای داده‌ی جدید خالی می‌شود.

مرحله‌ی ۳، Compaction در پس‌زمینه: با گذشت زمان SSTableهای زیادی جمع می‌شوند. خواندن گران می‌شود چون ممکن است لازم باشد چند فایل را بررسی کنید. Compaction در پس‌زمینه اجرا می‌شود، SSTableهای کوچک‌تر را در SSTableهای بزرگ‌تر ادغام می‌کند و ورودی‌های تکراری و Tombstoneها (نشانگرهای داده‌ی حذف‌شده) را پاک می‌کند.

زیبایی این رویکرد این است که نوشتن‌ها هرگز روی خواندن‌ها بلاک نمی‌شوند. Memtable داده‌ی جدید را مدیریت می‌کند در حالی که Threadهای پس‌زمینه داده‌ی قدیمی‌تر را سازمان می‌دهند. همین جداسازی است که به پایگاه‌های داده‌ی مبتنی بر LSM اجازه می‌دهد Throughput نوشتن بالای پایدار را تحمل کنند.

LSM Tree رایگان نیست. عملکرد خواندن می‌تواند ضعیف‌تر شود، چون ممکن است برای پیدا کردن یک مقدار مجبور باشید چند SSTable را بررسی کنید. همچنین Write Amplification داریم؛ داده هنگام Compaction چند بار بازنویسی می‌شود. پس وقتی سراغ LSM Tree بروید که بار کاری پرنوشتن دارید و حاضرید مقداری عملکرد خواندن را فدای عملکرد نوشتن کنید.

خوب، با Append-only و LSM Tree کم‌کم شبیه Cassandra می‌شویم. چند قطعه‌ی دیگر به پازل اضافه می‌کنیم.

Delta Encoding و فشرده‌سازی

داده‌ی Time-series ویژگی منحصربه‌فردی دارد: مقادیر مجاور اغلب شبیه هم‌اند. اگر مصرف CPU را هر ثانیه ثبت کنید، مقادیر ممکن است 45.2٪ و 45.3٪ و 45.1٪ و 45.4٪ باشند. ذخیره‌ی مقدار کامل در هر بار فضا را هدر می‌دهد.

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

1
2
Raw values:     [45.2] [45.3] [45.1] [45.4]
Delta encoded:  [45.2] [+0.1] [-0.2] [+0.3]

Deltaها اعداد بسیار کوچک‌تری هستند و بیت کمتری برای ذخیره می‌خواهند.

اما صبر کنید، مگر عددهای صحیح و اعشاری همیشه ۳۲ یا ۶۴ بیت نمی‌گیرند؟ نه اگر از Variable-length Encoding استفاده کنید. تکنیک‌هایی مثل varint (عدد صحیح با طول متغیر) اعداد کوچک را با بایت کمتر کد می‌کنند: عدد ۱ ممکن است فقط ۱ بایت بگیرد، در حالی که ۱٬۰۰۰٬۰۰۰ سه بایت. وقتی Deltaهای شما ریزند (مثل +1 یا -2)، به‌جای ۸ بایت، ۱ یا ۲ بایت ذخیره می‌کنید. به همین دلیل تبدیل مقادیر مطلق بزرگ به Deltaهای کوچک این‌قدر چشمگیر سود می‌دهد.

پایگاه‌های داده‌ی Time-series حتی جلوتر می‌روند و الگوریتم‌های فشرده‌سازی تخصصی دارند.

Timestampها از Delta-of-delta Encoding استفاده می‌کنند. Timestampها در داده‌ی Time-series اغلب منظم‌اند، مثلاً هر ۱۰ ثانیه. Delta بین Timestampها ممکن است ثابت یا تقریباً ثابت باشد:

1
2
3
Raw timestamps:     1000, 1010, 1020, 1030, 1040
Deltas:             10  , 10  , 10  , 10  , 10  , ...
Delta-of-deltas:    10  , 0   , 0   , 0   , 0   , ...

وقتی Timestampها کاملاً منظم‌اند، می‌توانید میلیون‌ها از آن‌ها را با سربار تقریباً صفر کد کنید. مقاله‌ی Gorilla فیسبوک نشان داد این تکنیک می‌تواند Timestampها را به‌طور میانگین تا ۱ بیت برای هر مقدار فشرده کند.

مقادیر Float از فشرده‌سازی مبتنی بر XOR استفاده می‌کنند. وقتی دو عدد اعشاری شبیه هم را XOR می‌کنید، بیشتر بیت‌ها صفر می‌شوند. بعد می‌توانید آن صفرها را با Run-length Encoding کد کنید:

1
2
3
4
Value 1: 0 10000010 01101000101000111101011
Value 2: 0 10000010 01101000110000100000000
XOR:     0 00000000 00000000011000011101011
                    ^^^^^^^^^^ lots of leading zeros

با ذخیره‌ی فقط موقعیت اولین بیت متفاوت و بیت‌های معنادار بعد از آن، هر مقدار را به‌طور قابل‌توجهی فشرده می‌کنید. در عمل برای داده‌ی Time-series معمولی به‌طور میانگین ۱٫۳۷ بایت برای هر مقدار به دست می‌آید، که بهبود عظیمی نسبت به ۸ بایت لازم برای Double خام است.

بیشتر مصاحبه‌ها وارد این سطح جزئیات نمی‌شوند، پس سعی نکنید «۱٫۳۷ بایت برای هر مقدار» را حفظ کنید. ایده‌ی اصلی این است که می‌توانیم برای داده‌ی ذخیره‌شده که افزونگی زیادی دارد، فشرده‌سازی قوی به دست بیاوریم، و داده‌ی Time-series نمونه‌ی عالی آن است.

پارتیشن‌بندی زمانی (Sharding بر اساس زمان)

مفهوم کلیدی دیگر سازمان‌دهی داده بر اساس زمان است. پایگاه‌های داده‌ی Time-series داده را بر اساس پنجره‌های زمانی در پارتیشن‌ها گروه می‌کنند، مثلاً یک پارتیشن برای هر روز یا هفته. این پارتیشن‌ها لزوماً روی ماشین‌های مختلف نیستند، ولی اگر برای مقیاس‌دهی لازم باشد قطعاً می‌توانند باشند.

چرا این‌قدر قدرتمند است؟

  • نوشتن‌ها محلی (Localized) هستند. همه‌ی داده‌ی ورودی به پارتیشن زمانی جاری می‌رود. لازم نیست تشخیص دهید کدام پارتیشن از بین چندین پارتیشن باید داده را بگیرد؛ همیشه پارتیشن «اکنون» است.
  • خواندن‌ها کارآمدند. وقتی «آخرین یک ساعت داده» را Query می‌کنید، پایگاه داده دقیقاً می‌داند کدام پارتیشن‌ها را بررسی کند. لازم نیست داده‌ی ماه گذشته را اسکن کند.
  • Retention ساده می‌شود. می‌خواهید فقط ۷ روز داده نگه دارید؟ پارتیشن‌های قدیمی‌تر از ۷ روز را حذف کنید. بدون Query های DELETE گران که جدول عظیمی را اسکن کنند؛ فقط فایل‌های قدیمی را کنار بگذارید.
1
2
3
4
5
6
7
8
9
┌─────────────────────────────────────────────────────────┐
│  Query: "Last 2 hours of CPU data for host-42"          │
└─────────────────────────────────────────────────────────┘
                         │
                         ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Nov 22  │ │ Nov 23  │ │ Nov 24  │ │ Nov 25  │ │ Nov 26  │
│  skip   │ │  skip   │ │  skip   │ │  skip   │ │ ← SCAN  │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘

این استراتژی پارتیشن‌بندی زمانی تقریباً در همه‌ی پایگاه‌های داده‌ی Time-series وجود دارد. آن را در TimescaleDB (که اسمش را «Chunk» می‌گذارد)، در Prometheus و در سیستم‌های سفارشی می‌بینید.

تا اینجا روی بهینه‌سازی نوشتن تمرکز کردیم. اما خواندن چه؟

Bloom Filter برای بهینه‌سازی خواندن

یادتان است LSM Tree چطور کار می‌کند: داده با گذشت زمان در چند SSTable نوشته می‌شود. برای پیدا کردن یک مقدار، ممکن است لازم باشد چند تا از این فایل‌ها را بررسی کنید. هر بررسی یعنی احتمال یک خواندن از دیسک. بدترین سناریو Queryی طولانی است که ممکن است پارتیشن‌های زیادی را پوشش دهد ولی فقط دنبال یک مقدار یا تعداد کمی مقدار است (مثل جمع کردن Time-series یک هاست در بازه‌ی زمانی طولانی).

Bloom Filter این مشکل را زیبا حل می‌کند.

Bloom Filter ساختار داده‌ی احتمالاتی است که می‌تواند بدون هیچ I/O دیسک بگوید «قطعاً اینجا نیست» یا «شاید اینجا باشد». هر SSTable یک Bloom Filter از همه‌ی کلیدهایش نگه می‌دارد. وقتی Series مشخصی را Query می‌کنید، پایگاه داده اول Bloom Filter هر SSTable را بررسی می‌کند. اگر فیلتر بگوید «اینجا نیست»، پایگاه داده با اطمینان مطلق آن فایل را رد می‌کند. False Positive ممکن است (فیلتر می‌گوید «شاید» ولی کلید آنجا نیست)، اما False Negative هرگز رخ نمی‌دهد.

1
2
3
4
5
6
Query: "Get data for host=server-42"

SSTable-1 Bloom filter: "not here"     → skip (no disk read)
SSTable-2 Bloom filter: "not here"     → skip (no disk read)
SSTable-3 Bloom filter: "maybe here"   → check (disk read)
SSTable-4 Bloom filter: "not here"     → skip (no disk read)

در عمل، Bloom Filter خوب تنظیم‌شده حدود ۱۰ بیت برای هر کلید استفاده می‌کند و به نرخ False Positive برابر ۱٪ می‌رسد. برای پایگاه داده‌ای با میلیون‌ها Series پخش‌شده روی ده‌ها SSTable، این کار دهها خواندن دیسک را به فقط یک یا دو خواندن تبدیل می‌کند.

Downsampling و Rollup

Bloom Filter به جستجوهای نقطه‌ای کمک می‌کند، اما Queryهای تجمیعی روی بازه‌های زمانی بزرگ چه؟ متریک‌های خام با رزولوشن ۱۰ ثانیه برای دیباگ مشکلات اخیر عالی‌اند، ولی وقتی به داده‌ی سال گذشته نگاه می‌کنید کسی به آن دانه‌بندی نیاز ندارد. Downsampling خودکار رزولوشن داده‌ی قدیمی‌تر را کاهش می‌دهد و دقت را با کارایی ذخیره‌سازی معاوضه می‌کند.

سیاست معمول چنین چیزی است:

  • ۲۴ ساعت اخیر: رزولوشن کامل (فاصله‌ی ۱۰ ثانیه).
  • ۷ روز اخیر: میانگین‌های ۱ دقیقه‌ای.
  • ۳۰ روز اخیر: میانگین‌های ۵ دقیقه‌ای.
  • سال اخیر: میانگین‌های ۱ ساعته.

پایگاه داده این Rollupها را در پس‌زمینه محاسبه می‌کند و مقادیر از پیش تجمیع‌شده (معمولاً min، max، sum، count) را ذخیره می‌کند که می‌توانند بیشتر Queryها را بدون دست زدن به داده‌ی خام جواب دهند. وقتی می‌پرسید «میانگین مصرف CPU ماه گذشته چقدر بود؟»، پایگاه داده از جدول Rollup ۵ دقیقه‌ای می‌خواند، یعنی ۳۰ برابر داده‌ی کمتر از داده‌ی خام ۱۰ ثانیه‌ای.

1
2
3
Raw data (10s):     [45.2] [45.3] [45.1] [45.4] [45.0] [45.5] ... (8,640 points/day)
1-min rollup:       [min:45.0, max:45.5, avg:45.25, count:6] ... (1,440 points/day)
1-hour rollup:      [min:44.1, max:47.2, avg:45.8, count:360] ... (24 points/day)

این نوعی پیش‌محاسبه است که فضای ذخیره‌سازی و Write Amplification را با خواندن به‌مراتب سریع‌تر روی داده‌ی تاریخی معاوضه می‌کند. اگر می‌خواهید Downsampling را در یک مسئله‌ی واقعی ببینید، به تحلیل مسئله‌ی Ad Click Aggregator مراجعه کنید که برای مدیریت میلیاردها رویداد تبلیغاتی از این تکنیک استفاده می‌کند.

Downsampling و Rollup اغلب در مصاحبه‌ها به‌صورت مذاکره بر سر نیازمندی‌ها ظاهر می‌شوند. مصاحبه‌کننده می‌گوید «باید نمونه‌های ۱۰ ثانیه‌ای را ۱ سال نگه داریم» و شما می‌گویید «این حجم داده‌ی عظیمی است. فکر می‌کنم رزولوشن ریز فقط برای یک هفته لازم باشد و برای یک ماه می‌توانیم به میانگین‌های ۵ دقیقه‌ای Downsample کنیم… این قابل‌قبول است؟» نکته این است که (الف) مشکل آینده را پیش‌بینی می‌کنید، (ب) چالش را توضیح می‌دهید و (ج) جایگزین پیشنهاد می‌دهید. حتی اگر مصاحبه‌کننده بگوید نه، توانایی شما در فکر کردن فراتر از نیازمندی‌های سخت‌گیرانه‌ای که به شما داده شده را ثبت می‌کند، که نشانه‌ی نامزد Staff+ است.

فراداده‌ی سطح بلوک (Block-level Metadata)

آخرین بهینه‌سازی ما تغییر شکلی از ایده‌های برنامه‌ریزی Query است که در Deep Dive مربوط به Elasticsearch دیدیم. هنگام اسکن فایل‌های داده، پایگاه‌های داده‌ی Time-series فراداده‌ای درباره‌ی محتوای هر بلوک نگه می‌دارند، به‌خصوص کمینه و بیشینه‌ی Timestamp و گاهی کمینه و بیشینه‌ی مقادیر. این Block Pruning را هنگام Query ممکن می‌کند.

اگر Queryی مصرف CPU بالای ۱۰٪ را بخواهد و فراداده‌ی یک بلوک نشان دهد فقط داده‌ی بین ۰ تا ۵٪ دارد، پایگاه داده کل آن بلوک را بدون خواندن رد می‌کند. همراه با پارتیشن‌بندی زمانی (که از قبل تعیین می‌کند کدام پارتیشن‌ها بررسی شوند)، این لایه‌ی فیلترینگ دیگری است که حتی با رشد حجم داده Queryها را سریع نگه می‌دارد.

کنار هم گذاشتن، موتور ذخیره‌سازی Time-series

حالا که بلوک‌های سازنده را فهمیدیم، ببینیم در معماری معمول پایگاه داده‌ی Time-series چطور ترکیب می‌شوند.

مدل داده

پایگاه‌های داده‌ی Time-series معمولاً داده را به این شکل سازمان می‌دهند:

  • Measurement یا متریک: مثل جدول (مثلاً cpu_usage، memory).
  • Tag: فراداده‌ی Index‌شده برای فیلتر (مثلاً host="server-1"، region="us-west").
  • Field: مقادیر اندازه‌گیری‌شده‌ی واقعی (مثلاً value=45.2).
  • Timestamp: زمان اندازه‌گیری.

یک نقطه‌ی داده چنین شکلی دارد:

1
2
3
cpu_usage,host=server-1,region=us-west value=45.2 1699999200000000000
└─────────────────────────────────────┘ └────────┘ └─────────────────┘
        measurement + tags               field          timestamp

Tagها حیاتی‌اند چون Index می‌شوند. Queryهایی که با Tag فیلتر می‌کنند سریع‌اند. Fieldها Index نمی‌شوند؛ این‌ها داده‌ی Time-series واقعی هستند که ذخیره می‌کنید.

تفاوت Tag و Field اغلب مردم را گیج می‌کند. از Tag برای فراداده‌ای استفاده کنید که با آن فیلتر می‌کنید (host، region، service). از Field برای مقادیر واقعی که اندازه می‌گیرید استفاده کنید. اشتباه گرفتن این‌ها یا به عملکرد ضعیف Query می‌انجامد یا به مشکل Cardinality Explosion که در ادامه می‌گوییم.

موتور ذخیره‌سازی

موتور ذخیره‌سازی معمول Time-series الگوهایی را که بحث کردیم ترکیب می‌کند:

  1. Write-Ahead Log (WAL): داده اول به WAL می‌رود تا Durability داشته باشد. اگر پایگاه داده Crash کند، می‌تواند داده‌ی Commit‌نشده را از WAL بازیابی کند.
  2. بافر درون‌حافظه: داده به بافر درون‌حافظه‌ای هم نوشته می‌شود که بر اساس Measurement و ترکیب Tag سازمان یافته. این همان Memtable بحث LSM است.
  3. Flush روی دیسک: وقتی بافر به آستانه می‌رسد، به‌صورت فایل تغییرناپذیر با Timestampها و مقادیر فشرده روی دیسک Flush می‌شود.
  4. Compaction پس‌زمینه: فایل‌های کوچک‌تر به‌صورت دوره‌ای در فایل‌های بزرگ‌تر ادغام می‌شوند، که تعداد فایل‌های لازم برای بررسی در Query را کم می‌کند و داده‌ی حذف‌شده را پاک می‌کند.

فرمت فایل به‌شدت بهینه شده است:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
File Structure:
┌──────────────────────────────────────────────────────────────┐
│                          Header                              │
├──────────────────────────────────────────────────────────────┤
│  Block 1: Timestamps (delta-of-delta + varint encoded)       │
│  Block 1: Values (XOR compressed)                            │
├──────────────────────────────────────────────────────────────┤
│  Block 2: Timestamps                                         │
│  Block 2: Values                                             │
├──────────────────────────────────────────────────────────────┤
│                         ...                                  │
├──────────────────────────────────────────────────────────────┤
│                    Index (series → block offsets)            │
├──────────────────────────────────────────────────────────────┤
│                         Footer                               │
└──────────────────────────────────────────────────────────────┘

هر فایل در انتها یک Index دارد که کلیدهای Series (ترکیب Measurement و Tag) را به بلوک‌های حاوی داده‌شان نگاشت می‌کند. یعنی جستجوی داده‌ی Series مشخص، یک Seek به Index و بعد یک Seek به داده است، یعنی دو عملیات دیسک، بدون توجه به حجم داده در فایل.

اجرای Query

وقتی از پایگاه داده‌ی Time-series Query می‌گیرید:

1
2
3
4
SELECT mean(value) FROM cpu_usage
WHERE host = 'server-1'
  AND time > now() - 1h
GROUP BY time(5m)

موتور Query:

  1. شناسایی پارتیشن‌های مرتبط بر اساس فیلتر زمانی. فقط پارتیشن‌هایی که با بازه‌ی زمانی Query هم‌پوشانی دارند بررسی می‌شوند.
  2. پیدا کردن Series با جستجوی فیلتر Tag (host='server-1') در Tag Index درون‌حافظه.
  3. خواندن از بافر و فایل‌های دیسک. بافر جدیدترین داده را دارد و فایل‌های دیسک داده‌ی قدیمی‌تر را. نتایج ادغام می‌شوند.
  4. اعمال تجمیع هنگام خواندن. این عملیات Streaming است؛ پایگاه داده لازم نیست پیش از محاسبه‌ی میانگین کل داده را در حافظه بارگذاری کند.

بینش کلیدی این است که پایگاه‌های داده‌ی Time-series هم از Time Locality (داده‌ی اخیر در حافظه یا فایل‌های اخیر است) و هم از Series Locality (نقاط داده‌ی مرتبط کنار هم ذخیره شده‌اند) بهره می‌برند تا دسترسی به دیسک را کم کنند.

مثال گام‌به‌گام، Query چند-Tag

بیایید مثال کاملی را دنبال کنیم تا ببینیم داده از دریافت تا نتیجه‌ی Query چطور جریان می‌یابد.

گام ۱، دریافت داده: تصور کنید سیستم پایش ما این نقاط داده را طی چند ثانیه می‌نویسد:

1
2
3
4
5
6
7
8
cpu_usage,host=server-1,region=us-west,env=prod value=45.2 1700000000000000000
cpu_usage,host=server-1,region=us-west,env=prod value=47.1 1700000010000000000
cpu_usage,host=server-2,region=us-west,env=prod value=62.3 1700000000000000000
cpu_usage,host=server-2,region=us-west,env=prod value=61.8 1700000010000000000
cpu_usage,host=server-3,region=us-east,env=prod value=38.9 1700000000000000000
cpu_usage,host=server-3,region=us-east,env=prod value=39.2 1700000010000000000
cpu_usage,host=server-4,region=us-east,env=staging value=71.0 1700000000000000000
cpu_usage,host=server-4,region=us-east,env=staging value=73.5 1700000010000000000

گام ۲، نحوه‌ی سازمان‌دهی داده: هر ترکیب یکتای Measurement و Tag یک Series می‌سازد. داده‌ی ما ۴ Series دارد:

1
2
3
4
Series 1: cpu_usage,host=server-1,region=us-west,env=prod
Series 2: cpu_usage,host=server-2,region=us-west,env=prod
Series 3: cpu_usage,host=server-3,region=us-east,env=prod
Series 4: cpu_usage,host=server-4,region=us-east,env=staging

در فایل داده، داده‌ی هر Series کنار هم در بلوک‌های فشرده ذخیره می‌شود. با همه‌ی ترفندهای فشرده‌سازی، روی دیسک چنین به نظر می‌رسد:

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
┌─────────────────────────────────────────────────────────────────────────┐
│ Data File (partition for Nov 15, 2023)                                  │
├─────────────────────────────────────────────────────────────────────────┤
│ Block 0: Series 1 (server-1, us-west, prod)                             │
│                                                                         │
│   Raw timestamps:    [1700000000000000000, 1700000010000000000] (16 bytes)
│   Stored as:         base=1700000000, delta=10, Δ-of-Δ=[0]       (3 bytes)
│                                                                         │
│   Raw values:        [45.2, 47.1]                                (16 bytes)
│   Stored as:         [45.2, XOR-diff]                           (10 bytes)
│                                                                         │
├─────────────────────────────────────────────────────────────────────────┤
│ Block 1: Series 2 (server-2, us-west, prod)                             │
│   Raw:    ts=[1700000000, 1700000010], vals=[62.3, 61.8]        (32 bytes)
│   Stored: ts=base+Δ+[0], vals=[62.3, XOR-diff]                  (13 bytes)
├─────────────────────────────────────────────────────────────────────────┤
│ Block 2: Series 3 (server-3, us-east, prod)                             │
│   Raw:    ts=[1700000000, 1700000010], vals=[38.9, 39.2]        (32 bytes)
│   Stored: ts=base+Δ+[0], vals=[38.9, XOR-diff]                  (13 bytes)
├─────────────────────────────────────────────────────────────────────────┤
│ Block 3: Series 4 (server-4, us-east, staging)                          │
│   Raw:    ts=[1700000000, 1700000010], vals=[71.0, 73.5]        (32 bytes)
│   Stored: ts=base+Δ+[0], vals=[71.0, XOR-diff]                  (13 bytes)
├─────────────────────────────────────────────────────────────────────────┤
│ INDEX                                                                   │
│   "cpu_usage,host=server-1,region=us-west,env=prod"  → Block 0          │
│   "cpu_usage,host=server-2,region=us-west,env=prod"  → Block 1          │
│   "cpu_usage,host=server-3,region=us-east,env=prod"  → Block 2          │
│   "cpu_usage,host=server-4,region=us-east,env=staging" → Block 3        │
└─────────────────────────────────────────────────────────────────────────┘

بلوک ۰ تفکیک کامل را نشان می‌دهد؛ بقیه‌ی بلوک‌ها از نمادگذاری فشرده استفاده می‌کنند. دقت کنید هر بلوک به‌جای ۳۲ بایت حدود ۱۳ بایت ذخیره می‌کند، یعنی ۶۰٪ کاهش فقط از همین دو تکنیک، و با رشد حجم داده این بیشتر هم می‌شود.

پایگاه داده همچنین Tag Index درون‌حافظه نگه می‌دارد که مقادیر Tag را به Series نگاشت می‌کند. اگر آشنا به نظر می‌رسد، این اساساً Inverted Index است، همان ساختار داده‌ای که Elasticsearch را قدرت می‌دهد. به‌جای نگاشت کلمه‌ها به سندها، مقادیر Tag را به Series نگاشت می‌کنیم.

1
2
3
4
5
6
7
8
9
10
11
Tag Index (in memory):
┌─────────────────────────────────────────────────────────────────────────┐
│ region=us-west  → [Series 1, Series 2]                                  │
│ region=us-east  → [Series 3, Series 4]                                  │
│ env=prod        → [Series 1, Series 2, Series 3]                        │
│ env=staging     → [Series 4]                                            │
│ host=server-1   → [Series 1]                                            │
│ host=server-2   → [Series 2]                                            │
│ host=server-3   → [Series 3]                                            │
│ host=server-4   → [Series 4]                                            │
└─────────────────────────────────────────────────────────────────────────┘

گام ۳، اجرای Query چند-Tag: حالا Query می‌زنیم: «میانگین مصرف CPU برای سرورهای Production در us-west چقدر است؟»

1
2
3
SELECT mean(value) FROM cpu_usage
WHERE region = 'us-west' AND env = 'prod'
  AND time >= 1700000000000000000 AND time <= 1700000010000000000

پایگاه داده این‌طور پردازش می‌کند:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Query: region='us-west' AND env='prod'

Step 1: Consult the tag index
         region=us-west → [Series 1, Series 2]
         env=prod       → [Series 1, Series 2, Series 3]

Step 2: Intersect the sets
         [Series 1, Series 2] ∩ [Series 1, Series 2, Series 3]
         = [Series 1, Series 2]

Step 3: Look up block locations in file index
         Series 1 → Block 0
         Series 2 → Block 1

Step 4: Read only blocks 0 and 1 from disk (skip blocks 2, 3!)
         Block 0: timestamps [1700000000, 1700000010], values [45.2, 47.1]
         Block 1: timestamps [1700000000, 1700000010], values [62.3, 61.8]

Step 5: Apply time filter (all points match in this case)

Step 6: Compute aggregation
         mean([45.2, 47.1, 62.3, 61.8]) = 54.1

چه چیزی این را سریع کرد؟ Tag Index به ما اجازه داد Seriesهای مطابق را بدون اسکن هیچ داده‌ای شناسایی کنیم. دقیقاً ۲ بلوک را از دیسک خواندیم و ۲ بلوک سرورهای us-east را کاملاً رد کردیم. داده درون هر بلوک از قبل بر اساس Series سازمان یافته بود، پس هیچ مرتب‌سازی یا فیلتری داخل بلوک‌ها لازم نبود.

این را با رویکرد ساده در Postgres مقایسه کنید:

1
2
3
4
-- PostgreSQL equivalent
SELECT AVG(value) FROM metrics
WHERE region = 'us-west' AND env = 'prod'
  AND timestamp BETWEEN '2023-11-15 00:00:00' AND '2023-11-15 00:00:10';

حتی با Index روی region و env، Postgres باید:

  1. از Indexها برای پیدا کردن شناسه‌ی ردیف‌های مطابق استفاده کند.
  2. هر ردیف را از جاهای احتمالاً پراکنده روی دیسک بگیرد.
  3. ستون value را از هر ردیف استخراج کند.
  4. میانگین را محاسبه کند.

با میلیون‌ها ردیف، آن خواندن‌های پراکنده از دیسک عملکرد را نابود می‌کنند. ذخیره‌سازی ستونی و Series-محور در پایگاه داده‌ی Time-series یعنی داده‌ی مورد نیاز شما فیزیکی کنار هم قرار دارد. نوشتن‌های ما برای کمک به خواندن‌هایمان بهینه شده‌اند!

جاهایی که کار می‌شکند

این مزیت‌ها بدون چالش نیستند. نمونه‌ی بسیار گویا مشکل Cardinality است.

Cardinality به تعداد ترکیب‌های یکتای Tag گفته می‌شود. اگر ۱۰۰۰ هاست و ۵۰ نام متریک داشته باشید، ۵۰ هزار Series می‌شود. قابل‌مدیریت. اما اگر Tagی برای user_id با ۱۰ میلیون کاربر یکتا اضافه کنید چه؟ ناگهان ۵۰۰ میلیارد Series بالقوه دارید.

چرا مشکل است؟ پایگاه‌های داده‌ی Time-series Index درون‌حافظه‌ای از همه‌ی Seriesها نگه می‌دارند. هر ترکیب یکتای Tag یک ورودی می‌خواهد. با میلیاردها Series حافظه تمام می‌شود. Queryها هم کند می‌شوند چون Index عظیم می‌شود.

به همین دلیل user_id، شناسه‌ی درخواست یا هر مقدار High-cardinality فقط به‌عنوان Field قابل ذخیره‌اند، نه Tag. در اصل می‌توانیم آن‌ها را بنویسیم ولی همه‌ی مزایای عملکردی پایگاه داده‌ی Time-series را هنگام خواندنشان از دست می‌دهیم.

جمع‌بندی

مشکل Cardinality درس پایگاه‌های داده‌ی Time-series را روشن می‌کند: اگر بتوانیم فرض‌های قوی درباره‌ی داده‌مان بکنیم (Tagهای Low-cardinality، داده‌ی بسیار منظم، Deltaهای کم بین نقاط)، می‌توانیم سیستمی بسازیم که از هر یک از این ویژگی‌ها بهره ببرد و بهبود عملکرد عظیمی بگیرد. اما به‌محض نقض این فرض‌ها، همه‌ی مزایا را از دست می‌دهیم و سیستم ما برای آن کار از پایگاه داده‌ی همه‌منظوره بدتر می‌شود.

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

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

  • ذخیره‌سازی Append-only I/O تصادفی را به I/O ترتیبی تبدیل می‌کند.
  • LSM Tree با به تعویق انداختن سازمان‌دهی به Compaction پس‌زمینه، Throughput نوشتن بالا را ممکن می‌کند.
  • Delta Encoding و فشرده‌سازی تخصصی از ساختار داده‌ی Time-series بهره می‌برند.
  • پارتیشن‌بندی زمانی نوشتن را محلی و Retention را ساده می‌کند.
  • Bloom Filter خواندن‌های غیرضروری از دیسک را هنگام بررسی SSTableها حذف می‌کند.
  • Downsampling و Rollup دقت را با کارایی ذخیره‌سازی برای داده‌ی تاریخی معاوضه می‌کنند.
  • فراداده‌ی سطح بلوک Pruning را هنگام Query ممکن می‌کند.

با این کار، به مزیت عملکردی عملی می‌رسیم که برای بار کاری هدفشان ۱۰ تا ۱۰۰ برابر بهتر از پایگاه داده‌ی همه‌منظوره است.

پس دفعه‌ی بعد که سیستمی را دیدید که میلیون‌ها رویداد در ثانیه را مدیریت می‌کند، می‌دانید جادو نیست. Logهای Append-only، LSM Tree، فشرده‌سازی هوشمند، Bloom Filter، Rollup و مدل‌سازی دقیق داده است.