برنتی / می‌نویسم که فکر کنم

Bloom Filter: بررسی عضویت در مجموعه‌های بزرگ با حافظه‌ی کم

1405.07.03·سیستم دیزاین

در نوشته‌ی طراحی کوتاه‌کننده‌ی لینک دو بار سراغ Bloom filter رفتم: یک‌بار برای سبک کردن بررسی برخورد کدها و یک‌بار برای جلوگیری از cache penetration. هر دو بار از کنارش رد شدم، چون جای توضیحش آن‌جا نبود. این نوشته همان توضیح است.

شاید Bloom filter را از کتاب‌های ساختار داده یا آمادگی مصاحبه بشناسید. ولی دلیل اصلی شناختنش این است که بی‌سروصدا داخل ابزارهای روزمره نشسته است. Cassandra، RocksDB و HBase با آن از خواندن دیسک فرار می‌کنند و Redis و PostgreSQL نسخه‌ی آماده‌اش را دارند. تصمیم‌های طراحی دورش هم آن‌قدر ظریف‌اند که اگر درست گرفته نشوند، سیستم بی‌آن‌که خطایی بدهد اشتباه جواب می‌دهد.

مسئله: «این را قبلاً دیده‌ام؟»

فرض کنید میلیاردها کد کوتاه دارید و می‌خواهید قبل از رفتن سراغ دیتابیس بدانید یک کد وجود دارد یا نه. راه بدیهی نگه داشتن همه‌ی کدها در یک hash set است. برای این‌که حسی از هزینه‌اش داشته باشیم، یک میلیون کد ۷ کاراکتری را در یک Set در Node.js ریختم و حافظه را اندازه گرفتم. همان یک میلیون کد را هم در یک Bloom filter با نرخ خطای ۱ درصد گذاشتم:

ساختارحافظه برای ۱ میلیون کد ۷ کاراکتری
Set در Node.jsحدود ۴۳ مگابایت
Bloom filter با خطای ۱٪حدود ۱٫۲ مگابایت

یعنی بیش از ۳۵ برابر کمتر. نکته‌ی جالب‌تر این است که حافظه‌ی Bloom filter به اندازه‌ی خود عضوها بستگی ندارد. چه کد ۷ کاراکتری ذخیره کنید، چه URL دوکیلوبایتی، هزینه برای هر عضو همان چند بیت است. بهایش هم یک چیز است: Bloom filter گاهی اشتباه می‌کند، ولی فقط در یک جهت.

سازوکار

یک Bloom filter فقط دو چیز دارد: یک آرایه‌ی m بیتی که همه‌ی بیت‌هایش صفر شروع می‌شوند، و k تابع هش که هرکدام یک مقدار را به یکی از خانه‌های آرایه نگاشت می‌کنند.

افزودن: مقدار را با هر k تابع هش می‌کنیم و بیت هر k خانه را یک می‌کنیم.

00011203140506170809010111012113014015add("aZ3kP9x")h: 2, 7, 13add("q8Lm2Tb")h: 4, 7, 11
افزودن دو کد به یک آرایه‌ی ۱۶ بیتی با ۳ تابع هش؛ بیت ۷ بین هر دو مشترک است

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

00011203140506170809010111012113014015check("k7Qp01x")h: 2, 9, 13 · bit 9 = 0 · definitely notcheck("Xy12Abc")h: 4, 11, 13 · all 1 · maybe
کد اول قطعاً اضافه نشده، چون بیت ۹ صفر است. کد دوم هم هرگز اضافه نشده، ولی همه‌ی بیت‌هایش را کدهای دیگر روشن کرده‌اند؛ این یک مثبت کاذب است

همین عدم تقارن کل ماجراست. منفی کاذب (false negative) هرگز رخ نمی‌دهد و مثبت کاذب (false positive) با احتمالی که خودمان انتخاب می‌کنیم رخ می‌دهد. پس Bloom filter جایی مفید است که جواب «نه» رایج باشد و فهمیدنش از راه عادی گران. هر «نه» یک سفر به دیسک، شبکه یا دیتابیس را حذف می‌کند و هر «شاید» ما را به همان راه عادی برمی‌گرداند.

چقدر حافظه و چند تابع هش؟

