cat parquet-time-series-store.md

وقتی هیچ دیتابیس سری‌زمانی جواب نداد

· 7 min read · parquet · storage · benchmarks

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

سؤال اول ساده به‌نظر می‌رسید: کدام دیتابیس سری‌زمانی؟ جواب، بعد از چند هفته بنچمارک، این شد که هیچ‌کدام — و همین «هیچ‌کدام» جالب‌ترین بخش ماجراست.

دو بارِ کاری روی یک داده

داده یک جریان بود و دو مصرف‌کننده‌ی کاملاً متفاوت داشت.

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

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

این دو، دو الگوی متفاوت‌اند و هر انتخابی باید هم‌زمان به هر دو جواب می‌داد.

چه چیزی را اندازه گرفتیم

چهار محور، و هر کدام دلیل مشخصی داشت:

توان ورودی. فید بازار متوقف نمی‌شود و منتظر ما نمی‌ماند. اگر لایه‌ی ذخیره‌سازی کندتر از جریان ورودی باشد، فشار برگشتی نهایتاً به از دست رفتن داده ختم می‌شود.

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

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

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

چرا TimescaleDB نبرد را باخت

TimescaleDB سامانه‌ی خوبی است و در بنچمارک هم بد ظاهر نشد. مشکل جای دیگری بود.

روی کاغذ همه‌چیز داشت: SQL کامل، تقسیم خودکار جدول به chunk بر اساس زمان، continuous aggregate برای پیش‌محاسبه، و کل اکوسیستم بالغ PostgreSQL زیرش. برای پنجره‌ی کوتاه و تازه، بهترین گزینه‌ی روی میز بود.

اما هزینه‌ی نوشتن هر رکورد، هزینه‌ی یک رکورد در یک دیتابیس تراکنشی است: WAL، نگه‌داری ایندکس، و بعدتر vacuum. ما این هزینه را می‌پرداختیم بدون اینکه از چیزی که در ازایش می‌گیریم استفاده کنیم — نه تراکنشی داشتیم، نه به‌روزرسانی، نه حذف، نه چند نویسنده‌ی هم‌زمان که سر یک سطر رقابت کنند.

مسئله‌ی جدی‌تر در خواندن بود. فشرده‌سازی ستونی روی chunkهای قدیمی حجم را واقعاً پایین می‌آورد، اما پویش طولانی تاریخی همچنان از مسیر موتور اجرای سطر-محور عبور می‌کرد و در انتها از پروتکل کلاینت رد می‌شد. برای پرس‌وجویی که هیچ فیلتر انتخاب‌گری ندارد و عملاً می‌گوید «همه‌ی این بازه را بده»، ایندکس و بهینه‌ساز پرس‌وجو هیچ کمکی نمی‌کنند. ما داشتیم کل هزینه‌ی یک دیتابیس تراکنشی را برای بارِ کاری‌ای می‌دادیم که هیچ‌کدام از ویژگی‌های تراکنشی‌اش را نمی‌خواست.

چرا InfluxDB باخت

InfluxDB دقیقاً برای سری زمانی ساخته شده و در محور توان ورودی بهترین عدد را گرفت. فشرده‌سازی‌اش هم روی داده‌ی زمانی خوب بود.

دو چیز آن را کنار گذاشت.

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

دوم — و مهم‌تر — مسیر خروج داده. مقصد نهایی هر پرس‌وجوی پژوهشی، یک آرایه‌ی عددی در حافظه‌ی پایتون بود. اما داده باید از موتور ذخیره‌سازی بیرون می‌آمد، به سطر تبدیل می‌شد، سریال می‌شد، از شبکه می‌گذشت، در کلاینت دی‌سریال می‌شد و دوباره به ستون برمی‌گشت. در پویش‌های بزرگ، گلوگاه دیگر دیسک نبود؛ همین رفت‌وبرگشت میان سطر و ستون بود. اضافه کنید تقسیم زبان پرس‌وجو میان InfluxQL و Flux و شکاف نسخه‌ها را، که هزینه‌ی یادگیری و ریسک نگه‌داری را بالا می‌برد.

الگویی که با هیچ‌کدام جور نبود

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

  • نوشتن یک‌باره، خواندن مکرر. بدون به‌روزرسانی، بدون حذف.
  • خواندن تقریباً هیچ‌وقت جست‌وجوی نقطه‌ای نیست؛ همیشه «این چند ستون، در این بازه» است.
  • مصرف‌کننده همیشه پشته‌ی عددی پایتون است، نه یک پروتکل سطری روی شبکه.
  • هم‌روندی یعنی چند پروسه‌ی پژوهشی، نه چند هزار کلاینت.

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

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

آنچه ساخته شد

فایل‌های Parquet روی فایل‌سیستم لینوکس، با چیدمانی که خودِ مسیر، ایندکس است:

/data/marketdata/
  exchange=<venue>/
    symbol=<pair>/
      interval=1m/
        year=2023/
          month=11/
            part-0001.parquet
            part-0002.parquet

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

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

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

خواندن با pyarrow.dataset انجام می‌شد، که هم کشف پارتیشن‌ها را خودش انجام می‌دهد، هم فیلتر و انتخاب ستون را به لایه‌ی فایل می‌فرستد:

import pyarrow.dataset as ds

dataset = ds.dataset("/data/marketdata", format="parquet", partitioning="hive")

table = dataset.to_table(
    columns=["ts", "close", "volume"],
    filter=(ds.field("symbol") == "BTC-USDT")
    & (ds.field("year") == 2023)
    & (ds.field("month") >= 11),
)

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

import pyarrow.parquet as pq

pq.write_to_dataset(
    table,
    root_path="/data/marketdata",
    partition_cols=["exchange", "symbol", "interval", "year", "month"],
    compression="zstd",
    use_dictionary=["exchange", "symbol", "interval"],
)

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

چه چیزی را از دست دادیم

این تصمیم رایگان نبود و بدترین کار این است که وانمود کنیم بود.

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

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

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

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

داده‌ی دیررس و اصلاح خطا گران است. تصحیح یک تیک اشتباه، یک UPDATE نیست؛ بازنویسی کل پارتیشن است.

و در نهایت، این کدی است که مالکش خودت هستی. نه انجمنی دارد، نه مستنداتی، نه پاسخ آماده‌ای در اینترنت.

آنچه یاد گرفتم

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

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

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

cd ..

بازگشت به صفحه‌ی اصلی