System Design · طراحی سیستم سنیورSenior ~52 دقیقه مطالعه~45 min read

کارگاهِ طراحیِ سیستم: مثال‌های واقعیSystem Design Walkthroughs

شش طراحیِ سیستمِ واقعی را از نیازمندی تا تخمین، API، مدلِ داده، معماری، مقیاس و گلوگاه قدم‌به‌قدم می‌سازیم و یک چارچوبِ تکرارپذیرِ اینترویو و زبانِ روایتِ trade-off به تو می‌دهیم تا مثلِ یک سنیور طراحی کنی و صحبت کنی.We build six real system designs end to end — requirements, estimation, API, data model, architecture, scaling and bottlenecks — and hand you a repeatable interview framework plus the language of trade-off narration so you design and talk like a senior.

پیش‌نیاز:Prerequisites: مبانیِ طراحیِ سیستمSystem Design Fundamentals


بذار با یک حقیقت شروع کنم که خیلی‌ها دیر می‌فهمند: اینترویوِ system design آزمونِ دانش نیست، آزمونِ قضاوت است. مصاحبه‌گر نمی‌خواهد ببیند نامِ ده‌تا دیتابیس را بلدی؛ می‌خواهد ببیند وقتی یک مسئله‌ی مبهم و بی‌مرز جلویت می‌گذارند، چطور آن را به تکه‌های قابل‌ساخت می‌شکنی، چه چیزهایی را می‌پرسی، کجا trade-off می‌کنی و آیا می‌فهمی هر تصمیم چه هزینه‌ای دارد.

فرق یک میدِلِ باتجربه با یک سنیور اینجاست: میدِل می‌گوید «از Kafka استفاده می‌کنیم چون scalable است»؛ سنیور می‌گوید «Kafka اینجا read-path را async می‌کند و throughput را بالا می‌برد، اما ordering فقط per-partition تضمین می‌شود، پس اگر ترتیبِ سراسری بخواهم باید کلید را عوض کنم یا از یک partition استفاده کنم که خودش گلوگاه است — و چون این trade-off را قبول نمی‌کنم، ordering را در سطحِ business با یک version number حل می‌کنم.» همان تصمیم، اما با آگاهی از هزینه.

این فصل یک کارگاه است. شش سیستمِ واقعی را کامل طراحی می‌کنیم — همان‌هایی که واقعاً در اینترویوهای سطح‌بالا می‌پرسند و واقعاً در پروداکشن می‌سازی. برای هرکدام یک مسیرِ ثابت را طی می‌کنیم و در حین کار، زبانِ روایتِ سنیور را هم تمرین می‌کنیم.

نقشه‌ی راه این فصل

۱) چارچوبِ تکرارپذیرِ اینترویو — هفت گامی که برای هر مسئله‌ای جواب می‌دهد. ۲) تخمینِ سرانگشتی — عددهایی که باید حفظ باشی و چطور back-of-envelope بزنی. ۳) شش طراحیِ کامل: (الف) URL shortener، (ب) distributed rate limiter، (ج) news feed / timeline با fan-out، (د) chat / messaging با delivery و presence، (ه) notification system، (و) e-commerce order flow با saga و outbox. ۴) زبانِ روایتِ trade-off — چطور یک سنیور بلند فکر می‌کند و تصمیم را توجیه می‌کند. هر مورد را از نیازمندی → تخمین → API → مدلِ داده → معماری (Mermaid) → مقیاس → گلوگاه → trade-off می‌بریم.


چارچوبِ تکرارپذیرِ اینترویو (هفت گام)

قبل از هر طراحی، این نقشه را در ذهن داشته باش. اسمش را هرچه بگذاری مهم نیست (بعضی‌ها RESHADED یا PEDALS صدایش می‌کنند)؛ مهم این است که همیشه از گامِ اول شروع کنی، نه از وسط. بزرگ‌ترین اشتباهِ کاندیداها این است که مصاحبه‌گر می‌گوید «یک URL shortener طراحی کن» و کاندیدا بلافاصله می‌پرد روی «از Cassandra استفاده می‌کنم». این یعنی تو نفهمیدی مسئله چیست.

معمار، نه بنّا

یک معمارِ خوب وقتی می‌خواهی خانه بسازی، اول نمی‌پرسد «آجر بخرم یا بتن؟». اول می‌پرسد «چند نفر می‌خواهند زندگی کنند؟ بودجه چقدر است؟ زلزله‌خیز است؟ فردا می‌خواهی طبقه اضافه کنی؟». جنسِ مصالح آخرین تصمیم است، نه اول. system design هم همین است: اول requirements و مقیاس، بعد تکنولوژی.

گام ۱ — روشن‌کردنِ نیازمندی‌ها (Functional & Non-functional). چند دقیقه سؤال بپرس. Functional یعنی «سیستم چه کاری می‌کند» (کوتاه‌کردنِ لینک، redirect). Non-functional یعنی «چطور آن کار را می‌کند» (چه تأخیری؟ چه availability؟ چقدر consistency؟). این‌ها معماری را تعیین می‌کنند، نه فیچرها.

گام ۲ — تخمینِ مقیاس (Back-of-envelope). QPS، حجمِ داده، پهنای‌باند، حافظه. لازم نیست دقیق باشد؛ لازم است order-of-magnitude درست باشد. تفاوتِ ۱۰۰ QPS و ۱۰۰٬۰۰۰ QPS معماری را کاملاً عوض می‌کند.

گام ۳ — طراحیِ API. قراردادِ بیرونی. چند endpoint، متدها، پارامترها. این تو را مجبور می‌کند دقیق فکر کنی که سیستم واقعاً چه چیزی ارائه می‌دهد.

گام ۴ — مدلِ داده و انتخابِ storage. چه entityهایی؟ SQL یا NoSQL؟ چرا؟ index روی چه چیزی؟

گام ۵ — معماریِ high-level. یک دیاگرام: client → LB → service → cache → DB. اجزای اصلی و جریانِ داده.

گام ۶ — عمیق‌شدن روی یک‌دو نقطه‌ی سخت. مصاحبه‌گر معمولاً یک جای خاص را می‌کاود: «چطور unique ID می‌سازی؟»، «celebrity را چه می‌کنی؟». اینجا عمقت را نشان می‌دهی.

گام ۷ — گلوگاه‌ها، مقیاس و trade-off. single point of failure کجاست؟ چطور scale می‌کنی؟ چه چیزی را فدای چه چیزی کردی؟

مدیریتِ زمان مثل سنیور

در یک مصاحبه‌ی ۴۵ دقیقه‌ای: ~۵ دقیقه requirements، ~۵ دقیقه تخمین، ~۵ دقیقه API و data model، ~۱۵ دقیقه معماری و deep-dive، ~۱۰ دقیقه scaling و trade-off، ~۵ دقیقه جمع‌بندی و سؤال. اگر ۲۰ دقیقه روی تخمین وقت بگذاری، باخته‌ای. سنیور تخمین را سریع و «به‌قدرِ کافی خوب» می‌زند و می‌رود سراغِ بخش‌های جالب.

چرا نباید مستقیم برویم سراغِ تکنولوژی؟

پرسش: مصاحبه‌گر می‌گوید «یک سیستم طراحی کن». چرا اشتباه است که بلافاصله بگویی «از میکروسرویس و Kafka و Cassandra استفاده می‌کنم»؟

پاسخ: چون هنوز نمی‌دانی مسئله چیست. تکنولوژی راه‌حل است، و راه‌حل بدونِ فهمِ مسئله یعنی حدس‌زدن. اگر مقیاس ۱۰۰ QPS باشد، یک PostgreSQL و یک instance کافی است و Cassandra over-engineering است. اگر ۱۰۰k QPS باشد، معماری فرق می‌کند. سنیور اول محدوده را روشن می‌کند، چون پیچیدگیِ بی‌جا خودش یک باگ است: هر جزءِ اضافه یک نقطه‌ی خرابی، یک هزینه‌ی نگه‌داری و یک بارِ شناختی روی تیم است. جمله‌ی طلایی در اینترویو: «قبل از انتخابِ storage، اجازه بده چند سؤال بپرسم تا مقیاس و الگوی خواندن/نوشتن را بفهمم.»


تخمینِ سرانگشتی: عددهایی که باید بلد باشی

تخمین ترسناک نیست اگر چند عددِ پایه را حفظ باشی. کلِ کار این است: ماهانه/روزانه را به ثانیه‌ای تبدیل کن، read/write را جدا کن، و حافظه/پهنای‌باند را از QPS دربیاور.

مقیاس مقدار تقریبی
ثانیه در روز ~۸۶٬۴۰۰ ≈ ۱۰⁵
ثانیه در ماه ~۲٫۵ میلیون
یک روز = چند ثانیه (گرد) ~۱۰⁵
نسبتِ peak به average ~۲x تا ۳x
یک کاراکتر (ASCII) ۱ بایت
یک UUID ۱۶ بایت
یک long ۸ بایت

قاعده‌ی تبدیل: اگر روزانه X درخواست داری، average QPS ≈ X / 10⁵. مثلاً ۱۰۰ میلیون درخواست در روز → ۱۰⁸/۱۰⁵ = ۱۰۰۰ QPS به‌طور متوسط، و peak حدود ۲–۳ هزار.

اعدادِ تأخیر (latency) که سنیورها با آن استدلال می‌کنند (ارقامِ کلاسیکِ «Latency Numbers Every Programmer Should Know»، مرتبه‌ی بزرگی):

عملیات تأخیر تقریبی
L1 cache reference ~۱ ns
Main memory (RAM) reference ~۱۰۰ ns
SSD random read ۱۶ µs (۱۰۰k IOPS)
Round trip در همان datacenter ~۰٫۵ ms
Redis GET (شبکه‌ی محلی) ~۱ ms
DB query با index (SSD) چند ms
Round trip بینِ قاره‌ای (مثلاً CA↔Netherlands) ~۱۵۰ ms
Disk seek (HDD چرخان) ~۱۰ ms
چرا این اعداد در روایت مهم‌اند

وقتی می‌گویی «cache می‌گذارم»، سنیور پشتش عدد دارد: «RAM حدود ۱۰۰ نانوثانیه و دیسک هزارها برابر کندتر است، پس اگر hit rate کش ۹۵٪ باشد، میانگینِ تأخیر از ~۵ms به زیرِ ۱ms می‌آید.» همین جمله تفاوتِ «کش خوب است» (شعار) با «کش این‌قدر کمک می‌کند» (مهندسی) است. هیچ‌وقت عددِ دقیق نمی‌خواهند؛ مرتبه‌ی بزرگی می‌خواهند.

تله‌ی storage estimation

یک خطای رایج: فقط اندازه‌ی رکورد را حساب می‌کنی و overhead را فراموش می‌کنی. یک رکوردِ ۱۰۰ بایتی در دیتابیس با index و replication و padding عملاً ۲–۳ برابر جا می‌گیرد. همیشه یک ضریبِ ~۲–۳x برای overhead و ~۳x برای replication (سه کپی) در نظر بگیر، وگرنه ظرفیت را کم‌برآورد می‌کنی و در پروداکشن دیسک پر می‌شود.

روی تخته، سریع QPS و storage را چطور تخمین می‌زنی؟

پرسش: مصاحبه‌گر می‌گوید «۵۰۰ میلیون کاربر داریم، هرکدام روزی ۱۰ عکس آپلود می‌کند، هر عکس ۲MB». QPS نوشتن و storageِ سالانه را سریع بده.

پاسخ: بلند و مرحله‌ای حساب می‌کنم. نوشتن در روز = ۵×۱۰⁸ × ۱۰ = ۵×۱۰⁹. تقسیم بر ~۱۰⁵ ثانیه = ۵×۱۰⁴ = ۵۰k write QPS متوسط، و peak را ~۲–۳ برابر می‌گیرم → ~۱۵۰k. storage: ۵×۱۰⁹ عکس × ۲MB = ۱۰¹⁰ MB = ۱۰PB در روز؟ نه — دوباره چک می‌کنم: ۵×۱۰⁹ × ۲×۱۰⁶ بایت = ۱۰¹⁶ بایت = ۱۰PB در روز، که غیرواقعی است، پس فرض را به چالش می‌کشم: احتمالاً منظور ۵۰۰ میلیون کاربر با ۱٪ آپلودِ روزانه است. نکته‌ای که سنیور را نشان می‌دهد: وقتی عدد بی‌معنی شد، فرض را زیرِ سؤال ببر، نه ماشین‌حساب را. همیشه بلند حساب کن تا مصاحبه‌گر روشِ فکرت را ببیند و اگر جایی خطا رفتی، وسطِ راه اصلاح کند.


طراحیِ ۱ — URL Shortener (کوتاه‌کننده‌ی لینک)

کلاسیک‌ترین سؤال، و فریبنده: ساده به‌نظر می‌رسد اما نقاطِ عمیق دارد (تولیدِ ID یکتا، read-heavy بودن، ماندگاریِ نقشه).

گام ۱ — نیازمندی‌ها

Functional: (۱) یک URLِ بلند بده، یک کدِ کوتاه بگیر. (۲) با کدِ کوتاه redirect شو به URLِ اصلی. (۳) اختیاری: custom alias، انقضا (TTL)، آمارِ کلیک. Non-functional: (۱) redirect باید خیلی سریع باشد (تأخیرِ پایین). (۲) read-heavy: نسبتِ خواندن به نوشتن حدود ۱۰۰:۱ یا بیشتر (لینک یک‌بار ساخته می‌شود، هزاران‌بار باز می‌شود). (۳) کدها یکتا و غیرقابل‌حدس (تا حدی). (۴) availability بالا؛ اگر redirect کار نکند، همه‌ی لینک‌ها می‌شکنند.

