DDIA·مطالعهSTUDY

نقشه راهRoadmap / فصل 4Chapter 4 / حل تمرین‌هاSolutions

حل آموزشی تمرین‌های فصل 4Chapter 4 exercise solutions, explained

هر حل از سؤال مصاحبه شروع می‌کند، فرض‌های آموزشی را روشن می‌گوید، گام‌به‌گام استدلال می‌کند و یک نمودار دارد که خواندنش را یاد می‌گیری. هر حل به تمرین خودش برمی‌گردد. Each solution starts from the interview question, states its teaching assumptions, reasons step by step, and shows one diagram you learn to read aloud. Every solution links back to its exercise.

33 حل تشریحیworked solutions· متناظر با بخش تمرین‌های فصل 4Companion to Chapter 4 exercises

11 تمرین ۱ — موتور کلید-مقدار log-structuredExercise 1 — a log-structured key-value engine

L2 · SeniorL2 · Seniorوابسته به نقشRole-specific45 دقیقه بحث45 min discussion

بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level

سؤال مصاحبهInterview question
برای یک workload نوشتن‌محور با point read و range read، موتور LSM و B-tree را مقایسه کن: مسیر write، نقش WAL و memtable، flush به SSTable، compaction و tombstone را رسم کن و بگو crash بین flush و ثبت manifest چه می‌کند و write/read/space amplification کِی بالا می‌رود.
For a write-heavy workload with point and range reads, compare an LSM engine with a B-tree: draw the write path through WAL and memtable, the flush to SSTable, compaction and tombstones, and explain what a crash between flush and manifest record does and when write, read and space amplification grow.

پاسخ ۶۰ثانیه‌ایThe 60-second answer

برای سمتِ نوشتن‌محور LSM را بردار: هر نوشتن یک append ترتیبی است (WAL با fsync برای durability و بعد memtable در حافظه)، پس بازده نوشتن بالا می‌ماند؛ بهایش compaction پس‌زمینه و خواندن‌هایی است که شاید چند سگمنت را بگردند. B-tree صفحهٔ رکورد را همان‌جا بازنویسی می‌کند: خواندن نقطه‌ای و range قابل‌پیش‌بینی‌تر است (عمق ۳ تا ۴ سطح) ولی نوشتن تصادفی گران‌تر تمام می‌شود. مهم‌ترین trade-off: LSM نوشتن ارزان را با write amplification هنگام compaction و read amplification روی سگمنت‌ها معاوضه می‌کند.
Pick LSM for the write-heavy side: every write is a sequential append (WAL with fsync for durability, then the in-memory memtable), so write throughput stays high; the price is background compaction and reads that may check several segments. A B-tree rewrites the record's page in place: point reads and range scans are more predictable (3-4 levels deep) but random writes cost more. The top trade-off: LSM buys cheap writes with write amplification at compaction time and read amplification across segments.

صورت مسئله و فرض‌هاProblem and assumptions

  • بارکاری یک ترکیب آموزشی است: حدود ۸۰٪ نوشتن، ۲۰٪ خواندن نقطه‌ای به‌علاوه rangeهای گاه‌به‌گاه. نسبت واقعی از لاگ دسترسی تو می‌آید، نه از این صفحه.
  • اندازه‌های آموزشی: صفحه ۴ کیلوبایت، flush حافظه در چند مگابایت، branching factor چندصدتایی. این‌ها شکل را نشان می‌دهند، نه plan ظرفیت تو.
  • یعنی WAL fsync پیش از ack. حافظه و ایندکس هر سگمنت ساختار سرعت‌اند، نه durability.
  • مقایسهٔ موتور تک‌گره. رپلیکیشن، پارتیشن‌بندی و recovery بین ماشین‌ها مال فصل‌های بعد است.
  • Workload is a teaching mix: roughly 80% writes, 20% point reads plus occasional range scans. Real ratios come from your access log, not from this page.
  • Teaching sizes: 4 KB pages, memtable flush at a few MB, branching factor in the hundreds. They illustrate the shapes, not your capacity plan.
  • Durability means the WAL fsync before the ack. The memtable and the per-segment indexes are speed structures, not durability.
  • Single-node engine comparison. Replication, partitioning and crash recovery across machines belong to later chapters.

چرا این روش و این عددها؟Why this method and these numbers?

LSM نوشته‌ها را جمع و به‌ترتیب روی دیسک می‌ریزد تا IO تصادفی کمتر شود؛ هزینه به compaction و read amplification منتقل می‌شود. B-tree برای lookup و range با ساختار مرتب مناسب است ولی تغییر pageها هزینهٔ write دارد. ترکیب ۸۰٪ write و page چهارهزاربایتی سناریوی مقایسه‌اند، نه قانون انتخاب موتور. با تغییر ترکیب به خواندن غالب، نتیجه را دوباره با latency و amplification اندازه بگیر؛ ACK بعد از fsync برای حفظ نوشتهٔ تأییدشده ضروری است.

LSM batches sorted disk writes to reduce random I/O, paying in compaction and read amplification. B-trees support ordered lookups and ranges with page-update cost. An 80% write mix and 4 KB pages define a comparison scenario, not a universal choice. Re-measure latency and amplification for read-heavy workloads. Fsync before ACK protects acknowledged writes.

مدل ذهنیMental model

دو راه برای عوض کردن داده روی دیسک هست: یا مثل دفترچه فقط به آخر اضافه کن و بعداً مرتب و تمیز کن (LSM)، یا برو سر جای دقیق رکورد و همان‌جا بازنویسی کن (B-tree). دفترچه نوشتن را ترتیبی و ارزان می‌کند و کار را به پس‌زمینه می‌اندازد؛ بازنویسی خواندن را سرراست نگه می‌دارد و هزینه را موقع نوشتن می‌پردازد.
There are two ways to change data on disk: keep appending to the end like a notebook and tidy up later (LSM), or walk to the record's exact slot and rewrite it there (B-tree). The notebook makes writes sequential and cheap and pushes the work to the background; the rewrite keeps reads straightforward and pays at write time.

حل گام‌به‌گامStep by step

  1. هر نوشتن دو جا می‌نشیند نوشتنِ durability همان WAL append + fsync است؛ به‌روزرسانی حافظه نوشتنِ visibility است. به کلاینت فقط بعد از fsync جواب بده، هرگز فقط بعد از به‌روزرسانی RAM.
  2. flush حافظه را فایل می‌کند وقتی حافظه از چند مگابایت گذشت، مرتب روی دیسک به‌صورت یک SSTable تغییرناپذیر نوشته می‌شود — جدیدترین سگمنت — و یک حافظهٔ تازه کار را تحویل می‌گیرد. هر خواندن اول حافظه، بعد سگمنت‌ها از جدید به قدیم را می‌گردد.
  3. compaction ادغام و تمیز می‌کند compaction سگمنت‌ها را مثل mergesort کنار هم می‌خواند: برای کلید تکراری فقط تازه‌ترین نسخه می‌ماند و tombstone به merge می‌گوید نسخه‌های قدیمی را دور بریزد. ادغام size-tiered به نفع بازده نوشتن است، leveled به نفع خواندن و فضا.
  4. کرش بین flush و manifest فایل flushشده‌ای که هنوز در manifest ثبت نشده موقع restart نادیده گرفته می‌شود؛ WAL سالم‌سنجی‌شده حافظه را بازپخش می‌کند و دمِ خرابش دور ریخته می‌شود. هیچ حالت نصفه‌ای به خواندن‌ها سرویس نمی‌دهد.
  5. سه amplification را بشمار write amplification (بایت دیسک به‌ازای بایت کاربر: در compaction بالا، در B-tree بازنویسی صفحه ~۱ تا ۲ برابر)، read amplification (چند سگمنت در برابر عمق ۳ تا ۴ صفحه)، space amplification (قدیم به‌علاوه جدید تا ادغام، در برابر صفحات به‌علاوه WAL). نسبت‌های آموزشی‌اند، نه بنچ‌مارک. حکم: نوشتن‌محور LSM، خواندن قابل‌پیش‌بینی و rangeها B-tree.
  1. Every write lands twice The WAL append + fsync is the durability write; the memtable update is the visibility write. Ack the client only after the fsync, never after the RAM update alone.
  2. Flush turns RAM into a file When the memtable passes a few megabytes it is written out sorted as one immutable SSTable — the newest segment — and a fresh memtable takes over. Every read checks the memtable first, then segments newest to oldest.
  3. Compaction merges and cleans Compaction reads segments side by side like mergesort: for duplicate keys only the newest version survives, and a tombstone tells the merge to drop the older values. Size-tiered merging favors write throughput, leveled merging favors reads and space.
  4. Crash between flush and manifest A flushed file that is not yet in the manifest is simply ignored on restart; the checksummed WAL replays the memtable and its corrupt tail is dropped. No half-visible state ever serves reads.
  5. Count the three amplifications Write amplification (disk bytes per user byte: high at LSM compaction, ~1-2x page rewrites in a B-tree), read amplification (several segments versus 3-4 page depth), space amplification (old plus new until merged versus pages plus WAL). Teaching ratios, not benchmarks. Verdict: write-heavy goes LSM, predictable reads and ranges go B-tree.

طراحی مشخصConcrete design

اجزا: WAL فقط‌افزودنی و checksumدار با fsync به‌ازای هر نوشتن (یا group commit)؛ memtable مرتب با شمارهٔ ترتیبی؛ manifest مجموعهٔ سگمنت‌های زنده با به‌روزرسانی اتمی؛ به‌ازای هر سگمنت یک sparse index و یک Bloom filter؛ compactکنندهٔ پس‌زمینه. مسیر نوشتن: put ← fsync لاگ ← حافظه ← ack. مسیر خواندن: حافظه ← Bloom ← sparse index ← سگمنت‌ها از جدید به قدیم؛ range با iterator ادغامی روی سگمنت‌های مرتب. حذف: tombstone باقیمانده با seq تازه که در merge نسخه‌های قدیمی را می‌کشد.

Parts: append-only checksummed WAL with fsync per write (or group commit); sorted memtable with sequence numbers; manifest of live segments updated atomically; one sparse index plus one Bloom filter per segment; a background compactor. Write path: put to log fsync to memtable to ack. Read path: memtable to Bloom to sparse index to segments newest to oldest; ranges use a merging iterator over the sorted segments. Delete: a tombstone carrying a fresh seq that kills older versions at merge.

مثال محاسبه‌شدهWorked example

دموی آموزشی همین محیط: دو سگمنت [u1=a2, u2=b] و [u2=<tombstone>, u3=c] ادغام می‌شوند به {u1=a2, u3=c} — حذف u2 دقیقاً چون tombstone تازه‌تر است اعمال می‌شود. بازپخش WAL هم همان را می‌دهد. حساب آموزشی: یک میلیون نوشتن ۱۰۰ بایتی یعنی ۱۰۰ مگابایت دادهٔ خام؛ با write amplification آموزشی ۱۰ برابری ≈ ۱ گیگابایت بایت دیسک. خواندن نقطه‌ای حافظه + حداکثر N سگمنت را می‌گردد و Bloom با ~۱۰ بیت به‌ازای کلید ≈ ۱٪ مثبت کاذب می‌دهد (قاعدهٔ سرانگشتی فصل).

