DDIA·مطالعهSTUDY

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

حل آموزشی تمرین‌های فصل 3Chapter 3 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· متناظر با بخش تمرین‌های فصل 3Companion to Chapter 3 exercises

11 تمرین ۱ — مدل‌سازی کاتالوگ فروشگاهExercise 1 — modelling a shop catalog

L2 · SeniorL2 · Seniorاولویت اصلیCore priority40 دقیقه بحث40 min discussion

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

سؤال مصاحبهInterview question
کاتالوگ یک فروشگاه (محصول، برند، دسته‌بندی چندگانه، نظر کاربران) را هم رابطه‌ای و هم سندی مدل کن. سه کوئری پرتکرار (صفحه محصول، صفحه دسته، نظرهای یک محصول) را با pagination، ایندکس‌ها، یکتایی نظر هر کاربر و اثر تغییر نام یا لوگوی برند مشخص کن، و انتخابت را با بار کاری توجیه کن.
Model a shop catalog (product, brand, multi-category, user reviews) in both relational and document form. Pin down three hot queries (product page, category page, one product's reviews) with pagination, indexes, one-review-per-user uniqueness and the cost of a brand rename, then justify your pick from the workload.

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

رابطه‌ای سند مرجع است: products با FK به brands، دسته‌بندی چندگانه با جدول واسط، و نظرها در جدول جدا با کلید یکتای (product_id, user_id). نظرها را هرگز در سند محصول embed نکن چون رشدشان بی‌کران است. صفحه‌بندی با کلید (keyset) نه offset؛ تغییر برند فقط یک سطر است. سند فقط وقتی می‌برد که صفحه محصول تقریباً همه بار باشد و گزارش میان‌محصولی نداشته باشیم.
Relational is the record: products with an FK to brands, multi-category via a join table, reviews in their own table under a (product_id, user_id) unique key. Never embed reviews in the product document — they grow without bound. Paginate by key (keyset), not offset; a brand rename touches one row. Documents win only when the product page is nearly all the load and cross-product reports don't exist.

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

  • مقیاس آموزشی: ۲۰۰هزار محصول، ۵هزار برند، میانگین ۴۰ نظر به‌ازای محصول — فرض آموزشی، نه بنچمارک.
  • بار آموزشی: خواندن سنگین (۸۰۰ صفحه محصول در ثانیه) و نوشتن سبک (چند نظر در ثانیه).
  • نظرها بی‌کران رشد می‌کنند؛ تغییر برند نادر است ولی باید اتمیک و فوری دیده شود.
  • Teaching scale: 200K products, 5K brands, ~40 reviews per product on average — a teaching assumption, not a benchmark.
  • Teaching load: read-heavy (800 product pages/s) with light review writes (a few/s).
  • Reviews grow without bound; brand changes are rare but must apply atomically and read-your-write.

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

مدل را از query و رشد انتخاب می‌کنیم: ۲۰۰هزار محصول × ۴۰ review حدود هشت میلیون review می‌دهد؛ embed نامحدود review سند محصول را پیوسته بزرگ می‌کند. brand کم‌تغییر می‌تواند مرجع جدا داشته باشد تا یک تغییر، یک حقیقت را اصلاح کند. ۸۰۰ خواندن/ثانیه ورودی آموزشی است و با trace جایگزین می‌شود؛ با زیادشدن خواندن می‌توان projection ساخت، ولی هزینهٔ invalidation و تأخیر آن باید در انتخاب حساب شود.

Choose a model from queries and growth: 200k products × 40 reviews gives eight million reviews; unbounded embedding continually expands product documents. A separate brand authority makes one update change one fact. The 800 reads/s load is an input to replace with traces. More reads may justify projections, with invalidation cost and lag counted explicitly.

مدل ذهنیMental model

اول کوئری‌ها را بنویس، بعد شکل داده را. هر رابطه‌ای که جواب «چندتا؟» آن «نامشخص» است (نظرها، دسته‌ها) در سند جا نمی‌شود و ارجاع می‌خواهد. چیزهایی که با هم خوانده و با هم عوض می‌شوند کنار هم می‌مانند.
Write the queries first, then the shape. Any relationship whose 'how many?' answer is 'unbounded' (reviews, categories) does not fit inside a document; it wants a reference. Things read together and changed together stay together.

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

  1. سه کوئری اول صفحه محصول (مشخصات + برند + دسته‌ها)، صفحه دسته (فهرست محصولات)، و نظرهای یک محصول. هر تصمیم مدل را با همین سه‌تا می‌سنجیم؛ بقیه کوئری‌ها مشتق‌اند.
  2. اسکیمای رابطه‌ای brands(id, name UNIQUE, logo_url, logo_v)، products(id, brand_id FK, name, price)، categories و جدول واسط product_categories(product_id, category_id) برای چندبه‌چند، و reviews(id, product_id FK, user_id, stars) با UNIQUE(product_id, user_id). ایندکس‌ها: reviews(product_id, id)، product_categories(category_id, product_id)، products(brand_id, id).
  3. سند و مرز آن سند محصول می‌تواند عکس کوچک برند و نام دسته‌ها را (کران‌دار و کوچک) embed کند، ولی نظرها فقط ارجاع + کوئری دوم می‌خواهند. آرایه میلیونی یعنی هر نظر تازه یک بازنویسی O(n) و سند داغ.
  4. صفحه‌بندی و یکتایی فهرست‌ها با keyset روی کلید یکتا: WHERE (id > last) ORDER BY id LIMIT n. offset زیر درج هم‌زمان سطر گم یا تکراری می‌دهد. یکتایی را قید پایگاه (UNIQUE + upsert) تضمین می‌کند، نه چک برنامه.
  5. تصمیم و تغییر برند پیش‌فرض رابطه‌ای است: تغییر نام یا لوگو یک UPDATE اتمیک روی یک سطر است و خواننده‌ها تازه‌ترین را join/hydrate می‌کنند. سند فقط به‌عنوان read model مشتق از همین جدول‌ها، وقتی صفحه محصول چیره است.
  1. The three queries first Product page (details + brand + categories), category listing, and one product's reviews. Every modelling call is judged against these three; the rest are derived.
  2. The relational schema brands(id, name UNIQUE, logo_url, logo_v), products(id, brand_id FK, name, price), categories plus a product_categories(product_id, category_id) join table for many-to-many, and reviews(id, product_id FK, user_id, stars) with UNIQUE(product_id, user_id). Indexes: reviews(product_id, id), product_categories(category_id, product_id), products(brand_id, id).
  3. The document and its boundary A product document may embed a brand thumbnail and category names (bounded, tiny), but reviews need a reference plus a second query. A million-long array turns every new review into an O(n) rewrite and a hot document.
  4. Pagination and uniqueness List with keyset on a unique key: WHERE (id > last) ORDER BY id LIMIT n. Offset drops or duplicates rows under concurrent inserts. Uniqueness is a database constraint (UNIQUE + upsert), never an app-side check.
  5. Decision and brand change Default to relational: a brand rename is one atomic UPDATE on one row, and readers join/hydrate the freshest value. Documents only as a derived read model over the same tables, when the product page dominates.

طراحی مشخصConcrete design

جدول‌ها و کلیدها همان گام ۲. مسیر خواندن: صفحه محصول = یک JOIN محصول+برند + یک lookup دسته‌ها + یک کوئری نظر با keyset. مسیر نوشتن: ثبت نظر = INSERT … ON CONFLICT(product_id, user_id) DO UPDATE؛ تغییر برند = یک UPDATE اتمیک روی brands با bump نسخه logo_v.

Tables and keys are step 2. Read path: product page = one product+brand JOIN + one category lookup + one keyset review query. Write path: review insert = INSERT … ON CONFLICT(product_id, user_id) DO UPDATE; brand change = one atomic UPDATE on brands bumping logo_v.

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

با داده آموزشی اسکریپت: صفحه محصول «Desk lamp A» با یک JOIN برمی‌گردد؛ دسته lamps با keyset ردیف‌های ۱ و ۲؛ دو نظر اول محصول ۱. درج تکراری (1,101) با خطای یکتایی رد شد. تغییر برند = ۱ سطر، در برابر بازنویسی همه سندهای محصول.

With the script's teaching rows: product page 'Desk lamp A' returns via one JOIN; category lamps keysets rows 1 and 2; first two reviews of product 1. Duplicate insert (1,101) fails the unique check. Brand change = 1 row versus rewriting every product document.

گام 1 از 2 · خط فکریStep 1 of 2 · reasoning thread · درخت تصمیم مدل: از سه کوئری تا سه برگ (اعداد آموزشی)Model decision tree: three queries to three leaves (teaching numbers) · گردش‌کارworkflowباز کردن در صفحه جدا ↗Open standalone ↗

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

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
لانچ فروشگاه نزدیک است و سه کوئری داغ باید پیش از نوشتن اسکیما جوابشان معلوم باشد. درخت را چپ‌به‌راست بخوان: اول سؤال‌ها، بعد دو انشعاب، آخر سه برگ.
The shop launches soon and three hot queries must be answered before writing any schema. Read the tree left to right: questions first, two forks, then three leaves.
  1. گره شروع 3 hot queries: صفحه محصول، صفحه دسته و نظرها — هر تصمیم مدل با همین سه‌تا سنجیده می‌شود.
  2. انشعاب اول Bounded growth?: رشد بی‌کران (نظرها) به شاخه رابطه‌ای می‌رود — Tables + UNIQUE و بعد Keyset + upsert با مکان‌نمای id > last؛ چیزهای کران‌دار و کوچک (بندانگشتی برند) به شاخه سندی — Tiny embeds.
  3. انشعاب دوم Atomic brand?: تغییر برند با 1 atomic UPDATE به برگ Relational first می‌رسد؛ در شاخه سندی همان تغییر N-doc rewrite با پنجره مخلوط می‌شود و فقط به Doc read model می‌رسد.
  4. برگ سوم Tables + view: وقتی صفحه محصول داغ است ولی گزارش هم لازم است — با derive when hot نمای نسخه‌دار از روی جدول‌ها مشتق می‌شود.
  1. The entry node of 3 hot queries: product page, category page and reviews — every modelling call is judged against these three.
  2. First fork on bounded growth: unbounded growth (reviews) takes the relational branch — tables plus UNIQUE, then keyset plus upsert with an id > last cursor; bounded tiny things (brand thumbnails) take the document branch — tiny embeds.
  3. Second fork on atomic brand change: a brand change lands on the relational leaf with one atomic UPDATE; on the document branch the same change becomes an N-document rewrite with a mixed window, reaching only a document read model.
  4. Third leaf of tables plus view: when the product page is hot but reports are still needed — a versioned view is derived off the tables when hot.

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

رد خرابی: اگر نظرهای بی‌کران را توکار کنی، هر نظر یک بازنویسی O(n) و یک سند داغ می‌سازد؛ اگر یکتایی را در برنامه چک کنی، زیر مسابقه تکراری رد می‌شود.

تضمین نشان‌داده‌شده: شکل ترتیب استدلال و سه برگ را نشان می‌دهد؛ اندازه واقعی پروداکشن را ثابت نمی‌کند — عددها آموزشی‌اند.

تضمین نشان‌نداده‌شده: درباره sharding نظرها، رتبه‌بندی جست‌وجو و کش صفحه اول چیزی نمی‌گوید.

Decision point: The decision point is the two forks: embed what is bounded and tiny, reference what is unbounded; mixed load stays relational, a dominant page earns a derived view.

Failure trace: Embedding unbounded reviews turns every review into an O(n) rewrite of a hot document; checking uniqueness in app code lets duplicates through under races.

Guarantee shown: The figure shows the reasoning order and the three leaves; it does not fix production sizing — the numbers are teaching numbers.

Not shown: It says nothing about review sharding, search ranking or first-page caching.

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

صفحه محصول ۹۹٪ بار است ولی گزارش فروش میان‌محصولی هم لازم است؛ کدام برگ و چرا؟

پاسخ: Tables + view: جدول‌ها مرجع می‌مانند و نمای مشتق نسخه‌دار صفحه داغ را سرو می‌کند؛ سند خالص گزارش را می‌کشد و رابطه‌ای خالص صفحه داغ را گران می‌کند.

The product page is 99% of load but cross-product sales reports are still needed; which leaf, and why?

Answer: Tables plus view: the tables stay authoritative while the versioned derived view serves the hot page; a pure document kills reporting and pure relational prices the hot page.

نمودار اسمبل نهاییFinal assembly diagram · کاتالوگ رابطه‌ای در برابر سندی و مرز توکار کردن (اعداد آموزشی)Relational vs document catalog and the embedding boundary (teaching numbers) · نمودار کلاس/ERUML class/ER diagramباز کردن در صفحه جدا ↗Open standalone ↗

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

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
هفته لانچ فروشگاه است: صفحه محصول، صفحه دسته و نظرها باید هم‌زمان درست کار کنند. از پنل چپ شروع کن.
It is the shop's launch week: product page, category page and reviews must all work at once. Start from the left panel.
  1. پنل چپ: پنج جدول با کلیدها — واسط چندبه‌چند و قید یکتای نظر.
  2. پنل راست: درخت سند — برند و دسته‌ها داخل، نظرها بیرون با خط ارجاع.
  3. نوار پایین: مکان‌نمای keyset که زیر درج هم‌زمان پایدار می‌ماند.
  4. سطر تصمیم: تغییر برند — یک سطر در برابر بازنویسی همه سندها.
  1. Left panel: five tables with keys — the many-to-many join table and the review uniqueness constraint.
  2. Right panel: the document tree — brand and categories inside, reviews outside behind a reference line.
  3. Bottom strip: the keyset cursor that stays stable under concurrent inserts.
  4. Decision row: brand change — one row versus rewriting every document.

نقطهٔ تصمیم: نقطه تصمیم مرز کران است: کران‌دار و کوچک را توکار کن، بی‌کران را ارجاع بده.

رد خرابی: رد خرابی: دو ثبت هم‌زمان یک کاربر — خط یکتایی دومی را می‌گیرد و upsert نجاتش می‌دهد.

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

تضمین نشان‌نداده‌شده: درباره sharding، رتبه‌بندی جست‌وجو و کش صفحه اول چیزی نمی‌گوید.

Decision point: The decision point is the boundedness boundary: embed what is bounded and tiny, reference what is unbounded.

Failure trace: Failure trace: two concurrent submits by one user — the unique line catches the second, upsert rescues it.

Guarantee shown: The figure guarantees keys and indexes, not production sizing.

Not shown: It says nothing about sharding, search ranking or first-page caching.

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

محصولی به ۲ میلیون نظر می‌رسد. چرا توکار کردن جواب نیست و جایگزین چیست؟

پاسخ: چون هر نظر بازنویسی O(n) و سند داغ می‌سازد. جایگزین: جدول جدا + keyset روی (product_id, id) و کش صفحه اول.

A product reaches 2M reviews. Why is embedding wrong, and what replaces it?

Answer: Because every review becomes an O(n) rewrite of a hot document. Replacement: separate table + keyset on (product_id, id) with a cached first page.

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

  • سند-اول با باکت نظر (هر باکت ≤۱۰۰ نظر، باکت‌های سرریز با کلید محصول): وقتی ۹۹٪ بار صفحه محصول است و گزارش میان‌محصولی نداریم. هزینه: منطق باکت + سازگاری نسخه برند کپی‌شده.
  • Document-first with review buckets (≤100 reviews each, overflow buckets keyed by product): when 99% of load is the product page with no cross-product reports. Cost: bucket logic + versioning the copied brand data.

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

  • مسابقه ثبت نظر: دو درخواست هم‌زمان یک کاربر → هر دو INSERT؛ یکی با UNIQUE می‌خورد و با upsert بازیابی می‌شود. بدون قید، نظر تکراری می‌ماند.
  • ساخت read model سندی که وسط کار می‌میرد: خواننده‌ها به جدول‌ها برمی‌گردند و بازسازی باید idempotent باشد، وگرنه سندهای نصفه می‌مانند.
  • Review race: two concurrent inserts by one user → both INSERT; one hits UNIQUE and recovers via upsert. Without the constraint the duplicate persists.
  • A document read-model build that dies halfway: readers fall back to the tables and the rebuild must be idempotent, or half-built documents linger.

کد قابل اجرا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.*;

// Teaching demo: catalog over in-memory rows — keyset pagination + UNIQUE guard.
// Mirrors the relational schema: brands, products, product_categories, reviews
// with UNIQUE(product_id, user_id). DDL lives in prose; this is runnable logic.
public class CatalogDemo {
    record Brand(int id, String name, String logoUrl, int logoV) {}
    record Product(int id, int brandId, String name, long price) {}
    record Review(int id, int productId, int userId, int stars) {}

    public static void main(String[] a) {
        List<Brand> brands = List.of(
            new Brand(1, "Acme", "acme-v3.png", 3),
            new Brand(2, "Globex", "globex-v1.png", 1));
        List<Product> products = List.of(
            new Product(1, 1, "Desk lamp A", 2900),
            new Product(2, 1, "Desk lamp B", 3400),
            new Product(3, 2, "Chair C", 9900),
            new Product(4, 2, "Floor lamp D", 4500));
        // product -> categories (join table rows)
        Map<Integer, List<String>> prodCats = Map.of(
            1, List.of("lamps", "desk"), 2, List.of("lamps"), 3, List.of("chairs"), 4, List.of("lamps"));
        List<Review> reviews = new ArrayList<>(List.of(
            new Review(1, 1, 101, 5), new Review(2, 1, 102, 4),
            new Review(3, 1, 103, 5), new Review(4, 2, 101, 3)));

        // 1) product page: one JOIN-equivalent (product + brand)
        Product p = products.get(0);
        Brand b = brands.stream().filter(x -> x.id() == p.brandId()).findFirst().orElseThrow();
        System.out.println("product page = " + p.name() + " / " + b.name()
            + " / cats=" + prodCats.get(p.id()));

        // 2) category page via keyset: WHERE (id > last) ORDER BY id LIMIT 2
        Map<String, List<Integer>> catIndex = new HashMap<>();
        for (Product x : products)
            for (String c : prodCats.getOrDefault(x.id(), List.of()))
                catIndex.computeIfAbsent(c, k -> new ArrayList<>()).add(x.id());
        catIndex.values().forEach(Collections::sort);
        List<Integer> page1 = keyset(catIndex.get("lamps"), 0, 2);
        List<Integer> page2 = keyset(catIndex.get("lamps"), page1.get(page1.size() - 1), 2);
        System.out.println("category lamps page1 = " + page1);
        System.out.println("category lamps page2 = " + page2);

        // 3) reviews of product 1, first two via keyset on (product_id, id)
        List<Review> r1 = reviews.stream()
            .filter(r -> r.productId() == 1).sorted(Comparator.comparingInt(Review::id))
            .limit(2).toList();
        System.out.println("reviews p1 first2 = " + r1.stream()
            .map(r -> "u" + r.userId() + ":" + r.stars()).toList());

        // 4) UNIQUE(product_id, user_id) guard: dup (1,101) rejected
        Set<String> uniq = new HashSet<>();
        for (Review r : reviews) uniq.add(r.productId() + ":" + r.userId());
        Review dup = new Review(9, 1, 101, 5);
        String key = dup.productId() + ":" + dup.userId();
        if (!uniq.add(key)) {
            System.out.println("UNIQUE holds: dup (1,101) rejected");
        } else {
            System.out.println("UNIQUE BROKEN: dup accepted");
        }

        // 5) brand change cost: 1 row vs rewriting every product doc
        System.out.println("brand rename cost = 1 row (relational) vs "
            + products.stream().filter(x -> x.brandId() == 1).count()
            + " doc rewrites (embedded)");
        System.out.println("OK");
    }

    static List<Integer> keyset(List<Integer> sorted, int last, int n) {
        List<Integer> out = new ArrayList<>();
        for (int id : sorted) if (id > last && out.size() < n) out.add(id);
        return out;
    }
}

اجرا: javac CatalogDemo.java && java CatalogDemo — فقط استاندارد جاوا، فوری.

خروجی مورد انتظار: صفحه Desk lamp A/Acme؛ lamps صفحه۱ [1,2] و صفحه۲ [4]؛ دو نظر اول u101:5 و u102:4؛ تکراری (1,101) رد شد؛ تغییر برند ۱ سطر در برابر ۲ بازنویسی سند؛ OK. (خروجی واقعی همین محیط)

Run: javac CatalogDemo.java && java CatalogDemo — stdlib Java only, instant.

Expected output: Desk lamp A / Acme page; lamps page1 [1,2], page2 [4]; first two reviews u101:5, u102:4; dup (1,101) rejected; brand change 1 row vs 2 doc rewrites; OK. (actual output of this environment)

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

بررسی‌ها: درج موازی یک کاربر فقط یک سطر می‌سازد؛ صفحه‌های keyset مجزا و مرتب‌اند؛ EXPLAIN استفاده از ایندکس‌ها را نشان می‌دهد؛ تغییر برند در خواندن بعدی دیده می‌شود.
Checks: concurrent inserts by one user leave one row; keyset pages are disjoint and ordered; EXPLAIN shows index use; a brand change is visible on the next read.

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

پیگیری (سینیور): اگر محصول در چند دسته باشد و نظرات میلیون‌تایی شوند مدل چگونه تغییر می‌کند؟Follow-up (Senior): How does the model change when a product has multiple categories and millions of reviews?

دسته‌ها از قبل با جدول واسط حل‌اند. نظرها با keyset روی (product_id, id) صفحه‌بندی می‌شوند و برای مرتب «برترین» ایندکس (product_id, stars, id) اضافه می‌شود. محصول میلیونی را با shard نظر بر اساس محصول + کش صفحه اول سرو کن. embed منتفی است: هر نظر یک بازنویسی O(n).

Categories are already solved by the join table. Reviews page by keyset on (product_id, id), adding a (product_id, stars, id) index for 'top' order. Serve a million-review product with review sharding by product plus a cached first page. Embedding is out: every review becomes an O(n) rewrite.

پیگیری (سینیور): مرتب‌سازی «برترین نظرها» با <span class="ltr">keyset</span> چه مکان‌نمایی می‌خواهد؟Follow-up (Senior): What cursor does 'top reviews' keyset pagination need?

مکان‌نمای مرکب (stars, id) با شرط stars < last_stars OR (stars = last_stars AND id > last_id) روی ایندکس مرکب. offset زیر درج تازه سطر گم می‌کند.

A composite (stars, id) cursor with stars < last_stars OR (stars = last_stars AND id > last_id) over a composite index. Offset loses rows under fresh inserts.

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

  • گفتن «SQL یا NoSQL» بدون بار کاری: انتخاب بدون کوئری و کاردینالیتی حدس است.
  • توکار کردن نظرهای بی‌کران در سند: سند داغ و بازنویسی O(n) به‌ازای هر نظر.
  • یکتایی در کد برنامه به‌جای قید پایگاه: زیر مسابقه، تکراری‌ها رد نمی‌شوند.
  • Answering 'SQL or NoSQL' with no workload: a choice without queries and cardinality is a guess.
  • Embedding unbounded reviews in a document: a hot document with an O(n) rewrite per review.
  • Uniqueness in app code instead of a database constraint: duplicates slip through under races.

منابع:Sources: Amazon · SDE III interview preparation · Atlassian · engineering interview handbook

22 تمرین ۲ — تصمیم نرمال‌سازیExercise 2 — the normalization call

L1 · پایهL1 · Foundationsاولویت اصلیCore priority20 دقیقه بحث20 min discussion

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

سؤال مصاحبهInterview question
لوگوی یک برند روی صد هزار محصول دیده می‌شود. سه طرح ارجاع (reference)، توکار کردن (embedding) و read model مشتق را مقایسه کن: هزینه خواندن، fan-out تغییر، داده stale، نسخه‌بندی و خرابی وسط به‌روزرسانی دسته‌ای را بیان کن و انتخابت را با حد تازگی قابل قبول توجیه کن.
One brand logo shows on 100,000 products. Compare reference, embedding and a derived read model: read cost, change fan-out, stale data, versioning and mid-batch failure — then justify your pick from an acceptable freshness bound.

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

مرجع حقیقت جدول brands با نسخه logo_v است. ارجاع: خواندن کمی گران‌تر ولی تغییر فقط یک سطر. توکار: خواندن رایگان ولی هر تغییر ۱۰۰هزار نوشتن با پنجره ناسازگاری ده‌ها ثانیه‌ای — منتفی. read model نسخه‌دار از outbox: خواندن سریع با تازگی محدود (مثلاً ۵ ثانیه آموزشی) و بازیابی از مکان‌نمای نسخه.
The brands table with a logo_v version is the authority. Reference: slightly pricier reads, one-row changes. Embedding: free reads but 100K writes per change with a tens-of-seconds inconsistency window — ruled out. A versioned read model off the outbox: fast reads inside a freshness bound (e.g. 5 teaching seconds) with resume from a version cursor.

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

  • اعداد آموزشی: ۱۰۰هزار محصول یک برند، ۸۰۰ خواندن صفحه در ثانیه، هزینه lookup برند ۱٫۵ms، نرخ backfill دوهزار در ثانیه.
  • تغییر لوگو نادر (چند بار در سال) ولی باید در حد تازگی اعلام‌شده همه‌جا دیده شود.
  • خواندن‌ها at-least-once نیستند؛ نوشتن‌های مدل مشتق باید idempotent باشند.
  • Teaching numbers: 100K products of one brand, 800 page reads/s, 1.5 ms brand lookup, 2K/s backfill rate.
  • Logo changes are rare (a few per year) but must converge everywhere inside the stated freshness bound.
  • Derived-model writes repeat, so the applier must be idempotent.

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

برای تغییر logo روی ۱۰۰هزار محصول با backfill دوهزار/ثانیه، حد پایین زمان ۱۰۰۰۰۰÷۲۰۰۰=۵۰ ثانیه است؛ retry و محدودیت DB آن را بیشتر می‌کنند. هزینهٔ lookup با ۸۰۰ خواندن/ثانیه × ۱٫۵ms برابر ۱۲۰۰ms کار در ثانیه است، نه ۱٫۲ ثانیه تأخیر هر درخواست. denormalization این هزینه را با هزینهٔ write fan-out عوض می‌کند؛ اگر logo باید فوراً همه‌جا عوض شود، بازهٔ ۵۰ثانیه‌ای پذیرفتنی نیست.

Backfilling 100k products at 2k/s takes at least 50 seconds, before retries and database limits. At 800 reads/s × 1.5ms, lookup work is 1200ms per second, not 1.2 seconds added to each request. Denormalization exchanges this read cost for write fan-out. If logo updates must appear everywhere immediately, that 50-second convergence window is unacceptable.

مدل ذهنیMental model

کپی یعنی بدهی: هر کپی یک مسیر به‌روزرسانی می‌خواهد. سؤال «خواندن سریع‌تر به چه قیمتی؟» را با «چند نوشتن به‌ازای هر تغییر و چه پنجره ناسازگاری؟» جواب بده.
A copy is a debt: every copy needs an update path. Answer 'faster reads at what price?' with 'how many writes per change, and how wide an inconsistency window?'

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

  1. سه گزینه روی میز ارجاع (فقط brand_id، لوگو هنگام خواندن hydrate)، توکار (logo_url در هر محصول)، و نمای مشتق (کپی ناهمگام نسخه‌دار از outbox برند).
  2. هزینه خواندن ارجاع: ۸۰۰ خواندن × ۱٫۵ms ≈ ۱٫۲ هسته-ثانیه بر ثانیه مالیات آموزشی. توکار و نما: صفر — ولی قیمتشان در نوشتن است.
  3. fan-out نوشتن توکار: هر تغییر = ۱۰۰هزار نوشتن ≈ ۵۰ ثانیه پنجره مخلوط قدیمی/جدید. ارجاع: ۱ سطر اتمیک. نما: همان ۱۰۰هزار ولی ناهمگام، با نسخه و قابل‌مشاهده‌بودن lag.
  4. خرابی وسط دسته اگر سازنده نما وسط کار بمیرد، از مکان‌نمای (logo_v, product_id) ادامه می‌دهد؛ فقط ننوشته‌ها retry می‌شوند و اعمال‌کننده idempotent است. در توکار هم‌زمان، خواننده‌ها مخلوط می‌بینند و نقطه ادامه مشخص نیست.
  5. تصمیم با حد تازگی اگر تازگی «فوری» است: ارجاع. اگر ۵ ثانیه آموزشی قابل‌قبول است: نمای نسخه‌دار. توکار هم‌زمان تقریباً هیچ‌وقت: گران‌ترین نوشتن با بدترین پنجره.
  1. Three options on the table Reference (brand_id only, logo hydrated at read), embedding (logo_url in every product), and a derived view (versioned async copy off the brand outbox).
  2. Read cost Reference: 800 reads × 1.5 ms ≈ 1.2 core-s per second of teaching tax. Embedding and the view: zero — but their price is on the write side.
  3. Write fan-out Embedding: each change = 100K writes ≈ a 50 s mixed old/new window. Reference: 1 atomic row. View: the same 100K but async, versioned, with observable lag.
  4. Mid-batch failure If the view builder dies halfway, it resumes from the (logo_v, product_id) cursor; only unwritten rows retry, and the applier is idempotent. With sync embedding, readers see a mix and there is no defined resume point.
  5. Decide from the freshness bound If freshness must be immediate: reference. If 5 teaching seconds are fine: the versioned view. Sync embedding almost never: the priciest writes with the worst window.

طراحی مشخصConcrete design

مرجع: brands(id, logo_url, logo_v)؛ تغییر = UPDATE اتمیک + رویداد outbox با logo_v تازه. محصول فقط brand_id دارد. سازنده نما رویداد را می‌خواند و هر محصول را با شرط logo_v < new_v به‌روز می‌کند (اعمال idempotent). مسیر خواندن: نما با برچسب نسخه؛ اگر lag از بودجه گذشت، fallback به جدول مرجع.

Authority: brands(id, logo_url, logo_v); a change = one atomic UPDATE + an outbox event carrying the new logo_v. Products keep only brand_id. The view builder consumes the event and updates each product guarded by logo_v < new_v (idempotent apply). Read path: the view with a version label; past the lag budget, fallback to the authority table.

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

اعداد آموزشی اسکریپت: مالیات ارجاع ۱٫۲ هسته-ثانیه بر ثانیه؛ fan-out توکار ۱۰۰هزار نوشتن با پنجره ≈۵۰ ثانیه؛ بودجه lag پنج‌ثانیه‌ای هر جارو ۱۰هزار محصول را پوشش می‌دهد و بعد هشدار می‌دهد.

Teaching numbers from the script: reference tax 1.2 core-s/s; embedding fan-out 100K writes with a ≈50 s window; a 5 s lag budget covers 10K products per sweep before alerting.

گام 1 از 2 · خط فکریStep 1 of 2 · reasoning thread · انتشار نسخه‌دار لوگو: مرجع تا نما با مکان‌نما و بودجه lag (اعداد آموزشی)Versioned logo propagation: authority to views with cursor and lag budget (teaching numbers) · جریان دادهdataflowباز کردن در صفحه جدا ↗Open standalone ↗

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

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
برند لوگویش را عوض کرده و صد هزار صفحه محصول (عدد آموزشی) باید همگرا شوند. جریان را چپ‌به‌راست بخوان: مرجع، انتشار، پخش نسخه‌دار، نماها و خواندن.
The brand changed its logo and 100K product pages (a teaching number) must converge. Read left to right: authority, publish, versioned propagation, views and read.
  1. Logo change با 1 atomic UPDATE روی brands table می‌نشیند — مرجع با logo_v؛ محصول‌ها فقط brand_id دارند.
  2. event + logo_v به Brand outbox می‌رود و View builder مصرف می‌کند؛ Resume cursor با (logo_v, product_id) نقطه ادامه بعد از مرگ سازنده است.
  3. اعمال با نگهبان guarded apply است: فقط اگر نسخه تازه‌تر باشد — رویداد تکراری بی‌اثر می‌شود و نسخه قدیمی دیررس تازه را له نمی‌کند.
  4. Product read view با برچسب نسخه خوانده می‌شود؛ Stale window با بودجه آموزشی ۵ ثانیه دیده و هشدار داده می‌شود و over budget به Authority fallback می‌افتد که سطر تازه مرجع را می‌خواند.
  1. The logo change lands on the brands table with one atomic UPDATE — the authority with logo_v; products keep only brand_id.
  2. The event plus logo_v goes to the brand outbox and the view builder consumes it; the resume cursor of (logo_v, product_id) is the continue point after a builder death.
  3. Applying is guarded: only when the version is newer — duplicate events become no-ops and a late old version never crushes the fresh one.
  4. The product read view is read with its version tag; the stale window is observed against the 5 s teaching budget, and over budget falls back to the authority fallback, which reads the fresh authority row.

نقطهٔ تصمیم: نقطه تصمیم بودجه تازگی است: «فوری» یعنی ارجاع و ۵ ثانیه آموزشی یعنی نمای نسخه‌دار؛ توکار هم‌زمان با پنجره کور ۵۰ ثانیه‌ای تقریباً هیچ‌وقت.

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

تضمین نشان‌داده‌شده: شکل بده‌بستان خواندن/نوشتن و قابل‌ادامه بودن را نشان می‌دهد، نه ظرفیت واقعی را — عددها آموزشی‌اند.

تضمین نشان‌نداده‌شده: درباره سهمیه چند برند هم‌زمان و پاک‌سازی کش‌های بیرونی چیزی نمی‌گوید.

Decision point: The decision point is the freshness budget: immediate means reference, 5 teaching seconds mean a versioned view; sync embedding with its blind 50 s window almost never.

Failure trace: Builder death mid-run: only the cursor's unwritten range retries; restarting opens a fresh window. Without the version guard, a late old event crushes the fresh version.

Guarantee shown: The figure shows the read/write trade and resumability, not real capacity — the numbers are teaching numbers.

Not shown: It says nothing about multi-brand quotas or purging outside caches.

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

چرا نما با lag پنج‌ثانیه‌ای (آموزشی) از توکار هم‌زمان بهتر است؟

پاسخ: چون تغییر مرجع ۱ سطر اتمیک است، انتشار نسخه‌دار و قابل‌ادامه است و lag دیده و هشدار داده می‌شود — نه پنجره کور ۵۰ ثانیه‌ای.

Why is a view with 5 s of (teaching) lag better than sync embedding?

Answer: Because the authority change is one atomic row, propagation is versioned and resumable, and lag is observed and alerted — not a blind 50 s window.

نمودار اسمبل نهاییFinal assembly diagram · ارجاع در برابر توکار در برابر نمای مشتق (اعداد آموزشی)Reference vs embedding vs derived view (teaching numbers) · پنل هزینه خواندن/نوشتن + انتشارread/write cost + propagation panelباز کردن در صفحه جدا ↗Open standalone ↗

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

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
برند لوگویش را عوض کرده و صد هزار صفحه محصول باید همگرا شوند. پنل چپ را بخوان.
The brand changed its logo and 100K product pages must converge. Read the left panel.
  1. پنل چپ: سه ستون — ارجاع (خواندن +۱، نوشتن ۱)، توکار (خواندن ۰، نوشتن ۱۰۰هزار)، نما (خواندن ۰ + lag محدود).
  2. نوار میانی: پنجره ۵۰ ثانیه‌ای مخلوط قدیمی/جدید در روش توکار.
  3. پنل راست: انتشار نسخه‌دار از outbox با مکان‌نمای ادامه و شرط idempotent.
  4. سطر بودجه: lag پنج‌ثانیه‌ای و fallback به مرجع.
  1. Left panel: three columns — reference (+1 read, 1 write), embedding (0 reads, 100K writes), view (0 reads + bounded lag).
  2. Middle strip: the 50 s mixed old/new window under embedding.
  3. Right panel: versioned propagation off the outbox with a resume cursor and an idempotent guard.
  4. Budget row: the 5 s lag budget with fallback to the authority.

نقطهٔ تصمیم: نقطه تصمیم بودجه تازگی است: عدد lag قابل‌قبول، روش را انتخاب می‌کند.

رد خرابی: رد خرابی: مرگ سازنده وسط کار — مکان‌نما می‌گوید کجاییم و فقط بقیه نوشته می‌شوند.

تضمین نشان‌داده‌شده: شکل بده‌بستان خواندن/نوشتن را تضمین می‌کند، نه ظرفیت واقعی را.

تضمین نشان‌نداده‌شده: درباره سهمیه چند برند هم‌زمان و پاک‌سازی CDN چیزی نمی‌گوید.

Decision point: The decision point is the freshness budget: the acceptable lag number picks the method.

Failure trace: Failure trace: builder death mid-run — the cursor says where we are, only the rest gets written.

Guarantee shown: The figure guarantees the read/write trade, not real capacity.

Not shown: It says nothing about multi-brand quotas or CDN purging.

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

چرا نمای مشتق با lag پنج‌ثانیه‌ای از توکار هم‌زمان بهتر است؟

پاسخ: چون تغییر مرجع ۱ سطر اتمیک است، انتشار نسخه‌دار و قابل‌ادامه است، و lag دیده و هشدار داده می‌شود — نه پنجره کور ۵۰ ثانیه‌ای.

Why is a derived view with 5 s lag better than sync embedding?

Answer: Because the authority change is 1 atomic row, propagation is versioned and resumable, and lag is observed and alerted — not a blind 50 s window.

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

  • URL نسخه‌دار تغییرناپذیر (logo-v4.png): تغییر = فایل تازه + یک سطر؛ کش‌ها با TTL خودشان می‌میرند. وقتی لوگو در CDNها و کلاینت‌ها پخش است بهتر است؛ هزینه: پاک‌سازی نسخه‌های قدیمی.
  • Immutable versioned URLs (logo-v4.png): a change = one new file + one row; caches die by TTL. Better when the logo spreads across CDNs and clients; cost: old-version cleanup.

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

  • مرگ سازنده وسط backfill: نمای نصفه با logo_v مخلوط؛ بازیابی = ادامه از مکان‌نما، نه شروع دوباره. تا همگرایی، برچسب «در حال به‌روزرسانی» یا fallback.
  • رویداد تکراری outbox: شرط نسخه (logo_v < new_v) اعمال دوم را بی‌اثر می‌کند؛ بدون آن، نسخه قدیمیِ دیررس نسخه تازه را له می‌کند.
  • Builder death mid-backfill: a half-built view with mixed logo_v; recovery = resume from the cursor, not restart. Until convergence, an 'updating' label or fallback.
  • Duplicate outbox events: the version guard (logo_v < new_v) makes the second apply a no-op; without it, a late old version crushes the fresh one.

کد قابل اجرا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.*;

// Teaching math: reference vs embedding vs derived read model for one brand
// logo shown on N products. Teaching numbers only (amozeshi), not benchmarks.
public class LogoMath {
    public static void main(String[] a) {
        double n = 100_000;      // products of one brand
        double rps = 800;        // product-page reads/s
        double lookupMs = 1.5;   // brand lookup cost
        double backfill = 2_000; // derived-view apply rate/s
        double budgetS = 5;      // acceptable freshness bound (teaching)

        double readTax = rps * lookupMs / 1000; // core-s per s
        double fanout = n;                       // sync-embed writes per change
        double mixedWindow = n / backfill;       // seconds of old/new mix
        long coverage = (long) (backfill * budgetS);

        System.out.println("read tax core-s/s (reference) = " + readTax);
        System.out.println("embed fan-out writes           = " + (long) fanout);
        System.out.println("mixed window s (embed)         = " + mixedWindow);
        System.out.println("lag-budget coverage (5 s)      = " + coverage);
        System.out.println("reference change cost          = 1 row");
        System.out.println("derived view                   = "
            + (long) fanout + " async versioned writes, resumable from cursor");

        String verdict;
        if (mixedWindow > budgetS * 4) {
            verdict = "VERDICT: sync embedding ruled out (50 s mix >> 5 s budget); "
                + "reference wins when freshness must be immediate, "
                + "versioned read model wins when 5 s lag is fine.";
        } else {
            verdict = "VERDICT: window fits the budget; embedding acceptable.";
        }
        System.out.println(verdict);
        System.out.println("OK");
    }
}

اجرا: javac LogoMath.java && java LogoMath — فقط استاندارد جاوا، فوری. عددها را با بار خودت عوض کن.

خروجی مورد انتظار: مالیات ۱٫۲، fan-out صد هزار، پنجره ۵۰ ثانیه، پوشش ۱۰هزار + حکم: توکار هم‌زمان منتفی، ارجاع برای تازگی فوری و نمای نسخه‌دار برای ۵ ثانیه؛ OK. (خروجی واقعی همین محیط)

Run: javac LogoMath.java && java LogoMath — stdlib Java only, instant. Swap in your own load numbers.

Expected output: Tax 1.2, fan-out 100K, window 50 s, coverage 10K + verdict: sync embedding ruled out, reference for immediate freshness, versioned view for 5 s; OK. (actual output of this environment)

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

اندازه بگیر: lag نما در برابر بودجه (متریک + هشدار)؛ یکنواختی نسخه‌ها (هیچ محصولی از logo_v مرجع جلوتر نیست)؛ تست آشوب: کشتن سازنده وسط کار و ادامه از مکان‌نما.
Measure: view lag against the budget (metric + alert); version monotonicity (no product ahead of the authority logo_v); chaos check: kill the builder mid-run and resume from the cursor.

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

پیگیری (سینیور): با شکست به‌روزرسانی در نیمهٔ محصولات چه چیزی را retry می‌کنی؟Follow-up (Senior): What do you retry when a logo update fails halfway through the products?

فقط بازه ننوشته مکان‌نما را، با همان logo_v — نه کل دسته را. اعمال idempotent است پس تکرار امن است؛ شروع دوباره ۵۰ ثانیه پنجره تازه می‌سازد.

Only the cursor's unwritten range, with the same logo_v — never the whole batch. The apply is idempotent so repeating is safe; restarting buys a fresh 50 s window.

پیگیری (استف): ده برند هم‌زمان لوگو عوض کنند چه می‌شود؟Follow-up (Staff): What if ten brands change logos at once?

سازنده‌ها باید سهمیه‌بندی شوند (صف به‌ازای برند + سقف نوشتن) تا یک برند بقیه را گرسنه نکند؛ lag به‌ازای هر برند جداگانه هشدار می‌دهد و نمای هر برند نسخه خودش را برچسب می‌زند.

Builders need quotas (one queue per brand + a write cap) so one brand can't starve the rest; lag alerts per brand, and each brand's view carries its own version label.

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

  • کپی بدون مسیر به‌روزرسانی: «سریع می‌خوانیم» بدون جواب fan-out و پنجره ناسازگاری.
  • یکی دانستن ناهمگام با ناسازگارِ همیشگی: نما با نسخه و بودجه lag همگرا می‌شود؛ بی‌قاعده رهایش نکن.
  • retry کور کل دسته: نوشته‌شده‌ها را دوباره می‌نویسد و پنجره را باز می‌کند؛ از مکان‌نما ادامه بده.
  • Copying with no update path: 'fast reads' with no answer for fan-out and the inconsistency window.
  • Confusing async with inconsistent-forever: a versioned view with a lag budget converges; don't leave it rule-less.
  • Blindly retrying the whole batch: rewrites finished rows and reopens the window; resume from the cursor.

منابع:Sources: Atlassian · engineering interview handbook · Debezium · outbox event router

33 تمرین ۳ — event sourcing برای رزرو سینماExercise 3 — event sourcing for cinema booking

L2 · SeniorL2 · Seniorاولویت اصلیCore priority40 دقیقه بحث40 min discussion

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

سؤال مصاحبهInterview question
رزرو سینما را با یک event log فقط‌افزودنی و سه projection (وضعیت صندلی، گزارش فروش، چاپ بلیت) طراحی کن. دو درخواست هم‌زمان برای یک صندلی، انقضای hold، لغو و اصلاح رزرو اشتباه را توضیح بده؛ انحصار صندلی را در مرز نوشتن اعمال کن و بازپخش (replay) را از ارسال دوباره بلیت جدا کن.
Design cinema reservations with an append-only event log and three projections (seat status, sales report, ticket printing). Handle two concurrent requests for one seat, hold expiry, cancellation and fixing a mistaken booking; enforce seat exclusivity at the write boundary and separate replay from ticket resend.

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

لاگ فقط رویداد معتبرِ گذشته (SeatHeld, SeatConfirmed, HoldExpired, BookingCanceled, BookingCorrected) نگه می‌دارد؛ فرمان‌ها (HoldSeat) جدا از رویدادها هستند. انحصار صندلی با conditional append روی نسخه صندلی در مرز نوشتن اعمال می‌شود، پس از دو مسابقه فقط یکی می‌برد. اصلاح = رویداد جبرانی تازه، نه ویرایش. بازپخش نما را می‌سازد ولی بلیت را دوباره نمی‌فرستد: ارسال با کلید idempotency نگهبانی می‌شود.
The log keeps only valid past-tense events (SeatHeld, SeatConfirmed, HoldExpired, BookingCanceled, BookingCorrected); commands (HoldSeat) are separate from events. Seat exclusivity is enforced by a conditional append on the seat version at the write boundary, so of two racers exactly one wins. Fixes are fresh compensating events, never edits. Replay rebuilds views but never resends tickets: sending is guarded by an idempotency key.

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

  • اعداد آموزشی: یک سالن ۳۰۰ صندلی، اوج فروش ۵۰ رزرو در ثانیه، مهلت hold ده دقیقه.
  • لاگ مرتب و فقط‌افزودنی است؛ هر نما رویدادها را با همان ترتیب می‌بیند.
  • چاپ بلیت و SMS اثر بیرونی‌اند؛ اثر یک‌باره فقط با idempotency مقصد یا پروتکل reconciliation قابل دفاع است.
  • Teaching numbers: one 300-seat hall, 50 bookings/s at peak, a 10-minute hold TTL.
  • The log is ordered and append-only; every projection sees events in log order.
  • Ticket printing and SMS are external effects; once-only effects require destination idempotency or a reconciliation protocol.

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

log برای بازسازی history مناسب است، اما به‌تنهایی از فروش یک صندلی به دو نفر جلوگیری نمی‌کند؛ تصمیم رزرو باید در مرز سریال همان صندلی گرفته شود. TTL ده‌دقیقه‌ای hold فرضی از زمان لازم برای checkout است: کوتاه‌تر شدنش خرید را قطع می‌کند و بلندترشدنش ظرفیت را بیهوده نگه می‌دارد. اثر بیرونی چاپ یا SMS از log اتمیک نمی‌شود؛ کلید idempotency مقصد یا reconciliation لازم است.

A log enables history replay but does not itself prevent double booking; serialize the booking decision for the seat. A ten-minute hold TTL is a checkout-duration assumption: shorter interrupts buyers, longer ties up capacity. Printing or SMS does not become atomic through logging; use destination idempotency or reconciliation.

مدل ذهنیMental model

فرمان «لطفاً» است و می‌تواند رد شود؛ رویداد «شد» است و لاگ فقط «شد»ها را دارد. سؤال هم‌زمان را نه در نما، که در مرز نوشتن (با نسخه صندلی) حل کن.
A command is a 'please' and may be rejected; an event is a 'done', and the log holds only dones. Solve concurrency not in the view but at the write boundary (with the seat version).

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

  1. لاگ و سه نما رویدادها: SeatHeld(seat, holder, hold_id, exp)، SeatConfirmed، HoldExpired، BookingCanceled، BookingCorrected. نماها: تابلوی صندلی‌ها، گزارش فروش، و چاپ بلیت — هر سه از یک لاگ ساخته می‌شوند.
  2. مسابقه روی یک صندلی هر HoldSeat نسخه‌ای که خوانده را شرط می‌گذارد: append iff seat_version == v. برنده نسخه را جلو می‌برد؛ بازنده SeatTaken می‌گیرد و می‌تواند صندلی دیگر بخواهد. نما هرگز داور نیست.
  3. انقضا، لغو، اصلاح انقضا را یک جاروب‌گر با رویداد HoldExpired (با همان hold_id) ثبت می‌کند، نه با پاک‌کردن. لغو و اصلاح هم رویداد تازه‌اند: BookingCanceled صندلی را آزاد می‌کند و BookingCorrected به رزرو درست پیوند می‌خورد؛ تاریخچه می‌ماند.
  4. بازپخش در برابر ارسال دوباره بازپخش = ساختن دوباره نما از لاگ، بدون اثر بیرونی. ارسال بلیت پشت کلید idempotency (شناسه رویداد) است: تکرار همان رویداد «ارسال مسدود شد» می‌دهد. گم‌شدن بلیت = ارسال دوباره صریح با همان کلید، نه بازپخش لاگ.
  1. The log and three views Events: SeatHeld(seat, holder, hold_id, exp), SeatConfirmed, HoldExpired, BookingCanceled, BookingCorrected. Views: seat map, sales report, ticket printing — all three built from one log.
  2. The race for one seat Each HoldSeat conditions on the version it read: append iff seat_version == v. The winner advances the version; the loser gets SeatTaken and may pick another seat. The view never referees.
  3. Expiry, cancel, correct Expiry is recorded by a sweeper as HoldExpired (same hold_id), never by deleting. Cancel and correct are fresh events too: BookingCanceled frees the seat, BookingCorrected links to the right booking; history stays.
  4. Replay versus resend Replay = rebuilding a view from the log with no external effect. Ticket sending sits behind an idempotency key (the event id): repeating the event yields 'resend blocked'. A lost ticket = an explicit resend with the same key, never a log replay.

طراحی مشخصConcrete design

مرز نوشتن: partition key شناسه صندلی است تا همه فرمان‌های یک صندلی به یک نویسنده شرطی برسند؛ append فقط اگر نسخه خوانده‌شده هنوز جاری است. نما: تابلوی صندلی (کلید صندلی → وضعیت + hold_id)، فروش (جمع SeatConfirmed منهای لغوها)، چاپ (مصرف‌کننده رویداد با جدول «ارسال‌شده‌ها»). مسیر خواندن از نما، مسیر نوشتن فقط از مرز شرطی.

Write boundary: the partition key is the seat id, so all commands for one seat reach one conditional writer; append only if the read version is still current. Views: seat map (seat → status + hold_id), sales (sum of SeatConfirmed minus cancels), printing (event consumer with a sent-table). Reads come from views; writes go only through the conditional boundary.

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

با منطق آموزشی اسکریپت: hold(A5) اول می‌برد، دومی «رد شد» می‌گیرد، تلاش دوباره با همان کلید «تکراری نادیده گرفته شد» می‌دهد و صندلی دو بار نگه داشته نمی‌شود. ارسال بلیت e-77 موفق است و تکرارش «مسدود» می‌شود.

With the script's teaching logic: the first hold(A5) wins, the second is rejected, retrying with the same key is dup-ignored, and the seat is never double-held. Ticket send e-77 succeeds once and its repeat is blocked.

گام 1 از 3 · خط فکریStep 1 of 3 · reasoning thread · مسابقه روی یک صندلی: الصاق شرطی، ارسال دقیقاً یک‌بار، انقضا و لغو (اعداد آموزشی)Race for one seat: conditional append, exactly-once send, expiry and cancel (teaching numbers) · توالیsequenceباز کردن در صفحه جدا ↗Open standalone ↗

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

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
دو تماشاگر هم‌زمان دکمه صندلی A5 را می‌زنند. توالی را بالا‌به‌پایین در سه باند بخوان: مسابقه، تأیید و ارسال، انقضا و لغو.
Two viewers hit seat A5 at the same moment. Read top to bottom in three bands: the race, confirm and send, expiry and cancel.
  1. باند مسابقه: دو HoldSeat A5 @v3 با نسخه خوانده‌شده یکسان وارد می‌شوند؛ append iff seat v==3 فقط اولی را می‌پذیرد و appended v4 برمی‌گرداند — بازنده SeatTaken صریح می‌گیرد، نه سکوت.
  2. باند تأیید: confirm A5 به append SeatConfirmed می‌رسد و on SeatConfirmed بلیت با کلید e-77 را می‌فرستد؛ ارسال دوباره همان کلید resend blocked می‌دهد — بازپخش نما بلیت نمی‌فرستد.
  3. باند انقضا و لغو: جاروب‌گر از همان مرز با HoldExpired (مهلت آموزشی ده‌دقیقه‌ای) صندلی B2 را آزاد می‌کند و لغو با append BookingCanceled — رویداد تازه، نه ویرایش — تاریخچه را می‌بندد.
  1. Race band: two HoldSeat A5 at v3 with the same read version arrive; the append-iff-v3 admits only the first and returns appended v4 — the loser gets an explicit SeatTaken, never silence.
  2. Confirm band: confirm A5 becomes an appended SeatConfirmed, and on SeatConfirmed sends the ticket under key e-77; resending the same key is blocked — replaying the view never sends tickets.
  3. Expiry and cancel band: the sweeper frees seat B2 through the same boundary with HoldExpired (the 10-minute teaching TTL), and the cancel appends BookingCanceled — a fresh event, never an edit — closing the history.

نقطهٔ تصمیم: نقطه تصمیم مرز شرطی است: نسخه صندلی — نه نما — برنده را تعیین می‌کند؛ کلید idempotency ارسال را دقیقاً یک‌بار نگه می‌دارد.

رد خرابی: کرش بین append و ارسال: retry با همان کلید امن است؛ SeatHeld دیررس بعد از آزاد شدن، با hold_id و ترتیب لاگ بی‌اثر می‌شود و صندلی آزاد را قفل نمی‌کند.

تضمین نشان‌داده‌شده: شکل انحصار صندلی و جدایی بازپخش از ارسال را نشان می‌دهد؛ تحویل واقعی پیامک را ثابت نمی‌کند.

تضمین نشان‌نداده‌شده: درباره پارتیشن‌بندی چند سالن و مقیاس هزاران درخواست در ثانیه چیزی نمی‌گوید.

Decision point: The decision point is the conditional boundary: the seat version — not the view — picks the winner; the idempotency key keeps the send exactly once.

Failure trace: Crash between append and send: same-key retry is safe; a late SeatHeld after release is superseded by hold_id and log order and never relocks the freed seat.

Guarantee shown: The figure shows seat exclusivity and the replay/send split; it does not prove real message delivery.

Not shown: It says nothing about multi-hall partitioning or thousands of requests per second.

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

دو درخواست با یک نسخه به مرز می‌رسند؛ دومی چه می‌گیرد و چرا کاربر پول صندلی‌ای که ندارد را نمی‌دهد؟

پاسخ: SeatTaken صریح می‌گیرد چون append شرطی فقط نسخه جاری را می‌پذیرد؛ خطای صریح یعنی پرداخت جلو نمی‌رود.

Two requests reach the boundary with one version; what does the second get, and why does nobody pay for a seat they do not hold?

Answer: An explicit SeatTaken, because the conditional append admits only the current version; the explicit error means no payment proceeds.

گام 2 از 3 · خط فکریStep 2 of 3 · reasoning thread · چرخه حیات hold: حالت‌های نگه‌داری‌شده با نگهبان‌ها (اعداد آموزشی)Hold lifecycle: guarded hold states (teaching numbers) · چرخه حیاتlifecycleباز کردن در صفحه جدا ↗Open standalone ↗

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

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
حالا همان صندلی را نه در زمان، که در حالت ببین: مسیر اصلی، انتظار و خروج‌های پایانی. هر پیکان را با نگهبان روی خودش بخوان.
Now see the same seat not in time but in state: the main path, waiting, and terminal exits. Read each arrow with its own guard.
  1. مسیر اصلی: Free با HoldSeat please به Version guard می‌رسد؛ نسخه جاری به Held می‌رود و نسخه کهنه با stale: SeatTaken برمی‌گردد — داور نگهبان است.
  2. انتظار: Held هم با TTL armed به TTL watch می‌رود و با HoldExpired (همان hold_id) آزاد می‌شود، هم با confirm requested به Awaiting payment که یا paid in time به Confirmed یا timeout به Free.
  3. پایانی: BookingCanceled از Held به Canceled؛ اصلاح اشتباه با BookingCorrected از Confirmed به Canceled و hold تازه — همیشه رویداد تازه. Confirmed با ارسال idempotent به Closed می‌رسد و تاریخچه می‌ماند.
  1. Main path: Free reaches the version guard with a HoldSeat please; the current version moves to Held while a stale one returns SeatTaken — the guard referees.
  2. Waiting: Held arms the TTL watch and is freed by HoldExpired with the same hold_id, or takes confirm requested to awaiting payment, which ends paid-in-time at Confirmed or timed-out at Free.
  3. Terminals: BookingCanceled moves Held to Canceled; fixing a mistake moves Confirmed to Canceled with a fresh BookingCorrected and a new hold — always a fresh event. Confirmed reaches Closed with an idempotent send, and history stays.

نقطهٔ تصمیم: نقطه تصمیم نگهبان‌هاست: اثر هر رویداد را نسخه و وضعیت جاری تعیین می‌کند، نه ترتیب رسیدن به مصرف‌کننده.

رد خرابی: نگهداشت منقضی‌شده دیررس صندلی آزاد را قفل نمی‌کند؛ حذف یا ویرایش رویداد برای اصلاح، حسابرسی را می‌کشد.

تضمین نشان‌داده‌شده: شکل حالت‌ها و نگهبان‌ها را نشان می‌دهد؛ ظرفیت مرز نوشتن را ثابت نمی‌کند — عددها (۳۰۰ صندلی، ده دقیقه) آموزشی‌اند.

تضمین نشان‌نداده‌شده: درباره پارتیشن‌بندی مرز نوشتن برای چند سالن چیزی نمی‌گوید.

Decision point: The decision point is the guards: version plus current state decides each event's effect, not consumer arrival order.

Failure trace: An expired late hold never relocks a freed seat; deleting or editing events to fix mistakes kills the audit trail.

Guarantee shown: The figure shows the states and their guards; it does not fix write-boundary capacity — the numbers (300 seats, ten minutes) are teaching numbers.

Not shown: It says nothing about partitioning the write boundary across halls.

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

HoldExpired با همان hold_id می‌رسد ولی صندلی قبلاً لغو شده؛ چه می‌شود؟

پاسخ: هیچی — نما با hold_id و ترتیب لاگ می‌بیند صندلی آزاد است؛ رویداد منقضی بی‌اثر ثبت می‌شود و قفل تازه نمی‌سازد.

HoldExpired arrives with the same hold_id but the seat was already canceled; what happens?

Answer: Nothing — the view sees by hold_id and log order that the seat is free; the expired event is recorded superseded and builds no new lock.

نمودار اسمبل نهاییFinal assembly diagram · لاگ رزرو، سه نما و نگهبان ارسال دقیقاً یک‌بار (اعداد آموزشی)Reservation log, three projections and the exactly-once send guard (teaching numbers) · لاگ رویداد + نماها + نگهبانevent log + projections + guard panelباز کردن در صفحه جدا ↗Open standalone ↗

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

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
دو تماشاگر هم‌زمان دکمه یک صندلی را می‌زنند. لاگ وسط را از چپ به راست بخوان.
Two viewers hit one seat at the same moment. Read the middle log left to right.
  1. ستون فرمان: دو HoldSeat با کلیدهای جدا وارد مرز شرطی می‌شوند.
  2. لاگ وسط: فقط SeatHeld برنده append می‌شود؛ بازنده SeatTaken می‌گیرد.
  3. سه نما: تابلوی صندلی، فروش و چاپ — هر سه از همان لاگ.
  4. نگهبان راست: ارسال بلیت با کلید رویداد؛ تکرار مسدود می‌شود.
  1. Command column: two HoldSeats with separate keys enter the conditional boundary.
  2. Middle log: only the winner's SeatHeld appends; the loser gets SeatTaken.
  3. Three views: seat map, sales and printing — all three from the same log.
  4. Right guard: ticket send keyed by event id; repeats are blocked.

نقطهٔ تصمیم: نقطه تصمیم مرز شرطی است: نسخه صندلی، نه نما، برنده را تعیین می‌کند.

رد خرابی: رد خرابی: کرش بین append و ارسال — retry با همان کلید امن است و بلیت دو بار نمی‌رود.

تضمین نشان‌داده‌شده: شکل انحصار صندلی و جدایی بازپخش از ارسال را تضمین می‌کند.

تضمین نشان‌نداده‌شده: درباره پارتیشن‌بندی چند سالن و تحویل واقعی پیامک چیزی نمی‌گوید.

Decision point: The decision point is the conditional boundary: the seat version, not the view, picks the winner.

Failure trace: Failure trace: crash between append and send — same-key retry is safe and no double ticket goes out.

Guarantee shown: The figure guarantees seat exclusivity and the replay/send split.

Not shown: It says nothing about multi-hall partitioning or real SMS delivery.

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

چرا بازپخش لاگ برای ارسال دوباره بلیت گم‌شده کافی نیست؟

پاسخ: چون بازپخش فقط نما می‌سازد و اثر بیرونی ممنوع است. ارسال دوباره باید صریح و با همان کلید idempotency باشد تا دقیقاً یک‌بار بماند.

Why isn't replaying the log enough to resend a lost ticket?

Answer: Because replay only rebuilds views and external effects are forbidden there. Resend must be explicit with the same idempotency key to stay exactly-once.

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

  • قفل خوش‌بینانه روی ردیف صندلی در پایگاه رابطه‌ای (UPDATE … WHERE version = v): وقتی ترافیک کم است و لاگ رویداد نمی‌خواهیم. هزینه: تاریخچه و نماهای بازسازی‌پذیر را از دست می‌دهیم.
  • Optimistic locking on a seat row (UPDATE … WHERE version = v): when traffic is low and no event log is wanted. Cost: we lose history and rebuildable views.

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

  • دو hold هم‌زمان: دقیقاً یکی append می‌شود؛ بازنده باید خطای مشخص بگیرد نه سکوت، وگرنه کاربر پول صندلی‌ای را می‌دهد که ندارد.
  • کرش بین append و ارسال بلیت: retry فرمان با همان کلید امن است (رویداد تکراری نادیده گرفته می‌شود)؛ ارسال جدا با کلید رویداد نگهبانی می‌شود تا بلیت دو بار نرود.
  • رویداد دیررس SeatHeld بعد از SeatReleased: نما با hold_id و ترتیب لاگ تصمیم می‌گیرد — نگهداشتی که آزادشده بی‌اعتبار است و هرگز صندلی آزاد را قفل نمی‌کند.
  • Two concurrent holds: exactly one appends; the loser must get an explicit error, not silence, or a user pays for a seat they don't have.
  • Crash between append and ticket send: command retry with the same key is safe (dup event ignored); sending is separately keyed by event id so no double ticket.
  • Late SeatHeld after SeatReleased: the view decides by hold_id and log order — an expired hold never relocks a freed seat.

کد قابل اجرا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.concurrent.*;
import java.util.concurrent.atomic.*;

// Teaching demo: one seat, two concurrent conditional-append requests —
// exactly one wins — plus an idempotent resend guard for the ticket send.
// Conditional append = append iff seat_version == read version.
public class SeatRace {
    // seat -> versioned holder; version -1 = free
    static class SeatStore {
        final ConcurrentHashMap<String, AtomicLong> versions = new ConcurrentHashMap<>();
        final ConcurrentHashMap<String, String> holders = new ConcurrentHashMap<>();
        final Set<String> seenReq = ConcurrentHashMap.newKeySet();

        String hold(String seat, String req, String holder, long readV) {
            if (!seenReq.add(req)) return "dup-ignored";
            AtomicLong v = versions.computeIfAbsent(seat, k -> new AtomicLong(-1));
            if (v.compareAndSet(readV, readV + 1)) {
                holders.put(seat, holder);
                return "held";
            }
            return "rejected";
        }
    }

    public static void main(String[] a) throws Exception {
        SeatStore store = new SeatStore();
        CountDownLatch go = new CountDownLatch(1);
        ExecutorService pool = Executors.newFixedThreadPool(2);
        List<Future<String>> fs = new ArrayList<>();
        // Both racers read version -1 (free) before either appends.
        fs.add(pool.submit(() -> { go.await(); return store.hold("A5", "req-1", "u1", -1); }));
        fs.add(pool.submit(() -> { go.await(); return store.hold("A5", "req-2", "u2", -1); }));
        go.countDown();
        List<String> res = List.of(fs.get(0).get(), fs.get(1).get());
        pool.shutdown();
        long held = res.stream().filter("held"::equals).count();
        long rejected = res.stream().filter("rejected"::equals).count();
        System.out.println("race results      = " + res);
        System.out.println("exactly one held  = " + (held == 1 && rejected == 1));

        // Idempotent resend guard: retry with the same key changes nothing.
        System.out.println("resend req-1      = " + store.hold("A5", "req-1", "u1", -1));

        // Ticket send behind the event-id key: first sends, repeat blocked.
        Set<String> sent = ConcurrentHashMap.newKeySet();
        System.out.println("send e-77         = " + send(sent, "e-77", "A5"));
        System.out.println("send e-77 again   = " + send(sent, "e-77", "A5"));
        System.out.println(held == 1 && rejected == 1 ? "OK" : "RACE BROKEN");
    }

    static String send(Set<String> sent, String ev, String seat) {
        return sent.add(ev) ? "ticket:" + seat : "resend-blocked";
    }
}

اجرا: javac SeatRace.java && java SeatRace — فقط استاندارد جاوا، فوری. مسابقه نخ‌ها را چند بار اجرا کن.

خروجی مورد انتظار: دقیقاً یک held و یک rejected (برنده هر اجرا فرق می‌کند)؛ resend با همان کلید dup-ignored؛ ارسال e-77 اول ticket:A5 و دوم resend-blocked؛ OK. (خروجی واقعی همین محیط)

Run: javac SeatRace.java && java SeatRace — stdlib Java only, instant. Run the thread race a few times.

Expected output: Exactly one held and one rejected (the winner varies per run); same-key resend dup-ignored; send e-77 first ticket:A5 then resend-blocked; OK. (actual output of this environment)

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

بررسی‌ها: زیر ۵۰ مسابقه هم‌زمان فقط یک hold می‌برد؛ retry با کلید تکراری حالت را عوض نمی‌کند؛ بازپخش کامل لاگ همان نما را می‌سازد و شمار ارسال‌ها ثابت می‌ماند؛ انقضا صندلی را آزاد می‌کند.
Checks: under 50 concurrent racers exactly one hold wins; same-key retry changes nothing; a full log replay rebuilds the same view with the sent-count unchanged; expiry frees the seat.

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

پیگیری (سینیور): رویداد SeatHeld دیر برسد ولی SeatReleased قبلاً پردازش شده باشد چه می‌کنی؟Follow-up (Senior): What happens if SeatHeld arrives after SeatReleased has already been processed?

نما هر دو را با hold_id و ترتیب لاگ می‌بیند: نگهداشت منقضی‌شده صندلی آزاد را قفل نمی‌کند و به‌جایش رویداد «بی‌اثر شد» ثبت می‌شود. قاعده: اثر رویداد را نسخه و وضعیت جاری تعیین می‌کند، نه ترتیب رسیدن به مصرف‌کننده.

The view sees both with hold_id and log order: the expired hold never relocks the freed seat, and a 'superseded' marker is recorded instead. Rule: version plus current state decides an event's effect, not consumer arrival order.

پیگیری (استف): دو سالن و هزاران درخواست در ثانیه؛ مرز نوشتن را چطور مقیاس می‌دهی؟Follow-up (Staff): How do you scale the write boundary to two halls and thousands of requests per second?

کلید پارتیشن شناسه صندلی (یا سالن) است تا هر صندلی یک نویسنده شرطی داشته باشد؛ پارتیشن‌های جدا موازی می‌شوند. جاروب انقضا هم پارتیشن‌بندی می‌شود تا یک جاروب تنها گلوگاه نشود.

Partition by seat (or hall) id so each seat has one conditional writer; partitions scale in parallel. The expiry sweeper is partitioned too, so no single sweeper becomes the bottleneck.

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

  • داور کردن نما: «نما نشان می‌دهد آزاد است پس می‌گیریم» — زیر مسابقه دو نفر یک صندلی را می‌برند؛ فقط مرز شرطی داور است.
  • ویرایش یا حذف رویداد برای اصلاح: تاریخچه و حسابرسی می‌میرد؛ همیشه رویداد جبرانی تازه.
  • بازپخش لاگ برای «پیدا کردن بلیت گم‌شده»: بازپخش نما می‌سازد نه اثر بیرونی؛ ارسال دوباره صریح با همان کلید لازم است.
  • Letting the view referee: 'the view shows free, so we take it' — under a race two users win one seat; only the conditional boundary referees.
  • Editing or deleting events to fix mistakes: history and audit die; always a fresh compensating event.
  • Replaying the log to 'find a lost ticket': replay builds views, not external effects; an explicit resend with the same key is required.

منابع:Sources: AWS · safe retries with idempotent APIs · Amazon · SDE III interview preparation