اگر n عضو را در آرایه‌ای با m بیت و k تابع هش بگذاریم، احتمال مثبت کاذب تقریباً این است:

با کمی حساب، تعداد بهینه‌ی تابع‌های هش و تعداد بیت لازم برای هر عضو به دست می‌آید:

عدد این فرمول‌ها برای چند نرخ خطای رایج:

نرخ مثبت کاذببیت برای هر عضوتعداد تابع هشحافظه برای ۶ میلیارد کد
۱٪۹٫۶۷حدود ۷٫۲ گیگابایت
۰٫۱٪۱۴٫۴۱۰حدود ۱۰٫۸ گیگابایت
۰٫۰۱٪۱۹٫۲۱۳حدود ۱۴٫۴ گیگابایت

قاعده‌ی سرانگشتی‌اش این است که هر بار نرخ خطا را ده برابر کم کنید، حدود ۴٫۸ بیت به هر عضو اضافه می‌شود. برای بازی با اعداد، ماشین‌حساب hur.st ابزار خوبی است.

پیاده‌سازی

نیازی به k تابع هش مستقل نیست. کیرش و میتزنماخر نشان داده‌اند که با دو هش h1 و h2 و ترکیب h1 + i·h2، کارایی عملاً همان می‌ماند. این پیاده‌سازی کوچک TypeScript از همین ترفند استفاده می‌کند:

export class BloomFilter {
  private bits: Uint8Array;
  readonly m: number; // تعداد بیت‌ها
  readonly k: number; // تعداد تابع‌های هش

  constructor(expectedItems: number, falsePositiveRate: number) {
    this.m = Math.ceil((-expectedItems * Math.log(falsePositiveRate)) / Math.LN2 ** 2);
    this.k = Math.max(1, Math.round((this.m / expectedItems) * Math.LN2));
    this.bits = new Uint8Array(Math.ceil(this.m / 8));
  }

  add(item: string) {
    for (const i of this.indexes(item)) this.bits[i >> 3] |= 1 << (i & 7);
  }

  mightContain(item: string): boolean {
    for (const i of this.indexes(item)) {
      if ((this.bits[i >> 3] & (1 << (i & 7))) === 0) return false; // قطعاً نیست
    }
    return true; // شاید هست
  }

  private *indexes(item: string) {
    const h1 = fnv1a(item, 0x811c9dc5);
    const h2 = (fnv1a(item, 0x9747b28c) | 1) >>> 0; // فرد و بدون علامت
    for (let i = 0; i < this.k; i++) yield (h1 + i * h2) % this.m;
  }
}

function fnv1a(str: string, seed: number): number {
  let h = seed;
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}

(این نسخه برای آموزش است. i >> 3 فقط تا ۲ به توان ۳۱ بیت درست کار می‌کند، یعنی حدود ۲۵۶ مگابایت. برای فیلترهای بزرگ‌تر باید از Math.floor(i / 8) استفاده کنید یا فیلتر را به چند بلوک بشکنید.)

باگی که تست پیدا کرد

نسخه‌ی اول این کد را که نوشتم، یک تست ساده رویش اجرا کردم: یک میلیون کد اضافه کن و بعد همه را دوباره بپرس. Bloom filter درست هیچ‌وقت منفی کاذب ندارد، پس جواب همه‌ی این پرسش‌ها باید «شاید هست» باشد. نتیجه این بود که حدود ۴۲ درصد کدهایی که همین الان اضافه کرده بودم، «قطعاً نیست» جواب گرفتند.

مقصر یک کاراکتر بود. در نسخه‌ی اول نوشته بودم fnv1a(...) | 1 تا h2 فرد شود. عملگرهای بیتی جاوااسکریپت خروجی را به عدد صحیح ۳۲ بیتی علامت‌دار تبدیل می‌کنند، پس برای نیمی از ورودی‌ها h2 منفی می‌شد. اندیس هم منفی می‌شد و Uint8Array نوشتن در اندیس منفی را بی‌صدا نادیده می‌گیرد. هیچ خطایی نبود؛ فقط بیت‌ها هیچ‌وقت روشن نمی‌شدند. >>> 0 در کد بالا همین را درست می‌کند.

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

