11 تمرین ۱ — موتور کلید-مقدار log-structuredExercise 1 — a log-structured key-value engine
بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level
پاسخ ۶۰ثانیهایThe 60-second answer
صورت مسئله و فرضها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
حل گامبهگامStep by step
- هر نوشتن دو جا مینشیند نوشتنِ durability همان WAL append + fsync است؛ بهروزرسانی حافظه نوشتنِ visibility است. به کلاینت فقط بعد از fsync جواب بده، هرگز فقط بعد از بهروزرسانی RAM.
- flush حافظه را فایل میکند وقتی حافظه از چند مگابایت گذشت، مرتب روی دیسک بهصورت یک SSTable تغییرناپذیر نوشته میشود — جدیدترین سگمنت — و یک حافظهٔ تازه کار را تحویل میگیرد. هر خواندن اول حافظه، بعد سگمنتها از جدید به قدیم را میگردد.
- compaction ادغام و تمیز میکند compaction سگمنتها را مثل mergesort کنار هم میخواند: برای کلید تکراری فقط تازهترین نسخه میماند و tombstone به merge میگوید نسخههای قدیمی را دور بریزد. ادغام size-tiered به نفع بازده نوشتن است، leveled به نفع خواندن و فضا.
- کرش بین flush و manifest فایل flushشدهای که هنوز در manifest ثبت نشده موقع restart نادیده گرفته میشود؛ WAL سالمسنجیشده حافظه را بازپخش میکند و دمِ خرابش دور ریخته میشود. هیچ حالت نصفهای به خواندنها سرویس نمیدهد.
- سه amplification را بشمار write amplification (بایت دیسک بهازای بایت کاربر: در compaction بالا، در B-tree بازنویسی صفحه ~۱ تا ۲ برابر)، read amplification (چند سگمنت در برابر عمق ۳ تا ۴ صفحه)، space amplification (قدیم بهعلاوه جدید تا ادغام، در برابر صفحات بهعلاوه WAL). نسبتهای آموزشیاند، نه بنچمارک. حکم: نوشتنمحور LSM، خواندن قابلپیشبینی و rangeها B-tree.
- 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.
- 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.
- 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.
- 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.
- 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).
دانلود نمودار مستقلDownload standalone diagram
- put کلاینت به دو append میرسد: WAL (append و fsync، نوشتن بادوام) و حافظه (نوشتن قابلمشاهده).
- پیکان چینچین برعکس میرود، از WAL به حافظه: بازپخش بعد از کرش تنها کار دیگر لاگ است.
- حافظه به سگمنتهای SSTable میریزد (تغییرناپذیر، جدید اول)؛ compaction ادغامشان میکند و تازهترین مقدار را نگه میدارد و tombstoneها کلیدهای حذفشده را میاندازند.
- Client put fans out to two appends: WAL (append + fsync, the durable write) and Memtable (the visible write).
- The dashed arrow runs backwards, WAL to memtable: replay after crash is the log's only other job.
- 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.
دانلود نمودار مستقلDownload standalone diagram
- نوشتنهای عادی: append و fsync به WAL، بهروزرسانی حافظه، ack فقط بعد از fsync.
- flush و بعد کرش: فایل SSTable روی دیسک نشست ولی رکورد manifestش با پراسس رفت.
- بازیابی restart: خواندن manifest، بازپخش WAL سالمسنجیشده، نادیده گرفتن فایل ثبتنشده، سرو دوبارهٔ خواندنها.
- Steady writes: append + fsync to the WAL, update the memtable, ack only after the fsync.
- Flush, then crash: the SSTable file lands on disk but its manifest record is lost with the process.
- 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.
دانلود SVG مستقلDownload standalone SVG
- پنل آبی، چپبهراست: WAL (append و fsync)، حافظهٔ مرتب، SSTableهای تغییرناپذیر، Compact (merge و tomb) — کل مسیر نوشتن هیچجا درجا بازنویسی نمیکند.
- پنل زرد: B-tree به همان put با یافتن برگ، بازنویسی همان صفحه و fsync لاگ خودش جواب میدهد — خواندن قابلپیشبینی، نوشتن تصادفی گرانتر.
- پنل پایین، سه ستون: write amp (پرداخت LSM هنگام compaction)، read amp (سگمنتها در برابر عمق صفحه)، space amp (قدیم بهعلاوه جدید در برابر صفحات بهعلاوه WAL).
- دو خط پایین: قاعدهٔ کرش (فایل ثبتنشده نادیده، WAL بازپخش) و قاعدهٔ tombstone (حذف زودهنگام مقدار قدیمی را زنده میکند).
- Blue panel, left to right: WAL (append + fsync), Memtable (sorted RAM), SSTables (immutable), Compact (merge + tomb) — the whole write path never rewrites in place.
- 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.
- 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).
- 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=
آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria
پاسخ سؤالهای پیگیری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
بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level
پاسخ ۶۰ثانیهایThe 60-second answer
صورت مسئله و فرضها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
حل گامبهگامStep by step
- اول ستاره را بکش fact_sales(day, pid, qty, amount) بهازای هر فروش یک سطر دارد؛ dim_date(day, weekday, year) و dim_product(pid, category) کوچک میمانند و با کلید join میشوند. factها باریک میمانند تا اسکن ارزان بماند.
- SQL را بنویس factها را به هر دو بعد join کن، سال گذشته را فیلتر کن، بر اساس دسته و روز هفته group کن و مبلغ را جمع بزن. فهرست SELECT فقط ستونهای لازم گزارش را نام میبرد — همان فهرست قرارداد pruning است.
- کلیدها معلوم میکنند چه رد میشود پارتیشن سال و ماه تا شرط سال گذشته بلوکهای قدیمی را بازنکرده دور بریزد؛ مرتبسازی هر فایل بر اساس روز بعد دسته تا دستههای مساوی کنار هم برای فشردهسازی run-length بنشینند و بازههای روز هفته prune شوند. دمو فقط خطهای روز، شناسه و مبلغ را میخواند.
- با cardinality فشرده کن، skew را بپا پنج دسته در bitmap جا میشود: یک بیت بهازای هر سطر و مقدار، AND بیتمپ دسته و روز هفته، بعد خواندن مبلغ فقط برای سطرهای باقیمانده. شناسههای پرمقدار dictionary بهعلاوه run-length میگیرند — bitmap خام منفجر میشود. محصول داغ یک بلوک مرتب را شلوغ میکند و prune بازهای را ضعیف میکند؛ ایزوله یا دوباره مرتبش کن.
- تاریخ هر دو برچسب را نگه میدارد جابهجایی دسته در ژوئن نباید ژانویه را بازنویسی کند: هر دو برچسب را بهصورت بعدِ کندتغییر نگه دار و دستهٔ زمان فروش را گزارش بده، مگر صورت سؤال صریح بازنویسی بخواهد — و آنوقت روی گزارش بنویس.
- 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.
- 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.
- 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.
- 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.
- 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.
دانلود نمودار مستقلDownload standalone diagram
- فایلهای fact به prune پارتیشن میرسند: شرط سال فقط بلوکهای سال گذشته را نگه میدارد و قدیمیها بازنمیشوند.
- بعدها موقع فیلتر حل میشوند: بیتهای دسته از dim_product و بیتهای روز هفته از dim_date که در فیلتر بیتی AND میشوند.
- فقط سطرهای باقیمانده به group-by میرسند: جمع دستهبهروزهفته فقط روی خط مبلغ.
- Fact files fan into partition prune: the year predicate keeps only last year's blocks, older ones stay unopened.
- Dimensions resolve at filter time: category bits from dim_product, weekday bits from dim_date, ANDed in the bitmap filter.
- 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.
دانلود SVG مستقلDownload standalone SVG
- ردیف بالا: پنج خط، سهتای سبز READ (روز، شناسه، مبلغ) و دو خاکستری SKIP — prune ستونی پیش از باز شدن هرچیز.
- ردیف دوم: بلوکهای سال — ۲۰۲۵ سبز و ۲۰۲۴ خاکستری و بازنکرده — prune پارتیشن با شرط سال.
- پنل راست: بیتهای دسته AND بیتهای روز هفته سطرهای باقیمانده را میدهند؛ فقط مبلغ همانها خوانده میشود.
- پنل پایین: skew بلوک مرتب را شلوغ میکند، شناسهها dictionary میگیرند و جابهجایی دسته هر دو برچسب را نگه میدارد.
- Top row: five lanes, three green READ (day, pid, amount) and two grey SKIP — column pruning before anything opens.
- Second row: year blocks — 2025 green, 2024 grey and unopened — partition pruning by the year predicate.
- Right panel: category bits AND weekday bits give the surviving rows; only their amounts are fetched.
- 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
category = B AND weekday = 2 است. اندازه بگیر: نسبت بلوکهای خواندهشده به کل بلوکها (اثر prune) و تعداد fetchهای خط مبلغ. بعد از هر بازنویسی پارتیشن، هر دو را دوباره اجرا کن.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
بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level
پاسخ ۶۰ثانیهایThe 60-second answer
صورت مسئله و فرضها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
حل گامبهگامStep by step
- اول postings همهٔ سندها را یکبار به فهرست term تا postings بشکن. موقع کوئری فهرستهای red و apples را اشتراک بگیر (AND بیتی روی شناسههای مرتب) تا سند ۰. ریزهکاری phrase و ranking بعداً میآید؛ اشتراک همان هستهٔ boolean دقیق است.
- اول نامزد، بعد اثبات literal چهارحرفی appl شامل app و ppl است. اشتراک postingهایشان هشت نامزد دارد؛ سند ۱۰ هر دو را جداگانه دارد و پس از اجرای regex رد میشود. هر دو نامزدسازی و verify لازماند؛ خروجی را با full scan مقایسه میکنیم تا هیچ match واقعی گم نشود.
- جایگزین را نام ببر literal چهارحرفی appl شامل app و ppl است. اشتراک postingهایشان هشت نامزد دارد؛ سند ۱۰ هر دو را جداگانه دارد و پس از اجرای regex رد میشود. هر دو نامزدسازی و verify لازماند؛ خروجی را با full scan مقایسه میکنیم تا هیچ match واقعی گم نشود.
- recall و تأخیر را بسنج IVF بردارها را به خوشهها میشکند و هر کوئری nprobeتایشان را میگردد. ground truth همان top-k دقیق روی همهٔ بردارهاست؛ اجرای آموزشی میانگین recall@3 برابر ۰٫۹ و ۰٫۹۶۷ و ۱ برای nprobeهای ۱ و ۲ و ۴ میدهد و p95 تقریبی در همان harness زیر p95 دقیق است.
- ترکیبی rank بده، tenantها را ایزوله کن literal چهارحرفی appl شامل app و ppl است. اشتراک postingهایشان هشت نامزد دارد؛ سند ۱۰ هر دو را جداگانه دارد و پس از اجرای regex رد میشود. هر دو نامزدسازی و verify لازماند؛ خروجی را با full scan مقایسه میکنیم تا هیچ match واقعی گم نشود.
- 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.
- 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.
- 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.
- 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.
- 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.
دانلود نمودار مستقلDownload standalone diagram
- کوئری به سه ایندکس میرسد: معکوس برای کلمات boolean، trigram برای app ثابت، و IVF برای بردار.
- برخوردهای boolean و ۸ نامزد trigram در تأیید به هم میرسند: regex هفتتا را نگه میدارد و سند ۱۰ را میاندازد.
- برخوردهای متنی تأییدشده بهعلاوه top-k برداری در ranker ترکیبی با وزنهای اعلامشده میآمیزند.
- The query fans out to three indexes: inverted for the boolean words, trigram for the fixed app, IVF for the vector.
- Boolean hits and 8 trigram candidates meet at verify: the regex keeps 7 and drops doc 10.
- 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.
دانلود SVG مستقلDownload standalone SVG
- خط بالا: از regex به trigram app به ۸ نامزد به تأیید تکتک به ۷ تطابق — سند ۱۰ همانجا که پیکان میگوید میافتد.
- خط وسط: اشتراک postings برای red AND apples به سند ۰ — هستهٔ boolean دقیق کنار مسیر فازی regex.
- پنل راست: میلههای nprobe با ۰٫۹ و ۰٫۹۶۷ و ۱ در برابر ground truth دقیق top-k و p95 کنار recall.
- خط پایین: امتیاز ترکیبی، p95 هر کوئری، اول فیلتر tenant و جایگزین بدون trigram.
- Top lane: regex to trigram app to 8 candidates to verify-each to 7 matches — doc 10 drops where the arrow says so.
- Middle lane: red AND apples intersects postings to doc 0 — the exact boolean core beside the fuzzy regex path.
- Right panel: nprobe bars 0.900, 0.967 and 1.000 against the exact top-k ground truth, with p95 beside recall.
- 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
پاسخ سؤالهای پیگیری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