Teaching demo from this environment: segments [u1=a2, u2=b] and [u2=<tombstone>, u3=c] merge to {u1=a2, u3=c} — the u2 delete applies exactly because the tombstone is newer. WAL replay gives the same map. Teaching arithmetic: one million 100-byte writes are 100 MB of raw data; at a teaching write amplification of 10x that is about 1 GB of disk bytes. A point read checks the memtable plus at most N segments, and a Bloom filter at ~10 bits per key gives ~1% false positives (the chapter's rule of thumb).

گام 1 از 3 · خط فکریStep 1 of 3 · reasoning thread · هر نوشتن دو جا می‌نشیند: WAL و حافظه، بعد flush و compactionEvery write lands twice: WAL and memtable, then flush and compaction · گذر داده مسیر نوشتنwrite-path dataflowباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
موج نوشتن به انبار سفارش می‌خورد. از put کلاینت در چپ شروع کن و هر appendای که یک put راه می‌اندازد قدم بزن.
A write surge hits the order store. Start at the Client put on the left and walk every append one put triggers.
  1. put کلاینت به دو append می‌رسد: WAL (append و fsync، نوشتن بادوام) و حافظه (نوشتن قابل‌مشاهده).
  2. پیکان چین‌چین برعکس می‌رود، از WAL به حافظه: بازپخش بعد از کرش تنها کار دیگر لاگ است.
  3. حافظه به سگمنت‌های SSTable می‌ریزد (تغییرناپذیر، جدید اول)؛ compaction ادغامشان می‌کند و تازه‌ترین مقدار را نگه می‌دارد و tombstoneها کلیدهای حذف‌شده را می‌اندازند.
  1. Client put fans out to two appends: WAL (append + fsync, the durable write) and Memtable (the visible write).
  2. The dashed arrow runs backwards, WAL to memtable: replay after crash is the log's only other job.
  3. Memtable flushes to SSTable segments (immutable, newest first); compaction merges them keeping the newest value, with tombstones dropping deleted keys.

نقطهٔ تصمیم: نقطهٔ durability همان fsync لاگ است: هرچه ack شده در لاگ است، بقیه شاید رفته باشد.

رد خرابی: کرش پیش از fsync را دنبال کن: put نه به WAL رسید نه به حافظه، پس نه ack رفت نه چیزی بازپخش می‌شود. بعد از fsync همان کرش رکورد را به حافظه برمی‌گرداند.

تضمین نشان‌داده‌شده: شکل ترتیب نوشتن را تضمین می‌کند: اول durability، بعد visibility، تمیزکاری در پس‌زمینه. هیچ‌اندازه‌ای نمی‌دهد.

تضمین نشان‌نداده‌شده: درباره خواندن‌ها، نسبت‌های amplification یا استراتژی compaction مناسب بارکاری تو چیزی نمی‌گوید.

Decision point: The durability point is the WAL fsync: everything acked is in the log, everything else may be lost.

Failure trace: Trace a crash before the fsync: the put reached neither WAL nor memtable, so no ack went out and nothing is replayed. After the fsync, the same crash replays the record back into the memtable.

Guarantee shown: The figure guarantees the write order: durability first, visibility second, cleanup in the background. It does not size anything.

Not shown: It says nothing about reads, amplification ratios, or which compaction strategy fits your workload.

خودآزماییSelf-check

کدام پیکان باید کامل شود تا کلاینت ack بگیرد؟

پاسخ: fsync لاگ، چون قاعدهٔ ack در شکل اجازهٔ ack را فقط بعد از بادوام شدن لاگ می‌دهد.

Which arrow must complete before the client gets its ack?

Answer: The WAL fsync, because the ack rule in the diagram allows the ack only after the log is durable.

گام 2 از 3 · خط فکریStep 2 of 3 · reasoning thread · کرش بین flush و manifest: فایل نادیده، WAL بازپخشCrash between flush and manifest: file ignored, WAL replayed · توالی کرش و بازیابیcrash and recovery sequenceباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
یک flush تمام شد و پراسس پیش از ثبتش مرد. بالا‌به‌پایین بخوان: نوشتن‌های عادی، پنجرهٔ کرش، بعد restart.
A flush finished and the process died before recording it. Read top to bottom: steady writes, the crash window, then restart.
  1. نوشتن‌های عادی: append و fsync به WAL، به‌روزرسانی حافظه، ack فقط بعد از fsync.
  2. flush و بعد کرش: فایل SSTable روی دیسک نشست ولی رکورد manifestش با پراسس رفت.
  3. بازیابی restart: خواندن manifest، بازپخش WAL سالم‌سنجی‌شده، نادیده گرفتن فایل ثبت‌نشده، سرو دوبارهٔ خواندن‌ها.
  1. Steady writes: append + fsync to the WAL, update the memtable, ack only after the fsync.
  2. Flush, then crash: the SSTable file lands on disk but its manifest record is lost with the process.
  3. Restart recovery: read the manifest, replay the checksummed WAL, ignore the unlisted file, serve reads again.

نقطهٔ تصمیم: نقطهٔ commit نشستن رکورد manifest است. flush به‌تنهایی هیچ‌چیز را commit نمی‌کند.

رد خرابی: کرش را دنبال کن: پیام flush کامل شد ولی رکورد manifest ننشست، پس restart مانیفست قدیمی را می‌خواند، WAL را بازپخش می‌کند و فایل ثبت‌نشده نادیده می‌ماند. هیچ حالت نصفه‌ای به خواندن سرویس نمی‌دهد.

تضمین نشان‌داده‌شده: شکل نقطهٔ commit را تضمین می‌کند: فقط فایل داخل manifest خواناست. رپلیکیشن یا recovery چندگره‌ای را پوشش نمی‌دهد.

تضمین نشان‌نداده‌شده: درباره استراتژی compaction، amplification یا بسامد flush چیزی نمی‌گوید.

Decision point: The commit point is the manifest record landing. The flush alone commits nothing.

Failure trace: Trace the crash: the flush message completed but the manifest record never landed, so restart reads the old manifest, replays the WAL, and the unlisted file is ignored. No half-state ever serves reads.

Guarantee shown: The figure guarantees the commit point: only a manifested file is readable. It does not cover replication or multi-node recovery.

Not shown: It says nothing about compaction strategy, amplification, or how often to flush.

خودآزماییSelf-check

بعد از restart چه بر سر فایل SSTable flushشدهٔ ثبت‌نشده می‌آید؟

پاسخ: نادیده گرفته می‌شود: فقط فایل‌های نام‌برده در manifest به خواندن سرو می‌دهند و بازپخش WAL محتوایشان را بازسازی کرده است.

After restart, what happens to the flushed-but-unlisted SSTable file?

Answer: It is ignored: only files named by the manifest serve reads, and the WAL replay already rebuilt their contents.

نمودار اسمبل نهاییFinal assembly diagram · مسیر نوشتن LSM، بازنویسی B-tree و پنل amplification (نسبت‌های آموزشی)LSM write path, B-tree rewrite, and the amplification panel (teaching ratios) · مسیر نوشتن + پنل compactionwrite path + compaction panelباز کردن در صفحه جدا ↗Open standalone ↗

دانلود SVG مستقلDownload standalone SVG

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
موج نوشتن در ساعت اوج به انبار سفارش می‌خورد. از پیکان بالا-چپ پنل آبی شروع کن و هر appendای که یک put راه می‌اندازد قدم بزن.
A write surge hits an order store at peak hour. Start at the top-left arrow of the blue panel and walk every append a single put triggers.
  1. پنل آبی، چپ‌به‌راست: WAL (append و fsync)، حافظهٔ مرتب، SSTableهای تغییرناپذیر، Compact (merge و tomb) — کل مسیر نوشتن هیچ‌جا درجا بازنویسی نمی‌کند.
  2. پنل زرد: B-tree به همان put با یافتن برگ، بازنویسی همان صفحه و fsync لاگ خودش جواب می‌دهد — خواندن قابل‌پیش‌بینی، نوشتن تصادفی گران‌تر.
  3. پنل پایین، سه ستون: write amp (پرداخت LSM هنگام compaction)، read amp (سگمنت‌ها در برابر عمق صفحه)، space amp (قدیم به‌علاوه جدید در برابر صفحات به‌علاوه WAL).
  4. دو خط پایین: قاعدهٔ کرش (فایل ثبت‌نشده نادیده، WAL بازپخش) و قاعدهٔ tombstone (حذف زودهنگام مقدار قدیمی را زنده می‌کند).
  1. Blue panel, left to right: WAL (append + fsync), Memtable (sorted RAM), SSTables (immutable), Compact (merge + tomb) — the whole write path never rewrites in place.
  2. Yellow panel: the B-tree answers the same put by finding the leaf, rewriting that page, and fsyncing its own WAL — predictable reads, costlier random writes.
  3. Bottom panel, three columns: write amp (LSM pays at compaction), read amp (segments versus page depth), space amp (old plus new versus pages plus WAL).
  4. Bottom two lines: the crash rule (unlisted file ignored, WAL replayed) and the tombstone rule (early drop resurrects the old value).

نقطهٔ تصمیم: نقطهٔ commit ثبت manifest است: فقط SSTable فهرست‌شده بادوام و خواناست. هرچه پیش از آن است یا در WAL است یا رفته است.

رد خرابی: کرش را دنبال کن: پیکانِ فایل SSTable ثبت‌نشده را بگیر — خواندن‌ها چون manifest نامش را ندارد لمسش نمی‌کنند — بعد پیکان چین‌چین بازپخش را از WAL به حافظه برگرد. recovery رکوردهای سالم‌سنجی‌شده را بازپخش می‌کند و دم خراب را می‌اندازد.

تضمین نشان‌داده‌شده: شکل شکلِ معاوضه را تضمین می‌کند: appendها در برابر بازنویسی‌ها و جای هر amplification. هیچ نسبتی را برای موتور تو تضمین نمی‌کند — آن‌ها اعداد آموزشی‌اند.

تضمین نشان‌نداده‌شده: درباره رپلیکیشن، recovery بین گره‌ها یا این‌که بارکاری تو کدام استراتژی compaction را می‌خواهد چیزی نمی‌گوید.

Decision point: The commit point is the manifest record: only a listed SSTable is durable and readable. Everything before it is either in the WAL or lost.

Failure trace: Trace the crash: follow the arrow into the unlisted SSTable file — reads never touch it because the manifest does not name it — then follow the dashed replay arrow from the WAL back into the memtable. Recovery replays checksummed records and drops the corrupt tail.

Guarantee shown: The figure guarantees the shape of the trade: appends versus rewrites and where each amplification lands. It does not guarantee any ratio for your engine — those are teaching numbers.

Not shown: It says nothing about replication, cross-node recovery, or which compaction strategy your workload needs.

خودآزماییSelf-check

یک put حافظه را به‌روز کرد ولی پراسس پیش از fsync لاگ کرش کرد. آیا نوشتن بادوام است؟

پاسخ: fsync لاگ، چون durability در لاگ است نه RAM. نوشتنِ فقط-حافظه با پراسس می‌رود.

A put updated the memtable but the process crashed before the WAL fsync. Is the write durable?

Answer: WAL fsync, because durability lives in the log, not in RAM. A memtable-only write disappears with the process.

گزینه‌های جایگزینAlternatives

  • وقتی خواندن غالب است B-tree: هر جست‌وجو ۳ تا ۴ صفحهٔ قابل‌پیش‌بینی می‌خواند و rangeها زنجیرهٔ برگ را بدون توقف compaction می‌پیمایند. هزینه: نوشتن‌های تصادفی کل صفحه را بازنویسی می‌کنند و split و WAL هم سربار خودشان را دارند.
  • لاگ با ایندکس hash (سبک Bitcask) برای خواندن نقطه‌ایِ خالص که مجموعهٔ کلیدها در RAM جا شود: خواندن O(1) و نوشتن ترتیبی. هزینه: بدون range query و بازسازی کل لاگ بعد از restart.
  • B-tree when reads dominate: each lookup costs a predictable 3-4 page fetches and ranges walk the leaf chain with no compaction stalls. Cost: random writes rewrite whole pages, splits and write-ahead logging add their own overhead.
  • Hash-indexed log (Bitcask style) for pure point lookups whose key set fits RAM: O(1) reads, sequential writes. Cost: no range queries and a full-log rebuild after restart.

سناریوهای خرابیFailure scenarios

  • کرش بین flush و manifest: قبلش حافظه به‌علاوه WAL نوشتن‌ها را دارند؛ بعد از restart فایل SSTable ثبت‌نشده نادیده گرفته می‌شود و بازپخش WAL حافظه را بازمی‌سازد. recovery: بازپخش رکوردهای سالم‌سنجی‌شده، دور ریختن دم خراب، ثبت فقط فایل‌های داخل manifest.
  • حذف زودهنگام tombstone: compactionای که قدیمی‌ترین سگمنت را شامل نمی‌شود باید tombstone را نگه دارد، وگرنه کپی قدیمیِ باقی‌مانده کلید حذف‌شده را زنده می‌کند. قاعده: tombstone را فقط هنگام ادغام با قدیمی‌ترین سگمنتِ دارای آن کلید دور بریز، یا بعد از یک مهلت اعلام‌شده که کپی قدیمی‌تری نیست.
  • عقب افتادن compaction از موج نوشتن: حافظه‌ها تلنبار می‌شوند، تعداد سگمنت‌ها رشد می‌کند و read و space amplification با هم اوج می‌گیرند. چاره: throttle نوشتن، ترجیح leveled، هشدار روی بایت‌های compaction معوق پیش از افت خواندن.
  • Crash between flush and manifest: before, the memtable plus WAL hold the writes; after restart, the unlisted SSTable file is ignored and WAL replay rebuilds the memtable. Recovery: replay checksummed records, drop the corrupt tail, re-register only manifested files.
  • Tombstone dropped too early: a compaction that does not include the oldest segment must keep the tombstone, otherwise the surviving older copy resurrects the deleted key. Rule: drop a tombstone only when merging the oldest segment holding that key, or after a stated grace period with no older copies.
  • Compaction falls behind a write spike: memtables pile up, segment count grows, read amplification and space amplification spike together. Mitigation: throttle writes, prefer leveled merging, alert on pending compaction bytes before reads degrade.

کد قابل اجراRunnable code

اجراشده و تأییدشده در همین محیطExecuted and verified in this environment

این کد آموزشی و ساده‌سازی‌شده است؛ فقط برای آشنایی با ایده. کد واقعی پروداکشن ملاحظات بسیار بیشتری دارد و هیچ‌کس این‌شکلی کد پروداکشن نمی‌زند.Simplified teaching code for building intuition only — not production code; nobody ships production code like this.

import java.util.*;

public class LsmToy {
    static List<String> wal = new ArrayList<>();
    static TreeMap<String, String> mem = new TreeMap<>();
    static final String TOMB = "<tombstone>";

    static void put(String k, String v) {
        wal.add(k + "=" + v); // fsync point in a real engine
        mem.put(k, v);
    }

    static List<Map.Entry<String, String>> flush() {
        List<Map.Entry<String, String>> seg = new ArrayList<>(mem.entrySet());
        mem.clear();
        return seg; // immutable from here on
    }

    static Map<String, String> merge(List<List<Map.Entry<String, String>>> segs) {
        Map<String, String> latest = new LinkedHashMap<>();
        for (List<Map.Entry<String, String>> seg : segs) // oldest first
            for (Map.Entry<String, String> e : seg) latest.put(e.getKey(), e.getValue());
        latest.values().removeIf(v -> v.equals(TOMB));
        return latest;
    }

    public static void main(String[] a) {
        put("u1", "a"); put("u2", "b"); put("u1", "a2");
        List<Map.Entry<String, String>> seg1 = flush();
        put("u2", TOMB); put("u3", "c");
        List<Map.Entry<String, String>> seg2 = flush();
        System.out.println("seg1: " + seg1);
        System.out.println("seg2: " + seg2);
        System.out.println("merged: " + merge(Arrays.asList(seg1, seg2)));
        Map<String, String> recovered = new LinkedHashMap<>();
        for (String r : wal) recovered.put(r.split("=")[0], r.split("=")[1]);
        recovered.values().removeIf(v -> v.equals(TOMB));
        System.out.println("wal-replay: " + recovered);
    }
}

اجرا: javac LsmToy.java && java LsmToy — فقط کتابخانه استاندارد، فوری. دنباله put را عوض کن تا سناریوی کرش خودت را بازپخش کنی.

خروجی مورد انتظار: خروجی واقعی همین محیط (JDK 27): دو سگمنت، بعد ادغام برابر {u1=a2, u3=c} و بازپخش WAL همان نقشه. حذف u2 فقط با tombstone اعمال شده است.

Run: javac LsmToy.java && java LsmToy — stdlib only, instant. Change the put sequence to replay your own crash case.

Expected output: seg1: [u1=a2, u2=b]; seg2: [u2=, u3=c]; merged: {u1=a2, u3=c}; wal-replay matches the merge.

آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria

راستی‌آزمایی: تازه‌ترین seq همیشه می‌برد؛ کلید حذف‌شده بعد از merge شامل tombstone غایب است؛ بازپخش WAL برابر حافظهٔ پیش از کرش است. اندازه بگیر: بایت دیسک ÷ بایت خام (write amplification)، تأخیر p95 خواندن نقطه‌ای در برابر تعداد سگمنت‌ها، و بایت‌های compaction معوق به‌عنوان بدهی عملیاتی.
Verify: the newest seq always wins; a deleted key is absent after a merge that includes its tombstone; WAL replay equals the pre-crash memtable. Measure: disk bytes over raw bytes (write amplification), p95 point-read latency versus segment count, and pending compaction bytes as operational debt.

پاسخ سؤال‌های پیگیریFollow-up answers

پیگیری (سینیور): حذف tombstone هنگام compaction چه زمانی رکورد قدیمی را زنده می‌کند؟Follow-up (Senior): When can dropping a tombstone during compaction resurrect an old value?

مقدار قدیمی برمی‌گردد. tombstone فقط کپی‌هایی را سرکوب می‌کند که merge واقعاً دیده است: اگر قدیمی‌ترین سگمنتِ دارای آن کلید در این compaction نباشد، یک کپی قدیمی پایین‌تر زنده می‌ماند و خواندن بعدی دوباره پیدایش می‌کند. tombstone را نگه دار تا mergeای کل بازهٔ کلید را پوشش دهد، یا تا یک مهلت اعلام‌شده به‌علاوه بررسی ضدنقیض ثابت کند کپی قدیمی‌تری نیست.

The old value comes back. A tombstone only suppresses the copies the merge actually sees: if the oldest segment holding that key is not part of this compaction, an older copy survives downstream and the next read finds it again. Keep the tombstone until a merge covers the key's full range, or until a stated grace period plus an anti-entropy check proves no older copy exists.

پیگیری (سینیور): دسته‌بندی fsyncهای WAL (group commit) چه چیزی را معاوضه می‌کند؟Follow-up (Senior): What does batching WAL fsyncs (group commit) trade away?

بازده می‌افتد ولی چیزی گم نمی‌شود: نوشتن‌های بدون ack در مسیر retry کلاینت می‌مانند و ackشده‌ها fsync شده‌اند. خطر، زود جشن گرفتن است — دسته‌بندی fsyncها (group commit) یک پنجرهٔ staleness محدود را با بازده معاوضه می‌کند و آن پنجره باید در قرارداد durability نوشته شود.

Throughput drops but nothing is lost: unacknowledged writes stay in the client retry path, acknowledged ones are already fsynced. The danger is declaring victory too early — batching fsyncs (group commit) trades a bounded staleness window for throughput, and that window must be written in the durability contract.

پیگیری (استف): زیر موج نوشتن استراتژی compaction را چطور تشخیص و انتخاب می‌کنی؟Follow-up (Staff): How do you detect and choose between compaction strategies under a write spike?

به صف نگاه کن نه میانگین: بایت‌های compaction معوق، تعداد سگمنت هر سطح و p99 خواندن با هم خبر می‌دهند توقف نزدیک است. size-tiered موج را جذب می‌کند و بعد با mergeهای بزرگ پس می‌دهد؛ leveled مداوم می‌پردازد و خواندن تخت می‌ماند. بر اساس این‌که بارکاری‌ات کدام صورت‌حساب را می‌تواند بدهد انتخاب کن و متریک صف را پیش از حادثه روی داشبورد بگذار.

Watch the backlog, not the averages: pending compaction bytes, segment count per level, and read p99 together tell you the stall is coming. Size-tiered absorbs bursts then pays in big merges; leveled pays continuously and reads stay flat. Pick by which bill your workload can afford, and put the backlog metric on the dashboard before the incident.

اشتباهات رایجCommon mistakes

  • بادوام خواندن حافظه. RAM با کرش می‌رود؛ فقط پیشوند fsyncشدهٔ WAL می‌ماند. قصهٔ recovery بازپخش است، نه حافظه.
  • حذف فوری دانستن tombstone. کلید از سگمنت‌های قدیمی خوانا می‌ماند و فضا مصرف می‌کند تا compaction پوششی حذفش کند.
  • گزارش بنچ‌مارک بدون حالت fsync، وضعیت کش و نسبت داده به RAM. اجرای بدون fsync و کاملاً کش‌شده هیچ‌چیز درباره durability یا رفتار دیسک در پروداکشن نمی‌گوید.
  • Calling the memtable durable. RAM is lost on crash; only the fsynced WAL prefix survives. Replay, not memory, is the recovery story.
  • Treating a tombstone as an instant delete. The key stays readable from older segments and keeps costing space until a covering compaction removes it.
  • Reporting a benchmark without fsync mode, cache state and data-to-RAM ratio. An fsync-free, fully cached run measures nothing about production durability or disk behavior.

منابع:Sources: Microsoft · technical interviewing · Atlassian · engineering interview handbook

22 تمرین ۲ — انباردادهٔ ستونی از صفرExercise 2 — a columnar warehouse from scratch

L2 · SeniorL2 · Seniorوابسته به نقشRole-specific40 دقیقه بحث40 min discussion

بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level

سؤال مصاحبهInterview question
مجموع فروش سال گذشته به تفکیک دسته‌بندی و روز هفته را طراحی کن: جدول‌های fact و dimension، کوئری SQL، کلیدهای پارتیشن و مرتب‌سازی، و دقیقاً کدام ستون‌ها و بلوک‌ها خوانده می‌شوند. بعد اثر skew، فشرده‌سازی bitmap با cardinality اعلام‌شده، و معنای تغییر دسته‌بندی محصول وسط تاریخ برای گزارش را توضیح بده.
Design last year's total sales per category by weekday: fact and dimension tables, the SQL, partition and sort keys, and exactly which columns and blocks get read. Then explain skew, bitmap compression under stated cardinality, and what a product category change mid-history means for the report.

پاسخ ۶۰ثانیه‌ایThe 60-second answer

استار اسکما: یک سطر fact_sales به‌ازای هر فروش (روز، شناسه محصول، تعداد، مبلغ) به‌علاوه دو جدول کوچک dim_date و dim_product. پارتیشن بر اساس سال و ماه، مرتب‌سازی بر اساس روز بعد دسته‌بندی، و کوئری فقط ۳ ستون باریک را فقط از بلوک‌های سال گذشته می‌خواند — column pruning به‌علاوه partition pruning. فیلتر دسته‌بندی کم‌مقدار با AND بیتی اجرا می‌شود؛ گزارش تاریخی دسته‌بندیِ زمان فروش را می‌دهد، نه برچسب امروز.
Star schema: one fact_sales row per sale (day, product id, quantity, amount) plus small dim_date and dim_product tables. Partition by year and month, sort by day then category, and the query reads only 3 narrow columns from only last year's blocks — column pruning plus partition pruning. Low-cardinality category filters run as bitmap ANDs; history reports the category at sale time, not today's label.

صورت مسئله و فرض‌هاProblem and assumptions

  • مقیاس آموزشی: دموی ۱۴ سطری که این‌جا اجرا می‌شود؛ همان شکل را در میلیون‌ها سطر تصور کن. نسبت‌ها آموزشی‌اند، نه بنچ‌مارک.
  • پنج دسته‌بندی محصول، روز هفته مشتق از بعد تاریخ، مبلغ‌ها با یک ارز و بدون منطق مالیات یا مرجوعی.
  • یک بارگذاری شبانه: factهای روزهای گذشته کامل‌اند و اصلاح‌های دیررس به‌صورت بازنویسی اعلام‌شده می‌آیند، نه ویرایش خاموش.
  • Teaching scale: a 14-row demo you can run here; imagine the same shape at millions of rows. Ratios below are teaching numbers, not benchmarks.
  • Five product categories, weekday derived from the date dimension, amounts in one currency with no tax or returns logic.
  • One nightly load: facts are complete for past days, and late corrections arrive as restatements, not silent edits.

چرا این روش و این عددها؟Why this method and these numbers?

ستونی‌بودن زمانی سود دارد که query فقط چند ستون را بخواند؛ حجم خواندن تقریباً تعداد ردیف‌های انتخاب‌شده × مجموع اندازهٔ ستون‌های لازم است، به‌علاوهٔ metadata. RLE از تکرار پشت‌سرهم سود می‌برد؛ shuffle کردن همان مقدارها فشرده‌سازی را بدتر می‌کند و نشان می‌دهد توزیع و ترتیب هر دو مهم‌اند. ۱۴ ردیف برای دیدن نتیجه با چشم است؛ نسبت آن، پیش‌بینی میلیون‌ها ردیف نیست. دادهٔ دیررس باید گزارش دورهٔ قبلی را طبق قرارداد تصحیح کند.

Column storage helps when queries read only a few columns: approximate bytes as selected rows × needed column widths, plus metadata. RLE benefits from consecutive repetition; shuffling identical values demonstrates why order matters. Fourteen rows make the result inspectable, not predictive at millions of rows. Late data must restate earlier reports under an explicit contract.

مدل ذهنیMental model

row store کل سطر را می‌خواند حتی وقتی سؤال دو ستون می‌خواهد؛ فایل ستونی کل ستون را می‌خواند ولی فقط ستون‌های خواسته‌شده را. مرتب‌سازی مقادیر مساوی را کنار هم می‌گذارد تا فشرده شوند، و پارتیشن‌بندی می‌گذارد موتور بلوک‌های کامل را بدون باز کردن رد کند.
A row store reads whole rows even when the question needs two columns; a columnar file reads whole columns but only the ones asked for. Sorting puts equal values next to each other so they compress, and partitioning lets the engine skip entire blocks without opening them.

حل گام‌به‌گامStep by step

  1. اول ستاره را بکش fact_sales(day, pid, qty, amount) به‌ازای هر فروش یک سطر دارد؛ dim_date(day, weekday, year) و dim_product(pid, category) کوچک می‌مانند و با کلید join می‌شوند. factها باریک می‌مانند تا اسکن ارزان بماند.
  2. SQL را بنویس factها را به هر دو بعد join کن، سال گذشته را فیلتر کن، بر اساس دسته و روز هفته group کن و مبلغ را جمع بزن. فهرست SELECT فقط ستون‌های لازم گزارش را نام می‌برد — همان فهرست قرارداد pruning است.
  3. کلیدها معلوم می‌کنند چه رد می‌شود پارتیشن سال و ماه تا شرط سال گذشته بلوک‌های قدیمی را بازنکرده دور بریزد؛ مرتب‌سازی هر فایل بر اساس روز بعد دسته تا دسته‌های مساوی کنار هم برای فشرده‌سازی run-length بنشینند و بازه‌های روز هفته prune شوند. دمو فقط خط‌های روز، شناسه و مبلغ را می‌خواند.
  4. با cardinality فشرده کن، skew را بپا پنج دسته در bitmap جا می‌شود: یک بیت به‌ازای هر سطر و مقدار، AND بیت‌مپ دسته و روز هفته، بعد خواندن مبلغ فقط برای سطرهای باقی‌مانده. شناسه‌های پرمقدار dictionary به‌علاوه run-length می‌گیرند — bitmap خام منفجر می‌شود. محصول داغ یک بلوک مرتب را شلوغ می‌کند و prune بازه‌ای را ضعیف می‌کند؛ ایزوله یا دوباره مرتبش کن.
  5. تاریخ هر دو برچسب را نگه می‌دارد جابه‌جایی دسته در ژوئن نباید ژانویه را بازنویسی کند: هر دو برچسب را به‌صورت بعدِ کندتغییر نگه دار و دستهٔ زمان فروش را گزارش بده، مگر صورت سؤال صریح بازنویسی بخواهد — و آن‌وقت روی گزارش بنویس.
  1. Shape the star first fact_sales(day, pid, qty, amount) holds one row per sale; dim_date(day, weekday, year) and dim_product(pid, category) stay small and joined by key. Facts stay narrow so scans stay cheap.
  2. Write the SQL Join facts to both dimensions, filter year = last year, group by category and weekday, sum the amount. The SELECT list names only the columns the report needs — that list is the pruning contract.
  3. Keys decide what is skipped Partition by year and month so last year's predicate discards all older blocks unopened; sort each file by day then category so equal categories sit together for run-length compression and weekday ranges prune. The demo reads day, pid and amount lanes only.
  4. Compress by cardinality, watch skew Five categories fit a bitmap: one bit per row per value, AND the category and weekday bitmaps, then fetch amounts only for surviving rows. High-cardinality ids get dictionary plus run-length encoding instead — raw bitmaps would explode. A hot product crowds one sort block and weakens range pruning; isolate or re-sort it.
  5. History keeps both labels A June category move must not rewrite January: keep both labels as a slowly changing dimension and report the category at sale time unless the brief explicitly asks for a restated view — and then say so on the report.

طراحی مشخصConcrete design

جدول‌ها: fact_sales(day, pid, qty, amount) و dim_date(day, weekday, year) و dim_product(pid, category). کلید پارتیشن: سال و ماه روی فایل‌های fact. کلید مرتب‌سازی داخل هر فایل: روز، بعد دسته. مسیر خواندن: فقط پارتیشن‌های سال گذشته، فقط خط‌های روز و شناسه و مبلغ، فیلتر year = سال گذشته، join دو بعد کوچک، GROUP BY دسته و روز هفته با SUM(مبلغ). شکل کامل SQL: SELECT p.category, d.weekday, SUM(f.amount) FROM fact_sales f JOIN dim_date d ON f.day = d.day JOIN dim_product p ON f.pid = p.pid WHERE d.year = سال_گذشته GROUP BY p.category, d.weekday.

Tables: fact_sales(day, pid, qty, amount), dim_date(day, weekday, year), dim_product(pid, category). Partition key: year and month on the fact files. Sort key inside each file: day, then category. Read path: open only last-year partitions, only the day, pid and amount lanes, filter year = last year, join the two small dimensions, GROUP BY category, weekday with SUM(amount). The full SQL is SELECT p.category, d.weekday, SUM(f.amount) FROM fact_sales f JOIN dim_date d ON f.day = d.day JOIN dim_product p ON f.pid = p.pid WHERE d.year = last_year GROUP BY p.category, d.weekday.

مثال محاسبه‌شدهWorked example

دموی جاوا همین محیط (JDK 27، چهارده سطر آموزشی): group-by چاپ می‌کند A|0 -> 90.0 و چهار سطر مشابه؛ فیلتر بیتی دستهٔ B و روز هفتهٔ ۲ می‌دهد rows = [9] و فقط یک مبلغ می‌خواند: [100.0]. تفسیر: از چهارده سطر یکی می‌ماند، پس خط مبلغ یک خواندن می‌پردازد نه چهارده‌تا — همین نسبت کل استدلال pruning است، در مقیاس آموزشی.

Java demo from this environment (JDK 27, 14 teaching rows): the group-by prints A|0 -> 90.0, A|1 -> 90.0, A|3 -> 40.0, A|4 -> 170.0, A|5 -> 130.0; the bitmap filter for category B and weekday 2 gives rows = [9] and reads a single amount, [100.0]. Interpretation: one row in fourteen survives, so the amount lane pays one fetch instead of fourteen — that ratio is the whole argument for pruning, at teaching scale.

گام 1 از 2 · خط فکریStep 1 of 2 · reasoning thread · اول رد کن بعد بخوان: پارتیشن، ستون، فیلتر بیتیSkip first, then read little: partition, column, bitmap filter · گذر داده خط کوئریquery-pipeline dataflowباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
گزارش سال گذشته باید آماده شود. از فایل‌های fact پارتیشن‌بندی‌شده در چپ شروع کن و فقط چیزی را دنبال کن که کوئری لازم دارد.
Last year's report is due. Start at the partitioned fact files on the left and follow only what the query needs.
  1. فایل‌های fact به prune پارتیشن می‌رسند: شرط سال فقط بلوک‌های سال گذشته را نگه می‌دارد و قدیمی‌ها بازنمی‌شوند.
  2. بعدها موقع فیلتر حل می‌شوند: بیت‌های دسته از dim_product و بیت‌های روز هفته از dim_date که در فیلتر بیتی AND می‌شوند.
  3. فقط سطرهای باقی‌مانده به group-by می‌رسند: جمع دسته‌به‌روزهفته فقط روی خط مبلغ.
  1. Fact files fan into partition prune: the year predicate keeps only last year's blocks, older ones stay unopened.
  2. Dimensions resolve at filter time: category bits from dim_product, weekday bits from dim_date, ANDed in the bitmap filter.
  3. Only surviving rows reach group-by: category by weekday sums over the amount lane alone.

نقطهٔ تصمیم: نقطهٔ فیلتر همان AND بیتی است: فقط سطرهای باقی‌مانده هزینهٔ خواندن مبلغ را می‌پردازند.

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

تضمین نشان‌داده‌شده: شکل ترتیب رد کردن را تضمین می‌کند: اول پارتیشن‌ها، بعد ستون‌ها، آخر سطرها. کلیدهای تو را تعیین نمی‌کند.

تضمین نشان‌نداده‌شده: درباره معنای تاریخ، چارهٔ skew یا codecهای فشرده‌سازی چیزی نمی‌گوید.

Decision point: The bitmap AND is the filter point: only surviving rows pay for an amount fetch.

Failure trace: Trace a late fact: it lands in an old partition, so the pipeline restates that partition and the totals change with a note, instead of silently editing history.

Guarantee shown: The figure guarantees the skip order: partitions first, columns second, rows last. It does not fix your keys.

Not shown: It says nothing about history semantics, skew handling, or compression codecs.

خودآزماییSelf-check

کدام مرحله برای گزارش سال گذشته بیشترین بایت را رد می‌کند و چرا؟

پاسخ: prune پارتیشن: شرط سال بلوک‌های کامل را پیش از لمس هر ستونی دور می‌ریزد.

Which stage skips the most bytes for a last-year report, and why?

Answer: Partition prune: the year predicate discards whole blocks before any column is touched.

نمودار اسمبل نهاییFinal assembly diagram · کدام ستون‌ها و بلوک‌ها خوانده می‌شوند و کدام رد (اعداد آموزشی)Which columns and blocks are read and which are skipped (teaching numbers) · پنل خوانده در برابر ردشدهread-vs-skipped panelباز کردن در صفحه جدا ↗Open standalone ↗

دانلود SVG مستقلDownload standalone SVG

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
پلنر کوئری روبه‌روی factهای سال گذشته است. چپ‌به‌راست بخوان: اول خط‌ها، بعد بلوک‌ها، بعد حساب فیلتر.
The query planner faces last year's facts. Read left to right: lanes first, then blocks, then the filter math.
  1. ردیف بالا: پنج خط، سه‌تای سبز READ (روز، شناسه، مبلغ) و دو خاکستری SKIP — prune ستونی پیش از باز شدن هرچیز.
  2. ردیف دوم: بلوک‌های سال — ۲۰۲۵ سبز و ۲۰۲۴ خاکستری و بازنکرده — prune پارتیشن با شرط سال.
  3. پنل راست: بیت‌های دسته AND بیت‌های روز هفته سطرهای باقی‌مانده را می‌دهند؛ فقط مبلغ همان‌ها خوانده می‌شود.
  4. پنل پایین: skew بلوک مرتب را شلوغ می‌کند، شناسه‌ها dictionary می‌گیرند و جابه‌جایی دسته هر دو برچسب را نگه می‌دارد.
  1. Top row: five lanes, three green READ (day, pid, amount) and two grey SKIP — column pruning before anything opens.
  2. Second row: year blocks — 2025 green, 2024 grey and unopened — partition pruning by the year predicate.
  3. Right panel: category bits AND weekday bits give the surviving rows; only their amounts are fetched.
  4. Bottom panel: skew crowds a sort block, ids take dictionary encoding, and category moves keep both labels.

نقطهٔ تصمیم: نقطهٔ تصمیم همان AND بیتی است: فقط سطرهای گذرنده از هر دو بیت هزینهٔ خواندن مبلغ می‌پردازند.

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

تضمین نشان‌داده‌شده: شکل تضمین می‌کند برای این شکل کوئری چه خوانده و چه رد می‌شود. کلیدها و codecهای تو را تعیین نمی‌کند.

تضمین نشان‌نداده‌شده: درباره معنای تاریخ، چارهٔ skew یا قرارداد refresh چیزی نمی‌گوید.

Decision point: The decision point is the bitmap AND: only rows passing both bits pay for an amount fetch.

Failure trace: Trace the late fact: it lands in an old block, so that block flips from skipped back to read, and the restated totals carry a correction note.

Guarantee shown: The figure guarantees what is read versus skipped for this query shape. It does not set your keys or codecs.

Not shown: It says nothing about history semantics, skew handling, or refresh contracts.

خودآزماییSelf-check

گزارش تعدادی لازم ندارد. کدام خط شکل بازنمی‌ماند و چه چیزی آن را تضمین می‌کند؟

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

The report needs no quantities. Which lane from the diagram stays unopened, and what enforces that?

Answer: The quantity lane: the SELECT list names only day, pid and amount, so qty blocks are never opened.

گزینه‌های جایگزینAlternatives

  • وقتی بارکاری lookup نقطه‌ای است (یک سفارش با شناسه) row store با ایندکس B-tree. هزینه: هر کوئری تحلیلی خواندن سطر کامل می‌پردازد و pruningای در کار نیست.
  • وقتی همان گزارش دسته‌به‌روزهفته هر صبح بدون تغییر اجرا می‌شود cube ازپیش‌جمع‌شده. هزینه: ذخیره‌سازی هر برش به‌علاوه قرارداد refresh؛ سؤال‌های ad-hoc هنوز factهای پایه را می‌خواهند.
  • Row store with a B-tree index when the workload is point lookups (one order by id), not scans. Cost: every analytical query pays full-row reads and no pruning applies.
  • Pre-aggregated cube when the same category-by-weekday report runs unchanged every morning. Cost: storage per slice plus a refresh contract; ad-hoc questions still need the base facts.

سناریوهای خرابیFailure scenarios

  • fact دیررس: اصلاح مارس در بارگذاری آوریل می‌نشیند. قبل: پارتیشن مارس گزارش شده. بعد: بازنویسی اعلام‌شدهٔ پارتیشن مارس با یادداشت اصلاح و اجرای دوبارهٔ گزارش‌های مرتبط — هرگز ویرایش خاموش تاریخ.
  • جابه‌جایی دسته وسط سال بدون بعد کندتغییر: فروش‌های ژانویه خاموش برچسب ژوئن را می‌گیرند و مقایسهٔ سال‌به‌سال دروغ می‌شود. قاعده: هر دو برچسب را نگه دار و پیش‌فرض دستهٔ زمان فروش را گزارش بده.
  • skew محصول داغ به‌علاوه شناسه‌های پرمقدار: یک محصول بلوک مرتب را شلوغ می‌کند و prune بازه‌ای می‌افتد و bitmap خام روی میلیون‌ها شناسه منفجر می‌شود. چاره: ایزوله یا مرتب‌سازی دوبارهٔ کلید داغ، dictionary برای شناسه‌ها و bitmap فقط برای خط‌های کم‌مقدار.
  • Late-arriving fact: a correction for March lands in April's load. Before: March partition already reported. After: restate the March partition with a visible correction note and re-run affected reports — never silently edit history.
  • Mid-year category move without a slowly changing dimension: January sales silently take June's label and year-over-year comparisons lie. Rule: keep both labels, report category-at-sale-time by default.
  • Hot-product skew plus high-cardinality ids: one product crowds a sort block so range pruning degrades, and raw bitmaps on millions of ids explode. Mitigation: isolate or re-sort the hot key, dictionary-encode ids, reserve bitmaps for low-cardinality lanes.

کد قابل اجراRunnable code

اجراشده و تأییدشده در همین محیطExecuted and verified in this environment

این کد آموزشی و ساده‌سازی‌شده است؛ فقط برای آشنایی با ایده. کد واقعی پروداکشن ملاحظات بسیار بیشتری دارد و هیچ‌کس این‌شکلی کد پروداکشن نمی‌زند.Simplified teaching code for building intuition only — not production code; nobody ships production code like this.

import java.util.*;

public class ColumnarToy {
    public static void main(String[] a) {
        Map<String, Integer> dimDate = new LinkedHashMap<>();
        for (int d = 1; d <= 14; d++) dimDate.put("2025-01-" + d, (d - 1) % 7);
        Map<Integer, String> dimProduct = Map.of(1, "A", 2, "A", 3, "B", 4, "C");
        List<String[]> facts = new ArrayList<>(); // (day, pid, qty, amount)
        for (int d = 1; d <= 14; d++)
            facts.add(new String[]{"2025-01-" + d, String.valueOf(d % 4 + 1),
                    String.valueOf(d), String.valueOf(d * 10.0)});
        Map<String, Double> agg = new TreeMap<>();
        for (String[] f : facts) {
            if (!f[0].startsWith("2025")) continue; // partition prune
            String key = dimProduct.get(Integer.parseInt(f[1])) + "|" + dimDate.get(f[0]);
            agg.merge(key, Double.parseDouble(f[3]), Double::sum);
        }
        agg.entrySet().stream().limit(5)
                .forEach(e -> System.out.println(e.getKey() + " -> " + e.getValue()));
        int bmCat = 0, bmWd = 0; // bitmap filter: category B AND weekday 2
        for (int i = 0; i < facts.size(); i++) {
            if (dimProduct.get(Integer.parseInt(facts.get(i)[1])).equals("B")) bmCat |= 1 << i;
            if (dimDate.get(facts.get(i)[0]) == 2) bmWd |= 1 << i;
        }
        int both = bmCat & bmWd;
        List<Integer> rows = new ArrayList<>();
        for (int i = 0; i < facts.size(); i++) if ((both >> i & 1) == 1) rows.add(i);
        System.out.println("bitmap AND rows = " + rows);
        List<Double> amounts = new ArrayList<>();
        for (int i : rows) amounts.add(Double.parseDouble(facts.get(i)[3]));
        System.out.println("amounts read for those rows = " + amounts);
    }
}

اجرا: javac ColumnarToy.java && java ColumnarToy — فقط کتابخانه استاندارد، فوری. یک بلوک ۲۰۲۴ اضافه کن تا ببینی prune پارتیشن ردش می‌کند.

خروجی مورد انتظار: خروجی واقعی همین محیط (JDK 27): پنج سطر اول group-by، بعد bitmap AND rows = [9] و amounts = [100.0]. فقط سطر ۹ از ۱۴ سطر باقی ماند.

Run: javac ColumnarToy.java && java ColumnarToy — stdlib only, instant. Add a 2024 block to watch partition pruning skip it.

Expected output: A|0 -> 90.0, A|1 -> 90.0, A|3 -> 40.0, A|4 -> 170.0, A|5 -> 130.0; bitmap AND rows = [9]; amounts read for those rows = [100.0].

آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria

راستی‌آزمایی: جمع group-by برابر جمع مستقیم روی factهای خام است؛ نتیجهٔ AND بیتی برابر فیلتر سطری category = B AND weekday = 2 است. اندازه بگیر: نسبت بلوک‌های خوانده‌شده به کل بلوک‌ها (اثر prune) و تعداد fetchهای خط مبلغ. بعد از هر بازنویسی پارتیشن، هر دو را دوباره اجرا کن.
Verify: the group-by totals equal a direct sum over the raw facts; the bitmap AND equals a row-wise category = B AND weekday = 2 filter. Measure: blocks read over total blocks (the pruning effect) and amount-lane fetches. Re-run both after every partition restatement.

پاسخ سؤال‌های پیگیریFollow-up answers

پیگیری (سینیور): آیا فروش سال گذشته باید با دسته‌بندی امروز گزارش شود یا دسته‌بندی زمان فروش؟Follow-up (Senior): Should last year's sales use today's category or the category at the time of sale?

پیش‌فرض زمان فروش. پول سال گذشته با برچسب‌های همان موقع به دست آمده؛ گزارش با طبقه‌بندی امروز سؤال دیگری است («تاریخ با دسته‌های امروز چه شکلی بود») و باید به‌عنوان نمای بازنویسی‌شده برچسب بخورد و هر دو برچسب در بعد کندتغییر بمانند.

At sale time, by default. Last year's money was earned under last year's labels; restating it under today's taxonomy answers a different question ('what would history look like under today's categories') and must be labelled as a restated view, with both labels kept in a slowly changing dimension.