گام ۲ — تخمین

فرض: ۱۰۰ میلیون لینکِ جدید در ماه. write QPS = ۱۰⁸ / ۲٫۵×۱۰⁶ ≈ ۴۰/s. با نسبتِ read:write = ۱۰۰:۱ → read ≈ ۴۰۰۰/s (peak ~۱۰k). در ۵ سال: ۱۰⁸ × ۶۰ ماه = ۶ میلیارد رکورد. هر رکورد ~۵۰۰ بایت (کد + URL + متادیتا) → ~۳TB داده‌ی خام، با overhead و replication ~۱۰TB. این «بزرگ اما نه غول‌آسا» است؛ یک دیتابیسِ خوب shard-شده کافی است.

بینشِ کلیدی این طراحی

مسئله‌ی اصلیِ URL shortener «تولیدِ ID» است، نه storage. اگر بتوانی به‌ازای هر لینک یک عددِ یکتای کوتاه بسازی، بقیه‌اش یک key-value lookup ساده است. پس عمقِ مصاحبه اینجاست: چطور ID یکتا می‌سازی بدونِ هماهنگیِ سراسری؟

گام ۳ — API

POST /api/v1/urls
  body: { "longUrl": "https://...", "customAlias": "promo", "ttlDays": 30 }
  201 -> { "shortUrl": "https://sho.rt/aX9bQ2", "code": "aX9bQ2" }

GET /{code}
  302 Found, Location: <longUrl>   // redirect

یک نکته‌ی ظریف: از 302 (temporary) یا 301 (permanent)؟ سنیور می‌گوید 301 را مرورگر cache می‌کند و دیگر به سرورت نمی‌آید — عالی برای بار، افتضاح برای آمارِ کلیک. پس اگر analytics می‌خواهی، 302 (یا 307) بزن تا هر کلیک به تو برسد.

گام ۴ — مدلِ داده

key-value ساده. کلید = code، مقدار = longUrl + متادیتا. برای این workload، PostgreSQL کاملاً کافی است (تا میلیاردها رکورد با sharding)؛ اما اگر write واقعاً عظیم شد، یک key-value store مثل Cassandra/DynamoDB منطقی است.

-- PostgreSQL
CREATE TABLE url_map (
    code        VARCHAR(11) PRIMARY KEY,
    long_url    TEXT        NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at  TIMESTAMPTZ,
    owner_id    BIGINT
);
CREATE INDEX idx_url_map_expires ON url_map (expires_at) WHERE expires_at IS NOT NULL;
-- Oracle
CREATE TABLE url_map (
    code        VARCHAR2(11) PRIMARY KEY,
    long_url    CLOB         NOT NULL,
    created_at  TIMESTAMP    DEFAULT SYSTIMESTAMP NOT NULL,
    expires_at  TIMESTAMP,
    owner_id    NUMBER
);
CREATE INDEX idx_url_map_expires ON url_map (expires_at);
تفاوتِ دیالکت اینجا

در PostgreSQL برای متنِ طولانی TEXT می‌گیری و partial index (WHERE expires_at IS NOT NULL) داری که Oracle تا 23ai به این شکل ندارد. در Oracle متنِ بزرگ CLOB است و SYSTIMESTAMP جایِ now(). اگر کد را portable می‌خواهی، VARCHAR/VARCHAR2، NUMERIC/NUMBER و timestamp with time zone را از یک لایه‌ی abstraction (مثل Flyway با placeholder یا JPA) عبور بده.

تولیدِ ID — قلبِ طراحی

سه راه:

راهِ ۱: hash کردن (MD5/SHA) و برداشتنِ چند کاراکتر. ساده، اما collision دارد و باید چک کنی — یعنی یک read اضافه به‌ازای هر write. برای همان URL همیشه همان کد (idempotent) که خوب است، اما مدیریتِ برخورد دردسر است.

راهِ ۲: شمارنده‌ی سراسری + Base62. یک عددِ auto-increment بگیر و به Base62 (0-9a-zA-Z، ۶۲ کاراکتر) تبدیل کن. با ۷ کاراکتر: ۶۲⁷ ≈ ۳٫۵ تریلیون کد. مشکل: شمارنده‌ی سراسری یک single point of contention است. راه‌حل: range allocation — هر instance یک بازه (مثلاً ۱۰۰۰ تایی) از یک سرویسِ مرکزی می‌گیرد و محلی مصرف می‌کند؛ فقط هر ۱۰۰۰ تا یک‌بار به مرکز می‌رود.

راهِ ۳: Snowflake ID. یک عددِ ۶۴ بیتیِ یکتا بدونِ هماهنگی: timestamp + machine id + sequence. این را پایین می‌سازیم.

// Snowflake-style 64-bit unique ID generator (Twitter-inspired)
// layout: 1 unused sign bit | 41 timestamp bits | 10 machine bits | 12 sequence bits
public final class SnowflakeIdGenerator {

    private static final long EPOCH = 1704067200000L; // 2024-01-01, custom epoch

    private static final long MACHINE_BITS  = 10L;
    private static final long SEQUENCE_BITS = 12L;

    private static final long MAX_MACHINE_ID = (1L << MACHINE_BITS) - 1;   // 1023
    private static final long MAX_SEQUENCE   = (1L << SEQUENCE_BITS) - 1;  // 4095

    private static final long MACHINE_SHIFT   = SEQUENCE_BITS;               // 12
    private static final long TIMESTAMP_SHIFT  = SEQUENCE_BITS + MACHINE_BITS; // 22

    private final long machineId;
    private long lastTimestamp = -1L;
    private long sequence = 0L;

    public SnowflakeIdGenerator(long machineId) {
        if (machineId < 0 || machineId > MAX_MACHINE_ID) {
            throw new IllegalArgumentException("machineId out of range: " + machineId);
        }
        this.machineId = machineId;
    }

    public synchronized long nextId() {
        long now = System.currentTimeMillis();
        if (now < lastTimestamp) {
            // clock moved backwards (NTP correction) -> refuse to hand out risky ids
            throw new IllegalStateException("Clock moved backwards by "
                    + (lastTimestamp - now) + " ms");
        }
        if (now == lastTimestamp) {
            sequence = (sequence + 1) & MAX_SEQUENCE;
            if (sequence == 0) {           // 4096 ids exhausted this ms -> wait next ms
                now = waitNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }
        lastTimestamp = now;
        return ((now - EPOCH) << TIMESTAMP_SHIFT)
                | (machineId << MACHINE_SHIFT)
                | sequence;
    }

    private long waitNextMillis(long last) {
        long ts = System.currentTimeMillis();
        while (ts <= last) { ts = System.currentTimeMillis(); }
        return ts;
    }
}

سپس آن long را Base62 می‌کنیم تا کدِ کوتاه شود:

public final class Base62 {
    private static final String ALPHABET =
        "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    private static final int BASE = ALPHABET.length(); // 62

    public static String encode(long value) {
        if (value == 0) return "0";
        StringBuilder sb = new StringBuilder();
        while (value > 0) {
            sb.append(ALPHABET.charAt((int) (value % BASE)));
            value /= BASE;
        }
        return sb.reverse().toString();
    }
}
دو تله‌ی واقعیِ Snowflake در پروداکشن

اول: پرشِ ساعت به عقب (clock skew). اگر NTP ساعت را عقب بکشد، ممکن است ID تکراری بسازی. کدِ بالا در این حالت خطا می‌دهد (رفتارِ درست: امتناع، نه تولیدِ ID مشکوک). دوم: تخصیصِ machine id. اگر دو instance با یک machineId بالا بیایند، ID تکراری می‌سازند و باگش هفته‌ها بعد و به‌شکلِ داده‌ی خراب ظاهر می‌شود. راهِ درست: machineId را از ZooKeeper/etcd یا از ordinalِ یک StatefulSet در Kubernetes بگیر، نه از config دستی. یک بار دیدن این باگ در پروداکشن کافی است تا هیچ‌وقت machineId را hard-code نکنی.

Snowflake قابل‌حدس است

چون ID مرتبِ زمانی است، کدهای متوالی الگو دارند؛ کسی می‌تواند لینک‌های دیگران را enumerate کند. اگر لینک‌ها باید غیرقابل‌حدس باشند (privacy)، یا از range-based counter با یک permutation/XOR-cipher استفاده کن، یا چند بیتِ random به کد اضافه کن. در اینترویو حتماً این را به‌عنوان trade-offِ امنیتی نام ببر — نشانه‌ی بلوغ است.

معماری

دیاگرام: مسیرِ نوشتن و خواندنِ URL shortener (Persian: مسیرِ ساخت و redirect).

flowchart LR
  Client -->|POST create| LB[Load Balancer]
  Client -->|GET code| LB
  LB --> App[Shortener Service]
  App -->|nextId + base62| IDGen[Snowflake / Counter]
  App -->|read-through| Cache[(Redis Cache)]
  App --> DB[(URL Store - sharded)]
  Cache -.miss.-> DB
  App -->|click event| MQ[(Kafka - analytics)]

نکته‌ی حیاتی: چون read-heavy است، redirect باید از cache بیاید نه دیتابیس. یک Redis با code -> longUrl جلوی دیتابیس بگذار. hit rate بالا (چون لینک‌های محبوب داغ‌اند) یعنی اکثرِ redirectها زیرِ ۱ms.

چطور بدونِ single point of failure در ID، مقیاس می‌دهی؟

پرسش: یک شمارنده‌ی سراسری برای تولیدِ ID یک گلوگاه است. چطور حلش می‌کنی؟

پاسخ: سه گزینه با trade-off مشخص. (۱) Snowflake: بدونِ هماهنگی، هر ماشین محلی ID می‌سازد؛ throughput عالی، اما IDها قابل‌حدس و به clock حساس‌اند. (۲) Range/segment allocation: یک سرویسِ مرکزی بازه‌های ۱۰۰۰تایی می‌دهد؛ instance محلی مصرف می‌کند و فقط هر ۱۰۰۰ تا یک RPC می‌زند — تماسِ مرکزی ۱۰۰۰ برابر کم می‌شود و اگر مرکز لحظه‌ای پایین باشد، هر instance هنوز بازه‌ی رزروشده دارد. (۳) Pre-generation: یک job از قبل میلیون‌ها کدِ یکتا می‌سازد و در یک صف می‌ریزد؛ ساخت فقط یک pop است. من معمولاً range allocation را انتخاب می‌کنم چون هم مقیاس می‌دهد هم IDها را قابل‌حدس نمی‌کند، و پیچیدگیِ عملیاتی‌اش از Snowflake کمتر است. اینجا صراحتاً می‌گویم trade-off چیست: Snowflake تأخیرِ کمتر ولی حدس‌پذیری، range allocation یک وابستگی به سرویسِ مرکزی ولی کدهای امن‌تر.


طراحیِ ۲ — Distributed Rate Limiter (محدودکننده‌ی نرخِ توزیع‌شده)

هر سیستمِ جدی به rate limiter نیاز دارد: جلوی abuse، DDoS و مصرفِ ناعادلانه را بگیرد و سرویس‌های پایین‌دستی را از overload محافظت کند.

گام ۱ — نیازمندی‌ها

Functional: به‌ازای یک کلید (userId/IP/apiKey) اجازه بده حداکثر N درخواست در بازه‌ی T. اگر بیشتر شد، 429 Too Many Requests برگردان با header مثل Retry-After. Non-functional: (۱) تأخیرِ بسیار کم — rate limiter در مسیرِ داغِ هر درخواست است، پس نباید کند باشد. (۲) توزیع‌شده: چند instance از سرویس، اما limit باید سراسری باشد (کاربر نباید با hit شدن به instanceهای مختلف، N برابر مصرف کند). (۳) دقت: کمی خطا قابل‌قبول است، ولی نه فاجعه‌بار.

الگوریتم‌ها — trade-off واقعی

الگوریتم حافظه burst دقت مرز پیچیدگی
Fixed window counter کم مشکلِ مرز (۲x در لبه) ضعیف ساده
Sliding window log زیاد (هر رخداد) دقیق عالی متوسط
Sliding window counter کم خوب (تقریبی) خوب متوسط
Token bucket کم اجازه‌ی burst کنترل‌شده خوب متوسط
Leaky bucket کم خروجیِ صاف، بدونِ burst خوب متوسط
سطلِ ژتون (token bucket)

یک سطل داری که با نرخِ ثابت ژتون در آن می‌ریزد (مثلاً ۱۰ ژتون در ثانیه) و ظرفیتش محدود است (مثلاً ۱۰۰). هر درخواست یک ژتون برمی‌دارد؛ اگر ژتون نبود، رد می‌شود. اگر مدتی درخواست نداشتی، سطل پر می‌شود و می‌توانی یک burst بزنی (تا ۱۰۰) — این «انعطافِ کنترل‌شده» چیزی است که token bucket را محبوب می‌کند. leaky bucket برعکس، خروجی را کاملاً صاف می‌کند و burst نمی‌دهد.

کدام الگوریتم را انتخاب کنم؟

اگر مطمئن نیستی، sliding window counter پیش‌فرضِ خوبی است: حافظه‌ی کم، دقتِ نزدیکِ کامل، بدونِ مشکلِ burstِ مرزیِ fixed window. اگر می‌خواهی به مشتری‌ها اجازه‌ی burstِ کوتاه بدهی (تجربه‌ی بهتر برای API)، token bucket. اگر پشتِ سرت یک سیستمِ downstream حساس داری که فقط throughputِ صاف تحمل می‌کند، leaky bucket. در اینترویو، نامِ هر سه را ببر و بگو چرا یکی را انتخاب می‌کنی — این نشانه‌ی عمق است.

پیاده‌سازیِ محلی با Bucket4j (single instance)

برای یک instance، Bucket4j (کتابخانه‌ی جاوا، مبتنی بر token bucket) عالی است:

// build.gradle: implementation 'com.bucket4j:bucket4j_jdk17-core:8.16.1'
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;

public Bucket newBucket() {
    // 100 requests capacity, refill 100 tokens every minute
    Bandwidth limit = Bandwidth.builder()
            .capacity(100)
            .refillGreedy(100, Duration.ofMinutes(1))
            .build();
    return Bucket.builder().addLimit(limit).build();
}

// در یک فیلتر:
Bucket bucket = cache.computeIfAbsent(apiKey, k -> newBucket());
if (bucket.tryConsume(1)) {
    chain.doFilter(request, response);   // allowed
} else {
    response.setStatus(429);
    response.setHeader("Retry-After", "60");
}
چرا rate limiterِ in-memory در پروداکشن دروغ می‌گوید

اگر limit را در حافظه‌ی هر instance نگه داری و ۵ تا instance داشته باشی، کاربر عملاً ۵ برابرِ limit مصرف می‌کند (هر instance جداگانه می‌شمارد). این باگ در dev دیده نمی‌شود (یک instance) و فقط بعد از scale-out در پروداکشن ظاهر می‌شود. راهِ درست: state را در یک storeِ مشترک (Redis) نگه دار. in-memory فقط وقتی درست است که load balancer با sticky session همیشه یک کاربر را به یک instance بفرستد — که خودش شکنندگی و imbalance می‌آورد.

پیاده‌سازیِ توزیع‌شده با Redis + Lua (atomic)

برای چند instance، state باید مشترک باشد و عملیاتِ «بخوان-تصمیم‌بگیر-بنویس» باید atomic باشد وگرنه race condition دو درخواست هم‌زمان اجازه می‌گیرند. راهِ حرفه‌ای: یک Lua script که Redis آن را به‌صورتِ atomic اجرا می‌کند.

-- token bucket in Redis, atomic via EVAL
-- KEYS[1] = bucket key
-- ARGV[1] = capacity, ARGV[2] = refill_per_sec, ARGV[3] = now_ms, ARGV[4] = requested
local capacity   = tonumber(ARGV[1])
local refill     = tonumber(ARGV[2])
local now        = tonumber(ARGV[3])
local requested  = tonumber(ARGV[4])

local data   = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts     = tonumber(data[2])
if tokens == nil then tokens = capacity; ts = now end

-- refill based on elapsed time
local delta = math.max(0, now - ts) / 1000.0
tokens = math.min(capacity, tokens + delta * refill)

local allowed = 0
if tokens >= requested then
  tokens = tokens - requested
  allowed = 1
end

redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], 60000)   -- TTL so idle keys vanish
return allowed