نرخ هدفنرخ اندازه‌گیری‌شده‌ی مثبت کاذبمنفی کاذب
۱٪۱٫۰۰۵٪صفر
۰٫۱٪۰٫۱۰۳٪صفر

محدودیت‌ها

حذف ممکن نیست. نمی‌شود بیت‌های یک عضو را صفر کرد، چون ممکن است همان بیت‌ها مال عضوهای دیگر هم باشند. اگر حذف لازم است، Counting Bloom filter به‌جای هر بیت یک شمارنده‌ی کوچک (معمولاً ۴ بیتی) نگه می‌دارد و حافظه را حدود ۴ برابر می‌کند. Cuckoo filter هم حذف را پشتیبانی می‌کند و در نرخ‌های خطای پایین معمولاً حافظه‌ی کمتری می‌خواهد.

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

عضوهای واقعینرخ مثبت کاذب (فیلتر ساخته‌شده برای ۱٪)
همان تعداد پیش‌بینی‌شده۱٪
۲ برابر پیش‌بینیحدود ۱۶٪

هر دو عدد را هم با فرمول حساب کردم و هم با همان پیاده‌سازی اندازه گرفتم. نسخه‌ی مقیاس‌پذیر (Scalable Bloom filter) با پر شدن هر لایه یک لایه‌ی جدید و بزرگ‌تر اضافه می‌کند. BF.RESERVE در Redis هم به‌طور پیش‌فرض همین کار را می‌کند.

فهرست عضوها را نمی‌دهد. فقط به «هست یا نیست» جواب می‌دهد. برای بازسازی فیلتر، مثلاً بعد از تغییر اندازه، به منبع اصلی داده نیاز دارید.

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

دسترسی پراکنده به حافظه. هر پرسش k خانه‌ی تصادفی از آرایه را می‌خواند و برای فیلترهای بزرگ یعنی چند cache miss2. به همین دلیل RocksDB در فرمت جدید فیلترش همه‌ی بیت‌های هر کلید را داخل یک cache line نگه می‌دارد.

کجا به کار رفته

  • دیتابیس‌های مبتنی بر LSM-tree3 مثل Cassandra، HBase، RocksDB و LevelDB. داده در چند فایل مرتب و تغییرناپذیر (SSTable4) ذخیره می‌شود و خواندن یک کلید ممکن است نیاز به نگاه کردن به چند فایل داشته باشد. هر فایل یک Bloom filter دارد و فایل‌هایی که فیلترشان «نه» می‌گوید، اصلاً از دیسک خوانده نمی‌شوند. در Cassandra نرخ خطای این فیلترها با تنظیم bloom_filter_fp_chance برای هر جدول قابل تغییر است.
  • CDN و کش‌کردن فقط بعد از درخواست دوم. مهندس‌های Akamai گزارش کرده‌اند که حدود سه‌چهارم اشیایی که درخواست می‌شوند، فقط یک‌بار درخواست می‌شوند. کش کردن این‌ها فقط فضای دیسک را هدر می‌دهد. راه‌حلشان این بود که هر شیء فقط وقتی کش شود که Bloom filter بگوید قبلاً یک‌بار دیده شده.
  • Redis با ماژول Bloom (که در نسخه‌های جدید جزو خود Redis است) دستورهای آماده دارد:
BF.RESERVE short_codes 0.01 10000000   # نرخ خطا و ظرفیت
BF.ADD short_codes aZ3kP9x
BF.EXISTS short_codes aZ3kP9x          # 1 = شاید هست
BF.EXISTS short_codes k7Qp01x          # 0 = قطعاً نیست
  • PostgreSQL افزونه‌ای به نام bloom دارد که یک نوع ایندکس Bloom برای جست‌وجوی برابری روی ترکیب‌های دلخواه از چند ستون می‌سازد، وقتی ساختن یک ایندکس B-tree برای هر ترکیب صرف نمی‌کند.

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

در آن طراحی دو جا برای Bloom filter بود. ظاهرشان شبیه است، ولی اهمیت منفی کاذب در آن‌ها کاملاً فرق می‌کند.