پیگیری (سینیور): یک محصول وسط سال منفجر می‌شود. در این طراحی چه می‌شکند؟Follow-up (Senior): One product explodes in popularity mid-year. What breaks in this design?

در توزیع کلید مرتب پیدایش کن: یک شناسه کل بلوک را گرفته و prune بازه‌ای روی آن بلوک به خواندن کامل تنزل می‌کند. کلید داغ را ایزوله کن (پارتیشن جدا یا شارد نمکی برای fact و نمای یکتا موقع کوئری) و بعد از چاره نسبت بلوک‌های خوانده‌شده را دوباره چک کن.

Detect it in the sort key distribution: one pid owns a whole block, so range pruning on that block degrades to a full read. Isolate the hot key (own partition or salted shards for the fact, single view at query time) and re-check the blocks-read ratio after the fix.

پیگیری (استف): اصلاح‌ها و backfillها را چطور مدیریت می‌کنی بدون این‌که گزارش‌های منتشرشده خراب شوند؟Follow-up (Staff): How do you handle corrections and backfills without corrupting published reports?

مسیر بازنویسی را پیش از حادثه طراحی کن: پارتیشن‌های نسخه‌دار، لاگ اصلاح append-only، اجرای دوبارهٔ idempotent با کلید نسخهٔ پارتیشن، و چک کیفیت که جمع‌های بازنویسی‌شده را با لاگ اصلاح می‌سنجد. هزینهٔ backfill با پارتیشن‌های بازنویسی‌شده سنجیده می‌شود، پس پارتیشن‌ها را به‌اندازه‌ای کوچک نگه دار که بازنویسی ارزان بماند.