از Spring:

@Component
public class RedisRateLimiter {
    private final StringRedisTemplate redis;
    private final RedisScript<Long> script; // loads the Lua above

    public boolean allow(String key, int capacity, int refillPerSec) {
        Long ok = redis.execute(
            script,
            List.of("rl:" + key),
            String.valueOf(capacity),
            String.valueOf(refillPerSec),
            String.valueOf(System.currentTimeMillis()),
            "1");
        return ok != null && ok == 1L;
    }
}
Redis هم SPOF می‌شود

حالا Redis در مسیرِ داغِ هر درخواست است. اگر Redis پایین برود چه؟ دو گزینه: fail-open (اگر Redis نبود، اجازه بده — در دسترس‌بودن مهم‌تر از محدودیت است) یا fail-closed (رد کن — امنیت مهم‌تر). این تصمیم business است، نه فنی: برای یک public API که از abuse می‌ترسی، شاید fail-closed؛ برای یک checkout که نباید مشتری از دست بدهی، fail-open با یک limiterِ in-memoryِ محلیِ محافظه‌کارانه به‌عنوان fallback. سنیور هر دو حالت را نام می‌برد و می‌گوید تصمیم به SLA بستگی دارد.

محدودیت را کجا اعمال کنی: gateway یا هر سرویس؟

پرسش: rate limiting را در API gateway بگذاری یا داخلِ هر microservice؟

پاسخ: هر دو، در لایه‌های مختلف. در gateway (مثل Spring Cloud Gateway یا یک reverse proxy)، limitِ درشت و سراسری اعمال کن — محافظت در برابرِ DDoS و abuse قبل از ورود به سیستم. این اولین سد است و بارِ اضافه را از کلِ سیستم دور می‌کند. داخلِ سرویس، limitِ ظریف و business-aware (مثلاً «این پلنِ کاربر فقط ۱۰ report در روز») که gateway از منطقِ آن بی‌خبر است. تله: اگر فقط در gateway بگذاری، ارتباطِ سرویس-به-سرویسِ داخلی محافظت نمی‌شود؛ اگر فقط در سرویس‌ها بگذاری، ترافیکِ مخرب تا اعماق نفوذ می‌کند. جوابِ سنیور: defense in depth — لایه‌بندی.


طراحیِ ۳ — News Feed / Timeline (فید و fan-out)

سؤالِ محبوب چون هسته‌ی سختش یک trade-offِ زیبا دارد: fan-out on write در برابرِ fan-out on read.

گام ۱ — نیازمندی‌ها

Functional: کاربر پست می‌گذارد؛ دنبال‌کننده‌ها آن را در timelineِ خود می‌بینند (مرتب، معمولاً جدیدترین اول یا rankبندی‌شده). Non-functional: (۱) خواندنِ فید باید سریع باشد (کاربر منتظر نمی‌ماند). (۲) read-heavy شدید. (۳) eventual consistency قابل‌قبول است — اگر پستِ دوستت با چند ثانیه تأخیر بیاید، فاجعه نیست.

گام ۲ — تخمین

فرض: ۵۰۰ میلیون کاربرِ روزانه، هرکدام روزی ۲۰ بار فید را باز می‌کند → ۱۰ میلیارد read/day ≈ ۱۰¹⁰/۱۰⁵ = ۱۰⁵ = ۱۰۰k QPS read. نوشتنِ پست خیلی کمتر (شاید ۱۰ میلیون پست/روز ≈ ~۱۰۰/s). این نسبتِ read:write عظیم، تصمیمِ اصلی را می‌سازد.

دو استراتژیِ اصلی

Fan-out on write (push): لحظه‌ی پست‌گذاشتن، پست را در timelineِ همه‌ی دنبال‌کننده‌ها می‌نویسی (معمولاً در یک cacheِ per-user در Redis). خواندنِ فید سریع می‌شود (فقط timeline خودت را می‌خوانی)، اما نوشتن سنگین است.

Fan-out on read (pull): پست را فقط یک‌جا ذخیره می‌کنی. لحظه‌ی خواندنِ فید، پست‌های همه‌ی کسانی که دنبال می‌کنی را جمع و merge می‌کنی. نوشتن سبک، اما خواندن سنگین و کند.

دیاگرام: دو استراتژیِ fan-out (Persian: نوشتنِ فوری در تایم‌لاینِ دنبال‌کننده‌ها در برابرِ جمع‌کردن هنگامِ خواندن).

flowchart TD
  subgraph Write[Fan-out on Write]
    A[User posts] --> B[Post Service]
    B --> C{For each follower}
    C --> D[(Follower timeline cache)]
  end
  subgraph Read[Fan-out on Read]
    E[User opens feed] --> F[Feed Service]
    F --> G[Fetch followees' posts]
    G --> H[Merge + rank at read time]
  end

مشکلِ سلبریتی و راه‌حلِ hybrid

اینجا trade-off زنده می‌شود. اگر یک سلبریتی با ۵۰ میلیون دنبال‌کننده پست بگذارد، fan-out on write یعنی ۵۰ میلیون نوشتن برای یک پست — write path را برای ثانیه‌ها اشباع می‌کند («hot key problem»). این دقیقاً همان مشکلی است که تیمِ Twitter سال‌ها با آن جنگید.

راهِ سنیور: hybrid. کاربرانِ عادی (زیرِ ~۱۰k دنبال‌کننده) → fan-out on write (نوشتن به timelineها). سلبریتی‌ها → fan-out on read (پست جدا ذخیره می‌شود، هیچ fan-out نمی‌شود). لحظه‌ی خواندنِ فید، timelineِ از پیش‌محاسبه‌شده (از کاربرانِ عادی) با یک fetchِ real-time از پست‌های سلبریتی‌هایی که دنبال می‌کنی merge می‌شود.

فید را shard می‌کنی؛ کلیدِ sharding چه باشد و مشکلش چیست؟

پرسش: برای ذخیره‌ی timelineها/پست‌ها باید shard کنی. کلید را چه می‌گذاری و چه گلوگاهی می‌سازد؟

پاسخ: معمولاً userId را به‌عنوانِ shard key می‌گذارم تا همه‌ی داده‌ی یک کاربر روی یک shard باشد و خواندنِ فید یک shard را بزند (locality خوب). مشکل: hot shard. اگر یک shard تصادفاً چند کاربرِ خیلی فعال یا سلبریتی را کنارِ هم بگذارد، آن shard داغ می‌شود و بقیه بیکار. راه‌حل: (۱) hashِ خوب برای توزیعِ یکنواخت، (۲) سلبریتی‌ها را از این مسیر جدا کن (همان hybrid)، (۳) consistent hashing تا هنگامِ اضافه‌شدنِ shard جدید، جابه‌جاییِ داده کمینه شود. تله‌ای که باید نام ببری: shard-key را هیچ‌وقت روی چیزی که مدام رشد می‌کند (مثل timestamp) نگذار، چون همه‌ی نوشتن‌های جدید روی آخرین shard می‌ریزد — «hotspotِ متحرک». سنیور همیشه می‌پرسد «توزیعِ این کلید یکنواخت است یا skewed؟».

چرا hybrid برنده است

fan-out on write برای «توده» بهینه است چون آن‌ها دنبال‌کننده‌ی کم دارند و نوشتن ارزان است. fan-out on read برای «سلبریتی» بهینه است چون نوشتنِ میلیونی را حذف می‌کند و در عوض یک fetchِ ارزان هنگامِ خواندن دارد (سلبریتی‌ها کم‌اند، پس merge سبک است). hybrid هزینه‌ی هر دو را کم می‌کند — این همان قضاوتی است که سنیور را از میدِل جدا می‌کند: «یک اندازه برای همه» جواب نمی‌دهد؛ رفتار را بر اساسِ توزیع تقسیم کن.

تله‌ی fan-out on write که کم گفته می‌شود

نه فقط سلبریتی — کاربرانِ غیرفعال هم هزینه دارند. اگر پست را به timelineِ ۱۰k دنبال‌کننده بنویسی که ۹۰٪‌شان ماه‌هاست login نکرده‌اند، ۹۰٪ کارَت را دور ریخته‌ای. راه‌حل: فقط برای کاربرانِ «فعالِ اخیر» push کن؛ بقیه هنگامِ برگشتن، فیدشان را pull بسازند. این بهینه‌سازی در مقیاس میلیون‌ها دلار در هزینه‌ی storage و compute صرفه‌جویی می‌کند.

مرزِ سلبریتی را چطور تعیین می‌کنی و چه اتفاقی سرِ مرز می‌افتد؟

پرسش: گفتی زیرِ ۱۰k فالوور push و بالاتر pull. این عدد از کجا آمد و اگر کاربری دقیقاً روی مرز نوسان کند چه؟

پاسخ: عدد جادویی نیست؛ از هزینه می‌آید. نقطه‌ی سربه‌سر جایی است که هزینه‌ی fan-out on write (تعدادِ فالوور × نرخِ پست) با هزینه‌ی fan-out on read (نرخِ خواندنِ فالوورها × هزینه‌ی merge) برابر شود. عملاً با اندازه‌گیریِ ترافیکِ واقعی تنظیمش می‌کنی، نه با حدس. برای نوسانِ سرِ مرز، hysteresis می‌گذاری: مثلاً بالای ۱۲k به pull سوییچ کن و زیرِ ۸k به push برگرد، تا مدام flip نشود. نکته‌ی مهم که باید بگویی: این تصمیم را per-user و به‌صورتِ dynamic می‌گیری، نه یک flag سراسری — و آن را بر اساسِ متریکِ واقعی monitor می‌کنی.


طراحیِ ۴ — Chat / Messaging (پیام‌رسان: تحویل و presence)

پیام‌رسان real-time است و مسائلِ خاصِ خودش را دارد: اتصالِ پایدار، تحویلِ تضمین‌شده، ترتیب، و presence (آنلاین/آفلاین).

گام ۱ — نیازمندی‌ها

Functional: (۱) پیامِ ۱-به-۱ و گروهی. (۲) delivery status (sent/delivered/read). (۳) presence (کی آنلاین است). (۴) تاریخچه‌ی پیام. Non-functional: (۱) تأخیرِ پایین (real-time). (۲) تحویلِ قابل‌اعتماد — پیام نباید گم شود. (۳) ترتیبِ within-conversation حفظ شود.

چرا WebSocket نه HTTP polling

HTTP request/response برای پیامِ real-time بد است: یا مدام poll می‌کنی (اتلاف) یا long-poll که سنگین است. WebSocket یک اتصالِ دوطرفه‌ی پایدار می‌دهد؛ سرور می‌تواند بدونِ درخواستِ client پیام push کند. در Spring، WebSocket + STOMP استاندارد است.

@Configuration
@EnableWebSocketMessageBroker
public class WsConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("https://app.example.com")
                .withSockJS(); // fallback for old browsers
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // for a single node the simple broker is fine;
        // for a cluster relay to RabbitMQ/ActiveMQ STOMP broker instead
        registry.enableSimpleBroker("/topic", "/queue");
        registry.setApplicationDestinationPrefixes("/app");
        registry.setUserDestinationPrefix("/user"); // per-user private queues
    }
}
WebSocket و مقیاس افقی — تله‌ی بزرگ

