System Design · طراحی سیستم سنیورSenior ~56 دقیقه مطالعه~47 min read
مبانیِ طراحیِ سیستمSystem Design Fundamentals
طراحیِ سیستم هنرِ معامله (trade-off) است نه حفظکردنِ الگو؛ این فصل به تو یاد میدهد از نیازمندی و تخمینِ سرانگشتی شروع کنی و با scaling، load balancing، caching، شاردینگ، CAP/PACELC، rate limiting، idempotency و ریاضیِ availability مثل یک سنیور تصمیم بگیری و در مصاحبه دفاع کنی.System design is the craft of trade-offs, not memorizing patterns; this chapter teaches you to start from requirements and back-of-envelope estimation and then reason like a senior about scaling, load balancing, caching, sharding, CAP/PACELC, rate limiting, idempotency, and availability math — and defend every choice in an interview.
بذار با حقیقتی شروع کنم که خیلیها را در مصاحبهٔ System Design زمین میزند: هیچ جوابِ درستی وجود ندارد؛ فقط trade-off وجود دارد. مصاحبهگر نمیخواهد ببیند تو معماریِ Netflix را از حفظ بلد هستی. میخواهد ببیند وقتی میگویی «read replica اضافه میکنم»، میفهمی چه چیزی را به دست آوردی (scale خواندن) و چه چیزی را از دست دادی (replication lag و consistency ضعیفتر). سنیور کسی است که هر تصمیمش را با «چون…» و یک عدد پشتیبانی میکند.
در این فصل قرار نیست فهرستی از buzzword حفظ کنی. قرار است روش فکر کردن یاد بگیری: از نیازمندیها شروع کنی، با تخمینِ سرانگشتی بار سیستم را بسنجی، و بعد لایهبهلایه — از load balancer تا cache تا دیتابیس — با آگاهی از trade-offها سیستم را بسازی. همان مسیری که در یک مصاحبهٔ ۴۵ دقیقهای هم باید طی کنی.
۱) روشِ مصاحبه: نیازمندیِ functional در برابر non-functional و چرا اینها همهچیز را تعیین میکنند. ۲) تخمینِ سرانگشتی (back-of-envelope): QPS، storage، bandwidth — با اعدادی که باید حفظ باشی. ۳) scaling عمودی در برابر افقی و چرا statelessness پیششرطِ افقیشدن است. ۴) Load balancing: لایهٔ ۴ در برابر ۷، الگوریتمها، health check. ۵) لایههای cache (client/CDN/app/DB)، الگوهای cache-aside و write-through، eviction و cache stampede — با اتصال به فصل Caching. ۶) CDN و چرا محتوای استاتیک هیچوقت نباید به origin برسد. ۷) مقیاسبندی دیتابیس: read replica، partitioning و sharding، consistent hashing. ۸) CAP و PACELC، مدلهای consistency. ۹) Rate limiting (token bucket و رفقا) و idempotency. ۱۰) ریاضیِ availability: «نُهها»، redundancy، failover. ۱۱) یک معماریِ کامل بهعنوان جمعبندی.
۱) روش: اول نیازمندی، بعد معماری
بزرگترین اشتباهِ آدمهای junior در مصاحبهٔ system design این است که مصاحبهگر میگوید «یک سیستمِ کوتاهکنندهٔ لینک طراحی کن» و طرف بلافاصله شروع میکند به کشیدنِ Kafka و Cassandra. سنیور اول سؤال میپرسد.
یک معمارِ خوب وقتی میخواهی برایت خانه بسازد، اول نمیپرسد «چند تا آجر؟». میپرسد «چند نفر قرار است اینجا زندگی کنند؟ بودجه چقدر است؟ زلزلهخیز است؟ ده سال دیگر میخواهی طبقه اضافه کنی؟». اینها نیازمندی هستند. تعدادِ آجر (تکنولوژی) نتیجهٔ این جوابهاست، نه برعکس. در system design هم اول ابعادِ مسئله را روشن کن، بعد ابزار انتخاب کن.
نیازمندیها دو دستهاند و این تفکیک، ستونِ فقراتِ کلِ مصاحبه است:
نیازمندیِ functional (کارکردی): سیستم چه کاری میکند. فعلها. «کاربر لینک بلند میدهد، لینک کوتاه میگیرد.» «با باز کردنِ لینک کوتاه به لینک اصلی redirect میشود.» اینها API تو را شکل میدهند.
نیازمندیِ non-functional (غیرکارکردی): سیستم چطور باید باشد. صفتها. مقیاس، تأخیر (latency)، دسترسپذیری (availability)، consistency، دوام (durability)، امنیت. اینها معماری تو را شکل میدهند — و ۹۰٪ نمرهٔ مصاحبه اینجاست.
دو سیستم با نیازمندیِ functional یکسان («یک پیام از A به B برسان») میتوانند معماریِ کاملاً متفاوت داشته باشند. اگر non-functional بگوید «۱۰۰ پیام در روز، تأخیر مهم نیست» یک جدولِ دیتابیس کافی است. اگر بگوید «۵۰۰ هزار پیام در ثانیه با تأخیر زیر ۱۰۰ms و دوامِ صفرْگمشدن» حالا به Kafka و partitioning و replication نیاز داری. همیشه از مصاحبهگر عدد بگیر. سؤالِ طلایی: «مقیاسِ هدف چقدر است؟ چند کاربرِ فعالِ روزانه (DAU)؟ نسبتِ خواندن به نوشتن؟»
چهار عددی که همیشه باید بپرسی:
- DAU / MAU (کاربرِ فعالِ روزانه / ماهانه) — پایهٔ همهٔ تخمینها.
- نسبت read:write — یک سیستمِ ۱۰۰:۱ خواندنمحور (مثل کوتاهکنندهٔ لینک) کاملاً متفاوت از یک سیستمِ نوشتنمحور طراحی میشود.
- حجم و طول عمر داده — چند سال نگهداری؟ این storage را تعیین میکند.
- SLA برای latency و availability — «زیر ۲۰۰ms در p99» و «سهنُه یا چهارنُه».
اگر بدون پرسیدنِ این چهار عدد شروع کنی به کشیدنِ دیاگرام، مصاحبهگر همانجا تو را junior حساب میکند — حتی اگر معماریت درست باشد. طراحیِ درست برای مسئلهٔ اشتباه، اشتباه است. سه دقیقهٔ اولِ مصاحبه را خرجِ روشنکردنِ scope و نیازمندی کن؛ این بهترین سرمایهگذاریِ ممکن است.
پاسخ: functional یعنی سیستم چه کاری میکند (فعلها: «لینک کوتاه بساز»، «redirect کن») و API را شکل میدهد. non-functional یعنی سیستم چطور باید باشد (صفتها: مقیاس، latency، availability، consistency، durability) و معماری را شکل میدهد. دو سیستم با functionalِ یکسان میتوانند معماریِ کاملاً متفاوت داشته باشند بسته به non-functional — یک جدولِ ساده در برابر Kafka و sharding. برای همین در مصاحبه اول اعدادِ non-functional را میگیرم (DAU، نسبت read:write، حجم و طول عمرِ داده، SLA) و از آنها به تصمیمِ معماری میرسم.
۲) تخمینِ سرانگشتی: با کاغذ و مغز، نه با calculator
Back-of-the-envelope estimation یعنی با چند ضرب و تقسیمِ سرانگشتی، ابعادِ سیستم را تخمین بزنی: چند request در ثانیه (QPS)؟ چند ترابایت storage؟ چند گیگابیت bandwidth؟ هدف دقتِ ممیزی نیست؛ هدف این است که بفهمی با «یک سرور» طرف هستی یا «هزار سرور» — چون این دو، دو معماریِ کاملاً متفاوتاند. مثلِ سرآشپزی که بدونِ ترازو میگوید «حدود ۴۰ کیلو گوشت» و میداند که یخچالِ خانگی جواب نمیدهد: کافی است بدانی از کدام مرتبهٔ بزرگی (order of magnitude) حرف میزنی.
اعدادی که باید حفظ باشی
ثانیههای روز: ۸۶٬۴۰۰ ثانیه ≈ ۱۰⁵. این تنها عددی است که برای QPS باید بلد باشی. (QPS متوسط = تعداد request روزانه ÷ ۱۰⁵.)
توانهای ۲ برای حافظه: 2¹⁰ ≈ هزار (KB)، 2²⁰ ≈ میلیون (MB)، 2³⁰ ≈ میلیارد (GB)، 2⁴⁰ ≈ تریلیون (TB).
اعدادِ latency که هر مهندس باید بداند (بهقولِ Jeff Dean؛ ارقامِ گرد شده و کماکان معتبر برای استدلال از مرتبهٔ بزرگی):
| عملیات | زمانِ تقریبی |
|---|---|
| رجوع به L1 cache | 0.5 ns |
| قفل/بازکردنِ mutex | 25 ns |
| رجوع به main memory (RAM) | 100 ns |
| خواندنِ ۱MB ترتیبی از RAM | ~۲۵۰ µs |
| round-trip داخلِ همان datacenter | ~۵۰۰ µs |
| خواندنِ تصادفی از SSD | ~۱۵۰ µs |
| خواندنِ ۱MB ترتیبی از SSD | ~۱ ms |
| seek روی دیسکِ چرخان (HDD) | ~۱۰ ms |
| بستهٔ شبکه CA→هلند→CA | ~۱۵۰ ms |
حافظه از SSD حدوداً ۴ برابر و SSD از HDD دهها برابر سریعتر است؛ و یک round-trip بینِ قارهها ۱۵۰ms است — یعنی ذاتاً نمیتوانی سریعتر از سرعتِ نور جواب بدهی. به همین دلیل cache نزدیک به کاربر و CDN و replicaهای جغرافیایی وجود دارند: تا فاصله را کم کنند. هر جا در معماری یک مرزِ شبکه رد میکنی، یک هزینهٔ latency پرداختهای.
مثالِ کامل: کوتاهکنندهٔ لینک
فرض کن مصاحبهگر گفت: ۱۰۰ میلیون لینکِ جدید در روز، نسبتِ خواندن به نوشتن ۱۰۰ به ۱، داده را ۵ سال نگه میداریم.
QPS نوشتن: ۱۰۰M ÷ ۱۰⁵ = ۱۰۰۰ نوشتن در ثانیه (متوسط). پیک معمولاً ۲ تا ۳ برابرِ متوسط است → ~۳۰۰۰ نوشتن/ثانیه در پیک.
QPS خواندن: ۱۰۰ برابر = ۱۰۰٬۰۰۰ خواندن/ثانیه (متوسط)، پیک ~۳۰۰٬۰۰۰/ثانیه.
Storage: هر رکورد فرض کن ۵۰۰ بایت (لینکِ کوتاه، لینکِ بلند، متادیتا). در روز: ۱۰۰M × ۵۰۰B = ۵۰GB/روز. در ۵ سال: ۵۰GB × ۳۶۵ × ۵ ≈ ۹۱TB. یعنی یک نودِ تکی جواب نمیدهد؛ به sharding نیاز داری.
Bandwidth خواندن: ۳۰۰٬۰۰۰ خواندن/ثانیه × ۵۰۰B ≈ ۱۵۰MB/s. کاملاً قابلمدیریت با cache.
پاسخ: ۵۰۰M × ۱۰ = ۵ میلیارد request در روز. تقسیم بر ۸۶٬۴۰۰ (≈۱۰⁵) میشود ۵ میلیارد ÷ ۱۰⁵ = ۵۰٬۰۰۰ QPS متوسط. پیک را ۳ برابر بگیر → ~۱۵۰٬۰۰۰ QPS. نکتهٔ سنیوری که باید اضافه کنی: توزیعِ بار یکنواخت نیست — پیکِ روزانه (مثلاً عصرها) و پیکِ رویدادی (رخدادِ خبری) میتواند چند برابرِ متوسط باشد، پس ظرفیت را برای پیک، نه متوسط، طراحی کن و headroom بگذار. همچنین بگو که feed معمولاً از cache خوانده میشود نه مستقیم از دیتابیس، پس این ۱۵۰k عمدتاً به cache میخورد.
۳) Scaling: عمودی در برابر افقی
وقتی بار زیاد میشود، دو راه داری بزرگتر شوی.
Vertical scaling (scale up): ماشینِ بزرگتر. CPU بیشتر، RAM بیشتر، NVMe سریعتر. مثل اینکه بهجای استخدامِ آشپزِ دوم، به همان آشپز چاقوی تیزتر و اجاقِ قویتر بدهی.
Horizontal scaling (scale out): ماشینهای بیشتر. ده سرورِ معمولی بهجای یک سرورِ غول. مثلِ استخدامِ ده آشپز.
عمودی یعنی یک اسبِ قویتر بخری. یک جایی به سقفِ بیولوژیک میرسی؛ قویترین اسبِ دنیا هم حدی دارد و اگر بمیرد، کلِ گاری خوابیده. افقی یعنی ده اسبِ معمولی ببندی به گاری؛ میتوانی تا بینهایت اسب اضافه کنی و اگر یکی زمین بخورد، بقیه گاری را نگه میدارند. این تفاوتِ SPOF (نقطهٔ شکستِ واحد) است.
| بُعد | عمودی (scale up) | افقی (scale out) |
|---|---|---|
| سقف | محدود به سختافزارِ یک ماشین | تقریباً نامحدود |
| SPOF | بله؛ یک ماشین = یک نقطهٔ شکست | خیر؛ افزونگیِ ذاتی |
| پیچیدگی | ساده (کد تغییر نمیکند) | پیچیده (نیاز به load balancer، statelessness، هماهنگی) |
| هزینه | گرانشونده بهشکلِ نمایی | خطی با سختافزارِ ارزان |
| downtime برای ارتقا | معمولاً reboot لازم است | rolling؛ بدون downtime |
عمودی «کسلکننده ولی عاقلانه» است تا وقتی که به سقف بخوری. پیچیدگیِ افقیشدن (load balancer، stateless کردن، session sharing، دیباگِ توزیعشده) هزینهٔ واقعی و دائمی دارد. بسیاری از سیستمها با یک ماشینِ چاق و یک replica بهعنوان failover سالها بیدردسر کار میکنند. افقی برو وقتی که (الف) به سقفِ ماشین خوردی، یا (ب) به availability بالاتر از یکماشین نیاز داری. اما نکتهٔ مهم: stateless بودن را از روزِ اول رعایت کن تا وقتی لازم شد، افقیشدن ارزان باشد.
Statelessness: پیششرطِ افقیشدن
یک سرویس stateless است اگر هیچ حالتِ خاصِ کاربر را بینِ requestها در حافظهٔ خودش نگه ندارد. هر request خودکفاست؛ هر instance میتواند هر request را جواب بدهد.
چرا حیاتی است؟ چون در scale افقی، load balancer request بعدیِ همان کاربر را ممکن است به instanceِ دیگری بفرستد. اگر session را در حافظهٔ instance نگه داشته باشی (sticky in-memory session)، آن instance که بیفتد کاربر logout میشود و اضافهکردنِ instance جدید بیفایده است چون دادهاش را ندارد.
// ❌ Stateful: session در حافظهٔ همین instance؛ افقیشدن را میشکند
@RestController
public class CartController {
private final Map<String, Cart> carts = new ConcurrentHashMap<>(); // در RAM همین pod
@PostMapping("/cart/add")
public void add(@RequestHeader("X-User") String user, @RequestBody Item item) {
carts.computeIfAbsent(user, k -> new Cart()).add(item);
}
}
// ✅ Stateless: حالت را به یک store مشترک (Redis/DB) بیرون بریز
@RestController
public class CartController {
private final CartRepository repo; // Redis یا دیتابیس، مشترک بین همهٔ instanceها
@PostMapping("/cart/add")
public void add(@RequestHeader("X-User") String user, @RequestBody Item item) {
Cart cart = repo.findByUser(user).orElseGet(Cart::new);
cart.add(item);
repo.save(cart);
}
}
load balancerها یک قابلیت دارند به اسمِ sticky session که همیشه کاربر را به همان instance میفرستد تا session در حافظه کار کند. این یک بدهیِ فنی است: (۱) توزیعِ بار را نامتوازن میکند (instanceهای قدیمی پُر، جدیدها خالی)، (۲) با افتادنِ یک instance، همهٔ کاربرانش logout میشوند، (۳) rolling deploy را دردناک میکند. راهِ درست: session را در Redis یا در JWTِ خودِ کلاینت بگذار و instanceها را کاملاً stateless نگه دار.
پاسخ: در scale افقی، load balancer requestهای یک کاربر را ممکن است به instanceهای مختلف بفرستد. اگر سرویس حالتِ کاربر (session، سبد خرید) را در حافظهٔ خودش نگه دارد، آن instance که بیفتد کاربر دادهاش را از دست میدهد، و افزودنِ instance جدید بیفایده است چون داده را ندارد. سرویسِ stateless هیچ حالتی در حافظه نگه نمیدارد؛ هر request خودکفاست و هر instance میتواند هر request را جواب بدهد، پس میتوانی آزادانه instance اضافه/کم کنی، rolling deploy بزنی، و افتادنِ یک نود بیدردسر باشد. حالت را به یک store مشترک (Redis/DB) یا به JWTِ کلاینت منتقل میکنم. راهِ فرارِ sticky session را رد میکنم چون بار را نامتوازن و deploy را دردناک میکند.
۴) Load Balancing: تقسیمکنندهٔ ترافیک
وقتی چند instance داری، به چیزی نیاز داری که ترافیک را بینشان پخش کند و ترافیک را از instanceهای مریض دور نگه دارد. این load balancer است.
یک صفِ طولانی از ماشینها به یک عوارضیِ چندبانده میرسد. پلیس (load balancer) هر ماشین را به بانِدی که خلوتتر است هدایت میکند، و اگر یک باند تعمیر باشد (instance مریض) هیچ ماشینی را آنطرف نمیفرستد. نتیجه: throughput بالا و هیچ ماشینی گیر نمیافتد.
بیایید یک توپولوژیِ معمول را ببینیم.
نمودار: ترافیک از کلاینت تا instanceهای پشتِ load balancer — Traffic flow from client through the load balancer to healthy instances.
flowchart LR
Client[Clients] --> LB[Load Balancer]
LB --> A[Instance A - healthy]
LB --> B[Instance B - healthy]
LB -. skipped .-> C[Instance C - unhealthy]
A --> Cache[(Redis)]
B --> Cache
A --> DB[(Database)]
B --> DB
لایهٔ ۴ در برابر لایهٔ ۷
این تفکیک از مدلِ OSI میآید و در مصاحبه زیاد پرسیده میشود.
L4 (transport): load balancer فقط IP و port را میبیند؛ بستهٔ TCP/UDP را بدونِ باز کردن محتوا به یک backend میفرستد. سریع، کمسربار، ولی «کور» — نمیتواند بر اساسِ مسیرِ URL یا header تصمیم بگیرد.
L7 (application): load balancer محتوای HTTP را میفهمد. میتواند بر اساسِ path مسیریابی کند (/api/* به یک سرویس، /img/* به دیگری)، TLS را terminate کند، header اضافه کند، sticky session بر اساس cookie بگذارد، و retry بزند. قدرتمندتر ولی کمی کندتر (چون باید request را parse کند).
| ویژگی | L4 | L7 |
|---|---|---|
| چه چیزی میبیند | IP/port (TCP/UDP) | کلِ HTTP (path, header, cookie) |
| مسیریابیِ محتوامحور | ندارد | دارد |
| TLS termination | ندارد | دارد |
| سرعت/سربار | سریعتر، سبکتر | کمی کندتر، هوشمندتر |
| نمونه | AWS NLB، HAProxy در حالتِ TCP | AWS ALB، Nginx، Envoy، Spring Cloud Gateway |
در دنیای Java/Spring معمولاً یک L7 مثل Nginx یا یک cloud ALB جلوی همهچیز است، و برای مسیریابیِ درونسیستمیِ میکروسرویسها یک API Gateway (مثل Spring Cloud Gateway) که خودش یک L7 router است؛ در Kubernetes، Service و Ingress همین نقش را بازی میکنند.
الگوریتمهای توزیع
- Round Robin: به ترتیب، یکییکی به هر backend. ساده و منصفانه اگر همهٔ instanceها و همهٔ requestها هموزن باشند.
- Weighted Round Robin: به ماشینِ قویتر سهمِ بیشتری بده. مفید در ناوگانِ ناهمگون.
- Least Connections: request بعدی را به instanceی بده که کمترین اتصالِ باز را دارد. عالی وقتی طولِ requestها متفاوت است (بعضی ۱۰ms، بعضی ۱۰s).
- Least Response Time: ترکیبِ least connections و کمترین latency اندازهگیریشده.
- IP Hash / Consistent Hash: hashِ IP یا key کاربر تعیین میکند به کدام backend برود؛ همان کاربر همیشه به همان backend (بدونِ نگهداشتنِ state در LB). برای cache locality مفید است.
اگر requestها کوتاه و هماندازهاند، Round Robin کافی است. اگر بعضی requestها سنگیناند (upload یا گزارشِ سنگین)، Least Connections عادلانهتر است چون Round Robin ممکن است چند requestِ سنگین را روی یک instance بریزد. اگر cacheِ محلیِ هر instance داری و میخواهی hit rate بالا بماند، Consistent Hashing را انتخاب کن.
Health Check: قلبِ availability
load balancer فقط وقتی ارزش دارد که بداند کدام backend سالم است. دو نوع بررسیِ سلامت:
- Active (proactive): LB هر چند ثانیه یک درخواستِ سبک به
/healthمیفرستد؛ اگر ۲۰۰ نگرفت یا timeout شد، instance را از rotation خارج میکند. - Passive (reactive): LB خطاهای واقعیِ ترافیکِ زنده را میشمارد؛ اگر یک instance شروع کرد به ۵۰۰ دادن، از rotation درش میآورد.
# Spring Boot Actuator: endpointهای سلامت برای health check
management:
endpoint:
health:
probes:
enabled: true # liveness و readiness جدا
show-details: never # جزئیات را به بیرون لو نده
endpoints:
web:
exposure:
include: health,info
# Kubernetes: از همان probeها استفاده میکند
livenessProbe: # اگر بمیرد → restart
httpGet: { path: /actuator/health/liveness, port: 8080 }
initialDelaySeconds: 20
periodSeconds: 10
readinessProbe: # اگر آماده نباشد → از Service خارجش کن (ترافیک نده)
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
liveness یعنی «زندهای؟» — اگر نه، restart کن. readiness یعنی «آمادهای ترافیک بگیری؟» — اگر نه، ترافیک نده ولی نکش. اشتباهِ رایج: health check که به دیتابیس وصل میشود را روی liveness بگذاری. اگر دیتابیس یک لحظه کند شود، Kubernetes همهٔ podها را همزمان kill و restart میکند و یک قطعیِ کوچک را به یک فروپاشیِ زنجیرهای (cascading failure) تبدیل میکنی. قانون: liveness باید سبک و مستقل از وابستگیهای بیرونی باشد؛ readiness میتواند وابستگیها را چک کند.
پاسخ: L4 در لایهٔ transport کار میکند و فقط IP/port را میبیند؛ بستهٔ TCP را بدون باز کردن به یک backend hand-off میکند. سریع و کمسربار است ولی نمیتواند محتوامحور تصمیم بگیرد. L7 در لایهٔ application کار میکند، کلِ HTTP را میفهمد و میتواند بر اساسِ path/header/cookie مسیریابی کند، TLS را terminate کند و retry بزند. L4 را وقتی انتخاب میکنم که فقط throughput خام و کمترین latency مهم است (مثلاً جلوی یک لایهٔ TCP یا برای عبور دادنِ TLS بدون terminate). L7 را وقتی میخواهم بر اساسِ مسیرِ URL به سرویسهای مختلف route کنم، احراز هویت/rate limit مرکزی بگذارم، یا canary/blue-green انجام بدهم. در معماریِ میکروسرویسِ واقعی معمولاً هر دو هستند: یک L4/L7 لبهای جلوی همه، و یک API Gateway ی L7 برای مسیریابیِ داخلی.
۵) لایههای Cache
cache ارزانترین و مؤثرترین اهرمِ scale است. قانونِ طلایی: ارزانترین کار، کاری است که اصلاً انجام ندهی. هر request که به cache بخورد، یک بار به دیتابیس نخورده. این بخش را مختصر میگویم چون فصلِ جداگانهٔ Caching عمیق واردش میشود؛ اینجا فقط جایگاهش در معماریِ کلان را میبینیم.
هر بار برای یک تخممرغ به انبارِ ته حیاط (دیتابیس) نمیروی؛ چندتایش را در یخچالِ کنارِ دستت (cache) نگه میداری. یخچال کوچک است (ظرفیتِ محدود) و ممکن است چیزی خراب شود (stale data)، ولی ۱۰۰ برابر سریعتر از رفتن به انبار است. مدیریتِ cache یعنی مدیریتِ همین یخچال: چه چیزی، چه مدت، و کِی دورش بریز.
cache در هر لایهای میتواند باشد و هرچه به کاربر نزدیکتر، سریعتر:
نمودار: لایههای cache از مرورگر تا دیتابیس — Cache layers from the browser down to the database.
flowchart LR
Browser[Browser cache] --> CDN[CDN edge cache]
CDN --> Gateway[Gateway / API]
Gateway --> AppCache[App cache - Redis]
AppCache --> DB[(Database + buffer cache)]
- Client cache (مرورگر): با header های
Cache-ControlوETag. سریعترین، چون اصلاً از دستگاه خارج نمیشود. - CDN: cache در لبهٔ شبکه، نزدیک به کاربرِ جغرافیایی (بخشِ بعدی).
- App cache (Redis/Memcached): دادههای داغِ برنامه؛ نتایجِ query، session، شمارندهها.
- DB cache: buffer pool خودِ دیتابیس دادههای اخیر را در RAM نگه میدارد.
دو الگوی اصلی
Cache-aside (lazy loading): رایجترین. برنامه اول cache را میپرسد؛ اگر نبود (miss)، از دیتابیس میخواند، در cache میگذارد، برمیگرداند.
public Product getProduct(Long id) {
String key = "product:" + id;
Product cached = redis.opsForValue().get(key);
if (cached != null) return cached; // hit
Product p = repository.findById(id).orElseThrow(); // miss → DB
redis.opsForValue().set(key, p, Duration.ofMinutes(10)); // با TTL
return p;
}
Write-through: هر نوشتن همزمان به cache و دیتابیس میرود؛ cache همیشه بهروز است ولی نوشتن کندتر. (الگوهای write-behind و read-through هم هستند — در فصل Caching.)
تصور کن یک key پرطرفدار (مثلاً صفحهٔ اصلی) TTL ش تمام میشود. در همان میلیثانیه، ۱۰٬۰۰۰ request همزمان miss میخورند و همه با هم به دیتابیس هجوم میبرند تا دوباره پُرش کنند. دیتابیس زیرِ این موجِ ناگهانی میخوابد. این «cache stampede» است. راهحلها: (۱) قفل/single-flight — فقط یک request اجازهٔ rebuild دارد و بقیه منتظر میمانند، (۲) مقدارِ منقضیشده را موقتاً serve کن و در پسزمینه refresh کن (stale-while-revalidate)، (۳) به TTL یک jitter تصادفی اضافه کن تا keyها همه با هم منقضی نشوند. این را در فصل Caching با کد کامل میبینی.
پاسخ: در cache-aside، cache را فقط موقعِ خواندن و پس از miss پُر میکنیم؛ نوشتن مستقیم به دیتابیس میرود و cache را invalidate/حذف میکنیم. مزیت: cache فقط دادهٔ واقعاً خواندهشده را نگه میدارد (حافظهٔ کمتر)، و اگر cache بیفتد سیستم فقط کندتر میشود نه خراب. عیب: اولین خواندنِ هر key همیشه miss است و یک پنجرهٔ کوچک ناسازگاری بینِ حذفِ cache و نوشتنِ DB وجود دارد. در write-through هر نوشتن همزمان cache و DB را بهروز میکند؛ مزیت: cache همیشه سازگار است و خواندنها همیشه hit. عیب: نوشتن کندتر و هزینهٔ نگهداریِ دادهٔ شاید هیچوقت خواندهنشده. در عمل cache-aside پیشفرضِ اکثرِ سیستمهاست چون خواندنمحورند و تحملِ ناسازگاریِ کوتاه را دارند؛ write-through را جایی میگذارم که خواندنِ فوریِ پس از نوشتن حیاتی باشد.
۶) CDN: نزدیککردنِ محتوا به کاربر
CDN (شبکهٔ توزیعِ محتوا) شبکهای از سرورهای cache در نقاطِ جغرافیاییِ مختلفِ دنیاست (PoP یا edge). وقتی کاربرِ تهران یک تصویر میخواهد، بهجای رفتن تا datacenterِ آمریکا (۱۵۰ms رفتوبرگشت)، از نزدیکترین edge (شاید چند ms) میگیرد — مثلِ فروشگاهِ زنجیرهای که بهجای یک انبارِ مرکزی، در هر شهر انبارِ محلیِ پرفروشها را دارد.
CDN عمدتاً برای محتوای استاتیک است: تصویر، ویدیو، CSS، JS، فایلِ دانلود. اما CDNهای مدرن (Cloudflare, Fastly, CloudFront) محتوای پویا را هم میتوانند cache یا accelerate کنند.
سه سود همزمان: (۱) latency پایین چون فاصلهٔ فیزیکی کم است، (۲) آزادسازیِ origin — اگر ۹۵٪ ترافیکِ استاتیک به CDN بخورد، سرورهای اصلیِ تو فقط ۵٪ بار را میبینند، پس کمتر سرور لازم داری، (۳) مقاومت در برابر DDoS و پیک — CDN یک سپرِ عظیم جلوی origin است. به همین دلیل قاعدهٔ سنیوری این است: هیچ بایتِ استاتیکی نباید به origin برسد.
سختترین مسئلهٔ CDN همان مسئلهٔ سختِ علومِ کامپیوتر است: باطلکردنِ cache. اگر logo.png را عوض کنی ولی همان اسم را نگه داری، کاربران تا انقضای TTL نسخهٔ قدیمی را میبینند. راهِ درست: fingerprint در نامِ فایل (logo.a1b2c3.png). هر تغییرِ محتوا اسم را عوض میکند، پس URLِ جدید ذاتاً یک cache miss است و میتوانی TTL را روی «یکسال، immutable» بگذاری. این الگوی استانداردِ ابزارهای build مدرن (Vite, webpack) است.
۷) مقیاسبندی دیتابیس
دیتابیس تقریباً همیشه اولین چیزی است که زیرِ فشار میشکند، چون stateful است و نمیتوانی مثل سرویسِ stateless صرفاً کپیاش کنی. سه اهرم داری، به ترتیبِ افزایشِ پیچیدگی.
اهرم ۱: Read Replica
اگر سیستمت خواندنمحور است (اکثریت اینطورند)، یک primary برای نوشتن و چند replica برای خواندن بساز. نوشتنها به primary میروند و بهصورتِ replication به replicaها پخش میشوند؛ خواندنها بینِ replicaها load-balance میشوند.
نمودار: تفکیک نوشتن روی primary و خواندن روی replicaها — Writes go to primary; reads fan out to replicas.
flowchart LR
App[App] -- writes --> P[(Primary)]
App -- reads --> R1[(Replica 1)]
App -- reads --> R2[(Replica 2)]
P -- async replication --> R1
P -- async replication --> R2
// Spring: مسیریابی خواندن/نوشتن با @Transactional(readOnly = true)
@Service
public class OrderService {
@Transactional // → primary
public Order place(OrderRequest req) { ... }
@Transactional(readOnly = true) // → replica (با DataSource routing)
public Order get(Long id) { ... }
}
replication معمولاً async است: primary نوشتن را تأیید میکند و بعداً آن را به replica میفرستد. این پنجره (چند میلیثانیه تا چند ثانیه در فشار) یعنی اگر کاربر همینالان چیزی save کند و بلافاصله صفحه را refresh کند و آن خواندن به replica برود، دادهٔ خودش را نمیبیند — چون هنوز به replica نرسیده. اسمِ این مشکل «read-your-own-writes» است. راهحل: خواندنهای حساسِ بلافاصلهپسازنوشتن را به primary بفرست (یا از یک session-consistency استفاده کن). این دقیقاً همان جایی است که consistency ضعیف در عمل کاربر را گاز میگیرد.
پاسخ: این replication lag است. replication معمولاً async است: primary نوشتن را تأیید میکند و با تأخیرِ چند میلیثانیهای آن را به replicaها میفرستد. اگر کاربر بلافاصله بعد از save صفحه را refresh کند و آن خواندن به یک replicaی برود که هنوز بهروز نشده، دادهٔ خودش را نمیبیند (نقضِ read-your-own-writes). راهحلها: (۱) خواندنهای بلافاصلهپسازنوشتن را برای همان کاربر/session به primary بفرست، (۲) از sticky routing بر اساسِ زمانِ آخرین نوشتنِ کاربر استفاده کن (تا وقتی lag میگذرد از primary بخوان)، (۳) اگر تحملپذیر است، در UI خوشبینانه نمایش بده. نکته: با افزایشِ بارِ نوشتن lag بدتر میشود، پس در پیک این مشکل شدیدتر است.
اهرم ۲: Partitioning (تقسیم درونِ یک دیتابیس)
partitioning یعنی یک جدولِ بزرگ را به بخشهای کوچکتر بشکنی که دیتابیس هرکدام را جدا مدیریت میکند — ولی همه در همان instance. مفید برای مدیریتِ جداولِ خیلی بزرگ (مثلاً partition بر اساسِ ماه، تا حذفِ دادهٔ قدیمی فقط drop یک partition باشد).
اینجا syntax دو دیتابیس فرق میکند، پس هر دو را میبینیم:
-- PostgreSQL: declarative partitioning (range بر اساس تاریخ)
CREATE TABLE events (
id bigint,
ts timestamptz NOT NULL,
payload jsonb
) PARTITION BY RANGE (ts);
CREATE TABLE events_2026_07 PARTITION OF events
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
-- برای توزیع یکنواخت بر اساس کلید: hash partitioning
CREATE TABLE accounts (id bigint, name text) PARTITION BY HASH (id);
CREATE TABLE accounts_p0 PARTITION OF accounts FOR VALUES WITH (MODULUS 4, REMAINDER 0);
-- Oracle: partitioning بومیِ خودش (interval بر اساس تاریخ، خودکار میسازد)
CREATE TABLE events (
id NUMBER,
ts TIMESTAMP NOT NULL,
payload CLOB
)
PARTITION BY RANGE (ts)
INTERVAL (NUMTOYMINTERVAL(1, 'MONTH'))
( PARTITION p0 VALUES LESS THAN (TO_DATE('2026-07-01','YYYY-MM-DD')) );
-- توزیع یکنواخت بر اساس کلید: hash partitioning
CREATE TABLE accounts (id NUMBER, name VARCHAR2(100))
PARTITION BY HASH (id) PARTITIONS 4;
PostgreSQL از نسخهٔ ۱۰ به بعد declarative partitioning دارد و باید partitionها را (یا با ابزار مثلِ pg_partman) خودت بسازی؛ INTERVAL خودکار ندارد. Oracle با INTERVAL میتواند partitionِ جدید را خودکار موقعِ ورودِ دادهٔ ماهِ جدید بسازد. هر دو hash/range/list partitioning دارند. نکتهٔ portability: منطقِ partitioning را در application فرض نکن؛ بگذار دیتابیس مدیریتش کند تا query planner بتواند partition pruning انجام بدهد (فقط partitionِ مربوطه را بخواند).
اهرم ۳: Sharding (تقسیم بینِ چند دیتابیس)
وقتی داده از یک ماشین بزرگتر شد (مثلِ همان ۹۱TBِ کوتاهکنندهٔ لینک)، باید بینِ چند دیتابیسِ مستقل پخشش کنی. این sharding است. هر shard زیرمجموعهای از داده را دارد و کاملاً مستقل است.
سؤالِ کلیدی: یک رکورد به کدام shard میرود؟ این را shard key و تابعِ توزیع تعیین میکنند:
- Range-based: بر اساسِ بازه (idهای ۱ تا میلیون در shard 1). ساده ولی مستعدِ hotspot (اگر idهای جدید همه در آخرین shard متمرکز شوند).
- Hash-based:
shard = hash(key) % N. توزیعِ یکنواخت، ولی یک مشکلِ بزرگ دارد که در ادامه میآید.
اگر shard = hash(key) % N باشد و از ۴ نود به ۵ نود بروی، % 4 به % 5 تغییر میکند و تقریباً تمامِ keyها به shardِ دیگری نگاشت میشوند — یعنی باید تقریباً کلِ داده را جابهجا (rehash) کنی. در مقیاسِ ترابایتی این یک فاجعه است. راهحل: consistent hashing.
Consistent Hashing
consistent hashing فضای hash را روی یک حلقه (۰ تا مثلاً 2³²−۱) تصور میکند. هم nodeها و هم keyها روی این حلقه hash میشوند. هر key به اولین nodeی که در جهتِ عقربههای ساعت بعد از آن میآید تعلق میگیرد.
مهمانها (keyها) و میزبانها (nodeها) را دورِ یک میزِ گرد بچین. هر مهمان به اولین میزبانی که ساعتگرد بعد از او نشسته سرویس داده میشود. حالا یک میزبانِ جدید بیاید و بنشیند: فقط مهمانهایی که بینِ او و میزبانِ قبلیاش بودند جابهجا میشوند؛ بقیهٔ میز دستنخورده میماند. برعکسِ % N که با هر تغییر همهٔ صندلیها را بههم میریخت.
مزیت: با افزودن یا حذفِ یک node، فقط K/N از keyها (K کل، N نودها) جابهجا میشوند، نه همه.
نمودار: حلقهٔ consistent hashing و تعلقِ keyها به nodeی که ساعتگرد بعدش میآید — Keys map to the next node clockwise on the hash ring.
flowchart LR
subgraph Ring[Hash Ring 0..2^32]
N1[Node A @ 30]
N2[Node B @ 120]
N3[Node C @ 220]
end
K1[key1 -> 45] --> N2
K2[key2 -> 130] --> N3
K3[key3 -> 250] --> N1
Virtual nodes (vnodes): اگر هر node فقط یک نقطه روی حلقه داشته باشد، توزیع نامتوازن میشود (یکی بازهٔ بزرگ میگیرد، یکی کوچک). راهحل: هر nodeِ فیزیکی را با چند صد نقطهٔ مجازی روی حلقه پخش کن. حالا بار یکنواختتر توزیع میشود و وقتی nodeی میافتد، بارش بینِ همهٔ بقیه پخش میشود نه فقط یک همسایه. این تکنیکِ استانداردی است که Dynamo، Cassandra و ScyllaDB استفاده میکنند.
پاسخ: با hash(key) % N، تعدادِ nodeها (N) در فرمول است، پس هر تغییرِ N (افزودن/حذفِ node) تقریباً همهٔ نگاشتها را عوض میکند و باید تقریباً کلِ داده را rehash و جابهجا کنیم — در مقیاسِ بزرگ فاجعه است و باعثِ downtime یا موجِ عظیمِ دادهجابهجایی میشود. consistent hashing با نگاشتِ node و key روی یک حلقهٔ hash، این وابستگی را میشکند: افزودن یا حذفِ یک node فقط K/N از keyها را جابهجا میکند (همانهایی که بینِ node جدید و همسایهاش بودند). با virtual nodeها هم توزیع را یکنواخت میکنیم و هم موقعِ افتادنِ یک node بارش را بینِ همه پخش میکنیم نه یک همسایه. به همین دلیل Cassandra، DynamoDB و Riak روی همین بنا شدهاند.
(۱) query چند-shardی گران است: هر query که shard key نداشته باشد باید به همهٔ shardها برود و نتیجهها ادغام شوند (scatter-gather). shard key را طوری انتخاب کن که پرتکرارترین queryها فقط یک shard را بزنند. (۲) JOIN بینِ shardها عملاً ممکن نیست — باید در application انجام بدهی یا داده را denormalize کنی. (۳) تراکنشِ توزیعشده بینِ shardها سخت و کند است. (۴) hot shard: اگر shard key بد باشد (مثلاً کشور، و ۸۰٪ کاربرت یک کشوریاند) یک shard میسوزد و بقیه بیکارند. sharding آخرین اهرم است، نه اولین — اول cache و read replica.
۸) CAP و PACELC: قانونِ آهنینِ سیستمهای توزیعشده
وقتی داده روی چند node تکرار میشود (که برای availability لازم است)، به یک قانونِ بنیادین میخوری.
قضیهٔ CAP
در یک سیستمِ توزیعشده، بینِ سه خاصیت فقط میتوانی دو تا را همزمان تضمین کنی:
- C — Consistency: هر خواندن جدیدترین نوشتن را میبیند (همهٔ nodeها یک نمای واحد).
- A — Availability: هر request جوابِ (غیرخطا) میگیرد، حتی اگر جدیدترین نباشد.
- P — Partition tolerance: سیستم با وجودِ قطعِ ارتباطِ شبکه بینِ nodeها کار میکند.
دو شعبهٔ یک بانک، و خطِ تلفنِ بینشان قطع شده (این partition است). مشتری در شعبهٔ A میخواهد ۱۰۰۰ برداشت کند. دو انتخاب داری: (الف) اجازه بده برداشت کند (available) — ولی حالا شعبهٔ B نمیداند و موجودی ممکن است دوبار خرج شود (inconsistent). (ب) بگو «تا وصلِ خط صبر کن» (consistent) — ولی الان مشتری جواب نمیگیرد (unavailable). partition (P) اجباری است چون شبکه واقعاً قطع میشود؛ پس مجبوری بینِ C و A یکی را انتخاب کنی.
CAP نمیگوید «۲ از ۳ را انتخاب کن» بهشکلِ آزاد. در سیستمِ توزیعشدهٔ واقعی، P اختیاری نیست — شبکه گاهی قطع میشود، این واقعیتِ فیزیک است. پس انتخابِ واقعی همیشه بینِ C و A است، آنهم فقط هنگامِ partition. سیستمها را به CP (مثلِ یک RDBMS با replicationِ sync، یا etcd/ZooKeeper) و AP (مثلِ Cassandra، DynamoDB با تنظیمِ eventual) تقسیم میکنیم.
PACELC: ادامهٔ صادقانهٔ CAP
CAP یک ضعفِ بزرگ دارد: فقط دربارهٔ هنگامِ partition حرف میزند، که نادر است. اما هر میلیثانیه که شبکه سالم است هم یک trade-off وجود دارد که CAP سکوت میکند: latency در برابر consistency. اینجاست که PACELC (دنیل آبادی، ۲۰۱۲) وارد میشود:
اگر Partition بود (P)، بینِ Availability و Consistency انتخاب کن؛ وگرنه (E = Else)، بینِ Latency و Consistency انتخاب کن.
منطقش: برای consistencyِ قوی باید نوشتن را به چند node تأییدشده برسانی (quorum/sync)، که latency اضافه میکند. اگر بخواهی latency پایین باشد، باید به consistencyِ ضعیفتر رضایت بدهی. این trade-off همیشه حاضر است، نه فقط موقعِ فاجعه.
| سیستم | هنگامِ partition | حالتِ عادی (Else) | برچسبِ PACELC |
|---|---|---|---|
| PostgreSQL / MySQL (single primary) | C (unavailable میشود) | C (latency میپذیرد) | PC/EC |
| DynamoDB / Cassandra (پیشفرض) | A | L (latency کم) | PA/EL |
| MongoDB | C | C (پیشفرض) | PC/EC |
| Cassandra با tunable consistency | قابلِتنظیم | قابلِتنظیم | معمولاً PA/EL |
در مصاحبه اگر فقط CAP بگویی، متوسطی. اگر بگویی «partition نادر است؛ هزینهٔ واقعیِ روزمرهٔ من همان L در برابر C در حالتِ عادی است»، نشان میدهی که trade-offِ واقعیِ production را فهمیدهای. اکثرِ تصمیمهای consistency که هر روز میگیری (quorum read/write، خواندن از replica، async replication) دربارهٔ همین «E» هستند، نه دربارهٔ partition.
مدلهای consistency (طیف، نه دوگانه)
consistency یک کلیدِ روشن/خاموش نیست؛ یک طیف است از قوی تا ضعیف:
- Strong / Linearizable: هر خواندن، آخرین نوشتنِ تأییدشده را میبیند، انگار یک نسخهٔ واحد. گران (نیاز به هماهنگی)، ولی ساده برای برنامهنویس.
- Sequential / Causal: ترتیبِ علتومعلولی حفظ میشود (اگر A باعثِ B شد، همه A را قبلِ B میبینند)، ولی رویدادهای بیربط ممکن است ترتیبِ متفاوت داشته باشند.
- Read-your-writes: حداقل نوشتههای خودت را میبینی (session consistency).
- Eventual: اگر نوشتن متوقف شود، بالاخره همهٔ replicaها همگرا میشوند. سریع و دردسترس، ولی ممکن است موقتاً دادهٔ کهنه ببینی.
eventual برای «تعداد لایک» عالی است (کسی نمیمیرد اگر یک ثانیه ۹۹ بهجای ۱۰۰ ببیند). برای «موجودیِ حسابِ بانکی» یا «آیا این بلیت فروخته شده؟» فاجعه است. قاعده: مدلِ consistency را برای هر داده جدا انتخاب کن، نه برای کلِ سیستم. پول، موجودیِ انبار، و یکتاییِ نامکاربری strong میخواهند؛ feed، شمارندهها و آمار eventual را تحمل میکنند. این هنرِ تفکیک است.
پاسخ: CAP میگوید هنگامِ partitionِ شبکه بینِ nodeها، مجبوری بینِ consistency (همه یک نمای واحد) و availability (هر request جواب میگیرد) یکی را قربانی کنی، چون partition tolerance در سیستمِ توزیعشدهٔ واقعی اجباری است. برای هستهٔ بانکی — موجودی، انتقال، تراکنش — CP انتخاب میکنم: اگر شبکه قطع شد، ترجیح میدهم request را رد کنم یا صبر کنم تا اینکه ریسکِ خرجِ دوبارهٔ پول یا موجودیِ منفیِ ناسازگار را بپذیرم. اما همهچیزِ بانک هستهای نیست: تاریخچهٔ تراکنشهای نمایشی، امتیازِ وفاداری، یا اعلانها را میتوانم AP و eventual بگذارم تا دردسترس بمانند. نکتهٔ سنیوری: با PACELC اضافه میکنم که حتی در حالتِ عادی، برای هستهٔ مالی L را فدای C میکنم (synchronous replication با quorum) چون درستیِ پول از چند میلیثانیه latency مهمتر است.
۹) Rate Limiting و Idempotency
Rate Limiting: محافظِ سیستم
rate limiting یعنی محدود کردنِ تعدادِ requestی که یک کلاینت در بازهٔ زمانی میتواند بزند. بدونِ آن، یک کلاینتِ بدرفتار (یا حمله، یا باگِ retry) میتواند کلِ ظرفیت را ببلعد.
یک سطل را تصور کن که با نرخِ ثابت (مثلاً ۱۰ توکن در ثانیه) آب/توکن به آن ریخته میشود، و ظرفیتِ محدودی دارد (مثلاً ۱۰۰ توکن). هر request یک توکن برمیدارد. اگر سطل خالی باشد، request رد میشود. این token bucket است: نرخِ پایدار را کنترل میکند ولی اجازهٔ burstِ کوتاه (تا ظرفیتِ سطل) را هم میدهد. این پرکاربردترین الگوریتم است.
الگوریتمهای رایج:
| الگوریتم | ایده | اجازهٔ burst | مصرفِ حافظه |
|---|---|---|---|
| Token Bucket | توکن با نرخِ ثابت پُر، هر request یکی مصرف | بله (تا ظرفیت) | کم (۲ عدد: توکن، زمان) |
| Leaky Bucket | خروجی با نرخِ ثابت، مثلِ صف | خیر (صاف میکند) | کم |
| Fixed Window | شمارنده در پنجرهٔ ثابت (هر دقیقه) | مشکلِ لبهٔ پنجره | کم |
| Sliding Window Log/Counter | پنجرهٔ لغزان، دقیقتر | نرم | بیشتر |
دقت کن که Fixed Window لبهٔ پنجره را نشت میدهد: با محدودیتِ «۱۰۰ در دقیقه»، کلاینت میتواند ۱۰۰تا در ثانیهٔ آخرِ یک دقیقه و ۱۰۰تای دیگر در ثانیهٔ اولِ دقیقهٔ بعد بزند — ۲۰۰تا در دو ثانیه. Sliding Window این را حل میکند ولی حافظهٔ بیشتری میخواهد؛ برای اکثرِ کاربردها Token Bucket تعادلِ خوبی است.
در Spring، Spring Cloud Gateway یک RequestRateLimiter داخلی با پیادهسازیِ Redis و الگوریتمِ token bucket دارد:
spring:
cloud:
gateway:
routes:
- id: orders
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- name: RequestRateLimiter
args:
# نرخِ پُرشدنِ سطل: مجازترین نرخِ پایدار (توکن در ثانیه)
redis-rate-limiter.replenishRate: 10
# ظرفیتِ سطل: بیشترین burstِ لحظهای
redis-rate-limiter.burstCapacity: 20
# هزینهٔ هر request (پیشفرض ۱)
redis-rate-limiter.requestedTokens: 1
key-resolver: "#{@userKeyResolver}"
// کلید را بر اساس کاربر تعریف کن (نه کلِ سیستم)؛ برمیگرداند 429 اگر تمام شد
@Bean
KeyResolver userKeyResolver() {
return exchange -> Mono.justOrEmpty(
exchange.getRequest().getHeaders().getFirst("X-User-Id"))
.defaultIfEmpty("anonymous");
}
هرگز کلِ سیستم را با یک limit محدود نکن؛ بر اساسِ user/API-key/IP کلید بزن تا یک کاربرِ پرمصرف بقیه را قربانی نکند. و پاسخ را استاندارد بده: status 429، همراه با header های Retry-After و X-RateLimit-Remaining تا کلاینت بداند کِی دوباره تلاش کند. rate limit بدونِ این headerها کلاینت را وادار به retryِ کور میکند که فشار را بدتر میکند. (Gateway از Redis استفاده میکند تا شمارنده بینِ همهٔ instanceها مشترک و limit سراسری باشد، نه per-instance.)
پاسخ: token bucket با نرخِ ثابت توکن پُر میشود و هر request یک توکن مصرف میکند؛ چون سطل ظرفیت دارد، burstِ کوتاه (تا ظرفیت) مجاز است — عالی برای APIهایی که ترافیکِ ناگهانیِ طبیعی دارند. leaky bucket مثلِ یک صف با خروجیِ ثابت است؛ ترافیک را صاف میکند و burst نمیدهد — عالی وقتی میخواهی downstream یک نرخِ کاملاً یکنواخت ببیند (مثلاً محافظت از سرویسی که با پیک میخوابد). در عمل token bucket پیشفرضِ اکثرِ API gatewayهاست (از جمله Spring Cloud Gateway) چون هم limit را اعمال میکند و هم تجربهٔ کاربرِ عادی را با اجازهٔ burst خراب نمیکند. اگر بخواهم دقتِ بالاتری بدونِ مشکلِ لبهٔ پنجره داشته باشم، sliding window را در نظر میگیرم، به قیمتِ حافظهٔ بیشتر.
Idempotency: تضمینِ «یکبار، نه بیشتر»
یک عملیات idempotent است اگر اجرای چندبارهٔ آن با ورودیِ یکسان، همان اثری را داشته باشد که یکبار. GET و PUT و DELETE ذاتاً idempotent اند؛ POST معمولاً نیست.
چرا حیاتی است؟ چون شبکه غیرقابلاعتماد است. کلاینت request پرداخت را میفرستد، سرور پول را کم میکند و پاسخ میفرستد، ولی پاسخ در راه گم میشود. کلاینت timeout میخورد و دوباره retry میکند. بدونِ idempotency، مشتری دوبار پول میدهد. مثلِ دکمهٔ آسانسور: هرچقدر دکمهٔ طبقهٔ ۵ را فشار بدهی، آسانسور فقط یکبار میآید — عملیاتِ idempotent باید همینطور باشد: retry بیضرر.
راهحلِ استاندارد: Idempotency Key. کلاینت یک کلیدِ یکتا (UUID) با request میفرستد؛ سرور نتیجهٔ اولین اجرا را ذخیره و به requestهای بعدی با همان کلید، همان نتیجه را برمیگرداند بدونِ اجرای دوباره.
نمودار: پردازشِ idempotent — تشخیصِ کلیدِ تکراری و بازگرداندنِ نتیجهٔ قبلی — Duplicate key returns the stored result instead of re-executing.
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Idempotency Store
C->>S: POST /payments (Idempotency-Key: k123)
S->>DB: exists(k123)?
DB-->>S: no
S->>S: charge card (once)
S->>DB: save(k123, result)
S-->>C: 200 OK (result)
Note over C,S: timeout → client retries
C->>S: POST /payments (Idempotency-Key: k123)
S->>DB: exists(k123)?
DB-->>S: yes → stored result
S-->>C: 200 OK (same result, no re-charge)
@PostMapping("/payments")
public ResponseEntity<PaymentResult> pay(
@RequestHeader("Idempotency-Key") String key,
@RequestBody PaymentRequest req) {
// اگر قبلاً دیدهایم، همان نتیجه را برگردان (بدونِ خرجِ دوباره)
Optional<PaymentResult> prior = idempotencyStore.find(key);
if (prior.isPresent()) {
return ResponseEntity.ok(prior.get());
}
// اتمی: فقط اگر کلید جدید بود جا بگیر (جلوی race میانِ دو retry همزمان)
if (!idempotencyStore.tryReserve(key)) {
return ResponseEntity.status(HttpStatus.CONFLICT).build(); // در حالِ پردازش
}
PaymentResult result = paymentService.charge(req); // یکبار
idempotencyStore.complete(key, result);
return ResponseEntity.ok(result);
}
گذاشتنِ unique constraint روی یک ستون از ذخیرهٔ رکوردِ دوم جلوگیری میکند، ولی side-effect را (مثلِ کمشدنِ پول از کارت، ارسالِ ایمیل، فراخوانیِ سرویسِ بیرونی) خنثی نمیکند. idempotencyِ درست یعنی قبل از هر side-effect کلید را چک کنی و نتیجهٔ کامل را ذخیره کنی. و مراقبِ race باش: دو retryِ همزمان میتوانند هر دو «کلید موجود نیست» ببینند؛ به یک عملیاتِ اتمی (مثلِ INSERT ... ON CONFLICT DO NOTHING یا SETNX در Redis) نیاز داری تا فقط یکی برنده شود.
-- ذخیرهٔ اتمیِ کلید — PostgreSQL
INSERT INTO idempotency (key, status) VALUES ('k123', 'IN_PROGRESS')
ON CONFLICT (key) DO NOTHING; -- اگر ۰ سطر متأثر شد، یعنی تکراری است
-- معادلِ Oracle (نبودِ ON CONFLICT؛ از MERGE یا exception هندلینگ استفاده کن)
MERGE INTO idempotency d
USING (SELECT 'k123' AS key FROM dual) s
ON (d.key = s.key)
WHEN NOT MATCHED THEN INSERT (key, status) VALUES (s.key, 'IN_PROGRESS');
PostgreSQL از INSERT ... ON CONFLICT DO NOTHING/UPDATE استفاده میکند (روشن و سریع). Oracle این را ندارد؛ راهِ استاندارد MERGE است، یا در نسخهٔ 23ai دستورِ جدیدِ ... ON CONFLICT هم اضافه شده. راهِ سومِ portable: INSERT را امتحان کن و unique violation را در application بگیر. برای idempotency، هرکدام را انتخاب کنی، باید اتمی باشد تا دو retry همزمان هر دو برنده نشوند.
پاسخ: چون شبکه غیرقابلاعتماد است: کلاینت ممکن است پاسخِ موفق را دریافت نکند (timeout، قطعیِ لحظهای) و همان request را retry کند؛ بدونِ idempotency، کارت دوبار شارژ میشود. پیادهسازی: کلاینت یک Idempotency-Key یکتا (UUID) با هر تراکنش میفرستد. سرور قبل از هر side-effect بهصورتِ اتمی این کلید را رزرو میکند (مثلاً INSERT ... ON CONFLICT DO NOTHING یا SETNX). اگر کلید جدید بود، شارژ را یکبار انجام میدهد و نتیجهٔ کامل را ذخیره میکند؛ اگر تکراری بود، همان نتیجهٔ ذخیرهشده را بدونِ اجرای دوباره برمیگرداند. نکتهٔ ظریف: باید مراقبِ race بینِ دو retryِ همزمان بود، برای همین رزرو باید اتمی باشد نه یک check-then-act معمولی. و کلیدها را با TTL (مثلاً ۲۴ ساعت) نگه میدارم تا store بینهایت رشد نکند.
۱۰) ریاضیِ Availability: نُهها، redundancy، failover
availability یعنی چند درصدِ زمان سیستم دردسترس است. با «نُه» اندازهاش میگیریم و هر نُهِ اضافه، downtime را دهبرابر کم میکند — و هزینه را چندبرابر.
| Availability | نامِ رایج | downtime سالانه | downtime ماهانه |
|---|---|---|---|
| 99% | دو نُه | ~۳٫۶۵ روز | ~۷٫۲ ساعت |
| 99.9% | سه نُه | ~۸٫۷۶ ساعت | ~۴۳ دقیقه |
| 99.95% | — | ~۴٫۳۸ ساعت | ~۲۲ دقیقه |
| 99.99% | چهار نُه | ~۵۲٫۶ دقیقه | ~۴٫۳ دقیقه |
| 99.999% | پنج نُه | ~۵٫۲۶ دقیقه | ~۲۶ ثانیه |
هر نُهِ اضافه یعنی یک جهشِ کیفی در مهندسی، نه فقط «کمی بهتر». سهنُه را با تلاشِ معمول و monitoring میگیری. چهارنُه یعنی failoverِ خودکار، redundancy کامل، و deployِ بیdowntime. پنجنُه (۲۶ ثانیه در ماه!) یعنی هیچ دخالتِ انسانی در مسیرِ recovery نمیتواند باشد — آدم بهاندازهٔ کافی سریع نیست؛ همهچیز باید خودکار باشد. هر نُه چند برابر گرانتر است، پس بیشتر از آنچه کسبوکار لازم دارد نخر.
ترکیبِ availability: سری در برابر موازی
اگر request تو باید از چند مؤلفه پشتِسرِهم عبور کند (سری)، availabilityها ضرب میشوند و همیشه کمتر از تکِ اجزا میشود:
اگر gateway و service و DB هرکدام 99.9% باشند: 0.999 × 0.999 × 0.999 ≈ 99.7%. زنجیره از ضعیفترین حلقهاش هم ضعیفتر است.
اگر یک مؤلفه را موازی (redundant) کنی، تنها وقتی میافتد که همهی نسخههایش بیفتند، پس احتمالِ خرابی ضرب میشود (کوچکتر):
دو instanceِ 99% موازی: احتمالِ خرابیِ هر دو = 0.01 × 0.01 = 0.0001 → availability = 99.99%. دو تا instanceِ متوسط، با هم یک سیستمِ عالی ساختند. این کلِ فلسفهٔ redundancy است.
ریاضیِ بالا فقط وقتی درست است که خرابیها مستقل باشند. اگر هر دو instance روی یک rack، یک برقِ مشترک، یک AZ، یا یک نسخهٔ باگدارِ کد باشند، یک رخداد هر دو را با هم میخواباند و redundancyِ تو کاغذی است. به همین دلیل redundancy را در دامنههای خرابیِ (failure domain) مستقل پخش میکنند: چند AZ، چند rack، چند provider. «دو کپی روی یک ماشین» redundancy نیست.
Redundancy و Failover
- Active-Passive: یک نودِ فعال کار میکند، یک standby آماده. اگر فعال بیفتد، failover به standby. ساده، ولی standby بیکار پول میگیرد و failover چند ثانیه طول میکشد.
- Active-Active: همه فعالاند و بار را تقسیم میکنند. کارآمدتر و failover آنی، ولی نیاز به هماهنگیِ state (و برخورد با همان مسائلِ consistency).
نمودار: چرخهٔ عمرِ failover در حالتِ active-passive — Active-passive failover lifecycle when the primary fails.
stateDiagram-v2
[*] --> ActiveHealthy
ActiveHealthy --> Degraded: health check fails
Degraded --> Failover: threshold exceeded
Failover --> StandbyPromoted: promote standby
StandbyPromoted --> ActiveHealthy: old node recovers as standby
همه فکر میکنند failover همیشه نجاتبخش است. ولی failoverِ خودکار خودش میتواند منبعِ فاجعه باشد: split-brain (هر دو نود فکر کنند primary اند و داده واگرا شود)، یا flapping (نود سالموناسالم پشتِسرِهم و failoverهای مکرر). به همین دلیل سیستمهای جدی از یک quorum/consensus (مثلِ Raft در etcd/Consul) برای انتخابِ leader استفاده میکنند تا هیچوقت دو primary نباشد. و همیشه failover را در محیطِ آزمایش تمرین کن (chaos engineering) — یک failoverی که هیچوقت تست نشده، موقعِ بحران کار نمیکند.
پاسخ: چون سریاند، ضرب میشوند: 0.999³ ≈ 0.997، یعنی حدودِ 99.7% — از تکِ اجزا بدتر، چون زنجیره به همهٔ حلقههایش وابسته است. برای بهترکردن: (۱) هر مؤلفه را موازی/redundant کن؛ دو instanceِ 99.9% بهصورتِ موازی availability را به ~99.9999% میرسانند چون فقط وقتی میافتند که هر دو با هم بیفتند. (۲) redundancy را در failure domainهای مستقل (چند AZ) پخش کن تا خرابیها همبسته نباشند، وگرنه ریاضی دروغ میشود. (۳) وابستگیهای غیرحیاتی را از مسیرِ بحرانی خارج کن (async یا circuit breaker) تا افتادنِ یک مؤلفهٔ فرعی کلِ request را نخواباند. (۴) timeout و retryِ محدود بگذار تا یک مؤلفهٔ کند، کلِ زنجیره را قفل نکند. نکته: هدفِ نهایی را از کسبوکار بپرس — رسیدن از 99.7% به 99.99% چند برابر هزینه دارد و شاید لازم نباشد.
۱۱) جمعبندی: یک معماریِ کامل
حالا همهٔ قطعات را کنارِ هم بگذاریم. این توپولوژیِ استانداردِ یک سیستمِ وب در مقیاسِ بزرگ است — همان که در مصاحبه باید بتوانی از حفظ و با توجیه بکشی:
نمودار: معماریِ مرجعِ یک سیستمِ وب مقیاسپذیر — Reference architecture of a scalable web system.
flowchart TD
User[Users] --> CDN[CDN - static assets]
User --> LB[L7 Load Balancer]
LB --> GW[API Gateway - auth, rate limit]
GW --> S1[Service instances - stateless]
S1 --> Cache[(Redis - cache + sessions)]
S1 --> P[(DB Primary - writes)]
S1 --> R[(DB Replicas - reads)]
P -- replication --> R
S1 -- events --> Q[(Message Queue)]
Q --> W[Async Workers]
مسیرِ یک request از بالا تا پایین، با هر تصمیمی که در این فصل ساختیم: ۱) محتوای استاتیک از CDN میآید و اصلاً به سیستم نمیرسد. ۲) بقیه به L7 load balancer میرسد که سالمها را با health check انتخاب میکند. ۳) API Gateway احراز هویت و rate limiting (token bucket) را مرکزی انجام میدهد. ۴) سرویسهای stateless که افقی scale میشوند؛ session در Redis. ۵) cache (cache-aside) اکثرِ خواندنها را جواب میدهد؛ فقط miss به DB میرود. ۶) primary/replica خواندن را از نوشتن جدا میکند؛ در ابعادِ بزرگتر sharding با consistent hashing. ۷) کارهای سنگین و غیرفوری async به queue میروند تا مسیرِ اصلی سریع بماند. ۸) هر لایه redundant در چند AZ برای availability هدف.
همیشه این ترتیب: (۱) نیازمندی را روشن کن (functional + non-functional + اعداد). (۲) تخمین بزن (QPS/storage/bandwidth) تا مقیاس معلوم شود. (۳) API و مدلِ داده را طراحی کن. (۴) یک طرحِ ساده بکش که کار کند. (۵) گلوگاهها را پیدا کن و مقیاسپذیر کن (cache → replica → shard → async). (۶) دربارهٔ trade-offها بلند فکر کن (CAP/PACELC، consistency، availability). هرگز از مرحلهٔ ۱ نپر؛ همیشه بلند فکر کن؛ همیشه با «چون…» توجیه کن.
- طراحیِ سیستم = مدیریتِ trade-off؛ هیچ جوابِ درستِ واحدی نیست، فقط انتخابِ آگاهانه با عدد.
- همیشه از نیازمندی (functional/non-functional) و تخمینِ سرانگشتی (QPS/storage/bandwidth، با ۸۶۴۰۰≈۱۰⁵) شروع کن؛ non-functionalها معماری را دیکته میکنند.
- عمودی ساده ولی محدود؛ افقی مقیاسپذیر ولی نیازمندِ statelessness. session را بیرون بگذار (Redis/JWT).
- Load balancer: L4 سریع و کور، L7 هوشمند و محتوامحور؛ health check با تفکیکِ liveness/readiness حیاتی است.
- Cache ارزانترین اهرم است (client/CDN/app/DB)؛ مراقبِ stampede و TTLِ بدونِ jitter باش (جزئیات در فصل Caching).
- CDN هیچ بایتِ استاتیکی را نگذار به origin برسد؛ با fingerprint در نام، invalidation را حل کن.
- دیتابیس: اول read replica (مراقبِ replication lag)، بعد partitioning، آخر sharding با consistent hashing (+ vnodes) تا rehash کمینه شود.
- CAP بینِ C و A هنگامِ partition؛ PACELC trade-offِ همیشگیِ L در برابر C در حالتِ عادی را هم میگوید. consistency را per-data انتخاب کن.
- Rate limiting با token bucket (Spring Cloud Gateway + Redis)؛ per-key و با header های
429/Retry-After. - Idempotency با کلیدِ یکتا و رزروِ اتمی؛ retryِ شبکه نباید دوبار پول کم کند.
- Availability: هر نُه دهبرابر گرانتر؛ سری ضرب و کاهش، موازیِ مستقل ضربِ خطا و بهبود؛ failover را تمرین کن و مراقبِ split-brain باش.
Let me start with the truth that sinks most people in a System Design interview: there is no right answer; there are only trade-offs. The interviewer doesn't want to see that you memorized Netflix's architecture. They want to see that when you say "I'll add a read replica," you understand what you gained (read scale) and what you gave up (replication lag and weaker consistency). A senior is someone who backs every decision with a "because…" and a number.
This chapter isn't a list of buzzwords to memorize. It teaches a way of thinking: start from requirements, size the load with back-of-envelope estimation, then build the system layer by layer — from load balancer to cache to database — always aware of the trade-offs. The same path you must walk in a 45-minute interview.
- The interview method: functional vs non-functional requirements and why they decide everything.
- Back-of-envelope estimation: QPS, storage, bandwidth — with numbers you must memorize.
- Vertical vs horizontal scaling and why statelessness is the precondition for scaling out.
- Load balancing: Layer 4 vs 7, algorithms, health checks.
- Cache layers (client/CDN/app/DB), cache-aside and write-through, eviction and cache stampede — linking to the Caching chapter.
- CDN and why static content should never reach the origin.
- Database scaling: read replicas, partitioning and sharding, consistent hashing.
- CAP and PACELC, consistency models.
- Rate limiting (token bucket and friends) and idempotency.
- Availability math: the "nines," redundancy, failover.
- A full reference architecture as the capstone.
1) Method: requirements first, architecture second
The biggest junior mistake in a system design interview: the interviewer says "design a link shortener" and the candidate immediately starts drawing Kafka and Cassandra. A senior asks questions first.
A good architect building your house doesn't first ask "how many bricks?" They ask "how many people will live here? What's the budget? Is it an earthquake zone? Will you add a floor in ten years?" Those are requirements. The number of bricks (the technology) follows from those answers, not the other way around. In system design, clarify the shape of the problem first, then pick tools.
Requirements come in two kinds, and this split is the backbone of the whole interview:
Functional requirements: what the system does. Verbs. "User submits a long URL, gets a short one." "Opening the short link redirects to the original." These shape your API.
Non-functional requirements: how the system must behave. Adjectives. Scale, latency, availability, consistency, durability, security. These shape your architecture — and 90% of the interview score lives here.
Two systems with identical functional requirements ("get a message from A to B") can have completely different architectures. If the non-functional says "100 messages a day, latency doesn't matter," one database table is enough. If it says "500k messages per second, under 100ms latency, zero-loss durability," now you need Kafka, partitioning, and replication. Always get numbers from the interviewer. The golden question: "What's the target scale? How many daily active users (DAU)? What's the read-to-write ratio?"
Four numbers you must always ask for:
- DAU / MAU (daily/monthly active users) — the basis of every estimate.
- read:write ratio — a 100:1 read-heavy system (like a link shortener) is designed completely differently from a write-heavy one.
- data volume and lifetime — retained for how many years? This determines storage.
- SLA for latency and availability — "under 200ms at p99" and "three nines or four."
If you start drawing diagrams without asking those four numbers, the interviewer marks you junior on the spot — even if your architecture is correct. The right design for the wrong problem is wrong. Spend the first three minutes clarifying scope and requirements; it's the best investment you can make.
Answer: Functional means what the system does (verbs: "create a short link," "redirect") and shapes the API. Non-functional means how it must behave (adjectives: scale, latency, availability, consistency, durability) and shapes the architecture. Two systems with identical functionals can have completely different architectures depending on the non-functionals — a simple table vs Kafka and sharding. That's why in an interview I first extract the non-functional numbers (DAU, read:write ratio, data volume and lifetime, SLA) and derive the architecture from them.
2) Back-of-envelope estimation: with paper and brain, not a calculator
Back-of-the-envelope estimation means sizing the system with a few rough multiplications and divisions: how many requests per second (QPS)? how many terabytes of storage? how many gigabits of bandwidth? The goal isn't decimal precision; it's knowing whether you're dealing with "one server" or "a thousand servers" — two entirely different architectures. Like a chef who says "about 40 kilos of meat" without a scale and knows a home fridge won't cut it: it's enough to know which order of magnitude you're in.
Numbers to memorize
Seconds in a day: 86,400 ≈ 10⁵. This is the only number you need for QPS. (Average QPS = daily requests ÷ 10⁵.)
Powers of 2 for memory: 2¹⁰ ≈ thousand (KB), 2²⁰ ≈ million (MB), 2³⁰ ≈ billion (GB), 2⁴⁰ ≈ trillion (TB).
Latency numbers every engineer should know (per Jeff Dean; rounded but still valid for order-of-magnitude reasoning):
| Operation | Approx time |
|---|---|
| L1 cache reference | 0.5 ns |
| Mutex lock/unlock | 25 ns |
| Main memory (RAM) reference | 100 ns |
| Read 1MB sequentially from RAM | ~250 µs |
| Round-trip within same datacenter | ~500 µs |
| SSD random read | ~150 µs |
| Read 1MB sequentially from SSD | ~1 ms |
| Disk seek (spinning HDD) | ~10 ms |
| Packet CA→Netherlands→CA | ~150 ms |
Memory is faster than SSD, and SSD is tens of times faster than HDD; and a cross-continent round-trip is 150ms — you fundamentally cannot answer faster than the speed of light. That's exactly why caches near the user, CDNs, and geographic replicas exist: to shrink distance. Every network boundary you cross costs latency.
Full worked example: a link shortener
Say the interviewer gives you: 100 million new links per day, read-to-write ratio 100 to 1, retain data for 5 years.
Write QPS: 100M ÷ 10⁵ = 1,000 writes/sec (average). Peak is usually 2–3x average → ~3,000 writes/sec at peak.
Read QPS: 100x = 100,000 reads/sec (average), peak ~300,000/sec.
Storage: assume 500 bytes per record (short link, long link, metadata). Per day: 100M × 500B = 50GB/day. Over 5 years: 50GB × 365 × 5 ≈ 91TB. A single node won't do it; you need sharding.
Read bandwidth: 300,000 reads/sec × 500B ≈ 150MB/s. Entirely manageable with a cache.
Answer: 500M × 10 = 5 billion requests/day. Divide by 86,400 (≈10⁵): 5 billion ÷ 10⁵ = 50,000 QPS average. Take 3x for peak → ~150,000 QPS. The senior point to add: load isn't uniform — daily peaks (evenings) and event-driven peaks (breaking news) can be several times the average, so design capacity for the peak, not the average, and leave headroom. Also note the feed is usually served from cache, not directly from the database, so most of that 150k hits the cache.
3) Scaling: vertical vs horizontal
When load grows, you have two ways to get bigger.
Vertical scaling (scale up): a bigger machine. More CPU, more RAM, faster NVMe — a sharper knife and stronger stove for the same cook.
Horizontal scaling (scale out): more machines. Ten ordinary servers instead of one giant — ten cooks instead of one.
Vertical means buying a stronger horse. At some point you hit a biological ceiling; even the world's strongest horse has a limit, and if it dies, the whole cart stops. Horizontal means hitching ten ordinary horses; you can add horses almost without limit, and if one falls, the rest keep the cart moving. That's the difference an SPOF (single point of failure) makes.
| Dimension | Vertical (scale up) | Horizontal (scale out) |
|---|---|---|
| Ceiling | Limited by one machine's hardware | Practically unlimited |
| SPOF | Yes; one machine = one failure point | No; inherent redundancy |
| Complexity | Simple (code unchanged) | Complex (needs LB, statelessness, coordination) |
| Cost | Grows exponentially | Linear with cheap hardware |
| Upgrade downtime | Usually needs a reboot | Rolling; zero downtime |
Vertical is "boring but wise" until you hit the ceiling. The complexity of scaling out (load balancer, going stateless, session sharing, distributed debugging) is a real and permanent cost — many systems run happily for years on one fat machine plus a failover replica. Go horizontal when (a) you've hit the machine's ceiling, or (b) you need availability beyond one machine. But crucially: enforce statelessness from day one so scaling out is cheap when you need it.
Statelessness: the precondition for scaling out
A service is stateless if it keeps no user-specific state in its own memory between requests — every request is self-contained.
Why is it vital? Because when scaling out, the load balancer may send a user's next request to a different instance. If the session lived in instance memory, a dead instance logs the user out — and a new instance is useless because it doesn't have the data.
// ❌ Stateful: session in this instance's memory; breaks scale-out
@RestController
public class CartController {
private final Map<String, Cart> carts = new ConcurrentHashMap<>(); // in this pod's RAM
@PostMapping("/cart/add")
public void add(@RequestHeader("X-User") String user, @RequestBody Item item) {
carts.computeIfAbsent(user, k -> new Cart()).add(item);
}
}
// ✅ Stateless: push state to a shared store (Redis/DB)
@RestController
public class CartController {
private final CartRepository repo; // Redis or DB, shared across all instances
@PostMapping("/cart/add")
public void add(@RequestHeader("X-User") String user, @RequestBody Item item) {
Cart cart = repo.findByUser(user).orElseGet(Cart::new);
cart.add(item);
repo.save(cart);
}
}
Load balancers offer sticky sessions, always routing a user to the same instance so in-memory sessions work. This is technical debt: it unbalances load (old instances full, new ones empty), it logs out all users when an instance dies, and it makes rolling deploys painful. The right way: put the session in Redis or the client's own JWT and keep instances fully stateless.
Answer: When scaling out, the load balancer may send a user's requests to different instances. If a service keeps user state (session, cart) in its own memory, then when that instance dies the user loses their data, and adding a new instance is useless because it doesn't have it. A stateless service keeps no in-memory state; every request is self-contained and any instance can serve any request, so you can freely add/remove instances, do rolling deploys, and survive node failures painlessly. I move state to a shared store (Redis/DB) or into the client's JWT. I reject the sticky-session escape hatch because it unbalances load and makes deploys painful.
4) Load balancing: the traffic distributor
Once you have multiple instances, you need something to spread traffic across them and steer traffic away from sick ones. That's the load balancer.
A long line of cars reaches a multi-lane toll plaza. The officer (load balancer) directs each car to the least busy lane, and if a lane is under repair (an unhealthy instance) sends no cars there. Result: high throughput and no car gets stuck.
Let's look at a typical topology.
Diagram: Traffic flow from client through the load balancer to healthy instances — جریان ترافیک از کلاینت به instanceهای سالم پشت load balancer.
flowchart LR
Client[Clients] --> LB[Load Balancer]
LB --> A[Instance A - healthy]
LB --> B[Instance B - healthy]
LB -. skipped .-> C[Instance C - unhealthy]
A --> Cache[(Redis)]
B --> Cache
A --> DB[(Database)]
B --> DB
Layer 4 vs Layer 7
This split comes from the OSI model and gets asked a lot in interviews.
L4 (transport): the load balancer only sees IP and port; it forwards the TCP/UDP packet to a backend without opening its contents. Fast, low overhead, but "blind" — it can't decide based on the URL path or headers.
L7 (application): the load balancer understands HTTP content. It can route by path (/api/* to one service, /img/* to another), terminate TLS, add headers, set cookie-based sticky sessions, and retry. More powerful but slightly slower (it must parse the request).
| Feature | L4 | L7 |
|---|---|---|
| What it sees | IP/port (TCP/UDP) | Full HTTP (path, header, cookie) |
| Content-based routing | No | Yes |
| TLS termination | No | Yes |
| Speed/overhead | Faster, lighter | Slightly slower, smarter |
| Example | AWS NLB, HAProxy in TCP mode | AWS ALB, Nginx, Envoy, Spring Cloud Gateway |
In the Java/Spring world an L7 like Nginx or a cloud ALB usually sits in front of everything, and for intra-system microservice routing there's an API Gateway (like Spring Cloud Gateway) that is itself an L7 router; in Kubernetes, Service and Ingress play this role.
Distribution algorithms
- Round Robin: one by one to each backend in turn. Simple and fair if all instances and all requests are equal-weight.
- Weighted Round Robin: give the stronger machine a bigger share. Useful for a heterogeneous fleet.
- Least Connections: send the next request to the instance with the fewest open connections. Great when request durations vary (some 10ms, some 10s).
- Least Response Time: combines least connections with lowest measured latency.
- IP Hash / Consistent Hash: a hash of the client's IP or key decides the backend; the same user always hits the same backend (without keeping state in the LB). Useful for cache locality.
If requests are short and uniform, Round Robin is enough. If some requests are heavy (an upload, a big report), Least Connections distributes more fairly, because Round Robin might pile several heavy requests onto one instance. If each instance has a local cache and you want a high hit rate, choose Consistent Hashing so a key always lands on the same instance.
Health checks: the heart of availability
A load balancer is only useful if it knows which backend is healthy. Two kinds of health check:
- Active (proactive): the LB sends a light request to
/healthevery few seconds; if it doesn't get 200 or it times out, it pulls the instance out of rotation. - Passive (reactive): the LB counts real errors from live traffic; if an instance starts returning 500s, it's removed from rotation.
# Spring Boot Actuator: health endpoints for the health check
management:
endpoint:
health:
probes:
enabled: true # separate liveness and readiness
show-details: never # don't leak details externally
endpoints:
web:
exposure:
include: health,info
# Kubernetes: uses those same probes
livenessProbe: # if dead → restart
httpGet: { path: /actuator/health/liveness, port: 8080 }
initialDelaySeconds: 20
periodSeconds: 10
readinessProbe: # if not ready → remove from Service (no traffic)
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
liveness means "are you alive?" — if not, restart. readiness means "are you ready for traffic?" — if not, send no traffic but don't kill. Common mistake: putting a health check that hits the database on liveness. If the DB gets slow for a moment, Kubernetes kills and restarts every pod at once, turning a small blip into a cascading failure. Rule: liveness must be light and independent of external dependencies; readiness may check dependencies.
Answer: L4 works at the transport layer and only sees IP/port; it hands off the TCP packet to a backend without opening it. It's fast and low-overhead but can't make content-based decisions. L7 works at the application layer, understands the full HTTP, and can route by path/header/cookie, terminate TLS, and retry. I pick L4 when only raw throughput and lowest latency matter (e.g., fronting a TCP layer, or passing TLS through without terminating). I pick L7 when I need to route to different services by URL path, apply central auth/rate limiting, or do canary/blue-green. In a real microservice architecture you usually have both: an edge L4/L7 in front of everything, and an L7 API Gateway for internal routing.
5) Cache layers
The cache is the cheapest, most effective scaling lever. The golden rule: the cheapest work is work you don't do at all. Every request that hits the cache is one that didn't hit the database. I'll keep this short because there's a dedicated Caching chapter; here we just place it in the big-picture architecture.
You don't walk to the backyard pantry (the database) for every single egg; you keep a few in the fridge next to you (the cache). The fridge is small (limited capacity) and things can spoil (stale data), but it's 100x faster than the pantry. Managing a cache is managing that fridge: what to keep, for how long, and when to throw it out.
A cache can sit at any layer, and the closer to the user, the faster:
Diagram: Cache layers from the browser down to the database — لایههای cache از مرورگر تا دیتابیس.
flowchart LR
Browser[Browser cache] --> CDN[CDN edge cache]
CDN --> Gateway[Gateway / API]
Gateway --> AppCache[App cache - Redis]
AppCache --> DB[(Database + buffer cache)]
- Client cache (browser): via
Cache-ControlandETagheaders. The fastest, since it never leaves the device. - CDN: cache at the network edge, near the geographic user (next section).
- App cache (Redis/Memcached): hot application data; query results, sessions, counters.
- DB cache: the database's own buffer pool keeps recent data in RAM.
The two main patterns
Cache-aside (lazy loading): the most common. The app asks the cache first; on a miss, it reads the DB, populates the cache, and returns.
public Product getProduct(Long id) {
String key = "product:" + id;
Product cached = redis.opsForValue().get(key);
if (cached != null) return cached; // hit
Product p = repository.findById(id).orElseThrow(); // miss → DB
redis.opsForValue().set(key, p, Duration.ofMinutes(10)); // with TTL
return p;
}
Write-through: every write goes to both cache and DB at once; the cache is always fresh but writes are slower. (Write-behind and read-through patterns also exist — see the Caching chapter.)
Imagine a hot key (say, the homepage) whose TTL expires. In that same millisecond, 10,000 concurrent requests all miss and all rush the database at once to rebuild it. The database collapses under the sudden wave. That's a "cache stampede." Fixes: (1) a lock/single-flight — only one request may rebuild while others wait, (2) serve the expired value briefly and refresh in the background (stale-while-revalidate), (3) add random jitter to the TTL so keys don't all expire together. You'll see this fully coded in the Caching chapter.
Answer: In cache-aside, we populate the cache only on reads after a miss; writes go straight to the DB and we invalidate/delete the cache entry. Pro: the cache only holds data actually read (less memory), and if the cache dies the system just slows down rather than breaking. Con: the first read of any key always misses, and there's a small inconsistency window between deleting the cache and writing the DB. In write-through, every write updates both cache and DB together; pro: the cache is always consistent and reads always hit. Con: slower writes and the cost of caching data that may never be read. In practice cache-aside is the default for most systems because they're read-heavy and tolerate brief inconsistency; I use write-through where an immediate read-after-write must be consistent.
6) CDN: bringing content closer to the user
A CDN (content delivery network) is a network of cache servers spread across geographic points worldwide (PoPs or edges). When a user in Tehran wants an image, instead of traveling to a datacenter in the US (150ms round-trip), they get it from the nearest edge (maybe a few ms) — like a retail chain that, instead of one central warehouse, keeps a small local warehouse of best-sellers in every city.
A CDN is mainly for static content: images, video, CSS, JS, downloads. But modern CDNs (Cloudflare, Fastly, CloudFront) can also cache or accelerate dynamic content.
Three wins at once: (1) low latency because the physical distance is short, (2) origin offload — if 95% of static traffic hits the CDN, your origin sees only 5% of the load, so fewer servers, (3) resilience against DDoS and spikes — the CDN is a huge shield in front of the origin. Hence the senior rule: no static byte should ever reach the origin.
The hardest CDN problem is that hard problem in computer science: cache invalidation. If you change logo.png but keep the same name, users see the old version until the TTL expires. The right way: fingerprint the filename (logo.a1b2c3.png). Any content change changes the name, so the new URL is inherently a cache miss, and you can set the TTL to "one year, immutable." This is the standard pattern of modern build tools (Vite, webpack).
7) Database scaling
The database is almost always the first thing to break under pressure, because it's stateful and you can't simply clone it like a stateless service. Three levers, in increasing order of complexity:
Lever 1: Read replicas
If your system is read-heavy (most are), build one primary for writes and several replicas for reads. Writes go to the primary and propagate via replication to the replicas; reads are load-balanced across the replicas.
Diagram: Writes go to primary; reads fan out to replicas — نوشتن روی primary، خواندن پخششده روی replicaها.
flowchart LR
App[App] -- writes --> P[(Primary)]
App -- reads --> R1[(Replica 1)]
App -- reads --> R2[(Replica 2)]
P -- async replication --> R1
P -- async replication --> R2
// Spring: route reads/writes with @Transactional(readOnly = true)
@Service
public class OrderService {
@Transactional // → primary
public Order place(OrderRequest req) { ... }
@Transactional(readOnly = true) // → replica (with DataSource routing)
public Order get(Long id) { ... }
}
Replication is usually async: the primary acknowledges the write and later sends it to the replica. That window (milliseconds to seconds under load) means if a user saves something and immediately refreshes, and that read goes to a replica, they don't see their own data — because it hasn't reached the replica yet. This is the "read-your-own-writes" problem. Fix: route sensitive immediately-after-write reads to the primary (or use session consistency). This is precisely where weak consistency bites the user in practice.
Answer: That's replication lag. Replication is usually async: the primary acknowledges the write and forwards it to replicas with a few milliseconds' delay. If the user refreshes right after saving and that read goes to a not-yet-updated replica, they don't see their own data (violating read-your-own-writes). Fixes: (1) route immediately-after-write reads for that user/session to the primary, (2) use sticky routing based on the user's last-write timestamp (read from primary until the lag window passes), (3) if tolerable, show it optimistically in the UI. Note: lag worsens as write load grows, so this problem is sharper at peak.
Lever 2: Partitioning (splitting within one database)
Partitioning means splitting a large table into smaller pieces that the database manages separately — but all in the same instance. Useful for managing huge tables (e.g., partition by month, so deleting old data is just dropping a partition).
The syntax differs between databases:
-- PostgreSQL: declarative partitioning (range by date)
CREATE TABLE events (
id bigint,
ts timestamptz NOT NULL,
payload jsonb
) PARTITION BY RANGE (ts);
CREATE TABLE events_2026_07 PARTITION OF events
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
-- For uniform distribution by key: hash partitioning
CREATE TABLE accounts (id bigint, name text) PARTITION BY HASH (id);
CREATE TABLE accounts_p0 PARTITION OF accounts FOR VALUES WITH (MODULUS 4, REMAINDER 0);
-- Oracle: its own native partitioning (interval by date, auto-creates partitions)
CREATE TABLE events (
id NUMBER,
ts TIMESTAMP NOT NULL,
payload CLOB
)
PARTITION BY RANGE (ts)
INTERVAL (NUMTOYMINTERVAL(1, 'MONTH'))
( PARTITION p0 VALUES LESS THAN (TO_DATE('2026-07-01','YYYY-MM-DD')) );
-- Uniform distribution by key: hash partitioning
CREATE TABLE accounts (id NUMBER, name VARCHAR2(100))
PARTITION BY HASH (id) PARTITIONS 4;
PostgreSQL has declarative partitioning since v10 and you must create partitions yourself (or with a tool like pg_partman); it has no automatic INTERVAL. Oracle's INTERVAL can auto-create a new partition when data for a new month arrives. Both support hash/range/list partitioning. Portability tip: don't assume partitioning logic in the application; let the database manage it so the query planner can do partition pruning (read only the relevant partition).
Lever 3: Sharding (splitting across multiple databases)
When the data outgrows a single machine (like that 91TB link shortener), you must spread it across multiple independent databases — that's sharding. Each shard holds a subset and is fully independent.
The key question: which shard does a record go to? Decided by the shard key and the distribution function:
- Range-based: by range (ids 1 to a million in shard 1). Simple but prone to hotspots (if new ids all cluster in the last shard).
- Hash-based:
shard = hash(key) % N. Uniform distribution, but it has one big problem, coming up next.
If shard = hash(key) % N and you go from 4 nodes to 5, % 4 becomes % 5 and almost all keys remap to a different shard — meaning you must rehash and move nearly the entire dataset. At terabyte scale that's a disaster. The fix: consistent hashing.
Consistent hashing
Consistent hashing imagines the hash space as a ring (0 to, say, 2³²−1). Both nodes and keys are hashed onto this ring. Each key belongs to the first node clockwise after it.
Arrange guests (keys) and hosts (nodes) around a round table. Each guest is served by the first host seated clockwise after them. Now a new host arrives and sits down: only the guests who were between them and their previous host move; the rest of the table is untouched. The opposite of % N, which reshuffled every seat on any change.
The benefit: adding or removing a node moves only K/N of the keys (K total, N nodes), not all of them.
Diagram: Keys map to the next node clockwise on the hash ring — نگاشتِ keyها به nodeی که ساعتگرد بعدش میآید روی حلقه.
flowchart LR
subgraph Ring[Hash Ring 0..2^32]
N1[Node A @ 30]
N2[Node B @ 120]
N3[Node C @ 220]
end
K1[key1 -> 45] --> N2
K2[key2 -> 130] --> N3
K3[key3 -> 250] --> N1
Virtual nodes (vnodes): if each node has just one point on the ring, distribution becomes uneven (one gets a big arc, another a small one). Fix: spread each physical node across several hundred virtual points on the ring. Now load is more uniform, and when a node dies, its load spreads across all the others rather than just one neighbor. This is the standard technique used by Dynamo, Cassandra, and ScyllaDB.
Answer: With hash(key) % N, the node count N is in the formula, so any change to N remaps almost all keys and forces you to rehash and move nearly the entire dataset — a disaster at scale. Consistent hashing breaks that dependency by mapping both nodes and keys onto a hash ring: adding or removing a node moves only K/N of the keys (those between the new node and its neighbor). Virtual nodes also even out distribution and, when a node dies, spread its load across all nodes rather than one neighbor. That's why Cassandra, DynamoDB, and Riak are built on it.
(1) Cross-shard queries are expensive: any query without the shard key must hit all shards and merge results (scatter-gather). Choose the shard key so frequent queries hit a single shard. (2) JOINs across shards are practically impossible — do them in the app or denormalize. (3) Distributed transactions across shards are hard and slow. (4) Hot shard: a bad shard key (say country, with 80% of users in one) burns one shard while the rest idle. Sharding is the last lever, not the first — cache and read replicas come first.
8) CAP and PACELC: the iron law of distributed systems
When data is replicated across multiple nodes (needed for availability), you hit a fundamental law.
The CAP theorem
In a distributed system, among three properties you can only guarantee two at once:
- C — Consistency: every read sees the latest write (all nodes present one view).
- A — Availability: every request gets a (non-error) response, even if not the latest.
- P — Partition tolerance: the system works despite network communication breaking between nodes.
Two branches of one bank, and the phone line between them is cut (that's a partition). A customer at branch A wants to withdraw 1000. You have two choices: (a) let them withdraw (available) — but now branch B doesn't know and the balance might be spent twice (inconsistent). (b) Say "wait until the line is restored" (consistent) — but now the customer gets no answer (unavailable). Partition (P) is forced because the network really does break; so you must choose between C and A.
CAP doesn't say "freely pick 2 of 3." In a real distributed system, P is not optional — the network sometimes breaks; that's a fact of physics. So the real choice is always between C and A, and only during a partition: CP systems (an RDBMS with sync replication, etcd/ZooKeeper) vs AP systems (Cassandra, DynamoDB with eventual settings).
PACELC: the honest continuation of CAP
CAP has a big weakness: it only talks about during a partition, which is rare. But every millisecond the network is healthy there's still a trade-off CAP is silent about: latency vs consistency. That's where PACELC (Daniel Abadi, 2012) comes in:
If there is a Partition (P), choose between Availability and Consistency; Else (E), choose between Latency and Consistency.
The logic: strong consistency requires a write acknowledged by multiple nodes (quorum/sync), which adds latency; wanting low latency means accepting weaker consistency. This trade-off is always present, not just during a disaster.
| System | During partition | Normal (Else) | PACELC label |
|---|---|---|---|
| PostgreSQL / MySQL (single primary) | C (becomes unavailable) | C (accepts latency) | PC/EC |
| DynamoDB / Cassandra (default) | A | L (low latency) | PA/EL |
| MongoDB | C | C (default) | PC/EC |
| Cassandra with tunable consistency | configurable | configurable | usually PA/EL |
In an interview, saying only CAP is average. Saying "partitions are rare; my real everyday cost is L vs C in normal operation" shows you understand the real production trade-off. Most consistency decisions you make every day (quorum read/write, reading from a replica, async replication) are about this "E," not about partitions.
Consistency models (a spectrum, not a binary)
Consistency is not an on/off switch; it's a spectrum from strong to weak:
- Strong / Linearizable: every read sees the latest acknowledged write, as if one copy. Expensive (needs coordination) but simple for the programmer.
- Sequential / Causal: cause-and-effect order is preserved (if A caused B, everyone sees A before B), but unrelated events may be ordered differently.
- Read-your-writes: you at least see your own writes (session consistency).
- Eventual: if writes stop, eventually all replicas converge. Fast and available, but you may briefly read stale data.
Eventual is great for "like counts" (nobody dies if they see 99 instead of 100 for a second). For "bank account balance" or "is this ticket sold?" it's a disaster. The rule: choose the consistency model per data, not for the whole system. Money, inventory, and username uniqueness need strong; feeds, counters, and analytics tolerate eventual.
Answer: CAP says that during a network partition you must sacrifice either consistency or availability, because partition tolerance is mandatory in a real distributed system. For the banking core — balances, transfers — I choose CP: if the network splits, I'd rather reject or delay than risk double-spending or an inconsistent negative balance. But not everything is core: display history, loyalty points, or notifications can be AP and eventual to stay available. The senior addition: with PACELC, even in normal operation I trade L for C for the financial core (synchronous quorum replication) because correctness of money outweighs a few milliseconds of latency.
9) Rate limiting and idempotency
Rate limiting: the system's guard
Rate limiting means capping how many requests a client can make in a time window. Without it, a misbehaving client (or an attack, or a retry bug) can swallow all your capacity.
Picture a bucket into which water/tokens drip at a steady rate (say 10 tokens/sec), with a limited capacity (say 100 tokens). Each request takes a token. If the bucket is empty, the request is rejected. That's the token bucket: it controls the sustained rate but also allows a short burst (up to bucket capacity). It's the most widely used algorithm.
Common algorithms:
| Algorithm | Idea | Allows burst | Memory |
|---|---|---|---|
| Token Bucket | Tokens refill at a fixed rate, each request consumes one | Yes (up to capacity) | Low (2 numbers: tokens, time) |
| Leaky Bucket | Fixed-rate output, like a queue | No (smooths it) | Low |
| Fixed Window | Counter in a fixed window (per minute) | Window-edge problem | Low |
| Sliding Window Log/Counter | Sliding window, more precise | Smooth | Higher |
Note that fixed window leaks at the window edge: with "100 per minute," a client can send 100 in the last second of one minute and 100 more in the first second of the next — 200 in two seconds. Sliding window solves this but costs more memory; for most uses, token bucket is a good balance.
In Spring, Spring Cloud Gateway has a built-in RequestRateLimiter with a Redis implementation using the token bucket algorithm:
spring:
cloud:
gateway:
routes:
- id: orders
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- name: RequestRateLimiter
args:
# bucket refill rate: the most permissive sustained rate (tokens/sec)
redis-rate-limiter.replenishRate: 10
# bucket capacity: the maximum instantaneous burst
redis-rate-limiter.burstCapacity: 20
# cost per request (default 1)
redis-rate-limiter.requestedTokens: 1
key-resolver: "#{@userKeyResolver}"
// Define the key per user (not per whole system); returns 429 when exhausted
@Bean
KeyResolver userKeyResolver() {
return exchange -> Mono.justOrEmpty(
exchange.getRequest().getHeaders().getFirst("X-User-Id"))
.defaultIfEmpty("anonymous");
}
Never cap the whole system with one limit; key by user/API-key/IP so one heavy user doesn't starve the rest. And respond in a standard way: status 429, with Retry-After and X-RateLimit-Remaining headers so the client knows when to try again. A rate limit without these headers forces the client into blind retries that make the pressure worse. (The gateway uses Redis to share the counter across all instances so the limit is global, not per-instance.)
Answer: A token bucket refills tokens at a fixed rate and each request consumes one; because the bucket has capacity, a short burst (up to capacity) is allowed — great for APIs with naturally bursty traffic. A leaky bucket is like a queue with fixed-rate output; it smooths traffic and allows no burst — great when you want the downstream to see a perfectly uniform rate (e.g., protecting a service that collapses under spikes). In practice token bucket is the default in most API gateways (including Spring Cloud Gateway) because it enforces the limit without ruining the normal user's experience by allowing bursts. If I need higher precision without the window-edge problem, I consider a sliding window, at the cost of more memory.
Idempotency: guaranteeing "once, not more"
An operation is idempotent if running it multiple times with the same input has the same effect as running it once. GET, PUT, and DELETE are inherently idempotent; POST usually is not.
Why is it vital? Because the network is unreliable. The client sends a payment request, the server charges the card and sends a response, but the response is lost in transit. The client times out and retries. Without idempotency, the customer pays twice. Like an elevator button: no matter how many times you press for floor 5, the elevator comes once — an idempotent operation must be the same: harmless to retry.
The standard solution: an idempotency key. The client sends a unique key (UUID) with the request; the server stores the result of the first execution and returns the same result to later requests with the same key without re-executing.
Diagram: Duplicate key returns the stored result instead of re-executing — کلیدِ تکراری نتیجهٔ ذخیرهشده را برمیگرداند نه اجرای دوباره.
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Idempotency Store
C->>S: POST /payments (Idempotency-Key: k123)
S->>DB: exists(k123)?
DB-->>S: no
S->>S: charge card (once)
S->>DB: save(k123, result)
S-->>C: 200 OK (result)
Note over C,S: timeout → client retries
C->>S: POST /payments (Idempotency-Key: k123)
S->>DB: exists(k123)?
DB-->>S: yes → stored result
S-->>C: 200 OK (same result, no re-charge)
@PostMapping("/payments")
public ResponseEntity<PaymentResult> pay(
@RequestHeader("Idempotency-Key") String key,
@RequestBody PaymentRequest req) {
// If we've seen it before, return the same result (no double charge)
Optional<PaymentResult> prior = idempotencyStore.find(key);
if (prior.isPresent()) {
return ResponseEntity.ok(prior.get());
}
// Atomic: reserve only if the key is new (guards the race between two concurrent retries)
if (!idempotencyStore.tryReserve(key)) {
return ResponseEntity.status(HttpStatus.CONFLICT).build(); // in progress
}
PaymentResult result = paymentService.charge(req); // once
idempotencyStore.complete(key, result);
return ResponseEntity.ok(result);
}
Putting a unique constraint on a column prevents storing the second record, but it doesn't neutralize the side effect (charging the card, sending an email, calling an external service). Proper idempotency means checking the key before any side effect and storing the full result. And watch the race: two concurrent retries can both see "key doesn't exist"; you need an atomic operation (like INSERT ... ON CONFLICT DO NOTHING or SETNX in Redis) so only one wins.
-- Atomic key reservation — PostgreSQL
INSERT INTO idempotency (key, status) VALUES ('k123', 'IN_PROGRESS')
ON CONFLICT (key) DO NOTHING; -- if 0 rows affected, it's a duplicate
-- Oracle equivalent (no ON CONFLICT; use MERGE or exception handling)
MERGE INTO idempotency d
USING (SELECT 'k123' AS key FROM dual) s
ON (d.key = s.key)
WHEN NOT MATCHED THEN INSERT (key, status) VALUES (s.key, 'IN_PROGRESS');
PostgreSQL uses INSERT ... ON CONFLICT DO NOTHING/UPDATE (clean and fast). Oracle lacks that; the standard way is MERGE, though 23ai added a new ... ON CONFLICT clause too. A third, portable route: attempt the INSERT and catch the unique violation in the application. For idempotency, whichever you pick, it must be atomic so two concurrent retries don't both win.
Answer: Because the network is unreliable: the client may not receive the success response (timeout) and retries; without idempotency, the card is charged twice. Implementation: the client sends a unique Idempotency-Key (UUID) per transaction. The server atomically reserves that key before any side effect (INSERT ... ON CONFLICT DO NOTHING or SETNX). If new, it charges once and stores the full result; if duplicate, it returns the stored result without re-executing. The subtle point: the reservation must be atomic (not a plain check-then-act) to guard the race between two concurrent retries. And I keep keys with a TTL (e.g., 24h) so the store doesn't grow unbounded.
10) Availability math: nines, redundancy, failover
Availability is what fraction of the time the system is up. We measure it in "nines," and each extra nine cuts downtime tenfold — and multiplies cost.
| Availability | Common name | Downtime/year | Downtime/month |
|---|---|---|---|
| 99% | two nines | ~3.65 days | ~7.2 hours |
| 99.9% | three nines | ~8.76 hours | ~43 minutes |
| 99.95% | — | ~4.38 hours | ~22 minutes |
| 99.99% | four nines | ~52.6 minutes | ~4.3 minutes |
| 99.999% | five nines | ~5.26 minutes | ~26 seconds |
Each extra nine is a qualitative leap in engineering, not just "a bit better." Three nines you get with normal effort and monitoring. Four nines means automatic failover, full redundancy, and zero-downtime deploys. Five nines (26 seconds a month!) means no human intervention can be in the recovery path — humans aren't fast enough; everything must be automatic. Each nine is several times more expensive, so don't buy more than the business needs.
Combining availability: serial vs parallel
If a request passes through several components in series, availabilities multiply and come out lower than any single part. Gateway, service, and DB each at 99.9%: 0.999³ ≈ 99.7% — a chain is weaker than even its weakest link.
Make a component parallel (redundant) and it only fails when all copies fail, so the failure probability multiplies (gets smaller). Two parallel 99% instances: both fail = 0.01 × 0.01 = 0.0001 → 99.99%. Two mediocre instances built an excellent system — the whole philosophy of redundancy.
The math above holds only if failures are independent. If both instances are on the same rack, one shared power supply, one AZ, or one buggy code version, a single event takes both down together and your redundancy is paper. That's why redundancy is spread across independent failure domains: multiple AZs, multiple racks, multiple providers. "Two copies on one machine" is not redundancy.
Redundancy and failover
- Active-Passive: one active node works, a standby waits. If the active fails, failover to the standby. Simple, but the idle standby costs money and failover takes a few seconds.
- Active-Active: all are active and share the load. More efficient with instant failover, but needs state coordination (and the same consistency issues).
Diagram: Active-passive failover lifecycle when the primary fails — چرخهٔ عمرِ failover در حالتِ active-passive.
stateDiagram-v2
[*] --> ActiveHealthy
ActiveHealthy --> Degraded: health check fails
Degraded --> Failover: threshold exceeded
Failover --> StandbyPromoted: promote standby
StandbyPromoted --> ActiveHealthy: old node recovers as standby
Everyone assumes failover always saves you. But automatic failover can itself cause disaster: split-brain (both nodes think they're primary and data diverges), or flapping (a node goes healthy/unhealthy repeatedly, triggering repeated failovers). That's why serious systems use a quorum/consensus (like Raft in etcd/Consul) to elect a leader so there are never two primaries. And always practice failover in a test environment (chaos engineering) — an untested failover won't work when the crisis hits.
Answer: Since they're serial, they multiply: 0.999³ ≈ 0.997, about 99.7% — worse than any single part. To improve: (1) make each component parallel/redundant; two parallel 99.9% instances reach ~99.9999% because they only fail when both fail together. (2) Spread redundancy across independent failure domains (multiple AZs) so failures aren't correlated, otherwise the math lies. (3) Move non-critical dependencies off the critical path (async or circuit breaker). (4) Add timeouts and bounded retries so a slow component doesn't lock the chain. Note: ask the business for the actual target — going from 99.7% to 99.99% costs many times more and may not be needed.
11) Capstone: a full architecture
Now let's put all the pieces together — the standard topology of a large-scale web system, the one you should be able to draw from memory with justification:
Diagram: Reference architecture of a scalable web system — معماریِ مرجعِ یک سیستمِ وب مقیاسپذیر.
flowchart TD
User[Users] --> CDN[CDN - static assets]
User --> LB[L7 Load Balancer]
LB --> GW[API Gateway - auth, rate limit]
GW --> S1[Service instances - stateless]
S1 --> Cache[(Redis - cache + sessions)]
S1 --> P[(DB Primary - writes)]
S1 --> R[(DB Replicas - reads)]
P -- replication --> R
S1 -- events --> Q[(Message Queue)]
Q --> W[Async Workers]
Trace a request through it: static content comes from the CDN and never reaches the system; the rest hits the L7 load balancer; the API Gateway centralizes auth and rate limiting; stateless services scale horizontally with sessions in Redis; the cache serves most reads and only misses hit the DB; primary/replica splits reads from writes (and sharding at larger scale); heavy work goes async to a queue; and every layer is redundant across AZs.
Always this order: (1) clarify requirements (functional + non-functional + numbers). (2) Estimate (QPS/storage/bandwidth) to reveal the scale. (3) Design the API and data model. (4) Draw a simple design that works. (5) Find the bottlenecks and scale them (cache → replica → shard → async). (6) Reason out loud about trade-offs (CAP/PACELC, consistency, availability). Never skip step 1; always think aloud; always justify with "because…".
- System design = managing trade-offs; there's no single right answer, only informed choices backed by numbers.
- Always start from requirements (functional/non-functional) and back-of-envelope estimation (QPS/storage/bandwidth, with 86,400≈10⁵); non-functionals dictate the architecture.
- Vertical is simple but capped; horizontal scales but demands statelessness. Externalize sessions (Redis/JWT).
- Load balancer: L4 fast and blind, L7 smart and content-aware; health checks with liveness/readiness separation are critical.
- Cache is the cheapest lever (client/CDN/app/DB); beware stampede and jitter-less TTLs (details in the Caching chapter).
- CDN: let no static byte reach the origin; solve invalidation with a fingerprint in the name.
- Database: first read replicas (watch replication lag), then partitioning, finally sharding with consistent hashing (+ vnodes) to minimize rehashing.
- CAP chooses between C and A during a partition; PACELC adds the ever-present L vs C trade-off in normal operation. Choose consistency per-data.
- Rate limiting with token bucket (Spring Cloud Gateway + Redis); per-key and with
429/Retry-Afterheaders. - Idempotency with a unique key and atomic reservation; a network retry must not charge twice.
- Availability: each nine is 10x more expensive; serial multiplies down, independent parallel multiplies failure away and improves; practice failover and beware split-brain.