Design the restatement path before the incident: versioned partitions, an append-only correction log, idempotent re-runs keyed by partition version, and a data-quality check comparing restated totals against the correction log. Backfill cost is measured in partitions rewritten, so keep partitions small enough to restate cheaply.

اشتباهات رایجCommon mistakes

  • نوشتن SELECT ستاره‌ای و خواندن همهٔ خط‌ها. prune ستونی فقط وقتی کار می‌کند که کوئری ستون‌هایش را نام ببرد — فهرست SELECT همان قرارداد است.
  • ایندکس bitmap روی خط شناسهٔ پرمقدار. یک بیت به‌ازای هر سطر و مقدار منفجر می‌شود؛ جایگزین اعلام‌شده dictionary به‌علاوه run-length است.
  • بازنویسی تاریخ با برچسب‌های امروز بدون اعلام. عددها تمیز به نظر می‌رسند و قصهٔ سال‌به‌سال بی‌سروصدا دروغ می‌شود.
  • Writing SELECT star and reading every lane. Column pruning only works when the query names its columns — the SELECT list is the contract.
  • Bitmap-indexing a high-cardinality id lane. One bit per row per value explodes; dictionary plus run-length encoding is the stated alternative.
  • Restating history under today's labels without saying so. The numbers look clean and the year-over-year story is quietly false.