یک اتصالِ WebSocket stateful است: کاربر به یک instanceِ خاص وصل است. اگر کاربر A روی instance-1 و کاربر B روی instance-2 باشد، وقتی A به B پیام می‌دهد، instance-1 باید بداند B کجاست. enableSimpleBroker (in-memory) در این حالت کار نمی‌کند چون هر instance فقط اتصالاتِ خودش را می‌شناسد. راه‌حل: یک message broker relay (RabbitMQ/Redis Pub/Sub/Kafka) بین instanceها که پیام را به instanceِ درست route کند. این را در dev با یک instance نمی‌بینی و فقط بعد از scale-out می‌شکند — کلاسیک‌ترین تله‌ی chat.

تحویلِ قابل‌اعتماد و ترتیب

پیام باید حتی اگر گیرنده آفلاین است گم نشود. الگو: پیام اول در دیتابیس persist می‌شود (source of truth)، بعد push می‌شود. اگر گیرنده آفلاین بود، دفعه‌ی بعد که وصل شد، پیام‌های معوق را از دیتابیس pull می‌کند. برای ترتیب، هر پیام یک sequence number در سطحِ conversation دارد.

دیاگرام: مسیرِ ارسالِ یک پیام با persist-then-push (Persian: ذخیره پیش از تحویل، تحویلِ آفلاین از دیتابیس).

sequenceDiagram
  participant A as Sender
  participant S as Chat Service
  participant DB as Message Store
  participant B as Receiver
  A->>S: send(msg, convId)
  S->>DB: persist(msg, seq)
  DB-->>S: ok (source of truth)
  S-->>A: ack (sent)
  alt receiver online
    S->>B: push(msg)
    B-->>S: delivered
  else receiver offline
    Note over S,B: stored — delivered on next connect
  end

Presence (آنلاین/آفلاین)

presence سخت‌تر از چیزی است که به‌نظر می‌رسد. ساده‌ترین راه: هر client هر چند ثانیه یک heartbeat می‌فرستد؛ سرور آخرین heartbeat را در Redis با TTL نگه می‌دارد. اگر TTL منقضی شد، کاربر آفلاین است.

// on each heartbeat / activity
redis.opsForValue().set("presence:" + userId, "online",
        Duration.ofSeconds(30));   // key auto-expires if no heartbeat
// to check:
boolean online = redis.hasKey("presence:" + userId);
presence در مقیاس، fan-out را منفجر می‌کند

اگر بخواهی «هر تغییرِ presence را به همه‌ی دوستانِ کاربر push کنی»، یک کاربرِ ۵۰۰۰ دوست که هر ۳۰ ثانیه online/offline flicker می‌کند، سیلی از پیام می‌سازد. راه‌حل‌های واقعی: (۱) presence را فقط برای conversationهای باز push کن، نه همه‌ی دوستان. (۲) تغییرات را batch/debounce کن. (۳) به‌جای push، presence را lazy و pull-on-demand کن (وقتی کاربر چت را باز کرد، وضعیت را بپرس). سنیور می‌داند presence یک فیچرِ «کوچک» است که می‌تواند سیستم را زمین بزند.

تحویلِ «دقیقاً یک‌بار» (exactly-once) را چطور تضمین می‌کنی؟

پرسش: چطور مطمئن می‌شوی هر پیام دقیقاً یک‌بار به گیرنده می‌رسد، نه صفر بار نه دوبار؟

پاسخ: جوابِ صادقانه: exactly-once تحویلِ سراسری تقریباً غیرممکن است؛ چیزی که واقعاً می‌سازی at-least-once + idempotency = effectively-once است. سمتِ سرور، پیام را قبل از ack پایدار کن (at-least-once به سمتِ فرستنده). سمتِ گیرنده، هر پیام یک messageId یکتا (client-generated، مثل UUID) دارد؛ گیرنده idهای دیده‌شده را نگه می‌دارد و تکراری‌ها را drop می‌کند (dedup). اگر ack گم شود و فرستنده دوباره بفرستد، messageId تکراری تشخیص داده می‌شود. پس ترکیب: persist قبل از ack + dedup با idempotency key. در اینترویو حتماً بگو «exactly-once یک افسانه است؛ من effectively-once می‌سازم» — این نشانه‌ی بلوغِ واقعی است.


طراحیِ ۵ — Notification System (سیستمِ اعلان)

اعلان (push/SMS/email/in-app) زیرساختی است که تقریباً هر محصول لازم دارد و ظرافت‌های زیادی دارد: چند کانال، rate limiting، اولویت، و idempotency.

گام ۱ — نیازمندی‌ها

Functional: (۱) ارسال از چند کانال: push (APNs/FCM)، SMS، email، in-app. (۲) قالب‌ها (templates). (۳) ترجیحاتِ کاربر (opt-out per channel). (۴) اولویت (transactional مثل OTP در برابرِ marketing). Non-functional: (۱) قابل‌اعتماد — یک OTP نباید گم شود. (۲) مقیاس — میلیون‌ها اعلان. (۳) هیچ اعلانِ تکراری (idempotency). (۴) عدم spam (rate limit per user).

معماری — decoupling با صف

هسته‌ی طراحی: async با message queue. سرویسی که می‌خواهد اعلان بفرستد، فقط یک event در صف می‌گذارد و برمی‌گردد؛ فرستادنِ واقعی را workerها انجام می‌دهند. این producer را از کندی/خرابیِ providerها جدا می‌کند.

دیاگرام: معماریِ سیستمِ اعلان با صف و workerهای per-channel (Persian: تفکیک تولیدکننده از تحویل، هر کانال worker مستقل).

flowchart LR
  Svc[Any Service] -->|notify event| Q[(Kafka / Queue)]
  Q --> Router[Notification Router]
  Router -->|check prefs + rate limit| Prefs[(Preferences DB)]
  Router --> PushW[Push Worker] --> FCM[FCM / APNs]
  Router --> SmsW[SMS Worker] --> Twilio[SMS Provider]
  Router --> MailW[Email Worker] --> SES[Email Provider]
  Router --> Dedup[(Dedup / Idempotency Store)]

idempotency — قلبِ قابلیت‌اعتماد

اگر producer به‌خاطرِ retry یک event را دوبار بفرستد، کاربر نباید دو OTP بگیرد. هر اعلان یک idempotencyKey یکتا دارد؛ قبل از ارسال، worker چک می‌کند این key قبلاً پردازش شده یا نه (در Redis با TTL).

public void handle(Notification n) {
    // SETNX-style: only the first caller wins
    Boolean fresh = redis.opsForValue()
        .setIfAbsent("notif:" + n.idempotencyKey(), "1", Duration.ofHours(24));
    if (Boolean.FALSE.equals(fresh)) {
        log.info("duplicate notification {}, skipping", n.idempotencyKey());
        return;                       // already sent
    }
    channelFor(n.channel()).send(n); // send exactly once (effectively)
}
provider سومِ شخص خواهد شکست — برایش برنامه بریز

providerهای SMS/email/push (Twilio، FCM، SES) گاهی کند می‌شوند، rate limit می‌زنند، یا موقتاً پایین‌اند. اگر مستقیم و sync صدایشان بزنی، هر hiccup به کلِ سیستمِ تو سرایت می‌کند. الزامات: (۱) retry با exponential backoff برای خطاهای گذرا. (۲) circuit breaker تا وقتی provider خراب است، مدام نکوبی. (۳) dead-letter queue برای پیام‌هایی که بعد از N تلاش نرفتند تا بعداً بررسی شوند. (۴) timeout روی هر تماس. بدونِ این‌ها، یک قطعیِ ۵ دقیقه‌ایِ Twilio می‌تواند صفِ تو را برای ساعت‌ها عقب بیندازد.

اولویت‌بندی: OTP نباید پشتِ marketing گیر کند

یک تله‌ی رایج: یک صفِ واحد برای همه‌ی اعلان‌ها. وقتی یک کمپینِ marketing با ۱۰ میلیون پیام صف را پر می‌کند، OTPِ لاگینِ کاربر پشتِ آن‌ها منتظر می‌ماند و کاربر نمی‌تواند وارد شود. راه‌حل: صف‌های جدا بر اساسِ اولویت (transactional/high در برابرِ marketing/bulk)، با workerها و منابعِ مجزا. transactional همیشه اولویت دارد. سنیور همیشه می‌پرسد «آیا OTP و newsletter از یک مسیر می‌روند؟» — اگر بله، مشکل داری.

چطور از spam-کردنِ کاربر جلوگیری می‌کنی؟

پرسش: چطور مطمئن می‌شوی کاربر با اعلان بمباران نمی‌شود؟

پاسخ: چند لایه. (۱) ترجیحاتِ کاربر: قبل از ارسال، opt-out per channel را چک کن (کاربر marketing email را خاموش کرده؟). (۲) rate limiting per user per channel: مثلاً حداکثر X push در ساعت — همان token bucket که در طراحیِ ۲ ساختیم، اینجا per user. (۳) aggregation/digest: به‌جای ۱۰ اعلانِ جدا برای ۱۰ like، یکی بفرست «۱۰ نفر پست‌ت را پسندیدند». (۴) quiet hours: اعلانِ غیرضروری را نیمه‌شب نفرست. نکته‌ای که سنیور اضافه می‌کند: transactional (OTP، هشدارِ امنیتی) را از این محدودیت‌ها مستثنا کن — این‌ها را همیشه بفرست، وگرنه کاربر نمی‌تواند وارد شود. تفکیکِ transactional از promotional کلِ ماجراست.


طراحیِ ۶ — E-commerce Order Flow (جریانِ سفارش در چند سرویس)

این طراحی همه‌چیز را کنار هم می‌گذارد: چند سرویس، تراکنشِ توزیع‌شده، consistency، و جبران (compensation). اینجا جایی است که فهمِ saga و outbox خودش را نشان می‌دهد.

گام ۱ — نیازمندی‌ها

Functional: مشتری checkout می‌کند؛ سیستم باید: (۱) موجودی را رزرو کند (Inventory)، (۲) پرداخت را بگیرد (Payment)، (۳) سفارش را ثبت کند (Order)، (۴) ارسال را شروع کند (Shipping)، (۵) اعلان بفرستد. Non-functional: (۱) consistency: نباید پول بگیری ولی موجودی نداشته باشی، یا موجودی رزرو کنی ولی پرداخت نگیری. (۲) قابل‌اعتماد و قابلِ retry. (۳) قابلِ رهگیری (کجای فرایند هستیم).

چالشِ اصلی — تراکنش در چند سرویس

در یک مونولیت، این‌ها یک @Transactional بود و همه‌چیز atomic. اما هر سرویس دیتابیسِ خودش را دارد؛ یک تراکنشِ ACIDِ سراسری وجود ندارد (2PC کند و شکننده است و در مقیاس اجتناب می‌شود). راه‌حل: saga — یک دنباله از تراکنش‌های محلی که هرکدام یک event منتشر می‌کند؛ اگر یک مرحله شکست، مراحلِ قبلی با compensating transaction جبران (undo) می‌شوند.

saga مثل رزروِ سفرِ چندمرحله‌ای

می‌خواهی سفر بروی: بلیت هواپیما، هتل، ماشین کرایه. اگر هتل رزرو نشد، باید بلیت هواپیما را کنسل کنی (جبران). saga همین است: هر مرحله جدا انجام می‌شود، و اگر جایی گیر کرد، مراحلِ قبلی را «به عقب باز می‌کنی». برخلافِ تراکنشِ دیتابیس که rollbackِ خودکار دارد، اینجا undo را تو باید صریحاً بنویسی — چون پول واقعاً منتقل شده و باید واقعاً برگردانده شود.

Orchestration در برابرِ Choreography

دو سبکِ saga:

  • Choreography: هر سرویس به eventهای دیگران گوش می‌دهد و واکنش نشان می‌دهد؛ کنترلِ مرکزی ندارد. ساده برای فلوهای کوتاه، اما در فلوهای بلند فهمِ «الان کجاییم» سخت می‌شود (منطق در همه‌جا پخش است).
  • Orchestration: یک orchestrator مرکزی (مثلاً Order Service یا یک state machine) مراحل را هدایت می‌کند و می‌داند کجا هستیم. برای فلوهای پیچیده‌ی چندمرحله‌ای بهتر است چون منطق یک‌جاست و رهگیری آسان.
کدام را انتخاب کنم؟

برای order flow که ۴–۵ مرحله و منطقِ جبرانِ پیچیده دارد، من orchestration را ترجیح می‌دهم: یک Order Saga Orchestrator که state machine را نگه می‌دارد. مزیت: وقتی چیزی خراب می‌شود، یک‌جا نگاه می‌کنی و می‌فهمی سفارش در چه وضعیتی گیر کرده. choreography برای ۲–۳ سرویسِ ساده خوب است، اما در مقیاس به «distributed monolith» تبدیل می‌شود که هیچ‌کس نمی‌فهمد جریان چطور کار می‌کند. در اینترویو هر دو را نام ببر و انتخابت را با «قابلیتِ رهگیری» توجیه کن.

دیاگرام: چرخه‌ی حیاتِ یک سفارش به‌صورتِ state machine (Persian: وضعیت‌های سفارش و مسیرِ جبران هنگامِ شکست).