کاربرد اول: بررسی برخورد در رویکرد هش. قبل از درج یک کد، از فیلتر می‌پرسیم. اگر گفت «نه»، کد را مستقیم با INSERT ... ON CONFLICT درج می‌کنیم و SELECT اضافه را حذف می‌کنیم. اگر گفت «شاید»، همان مسیر عادی را می‌رویم. این‌جا فیلتر فقط یک بهینه‌سازی است و منبع حقیقت همچنان قید یکتایی دیتابیس است. اگر فیلتر اشتباه کند، فقط یک رفت‌وبرگشت اضافه داریم. لینک‌های منقضی‌شده هم بیت‌هایشان را روشن نگه می‌دارند، یعنی نرخ مثبت کاذب به‌مرور بالا می‌رود ولی منفی کاذب ایجاد نمی‌شود. کافی است فیلتر را هر از گاهی از روی دیتابیس از نو بسازید.

کاربرد دوم: جلوگیری از cache penetration. این‌جا فیلتر همه‌ی کدهای موجود را نگه می‌دارد و اگر گفت «نه»، بدون رفتن به دیتابیس 404 برمی‌گردانیم. حالا منفی کاذب یعنی یک لینک سالم 404 بدهد، و این سناریو از چیزی که فکر می‌کنید محتمل‌تر است:

  • فیلتر محلی در هر سرور. اگر ده سرور اپلیکیشن دارید و هرکدام فیلتر خودش را در حافظه نگه دارد، لینکی که سرور A ساخته، در فیلتر سرور B نیست. کاربری که درخواستش به B برسد، 404 می‌گیرد. تضمین «منفی کاذب ندارد» فقط وقتی برقرار است که هر درج به همان فیلتری برسد که از آن می‌پرسید. راه‌حل یک فیلتر مشترک است، مثلاً در Redis، یا این‌که هر سرور درج‌ها را از یک صف مشترک بخواند.
  • ترتیب نوشتن. کد باید قبل از این‌که لینک کوتاه به کاربر برگردانده شود در فیلتر نوشته شده باشد، نه در یک job ناهمگام که چند ثانیه بعد اجرا می‌شود.
  • ری‌استارت. فیلتری که فقط در حافظه است، با ری‌استارت خالی می‌شود و تا بازسازی نشده، به همه‌چیز «نه» می‌گوید. سرور یا باید قبل از پذیرفتن ترافیک فیلتر را از snapshot یا دیتابیس بازسازی کند، یا تا آن موقع فیلتر را دور بزند.

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

جایگزین‌ها

ساختارحذفحافظه در مقایسه با Bloomمناسب برای
Bloom filterنداردمبنامجموعه‌های در حال رشد، ساده‌ترین پیاده‌سازی
Counting Bloom filterداردحدود ۴ برابروقتی حذف لازم است و حافظه مهم نیست
Cuckoo filterداردمعمولاً کمتر در نرخ خطای پایینحذف و نرخ خطای کم
Xor filterنداردحدود ۱۵٪ کمترمجموعه‌های ثابت که یک‌بار ساخته می‌شوند، مثل فایل‌های SSTable

در مصاحبه

اگر در مصاحبه Bloom filter را پیشنهاد می‌کنید، به همین چند سؤال جواب بدهید، قبل از این‌که مصاحبه‌کننده بپرسد:

  1. چه چیزی را صرفه‌جویی می‌کند؟ کدام سفر به دیسک یا شبکه با جواب «نه» حذف می‌شود و این «نه» چقدر رایج است؟
  2. نرخ خطا و حافظه چقدر است؟ یک عدد بدهید، مثلاً «۱٪، یعنی حدود ۱۰ بیت برای هر عضو».
  3. مثبت کاذب چه هزینه‌ای دارد؟ معمولاً فقط یک رفت‌وبرگشت اضافه به منبع اصلی.
  4. فیلتر کجا زندگی می‌کند و چطور به‌روز می‌ماند؟ با چند سرور، ری‌استارت، حذف و رشد داده چه می‌شود؟

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

منابع