منابع:Sources: Microsoft · technical interviewing · Atlassian · engineering interview handbook

33 تمرین ۳ — ایندکس جست‌وجو: از متن تا بردارExercise 3 — a search index: from text to vectors

L2 · SeniorL2 · Seniorوابسته به نقشRole-specific45 دقیقه بحث45 min discussion

بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level

سؤال مصاحبهInterview question
جست‌وجوی محصول را با inverted index، فیلتر و vector index طراحی کن. برای red AND apples اشتراک postings را توضیح بده. برای regex /appl./ ابتدا candidates با trigram و سپس تأیید regex را انجام بده. در IVF با nprobe=1، recall@k را نسبت به exact top-k و p95 latency بسنج؛ hybrid ranking و جداسازی tenant را هم تحلیل کن.
Design lexical and vector product retrieval. Intersect postings for red AND apples; filter /appl./ by trigram then verify the regex. Evaluate IVF using exact top-k recall@k and p95 latency, and discuss hybrid ranking and tenant isolation.

پاسخ ۶۰ثانیه‌ایThe 60-second answer

الگوی /appl./ به app و ppl نیاز دارد؛ اشتراک postingها ۸ نامزد می‌دهد و regex هفت مورد را تأیید می‌کند. سند ۱۰ هر دو trigram را در دو جای جدا دارد ولی خود الگو را ندارد؛ پس verify ضروری است. recall@k نتایج ANN را با exact top-k مقایسه می‌کند و p95 هزینهٔ تأخیر را نشان می‌دهد. دمو بردار مصنوعی یک‌بعدی دارد؛ از آن ظرفیت یا کیفیت embedding واقعی نتیجه نمی‌گیریم.
The pattern /appl./ requires app and ppl. Intersecting postings yields eight candidates; the regex verifies seven. Document 10 contains both trigrams separately but does not match the pattern, so verification is necessary. Recall@k compares ANN with exact top-k; p95 reports latency cost. The demo’s synthetic 1-D vectors do not establish production embedding quality or capacity.