stateDiagram-v2
  [*] --> Created
  Created --> InventoryReserved: reserve ok
  InventoryReserved --> PaymentCaptured: payment ok
  PaymentCaptured --> Confirmed: order confirmed
  Confirmed --> Shipped: shipping started
  Shipped --> [*]
  InventoryReserved --> Cancelled: payment failed / compensate release stock
  PaymentCaptured --> Refunding: downstream failed / refund
  Refunding --> Cancelled
  Created --> Cancelled: out of stock

مشکلِ dual-write و راه‌حلِ Outbox

اینجا ظریف‌ترین باگِ سیستم‌های event-driven است. Order Service می‌خواهد هم در دیتابیسِ خودش بنویسد (سفارش ثبت شد) و هم یک event به Kafka بفرستد (تا سرویس‌های بعدی خبردار شوند). اگر اول در DB بنویسی و بعد به Kafka بفرستی، و بینِ این دو crash کنی؟ سفارش ثبت شده اما هیچ‌کس خبردار نشد — سفارشِ «گم‌شده». اگر برعکس کنی، event رفته اما سفارش ثبت نشده — event برای سفارشی که وجود ندارد.

راه‌حل: Transactional Outbox. event را در همان تراکنشِ دیتابیس، در یک جدولِ outbox بنویس. چون هر دو نوشتن در یک تراکنشِ محلیِ ACID اند، یا هر دو انجام می‌شوند یا هیچ‌کدام. بعد یک فرایندِ جدا (polling یا CDC مثل Debezium) جدولِ outbox را می‌خواند و به Kafka منتشر می‌کند.

@Transactional
public Order placeOrder(OrderRequest req) {
    Order order = orderRepository.save(Order.created(req));   // write 1
    OutboxEvent evt = OutboxEvent.of(
        "OrderCreated", order.getId(), toJson(order));
    outboxRepository.save(evt);                               // write 2 -- SAME tx
    return order;
    // both commit atomically; a separate relay ships evt to Kafka later
}
-- PostgreSQL: outbox table
CREATE TABLE outbox (
    id            BIGSERIAL PRIMARY KEY,
    aggregate_id  BIGINT      NOT NULL,
    event_type    VARCHAR(64) NOT NULL,
    payload       JSONB       NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    published     BOOLEAN     NOT NULL DEFAULT false
);
-- Oracle: outbox table
CREATE TABLE outbox (
    id            NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    aggregate_id  NUMBER        NOT NULL,
    event_type    VARCHAR2(64)  NOT NULL,
    payload       CLOB          NOT NULL,   -- or JSON type in 21c+
    created_at    TIMESTAMP     DEFAULT SYSTIMESTAMP NOT NULL,
    published     NUMBER(1)     DEFAULT 0 NOT NULL
);
تفاوتِ دیالکت در outbox

PostgreSQL نوعِ بومیِ JSONB (باینری، قابلِ index با GIN) و BIGSERIAL برای auto-increment دارد. Oracle تا 21c نوعِ JSON بومی دارد (قبل‌تر CLOB با چکِ IS JSON)، و identity column با GENERATED ... AS IDENTITY. برای boolean، PostgreSQL نوعِ BOOLEAN واقعی دارد اما Oracle تا 23ai در SQL نداشت و از NUMBER(1) (0/1) استفاده می‌شد — Oracle 23ai بالاخره BOOLEAN را در SQL آورد.

idempotency در مصرف‌کننده اجباری است

outbox تضمین می‌کند event حداقل یک‌بار منتشر می‌شود — یعنی گاهی بیش از یک‌بار (اگر relay بعد از publish و قبل از mark-as-published کرش کند). پس هر مصرف‌کننده باید idempotent باشد: Payment Service نباید یک سفارش را دوبار شارژ کند فقط چون OrderCreated را دوبار دید. راهِ استاندارد: هر consumer یک جدولِ processed_events(event_id) نگه می‌دارد و قبل از پردازش چک می‌کند. این را در اینترویو حتماً بگو: «outbox = at-least-once، پس consumer باید idempotent باشد» — بدونِ این جمله، طراحیت ناقص است.

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

پرسش: در order saga، پول را گرفتی اما مرحله‌ی بعد (مثلاً تأییدِ نهایی) شکست خورد. چه می‌کنی؟

پاسخ: compensating transaction. saga برعکس اجرا می‌شود: اگر پرداخت انجام شد اما بعدش شکست خوردیم، یک refund صادر می‌شود (جبرانِ پرداخت)، و اگر موجودی رزرو شده بود، release stock. نکته‌ی مهم: compensationها هم می‌توانند شکست بخورند (refund provider پایین است)، پس باید retryable و idempotent باشند و در نهایت اگر نشد به یک صفِ «نیازِ دخالتِ انسانی» بروند. ترتیب هم مهم است: جبران‌ها را به ترتیبِ معکوسِ اجرا انجام بده. و برخی مراحل جبران‌ناپذیرند (ایمیلِ ارسالی را نمی‌توانی پس بگیری) — این‌ها را تا حدِ ممکن به آخرِ saga منتقل کن تا کمترین چیز نیاز به undo داشته باشد. سنیور می‌گوید: «saga را طوری بچین که عملیاتِ برگشت‌ناپذیر آخر باشند.»

چطور جلوی سفارشِ تکراری هنگامِ دابل‌کلیکِ کاربر را می‌گیری؟

پرسش: کاربر دکمه‌ی «ثبتِ سفارش» را دوبار می‌زند، یا شبکه‌اش قطع می‌شود و retry می‌کند. چطور دو سفارش ثبت نشود؟

پاسخ: idempotency key. client یک کلیدِ یکتا (UUID) برای هر تلاشِ checkout می‌سازد و در header می‌فرستد (Idempotency-Key). سرور قبل از پردازش چک می‌کند این کلید قبلاً دیده شده یا نه؛ اگر بله، همان نتیجه‌ی قبلی را برمی‌گرداند (نه یک سفارشِ جدید). این را در یک جدول یا Redis با unique constraint پیاده می‌کنی، طوری که insertِ دوم روی constraint شکست بخورد. این دقیقاً همان الگویی است که Stripe و درگاه‌های پرداختِ واقعی برای همین سناریو دارند — چون در پرداخت، «دوبار شارژ» یک فاجعه‌ی واقعی است. نکته‌ی سنیور: کلید را client بسازد نه server، چون فقط client می‌داند «این همان تلاشِ قبلی است یا یک سفارشِ جدید».


زبانِ روایتِ trade-off: چطور یک سنیور بلند فکر می‌کند

تا اینجا شش سیستم ساختیم. اما آنچه در اینترویو تو را سنیور نشان می‌دهد، خودِ طراحی نیست — نحوه‌ی حرف‌زدن درباره‌ی آن است. اینجا چند الگوی زبانی که سنیورها استفاده می‌کنند:

۱) همیشه هزینه را کنارِ فایده بگذار. هیچ‌وقت نگو «cache می‌گذارم». بگو «cache تأخیر را کم می‌کند اما یک مسئله‌ی consistency می‌آورد (داده‌ی کهنه) و یک لایه‌ی خرابیِ جدید — و این trade-off را قبول می‌کنم چون این داده read-heavy است و staleness چند ثانیه‌ای مشکلی ندارد.» ساختار: «X را انتخاب می‌کنم، که Y را بهتر می‌کند به قیمتِ Z، و Z اینجا قابل‌قبول است چون W.»

۲) صریح درباره‌ی CAP و consistency حرف بزن. بگو کجا strong consistency لازم است (پرداخت، موجودی) و کجا eventual کافی است (فید، like count). این نشان می‌دهد می‌فهمی همه‌جا نمی‌شود strong داشت.

۳) گلوگاه را قبل از مصاحبه‌گر پیدا کن. بعد از کشیدنِ معماری، خودت بگو «single point of failure اینجا Redis است؛ اگر بیفتد، این اتفاق می‌افتد و راهِ mitigation این است.» پیش‌دستی در یافتنِ ضعف، اعتماد می‌سازد.

۴) مقیاس را مرحله‌ای بگو. «برای V1 یک PostgreSQL کافی است. وقتی به X QPS رسیدیم، read replica اضافه می‌کنم. وقتی write هم گلوگاه شد، shard می‌کنم بر اساسِ این کلید.» این نشان می‌دهد over-engineer نمی‌کنی و مسیرِ رشد را می‌بینی.

جمله‌هایی که سنیور را لو می‌دهند

«بستگی دارد» — و بعد توضیح بده به چه. «برای V1 این کافی است» — نشان‌دهنده‌ی ضدِ over-engineering. «trade-off اینجا این است...» — صریح‌سازیِ هزینه. «این را با یک متریک monitor می‌کنم» — تفکرِ عملیاتی. «اگر اشتباه کنم، اینطور می‌فهمم و اینطور برمی‌گردم» — تفکرِ reversible. برعکس، جمله‌هایی که میدِل را لو می‌دهند: «همیشه از X استفاده کن»، «X بهترین است»، «Kafka چون scalable است» بدونِ گفتنِ چرا.

کجای این طراحی‌ها strong consistency لازم است و کجا eventual کافی است؟

پرسش: درباره‌ی CAP حرف زدی. در همین شش سیستم، دقیقاً کجا strong consistency می‌خواهی و کجا eventual را می‌پذیری؟

پاسخ: قاعده‌ی من: strong آنجا که پول یا موجودی درگیر است، eventual همه‌جای دیگر. در order flow، کسرِ موجودی و شارژِ پرداخت باید strong باشند (نباید دوبار بفروشی یا دوبار شارژ کنی) — اینجا حاضرم availability یا latency را فدا کنم. اما شمارنده‌ی like، تعدادِ view، و رسیدنِ پستِ دوست به فید، eventual کافی است؛ چند ثانیه تأخیر یا کمی ناهماهنگی مشکلی ندارد و در عوض availability و مقیاس می‌گیرم. در rate limiter، کمی خطا (چند درخواستِ اضافه هنگامِ race) قابل‌قبول است، پس آنجا هم سمتِ availability می‌روم (fail-open). نکته‌ی کلیدی که سنیور می‌گوید: consistency یک تصمیمِ per-operation است، نه per-system — در یک سیستم می‌توانی هم مسیرِ strong داشته باشی هم eventual، و هنر این است که بدانی کدام داده کدام را می‌خواهد.

دام‌های رایج در اینترویوِ system design

(۱) پریدن به راه‌حل بدونِ روشن‌کردنِ نیازمندی. (۲) over-engineering — کشیدنِ Kafka و Kubernetes برای مسئله‌ای که ۱۰۰ QPS دارد. (۳) بی‌توجهی به failure — طراحیِ happy path و بی‌جواب‌ماندن سرِ «اگر این بیفتد چه؟». (۴) نگفتنِ عدد — «خیلی زیاد» جای «۱۰۰k QPS» نمی‌نشیند. (۵) سکوت — مصاحبه‌گر فکرِ تو را نمی‌بیند؛ بلند فکر کن. (۶) لجاجت — وقتی مصاحبه‌گر یک محدودیتِ جدید می‌آورد، طراحی را وفق بده، دفاعِ کور نکن.

جمع‌بندی

system design آزمونِ قضاوت است، نه حفظیات. برای هر مسئله همان هفت گام را برو: نیازمندی → تخمین → API → مدلِ داده → معماری → deep-dive → گلوگاه و trade-off. تخمین را سریع و order-of-magnitude بزن؛ چند عددِ پایه (۱۰⁵ ثانیه در روز، RAM ~۱۰۰ns، round trip ~۰٫۵ms) کافی است. در URL shortener هسته تولیدِ IDِ یکتا بود (Snowflake در برابرِ range allocation، با آگاهی از clock skew و حدس‌پذیری). در rate limiter انتخابِ الگوریتم (token bucket / sliding window) و توزیع‌شدگی با Redis+Luaی atomic، و تصمیمِ fail-open/closed. در news feed رقصِ fan-out on write در برابرِ read و راه‌حلِ hybrid برای سلبریتی. در chat اتصالِ statefulِ WebSocket و نیاز به broker relay، persist-then-push، و effectively-once با idempotency. در notification تفکیک با صف، صف‌های اولویت‌دار، idempotency، و مقاوم‌سازی در برابرِ providerهای شخصِ ثالث. در order flow ساگا با orchestration، الگوی outbox برای dual-write، و compensating transactionهای idempotent. و بالاتر از همه: زبانِ trade-off — هزینه را کنارِ فایده بگذار، گلوگاه را خودت پیدا کن، مقیاس را مرحله‌ای بگو، و بلند فکر کن. این‌طور مثلِ سنیور طراحی می‌کنی و مثلِ سنیور صحبت می‌کنی.

Let me start with a truth many people learn too late: a system design interview is not a knowledge test — it is a judgment test. The interviewer does not want to see that you can name ten databases; they want to see how you take a vague, unbounded problem and break it into buildable pieces, what you ask, where you make trade-offs, and whether you understand the cost of every decision.

Here is what separates an experienced mid-level engineer from a senior. The mid-level says "we'll use Kafka because it's scalable." The senior says "Kafka makes the read path async here and raises throughput, but ordering is only guaranteed per-partition, so if I need global ordering I'd have to change the key or use a single partition — which is itself a bottleneck — and since I won't accept that trade-off, I'll solve ordering at the business level with a version number." Same decision, but with full awareness of the cost.

This chapter is a workshop. We fully design six real systems — the ones actually asked in senior interviews and actually built in production. For each we walk the same path, and along the way we practice the language a senior uses to narrate decisions.

Roadmap for this chapter
  1. A repeatable interview framework — the seven steps that work for any problem. 2) Back-of-envelope estimation — the numbers to memorize and how to reason with them. 3) Six complete designs: (a) URL shortener, (b) distributed rate limiter, (c) news feed / timeline with fan-out, (d) chat / messaging with delivery and presence, (e) notification system, (f) e-commerce order flow with saga and outbox. 4) The language of trade-off narration — how a senior thinks out loud and justifies a decision. Each case goes requirements → estimation → API → data model → architecture (Mermaid) → scaling → bottlenecks → trade-offs.