یادداشت‌ها

  1. یک تابع هش سریع که با یک کلید مخفی کار می‌کند. کسی که کلید را نداند، نمی‌تواند خروجی هش را برای ورودی‌های دلخواهش پیش‌بینی کند. زبان‌هایی مثل Rust و Python از آن برای جدول‌های هش خود استفاده می‌کنند. ↑

  2. کوچک‌ترین بلوکی که CPU از حافظه‌ی اصلی به کش می‌آورد، معمولاً ۶۴ بایت. خواندن چند خانه‌ی دور از هم یعنی چند بار رفتن به حافظه‌ی اصلی، که ده‌ها برابر کندتر از خواندن از کش است. ↑

  3. مخفف Log-Structured Merge-tree. ساختاری که نوشتن‌ها را اول در حافظه جمع می‌کند و بعد به‌صورت فایل‌های مرتب روی دیسک می‌نویسد و این فایل‌ها را در پس‌زمینه با هم ادغام می‌کند. نوشتن در آن خیلی سریع است، ولی خواندن ممکن است به چند فایل سر بزند. ↑

  4. مخفف Sorted String Table. فایلی تغییرناپذیر از جفت‌های کلید و مقدار که بر اساس کلید مرتب شده‌اند. ↑

§ فهرست « سیستم دیزاین »
  1. 1405.07.03دیتابیس ستونی و OLAP: چرا گزارش سالانه را نباید از دیتابیس اپلیکیشن گرفتاز یک گزارش سالانه که دیتابیس فروشگاه را از کار می‌اندازد شروع می‌کنم و به تفاوت بار کاری OLTP و OLAP می‌رسم: دیتابیس ستونی مثل ClickHouse داده را چطور روی دیسک می‌چیند، چرا کوئری‌های تحلیلی روی آن چند ده برابر سریع‌تر است و چه چیزهایی را در ازایش از دست می‌دهید.
  2. 1405.07.03View در دیتابیس: وقتی سه تیم یک عدد را سه‌جور حساب می‌کننداز یک مسئله‌ی واقعی در طراحی سیستم شروع می‌کنم که در آن چند سرویس و تیم یک کوئری مشترک را هر کدام به شکل خودشان می‌نویسند و به view می‌رسم: view چیست، چه چیزی را حل می‌کند، materialized view کجا به کار می‌آید و در پروژه‌ی واقعی چطور باید ساخت و نگه‌داری‌اش کرد.
  3. 1405.07.03Bloom Filter: بررسی عضویت در مجموعه‌های بزرگ با حافظه‌ی کمBloom filter با چند بیت برای هر عضو جواب می‌دهد که یک مقدار «قطعاً در مجموعه نیست» یا «شاید هست». در این نوشته سازوکار، ریاضی انتخاب اندازه، یک پیاده‌سازی تست‌شده، محدودیت‌ها و جاهایی که در دیتابیس‌ها و CDNها به کار رفته را مرور می‌کنم و می‌گویم کجا در طراحی یک سیستم واقعی دردسر درست می‌کند.
  4. 1405.07.03ZooKeeper را هنوز باید بشناسیم؟ هماهنگی در سیستم‌های توزیع‌شدهکافکا دیگر به ZooKeeper نیاز ندارد، ولی مسئله‌ای که ZooKeeper حل می‌کرد سر جایش است. در این نوشته می‌گویم ZooKeeper چطور کار می‌کند، کجا هنوز به کار می‌آید و مهندس ارشد دقیقاً چه چیزی از آن باید بداند.
  5. 1405.07.03کافکا، RabbitMQ یا صف Redis؟ مقایسه با یک مثال سادههر سه ابزار پیام را از یک سرویس به سرویس دیگر می‌رسانند، ولی برای کارهای متفاوتی ساخته شده‌اند. در این نوشته با یک فروشگاه اینترنتی ساده نشان می‌دهم هر کدام کجا به کار می‌آید و کجا نه.
  6. 1405.07.03طراحی سرویس کوتاه‌کننده‌ی لینک: از یک سرور ساده تا مقیاس میلیاردیطراحی URL Shortener پرتکرارترین سؤال مصاحبه‌های طراحی سیستم است. در این نوشته قدم‌به‌قدم از ساده‌ترین نسخه با یک سرور و یک دیتابیس شروع می‌کنیم و تا معماری توزیع‌شده با کش، تولید شناسه‌ی توزیع‌شده، شاردینگ و آنالیتیکس جلو می‌رویم و مزایا و معایب هر مرحله را می‌سنجیم.