صورت مسئله و فرض‌هاProblem and assumptions

  • یازده سند کوتاه آموزشی؛ دموی trigram این‌جا روی متن واقعی‌شان اجرا می‌شود.
  • دموی recall از بردارهای مصنوعی یک‌بعدی در ۴ خوشه استفاده می‌کند، نه embedding واقعی — متریک را یاد می‌دهد، نه recall پروداکشن را.
  • تأخیر p50/p95 روی ۲۰٬۰۰۰ اجرای تکراری درون‌پراسس همین ماشین است — harness آموزشی، نه بنچ‌مارک.
  • دمو تک‌مستأجر؛ چندمستأجری یک قاعدهٔ فیلتر اعلام‌شده است، نه ایندکس دوم در کد.
  • Eleven short teaching documents; the trigram demo runs on their real text here.
  • Recall demo uses synthetic 1-D vectors in 4 clusters, not real embeddings — it teaches the metric, not production recall.
  • Latency is p50/p95 over 20,000 repeated in-process runs on this machine — a teaching harness, not a benchmark.
  • Single-tenant demo; multi-tenancy is a stated filter rule, not a second index in the code.

چرا این روش و این عددها؟Why this method and these numbers?

trigram یعنی سه حرف پیوسته، پس literal چهارحرفی appl شامل app و ppl است. وجود هر دو در سند هنوز regex را ثابت نمی‌کند چون ممکن است در دو جای جدا باشند؛ مرحلهٔ verify به همین دلیل لازم است. recall@k سهم k نتیجهٔ دقیق است که ANN یافته: هشت نتیجهٔ مشترک از ده یعنی ۰٫۸. nprobe بیشتر candidate و کار بیشتری دارد و معمولاً recall را بالا می‌برد؛ p95 را اندازه می‌گیریم چون میانگین، queryهای کند را پنهان می‌کند. بردارهای یک‌بعدی دمو فقط برای فهم معیارند.

A trigram is three consecutive characters, so literal appl contains app and ppl. Both may occur separately in a document, hence regex verification. Recall@k is the fraction of exact top-k recovered: eight shared results out of ten gives 0.8. More probes inspect more candidates and usually improve recall at greater cost. Measure p95 because the mean hides slow queries. The demo’s 1-D vectors teach the metric only.

مدل ذهنیMental model

پیمایش کامل O(corpus) به‌ازای هر کوئری است؛ هر ایندکس میان‌بری است که هزینهٔ به‌روزرسانی می‌گیرد. نامزدها مظنون‌اند و تطابق‌ها محکوم: trigram پیشنهاد می‌دهد و regex اثبات می‌کند. جست‌وجوی برداری میان‌بر دومی اضافه می‌کند که افت recall اندازه‌گیری‌شده هم می‌گیرد.
A full scan is O(corpus) per query; every index is a shortcut that charges update cost. Candidates are suspects, matches are convictions: the trigram proposes, the regex proves. Vector search adds a second shortcut that also charges a measured recall loss.

حل گام‌به‌گامStep by step

  1. اول postings همهٔ سندها را یک‌بار به فهرست term تا postings بشکن. موقع کوئری فهرست‌های red و apples را اشتراک بگیر (AND بیتی روی شناسه‌های مرتب) تا سند ۰. ریزه‌کاری phrase و ranking بعداً می‌آید؛ اشتراک همان هستهٔ boolean دقیق است.
  2. اول نامزد، بعد اثبات literal چهارحرفی appl شامل app و ppl است. اشتراک postingهایشان هشت نامزد دارد؛ سند ۱۰ هر دو را جداگانه دارد و پس از اجرای regex رد می‌شود. هر دو نامزدسازی و verify لازم‌اند؛ خروجی را با full scan مقایسه می‌کنیم تا هیچ match واقعی گم نشود.
  3. جایگزین را نام ببر literal چهارحرفی appl شامل app و ppl است. اشتراک postingهایشان هشت نامزد دارد؛ سند ۱۰ هر دو را جداگانه دارد و پس از اجرای regex رد می‌شود. هر دو نامزدسازی و verify لازم‌اند؛ خروجی را با full scan مقایسه می‌کنیم تا هیچ match واقعی گم نشود.
  4. recall و تأخیر را بسنج IVF بردارها را به خوشه‌ها می‌شکند و هر کوئری nprobeتایشان را می‌گردد. ground truth همان top-k دقیق روی همهٔ بردارهاست؛ اجرای آموزشی میانگین recall@3 برابر ۰٫۹ و ۰٫۹۶۷ و ۱ برای nprobeهای ۱ و ۲ و ۴ می‌دهد و p95 تقریبی در همان harness زیر p95 دقیق است.
  5. ترکیبی rank بده، tenantها را ایزوله کن literal چهارحرفی appl شامل app و ppl است. اشتراک postingهایشان هشت نامزد دارد؛ سند ۱۰ هر دو را جداگانه دارد و پس از اجرای regex رد می‌شود. هر دو نامزدسازی و verify لازم‌اند؛ خروجی را با full scan مقایسه می‌کنیم تا هیچ match واقعی گم نشود.
  1. Postings first Tokenize all docs into term-to-postings lists once. At query time intersect the red and apples lists (bitwise AND over sorted ids) down to doc 0. Phrase and ranking refinements come later; the intersection is the exact boolean core.
  2. Candidates, then proof The literal appl contains app and ppl. Their postings intersect in eight candidates; document 10 has both separately and fails regex verification. Compare the result with a full regex scan to assert that no actual match was lost.
  3. Name the fallback The literal appl contains app and ppl. Their postings intersect in eight candidates; document 10 has both separately and fails regex verification. Compare the result with a full regex scan to assert that no actual match was lost.
  4. Measure recall and latency IVF splits vectors into clusters and each query probes nprobe of them. Ground truth is the exact top-k over all vectors; the teaching run gives mean recall@3 of 0.900, 0.967 and 1.000 for nprobe 1, 2 and 4, with approx p95 below exact p95 in the same harness.
  5. Rank hybrid, isolate tenants The literal appl contains app and ppl. Their postings intersect in eight candidates; document 10 has both separately and fails regex verification. Compare the result with a full regex scan to assert that no actual match was lost.

طراحی مشخصConcrete design