The repeatable interview framework (seven steps)

Before any design, keep this map in mind. Whatever you call it (some call it RESHADED or PEDALS) doesn't matter; what matters is that you always start from step one, not the middle. The biggest candidate mistake: the interviewer says "design a URL shortener" and the candidate immediately jumps to "I'll use Cassandra." That means you didn't understand the problem.

Architect, not bricklayer

A good architect, when you want to build a house, doesn't first ask "brick or concrete?". They first ask "how many people will live here? what's the budget? is it an earthquake zone? do you want to add a floor later?". Material is the last decision, not the first. System design is the same: requirements and scale first, technology later.

Step 1 — Clarify requirements (functional & non-functional). Spend a few minutes asking. Functional is "what the system does" (shorten a link, redirect). Non-functional is "how well it does it" (what latency? what availability? how much consistency?). These, not the features, drive the architecture.

Step 2 — Estimate scale (back-of-envelope). QPS, data volume, bandwidth, memory. It needn't be exact; the order of magnitude must be right. The difference between 100 QPS and 100,000 QPS completely changes the architecture.

Step 3 — Design the API. The external contract. Endpoints, methods, parameters. This forces you to think precisely about what the system actually offers.

Step 4 — Data model and storage choice. What entities? SQL or NoSQL? Why? Index on what?

Step 5 — High-level architecture. One diagram: client → LB → service → cache → DB. Main components and data flow.

Step 6 — Deep-dive on one or two hard spots. The interviewer usually probes a specific area: "how do you generate unique IDs?", "what about the celebrity?". Here you show your depth.

Step 7 — Bottlenecks, scale, trade-offs. Where is the single point of failure? How do you scale? What did you trade for what?

Manage time like a senior

In a 45-minute interview: ~5 min requirements, ~5 min estimation, ~5 min API and data model, ~15 min architecture and deep-dive, ~10 min scaling and trade-offs, ~5 min wrap-up and questions. If you spend 20 minutes on estimation, you've lost. A senior estimates quickly and "good enough" and moves to the interesting parts.

Why not jump straight to technology?

Question: The interviewer says "design a system." Why is it wrong to immediately say "I'll use microservices, Kafka, and Cassandra"?

Answer: Because you don't yet know the problem. Technology is a solution, and a solution without understanding the problem is guessing. If scale is 100 QPS, one PostgreSQL on one instance is plenty and Cassandra is over-engineering. If it's 100k QPS, the architecture differs. A senior clarifies scope first, because unnecessary complexity is itself a bug: every extra component is a failure point, a maintenance cost, and a cognitive load on the team. The golden line in an interview: "Before choosing storage, let me ask a few questions to understand the scale and read/write pattern."


Back-of-envelope estimation: the numbers you must know

Estimation isn't scary if you memorize a few base numbers. The whole trick: convert monthly/daily into per-second, separate reads from writes, and derive memory/bandwidth from QPS.

Quantity Approx value
Seconds in a day ~86,400 ≈ 10⁵
Seconds in a month ~2.5 million
A day, rounded, in seconds ~10⁵
Peak-to-average ratio ~2x to 3x
One ASCII char 1 byte
One UUID 16 bytes
One long 8 bytes

Conversion rule: if you have X requests per day, average QPS ≈ X / 10⁵. E.g. 100 million/day → 10⁸/10⁵ = 1000 QPS average, peak ~2–3k.

Latency numbers seniors reason with (the classic "Latency Numbers Every Programmer Should Know", orders of magnitude):

Operation Approx latency
L1 cache reference ~1 ns
Main memory (RAM) reference ~100 ns
SSD random read 16 µs (100k IOPS)
Round trip within a datacenter ~0.5 ms
Redis GET (local network) ~1 ms
Indexed DB query (SSD) a few ms
Intercontinental round trip (e.g. CA↔Netherlands) ~150 ms
Disk seek (spinning HDD) ~10 ms
Why these numbers matter in narration

When you say "I'll add a cache," a senior has a number behind it: "RAM is ~100 ns and disk is thousands of times slower, so if the cache hit rate is 95%, average latency drops from ~5ms to under 1ms." That single sentence is the difference between "cache is good" (a slogan) and "the cache helps this much" (engineering). They never want the exact number; they want the order of magnitude.

The storage-estimation trap

A common mistake: you compute only the record size and forget overhead. A 100-byte record, with index, replication, and padding, effectively takes 2–3x more space. Always add a ~2–3x factor for overhead and ~3x for replication (three copies), or you'll under-estimate capacity and run out of disk in production.

On the whiteboard, how do you quickly estimate QPS and storage?

Question: The interviewer says "we have 500 million users, each uploads 10 photos/day, each photo 2MB." Quickly give write QPS and yearly storage.

Answer: I compute out loud, step by step. Writes/day = 5×10⁸ × 10 = 5×10⁹. Divide by ~10⁵ seconds = 5×10⁴ = 50k write QPS average, and I take peak as ~2–3x → ~150k. Storage: 5×10⁹ photos × 2MB = 10¹⁰ MB = 10PB per day? No — let me re-check: 5×10⁹ × 2×10⁶ bytes = 10¹⁶ bytes = 10PB/day, which is unrealistic, so I challenge the assumption: likely it means 500 million users with ~1% uploading daily. The senior tell: when the number becomes absurd, question the assumption, not the calculator. Always compute out loud so the interviewer sees your reasoning and can correct you mid-way if you slipped.


Design 1 — URL Shortener

The most classic question, and deceptive: it looks simple but has deep points (unique ID generation, being read-heavy, mapping durability).

Step 1 — Requirements

Functional: (1) give a long URL, get a short code. (2) redirect from short code to the original. (3) optional: custom alias, expiry (TTL), click analytics. Non-functional: (1) redirect must be very fast (low latency). (2) read-heavy: read-to-write ratio around 100:1 or higher (a link is created once, opened thousands of times). (3) codes unique and (somewhat) unguessable. (4) high availability; if redirect fails, every link breaks.

Step 2 — Estimation

Assume 100 million new links per month. Write QPS = 10⁸ / 2.5×10⁶ ≈ 40/s. With read:write = 100:1 → reads ≈ 4000/s (peak ~10k). Over 5 years: 10⁸ × 60 months = 6 billion records. Each record ~500 bytes (code + URL + metadata) → ~3TB of raw data, with overhead and replication ~10TB. This is "large but not gigantic"; a well-sharded database suffices.

The key insight of this design

The real problem of a URL shortener is ID generation, not storage. If you can produce a unique short number per link, the rest is a simple key-value lookup. So the interview depth is here: how do you generate a unique ID without global coordination?

Step 3 — API

POST /api/v1/urls
  body: { "longUrl": "https://...", "customAlias": "promo", "ttlDays": 30 }
  201 -> { "shortUrl": "https://sho.rt/aX9bQ2", "code": "aX9bQ2" }

GET /{code}
  302 Found, Location: <longUrl>   // redirect

A subtle point: 302 (temporary) or 301 (permanent)? A senior notes that browsers cache 301 and stop hitting your server — great for load, terrible for click analytics. So if you want analytics, use 302 (or 307) so every click reaches you.

Step 4 — Data model

A simple key-value: key = code, value = longUrl + metadata. For this workload PostgreSQL is entirely enough (into the billions with sharding); but if writes become truly massive, a key-value store like Cassandra/DynamoDB is reasonable.

-- PostgreSQL
CREATE TABLE url_map (
    code        VARCHAR(11) PRIMARY KEY,
    long_url    TEXT        NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at  TIMESTAMPTZ,
    owner_id    BIGINT
);
CREATE INDEX idx_url_map_expires ON url_map (expires_at) WHERE expires_at IS NOT NULL;
-- Oracle
CREATE TABLE url_map (
    code        VARCHAR2(11) PRIMARY KEY,
    long_url    CLOB         NOT NULL,
    created_at  TIMESTAMP    DEFAULT SYSTIMESTAMP NOT NULL,
    expires_at  TIMESTAMP,
    owner_id    NUMBER
);
CREATE INDEX idx_url_map_expires ON url_map (expires_at);
The dialect difference here

In PostgreSQL you use TEXT for long text and can create a partial index (WHERE expires_at IS NOT NULL), which Oracle up to 23ai has no direct equivalent for. In Oracle, large text is CLOB and SYSTIMESTAMP replaces now(). If you want portable code, run VARCHAR/VARCHAR2, NUMERIC/NUMBER, and timestamp-with-time-zone through an abstraction layer (Flyway placeholders or JPA).

ID generation — the heart of the design

Three approaches:

Approach 1: hash (MD5/SHA) and take a few chars. Simple, but has collisions you must check — meaning an extra read per write. Same URL always maps to the same code (idempotent), which is nice, but collision handling is a hassle.

Approach 2: global counter + Base62. Take an auto-increment number and convert to Base62 (0-9a-zA-Z, 62 chars). With 7 chars: 62⁷ ≈ 3.5 trillion codes. Problem: a global counter is a single point of contention. Solution: range allocation — each instance grabs a range (say 1000) from a central service and consumes it locally, only hitting the center once per 1000.

Approach 3: Snowflake ID. A unique 64-bit number without coordination: timestamp + machine id + sequence. We build this below.

// Snowflake-style 64-bit unique ID generator (Twitter-inspired)
// layout: 1 unused sign bit | 41 timestamp bits | 10 machine bits | 12 sequence bits
public final class SnowflakeIdGenerator {

    private static final long EPOCH = 1704067200000L; // 2024-01-01, custom epoch

    private static final long MACHINE_BITS  = 10L;
    private static final long SEQUENCE_BITS = 12L;

    private static final long MAX_MACHINE_ID = (1L << MACHINE_BITS) - 1;   // 1023
    private static final long MAX_SEQUENCE   = (1L << SEQUENCE_BITS) - 1;  // 4095

    private static final long MACHINE_SHIFT    = SEQUENCE_BITS;                // 12
    private static final long TIMESTAMP_SHIFT  = SEQUENCE_BITS + MACHINE_BITS; // 22

    private final long machineId;
    private long lastTimestamp = -1L;
    private long sequence = 0L;

    public SnowflakeIdGenerator(long machineId) {
        if (machineId < 0 || machineId > MAX_MACHINE_ID) {
            throw new IllegalArgumentException("machineId out of range: " + machineId);
        }
        this.machineId = machineId;
    }

    public synchronized long nextId() {
        long now = System.currentTimeMillis();
        if (now < lastTimestamp) {
            // clock moved backwards (NTP correction) -> refuse to hand out risky ids
            throw new IllegalStateException("Clock moved backwards by "
                    + (lastTimestamp - now) + " ms");
        }
        if (now == lastTimestamp) {
            sequence = (sequence + 1) & MAX_SEQUENCE;
            if (sequence == 0) {           // 4096 ids exhausted this ms -> wait next ms
                now = waitNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }
        lastTimestamp = now;
        return ((now - EPOCH) << TIMESTAMP_SHIFT)
                | (machineId << MACHINE_SHIFT)
                | sequence;
    }

    private long waitNextMillis(long last) {
        long ts = System.currentTimeMillis();
        while (ts <= last) { ts = System.currentTimeMillis(); }
        return ts;
    }
}

Then we Base62-encode that long into the short code:

public final class Base62 {
    private static final String ALPHABET =
        "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    private static final int BASE = ALPHABET.length(); // 62

    public static String encode(long value) {
        if (value == 0) return "0";
        StringBuilder sb = new StringBuilder();
        while (value > 0) {
            sb.append(ALPHABET.charAt((int) (value % BASE)));
            value /= BASE;
        }
        return sb.reverse().toString();
    }
}
Two real Snowflake traps in production

First: clock moving backwards (clock skew). If NTP pulls the clock back, you might generate a duplicate ID. The code above throws in that case (the correct behavior: refuse, don't emit a suspect ID). Second: machine id assignment. If two instances come up with the same machineId, they generate duplicate IDs, and the bug shows up weeks later as corrupt data. The right way: get machineId from ZooKeeper/etcd or from the ordinal of a Kubernetes StatefulSet, not from a hand-edited config. Seeing this bug once in production is enough to never hard-code machineId again.

Snowflake is guessable

Because the ID is time-ordered, sequential codes have a pattern; someone can enumerate other people's links. If links must be unguessable (privacy), either use a range-based counter with a permutation/XOR-cipher, or add a few random bits to the code. In an interview, always name this as a security trade-off — it's a sign of maturity.

Architecture

Diagram: the write and read paths of the URL shortener (English labels, create + redirect).

flowchart LR
  Client -->|POST create| LB[Load Balancer]
  Client -->|GET code| LB
  LB --> App[Shortener Service]
  App -->|nextId + base62| IDGen[Snowflake / Counter]
  App -->|read-through| Cache[(Redis Cache)]
  App --> DB[(URL Store - sharded)]
  Cache -.miss.-> DB
  App -->|click event| MQ[(Kafka - analytics)]

The critical point: since it's read-heavy, redirects must come from cache, not the database. Put a Redis in front with code -> longUrl. A high hit rate (popular links stay hot) means most redirects are under 1ms.

How do you scale ID generation without a single point of failure?

Question: A global counter for ID generation is a bottleneck. How do you solve it?

Answer: Three options with clear trade-offs. (1) Snowflake: no coordination, each machine generates IDs locally; great throughput, but IDs are guessable and clock-sensitive. (2) Range/segment allocation: a central service hands out ranges of 1000; the instance consumes locally and only makes one RPC per 1000 — central calls drop 1000x, and if the center is briefly down, each instance still has its reserved range. (3) Pre-generation: a job pre-creates millions of unique codes into a queue; creation is just a pop. I usually pick range allocation because it scales, keeps IDs less guessable, and has less operational complexity than Snowflake. Here I explicitly state the trade-off: Snowflake gives lower latency but guessability; range allocation adds a dependency on a central service but yields safer codes.


Design 2 — Distributed Rate Limiter

Every serious system needs a rate limiter: to stop abuse, DDoS, and unfair usage, and to protect downstream services from overload.

Step 1 — Requirements

Functional: for a given key (userId/IP/apiKey), allow at most N requests in window T. Beyond that, return 429 Too Many Requests with a header like Retry-After. Non-functional: (1) very low latency — the rate limiter is on the hot path of every request, so it must not be slow. (2) distributed: multiple service instances, but the limit must be global (a user must not consume N times as much by hitting different instances). (3) accuracy: a little error is acceptable, but not catastrophic.

Algorithms — the real trade-off

Algorithm Memory Burst Boundary accuracy Complexity
Fixed window counter Low Boundary problem (2x at edge) Weak Simple
Sliding window log High (per event) Exact Excellent Medium
Sliding window counter Low Good (approximate) Good Medium
Token bucket Low Controlled burst allowed Good Medium
Leaky bucket Low Smooth output, no burst Good Medium
The token bucket

You have a bucket into which tokens drip at a fixed rate (say 10/sec) with a limited capacity (say 100). Each request takes one token; if none are available, it's rejected. If you've had no requests for a while, the bucket fills up and you can do a burst (up to 100) — this "controlled flexibility" is what makes token bucket popular. Leaky bucket is the opposite: it smooths output completely and allows no bursts.

Which algorithm should I pick?

If unsure, sliding window counter is a good default: low memory, near-exact accuracy, no fixed-window boundary-burst problem. If you want to allow short bursts (a nicer API experience), token bucket. If you have a sensitive downstream system that only tolerates smooth throughput, leaky bucket. In an interview, name all three and say why you pick one — that shows depth.

Local implementation with Bucket4j (single instance)

For a single instance, Bucket4j (a Java library based on token bucket) is excellent:

// build.gradle: implementation 'com.bucket4j:bucket4j_jdk17-core:8.16.1'
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;

public Bucket newBucket() {
    // 100 requests capacity, refill 100 tokens every minute
    Bandwidth limit = Bandwidth.builder()
            .capacity(100)
            .refillGreedy(100, Duration.ofMinutes(1))
            .build();
    return Bucket.builder().addLimit(limit).build();
}

// in a filter:
Bucket bucket = cache.computeIfAbsent(apiKey, k -> newBucket());
if (bucket.tryConsume(1)) {
    chain.doFilter(request, response);   // allowed
} else {
    response.setStatus(429);
    response.setHeader("Retry-After", "60");
}
Why an in-memory rate limiter lies in production

If you keep the limit in each instance's memory and have 5 instances, a user effectively consumes 5x the limit (each instance counts separately). This bug is invisible in dev (one instance) and only appears after scale-out in production. The right way: keep state in a shared store (Redis). In-memory is only correct if the load balancer uses sticky sessions to always send a user to the same instance — which itself brings fragility and imbalance.

Distributed implementation with Redis + Lua (atomic)

For multiple instances, state must be shared and the "read-decide-write" cycle must be atomic, or a race condition lets two concurrent requests both get through. The professional way: a Lua script Redis executes atomically.

-- token bucket in Redis, atomic via EVAL
-- KEYS[1] = bucket key
-- ARGV[1] = capacity, ARGV[2] = refill_per_sec, ARGV[3] = now_ms, ARGV[4] = requested
local capacity   = tonumber(ARGV[1])
local refill     = tonumber(ARGV[2])
local now        = tonumber(ARGV[3])
local requested  = tonumber(ARGV[4])

local data   = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts     = tonumber(data[2])
if tokens == nil then tokens = capacity; ts = now end

-- refill based on elapsed time
local delta = math.max(0, now - ts) / 1000.0
tokens = math.min(capacity, tokens + delta * refill)

local allowed = 0
if tokens >= requested then
  tokens = tokens - requested
  allowed = 1
end

redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], 60000)   -- TTL so idle keys vanish
return allowed

From Spring:

@Component
public class RedisRateLimiter {
    private final StringRedisTemplate redis;
    private final RedisScript<Long> script; // loads the Lua above

    public boolean allow(String key, int capacity, int refillPerSec) {
        Long ok = redis.execute(
            script,
            List.of("rl:" + key),
            String.valueOf(capacity),
            String.valueOf(refillPerSec),
            String.valueOf(System.currentTimeMillis()),
            "1");
        return ok != null && ok == 1L;
    }
}
Redis becomes a SPOF too

Now Redis is on the hot path of every request. What if Redis goes down? Two options: fail-open (if Redis is unavailable, allow — availability over limiting) or fail-closed (reject — security over availability). This is a business decision, not a technical one: for a public API where you fear abuse, maybe fail-closed; for a checkout where you must not lose customers, fail-open with a conservative local in-memory limiter as fallback. A senior names both cases and says the decision depends on the SLA.

Where do you enforce the limit: gateway or each service?

Question: Do you put rate limiting in the API gateway or inside each microservice?

Answer: Both, at different layers. In the gateway (like Spring Cloud Gateway or a reverse proxy), enforce coarse, global limits — protection against DDoS and abuse before traffic enters the system. This is the first barrier and keeps excess load off the whole system. Inside the service, enforce fine, business-aware limits (e.g. "this user's plan gets only 10 reports/day") that the gateway is unaware of. The trap: if you only do it at the gateway, internal service-to-service traffic is unprotected; if you only do it in services, malicious traffic penetrates deep. The senior answer: defense in depth — layering.


Design 3 — News Feed / Timeline (fan-out)

A popular question because its hard core is a beautiful trade-off: fan-out on write vs fan-out on read.

Step 1 — Requirements