اجزا: tokenizer که ایندکس معکوس (term به postings مرتب) را تغذیه می‌کند؛ ایندکس trigram (زیررشتهٔ ثابت به مجموعهٔ سند)؛ تأییدکنندهٔ regex که java.util.regex را فقط روی نامزدها اجرا می‌کند؛ ایندکس برداری IVF (خوشه‌ها به‌علاوه nprobe)؛ یک ranker ترکیبی که امتیاز متنی و برداری را با وزن‌های اعلام‌شده می‌آمیزد؛ شرط tenant که زیر جست‌وجو رانده می‌شود؛ پنجرهٔ refresh که بعدش نوشتن‌ها دیده می‌شوند. مسیر خواندن red AND apples: اشتراک دو postings list. مسیر خواندن /appl./: استخراج trigram ثابت app، گرفتن نامزدها، تأیید تک‌تک و انداختن سند ۱۰.

Parts: a tokenizer feeding an inverted index (term to sorted postings); a trigram index (fixed substring to doc set); a regex verifier running java.util.regex only over candidates; an IVF vector index (clusters plus nprobe); one hybrid ranker merging lexical and vector scores with stated weights; a tenant predicate pushed below the search; a refresh window after which writes become visible. Read path for red AND apples: intersect the two postings lists. Read path for /appl./: extract fixed trigram app, fetch candidates, verify each, drop doc 10.

مثال محاسبه‌شدهWorked example

دموی جاوا همین محیط (JDK 27): red AND apples = [0]؛ هشت نامزد trigram و هفت تأییدشده با doc10 ... = true؛ recall روی بردارهای مصنوعی 0.9 و 0.967 و 1.0 برای nprobeهای ۱ و ۲ و ۴؛ تأخیر (۲۰٬۰۰۰ اجرا، همین ماشین) دقیق p50=333ns p95=583ns و تقریبی p50=250ns p95=375ns. تفسیر: یک probe یک‌دهم recall می‌ارزد و p95 پایین‌تر را می‌خرد — اندازه‌گیری آموزشی، نه بنچ‌مارک.

Java demo from this environment (JDK 27): red AND apples = [0]; trigram 'app' candidates = [0, 1, 3, 4, 6, 7, 9, 10]; regex /appl./ verified = [0, 1, 3, 4, 6, 7, 9]; doc10 candidate but not verified = true. Recall on synthetic vectors: nprobe=1 mean recall@3=0.9, nprobe=2 mean recall@3=0.967, nprobe=4 mean recall@3=1.0. Latency harness (20,000 runs, this machine): exact p50=333ns p95=583ns, approx p50=250ns p95=375ns. Interpretation: one probe costs a tenth of recall and buys the lower p95 — a teaching measurement, not a benchmark.

گام 1 از 2 · خط فکریStep 1 of 2 · reasoning thread · پیشنهاد از ایندکس، اثبات با regex، رتبهٔ ترکیبیPropose from indexes, prove by regex, rank hybrid · گذر داده خط retrievalretrieval-pipeline dataflowباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
خریداری appl به‌علاوه یک wildcard می‌زند. از کوئری در چپ شروع کن و سه شاخهٔ ایندکس را دنبال کن.
A shopper types appl plus a wildcard. Start at the query on the left and follow the three index branches.
  1. کوئری به سه ایندکس می‌رسد: معکوس برای کلمات boolean، trigram برای app ثابت، و IVF برای بردار.
  2. برخوردهای boolean و ۸ نامزد trigram در تأیید به هم می‌رسند: regex هفت‌تا را نگه می‌دارد و سند ۱۰ را می‌اندازد.
  3. برخوردهای متنی تأییدشده به‌علاوه top-k برداری در ranker ترکیبی با وزن‌های اعلام‌شده می‌آمیزند.
  1. The query fans out to three indexes: inverted for the boolean words, trigram for the fixed app, IVF for the vector.
  2. Boolean hits and 8 trigram candidates meet at verify: the regex keeps 7 and drops doc 10.
  3. Verified lexical hits plus the vector top-k merge in the hybrid ranker with stated weights.

نقطهٔ تصمیم: نقطهٔ فیلتر همان گره تأیید است: هیچ تأییدنشده‌ای به ranker نمی‌رسد.

رد خرابی: سند ۱۰ را دنبال کن: trigram app را دارد پس نامزد می‌شود، بعد regex ردش می‌کند و هیچ‌وقت به ranker نمی‌رسد. نامزدها مظنون‌اند؛ فقط سندهای تأییدشده نتیجه‌اند.

تضمین نشان‌داده‌شده: شکل ترتیب را تضمین می‌کند: پیشنهاد از ایندکس‌ها، اثبات با regex، رتبهٔ ترکیبی. وزن‌ها و probeها را تعیین نمی‌کند.

تضمین نشان‌نداده‌شده: درباره سازوکار ایزولاسیون tenant، پنجرهٔ تازگی یا recall پروداکشن چیزی نمی‌گوید.

Decision point: The verify node is the filter point: nothing unverified reaches the ranker.

Failure trace: Trace doc 10: it carries the app trigram so it becomes a candidate, then the regex rejects it and it never reaches the ranker. Candidates are suspects; only verified docs are results.

Guarantee shown: The figure guarantees the order: propose from indexes, prove by regex, rank hybrid. It does not set weights or probes.

Not shown: It says nothing about tenant isolation mechanics, freshness windows, or production recall.

خودآزماییSelf-check

چرا سند ۱۰ در نامزدها هست ولی در تطابق‌ها نیست؟

پاسخ: با trigram app نامزد می‌شود و بعد regex ردش می‌کند — و دقیقاً به همین دلیل نامزد با تطابق فرق دارد.

Why does doc 10 (happy app day) appear in candidates but not in matches?

Answer: It becomes a candidate via the app trigram, then the regex rejects it — which is exactly why candidates and matches are different things.

نمودار اسمبل نهاییFinal assembly diagram · اول نامزدهای trigram بعد تأیید regex، با پنل recall و تأخیر (اعداد آموزشی)Trigram candidates first, regex verification second, with recall/latency panel (teaching numbers) · خط نامزد-بعد-تأیید + پنل recallcandidates-then-verify pipeline + recall panelباز کردن در صفحه جدا ↗Open standalone ↗

دانلود SVG مستقلDownload standalone SVG

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
خط را چپ‌به‌راست بخوان و بعد به میله‌های recall و قواعد پایین برو: پیشنهاد، اثبات، اندازه‌گیری، ایزولاسیون.
Read the pipeline left to right, then drop to the recall bars and the bottom rules: propose, prove, measure, isolate.
  1. خط بالا: از regex به trigram app به ۸ نامزد به تأیید تک‌تک به ۷ تطابق — سند ۱۰ همان‌جا که پیکان می‌گوید می‌افتد.
  2. خط وسط: اشتراک postings برای red AND apples به سند ۰ — هستهٔ boolean دقیق کنار مسیر فازی regex.
  3. پنل راست: میله‌های nprobe با ۰٫۹ و ۰٫۹۶۷ و ۱ در برابر ground truth دقیق top-k و p95 کنار recall.
  4. خط پایین: امتیاز ترکیبی، p95 هر کوئری، اول فیلتر tenant و جایگزین بدون trigram.
  1. Top lane: regex to trigram app to 8 candidates to verify-each to 7 matches — doc 10 drops where the arrow says so.
  2. Middle lane: red AND apples intersects postings to doc 0 — the exact boolean core beside the fuzzy regex path.
  3. Right panel: nprobe bars 0.900, 0.967 and 1.000 against the exact top-k ground truth, with p95 beside recall.
  4. Bottom lane: hybrid score, per-query p95, tenant filter first, and the no-trigram fallback.

نقطهٔ تصمیم: دو نقطهٔ تصمیم: گام تأیید (نامزدها تطابق می‌شوند) و انتخاب nprobe (recall که با تأخیر خریده می‌شود).

رد خرابی: regex بدون trigram را دنبال کن: بدون trigram ثابت پیکان نامزدی نیست، پس کوئری به خط جایگزین پایین می‌افتد — پیمایش کامل یا اول فیلتر گزینشی.

تضمین نشان‌داده‌شده: شکل قرارداد دومرحله‌ای و تعریف recall را تضمین می‌کند. هیچ عدد پروداکشنی را قول نمی‌دهد.

تضمین نشان‌نداده‌شده: درباره کیفیت embedding، refresh ایندکس یا استقرار هر مستأجر چیزی نمی‌گوید.

Decision point: Two decision points: the verify step (candidates become matches) and the nprobe choice (recall bought with latency).

Failure trace: Trace the trigram-less regex: no fixed trigram means no candidate arrow, so the query falls to the bottom fallback line — full scan or selective filter first.

Guarantee shown: The figure guarantees the two-stage contract and the recall definition. It does not promise any production number.

Not shown: It says nothing about embedding quality, index refresh, or per-tenant deployment.

خودآزماییSelf-check

آیا الگوی /ap.l/ اولیهٔ فصل می‌توانست همین‌طور از ایندکس trigram استفاده کند؟

پاسخ: نه: /ap.l/ قدیمی هیچ زیررشته‌ای ندارد که هر تطابقی حتماً داشته باشد، پس ایندکس trigram نمی‌تواند برایش نامزد بدهد.

Could the original /ap.l/ pattern from the chapter use the trigram index the same way?

Answer: No: the old /ap.l/ has no substring that every match must contain, so the trigram index cannot propose candidates for it.

گزینه‌های جایگزینAlternatives

  • پیمایش کامل برای corpus کوچک یا regex بدون trigram ثابت: ایندکسی برای نگه‌داری نیست و ذاتاً دقیق است. هزینه: O(corpus) به‌ازای هر کوئری — برای صدها سند خوب و برای میلیون‌ها ناامیدکننده.
  • kNN دقیق وقتی مجموعهٔ بردارها آن‌قدر کوچک است که پیمایش کامل در بودجهٔ p95 جا می‌شود. هزینه: پیمایش خطی هر کوئری؛ روزی که بودجه را از دست بدهد IVF با recall اندازه‌گیری‌شده جایش را می‌گیرد.
  • Full scan for a tiny corpus or a regex with no fixed trigram: no index to maintain, exact by construction. Cost: O(corpus) per query — fine for hundreds of docs, hopeless for millions.
  • Exact kNN when the vector set is small enough that a full scan meets the p95 budget. Cost: linear scan per query; the day it misses the budget, IVF with measured recall replaces it.

سناریوهای خرابیFailure scenarios

  • ایندکس stale سند حذف‌شده را می‌دهد: نوشتن نشسته ولی پنجرهٔ refresh نگذشته. قرارداد: هر نتیجه نسخهٔ ایندکس را دارد و حذف‌ها در refresh بعدی آشکارا می‌برند — هرگز خاموش.
  • نشت tenant از ایندکس مشترک: یک شرط جامانده سندهای مستأجر دیگر را برمی‌گرداند. قاعده: فیلتر tenant زیر جست‌وجو رانده می‌شود (یا ایندکس جدا هر مستأجر) و یک تست assert می‌کند سند بیگانه هیچ‌وقت بالا نمی‌آید.
  • nprobe کم بی‌سروصدا بهترین تطابق را می‌اندازد: recall بدون خطا می‌افتد. چاره: پایش recall@k در برابر ground truth دقیق به‌ازای هر گروه کوئری و پیج وقتی drift کرد.
  • Stale index serves a deleted doc: the write landed but the refresh window has not passed. Contract: every result carries the index version, and deletes win visibly at the next refresh — never silently.
  • Tenant leak through a shared index: one missing predicate returns another tenant's docs. Rule: the tenant filter is pushed below the search (or a per-tenant index), and a test asserts foreign docs never surface.
  • nprobe too low silently drops the best match: recall falls with no error. Mitigation: monitor recall@k against the exact ground truth per query cohort and page when it drifts.

کد قابل اجراRunnable code

اجراشده و تأییدشده در همین محیطExecuted and verified in this environment

این کد آموزشی و ساده‌سازی‌شده است؛ فقط برای آشنایی با ایده. کد واقعی پروداکشن ملاحظات بسیار بیشتری دارد و هیچ‌کس این‌شکلی کد پروداکشن نمی‌زند.Simplified teaching code for building intuition only — not production code; nobody ships production code like this.

import java.util.*;
import java.util.regex.Pattern;

public class RetrievalToy {
    static Set<String> trigrams(String s) {
        s = "  " + s.toLowerCase() + " ";
        Set<String> out = new HashSet<>();
        for (int i = 0; i + 3 <= s.length(); i++) out.add(s.substring(i, i + 3));
        return out;
    }

    public static void main(String[] a) {
        String[] docs = {"red apples fresh", "green apples tart", "red berries sweet",
                "apple pie recipe", "red apple juice", "blueberries fresh",
                "applause loud crowd", "crab apples wild", "red wine aged",
                "pineapple chunks", "happy app and ppl day"};
        Map<String, Set<Integer>> inv = new HashMap<>();
        Map<String, Set<Integer>> tri = new HashMap<>();
        for (int d = 0; d < docs.length; d++) {
            for (String w : docs[d].split(" ")) inv.computeIfAbsent(w, k -> new HashSet<>()).add(d);
            for (String t : trigrams(docs[d])) tri.computeIfAbsent(t, k -> new HashSet<>()).add(d);
        }
        Set<Integer> and = new TreeSet<>(inv.get("red"));
        and.retainAll(inv.get("apples"));
        System.out.println("red AND apples = " + and);
        Pattern pat = Pattern.compile("appl.");
        Set<Integer> cands = new TreeSet<>(tri.getOrDefault("app", Set.of()));
        cands.retainAll(tri.getOrDefault("ppl", Set.of()));
        List<Integer> verified = new ArrayList<>();
        for (int d : cands) if (pat.matcher(docs[d]).find()) verified.add(d);
        System.out.println("trigram app AND ppl candidates = " + cands);
        System.out.println("regex /appl./ verified = " + verified);
        System.out.println("doc10 candidate but not verified = "
                + (cands.contains(10) && !verified.contains(10)));
        List<Integer> fullScan = new ArrayList<>();
        for (int d=0; d<docs.length; d++) if (pat.matcher(docs[d]).find()) fullScan.add(d);
        if (!verified.equals(fullScan) || !cands.contains(10) || verified.contains(10))
            throw new AssertionError("candidate filtering lost a match or skipped verification");
        int[] cluster = new int[20]; // IVF recall on synthetic 1-D vectors
        for (int p = 0; p < 20; p++) cluster[p] = p / 5;
        for (int nprobe : new int[]{1, 2, 4}) {
            double sum = 0;
            for (int q = 0; q < 20; q++) sum += recallAtK(q, 3, nprobe, cluster);
            System.out.println("nprobe=" + nprobe + " mean recall@3="
                    + Math.round(sum / 20 * 1000) / 1000.0);
        }
        long[] exact = new long[20000], approx = new long[20000];
        for (int i = 0; i < 20000; i++) {
            int q = i % 20;
            long t = System.nanoTime();
            topK(q, 3, null, cluster);
            exact[i] = System.nanoTime() - t;
            t = System.nanoTime();
            topK(q, 3, Set.of(cluster[q]), cluster);
            approx[i] = System.nanoTime() - t;
        }
        Arrays.sort(exact); Arrays.sort(approx);
        System.out.println("exact p50=" + exact[10000] + "ns p95=" + exact[19000] + "ns");
        System.out.println("approx p50=" + approx[10000] + "ns p95=" + approx[19000] + "ns");
    }

    static double recallAtK(int q, int k, int nprobe, int[] cluster) {
        Set<Integer> exact = new HashSet<>(topK(q, k, null, cluster));
        List<Integer> order = new ArrayList<>(List.of(0, 1, 2, 3));
        order.sort(Comparator.comparingInt(c -> Math.abs(c - cluster[q])));
        Set<Integer> probed = new HashSet<>();
        for (int i = 0; i < nprobe; i++) probed.add(order.get(i));
        List<Integer> found = topK(q, k, probed, cluster);
        return found.stream().filter(exact::contains).count() / (double) k;
    }

    static List<Integer> topK(int q, int k, Set<Integer> probed, int[] cluster) {
        List<Integer> s = new ArrayList<>();
        for (int p = 0; p < 20; p++)
            if (probed == null || probed.contains(cluster[p])) s.add(p);
        s.sort(Comparator.comparingInt(p -> Math.abs(p - q)));
        return s.subList(0, Math.min(k, s.size()));
    }
}

اجرا: javac RetrievalToy.java && java RetrievalToy — فقط کتابخانه استاندارد، چند ثانیه. تعداد سند یا بعد را بالا ببر تا هزینهٔ recall را حس کنی.

خروجی مورد انتظار: خروجی واقعی همین محیط (JDK 27): اشتراک [0]؛ هشت نامزد و هفت تأییدشده با افتادن سند ۱۰؛ recall@3 برابر ۰٫۹ و ۰٫۹۶۷ و ۱؛ p50/p95 دقیق ۳۳۳/۵۸۳ نانوثانیه و تقریبی ۲۵۰/۳۷۵ نانوثانیه (همین ماشین، harness آموزشی).

Run: javac RetrievalToy.java && java RetrievalToy — stdlib only, seconds. Raise the doc count or dims to feel the recall cost.

Expected output: red AND apples = [0]; candidates [0,1,3,4,6,7,9,10]; verified [0,1,3,4,6,7,9]; doc10 drops; recall@3 0.9 / 0.967 / 1.0 for nprobe 1/2/4; exact p50=333ns p95=583ns; approx p50=250ns p95=375ns (this machine, teaching harness).

آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria

راستی‌آزمایی: اشتراک postings برابر AND brute-force روی سندها؛ مجموعهٔ تأییدشده برابر regex brute-force روی کل corpus؛ recall@k روی همان بردارها نسبت به top-k دقیق؛ p95 از اجراهای زمان‌دار تکراری با N اعلام‌شده. تست tenant: سند مستأجر بیگانهٔ حاوی termهای کوئری بکار و assert کن هیچ‌وقت بالا نمی‌آید.
Verify: postings intersection equals a brute-force AND over the docs; the verified set equals a brute-force regex over the whole corpus; recall@k is computed against the exact top-k on the same vectors; p95 comes from repeated timed runs with stated N. Tenant test: seed a foreign-tenant doc containing the query terms and assert it never surfaces.

پاسخ سؤال‌های پیگیریFollow-up answers

پیگیری (سینیور): اگر regex هیچ trigram ثابت نداشته باشد یا filter بسیار محدود باشد چه مسیر جایگزینی داری؟Follow-up (Senior): What fallback handles a regex without any fixed trigram, or a highly selective filter?

دو جایگزین به ترتیب: اگر فیلتر tenant یا دستهٔ گزینشی هست اول اجرایش کن و فقط همان مجموعهٔ کوچک‌تر را تأیید کن؛ وگرنه به پیمایش کامل corpus برگرد. regex بدون trigram ذاتاً O(corpus) است — پاسخ صادقانه هزینه را اعلام می‌کند نه این‌که وانمود کند ایندکس کمک می‌کند.

Two fallbacks, in order: if a selective tenant or category filter exists, run it first and verify only that smaller set; otherwise fall back to a full scan of the corpus. A trigram-less regex is O(corpus) by nature — the honest answer states the cost instead of pretending the index helps.

پیگیری (سینیور): ground truth عدد recall@k تو دقیقاً چیست؟Follow-up (Senior): What exactly is the ground truth for your recall@k number?

همان top-k دقیق با brute-force روی همان بردارها. recall@k کسری از آن k است که مسیر تقریبی هم برمی‌گرداند. همان بردارها، همان k و همان فاصله — وگرنه کسر بی‌معناست.

The exact top-k computed by brute force over the same vectors. recall@k is the fraction of those k that the approximate path also returns. Same vectors, same k, same distance — otherwise the fraction is meaningless.

پیگیری (استف): وزن‌های ترکیبی را چطور می‌چینی و با رشد حجم کوئری tenantها را چطور ایزوله می‌کنی؟Follow-up (Staff): How do you set hybrid weights and isolate tenants as query volume grows?

با وزن‌های اندازه‌گیری‌شده روی مجموعهٔ eval برچسب‌دار شروع کن (دقت متنی در برابر recall برداری به‌ازای هر گروه کوئری) و بعد با کف recall هر گروه و بودجهٔ p95 نگهش دار. tenantها تا وقتی cardinality کوچک است با pushdown فیلتر جدا می‌شوند؛ مستأجری که حجم یا سخت‌گیری ACLش غالب است ایندکس خودش را می‌گیرد، چون ایندکس مشترک به‌علاوه شرط فراموش‌شده یعنی نشت.

Start with measured weights on a labelled eval set (lexical precision versus vector recall per query cohort), then guard with per-cohort recall floors and a p95 budget. Tenants split by filter pushdown while the cardinality is small; a tenant whose volume or ACL strictness dominates gets its own index, because a shared index plus a forgotten predicate is a leak.

پیگیری (استف): چرا tenant filter باید در مسیر retrieval باشد؟Follow-up (Staff): Why enforce tenant filtering on the retrieval path?

tenant_id از هویت احراز‌شده می‌آید، نه فقط body درخواست. فیلتر همهٔ مسیرهای lexical، vector، cache و fetch سند باید همین مرز را رعایت کند؛ cache key شامل tenant است. post-filter ساده ممکن است recall را کاهش دهد و رتبه‌بندی/metadata بدون مجوز را پردازش کند. تست با دو tenant دارای متن و vector یکسان باید نبود سند و metadata دیگری را assert کند؛ تغییر ACL نیز version یا invalidation می‌خواهد.

Derive tenant identity from authentication, not just request body. Apply the same boundary to lexical/vector retrieval, caches and document fetches; cache keys include tenant. Simple post-filtering can reduce recall and process unauthorized ranking metadata. Test two tenants with identical text/vectors for cross-tenant document and metadata exclusion. ACL changes need versioning or invalidation.

اشتباهات رایجCommon mistakes

  • تطابق خواندن برخورد trigram. trigram شرط لازم است نه کافی — سند ۱۰ ثابت می‌کند گام تأیید باربر است.
  • گفتن recall بدون ground truth. recall@k کسری نسبت به top-k دقیق است؛ بدون آن عدد تزئین است.
  • رها کردن nprobe روی ۱ چون سریع است. سرعت بدون recall اندازه‌گیری‌شده کاهش خاموش کیفیت است — ۰٫۹ دموی ما می‌گوید یک probe چه می‌ارزد.
  • Calling a trigram hit a match. The trigram is a necessary condition, not a sufficient one — doc 10 proves the verify step is load-bearing.
  • Quoting recall without a ground truth. recall@k is a fraction against the exact top-k; without it the number is decoration.
  • Leaving nprobe at 1 because it is fast. Speed without measured recall is a silent quality cut — the demo's 0.900 says what one probe costs.

منابع:Sources: Atlassian · engineering interview handbook · Pinecone · production checklist