Functional: a user posts; followers see it in their timeline (ordered, usually newest-first or ranked). Non-functional: (1) reading the feed must be fast (users won't wait). (2) heavily read-heavy. (3) eventual consistency is fine — if a friend's post arrives a few seconds late, no disaster.

Step 2 — Estimation

Assume 500 million daily users, each opening the feed 20 times/day → 10 billion reads/day ≈ 10¹⁰/10⁵ = 10⁵ = 100k read QPS. Post writes are far fewer (maybe 10 million posts/day ≈ ~100/s). This huge read:write ratio drives the main decision.

The two main strategies

Fan-out on write (push): at post time, you write the post into every follower's timeline (usually a per-user cache in Redis). Reading the feed becomes fast (just read your own timeline), but writing is heavy.

Fan-out on read (pull): you store the post in just one place. At read time, you gather and merge posts from everyone you follow. Writing is light, but reading is heavy and slow.

Diagram: the two fan-out strategies (write immediately into followers' timelines vs gather at read time).

flowchart TD
  subgraph Write[Fan-out on Write]
    A[User posts] --> B[Post Service]
    B --> C{For each follower}
    C --> D[(Follower timeline cache)]
  end
  subgraph Read[Fan-out on Read]
    E[User opens feed] --> F[Feed Service]
    F --> G[Fetch followees' posts]
    G --> H[Merge + rank at read time]
  end

The celebrity problem and the hybrid solution

Here the trade-off comes alive. If a celebrity with 50 million followers posts, fan-out on write means 50 million writes for a single post — saturating the write path for seconds (the "hot key problem"). This is exactly the problem Twitter's team fought for years.

The senior approach: hybrid. Regular users (below ~10k followers) → fan-out on write (write into timelines). Celebrities → fan-out on read (their posts stored separately, no fan-out). At read time, the pre-computed timeline (from regular users) is merged with a real-time fetch of the posts of the celebrities you follow.

You shard the feed; what's the shard key and its problem?

Question: To store timelines/posts you must shard. What key do you choose and what bottleneck does it create?

Answer: I usually use userId as the shard key so all of a user's data is on one shard and a feed read hits one shard (good locality). The problem: hot shard. If a shard happens to co-locate several very active users or celebrities, it gets hot while others idle. Solutions: (1) a good hash for uniform distribution, (2) separate celebrities from this path (the same hybrid), (3) consistent hashing so adding a new shard moves minimal data. The trap to name: never put the shard key on something monotonically growing (like a timestamp), because all new writes pile onto the last shard — a "moving hotspot." A senior always asks "is this key's distribution uniform or skewed?".

Why hybrid wins

Fan-out on write is optimal for "the masses" because they have few followers and writing is cheap. Fan-out on read is optimal for "celebrities" because it eliminates the millions of writes, at the cost of a cheap fetch at read time (celebrities are few, so the merge is light). Hybrid reduces both costs — this is the judgment that separates senior from mid: "one size fits all" doesn't work; partition the behavior based on the distribution.

The under-discussed fan-out-on-write trap

It's not just celebrities — inactive users cost too. If you write a post to a 10k-follower timeline where 90% haven't logged in for months, you've thrown away 90% of your work. Solution: only push for "recently active" users; the rest rebuild their feed via pull when they return. At the scale of millions, this optimization saves serious money in storage and compute.

How do you decide the celebrity threshold and what happens at the boundary?

Question: You said push below 10k followers and pull above. Where did that number come from, and what if a user oscillates right at the boundary?

Answer: The number is not magic; it comes from cost. The break-even point is where the cost of fan-out on write (follower count × post rate) equals the cost of fan-out on read (followers' read rate × merge cost). In practice you tune it by measuring real traffic, not guessing. For boundary oscillation, apply hysteresis: e.g. switch to pull above 12k and back to push below 8k, so it doesn't constantly flip. The key point to state: you make this decision per-user and dynamically, not as a global flag — and you monitor it with real metrics.


Design 4 — Chat / Messaging (delivery and presence)

A messenger is real-time and has its own problems: a persistent connection, guaranteed delivery, ordering, and presence (online/offline).

Step 1 — Requirements

Functional: (1) 1-to-1 and group messages. (2) delivery status (sent/delivered/read). (3) presence (who's online). (4) message history. Non-functional: (1) low latency (real-time). (2) reliable delivery — no message lost. (3) within-conversation ordering preserved.

Why WebSocket, not HTTP polling

HTTP request/response is bad for real-time messaging: you either poll constantly (wasteful) or long-poll (heavy). WebSocket gives a persistent bidirectional connection; the server can push without the client asking. In Spring, WebSocket + STOMP is standard.

@Configuration
@EnableWebSocketMessageBroker
public class WsConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("https://app.example.com")
                .withSockJS(); // fallback for old browsers
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // for a single node the simple broker is fine;
        // for a cluster relay to RabbitMQ/ActiveMQ STOMP broker instead
        registry.enableSimpleBroker("/topic", "/queue");
        registry.setApplicationDestinationPrefixes("/app");
        registry.setUserDestinationPrefix("/user"); // per-user private queues
    }
}
WebSocket and horizontal scaling — the big trap

A WebSocket connection is stateful: a user is connected to a specific instance. If user A is on instance-1 and user B on instance-2, when A messages B, instance-1 must know where B is. enableSimpleBroker (in-memory) doesn't work here because each instance only knows its own connections. Solution: a message broker relay (RabbitMQ/Redis Pub/Sub/Kafka) between instances that routes the message to the right instance. You won't see this in dev with one instance; it only breaks after scale-out — the most classic chat trap.

Reliable delivery and ordering

A message must not be lost even if the recipient is offline. Pattern: the message is first persisted to the database (source of truth), then pushed. If the recipient was offline, on their next connect they pull pending messages from the database. For ordering, each message has a conversation-level sequence number.

Diagram: the path of sending a message with persist-then-push (store before delivery, offline delivery from the database).

sequenceDiagram
  participant A as Sender
  participant S as Chat Service
  participant DB as Message Store
  participant B as Receiver
  A->>S: send(msg, convId)
  S->>DB: persist(msg, seq)
  DB-->>S: ok (source of truth)
  S-->>A: ack (sent)
  alt receiver online
    S->>B: push(msg)
    B-->>S: delivered
  else receiver offline
    Note over S,B: stored — delivered on next connect
  end

Presence (online/offline)

Presence is harder than it looks. The simplest way: each client sends a heartbeat every few seconds; the server keeps the last heartbeat in Redis with a TTL. If the TTL expires, the user is offline.

// on each heartbeat / activity
redis.opsForValue().set("presence:" + userId, "online",
        Duration.ofSeconds(30));   // key auto-expires if no heartbeat
// to check:
boolean online = redis.hasKey("presence:" + userId);
Presence at scale explodes fan-out

If you try to "push every presence change to all of a user's friends," a user with 5000 friends who flickers online/offline every 30 seconds creates a flood of messages. Real solutions: (1) push presence only for open conversations, not all friends. (2) batch/debounce changes. (3) instead of push, make presence lazy and pull-on-demand (when the user opens a chat, ask the status). A senior knows presence is a "small" feature that can bring the system down.

How do you guarantee exactly-once delivery?

Question: How do you ensure each message reaches the recipient exactly once — not zero, not twice?

Answer: The honest answer: global exactly-once delivery is nearly impossible; what you actually build is at-least-once + idempotency = effectively-once. Server-side, persist the message before ack (at-least-once toward the sender). Recipient-side, each message has a unique messageId (client-generated, like a UUID); the recipient tracks seen IDs and drops duplicates (dedup). If an ack is lost and the sender resends, the duplicate messageId is detected. So the combination: persist before ack + dedup with an idempotency key. In an interview, be sure to say "exactly-once is a myth; I build effectively-once" — that's a sign of real maturity.


Design 5 — Notification System

Notifications (push/SMS/email/in-app) are infrastructure almost every product needs, with many subtleties: multiple channels, rate limiting, priority, and idempotency.

Step 1 — Requirements

Functional: (1) send over multiple channels: push (APNs/FCM), SMS, email, in-app. (2) templates. (3) user preferences (opt-out per channel). (4) priority (transactional like OTP vs marketing). Non-functional: (1) reliable — an OTP must not be lost. (2) scale — millions of notifications. (3) no duplicate notifications (idempotency). (4) no spam (rate limit per user).

Architecture — decoupling with a queue

The core of the design: async with a message queue. A service wanting to send a notification just drops an event in a queue and returns; workers do the actual sending. This decouples the producer from the slowness/failure of providers.

Diagram: notification system architecture with a queue and per-channel workers (producer decoupled from delivery, each channel an independent worker).

flowchart LR
  Svc[Any Service] -->|notify event| Q[(Kafka / Queue)]
  Q --> Router[Notification Router]
  Router -->|check prefs + rate limit| Prefs[(Preferences DB)]
  Router --> PushW[Push Worker] --> FCM[FCM / APNs]
  Router --> SmsW[SMS Worker] --> Twilio[SMS Provider]
  Router --> MailW[Email Worker] --> SES[Email Provider]
  Router --> Dedup[(Dedup / Idempotency Store)]

Idempotency — the heart of reliability

If the producer, due to a retry, sends an event twice, the user must not get two OTPs. Each notification has a unique idempotencyKey; before sending, the worker checks whether this key was already processed (in Redis with a TTL).

public void handle(Notification n) {
    // SETNX-style: only the first caller wins
    Boolean fresh = redis.opsForValue()
        .setIfAbsent("notif:" + n.idempotencyKey(), "1", Duration.ofHours(24));
    if (Boolean.FALSE.equals(fresh)) {
        log.info("duplicate notification {}, skipping", n.idempotencyKey());
        return;                       // already sent
    }
    channelFor(n.channel()).send(n); // send exactly once (effectively)
}
Third-party providers will fail — plan for it

SMS/email/push providers (Twilio, FCM, SES) sometimes slow down, rate-limit you, or go temporarily down. If you call them directly and synchronously, every hiccup propagates into your whole system. Requirements: (1) retry with exponential backoff for transient errors. (2) circuit breaker so you don't keep hammering a broken provider. (3) dead-letter queue for messages that didn't send after N attempts, to inspect later. (4) timeout on every call. Without these, a 5-minute Twilio outage can back up your queue for hours.

Prioritization: an OTP must not queue behind marketing

A common trap: a single queue for all notifications. When a marketing campaign of 10 million messages fills the queue, the user's login OTP waits behind them and the user can't sign in. Solution: separate queues by priority (transactional/high vs marketing/bulk), with dedicated workers and resources. Transactional always takes priority. A senior always asks "do the OTP and the newsletter go through the same path?" — if yes, you have a problem.

How do you prevent spamming the user?

Question: How do you make sure the user isn't bombarded with notifications?

Answer: Several layers. (1) User preferences: before sending, check opt-out per channel (did the user turn off marketing email?). (2) Rate limiting per user per channel: e.g. at most X push/hour — the same token bucket from Design 2, here per user. (3) Aggregation/digest: instead of 10 separate notifications for 10 likes, send one: "10 people liked your post." (4) Quiet hours: don't send non-urgent notifications at midnight. The point a senior adds: exempt transactional (OTP, security alerts) from these limits — always send them, or the user can't log in. Separating transactional from promotional is the whole game.


Design 6 — E-commerce Order Flow (across services)

This design brings it all together: multiple services, a distributed transaction, consistency, and compensation. This is where understanding saga and outbox shows its worth.

Step 1 — Requirements

Functional: a customer checks out; the system must: (1) reserve inventory (Inventory), (2) capture payment (Payment), (3) record the order (Order), (4) start shipping (Shipping), (5) send notifications. Non-functional: (1) consistency: you must not charge money without inventory, or reserve inventory without capturing payment. (2) reliable and retryable. (3) traceable (where in the flow are we).

The core challenge — a transaction across services

In a monolith, these were one @Transactional and everything was atomic. But each service has its own database; there is no global ACID transaction (2PC is slow, fragile, and avoided at scale). Solution: saga — a sequence of local transactions, each publishing an event; if a step fails, prior steps are undone via a compensating transaction.

Saga like a multi-step trip booking

You're planning a trip: flight, hotel, rental car. If the hotel booking fails, you must cancel the flight (compensation). Saga is exactly this: each step runs separately, and if one gets stuck, you "unwind" the prior steps. Unlike a database transaction with automatic rollback, here you must write the undo explicitly — because money actually moved and must actually be returned.

Orchestration vs Choreography

Two saga styles:

  • Choreography: each service listens to others' events and reacts; no central control. Simple for short flows, but for long flows it becomes hard to know "where are we now" (logic scattered everywhere).
  • Orchestration: a central orchestrator (e.g. the Order Service or a state machine) drives the steps and knows where you are. Better for complex multi-step flows because the logic is in one place and tracing is easy.
Which do I pick?

For an order flow with 4–5 steps and complex compensation logic, I prefer orchestration: an Order Saga Orchestrator holding the state machine. Benefit: when something breaks, you look in one place and see what state the order is stuck in. Choreography is fine for 2–3 simple services, but at scale it turns into a "distributed monolith" where no one understands how the flow works. In an interview, name both and justify your choice with "traceability."

Diagram: the lifecycle of an order as a state machine (order states and the compensation path on failure).

stateDiagram-v2
  [*] --> Created
  Created --> InventoryReserved: reserve ok
  InventoryReserved --> PaymentCaptured: payment ok
  PaymentCaptured --> Confirmed: order confirmed
  Confirmed --> Shipped: shipping started
  Shipped --> [*]
  InventoryReserved --> Cancelled: payment failed / compensate release stock
  PaymentCaptured --> Refunding: downstream failed / refund
  Refunding --> Cancelled
  Created --> Cancelled: out of stock

The dual-write problem and the Outbox solution

Here is the subtlest bug in event-driven systems. The Order Service wants to both write to its own database (order recorded) and send an event to Kafka (so downstream services find out). If you write to the DB first and then send to Kafka, and crash in between? The order is recorded but nobody was notified — a "lost order." If you reverse it, the event went out but the order wasn't recorded — an event for an order that doesn't exist.

Solution: Transactional Outbox. Write the event, in the same database transaction, into an outbox table. Since both writes are in one local ACID transaction, either both happen or neither. Then a separate process (polling, or CDC like Debezium) reads the outbox table and publishes to Kafka.

@Transactional
public Order placeOrder(OrderRequest req) {
    Order order = orderRepository.save(Order.created(req));   // write 1
    OutboxEvent evt = OutboxEvent.of(
        "OrderCreated", order.getId(), toJson(order));
    outboxRepository.save(evt);                               // write 2 -- SAME tx
    return order;
    // both commit atomically; a separate relay ships evt to Kafka later
}
-- PostgreSQL: outbox table
CREATE TABLE outbox (
    id            BIGSERIAL PRIMARY KEY,
    aggregate_id  BIGINT      NOT NULL,
    event_type    VARCHAR(64) NOT NULL,
    payload       JSONB       NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    published     BOOLEAN     NOT NULL DEFAULT false
);
-- Oracle: outbox table
CREATE TABLE outbox (
    id            NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    aggregate_id  NUMBER        NOT NULL,
    event_type    VARCHAR2(64)  NOT NULL,
    payload       CLOB          NOT NULL,   -- or JSON type in 21c+
    created_at    TIMESTAMP     DEFAULT SYSTIMESTAMP NOT NULL,
    published     NUMBER(1)     DEFAULT 0 NOT NULL
);
The dialect difference in the outbox

PostgreSQL has a native JSONB type (binary, indexable with GIN) and BIGSERIAL for auto-increment. Oracle has a native JSON type since 21c (before that, CLOB with an IS JSON check), and identity columns via GENERATED ... AS IDENTITY. For boolean, PostgreSQL has a real BOOLEAN type, but Oracle had none in SQL until 23ai and used NUMBER(1) (0/1) — Oracle 23ai finally brought BOOLEAN to SQL.

Idempotency in the consumer is mandatory

The outbox guarantees the event is published at least once — meaning sometimes more than once (if the relay crashes after publishing but before marking-as-published). So every consumer must be idempotent: the Payment Service must not charge an order twice just because it saw OrderCreated twice. The standard way: each consumer keeps a processed_events(event_id) table and checks before processing. Say this in an interview: "outbox = at-least-once, so the consumer must be idempotent" — without that sentence, your design is incomplete.

What if payment succeeds but a later step fails?

Question: In the order saga, you captured payment but the next step (e.g. final confirmation) failed. What do you do?

Answer: A compensating transaction. The saga runs in reverse: if payment happened but we then failed, issue a refund (compensate the payment), and if inventory was reserved, release stock. The important point: compensations can also fail (the refund provider is down), so they must be retryable and idempotent, and ultimately, if they can't complete, go to a "needs human intervention" queue. Order matters too: run compensations in the reverse order of execution. And some steps are irreversible (you can't un-send a sent email) — push these as late in the saga as possible so the least amount needs undoing. A senior says: "Arrange the saga so irreversible operations come last."

How do you prevent a duplicate order on a user's double-click?

Question: The user hits "Place Order" twice, or their network drops and retries. How do you avoid recording two orders?

Answer: Idempotency key. The client generates a unique key (UUID) per checkout attempt and sends it in a header (Idempotency-Key). The server checks whether this key was seen before; if so, it returns the same previous result (not a new order). You implement this in a table or Redis with a unique constraint, so the second insert fails on the constraint. This is exactly the pattern Stripe and real payment gateways use for this scenario — because in payments, "charged twice" is a real disaster. The senior point: the client must generate the key, not the server, because only the client knows "this is the same previous attempt or a new order."


The language of trade-off narration: how a senior thinks out loud

So far we've built six systems. But what marks you as a senior in an interview isn't the design itself — it's how you talk about it. Here are the linguistic patterns seniors use:

1) Always put the cost next to the benefit. Never just say "I'll add a cache." Say "a cache lowers latency but introduces a consistency problem (stale data) and a new failure layer — and I accept this trade-off because this data is read-heavy and a few seconds of staleness is fine." The structure: "I'll choose X, which improves Y at the cost of Z, and Z is acceptable here because W."

2) Talk explicitly about CAP and consistency. Say where strong consistency is needed (payment, inventory) and where eventual is enough (feed, like count). This shows you understand you can't have strong everywhere.

3) Find the bottleneck before the interviewer does. After drawing the architecture, say yourself: "the single point of failure here is Redis; if it goes down, this happens, and the mitigation is this." Proactively naming weaknesses builds trust.

4) Describe scaling incrementally. "For V1, one PostgreSQL suffices. When we hit X QPS, I add a read replica. When writes also bottleneck, I shard on this key." This shows you don't over-engineer and you see the growth path.

Phrases that reveal a senior

"It depends" — then explain on what. "For V1 this is enough" — signals anti-over-engineering. "The trade-off here is..." — making cost explicit. "I'll monitor this with a metric" — operational thinking. "If I'm wrong, this is how I'd find out and roll back" — reversible thinking. Conversely, phrases that reveal a mid-level: "always use X," "X is the best," "Kafka because it's scalable" without saying why.

Where in these designs is strong consistency needed and where is eventual enough?

Question: You mentioned CAP. In these six systems, where exactly do you want strong consistency and where do you accept eventual?

Answer: My rule: strong where money or inventory is involved, eventual everywhere else. In the order flow, decrementing inventory and capturing payment must be strong (you must not oversell or double-charge) — here I'll sacrifice availability or latency. But a like counter, view count, and a friend's post arriving in the feed — eventual is fine; a few seconds of delay or slight inconsistency is acceptable, and in return I gain availability and scale. In the rate limiter, a little error (a few extra requests during a race) is acceptable, so I lean toward availability there too (fail-open). The key point a senior states: consistency is a per-operation decision, not per-system — within one system you can have both a strong path and an eventual path, and the craft is knowing which data needs which.

Common pitfalls in a system design interview

(1) Jumping to a solution without clarifying requirements. (2) Over-engineering — drawing Kafka and Kubernetes for a 100-QPS problem. (3) Ignoring failure — designing the happy path and going blank at "what if this fails?". (4) Not giving numbers — "a lot" doesn't replace "100k QPS." (5) Silence — the interviewer can't see your thinking; think out loud. (6) Stubbornness — when the interviewer adds a new constraint, adapt the design, don't defend blindly.

Summary

System design is a judgment test, not a memorization test. For any problem, walk the same seven steps: requirements → estimation → API → data model → architecture → deep-dive → bottlenecks and trade-offs. Estimate quickly at the order-of-magnitude level; a few base numbers (10⁵ seconds/day, RAM ~100ns, round trip ~0.5ms) are enough. In the URL shortener, the core was unique ID generation (Snowflake vs range allocation, aware of clock skew and guessability). In the rate limiter, the algorithm choice (token bucket / sliding window), distribution via atomic Redis+Lua, and the fail-open/closed decision. In the news feed, the dance of fan-out on write vs read and the hybrid solution for celebrities. In chat, the stateful WebSocket connection needing a broker relay, persist-then-push, and effectively-once via idempotency. In notifications, decoupling with a queue, priority queues, idempotency, and hardening against third-party providers. In the order flow, an orchestrated saga, the outbox pattern for dual-write, and idempotent compensating transactions. And above all: the language of trade-offs — put cost next to benefit, find the bottleneck yourself, describe scaling incrementally, and think out loud. That's how you design like a senior and speak like a senior.