Levelwise
فارسی
برگشت به نقشه راه
Software Architect

سؤال‌های مصاحبه

آزمون تمرینی
۱معمار نرم‌افزار چه چیزی را تصمیم می‌گیرد؟سادهسبک‌های معماریArchitecture Decision Record

سؤال: یک تیم ۸ نفره روی یک فروشگاه آنلاین کار می‌کند. همه برنامه‌نویس‌ها senior هستند. حالا یک نفر با عنوان «معمار نرم‌افزار» به تیم آمده است. یکی از برنامه‌نویس‌ها می‌پرسد: «ما خودمان کد خوب می‌نویسیم. معمار دقیقاً چه کاری می‌کند که ما نمی‌کنیم؟» تو جای آن معمار هستی. چه جوابی می‌دهی؟

جواب کوتاه: معمار روی تصمیم‌هایی کار می‌کند که عوض کردنشان بعداً گران است و روی کل سیستم اثر دارند. مثلاً سبک کلی سیستم (monolith یا سرویس‌های جدا)، مرز ماژول‌ها، نوع دیتابیس، و روش ارتباط بخش‌ها. برنامه‌نویس senior بیشتر درباره طراحی داخل یک بخش تصمیم می‌گیرد: کلاس‌ها، توابع، الگوها. معمار این تصمیم‌ها را با trade-off می‌سنجد، آن‌ها را ثبت می‌کند، و به همه توضیح می‌دهد.

فرق معماری و طراحی (با یک مثال):

  • تصمیم طراحی: کلاس محاسبه تخفیف را با الگوی Strategy بنویسیم یا با چند if. اگر اشتباه باشد، یک روز کار برای عوض کردنش لازم است.
  • تصمیم معماری: سفارش و پرداخت در یک برنامه باشند یا دو سرویس جدا با دو دیتابیس. اگر اشتباه باشد، شاید ماه‌ها کار برای عوض کردنش لازم باشد.
  • مرز بین این دو همیشه روشن نیست. معیار ساده این است: هزینه تغییر و تعداد بخش‌هایی که درگیر می‌شوند.

معمار چه کارهایی می‌کند؟ (قدم به قدم)

  1. نیازهای کیفی را روشن می‌کند. یعنی می‌پرسد: چند کاربر؟ چقدر سریع؟ چقدر همیشه در دسترس؟ چقدر امن؟ چه بودجه‌ای؟
  2. چند گزینه را کنار هم می‌گذارد. برای هر کدام سود و هزینه را می‌نویسد. هیچ گزینه‌ای فقط سود ندارد.
  3. تصمیم را با تیم می‌گیرد، نه تنها. چون تیم باید آن را بفهمد و اجرا کند.
  4. دلیل تصمیم را ثبت می‌کند (مثلاً با Architecture Decision Record). تا شش ماه بعد کسی نپرسد «چرا این را انتخاب کردیم؟».
  5. با آدم‌های غیر فنی هم حرف می‌زند. مثلاً به مدیر محصول می‌گوید: «اگر این ویژگی را بخواهی، هزینه سرور دو برابر می‌شود.»
  6. بعد از تصمیم، نگاه می‌کند که کد واقعاً همان مسیر را می‌رود یا نه.

چیزی که معمار نیست:

  • کسی نیست که همه تصمیم‌های کوچک را بگیرد. این کار تیم را کند و بی‌انگیزه می‌کند.
  • کسی نیست که فقط نمودار بکشد و هیچ وقت کد نبیند. معماری که کد را نمی‌شناسد، تصمیم‌های غیر واقعی می‌گیرد.
  • کسی نیست که همیشه جدیدترین تکنولوژی را انتخاب کند. انتخاب ساده‌ترین راهی که نیاز را برآورده کند، معمولاً بهتر است.

سؤال پیگیری: از کجا می‌فهمی یک تصمیم «معماری» است و باید برایش وقت بیشتری بگذاری، و کجا تیم خودش سریع تصمیم بگیرد؟

جواب پیگیری: چند سؤال ساده کمک می‌کند:

  1. اگر اشتباه باشد، برگرداندنش چقدر هزینه دارد؟ چند روز یا چند ماه؟
  2. چند تیم یا چند بخش از سیستم را درگیر می‌کند؟
  3. آیا روی یک نیاز کیفی مهم (سرعت، امنیت، در دسترس بودن، هزینه) اثر دارد؟
  4. اگر جواب‌ها کوچک‌اند، تیم خودش تصمیم می‌گیرد. اگر بزرگ‌اند، گزینه‌ها را مقایسه می‌کنیم و تصمیم را ثبت می‌کنیم.
  5. برای تصمیم‌های برگشت‌پذیر، سریع جلو برو. برای تصمیم‌های برگشت‌ناپذیر، آرام و با دقت.

نشانه خطر: فکر می‌کند معمار یعنی «کسی که تکنولوژی را انتخاب می‌کند و بقیه اجرا می‌کنند»، یا هیچ هزینه‌ای برای تصمیم‌های خودش نام نمی‌برد.

ویرایش در GitHub

۲شروع معماری فقط با لیست ویژگی‌هاسادهسبک‌های معماری

سؤال: مدیر محصول یک سیستم جدید سفارش غذا را تعریف می‌کند. فقط این لیست را می‌دهد:

  • کاربر می‌تواند از رستوران‌ها غذا سفارش بدهد.
  • کاربر می‌تواند آنلاین پرداخت کند.
  • کاربر می‌تواند وضعیت سفارش را دنبال کند.

از تو می‌خواهند معماری این سیستم را شروع کنی. اولین کاری که می‌کنی چیست؟

جواب کوتاه: قبل از کشیدن هر نمودار، نیازهای کیفی (quality attributes یا non-functional requirements) را می‌پرسم. لیست ویژگی‌ها می‌گوید سیستم چه کاری می‌کند. نیازهای کیفی می‌گویند سیستم چقدر خوب باید آن کار را بکند. معماری بیشتر از نیازهای کیفی شکل می‌گیرد، نه از لیست ویژگی‌ها. این نیازها با هم در تضادند، پس باید بفهمیم کدام مهم‌تر است.

سرنخ (اگر کاندید گفت مشکلی نیست): فرض کن دو تیم همین سه ویژگی را می‌سازند. تیم اول برای یک رستوران با ۱۰۰ سفارش در روز. تیم دوم برای یک شهر بزرگ با ۵۰ هزار سفارش در ساعت شلوغی ناهار. آیا معماری این دو یکی است؟

چرا لیست ویژگی‌ها کافی نیست؟ (قدم به قدم)

  1. همین سه ویژگی را می‌شود با یک برنامه ساده و یک دیتابیس ساخت.
  2. همین سه ویژگی را می‌شود با ده‌ها سرویس، صف پیام و چند دیتابیس ساخت.
  3. هر دو از نظر ویژگی درست‌اند. فرقشان در بار، سرعت، در دسترس بودن و هزینه است.
  4. پس بدون این اعداد، هر معماری که بکشیم فقط یک حدس است.

چه چیزهایی را می‌پرسم؟

نیاز کیفی سؤال نمونه
بار چند سفارش در ساعت شلوغی؟ رشد سال بعد چقدر است؟
سرعت پاسخ صفحه منو باید در چند میلی‌ثانیه باز شود؟
در دسترس بودن اگر سیستم ۱ ساعت قطع شود، چه ضرری دارد؟
امنیت اطلاعات کارت بانکی را خودمان نگه می‌داریم یا درگاه پرداخت؟
هزینه بودجه ماهانه سرور چقدر است؟
تیم چند نفر؟ چه تکنولوژی‌هایی را بلدند؟
زمان نسخه اول کی باید بیرون برود؟

چرا این نیازها با هم در تضادند؟

  • در دسترس بودن بالاتر یعنی چند سرور و چند منطقه. پس هزینه بیشتر و سیستم پیچیده‌تر.
  • امنیت بیشتر (مثلاً چک‌های اضافه) معمولاً کمی سرعت را کم می‌کند.
  • زمان کوتاه تا انتشار یعنی راه ساده‌تر. پس شاید بعداً برای بار زیاد باید بخشی را دوباره نوشت.
  • برای همین از مدیر محصول می‌خواهم این نیازها را اولویت‌بندی کند. همه چیز نمی‌تواند «خیلی مهم» باشد.

یک نکته: نیاز کیفی باید قابل اندازه‌گیری باشد. «سیستم باید سریع باشد» کمکی نمی‌کند. «۹۵٪ درخواست‌های منو زیر ۳۰۰ میلی‌ثانیه» قابل تست است.

سؤال پیگیری: مدیر محصول می‌گوید «همه‌اش مهم است: باید خیلی سریع، همیشه در دسترس و ارزان باشد.» چه می‌کنی؟

جواب پیگیری:

  1. برای هر نیاز، هزینه‌اش را با عدد نشان می‌دهم. مثلاً «در دسترس بودن بیشتر یعنی دو برابر سرور».
  2. از او می‌پرسم کدام یک را در صورت تضاد قربانی کنیم.
  3. نیاز را برای هر بخش جدا می‌پرسم. شاید پرداخت باید همیشه کار کند، ولی صفحه نظرات کاربران نه.
  4. نتیجه را می‌نویسم تا بعداً روشن باشد چرا این انتخاب شد.

نشانه خطر: بدون هیچ سؤالی درباره بار، سرعت یا تیم، مستقیم میکروسرویس و Kafka و Kubernetes را پیشنهاد می‌دهد.

ویرایش در GitHub

۳کنترلری که لایه سرویس را دور می‌زندسادهClean Architecture

سؤال: پروژه یک فروشگاه آنلاین سه لایه دارد: لایه API (کنترلرها)، لایه سرویس که قانون‌های کسب و کار در آن است، و لایه دسترسی به داده. در code review این handler جدید را می‌بینی. نویسنده‌اش گفته «برای سریع‌تر شدن، مستقیم سراغ دیتابیس رفتم»:

@app.post("/orders/{order_id}/cancel")
def cancel_order(order_id: int):
    db.execute(
        "UPDATE orders SET status = 'cancelled' WHERE id = %s",
        (order_id,),
    )
    return {"ok": True}

در لایه سرویس، یک متد cancel برای سفارش از قبل وجود دارد. نظرت چیست؟

جواب کوتاه: این handler قانون‌های کسب و کار را دور می‌زند. لایه سرویس جایی است که قانون‌ها یک بار نوشته می‌شوند: مثلاً «سفارشی که ارسال شده لغو نمی‌شود» یا «بعد از لغو، موجودی برگردد و پول پس داده شود». با رفتن مستقیم به دیتابیس، این قانون‌ها اجرا نمی‌شوند و داده ناهماهنگ می‌شود. «سریع‌تر» هم معمولاً درست نیست: یک فراخوانی تابع اضافه تقریباً هیچ هزینه‌ای ندارد.

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

چرا این مشکل می‌سازد؟ (قدم به قدم)

  1. متد cancel در لایه سرویس سه کار می‌کند: وضعیت را چک می‌کند، موجودی را برمی‌گرداند، و درخواست بازپرداخت می‌فرستد.
  2. این handler فقط یک ستون را عوض می‌کند. پس آن سه کار انجام نمی‌شود.
  3. حالا دو راه برای لغو سفارش داریم که رفتار متفاوت دارند.
  4. اگر فردا یک قانون جدید اضافه شود (مثلاً «لغو بعد از ۲۴ ساعت هزینه دارد»)، باید یادمان باشد هر دو جا را عوض کنیم. معمولاً یکی فراموش می‌شود.

قانون وابستگی (dependency rule):

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

آیا همیشه ممنوع است؟ نه.

  • برای خواندن ساده (مثلاً لیست محصولات برای نمایش، بدون هیچ قانونی)، بعضی تیم‌ها عمداً یک مسیر خواندن مستقیم دارند. این همان ایده جدا کردن مسیر خواندن و نوشتن (CQRS) در شکل ساده است.
  • شرط مهم: این یک تصمیم تیمی و ثبت شده باشد، نه کار یک نفر در یک PR.
  • برای نوشتن (تغییر وضعیت، پول، موجودی) همیشه از لایه سرویس رد می‌شویم.
  • یکدست بودن مهم است. اگر هر کس هر بار یک راه را انتخاب کند، کد قابل پیش‌بینی نیست.

راه حل: handler فقط متد cancel لایه سرویس را صدا بزند. اگر واقعاً مشکل سرعت هست، اول اندازه بگیریم. معمولاً کندی از کوئری‌ها یا شبکه است، نه از یک لایه اضافه.

سؤال پیگیری: چطور جلوی تکرار این کار را می‌گیری، بدون این‌که هر PR را خودت چک کنی؟

جواب پیگیری:

  1. قانون را با ابزار چک می‌کنیم. ابزارهای «architecture test» یا linter می‌توانند بگویند ماژول API حق import کردن ماژول دیتابیس را ندارد. این تست در CI اجرا می‌شود.
  2. ساختار پروژه را طوری می‌چینیم که دسترسی مستقیم سخت باشد (مثلاً لایه داده فقط به سرویس‌ها export شود).
  3. قانون و استثناهایش (مثل مسیر خواندن ساده) را در یک ADR می‌نویسیم.

نشانه خطر: می‌گوید «کار می‌کند، پس مشکلی نیست» و نمی‌پرسد آیا قانونی در لایه سرویس دور زده شده است.

ویرایش در GitHub

۴بحثی که هر چند ماه تکرار می‌شودسادهArchitecture Decision Record

سؤال: یک تیم ۱۲ نفره سه سال است روی یک سیستم کار می‌کند. سیستم از PostgreSQL و Kafka استفاده می‌کند. هر چند ماه، یک نفر جدید یا یک نفر قدیمی می‌پرسد: «چرا PostgreSQL؟ MongoDB که راحت‌تر است» یا «چرا Kafka؟ RabbitMQ ساده‌تر بود». جلسه‌ای یک ساعته برگزار می‌شود. هیچ کس دلیل اصلی را دقیق یادش نیست. کسی که آن تصمیم را گرفت، از شرکت رفته است. تو معمار این تیم هستی. چه می‌کنی؟

جواب کوتاه: مشکل خود تصمیم نیست. مشکل این است که دلیل تصمیم ثبت نشده است. راه حل رایج Architecture Decision Record (ADR) است: یک فایل کوتاه برای هر تصمیم مهم، در همان repository کد. در آن می‌نویسیم شرایط چه بود، چه تصمیمی گرفتیم، چه گزینه‌هایی را کنار گذاشتیم، و چه پیامدهایی دارد.

چرا این بحث تکرار می‌شود؟ (قدم به قدم)

  1. تصمیم سه سال پیش گرفته شد. شرایط آن روز (بار، تیم، نیازها) فقط در ذهن چند نفر بود.
  2. آن آدم‌ها رفتند یا فراموش کردند.
  3. آدم جدید فقط نتیجه را می‌بیند، نه دلیل را. پس طبیعی است که بپرسد.
  4. بدون نوشته، هر بحث از صفر شروع می‌شود. وقت تیم هدر می‌رود و تصمیم‌ها بر اساس نظر بلندترین صدا عوض می‌شوند.

یک ADR چه بخش‌هایی دارد؟

# ADR-007: Use PostgreSQL as the main database

Status: Accepted (2023-04-10)

Context:
  Orders, payments and stock need transactions across tables.
  The team knows SQL well. Data is mostly relational.

Decision:
  Use PostgreSQL for all core services.

Alternatives considered:
  MongoDB: flexible schema, but multi-document transactions
  were not something the team had experience with.

Consequences:
  + Strong transactions and joins.
  - Schema changes need migrations.
  • وضعیت: پیشنهادی، پذیرفته شده، یا جایگزین شده با یک ADR دیگر.
  • شرایط: مسئله و محدودیت‌های آن زمان.
  • تصمیم: یک یا دو جمله روشن.
  • پیامدها: سودها و هزینه‌ها. نوشتن هزینه‌ها مهم است.

قانون‌های ساده برای ADR:

  • کوتاه باشد. یک صفحه کافی است. اگر کسی آن را نخواند، فایده ندارد.
  • در repository کد باشد، کنار کد. نه در یک wiki جدا که گم می‌شود.
  • یک ADR قدیمی را هیچ وقت پاک نمی‌کنیم. اگر تصمیم عوض شد، یک ADR جدید می‌نویسیم و وضعیت قبلی را «جایگزین شده» می‌کنیم. پس تاریخچه می‌ماند.
  • فقط برای تصمیم‌های مهم و گران. برای هر چیز کوچک ADR نمی‌نویسیم.

برای تصمیم‌های قدیمی چه کنیم؟ همان کسانی که هنوز در تیم هستند، چیزی که یادشان است را می‌نویسند. اگر دلیل را نمی‌دانیم، صادقانه می‌نویسیم «دلیل اصلی معلوم نیست» و شرایط امروز را بررسی می‌کنیم.

سؤال پیگیری: یک نفر می‌گوید «حالا که ADR داریم، یعنی دیگر هیچ وقت نمی‌توانیم Kafka را عوض کنیم؟»

جواب پیگیری:

  1. نه. ADR تصمیم را قفل نمی‌کند. فقط دلیلش را روشن می‌کند.
  2. حالا بحث بهتر می‌شود. سؤال این است: «آیا شرایطی که در ADR نوشته شده، هنوز درست است؟»
  3. اگر شرایط عوض شده (مثلاً بار خیلی کم شده و Kafka هزینه زیادی دارد)، یک ADR جدید می‌نویسیم.
  4. هزینه مهاجرت را هم در ADR جدید می‌آوریم.

نشانه خطر: راه حلش «یک جلسه دیگر» یا «یک سند ۴۰ صفحه‌ای در wiki» است، یا فکر می‌کند ثبت تصمیم یعنی دیگر نمی‌شود آن را عوض کرد.

ویرایش در GitHub

۵یک تغییر کوچک در ۷ ماژولسادهسبک‌های معماری

سؤال: یک فروشگاه آنلاین بزرگ، ۳ تیم و حدود ۲۰ ماژول دارد. مدیر محصول یک تغییر کوچک می‌خواهد: «تخفیف ۱۰٪ برای اولین خرید، فقط اگر مبلغ سبد بالای ۵۰۰ هزار تومان باشد.» تیم برآورد می‌کند:

  • باید ۷ ماژول عوض شوند: سبد خرید، صفحه پرداخت، سفارش، فاکتور، گزارش‌ها، اپ موبایل، و ایمیل‌ها.
  • هر کدام جداگانه حساب می‌کند تخفیف چقدر است.
  • ۳ تیم باید هماهنگ شوند و با هم deploy کنند.
  • برآورد: سه هفته.

این وضعیت چه چیزی درباره معماری به تو می‌گوید؟

جواب کوتاه: این نشانه coupling بالا و cohesion پایین است. قانون تخفیف یک مفهوم است، ولی در ۷ جا پخش شده است. یعنی چیزهایی که با هم عوض می‌شوند، با هم نیستند. راه حل این است که رفتار تخفیف را در یک جا جمع کنیم (یک ماژول یا سرویس مالک تخفیف). بقیه فقط نتیجه را از آن بپرسند.

تعریف ساده:

  • انسجام (cohesion): چیزهایی که با هم عوض می‌شوند، کنار هم باشند. ماژول با انسجام بالا یک کار روشن دارد.
  • وابستگی (coupling): چقدر تغییر در یک ماژول، ماژول‌های دیگر را مجبور به تغییر می‌کند.
  • هدف: انسجام بالا داخل ماژول، وابستگی کم بین ماژول‌ها.

چرا این وضعیت بد است؟ (قدم به قدم)

  1. قانون تخفیف ۷ بار نوشته شده است. شاید هر کدام کمی متفاوت باشد.
  2. هر تغییر یعنی ۷ تغییر. احتمال این‌که یکی فراموش شود زیاد است.
  3. اگر یکی فراموش شود، سبد خرید یک مبلغ نشان می‌دهد و فاکتور مبلغ دیگری. مشتری شکایت می‌کند.
  4. سه تیم باید هماهنگ شوند. پس سرعت تیم‌ها به کندترین تیم بسته است.
  5. این‌ها هزینه واقعی‌اند: سه هفته برای یک تغییر یک خطی.

چطور درستش کنیم؟

  1. یک مالک برای قانون قیمت و تخفیف تعیین می‌کنیم. مثلاً ماژول «قیمت‌گذاری».
  2. همه محاسبه‌های تخفیف به آن ماژول منتقل می‌شوند.
  3. بقیه فقط می‌پرسند: «قیمت نهایی این سبد چقدر است؟» و جواب را استفاده می‌کنند.
  4. فاکتور و ایمیل و گزارش، مبلغ تخفیف را محاسبه نمی‌کنند. مبلغ ثبت شده در سفارش را نمایش می‌دهند.
  5. حالا تغییر بعدی فقط در یک ماژول و یک تیم انجام می‌شود.

این کار را یک باره انجام نمی‌دهیم. همین تغییر جدید را در ماژول جدید می‌سازیم. بعد ماژول‌های قدیمی را یکی یکی به آن وصل می‌کنیم.

یک دام: اگر کد مشترک را فقط در یک کتابخانه مشترک بگذاریم که هر ۷ ماژول آن را import کنند، هنوز هر تغییر یعنی به‌روز کردن و deploy دوباره ۷ ماژول. رفتار باید یک مالک داشته باشد، نه فقط یک کد مشترک.

سؤال پیگیری: از کجا قبل از این‌که دیر شود، بفهمی کجا coupling بالاست؟

جواب پیگیری:

  1. تاریخچه git را نگاه می‌کنیم. فایل‌هایی که همیشه با هم در یک commit عوض می‌شوند، ولی در ماژول‌های جدا هستند، نشانه خوبی‌اند.
  2. تغییرهایی که همیشه به چند تیم نیاز دارند را می‌شماریم.
  3. نمودار وابستگی ماژول‌ها را می‌کشیم. دورهای وابستگی (A به B، B به A) زنگ خطرند.
  4. می‌پرسیم: «برای این تغییر کسب و کاری، چند جا باید عوض شود؟» جواب خوب «یک جا» است.

نشانه خطر: راه حلش فقط «بیشتر هماهنگ شویم» یا «یک جلسه هفتگی بین تیم‌ها» است، یا coupling و cohesion را فقط به عنوان تعریف کتابی بلد است و نمی‌تواند در این مثال نشانشان دهد.

ویرایش در GitHub

۶موجودی انبار و تعداد لایک هنگام قطعی شبکهمتوسطConsistency و CAP

سؤال: یک فروشگاه آنلاین در دو دیتاسنتر (تهران و مشهد) اجرا می‌شود. هر دو دیتاسنتر درخواست کاربر را جواب می‌دهند و داده را بین خودشان همگام می‌کنند. دو ویژگی در این محصول هست:

  • موجودی کالا هنگام پرداخت: مثلاً از یک گوشی فقط ۳ عدد باقی مانده است.
  • شمارنده «لایک» زیر هر محصول.

یک روز ارتباط شبکه بین دو دیتاسنتر ۲۰ دقیقه قطع می‌شود. هر دو دیتاسنتر سالم‌اند و کاربرها هنوز به هر دو وصل می‌شوند، ولی دو دیتاسنتر نمی‌توانند با هم حرف بزنند. هر کدام از این دو ویژگی در این ۲۰ دقیقه چه رفتاری باید داشته باشد؟

جواب کوتاه: این دو ویژگی جواب متفاوت دارند. طبق CAP، هنگام قطعی شبکه (partition) باید بین سازگاری (consistency) و در دسترس بودن (availability) یکی را انتخاب کنیم. برای موجودی، سازگاری مهم‌تر است: بهتر است پرداخت را موقتاً رد کنیم یا فقط یک دیتاسنتر اجازه فروش داشته باشد، تا یک کالا دو بار فروخته نشود. برای لایک، در دسترس بودن مهم‌تر است: هر دو طرف لایک را قبول کنند و بعد از وصل شدن شبکه، عددها جمع شوند.

سرنخ (اگر کاندید گفت مشکلی نیست): در این ۲۰ دقیقه، دو کاربر یکی در تهران و یکی در مشهد آخرین گوشی را می‌خرند. هر دیتاسنتر موجودی را ۱ می‌بیند و فروش را قبول می‌کند. حالا چه می‌شود؟

چرا نمی‌شود هر دو را داشت؟ (قدم به قدم)

  1. شبکه بین دو دیتاسنتر قطع است. پس هر کدام نمی‌داند دیگری چه کرده است.
  2. اگر هر دو به نوشتن ادامه دهند (در دسترس)، ممکن است داده متفاوت داشته باشند (ناسازگار).
  3. اگر بخواهیم داده یکی بماند (سازگار)، یکی از دو طرف باید نوشتن را رد کند تا شبکه وصل شود.
  4. پس هنگام قطعی، یکی از این دو را از دست می‌دهیم. این انتخاب کسب و کاری است، نه فقط فنی.

موجودی: سازگاری را انتخاب می‌کنیم.

  • فروش یک کالا به دو نفر یعنی لغو سفارش، بازپرداخت، و مشتری ناراضی.
  • گزینه‌ها: موجودی هر کالا یک دیتاسنتر «مالک» دارد و فقط آن‌جا فروش قبول می‌شود. طرف دیگر پیام «لطفاً چند دقیقه دیگر تلاش کنید» نشان می‌دهد.
  • گزینه دیگر: موجودی را از قبل بین دو دیتاسنتر تقسیم کنیم (مثلاً ۲ تا این‌جا، ۱ تا آن‌جا). هر طرف فقط سهم خودش را می‌فروشد.
  • بعضی کسب و کارها عمداً کمی بیش‌فروشی را قبول می‌کنند و بعداً با مشتری تماس می‌گیرند. این هم یک انتخاب است، ولی باید آگاهانه باشد.

لایک: در دسترس بودن را انتخاب می‌کنیم.

  • اگر عدد لایک چند دقیقه کمی اشتباه باشد، ضرری ندارد.
  • رد کردن لایک تجربه کاربر را بد می‌کند، بدون هیچ سودی.
  • هر دیتاسنتر شمارنده خودش را نگه می‌دارد. بعد از وصل شدن، عددها با هم جمع می‌شوند. چون جمع زدن ترتیب ندارد، ادغام ساده است.

فراتر از CAP، یعنی PACELC: حتی وقتی شبکه سالم است، یک انتخاب داریم. صبر کنیم تا هر دو دیتاسنتر نوشتن را تأیید کنند (سازگار ولی کندتر)، یا زود جواب بدهیم (سریع ولی موقتاً ناسازگار). برای موجودی، کندی کمی را قبول می‌کنیم. برای لایک، سرعت را انتخاب می‌کنیم.

سؤال پیگیری: مدیر محصول می‌گوید «برای کل سیستم یک دیتابیس انتخاب کن: یا سازگار یا در دسترس.» نظرت چیست؟

جواب پیگیری:

  1. انتخاب CAP برای هر نوع داده جداست، نه برای کل سیستم.
  2. پول، موجودی و رزرو معمولاً سازگاری می‌خواهند.
  3. لایک، تعداد بازدید، پیشنهادها و سبد خرید معمولاً در دسترس بودن را ترجیح می‌دهند.
  4. یک سیستم می‌تواند برای هر بخش رفتار متفاوت داشته باشد، با یک دیتابیس که تنظیمات متفاوت دارد، یا چند ذخیره‌ساز متفاوت.

نشانه خطر: می‌گوید «هر سه را با هم داریم» یا CAP را فقط به عنوان «یکی از سه تا را انتخاب کن» حفظ کرده و نمی‌داند انتخاب فقط هنگام قطعی شبکه مطرح است.

ویرایش در GitHub

۷کش ۲۴ ساعته برای صفحه محصولمتوسطالگوهای Caching

سؤال: در یک فروشگاه آنلاین، ۹۵٪ ترافیک خواندن صفحه محصول است. دیتابیس زیر بار است. تیم این طرح را پیشنهاد می‌دهد:

  • یک Redis جلوی دیتابیس می‌گذاریم.
  • وقتی صفحه محصول خوانده می‌شود، اول در کش نگاه می‌کنیم. اگر نبود، از دیتابیس می‌خوانیم و در کش می‌گذاریم.
  • مدت اعتبار (TTL) هر محصول در کش ۲۴ ساعت است.
  • وقتی قیمت عوض می‌شود، فقط دیتابیس به‌روز می‌شود.

حدود ۲۰۰ هزار محصول داریم. تیم فروش چند بار در روز قیمت‌ها را عوض می‌کند، مخصوصاً در حراج‌ها. نظرت درباره این طرح چیست؟

جواب کوتاه: ایده کش برای این بار درست است. الگوی خواندن هم همان cache-aside است. مشکل این است که وقتی قیمت عوض می‌شود، کش خبر ندارد. پس تا ۲۴ ساعت ممکن است قیمت قدیمی نشان داده شود. باید هنگام تغییر قیمت، کلید آن محصول را از کش پاک کنیم (invalidation)، و TTL را هم کوتاه‌تر کنیم تا اگر پاک کردن شکست خورد، خطا زیاد طول نکشد.

سرنخ (اگر کاندید گفت مشکلی نیست): حراج ساعت ۱۰ صبح تمام می‌شود و قیمت‌ها برمی‌گردند. ساعت ۴ عصر هنوز چند مشتری با قیمت حراج سفارش می‌دهند. مشتری‌های دیگر شکایت می‌کنند که صفحه محصول یک قیمت نشان می‌دهد و صفحه پرداخت قیمت دیگری.

چرا این اتفاق می‌افتد؟ (قدم به قدم)

  1. ساعت ۹ صبح، صفحه یک محصول خوانده می‌شود. قیمت حراج در کش می‌رود، با اعتبار ۲۴ ساعت.
  2. ساعت ۱۰، قیمت در دیتابیس عوض می‌شود. کش دست نمی‌خورد.
  3. تا ساعت ۹ صبح فردا، هر کس صفحه را باز کند، قیمت قدیمی را از کش می‌گیرد.
  4. اگر صفحه پرداخت قیمت را از دیتابیس بخواند، دو قیمت متفاوت داریم. اگر از کش بخواند، با قیمت اشتباه می‌فروشیم.

راه حل:

  1. هنگام تغییر قیمت: اول دیتابیس را به‌روز می‌کنیم، بعد کلید آن محصول را از کش پاک می‌کنیم. خواندن بعدی مقدار تازه را از دیتابیس می‌آورد.
  2. اگر چند سرویس قیمت را عوض می‌کنند، بهتر است یک رویداد «قیمت عوض شد» منتشر شود و یک مصرف‌کننده کش را پاک کند. پس هیچ مسیری فراموش نمی‌شود.
  3. مدت اعتبار را کوتاه‌تر می‌کنیم (مثلاً چند دقیقه). این یک تور ایمنی است: اگر پیام پاک کردن گم شد، خطا فقط چند دقیقه می‌ماند.
  4. برای پول، منبع حقیقت دیتابیس است. صفحه پرداخت قیمت نهایی را همیشه از دیتابیس (یا سرویس قیمت) می‌خواند، نه از کش.

چرا پاک کردن و نه به‌روز کردن کش؟ اگر دو تغییر قیمت هم‌زمان باشند، ممکن است نوشتن در کش به ترتیب اشتباه انجام شود و مقدار قدیمی‌تر بماند. پاک کردن ساده‌تر است: خواندن بعدی همیشه از دیتابیس می‌آید.

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

سؤال پیگیری: حالا TTL را ۵ دقیقه کرده‌ایم. محصول پرفروش حراج، هر ثانیه هزاران بار خوانده می‌شود. وقتی کلیدش منقضی می‌شود، چه اتفاقی می‌افتد؟

جواب پیگیری:

  1. در همان لحظه، هزاران درخواست کش خالی می‌بینند و همه با هم به دیتابیس می‌روند. به این cache stampede می‌گویند.
  2. راه اول: فقط یک درخواست اجازه دارد مقدار را از دیتابیس بیاورد (یک قفل کوتاه). بقیه کمی صبر می‌کنند یا مقدار قبلی را می‌گیرند.
  3. راه دوم: کمی عدد تصادفی به TTL اضافه می‌کنیم تا همه کلیدها با هم منقضی نشوند.
  4. راه سوم: برای کلیدهای خیلی پرطرفدار، قبل از منقضی شدن، مقدار را در پس‌زمینه تازه می‌کنیم.

نشانه خطر: کش را فقط ابزار سرعت می‌بیند و هیچ سؤالی درباره «وقتی داده عوض شد چه می‌شود؟» نمی‌پرسد، یا پیشنهاد می‌دهد قیمت نهایی پرداخت را هم از کش بخوانیم.

ویرایش در GitHub

۸زنجیره پنج سرویسی در پرداختمتوسطMicroservicesResilience با Polly

سؤال: یک فروشگاه آنلاین روزانه حدود ۲۰ هزار سفارش دارد. تیم برای پرداخت این طرح را آماده کرده است:

Checkout API
  -> 1. User service       (check user)
  -> 2. Cart service       (get items)
  -> 3. Inventory service  (reserve stock)
  -> 4. Payment service    (charge card)
  -> 5. Email service      (send receipt)
  -> return "order placed"

All calls: HTTP, one after another
Timeout per call: 30 seconds
Retries: none

هر سرویس به تنهایی معمولاً ۹۹.۹٪ وقت سالم است و در حدود ۱۰۰ میلی‌ثانیه جواب می‌دهد. این طرح را بررسی کن. نظرت چیست؟

جواب کوتاه: در زنجیره sync، در دسترس بودن‌ها در هم ضرب می‌شوند و زمان‌ها با هم جمع می‌شوند. پس کل پرداخت از هر سرویس به تنهایی شکننده‌تر و کندتر است. timeout ۳۰ ثانیه‌ای هم یعنی اگر یک سرویس کند شود، درخواست‌ها و thread ها در همه سرویس‌های بالایی گیر می‌کنند و خرابی پخش می‌شود (cascading failure). باید زنجیره را کوتاه کنیم، کارهایی که لازم نیست همین الان انجام شوند (مثل ایمیل) را async کنیم، و timeout ها را کوتاه و واقعی بگذاریم.

سرنخ (اگر کاندید گفت مشکلی نیست): یک روز سرویس ایمیل کند می‌شود و هر درخواست ۲۵ ثانیه طول می‌کشد. کارت مشتری شارژ شده، ولی صفحه پرداخت چرخ می‌زند. چند دقیقه بعد، Checkout API به کلی جواب نمی‌دهد، حتی برای کاربرانی که فقط سبد خرید را نگاه می‌کنند.

چرا این طرح شکننده است؟ (قدم به قدم)

  1. در دسترس بودن: هر سرویس ۹۹.۹٪. برای موفقیت، هر ۵ تا باید سالم باشند. ۰.۹۹۹ به توان ۵ تقریباً ۹۹.۵٪ می‌شود. یعنی خرابی تقریباً ۵ برابر بیشتر.
  2. زمان: ۵ تماس پشت سر هم، هر کدام حدود ۱۰۰ میلی‌ثانیه، یعنی حداقل نیم ثانیه. کندی هر کدام به کل اضافه می‌شود.
  3. در بدترین حالت، هر تماس تا ۳۰ ثانیه صبر می‌کند. یعنی یک درخواست ممکن است دو دقیقه گیر کند.
  4. در این مدت، هر درخواست گیر کرده یک اتصال یا thread را نگه می‌دارد. درخواست‌های جدید پشت سر هم جمع می‌شوند. منابع Checkout API تمام می‌شود و برای همه از کار می‌افتد.
  5. ایمیل اصلاً لازم نیست در مسیر اصلی باشد. ولی الان کندی ایمیل، پرداخت را خراب می‌کند.

راه حل:

  1. کارهایی که کاربر منتظرشان نیست را async کنیم. بعد از ثبت سفارش، یک رویداد «سفارش ثبت شد» منتشر می‌شود. سرویس ایمیل آن را از صف برمی‌دارد. اگر ایمیل یک ساعت قطع باشد، سفارش‌ها هنوز ثبت می‌شوند.
  2. تعداد گام‌ها را کم کنیم. مثلاً اطلاعات سبد خرید همراه درخواست بیاید، یا اطلاعات لازم کاربر در توکن باشد.
  3. هر تماس timeout کوتاه و واقعی داشته باشد (بر اساس زمان معمول آن سرویس، نه ۳۰ ثانیه). کل درخواست هم یک سقف زمانی داشته باشد.
  4. برای خطاهای موقتی، retry محدود با فاصله و کمی عدد تصادفی بگذاریم. ولی فقط برای عملیاتی که تکرارشان امن است (idempotent). شارژ کارت بدون کلید یکتا retry نمی‌شود.
  5. یک circuit breaker جلوی سرویس‌هایی که خرابند می‌گذاریم تا سریع خطا بدهیم، به جای صبر کردن.

سؤال پیگیری: چرا retry ساده (مثلاً ۳ بار فوری) روی همه تماس‌ها ممکن است اوضاع را بدتر کند؟

جواب پیگیری:

  1. اگر سرویسی زیر بار است، هر retry بار بیشتری به آن می‌دهد. با ۳ retry، بار می‌تواند تا ۴ برابر شود.
  2. اگر هر لایه زنجیره خودش retry کند، تعداد تلاش‌ها در هم ضرب می‌شود.
  3. برای پرداخت، retry بدون کلید یکتا ممکن است کارت را دو بار شارژ کند.
  4. پس retry کم، با فاصله رو به افزایش و عدد تصادفی، فقط در یک لایه، و فقط برای عملیات idempotent.

نشانه خطر: فقط می‌گوید «timeout را بیشتر کنیم» یا «سرورها را بیشتر کنیم» و نمی‌بیند که ایمیل اصلاً نباید در مسیر sync پرداخت باشد.

ویرایش در GitHub

۹دو سرویس با جدول‌های مشترکمتوسطMicroservicesDomain-Driven Design

سؤال: یک شرکت دو تیم دارد: تیم سفارش و تیم صورتحساب. هر تیم یک میکروسرویس جدا دارد و جدا deploy می‌کند. برای «صرفه‌جویی در زمان»، هر دو سرویس به یک دیتابیس وصل‌اند و مستقیم از جدول‌های هم می‌خوانند و در آن‌ها می‌نویسند:

Orders service   --read/write-->  orders, order_items, invoices
Billing service  --read/write-->  orders, invoices, payments

مثلاً سرویس صورتحساب وقتی پرداخت انجام شد، مستقیم ستون status در جدول orders را به «پرداخت شده» عوض می‌کند. نظرت درباره این طرح چیست؟

جواب کوتاه: این طرح دو سرویس را از نظر deploy جدا کرده، ولی از نظر داده به هم چسبانده است. به این distributed monolith می‌گویند: هزینه‌های میکروسرویس (شبکه، deploy جدا، چند تیم) را داریم، ولی مزیت اصلی‌اش (تغییر و deploy مستقل) را نداریم. هر سرویس باید مالک داده خودش باشد. سرویس دیگر فقط از راه API یا رویداد (event) به آن داده دسترسی دارد.

سرنخ (اگر کاندید گفت مشکلی نیست): تیم سفارش ستون status را از متن به یک عدد تغییر می‌دهد و migration را اجرا می‌کند. همان شب، سرویس صورتحساب خطا می‌دهد و هیچ فاکتوری ساخته نمی‌شود. تیم صورتحساب از این تغییر خبر نداشت.

چرا این طرح مشکل دارد؟ (قدم به قدم)

  1. ساختار جدول یک قرارداد پنهان بین دو تیم است. ولی هیچ کس آن را به عنوان قرارداد نمی‌بیند.
  2. هر تغییر در جدول (نام ستون، نوع، معنی یک مقدار) ممکن است سرویس دیگر را خراب کند.
  3. پس قبل از هر migration، دو تیم باید هماهنگ شوند و گاهی با هم deploy کنند. یعنی دیگر مستقل نیستند.
  4. قانون‌های کسب و کار دور زده می‌شوند. مثلاً سرویس سفارش قانونی دارد که «سفارش لغو شده نباید پرداخت شده شود». سرویس صورتحساب مستقیم ستون را عوض می‌کند و این قانون را نمی‌بیند.
  5. وقتی داده اشتباه است، معلوم نیست کدام سرویس آن را نوشته است.
  6. هر دو سرویس روی یک دیتابیس بار می‌گذارند. نمی‌شود یکی را جدا scale کرد.

راه حل: هر سرویس داده خودش را دارد.

  1. جدول‌های orders و order_items فقط مال سرویس سفارش هستند. جدول‌های invoices و payments فقط مال سرویس صورتحساب.
  2. اگر صورتحساب اطلاعات سفارش را لازم دارد، دو راه دارد:
    • صدا زدن API سرویس سفارش، وقتی داده تازه همین الان لازم است.
    • گوش دادن به رویداد «سفارش ثبت شد» و نگه داشتن یک کپی از فیلدهای لازم در دیتابیس خودش.
  3. وقتی پرداخت انجام شد، صورتحساب یک رویداد «پرداخت انجام شد» منتشر می‌کند. سرویس سفارش آن را می‌گیرد و خودش وضعیت را با قانون‌های خودش عوض می‌کند.
  4. حالا API و رویدادها قرارداد رسمی‌اند. می‌شود آن‌ها را نسخه‌بندی کرد و تست کرد.

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

مهاجرت قدم به قدم: یک شبه همه چیز را جدا نمی‌کنیم. اول مالک هر جدول را مشخص می‌کنیم. بعد نوشتن در جدول دیگران را به API منتقل می‌کنیم. خواندن‌ها را در آخر جدا می‌کنیم.

سؤال پیگیری: اگر این دو سرویس آن‌قدر به داده هم نیاز دارند، شاید اصلاً نباید جدا باشند. چطور تصمیم می‌گیری؟

جواب پیگیری:

  1. اگر تقریباً هر تغییر هر دو را درگیر می‌کند، شاید مرز اشتباه کشیده شده است.
  2. گزینه‌ای جدی این است که دوباره یک سرویس شوند، با دو ماژول جدا در داخل (modular monolith).
  3. اگر دو تیم واقعاً جدا هستند و مفهوم‌ها فرق دارند (سفارش در برابر پول)، جدا ماندن درست است، با مالکیت روشن داده.

نشانه خطر: دیتابیس مشترک را «ساده‌تر» می‌داند و نمی‌بیند که هر تغییر جدول، دو تیم را به هم قفل می‌کند.

ویرایش در GitHub

۱۰تغییر نام فیلد در API عمومیمتوسطSchema Evolution

سؤال: یک API عمومی داریم که اپ‌های موبایل اندروید و iOS از آن استفاده می‌کنند. حدود ۵۰۰ هزار کاربر فعال داریم. جواب فعلی یک endpoint این است:

{
  "id": 42,
  "price": "129000",
  "user_name": "sara"
}

تیم بک‌اند می‌خواهد در نسخه بعدی این تغییرها را انجام دهد:

{
  "id": 42,
  "price": 129000,
  "customerName": "sara"
}

یعنی نوع فیلد price از متن به عدد عوض می‌شود و نام user_name به customerName تغییر می‌کند. تیم موبایل هم هم‌زمان اپ را به‌روز می‌کند. برنامه انتشار تو چیست؟

جواب کوتاه: این دو تغییر breaking هستند. سرور را می‌شود یک شبه عوض کرد، ولی اپ‌های موبایل را نه. خیلی از کاربران تا هفته‌ها یا ماه‌ها اپ را به‌روز نمی‌کنند. پس نسخه‌های قدیمی اپ هنوز فیلد قدیمی را با نوع قدیمی انتظار دارند و خراب می‌شوند. راه درست: تغییر افزایشی (فیلد جدید اضافه کن، قدیمی را نگه دار)، یک دوره deprecation با تاریخ مشخص، و اندازه‌گیری این‌که چند درصد کاربران هنوز نسخه قدیمی دارند. اگر تغییر بزرگ است، یک نسخه جدید API.

سرنخ (اگر کاندید گفت مشکلی نیست): روز انتشار، نسخه جدید اپ درست کار می‌کند. ولی گزارش crash از گوشی‌هایی با نسخه قبلی اپ بالا می‌رود. این کاربران صفحه محصول را باز می‌کنند و اپ بسته می‌شود.

چرا این اتفاق می‌افتد؟ (قدم به قدم)

  1. اپ وب را با یک deploy برای همه عوض می‌کنیم. اپ موبایل روی گوشی کاربر است و ما کنترلش نمی‌کنیم.
  2. کاربر باید خودش اپ را به‌روز کند. خیلی‌ها به‌روزرسانی خودکار را خاموش کرده‌اند یا دیر به‌روز می‌کنند.
  3. نسخه قدیمی دنبال فیلد user_name می‌گردد. آن فیلد دیگر نیست. بسته به کد اپ، یا خالی نشان می‌دهد یا crash می‌کند.
  4. نسخه قدیمی price را متن انتظار دارد. حالا عدد می‌آید. خواندن JSON ممکن است خطا بدهد.

برنامه درست:

  1. فقط اضافه کن، چیزی را حذف یا عوض نکن. فیلدهای جدید کنار فیلدهای قدیمی می‌آیند:
{
  "id": 42,
  "price": "129000",
  "priceAmount": 129000,
  "user_name": "sara",
  "customerName": "sara"
}
  1. نسخه جدید اپ فقط از فیلدهای جدید استفاده می‌کند.
  2. فیلدهای قدیمی را deprecated اعلام می‌کنیم و در مستندات تاریخ حذف را می‌نویسیم.
  3. با لاگ یا header نسخه اپ، اندازه می‌گیریم چند درصد درخواست‌ها هنوز از نسخه‌های قدیمی می‌آیند.
  4. وقتی این عدد خیلی کم شد، یا برای نسخه‌های خیلی قدیمی پیام «لطفاً اپ را به‌روز کنید» (حداقل نسخه اجباری) گذاشتیم، فیلدهای قدیمی را حذف می‌کنیم.

کی نسخه جدید API (مثلاً v2)؟ وقتی تغییرها زیاد و ساختاری‌اند و افزودن فیلد کافی نیست. در این حالت هر دو نسخه مدتی کنار هم اجرا می‌شوند. هزینه‌اش نگهداری دو نسخه است، پس برای تغییر یک فیلد معمولاً ارزشش را ندارد.

قانون ساده: تغییر نام فیلد، تغییر نوع، حذف فیلد، و اجباری کردن یک فیلد ورودی، همه breaking هستند. اضافه کردن یک فیلد اختیاری معمولاً breaking نیست، به شرط این‌که کلاینت‌ها فیلدهای ناشناخته را نادیده بگیرند.

سؤال پیگیری: از کجا مطمئن می‌شوی که یک تغییر در آینده، بدون این‌که کسی متوجه شود، API را نمی‌شکند؟

جواب پیگیری:

  1. قرارداد API را به صورت رسمی می‌نویسیم (مثلاً با OpenAPI) و در repository نگه می‌داریم.
  2. در CI، قرارداد جدید را با قرارداد قبلی مقایسه می‌کنیم. ابزارهایی برای پیدا کردن تغییرهای breaking در فایل OpenAPI وجود دارند.
  3. تست قرارداد (contract test) می‌نویسیم که مطمئن شود جواب سرور هنوز چیزی را که نسخه‌های قدیمی کلاینت انتظار دارند، دارد.
  4. به تیم موبایل یاد می‌دهیم کلاینت فیلدهای ناشناخته را نادیده بگیرد.

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

ویرایش در GitHub

۱۱مصرف‌کننده پیام و پیام تکراریمتوسطIdempotency

سؤال: در یک فروشگاه آنلاین، روزی حدود ۲۰ هزار سفارش پرداخت می‌شود. بعد از هر پرداخت، سرویس پرداخت یک پیام «OrderPaid» در broker می‌گذارد. سرویس باشگاه مشتریان (loyalty) این پیام‌ها را می‌خواند و به مشتری امتیاز می‌دهد. تنظیم broker روی تحویل at-least-once است. طراحی مصرف‌کننده این است:

on message OrderPaid(orderId, customerId, amount):
    points = amount / 10
    UPDATE customers SET points = points + :points WHERE id = :customerId
    ack(message)

این طراحی را در جلسه بررسی معماری می‌بینی. نظرت چیست؟ آیا مشکلی دارد؟

جواب کوتاه: در تحویل at-least-once، یک پیام ممکن است بیش از یک بار برسد. این مصرف‌کننده هر بار امتیاز را اضافه می‌کند. پس پیام تکراری یعنی امتیاز دوبرابر. راه حل این است که مصرف‌کننده idempotent باشد: پردازش دوباره یک پیام، نتیجه را عوض نکند. معمولاً با یک جدول «پیام‌های پردازش‌شده» و یک کلید طبیعی مثل شماره سفارش.

سرنخ (اگر کاندید گفت مشکلی نیست): پشتیبانی می‌گوید چند مشتری در هفته شکایت می‌کنند که امتیازشان «اشتباه زیاد» شده است. بررسی نشان می‌دهد این اتفاق بیشتر روزهایی می‌افتد که سرویس loyalty دوباره deploy شده یا restart شده است.

چرا پیام تکراری می‌رسد؟ (قدم به قدم)

  1. مصرف‌کننده پیام را می‌گیرد و امتیاز را در دیتابیس ذخیره می‌کند.
  2. درست قبل از ack، سرویس restart می‌شود (deploy، کرش، یا قطعی شبکه).
  3. برای broker، این پیام هنوز تأیید نشده است.
  4. پس broker همان پیام را دوباره به یک مصرف‌کننده می‌دهد.
  5. مصرف‌کننده دوباره امتیاز را اضافه می‌کند. حالا مشتری دو بار امتیاز گرفته است.

تولیدکننده هم می‌تواند پیام تکراری بفرستد. مثلاً اگر جواب broker به او نرسد، دوباره می‌فرستد. پس «فقط یک بار» را نباید از broker انتظار داشت. این وظیفه مصرف‌کننده است.

راه حل: مصرف‌کننده idempotent

  1. هر پیام یک شناسه یکتا دارد. بهتر است یک کلید طبیعی باشد، مثل شماره سفارش. چون اگر تولیدکننده پیام را دوباره بسازد، شناسه پیام عوض می‌شود ولی شماره سفارش نه.
  2. یک جدول برای پیام‌های پردازش‌شده می‌سازیم، با یک unique constraint روی این کلید.
  3. ثبت در این جدول و اضافه کردن امتیاز در یک تراکنش انجام می‌شود.
  4. اگر پیام تکراری برسد، insert به خاطر unique constraint خطا می‌دهد. پس امتیاز اضافه نمی‌شود و فقط ack می‌کنیم.
on message OrderPaid(orderId, customerId, amount):
    begin transaction
        INSERT INTO processed_orders(order_id) VALUES (:orderId)  -- unique
        UPDATE customers SET points = points + :points WHERE id = :customerId
    commit
    ack(message)
    -- on unique violation: rollback, then ack (already done)

راه دیگر: به جای «اضافه کن»، یک ردیف برای هر سفارش در جدول تراکنش‌های امتیاز ثبت کنیم (با کلید یکتای شماره سفارش). جمع امتیاز از جمع این ردیف‌ها می‌آید. این طراحی به طور طبیعی idempotent است و تاریخچه هم دارد.

چند نکته طراحی:

  • جدول پیام‌های پردازش‌شده بزرگ می‌شود. پس یک سیاست پاک کردن لازم دارد. مدت نگهداری باید از بیشترین زمان ممکن برای تکرار پیام بیشتر باشد.
  • بررسی «اول بخوان، بعد اگر نبود بنویس» بدون تراکنش و constraint کافی نیست. دو نسخه از مصرف‌کننده ممکن است هم‌زمان همان پیام را ببینند.

سؤال پیگیری: اگر کار مصرف‌کننده نوشتن در دیتابیس خودش نباشد، بلکه صدا زدن یک API بیرونی باشد (مثلاً فرستادن پیامک)، چه می‌کنی؟

جواب پیگیری:

  • اینجا نمی‌توانیم تراکنش مشترک داشته باشیم.
  • اگر API بیرونی idempotency key را قبول می‌کند، شماره سفارش را به عنوان کلید می‌فرستیم. پس خود API تکرار را نادیده می‌گیرد.
  • اگر قبول نمی‌کند، قبل از صدا زدن وضعیت را ثبت می‌کنیم (مثلاً «در حال ارسال»). ولی باید قبول کنیم که در موارد نادر یک تکرار ممکن است. پس باید تصمیم بگیریم کدام بدتر است: پیامک تکراری یا پیامک گم‌شده.

نشانه خطر: فکر می‌کند broker تضمین «دقیقاً یک بار» می‌دهد و مصرف‌کننده لازم نیست کاری بکند، یا برای جلوگیری از تکرار فقط به «اول چک کن» بدون تراکنش تکیه می‌کند.

ویرایش در GitHub

۱۲وابستگی کند و صفحه محصولمتوسطResilience با Polly

سؤال: صفحه محصول یک فروشگاه آنلاین در ساعت شلوغی حدود ۵۰۰ درخواست در ثانیه دارد. سرویس صفحه محصول برای ساختن هر صفحه این کارها را پشت سر هم انجام می‌کند:

  1. اطلاعات محصول را از سرویس کاتالوگ می‌گیرد.
  2. قیمت و موجودی را از سرویس انبار می‌گیرد.
  3. لیست «محصولات پیشنهادی» را از سرویس پیشنهاد (recommendation) می‌گیرد.
  4. صفحه را می‌سازد و برمی‌گرداند.

هر سه صدا زدن با HTTP و با تنظیمات پیش‌فرض کلاینت HTTP انجام می‌شود. سرویس صفحه محصول یک thread pool (یا تعداد worker) محدود دارد. این طرح را در بررسی معماری می‌بینی. نظرت چیست؟

جواب کوتاه: این طرح در برابر کند شدن یک وابستگی محافظت ندارد. اگر سرویس پیشنهاد کند شود، همه workerها منتظر آن می‌مانند و کل صفحه محصول از کار می‌افتد، در حالی که بخش پیشنهاد اصلاً حیاتی نیست. راه حل‌ها: timeout کوتاه، circuit breaker، fallback (مثلاً صفحه بدون پیشنهاد)، و bulkhead (جدا کردن منابع هر وابستگی).

سرنخ (اگر کاندید گفت مشکلی نیست): یک روز سرویس پیشنهاد به خاطر یک کوئری سنگین کند می‌شود و هر جواب حدود ۵ ثانیه طول می‌کشد. خطا نمی‌دهد، فقط کند است. چند دقیقه بعد، کل سایت از دسترس خارج می‌شود. حتی صفحه‌هایی که اصلاً به پیشنهاد نیاز ندارند هم جواب نمی‌دهند.

چرا کل سایت از کار افتاد؟ (قدم به قدم)

  1. هر درخواست صفحه محصول یک worker را می‌گیرد تا کارش تمام شود.
  2. قبلاً هر درخواست مثلاً ۱۰۰ میلی‌ثانیه طول می‌کشید. حالا ۵ ثانیه طول می‌کشد، چون منتظر سرویس پیشنهاد است.
  3. با ۵۰۰ درخواست در ثانیه و ۵ ثانیه انتظار، حدود ۲۵۰۰ درخواست هم‌زمان در جریان است. این از تعداد workerها خیلی بیشتر است.
  4. همه workerها پر می‌شوند. درخواست‌های جدید در صف می‌مانند یا رد می‌شوند.
  5. کاربرها صفحه را refresh می‌کنند. پس بار بیشتر می‌شود.
  6. اگر صفحه‌های دیگر هم روی همین سرویس یا همین pool هستند، آن‌ها هم از کار می‌افتند. به این cascading failure (شکست زنجیره‌ای) می‌گویند.

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

راه حل:

  1. با timeout: برای هر صدا زدن یک timeout کوتاه و واقعی می‌گذاریم. مثلاً اگر سرویس پیشنهاد معمولاً زیر ۲۰۰ میلی‌ثانیه جواب می‌دهد، timeout حدود ۳۰۰ میلی‌ثانیه. تنظیم پیش‌فرض کلاینت‌ها معمولاً خیلی طولانی است.
  2. با circuit breaker: اگر درصد خطا یا timeout از یک حد بیشتر شد، مدار «باز» می‌شود. یعنی برای مدتی اصلاً سرویس پیشنهاد را صدا نمی‌زنیم و فوراً fallback را برمی‌گردانیم. بعد از چند ثانیه، چند درخواست آزمایشی می‌فرستیم (حالت half-open). اگر سالم بود، مدار بسته می‌شود.
  3. با fallback: صفحه بدون بخش پیشنهاد، یا با یک لیست ثابت از پرفروش‌ها که در کش است. کاربر هنوز می‌تواند خرید کند.
  4. با bulkhead: برای هر وابستگی یک سقف هم‌زمانی جدا می‌گذاریم. مثلاً حداکثر ۵۰ صدا زدن هم‌زمان به سرویس پیشنهاد. پس اگر آن کند شود، فقط همان ۵۰ جا پر می‌شود، نه همه workerها.
  5. با صدا زدن موازی: سه صدا زدن به هم وابسته نیستند. پس می‌توانند موازی انجام شوند. زمان کل برابر کندترین آن‌ها می‌شود، نه جمع آن‌ها.

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

سؤال پیگیری: تیم می‌خواهد برای هر خطا ۳ بار retry اضافه کند. نظرت چیست؟

جواب پیگیری:

  • برای سرویسی که زیر بار است، retry بار را چند برابر می‌کند. سه بار retry یعنی تا چهار برابر درخواست. این می‌تواند سرویس را کامل از پا بیندازد (retry storm).
  • اگر retry لازم است: فقط برای عملیات idempotent، با تعداد کم، با exponential backoff و jitter (فاصله تصادفی)، و داخل همان timeout کلی درخواست.
  • و همیشه همراه با circuit breaker، تا وقتی سرویس واقعاً خراب است، retry نکنیم.

نشانه خطر: فقط به حالت «سرویس خاموش است» فکر می‌کند و کند شدن را نمی‌بیند، یا بدون timeout و بدون سقف، retry را راه حل می‌داند.

ویرایش در GitHub

۱۳«پرداخت گاهی کند است»متوسطDistributed Tracing

سؤال: یک فروشگاه آنلاین ۸ سرویس دارد (gateway، سبد خرید، قیمت، تخفیف، انبار، پرداخت، سفارش، اعلان). هر سرویس چند نسخه (instance) دارد. هر سرویس فقط لاگ متنی خودش را روی دیسک خودش می‌نویسد. داشبورد فعلی فقط میانگین زمان پاسخ هر سرویس را نشان می‌دهد و همه میانگین‌ها خوب به نظر می‌رسند. کاربرها می‌گویند «پرداخت (checkout) گاهی خیلی کند است». به عنوان معمار، علت را چطور پیدا می‌کنی؟ و چه چیزی در سیستم عوض می‌کنی؟

جواب کوتاه: با این ابزارها نمی‌شود یک درخواست را بین ۸ سرویس دنبال کرد. سه چیز لازم است: لاگ ساختاریافته و متمرکز با یک correlation id، متریک‌ها با صدک‌ها (مثل p95 و p99) به جای میانگین، و distributed tracing (مثلاً با OpenTelemetry) تا ببینیم زمان یک درخواست کند دقیقاً کجا خرج شده است.

چرا الان پیدا کردن علت سخت است؟ (قدم به قدم)

  1. یک درخواست پرداخت از چند سرویس و چند نسخه می‌گذرد.
  2. لاگ هر نسخه روی ماشین خودش است. پس باید به چند ماشین سر بزنیم.
  3. هیچ شناسه مشترکی در لاگ‌ها نیست. پس نمی‌دانیم کدام خط لاگ در سرویس انبار مال همین درخواست است.
  4. کلمه «گاهی» یعنی مشکل فقط در بخش کوچکی از درخواست‌ها است. مثلاً ۲٪.
  5. میانگین این بخش کوچک را پنهان می‌کند. اگر ۹۸٪ درخواست‌ها ۲۰۰ میلی‌ثانیه و ۲٪ آن‌ها ۸ ثانیه باشند، میانگین حدود ۳۵۰ میلی‌ثانیه است. این عدد «خوب» به نظر می‌رسد.

راه حل: سه ستون observability

  1. متریک‌ها: زمان پاسخ را با صدک‌ها نشان می‌دهیم: p50، p95، p99. صدک p99 یعنی ۹۹٪ درخواست‌ها از این عدد سریع‌ترند. این‌جا همان «گاهی» دیده می‌شود. نرخ خطا و تعداد درخواست را هم کنارش می‌گذاریم.
  2. لاگ‌ها: لاگ ساختاریافته (مثلاً JSON) که به یک جای مرکزی فرستاده می‌شود. هر خط لاگ یک correlation id (یا trace id) دارد. پس با یک جستجو، همه لاگ‌های یک درخواست از همه سرویس‌ها پیدا می‌شود.
  3. ردگیری‌ها (trace): هر درخواست یک trace است. هر مرحله در هر سرویس یک span است، با زمان شروع و پایان. شناسه trace در header ها به سرویس بعدی می‌رود. نتیجه یک نمودار زمانی است که نشان می‌دهد مثلاً ۷ ثانیه از ۸ ثانیه در صدا زدن سرویس تخفیف به دیتابیس خرج شده است.

چرا OpenTelemetry؟ یک استاندارد باز و مستقل از فروشنده است، برای trace، متریک و لاگ. کتابخانه‌هایش برای خیلی از زبان‌ها و فریم‌ورک‌ها وجود دارد. پس می‌توانیم بعداً ابزار نمایش (backend) را عوض کنیم بدون این‌که کد را عوض کنیم.

قدم‌های عملی برای همین مشکل:

  1. همه سرویس‌ها را به OpenTelemetry مجهز می‌کنیم. اول gateway و مسیر پرداخت.
  2. چون trace گرفتن از همه درخواست‌ها گران است، sampling می‌کنیم. ولی بهتر است trace های کند و پرخطا همیشه نگه داشته شوند (tail-based sampling)، چون دنبال همان‌ها هستیم.
  3. صدک p99 مسیر پرداخت را نگاه می‌کنیم. چند trace کند را باز می‌کنیم و دنبال الگو می‌گردیم: همیشه یک سرویس؟ یک نسخه خاص؟ یک نوع سبد خرید؟
  4. برای مسیر پرداخت یک SLO تعریف می‌کنیم. مثلاً «۹۹٪ پرداخت‌ها زیر ۲ ثانیه». هشدار را روی این عدد می‌گذاریم، نه روی میانگین.

سؤال پیگیری: trace نشان می‌دهد زمان در صف انتظار برای گرفتن اتصال از connection pool دیتابیس خرج می‌شود، نه در خود کوئری. بعد چه؟

جواب پیگیری:

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

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

ویرایش در GitHub

۱۴«وقت برای refactor نداریم»متوسطTechnical Debt

سؤال: تو معمار یک محصول هستی که ۵ سال است در حال توسعه است. دو تیم ۶ نفره روی آن کار می‌کنند. مدیر محصول می‌گوید: «برای refactor وقت نداریم. فقط فیچر می‌سازیم.» از طرف دیگر، تیم‌ها می‌گویند هر فصل تعداد فیچرهای تحویل‌شده کمتر می‌شود و باگ‌های بعد از release بیشتر. یکی از برنامه‌نویس‌های ارشد پیشنهاد می‌دهد: «شش ماه فیچر را متوقف کنیم و کل سیستم را از نو بنویسیم.» به عنوان معمار چه می‌کنی؟

جواب کوتاه: هیچ‌کدام از دو حالت افراطی درست نیست. کار معمار این است که بدهی فنی را قابل دیدن کند، هزینه‌اش را به زبان کسب‌وکار بگوید (زمان تحویل، باگ، ریسک)، و یک پرداخت کوچک و پیوسته پیشنهاد کند که به فیچرها گره خورده باشد. بازنویسی کامل یک‌جا (big-bang) ریسک خیلی بالایی دارد.

چرا «فقط فیچر» کار نمی‌کند؟ (قدم به قدم)

  1. هر میان‌بُر در کد، کار بعدی در همان بخش را سخت‌تر می‌کند. مثل بهره روی وام.
  2. اگر هیچ‌وقت چیزی پرداخت نشود، این بهره جمع می‌شود.
  3. نتیجه همان چیزی است که تیم‌ها می‌بینند: هر فیچر زمان بیشتری می‌گیرد و چیزهای بیشتری می‌شکند.
  4. پس «وقت نداریم» در عمل یعنی هر فصل وقت کمتری خواهیم داشت.

چرا «شش ماه بازنویسی» هم خطرناک است؟

  • در این شش ماه کسب‌وکار هیچ چیز جدیدی نمی‌گیرد.
  • سیستم قدیمی خیلی قانون پنهان دارد که در هیچ سندی نیست. بازنویسی معمولاً بیشتر از تخمین طول می‌کشد.
  • بازار منتظر نمی‌ماند. فشار برای اضافه کردن فیچر به هر دو سیستم شروع می‌شود.

راه حل: یک برنامه قابل اجرا

  1. قابل دیدن کردن: یک فهرست بدهی فنی می‌سازیم. برای هر مورد: کجاست، چه دردی دارد، و هزینه رفع آن تقریباً چقدر است.
  2. اندازه گرفتن: عددهایی که مدیر می‌فهمد. مثلاً متریک‌های DORA: زمان از commit تا رسیدن به production (lead time)، تعداد deploy در هفته، درصد deploy هایی که مشکل ساختند، و زمان رفع مشکل. تعداد باگ بعد از هر release را هم کنارش می‌گذاریم. روند این عددها را در چند فصل نشان می‌دهیم.
  3. اولویت با درد: همه بدهی‌ها مهم نیستند. بدهی در کدی که هر هفته عوض می‌شود مهم است. بدهی در کدی که سال‌هاست کسی به آن دست نزده، فعلاً مهم نیست.
  4. گره زدن به فیچرها: وقتی فیچری در یک بخش ساخته می‌شود، همان بخش را کمی تمیز می‌کنیم (قانون پیشاهنگی: کد را تمیزتر از وقتی که دیدی تحویل بده). تخمین فیچر شامل این کار است.
  5. سهم ثابت: یک سهم کوچک و ثابت از ظرفیت هر sprint برای بدهی کنار می‌گذاریم. عدد دقیق را با تیم و مدیر توافق می‌کنیم و نتیجه را در متریک‌ها نشان می‌دهیم.
  6. جلوگیری از بدهی جدید: تست خودکار، code review، و ADR برای تصمیم‌های مهم. اگر بدهی عمداً گرفته می‌شود (مثلاً برای یک موعد مهم)، ثبت می‌شود و زمان پرداختش مشخص می‌شود.

زبان مناسب با مدیر: نگو «کد کثیف است». بگو «فیچرهای پرداخت الان حدوداً دو برابر یک سال پیش وقت می‌گیرند. اگر این دو ماژول را در سه sprint درست کنیم، انتظار داریم این زمان کم شود. بعد با عدد نشان می‌دهیم.»

سؤال پیگیری: اگر یک بخش واقعاً آن‌قدر بد است که باید جایگزین شود، چه می‌کنی؟

جواب پیگیری:

  • باز هم یک‌جا بازنویسی نمی‌کنیم. با الگوی Strangler Fig آن بخش را تکه‌تکه جایگزین می‌کنیم (سؤال ۱۸).
  • یک مرز روشن (interface) دور آن بخش می‌کشیم، تست‌های رفتاری می‌نویسیم، و بعد پیاده‌سازی پشت این مرز را عوض می‌کنیم.
  • هر تکه جداگانه به production می‌رود. پس کسب‌وکار در طول کار هم ارزش می‌گیرد.

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

ویرایش در GitHub

۱۵خودمان بسازیم یا آماده بخریم؟متوسطArchitecture Decision Record

سؤال: یک شرکت ۴۰ نفره یک نرم‌افزار حسابداری آنلاین (SaaS) برای کسب‌وکارهای کوچک می‌فروشد. تیم فنی ۱۲ نفر است. حالا تیم پیشنهاد می‌دهد سیستم احراز هویت و مدیریت کاربر (ورود، رمز، ورود دو مرحله‌ای، SSO برای مشتری‌های بزرگ) را خودشان از صفر بسازند. دلیلشان این است: «می‌خواهیم کنترل کامل داشته باشیم و به هیچ فروشنده‌ای وابسته نباشیم.» نظرت چیست؟

جواب کوتاه: برای این شرکت، احراز هویت دامنه اصلی (core) نیست. مشتری برای حسابداری پول می‌دهد، نه برای صفحه ورود. ساختن آن ریسک امنیتی بالا و هزینه کل مالکیت (TCO) زیادی دارد. معمولاً بهتر است از یک راه حل آماده و شناخته‌شده استفاده کنیم (یک سرویس مدیریت‌شده یا یک محصول متن‌باز بالغ) و وابستگی را با استانداردها (OpenID Connect، OAuth 2.0، SAML) و یک لایه نازک کنترل کنیم.

چطور فکر می‌کنم؟ (قدم به قدم)

  1. اول می‌پرسم: این بخش، ما را از رقبا متمایز می‌کند؟ در DDD به بخش‌های متمایزکننده core domain می‌گویند. بخش‌هایی که همه دارند و لازم‌اند ولی متمایزکننده نیستند، generic subdomain هستند. احراز هویت برای یک نرم‌افزار حسابداری generic است.
  2. بعد ریسک را نگاه می‌کنم: احراز هویت جای خطای کوچک نیست. ذخیره درست رمز، محافظت در برابر حمله‌های حدس رمز، مدیریت session، بازیابی رمز، ورود دو مرحله‌ای، و پیاده‌سازی درست استانداردها. یک اشتباه کوچک یعنی نشت داده مشتری.
  3. بعد هزینه کل را حساب می‌کنم، نه فقط هزینه ساختن: ساختن نسخه اول فقط شروع است. بعد نگهداری، به‌روزرسانی امنیتی، audit، پشتیبانی شبانه، و فیچرهای جدیدی که مشتری‌ها می‌خواهند (مثلاً SSO با سیستم شرکت خودشان) اضافه می‌شود.
  4. بعد هزینه فرصت: هر ماهی که ۳ نفر روی صفحه ورود کار می‌کنند، روی حسابداری کار نمی‌کنند.
  5. آخر، «کنترل» را دقیق می‌کنم: دقیقاً چه کنترلی لازم است؟ معمولاً جواب این‌هاست: داده کاربران، ظاهر صفحه ورود، و امکان عوض کردن فروشنده. این‌ها بدون ساختن از صفر هم به دست می‌آیند.

چطور وابستگی را کنترل کنیم؟

  • با استانداردهای باز (OpenID Connect، SAML) به سیستم هویت وصل می‌شویم، نه با API اختصاصی یک فروشنده.
  • در کد خودمان فقط به یک interface کوچک وابسته‌ایم. پس عوض کردن فروشنده بعداً ممکن است، هرچند کار دارد.
  • امکان خروجی گرفتن از داده کاربران را قبل از انتخاب بررسی می‌کنیم.
  • تصمیم را با یک ADR ثبت می‌کنیم: گزینه‌ها، دلیل انتخاب، و شرایطی که تصمیم را دوباره بررسی می‌کنیم.

کی ساختن درست است؟

  • وقتی آن بخش واقعاً core است و ما را متمایز می‌کند.
  • وقتی هیچ راه حل آماده‌ای نیاز واقعی ما را برآورده نمی‌کند (مثلاً یک قانون خاص یا محدودیت شدید عملکرد).
  • وقتی تیم تخصص و ظرفیت نگهداری بلندمدت را دارد.
  • وقتی هزینه یا مقیاس راه حل آماده واقعاً قابل قبول نیست، و این را با عدد نشان داده‌ایم.

همین منطق برای ساختن message queue خودمان: صف پیام هم معمولاً generic است. محصولات بالغ (مثل Kafka یا RabbitMQ) سال‌ها روی مسائل سخت مثل ماندگاری، تکرار و ترتیب پیام کار کرده‌اند. ساختن نسخه خودمان معمولاً همان مسائل را با کیفیت کمتر دوباره حل می‌کند.

سؤال پیگیری: مدیر مالی می‌گوید هزینه ماهانه سرویس آماده با رشد کاربران زیاد می‌شود. چه جواب می‌دهی؟

جواب پیگیری:

  • حق دارد. باید هزینه را در چند سناریوی رشد حساب کنیم، و هزینه ساختن و نگهداری خودمان (حقوق، زیرساخت، ریسک) را هم کنارش بگذاریم.
  • گزینه سوم هم هست: یک محصول متن‌باز بالغ که خودمان میزبانی می‌کنیم. هزینه لایسنس کمتر است ولی هزینه عملیات بیشتر.
  • یک نقطه بررسی دوباره تعریف می‌کنیم. مثلاً «اگر هزینه از فلان عدد گذشت، دوباره تصمیم می‌گیریم». این را در ADR می‌نویسیم.

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

ویرایش در GitHub

۱۶ذخیره سفارش و فرستادن رویدادمتوسط تا سختOutbox و InboxKafka

سؤال: سرویس سفارش روزی حدود ۵۰ هزار سفارش ثبت می‌کند. بعد از ثبت هر سفارش، یک رویداد «OrderCreated» در Kafka منتشر می‌شود. سرویس‌های انبار، ایمیل و گزارش این رویداد را می‌خوانند. کد ثبت سفارش این است:

function createOrder(request):
    order = new Order(request)
    db.beginTransaction()
    db.insert(order)
    db.commit()
    kafka.publish("orders", OrderCreated(order.id, order.items))
    return order.id

این کد را در code review می‌بینی. نظرت چیست؟ آیا مشکلی دارد؟

جواب کوتاه: این کد در دو سیستم جدا می‌نویسد (دیتابیس و Kafka) و هیچ تراکنشی هر دو را با هم پوشش نمی‌دهد. به این مشکل dual write می‌گویند. اگر بین این دو مرحله چیزی خراب شود، سفارش ذخیره می‌شود ولی رویداد هیچ‌وقت منتشر نمی‌شود. راه حل رایج Transactional Outbox است: رویداد را در همان تراکنش، در یک جدول outbox در همان دیتابیس می‌نویسیم، و یک relay جداگانه آن را به Kafka می‌فرستد.

سرنخ (اگر کاندید گفت مشکلی نیست): چند بار در ماه، سفارشی در سیستم ثبت شده ولی انبار هیچ‌وقت برایش کالا رزرو نکرده و ایمیل تأیید هم نرفته است. این موارد معمولاً نزدیک زمان deploy یا وقتی Kafka چند ثانیه در دسترس نبوده، دیده می‌شوند.

چرا این اتفاق می‌افتد؟ (قدم به قدم)

  1. تراکنش دیتابیس commit می‌شود. حالا سفارش قطعاً ذخیره شده است.
  2. قبل از اجرای publish، سرویس کرش می‌کند یا برای deploy خاموش می‌شود. یا Kafka در دسترس نیست و publish خطا می‌دهد.
  3. سفارش در دیتابیس هست، ولی هیچ رویدادی نرفته است. هیچ‌کس هم دوباره تلاش نمی‌کند.
  4. پس انبار و ایمیل هیچ‌وقت از این سفارش خبردار نمی‌شوند.

جابه‌جا کردن ترتیب هم کمکی نمی‌کند:

  • اگر اول publish کنیم و بعد commit، ممکن است رویداد برود ولی commit شکست بخورد. حالا انبار برای سفارشی که وجود ندارد کالا رزرو می‌کند.
  • اگر publish را داخل تراکنش بگذاریم، باز هم Kafka جزو تراکنش دیتابیس نیست. اگر بعد از publish، commit شکست بخورد، همان مشکل قبلی است.

راه حل: Transactional Outbox

  1. در همان دیتابیس سفارش، یک جدول outbox می‌سازیم.
  2. در همان تراکنش، هم سفارش و هم رویداد را ذخیره می‌کنیم. پس یا هر دو ذخیره می‌شوند، یا هیچ‌کدام.
  3. یک فرایند جدا (relay) ردیف‌های ارسال‌نشده outbox را می‌خواند، به Kafka می‌فرستد، و بعد از تأیید Kafka آن‌ها را «ارسال‌شده» علامت می‌زند.
  4. به جای خواندن دوره‌ای جدول (polling)، می‌شود از CDC (Change Data Capture، مثلاً با Debezium) استفاده کرد که تغییرات را از لاگ دیتابیس می‌خواند.
function createOrder(request):
    order = new Order(request)
    db.beginTransaction()
    db.insert(order)
    db.insert(outbox, { id: newId(), type: "OrderCreated", payload: order })
    db.commit()
    return order.id

// relay process, runs separately
loop:
    rows = db.select(outbox where sent = false order by id limit 100)
    for row in rows:
        kafka.publish("orders", row.payload, key = order id)
        db.update(outbox set sent = true where id = row.id)

نتیجه مهم: at-least-once. اگر relay بعد از publish و قبل از علامت زدن کرش کند، همان رویداد دوباره فرستاده می‌شود. پس Outbox تضمین می‌کند رویداد گم نمی‌شود، ولی ممکن است تکراری برسد. بنابراین مصرف‌کننده‌ها باید idempotent باشند (سؤال ۱۱). هر رویداد یک شناسه یکتا دارد تا تکرار قابل تشخیص باشد.

یک نکته دیگر: اگر ترتیب رویدادهای یک سفارش مهم است، شماره سفارش را کلید پیام در Kafka می‌گذاریم. پس همه رویدادهای یک سفارش به یک partition می‌روند و ترتیبشان حفظ می‌شود.

سؤال پیگیری: در طرف مصرف‌کننده چه؟ سرویس انبار پیام را می‌خواند، در دیتابیس خودش می‌نویسد و یک رویداد جدید هم می‌فرستد.

جواب پیگیری:

  • همان مشکل در سمت دیگر است. پس دو کار می‌کنیم.
  • با Inbox: شناسه پیام را در یک جدول inbox در همان تراکنش تغییرات انبار ثبت می‌کنیم. پیام تکراری نادیده گرفته می‌شود.
  • با Outbox در سرویس انبار: رویداد جدید هم از راه outbox خود سرویس انبار فرستاده می‌شود، نه مستقیم.

نشانه خطر: فرض می‌کند «اگر commit شد، publish هم حتماً انجام می‌شود»، یا فکر می‌کند با یک try/catch و retry ساده مشکل حل می‌شود (کرش فرایند را پوشش نمی‌دهد).

ویرایش در GitHub

۱۷تراکنش بین سفارش، پرداخت و انبارمتوسط تا سختالگوی Saga

سؤال: یک فروشگاه آنلاین سه سرویس جدا دارد: سفارش، پرداخت و انبار. هر سرویس دیتابیس خودش را دارد و یک تیم جدا مالک آن است. در ساعت شلوغی حدود ۲۰۰ سفارش در دقیقه ثبت می‌شود. برای ثبت سفارش، باید سفارش ساخته شود، پول از کارت مشتری کم شود، و کالا در انبار رزرو شود. تیم پیشنهاد می‌دهد برای این‌که «همه یا هیچ» باشد، از یک تراکنش توزیع‌شده با Two-Phase Commit (2PC) بین سه دیتابیس استفاده کنند. نظرت چیست؟ تو چه می‌کنی؟

جواب کوتاه: در میکروسرویس‌ها 2PC معمولاً انتخاب خوبی نیست. سرویس‌ها را به هم قفل می‌کند، به در دسترس بودن همه آن‌ها وابسته است، و خیلی از دیتابیس‌ها، brokerها و API های بیرونی (مثل درگاه پرداخت) اصلاً از آن پشتیبانی نمی‌کنند. راه رایج Saga است: یک دنباله از تراکنش‌های محلی. اگر یک مرحله شکست بخورد، مرحله‌های قبلی با عملیات جبرانی (compensating action) برگردانده می‌شوند. Saga دو سبک دارد: orchestration و choreography.

چرا 2PC این‌جا مشکل دارد؟ (قدم به قدم)

  1. در 2PC یک هماهنگ‌کننده از همه شرکت‌کننده‌ها می‌پرسد «آماده‌ای؟». هر کدام قفل‌هایش را نگه می‌دارد و «بله» می‌گوید.
  2. بعد هماهنگ‌کننده می‌گوید «commit». تا این پیام نرسد، قفل‌ها باز نمی‌شوند.
  3. اگر هماهنگ‌کننده بین این دو مرحله خراب شود، شرکت‌کننده‌ها با قفل باز منتظر می‌مانند. این روی همه سفارش‌های دیگر هم اثر می‌گذارد.
  4. در دسترس بودن کل عملیات به سالم بودن هم‌زمان هر سه سرویس و هماهنگ‌کننده وابسته است.
  5. درگاه پرداخت بیرونی در تراکنش ما شرکت نمی‌کند. پس 2PC حتی از نظر فنی همه کار را پوشش نمی‌دهد.
  6. هدف میکروسرویس استقلال تیم‌ها و سرویس‌هاست. 2PC آن‌ها را دوباره به هم می‌چسباند.

راه حل: Saga

مراحل و عملیات جبرانی هر مرحله:

مرحله عملیات عملیات جبرانی
۱ ساختن سفارش با وضعیت «در انتظار» لغو سفارش
۲ رزرو کالا در انبار آزاد کردن رزرو
۳ گرفتن پول از کارت برگرداندن پول (refund)
۴ تأیید سفارش (آخرین مرحله)

اگر مرحله ۳ شکست بخورد (کارت رد شود)، رزرو آزاد می‌شود و سفارش لغو می‌شود.

ترتیب مراحل مهم است. کاری که برگرداندنش سخت یا پرهزینه است (مثل گرفتن پول) را تا جای ممکن آخر می‌گذاریم. کاری که اصلاً برگشت ندارد (مثل فرستادن کالا) بعد از همه مراحلی می‌آید که ممکن است شکست بخورند.

دو سبک Saga:

  • با orchestration: یک هماهنگ‌کننده (مثلاً در سرویس سفارش) مراحل را یکی‌یکی صدا می‌زند و وضعیت Saga را نگه می‌دارد. جریان در یک جا دیده می‌شود و دنبال کردن خطا ساده‌تر است. عیبش: هماهنگ‌کننده ممکن است منطق زیادی جمع کند.
  • با choreography: هماهنگ‌کننده نداریم. هر سرویس به رویدادها گوش می‌دهد و رویداد بعدی را منتشر می‌کند. برای جریان کوتاه ساده است و وابستگی کمی دارد. عیبش: وقتی مراحل زیاد شوند، دیدن کل جریان سخت می‌شود.
  • قاعده سرانگشتی: برای جریان کوتاه با دو سه مرحله، choreography کافی است. برای جریان طولانی یا پیچیده، orchestration روشن‌تر است.

هزینه‌هایی که باید قبول کنیم:

  • سازگاری نهایی: برای مدتی سفارش «در انتظار» است. رابط کاربری و گزارش‌ها باید این وضعیت را بشناسند.
  • بدون ایزوله‌سازی: بقیه سیستم ممکن است وضعیت‌های میانی را ببیند. مثلاً کالای رزروشده‌ای که بعداً آزاد می‌شود.
  • عملیات جبرانی هم ممکن است شکست بخورد. پس باید retry شود و idempotent باشد. ارتباط بین مراحل با پیام و Outbox انجام می‌شود (سؤال ۱۶).

سؤال پیگیری: عملیات جبرانی «برگرداندن پول» هم بعد از چند بار تلاش شکست می‌خورد. چه می‌کنی؟

جواب پیگیری:

  • فرایند Saga را در وضعیت «نیاز به بررسی» نگه می‌داریم و هشدار می‌دهیم.
  • بعضی خطاها باید به یک انسان برسند (مثلاً تیم پشتیبانی مالی). سیستم باید ابزار دیدن و حل این موارد را داشته باشد.
  • همه مراحل و نتیجه‌شان ثبت می‌شود تا بعداً بشود حساب‌ها را تطبیق داد.

نشانه خطر: 2PC بین میکروسرویس‌ها را بدون دیدن هزینه‌اش پیشنهاد می‌کند، یا Saga را بدون عملیات جبرانی و بدون فکر به وضعیت‌های میانی توضیح می‌دهد.

ویرایش در GitHub

۱۸بازنویسی کامل یک monolith ده‌سالهمتوسط تا سختسبک‌های معماریMicroservices

سؤال: یک شرکت بیمه یک سیستم monolith ده‌ساله دارد. همه کار شرکت روی آن است: ثبت مشتری، صدور بیمه‌نامه، پرداخت و خسارت. مستندات کمی دارد و چند نفر از سازندگان اصلی رفته‌اند. مدیریت این برنامه را تصویب کرده است: «یک تیم جدید در ۱۲ ماه کل سیستم را با معماری جدید از نو می‌نویسد. در این مدت سیستم قدیمی فریز می‌شود (فیچر جدید نمی‌گیرد). بعد در یک آخر هفته همه چیز را به سیستم جدید منتقل می‌کنیم.» از تو به عنوان معمار نظر می‌خواهند. نظرت چیست؟

جواب کوتاه: بازنویسی یک‌جا (big-bang rewrite) ریسک خیلی بالایی دارد: قوانین پنهان سیستم قدیمی، تخمین‌های خوش‌بینانه، فریز کردن کسب‌وکار، و یک روز انتقال پرخطر. پیشنهاد من الگوی Strangler Fig است: یک لایه مسیریابی (facade) جلوی سیستم قدیمی می‌گذاریم و قابلیت‌ها را تکه‌تکه به سیستم جدید منتقل می‌کنیم. هر تکه جداگانه به production می‌رود، تا سیستم قدیمی کم‌کم کوچک شود و کنار برود.

چرا بازنویسی یک‌جا خطرناک است؟ (قدم به قدم)

  1. ده سال باگ‌فیکس و حالت خاص در کد قدیمی است. خیلی از آن‌ها در هیچ سندی نیستند. سیستم جدید آن‌ها را نمی‌داند.
  2. تخمین ۱۲ ماه بر اساس چیزهایی است که می‌دانیم. چیزهایی که نمی‌دانیم بعداً پیدا می‌شوند. پس زمان معمولاً بیشتر می‌شود.
  3. کسب‌وکار ۱۲ ماه (یا بیشتر) منتظر نمی‌ماند. فشار برای فیچر جدید شروع می‌شود. حالا باید هر فیچر را در دو سیستم بسازیم.
  4. تا روز انتقال، سیستم جدید هیچ‌وقت با کاربر و داده واقعی کار نکرده است. پس اولین بازخورد واقعی در پرخطرترین لحظه می‌رسد.
  5. اگر روز انتقال مشکل جدی پیش بیاید، برگشتن سخت است، چون داده جدید در سیستم جدید ثبت شده است.

راه حل: Strangler Fig

  1. گذاشتن facade: یک لایه مسیریابی (مثلاً یک API gateway یا reverse proxy) جلوی سیستم قدیمی می‌گذاریم. اول همه درخواست‌ها به سیستم قدیمی می‌رود. کاربر تفاوتی نمی‌بیند.
  2. انتخاب اولین تکه: یک قابلیت با مرز روشن و ریسک کم. مثلاً «مشاهده وضعیت بیمه‌نامه» که فقط خواندنی است. نه «پرداخت خسارت» در قدم اول.
  3. ساختن و مقایسه: قابلیت را در سیستم جدید می‌سازیم. می‌شود مدتی درخواست‌ها را به هر دو فرستاد و جواب‌ها را مقایسه کرد، ولی فقط جواب سیستم قدیمی را به کاربر نشان داد.
  4. تغییر مسیر تدریجی: facade این قابلیت را کم‌کم به سیستم جدید می‌فرستد. اول درصد کمی از کاربران. اگر مشکل بود، با یک تنظیم برمی‌گردیم.
  5. حذف کد قدیمی: وقتی همه ترافیک آن قابلیت به سیستم جدید رفت، کد قدیمی آن را حذف می‌کنیم.
  6. تکرار: تکه بعدی. سیستم قدیمی هر بار کوچک‌تر می‌شود.

سخت‌ترین بخش: داده

  • معمولاً همه قابلیت‌ها یک دیتابیس مشترک دارند. پس انتقال داده سخت‌تر از انتقال کد است.
  • در دوره گذار، یک قابلیت ممکن است در یک سیستم نوشته و در دیگری خوانده شود. پس لازم است داده بین دو طرف همگام شود. مثلاً با CDC از دیتابیس قدیمی، یا با رویداد.
  • برای هر تکه روشن می‌کنیم مالک داده کیست (منبع حقیقت). در هر لحظه فقط یک طرف باید مالک یک داده باشد.
  • یک Anti-Corruption Layer کمک می‌کند مدل قدیمی و شلوغ به مدل تمیز سیستم جدید نشت نکند.

جواب به مدیریت: فیچر جدید فریز نمی‌شود. فیچرهای جدید در سیستم جدید ساخته می‌شوند. ارزش از ماه‌های اول دیده می‌شود، نه بعد از ۱۲ ماه. و ریسک هر قدم کوچک و قابل برگشت است.

سؤال پیگیری: کی Strangler Fig مناسب نیست؟

جواب پیگیری:

  • وقتی نمی‌شود درخواست‌ها را جلوی سیستم قدیمی گرفت و مسیریابی کرد. مثلاً یک برنامه دسکتاپ که مستقیم به دیتابیس وصل است.
  • وقتی سیستم آن‌قدر کوچک است که بازنویسی کاملش چند هفته طول می‌کشد. آن‌وقت هزینه facade و همگام‌سازی ارزشش را ندارد.
  • وقتی خود پلتفرم قدیمی دیگر پشتیبانی نمی‌شود و یک موعد قطعی بیرونی وجود دارد. باز هم بهتر است انتقال در چند مرحله باشد، ولی ممکن است زمان کافی برای همه قدم‌ها نباشد.

نشانه خطر: بازنویسی کامل با فریز سیستم قدیمی را بدون دیدن ریسک قبول می‌کند، یا Strangler Fig را می‌گوید ولی به مسئله داده و مالکیت داده فکر نمی‌کند.

ویرایش در GitHub

۱۹یک کلاس Customer برای همهمتوسط تا سختDomain-Driven Design

سؤال: یک شرکت فروش آنلاین چهار تیم دارد: فروش، پشتیبانی، مالی (صورت‌حساب) و ارسال. همه آن‌ها از یک کلاس مشترک به نام Customer و یک جدول customers استفاده می‌کنند. این کلاس حدود ۸۰ فیلد دارد. نمونه‌ای از فیلدها:

Customer
  id, name, email, phone
  leadSource, salesRepId, discountTier        // sales
  openTickets, supportPlan, preferredLanguage // support
  taxId, billingAddress, paymentTerms, creditLimit // billing
  shippingAddresses, deliveryNotes, courierPreference // shipping
  ... (about 60 more fields)

تیم‌ها می‌گویند تقریباً هر تغییری در این کلاس، کار یک تیم دیگر را خراب می‌کند. مثلاً تیم فروش قانون «مشتری فعال» را عوض کرد و گزارش‌های مالی اشتباه شد. هر تغییر حالا نیاز به جلسه با هر چهار تیم دارد. نظرت چیست؟ چه کار می‌کنی؟

جواب کوتاه: کلمه «مشتری» در هر بخش معنی متفاوتی دارد. یک مدل برای همه، همه را به هم قفل می‌کند. در DDD هر بخش یک Bounded Context است با مدل خودش. مالی یک «حساب صورت‌حساب» دارد، ارسال یک «گیرنده»، فروش یک «مشتری بالقوه یا فعلی». فقط یک شناسه مشترک بین آن‌ها رد و بدل می‌شود. رابطه بین context ها را در یک Context Map نشان می‌دهیم و هر مدل یک تیم مالک دارد.

چرا یک مدل مشترک مشکل می‌سازد؟ (قدم به قدم)

  1. هر تیم یک تصویر متفاوت از «مشتری» دارد. برای فروش، مشتری کسی است که شاید بخرد. برای مالی، کسی است که باید پول بدهد. برای ارسال، یک آدرس و یک نام روی بسته.
  2. وقتی همه یک کلاس دارند، یک کلمه مثل «فعال» برای هر تیم معنی دیگری دارد، ولی فقط یک فیلد برایش هست.
  3. هیچ تیمی مالک واقعی کلاس نیست. پس هر تیم تغییر خودش را می‌دهد و نمی‌داند چه کسی دیگر به آن فیلد وابسته است.
  4. هر چه سیستم بزرگ‌تر شود، کلاس بزرگ‌تر و هماهنگی گران‌تر می‌شود. سرعت همه تیم‌ها پایین می‌آید.

راه حل: Bounded Context ها

  1. پیدا کردن مرزها: با تیم‌ها و کارشناسان کسب‌وکار حرف می‌زنیم (مثلاً با کارگاه Event Storming). جاهایی که یک کلمه معنی متفاوت پیدا می‌کند، معمولاً مرز یک context است.
  2. یک مدل برای هر context: هر context فقط فیلدهای لازم خودش را دارد، با نام‌هایی که همان تیم استفاده می‌کند (Ubiquitous Language).
بخش مدل فیلدهای نمونه
فروش Prospect / Account leadSource, salesRepId, discountTier
پشتیبانی Requester supportPlan, preferredLanguage
مالی BillingAccount taxId, billingAddress, paymentTerms
ارسال Recipient shippingAddresses, deliveryNotes
  1. شناسه مشترک: همه یک شناسه مشتری مشترک دارند. اطلاعات پایه (مثل نام و ایمیل) یک مالک مشخص دارد. بقیه context ها با رویداد (مثلاً «CustomerRegistered» یا «EmailChanged») یک کپی از چیزی که لازم دارند نگه می‌دارند.
  2. نقشه context ها: در Context Map نشان می‌دهیم کدام context به کدام وابسته است و رابطه چیست. مثلاً upstream/downstream، یا یک Anti-Corruption Layer وقتی مدل یک طرف نباید به طرف دیگر نشت کند.
  3. مالکیت: هر context یک تیم مالک دارد. تغییر داخل یک context فقط تصمیم همان تیم است. فقط قرارداد بین context ها (رویدادها و API ها) نیاز به هماهنگی دارد.

انتقال تدریجی: این را یک‌جا عوض نمی‌کنیم. اول یک context با درد بیشتر (مثلاً مالی) را جدا می‌کنیم: مدل خودش، جدول‌های خودش، و همگام شدن با رویداد. بعد بقیه.

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

سؤال پیگیری: پس آیا هر Bounded Context باید یک میکروسرویس جدا باشد؟

جواب پیگیری:

  • نه لزوماً. Bounded Context یک مرز مدل و زبان است، نه یک مرز deploy.
  • می‌شود چند context را در یک Modular Monolith نگه داشت، به شرطی که مرزها در کد رعایت شوند و هر ماژول جدول‌های خودش را داشته باشد.
  • ولی معمولاً یک میکروسرویس نباید از چند context تکه بردارد. مرز خوب سرویس معمولاً با مرز یک context هم‌خوانی دارد.

نشانه خطر: راه حلش «کلاس را تمیزتر کنیم» یا «فیلدها را گروه‌بندی کنیم» است ولی همچنان یک مدل برای همه نگه می‌دارد، یا Bounded Context را با جدول دیتابیس یکی می‌داند.

ویرایش در GitHub

۲۰میکروسرویس با تیم‌های لایه‌ایمتوسط تا سختMicroservices

سؤال: یک شرکت ۳۰ برنامه‌نویس دارد که در سه تیم کار می‌کنند: تیم وب (رابط کاربری)، تیم backend، و تیم دیتابیس. یک سال پیش تصمیم گرفتند سیستم را به میکروسرویس‌های مستقل بر اساس قابلیت‌های کسب‌وکار تقسیم کنند (سفارش، کاتالوگ، پرداخت، ارسال و …). حالا ۹ سرویس دارند. ولی هنوز تقریباً هر فیچر به هر سه تیم نیاز دارد: تیم وب صفحه را می‌سازد، تیم backend تغییر سرویس را، و تیم دیتابیس تغییر schema را. release ها هنوز هماهنگ و با هم انجام می‌شوند و مدیرها می‌پرسند «پس فایده میکروسرویس کجاست؟» به نظرت چه اتفاقی افتاده است؟ چه پیشنهادی داری؟

جواب کوتاه: این قانون Conway است: ساختار سیستم، ساختار ارتباطی سازمانی که آن را می‌سازد را تکرار می‌کند. سازمان به لایه‌ها تقسیم شده (وب، backend، دیتابیس)، پس کار هم در عمل به لایه‌ها تقسیم می‌شود، هرچند کد به سرویس‌ها تقسیم شده است. راه حل مانور معکوس Conway (Inverse Conway Maneuver) است: اول تیم‌ها را به شکل معماری‌ای که می‌خواهیم دربیاوریم. یعنی تیم‌های stream-aligned که هر کدام یک یا چند قابلیت کسب‌وکار را از رابط کاربری تا دیتابیس در اختیار دارند.

چرا میکروسرویس‌ها مستقل نشدند؟ (قدم به قدم)

  1. هدف میکروسرویس این است که یک تیم بتواند تغییر خودش را بدون انتظار برای دیگران بسازد و deploy کند.
  2. این‌جا هیچ تیمی کل یک قابلیت را ندارد. هر فیچر از سه تیم می‌گذرد.
  3. هر تیم صف کار و اولویت‌های خودش را دارد. پس هر فیچر حداقل دو بار منتظر تیم دیگر می‌ماند.
  4. چون تغییرات وب، سرویس و schema به هم وابسته‌اند، باید با هم release شوند.
  5. نتیجه: کد به سرویس‌ها تقسیم شده، ولی کار و تصمیم هنوز لایه‌ای است. هزینه میکروسرویس را می‌پردازیم و استقلالش را نداریم.
  6. با گذشت زمان، کد هم به سمت ساختار تیم‌ها می‌رود. مثلاً تیم دیتابیس یک schema مشترک برای همه سرویس‌ها نگه می‌دارد، چون ساده‌ترین راه برای خودش است.

راه حل: تیم را هم‌شکل معماری کنیم

  1. تیم‌های stream-aligned: هر تیم مالک یک یا چند سرویس مرتبط است (مثلاً «سفارش و پرداخت»). داخل تیم مهارت وب، backend و دیتابیس هست. پس یک فیچر معمولی از اول تا آخر داخل یک تیم انجام می‌شود.
  2. مرز تیم‌ها با مرز context ها: مرز سرویس‌ها و تیم‌ها از Bounded Context ها می‌آید، نه از لایه‌های فنی (سؤال ۱۹).
  3. دیتابیس برای هر سرویس: هر سرویس داده خودش را دارد و تیم خودش schema را عوض می‌کند. کارشناس‌های دیتابیس هنوز لازم‌اند، ولی به عنوان مشاور و سازنده ابزار، نه دروازه‌بانِ هر تغییر.
  4. تیم پلتفرم: کارهای مشترک (CI/CD، مانیتورینگ، زیرساخت دیتابیس) را یک تیم پلتفرم به شکل سرویس سلف‌سرویس فراهم می‌کند تا تیم‌های stream-aligned منتظر او نمانند.
  5. قرارداد بین تیم‌ها: ارتباط بین سرویس‌ها از راه API ها و رویدادهای نسخه‌دار است. پس هر تیم می‌تواند جداگانه release کند.

این نام‌ها (stream-aligned، platform) از کتاب Team Topologies آمده‌اند که دو نوع دیگر هم دارد: enabling و complicated-subsystem.

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

یک گزینه دیگر: اگر سازمان نمی‌خواهد یا نمی‌تواند تیم‌ها را عوض کند، شاید میکروسرویس انتخاب درستی نیست. یک Modular Monolith با یک deploy، هزینه هماهنگی کمتری دارد.

سؤال پیگیری: تغییر ساختار تیم‌ها را چطور شروع می‌کنی، بدون این‌که همه چیز یک‌باره به هم بریزد؟

جواب پیگیری:

  • با یک تیم آزمایشی شروع می‌کنیم. یک قابلیت با مرز روشن (مثلاً کاتالوگ) و چند نفر از هر سه تیم فعلی.
  • عدد قبل و بعد را اندازه می‌گیریم: زمان از شروع کار تا production، و تعداد تیم‌هایی که هر فیچر به آن‌ها نیاز دارد.
  • اگر نتیجه خوب بود، قابلیت بعدی. همزمان مالکیت هر سرویس را روشن ثبت می‌کنیم.

نشانه خطر: مشکل را فقط فنی می‌بیند (مثلاً «ابزار deploy بهتر لازم است») و رابطه ساختار تیم و معماری را نمی‌بیند، یا راه حلش اضافه کردن جلسه‌های هماهنگی بیشتر است.

ویرایش در GitHub

۲۱کی Event Sourcing ارزشش را دارد؟سختEvent Sourcing

سؤال: شرکت یک پنل ادمین داخلی دارد. کارش ساده است: ادمین‌ها دسته‌بندی محصول، بنرها و تنظیمات سایت را می‌سازند، ویرایش و حذف می‌کنند. حدود ۲۰ ادمین از آن استفاده می‌کنند. تیم ۳ نفره است. یکی از اعضا پیشنهاد می‌دهد پنل را با Event Sourcing بسازند، چون «روش مدرن» است و در کنفرانس‌ها زیاد درباره‌اش حرف می‌زنند. نظرت چیست؟

جواب کوتاه: برای این پنل، Event Sourcing انتخاب خوبی نیست. یک CRUD ساده با یک جدول audit log کافی است. دلیلش:

  1. در Event Sourcing، حالت فعلی را ذخیره نمی‌کنیم. فقط لیست رویدادها (event) را ذخیره می‌کنیم و حالت را از روی آن‌ها می‌سازیم.
  2. این روش سود واقعی دارد، ولی هزینه‌اش هم زیاد است.
  3. این پنل هیچ‌کدام از نیازهایی را که Event Sourcing حل می‌کند ندارد. پس فقط هزینه را می‌پردازیم.

سرنخ (اگر کاندید گفت مشکلی نیست): شش ماه بعد، تیم می‌خواهد یک فیلد جدید به «بنر» اضافه کند. باید تصمیم بگیرند با رویدادهای قدیمی که این فیلد را ندارند چه کنند. صفحه لیست بنرها هم گاهی چند ثانیه داده قدیمی نشان می‌دهد. یک کاربر هم خواسته داده‌های شخصی‌اش پاک شود. حالا چه؟

تعریف Event Sourcing در یک نگاه:

  • به جای «موجودی حساب = ۷۰۰»، این را ذخیره می‌کنیم: «حساب باز شد»، «۱۰۰۰ واریز شد»، «۳۰۰ برداشت شد».
  • برای دانستن حالت فعلی، رویدادها را به ترتیب دوباره اجرا می‌کنیم (replay).
  • رویدادها هیچ وقت عوض یا پاک نمی‌شوند. فقط رویداد جدید اضافه می‌شود.

کجا Event Sourcing واقعاً مفید است؟

  • جایی که تاریخچه کامل خودش ارزش کسب‌وکار دارد. مثال: حسابداری، بانک، بیمه. باید بتوانیم بگوییم دقیقاً چه شد و چرا.
  • جایی که سؤال‌های زمانی داریم: «موجودی این حساب در ۳۱ اسفند چقدر بود؟»
  • دامنه‌های پیچیده که رفتار مهم‌تر از داده است، و رویدادها زبان مشترک تیم و کسب‌وکار هستند.
  • وقتی بعداً می‌خواهیم از همان رویدادها، گزارش یا مدل خواندن جدیدی بسازیم.

هزینه‌های Event Sourcing (قدم به قدم):

  1. نسخه‌بندی رویدادها: رویدادهای قدیمی هرگز عوض نمی‌شوند. پس وقتی شکل یک رویداد تغییر می‌کند، کد باید نسخه‌های قدیمی را هم بفهمد (مثلاً با تبدیل نسخه قدیم به جدید هنگام خواندن).
  2. مدل خواندن (Projection): برای نمایش لیست و جستجو، نمی‌شود هر بار همه رویدادها را replay کرد. پس باید جدول‌های خواندن جدا بسازیم و آن‌ها را با رویدادها به‌روز نگه داریم. این یعنی کد بیشتر و جای بیشتر برای باگ.
  3. سازگاری نهایی (Eventual Consistency): اگر Projection جدا به‌روز شود، کاربر ممکن است چند لحظه داده قدیمی ببیند.
  4. پاک کردن داده شخصی: قانون‌هایی مثل GDPR حق پاک کردن داده را می‌دهند. ولی رویدادها قرار است تغییر نکنند. راه‌هایی مثل نگه داشتن داده شخصی بیرون از رویداد یا رمزگذاری با کلیدی که بعداً پاک می‌شود (crypto-shredding) وجود دارد، ولی همه پیچیدگی اضافه‌اند.
  5. یادگیری: تیم باید الگوهای جدید یاد بگیرد. برای یک تیم ۳ نفره این زمان زیادی است.

پیشنهاد برای این پنل:

  • یک CRUD ساده با دیتابیس رابطه‌ای.
  • اگر می‌خواهیم بدانیم چه کسی چه چیزی را کی عوض کرد، یک جدول audit log کافی است: کاربر، زمان، نوع تغییر، مقدار قبل و بعد.
  • اگر روزی یک بخش واقعاً نیاز داشت، می‌شود فقط همان بخش را با Event Sourcing ساخت، نه کل سیستم.

سؤال پیگیری: فرق audit log با Event Sourcing چیست؟ چرا audit log برای این پنل کافی است؟

جواب پیگیری:

  • در audit log، منبع حقیقت هنوز جدول اصلی است. لاگ فقط یک کپی کناری است. اگر لاگ ناقص باشد، سیستم هنوز درست کار می‌کند.
  • در Event Sourcing، منبع حقیقت خود رویدادها هستند. حالت فعلی فقط نتیجه آن‌هاست.
  • برای پنل ادمین، فقط سؤال «چه کسی چه کرد؟» مهم است. این را audit log جواب می‌دهد، بدون هزینه Projection و نسخه‌بندی.

نشانه خطر: الگو را به خاطر «مدرن بودن» انتخاب می‌کند و نمی‌تواند هیچ هزینه مشخصی از Event Sourcing نام ببرد.

ویرایش در GitHub

۲۲داشبورد گزارش روی دیتابیس اصلیسختCQRS و Mediator

سؤال: یک فروشگاه آنلاین یک دیتابیس رابطه‌ای اصلی دارد. همه سفارش‌ها، پرداخت‌ها و موجودی انبار در آن نوشته می‌شود. تیم فروش یک داشبورد گزارش دارد که هر چند ثانیه به‌روز می‌شود: فروش امروز به تفکیک شهر، دسته‌بندی و کمپین. کوئری‌های داشبورد مستقیم روی همین دیتابیس اجرا می‌شوند و چند جدول بزرگ را با هم JOIN می‌کنند. حدود ۵۰ نفر از تیم فروش داشبورد را باز نگه می‌دارند. طراحی را نگاه کن. نظرت چیست؟

جواب کوتاه: خواندن سنگین گزارش و نوشتن سفارش روی یک دیتابیس با هم رقابت می‌کنند. در ساعت شلوغ، کوئری‌های داشبورد CPU، حافظه و دیسک را می‌گیرند و ثبت سفارش کند می‌شود. باید بار گزارش را از دیتابیس اصلی جدا کنیم. دو راه اصلی داریم:

  1. یک Read Replica برای داشبورد.
  2. یک مدل خواندن جدا (ایده CQRS) که با رویدادها به‌روز می‌شود.

در هر دو، داشبورد کمی از داده واقعی عقب‌تر است. برای داشبورد فروش این معمولاً قابل قبول است.

سرنخ (اگر کاندید گفت مشکلی نیست): شب‌ها همه چیز سریع است. ولی در ساعت ۸ تا ۱۰ شب، زمان ثبت سفارش از ۲۰۰ میلی‌ثانیه به چند ثانیه می‌رسد. در مانیتورینگ دیتابیس، سنگین‌ترین کوئری‌ها همه مال داشبورد هستند. گاهی هم ثبت سفارش منتظر قفل (lock) می‌ماند.

چرا این اتفاق می‌افتد؟ (قدم به قدم)

  1. هر کوئری داشبورد چند جدول بزرگ را JOIN و جمع می‌زند. این کار حافظه و دیسک زیادی می‌خواهد.
  2. با ۵۰ نفر و به‌روزرسانی هر چند ثانیه، این کوئری‌ها پشت سر هم اجرا می‌شوند.
  3. سفارش‌ها هم همان منابع را لازم دارند. پس منتظر می‌مانند.
  4. بسته به دیتابیس و سطح isolation، خواندن طولانی ممکن است با نوشتن‌ها هم تداخل قفل داشته باشد.
  5. نتیجه: کاری که پول می‌آورد (سفارش) قربانی کاری می‌شود که می‌تواند چند ثانیه صبر کند (گزارش).

راه ۱: Read Replica

  • دیتابیس یک کپی فقط خواندنی دارد که تغییرها را از اصلی می‌گیرد. داشبورد فقط به کپی وصل می‌شود.
  • مزیت: ساده است. کد گزارش تقریباً عوض نمی‌شود.
  • عیب: کپی کمی عقب‌تر است (replication lag). مدل داده هنوز برای نوشتن طراحی شده، پس JOIN ها هنوز سنگین‌اند و فقط جایشان عوض شده است.

راه ۲: مدل خواندن جدا (CQRS)

  • ایده CQRS این است که مدل نوشتن و مدل خواندن جدا باشند.
  • وقتی سفارشی ثبت می‌شود، یک رویداد منتشر می‌شود (بهتر است با الگوی Outbox تا رویداد گم نشود).
  • یک consumer رویداد را می‌گیرد و یک جدول خلاصه را به‌روز می‌کند. مثلاً «فروش امروز، شهر، دسته‌بندی، جمع مبلغ».
  • این جدول می‌تواند در یک دیتابیس دیگر باشد، حتی دیتابیسی که برای گزارش ساخته شده است.
  • مزیت: کوئری داشبورد دیگر JOIN ندارد و خیلی سریع است. دیتابیس اصلی کاملاً آزاد است.
  • عیب: کد بیشتر، صف پیام، و باید مراقب رویداد تکراری یا جاافتاده باشیم (consumer باید idempotent باشد).

کدام را انتخاب کنم؟

وضعیت پیشنهاد
مشکل فقط بار است و گزارش‌ها کم‌اند ریپلیکا، چون ساده‌ترین قدم است
گزارش‌ها روی ریپلیکا هم کندند مدل خواندن جدا با جدول‌های خلاصه

سؤال پیگیری: مدیر فروش می‌گوید «داشبورد باید دقیقاً همان عدد لحظه‌ای را نشان دهد». چه جوابی می‌دهی؟

جواب پیگیری:

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

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

ویرایش در GitHub

۲۳طراحی داده در SaaS چندمستأجرهسختطراحی سیستمامنیت

سؤال: یک محصول SaaS برای شرکت‌ها (B2B) می‌سازیم. حدود ۵۰۰۰ مشتری کوچک داریم که هر کدام چند کاربر و داده کمی دارند. ۳ مشتری خیلی بزرگ هم داریم که هر کدام به اندازه صدها مشتری کوچک داده و ترافیک دارند. دو تا از این مشتری‌های بزرگ در قراردادشان نوشته‌اند داده‌شان باید از بقیه جدا باشد. داده مشتری‌ها را چطور طراحی می‌کنی؟

جواب کوتاه: هیچ مدل واحدی برای همه بهترین نیست. پیشنهاد من یک مدل ترکیبی است:

  1. برای ۵۰۰۰ مشتری کوچک: یک دیتابیس مشترک و یک schema مشترک. هر جدول یک ستون tenant_id دارد.
  2. برای مشتری‌های بزرگ که جداسازی می‌خواهند: یک دیتابیس جدا برای هر کدام.
  3. یک کاتالوگ مرکزی می‌گوید داده هر مشتری کجاست، و برنامه بر اساس آن به دیتابیس درست وصل می‌شود.

سه مدل اصلی:

مدل مزیت عیب
اسکیمای مشترک با tenant_id ارزان، ساده برای هزاران مشتری، یک migration خطر نشت داده، همسایه پرسروصدا
اسکیمای جدا برای هر مشتری جداسازی بهتر در یک دیتابیس اجرای migration روی هزاران schema سخت است
دیتابیس جدا برای هر مشتری بیشترین جداسازی، backup و restore جدا گران، مدیریت هزاران دیتابیس سخت است

مشکل اول: همسایه پرسروصدا (Noisy Neighbor)

  1. در مدل مشترک، همه مشتری‌ها CPU، حافظه و دیسک یک دیتابیس را با هم استفاده می‌کنند.
  2. یک مشتری بزرگ یک گزارش سنگین می‌گیرد.
  3. دیتابیس شلوغ می‌شود و ۵۰۰۰ مشتری کوچک هم کند می‌شوند، با اینکه کاری نکرده‌اند.
  4. برای همین مشتری‌های خیلی بزرگ بهتر است جای خودشان را داشته باشند، حتی اگر جداسازی نخواهند.

مشکل دوم: نشت داده بین مشتری‌ها

  1. در مدل مشترک، هر کوئری باید شرط tenant_id را داشته باشد.
  2. کافی است یک برنامه‌نویس یک بار این شرط را فراموش کند.
  3. آن وقت مشتری الف داده مشتری ب را می‌بیند. برای B2B این یک حادثه جدی امنیتی و قراردادی است.

راه‌های کم کردن خطر نشت:

  • شرط tenant_id را در یک لایه مرکزی بگذاریم (مثلاً فیلتر سراسری در ORM)، نه در هر کوئری با دست.
  • از Row Level Security در دیتابیس استفاده کنیم (مثلاً در PostgreSQL). دیتابیس خودش ردیف‌های مشتری‌های دیگر را پنهان می‌کند، حتی اگر کد شرط را فراموش کند.
  • شناسه مشتری را از توکن کاربر بگیریم، نه از ورودی که کاربر می‌فرستد.
  • تست خودکار بنویسیم که با دو مشتری، مطمئن شود داده یکی به دیگری نمی‌رسد.

یک نمونه کوچک از Row Level Security در PostgreSQL:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

چرا مدل ترکیبی؟

  • برای مشتری‌های کوچک، دیتابیس جدا برای هر کدام خیلی گران است و ارزشش را ندارد.
  • برای مشتری‌های بزرگ، دیتابیس جدا هم قرارداد را راضی می‌کند و هم مشکل همسایه پرسروصدا را حل می‌کند. هزینه‌اش را هم معمولاً در قیمت قرارداد حساب می‌کنیم.
  • چون همه دیتابیس‌ها یک schema دارند، کد برنامه یکی است. فقط رشته اتصال فرق می‌کند.

سؤال پیگیری: یک مشتری کوچک بزرگ شده و حالا خودش همسایه پرسروصداست. چطور او را به دیتابیس جدا منتقل می‌کنی؟

جواب پیگیری:

  1. دیتابیس جدید را با همان schema می‌سازد.
  2. داده آن مشتری را با فیلتر tenant_id کپی می‌کند، و تغییرهای جدید را هم دنبال می‌کند تا کپی به‌روز بماند.
  3. برای چند دقیقه نوشتن آن مشتری را متوقف می‌کند و آخرین تغییرها را منتقل می‌کند.
  4. آدرس آن مشتری را در کاتالوگ مرکزی عوض می‌کند.
  5. بعد از اطمینان، داده قدیمی را از دیتابیس مشترک پاک می‌کند.

نشانه خطر: خطر فراموش کردن شرط tenant_id را نمی‌بیند، یا برای ۵۰۰۰ مشتری کوچک بدون فکر به هزینه، دیتابیس جدا پیشنهاد می‌دهد.

ویرایش در GitHub

۲۴بررسی توکن کاربر در هر سرویسسختامنیتAuthentication و Authorization

سؤال: یک سیستم ۱۵ میکروسرویس دارد. یک سرویس مرکزی احراز هویت (Auth Service) هم داریم. کاربر بعد از ورود یک توکن تصادفی می‌گیرد. هر سرویس، در هر request، توکن را برای Auth Service می‌فرستد و می‌پرسد «این توکن معتبر است؟ کاربرش کیست؟». یک request کاربر معمولاً از ۳ یا ۴ سرویس می‌گذرد. سرویس‌ها هم وقتی همدیگر را صدا می‌زنند، همان توکن کاربر را جلو می‌فرستند. طراحی را نگاه کن. نظرت چیست؟

جواب کوتاه: این طراحی دو مشکل اصلی دارد:

  1. سرویس Auth یک نقطه شکست واحد (Single Point of Failure) است. اگر کند یا خراب شود، همه سیستم از کار می‌افتد.
  2. هر request کاربر چند بار به Auth Service می‌رود. پس تأخیر و بار اضافه زیاد است.

راه معمول: توکن امضاشده (مثل JWT) با عمر کوتاه. هر سرویس امضا را خودش با کلید عمومی بررسی می‌کند و دیگر Auth Service را صدا نمی‌زند. برای ارتباط سرویس به سرویس هم هر سرویس هویت خودش را دارد.

سرنخ (اگر کاندید گفت مشکلی نیست): در یک روز شلوغ، Auth Service بیشترین بار را در کل سیستم دارد، بیشتر از سرویس سفارش. یک بار Auth Service برای ۵ دقیقه restart شد. در این ۵ دقیقه هیچ سرویسی کار نکرد، حتی صفحه‌هایی که فقط لیست محصول نشان می‌دهند.

چرا این طراحی مشکل دارد؟ (قدم به قدم)

  1. یک request کاربر از ۴ سرویس می‌گذرد. پس ۴ بار Auth Service صدا زده می‌شود.
  2. هر صدا زدن یک رفت و برگشت شبکه است. این تأخیرها روی هم جمع می‌شوند.
  3. بار Auth Service چند برابر کل ترافیک کاربران است.
  4. اگر Auth Service در دسترس نباشد، هیچ سرویسی نمی‌تواند هیچ request ای را بپذیرد.
  5. فرستادن توکن کاربر از یک سرویس به سرویس دیگر هم یعنی سرویس مقصد نمی‌داند واقعاً کدام سرویس دارد صدایش می‌زند.

راه حل:

  1. توکن امضاشده: سرویس Auth بعد از ورود، یک JWT می‌سازد و آن را با کلید خصوصی خودش امضا می‌کند. داخل توکن شناسه کاربر، نقش‌ها و زمان انقضا هست.
  2. بررسی محلی: هر سرویس کلید عمومی را یک بار می‌گیرد (مثلاً از یک آدرس JWKS) و در حافظه نگه می‌دارد. بعد خودش امضا و زمان انقضا را بررسی می‌کند. شبکه‌ای در کار نیست.
  3. عمر کوتاه: توکن دسترسی مثلاً چند دقیقه اعتبار دارد. برای گرفتن توکن جدید، از refresh token استفاده می‌شود و فقط آنجا Auth Service صدا زده می‌شود.
  4. هویت سرویس‌ها: وقتی سرویس الف سرویس ب را صدا می‌زند، خودش هم هویت دارد. دو راه رایج: mTLS (هر سرویس یک گواهی دارد) یا OAuth2 Client Credentials (هر سرویس برای خودش توکن می‌گیرد). این‌طور سرویس ب می‌داند هم کاربر کیست و هم کدام سرویس صدایش زده است.

هزینه این راه: باطل کردن توکن (Revocation)

  • با توکن امضاشده، سرویس‌ها از Auth Service نمی‌پرسند. پس اگر کاربری را مسدود کنیم، توکنش تا زمان انقضا هنوز کار می‌کند.
  • برای همین عمر توکن کوتاه است: هرچه کوتاه‌تر، خطر کمتر، ولی تمدید بیشتر.
  • برای کارهای خیلی حساس (مثل تغییر رمز یا پرداخت بزرگ) می‌شود همچنان از Auth Service پرسید، یا یک لیست کوچک توکن‌های باطل‌شده را بین سرویس‌ها پخش کرد.

سؤال پیگیری: کلید امضا لو رفته است، یا باید آن را مرتب عوض کنیم. چطور کلید را بدون قطع سرویس عوض می‌کنی؟

جواب پیگیری:

  1. کلید جدید ساخته می‌شود و کلید عمومی‌اش کنار کلید قبلی در آدرس JWKS قرار می‌گیرد. هر توکن در سرآیندش شناسه کلید (kid) را دارد.
  2. سرویس‌ها کلیدها را دوباره می‌خوانند و حالا هر دو را قبول می‌کنند.
  3. سرویس Auth شروع می‌کند توکن‌های جدید را با کلید جدید امضا کند.
  4. بعد از اینکه همه توکن‌های قدیمی منقضی شدند، کلید قدیمی حذف می‌شود.
  5. اگر کلید لو رفته باشد، کلید قدیمی را فوراً حذف می‌کنیم و قبول می‌کنیم که کاربران دوباره وارد شوند.

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

ویرایش در GitHub

۲۵تخمین ظرفیت یک اپ عکسسختطراحی سیستمبرآورد و برنامه‌ریزی

سؤال: یک اپ اشتراک عکس داریم. ۱۰ میلیون کاربر فعال روزانه (DAU) دارد. هر کاربر به طور میانگین روزی ۲ عکس آپلود می‌کند. هر عکس به طور میانگین ۲ مگابایت است. هر کاربر روزی ۵۰ عکس می‌بیند. با یک تخمین سریع روی کاغذ (back-of-the-envelope) بگو: چند request در ثانیه داریم؟ در ساعت اوج چقدر؟ در یک سال چقدر فضای ذخیره‌سازی لازم است؟ پهنای باند چقدر است؟ قدم‌ها را بلند بگو.

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

مورد میانگین اوج (حدود ۳ برابر)
آپلود حدود ۲۰۰ در ثانیه حدود ۶۰۰ در ثانیه
دیدن عکس حدود ۵۰۰۰ در ثانیه حدود ۱۵۰۰۰ در ثانیه
فضای عکس در سال حدود ۱۵ پتابایت (بدون کپی)

نکته اصلی: سیستم خواندن‌محور است (۲۵ دیدن برای هر آپلود)، و هزینه اصلی فضای ذخیره و پهنای باند دیدن عکس است، نه CPU.

قدم ۱: فرض‌ها و گرد کردن

  • یک روز ۸۶۴۰۰ ثانیه است. برای حساب ساده، آن را ۱۰۰۰۰۰ ثانیه می‌گیریم. خطایش حدود ۱۵٪ است و برای تخمین قابل قبول است.
  • ترافیک در طول روز یکنواخت نیست. فرض می‌کنیم ساعت اوج حدود ۲ تا ۳ برابر میانگین است. اینجا ۳ را می‌گیریم تا محتاط باشیم.

قدم ۲: تعداد request

  1. آپلود در روز: ۱۰ میلیون × ۲ = ۲۰ میلیون.
  2. آپلود در ثانیه: ۲۰ میلیون ÷ ۱۰۰۰۰۰ = حدود ۲۰۰. در اوج حدود ۶۰۰.
  3. دیدن در روز: ۱۰ میلیون × ۵۰ = ۵۰۰ میلیون.
  4. دیدن در ثانیه: ۵۰۰ میلیون ÷ ۱۰۰۰۰۰ = حدود ۵۰۰۰. در اوج حدود ۱۵۰۰۰.

قدم ۳: فضای ذخیره‌سازی

  1. در روز: ۲۰ میلیون × ۲ مگابایت = ۴۰ میلیون مگابایت = حدود ۴۰ ترابایت.
  2. در سال: ۴۰ × ۳۶۵ ≈ ۴۰ × ۴۰۰ = ۱۶۰۰۰ ترابایت. پس حدود ۱۵ پتابایت (حساب دقیق‌تر حدود ۱۴.۶).
  3. اگر هر فایل ۳ کپی داشته باشد (برای اطمینان)، حدود ۴۵ پتابایت می‌شود.
  4. متادیتا (کاربر، زمان، توضیح) شاید حدود ۱ کیلوبایت برای هر عکس است: ۲۰ میلیون × ۱ کیلوبایت = حدود ۲۰ گیگابایت در روز. در برابر خود عکس‌ها خیلی کوچک است.

قدم ۴: پهنای باند

  1. ورودی (آپلود): ۲۰۰ × ۲ مگابایت = حدود ۴۰۰ مگابایت در ثانیه به طور میانگین.
  2. خروجی (دیدن)، اگر عکس اصلی ۲ مگابایتی را بفرستیم: ۵۰۰۰ × ۲ مگابایت = حدود ۱۰ گیگابایت در ثانیه. در اوج حدود ۳۰ گیگابایت در ثانیه. این خیلی زیاد است.
  3. پس در عمل عکس را در اندازه‌های کوچک‌تر هم ذخیره می‌کنیم (مثلاً تصویر کوچک برای لیست). اگر عکسی که نشان می‌دهیم حدود ۲۰۰ کیلوبایت باشد، خروجی ۱۰ برابر کمتر می‌شود.

این عددها چه تصمیمی به ما می‌دهند؟

  • عکس‌ها در دیتابیس نمی‌روند. در یک Object Storage ذخیره می‌شوند و دیتابیس فقط آدرس و متادیتا را نگه می‌دارد.
  • دیدن عکس باید از CDN باشد، چون خروجی خیلی بیشتر از ورودی است و عکس‌ها بعد از آپلود تغییر نمی‌کنند. پس خیلی خوب کش می‌شوند.
  • آپلود می‌تواند مستقیم از کاربر به Object Storage برود (مثلاً با یک آدرس امضاشده موقت)، تا سرورهای ما بار فایل را نکشند.
  • ساختن اندازه‌های کوچک‌تر کار سنگینی است. پس آن را async با صف انجام می‌دهیم.

سؤال پیگیری: بعد از ۵ سال هزینه ذخیره‌سازی خیلی بالا رفته است. چه می‌کنی؟

جواب پیگیری:

  • عکس‌های قدیمی خیلی کم دیده می‌شوند. پس آن‌ها را به یک طبقه ذخیره‌سازی ارزان‌تر و کندتر منتقل می‌کنیم (cold storage).
  • نسخه اصلی را با فرمت فشرده‌تر نگه می‌داریم، اگر کیفیت قابل قبول بماند.
  • عکس‌های حذف‌شده را واقعاً پاک می‌کنیم.
  • قبل از هر کاری، اندازه می‌گیریم کدام دسته از داده بیشترین هزینه را دارد.

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

ویرایش در GitHub

۲۶برنامه رفتن به چند منطقه (Multi-Region)سختطراحی سیستمConsistency و CAP

سؤال: یک سرویس رزرو آنلاین داریم. کل سیستم (سرورها و یک دیتابیس رابطه‌ای) در یک منطقه ابری در آمریکا اجرا می‌شود. حالا ۴۰٪ کاربران در اروپا و ۲۰٪ در آسیا هستند. این کاربران می‌گویند سایت کند است. مدیر محصول می‌پرسد: «آیا باید به چند منطقه (multi-region) برویم؟». برنامه‌ات چیست؟

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

  1. اول اندازه می‌گیرم کندی از کجاست. خیلی وقت‌ها با CDN و کم کردن رفت و برگشت‌ها بخش بزرگی حل می‌شود.
  2. بعد خواندن را نزدیک کاربر می‌برم (سرور و کپی فقط خواندنی دیتابیس در اروپا و آسیا).
  3. فقط اگر لازم بود، نوشتن را هم در چند منطقه انجام می‌دهم (active-active). این سخت‌ترین قدم است، چون تداخل داده پیش می‌آید.

در کنار همه این‌ها، باید با تیم حقوقی درباره قانون‌های داده (مثل GDPR در اروپا) صحبت کنیم.

چرا فاصله کندی می‌آورد؟

  1. نور در فیبر نوری سرعت محدودی دارد. هیچ نرم‌افزاری این را عوض نمی‌کند.
  2. یک رفت و برگشت از اروپا یا آسیا تا آمریکا ده‌ها تا صدها میلی‌ثانیه طول می‌کشد.
  3. یک صفحه معمولاً چند رفت و برگشت دارد: اتصال، TLS، چند فراخوانی API.
  4. پس این تأخیرها چند برابر می‌شوند و کاربر چند ثانیه منتظر می‌ماند.

قدم ۱: کارهای ارزان

  • فایل‌های ثابت (عکس، JS، CSS) را از CDN بدهیم. این‌ها نزدیک کاربر کش می‌شوند.
  • اتصال TLS را در لبه شبکه نزدیک کاربر باز کنیم (بسیاری از CDN ها این کار را می‌کنند).
  • تعداد فراخوانی‌های API هر صفحه را کم کنیم.

قدم ۲: Active-Passive یا خواندن محلی

  • در هر منطقه سرور برنامه و یک کپی فقط خواندنی از دیتابیس داریم.
  • خواندن (جستجو، دیدن لیست) محلی و سریع است.
  • نوشتن (ثبت رزرو) هنوز به دیتابیس اصلی در آمریکا می‌رود.
  • اگر منطقه اصلی خراب شود، می‌شود یک منطقه دیگر را اصلی کرد. این یعنی برای خرابی هم آماده‌تریم.
  • هزینه: کپی‌ها کمی عقب‌اند. کاربر ممکن است بلافاصله بعد از ثبت، رزرو خودش را نبیند. یک راه ساده: بعد از نوشتن، خواندن‌های همان کاربر برای چند ثانیه از دیتابیس اصلی انجام شود.

قدم ۳: Active-Active (نوشتن در چند منطقه)

  • هر منطقه می‌تواند بنویسد. پس نوشتن هم سریع است.
  • مشکل اصلی تداخل است: دو نفر در دو منطقه، در یک لحظه، آخرین صندلی را رزرو می‌کنند.
  • راه‌ها:
    • هر داده یک «منطقه خانه» دارد. مثلاً هر کاربر فقط در منطقه خودش نوشته می‌شود. این ساده‌ترین راه است.
    • برای منابع مشترک (مثل صندلی) نوشتن فقط در یک جا انجام شود، حتی اگر کندتر باشد.
    • قانون حل تداخل مثل «آخرین نوشتن برنده است» برای رزرو خطرناک است، چون داده گم می‌شود.

قانون‌های داده (Data Residency):

  • قانون GDPR برای انتقال داده شخصی شهروندان اتحادیه اروپا به بیرون از اروپا قانون‌های سختی دارد.
  • بعضی کشورها هم قانون دارند که داده باید داخل کشور بماند.
  • پس شاید لازم باشد داده کاربران اروپایی در اروپا بماند. این تصمیم فنی نیست. باید با تیم حقوقی گرفته شود. مدل «منطقه خانه» این کار را آسان‌تر می‌کند.

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

سؤال پیگیری: رفتی به حالت خواندن محلی. چطور می‌فهمی کار کرده است؟

جواب پیگیری:

  • قبل و بعد، زمان پاسخ را به تفکیک منطقه و با صدک‌ها (مثل p95) اندازه می‌گیریم، نه فقط میانگین کل.
  • تأخیر کپی دیتابیس (replication lag) را هم در هر منطقه مانیتور می‌کنیم.
  • عدد کسب‌وکار را هم نگاه می‌کنیم: مثلاً نرخ تکمیل رزرو در اروپا و آسیا.

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

ویرایش در GitHub

۲۷طراحی سرویس کوتاه‌کننده لینکطراحیضریب ×۲طراحی سیستمالگوهای Caching

سؤال: یک سرویس کوتاه‌کننده لینک طراحی کن. کاربر یک آدرس بلند می‌دهد و یک آدرس کوتاه می‌گیرد، مثل این:

https://sho.rt/aZ3kQ9x

هر کس آدرس کوتاه را باز کند، به آدرس اصلی فرستاده می‌شود (redirect). نیازها:

  • ماهی ۱۰۰ میلیون لینک جدید.
  • خواندن خیلی بیشتر از نوشتن است: حدود ۱۰۰ بار باز کردن برای هر لینک.
  • لینک‌ها ۵ سال نگه داشته می‌شوند.
  • صاحب لینک می‌خواهد آمار کلیک را ببیند.

جواب کوتاه: یک سیستم خواندن‌محور است. قلب طراحی سه چیز است:

  1. ساختن کلید کوتاه و یکتا بدون تداخل.
  2. یک جدول ساده کلید به آدرس، با کش جلوی آن برای redirect سریع.
  3. ثبت کلیک به صورت async تا redirect کند نشود.

قدم ۱: نیازها و عددها

  1. نوشتن: ۱۰۰ میلیون در ماه ≈ ۳.۳ میلیون در روز. با گرفتن ۱۰۰۰۰۰ ثانیه برای یک روز، حدود ۴۰ نوشتن در ثانیه.
  2. خواندن: ۱۰۰ برابر، یعنی حدود ۴۰۰۰ در ثانیه. در اوج شاید ۳ برابر، حدود ۱۲۰۰۰.
  3. تعداد کل لینک در ۵ سال: ۱۰۰ میلیون × ۶۰ ماه = ۶ میلیارد.
  4. اگر هر رکورد حدود ۵۰۰ بایت باشد، کل داده حدود ۳ ترابایت است. زیاد نیست.

قدم ۲: طول کلید

  • با حروف کوچک، بزرگ و عدد (base62)، ۶۲ حالت برای هر حرف داریم.
  • با ۶ حرف حدود ۵۶ میلیارد حالت داریم و با ۷ حرف حدود ۳.۵ هزار میلیارد.
  • برای ۶ میلیارد لینک، ۷ حرف فضای خالی کافی دارد.

قدم ۳: ساختن کلید (بخش سخت)

روش مزیت عیب
هش آدرس و برداشتن ۷ حرف اول ساده تداخل ممکن است؛ باید چک و دوباره تلاش کرد
شمارنده سراسری و تبدیل به base62 بدون تداخل شمارنده مرکزی گلوگاه است؛ کلیدها قابل حدس‌اند
بازه‌های از پیش رزروشده بدون تداخل، بدون رفت و برگشت برای هر لینک کمی پیچیدگی؛ اگر سرور بمیرد بخشی از بازه هدر می‌رود
  • پیشنهاد من بازه است: هر سرور مثلاً ۱۰۰۰۰ شماره را یک جا از یک سرویس مرکزی می‌گیرد و خودش آن‌ها را مصرف می‌کند.
  • اگر نمی‌خواهیم کلیدها پشت سر هم و قابل حدس باشند، عدد را قبل از تبدیل با یک تابع برگشت‌پذیر به هم می‌ریزیم.

قدم ۴: مدل داده

  • جدول اصلی: کلید کوتاه (کلید اصلی)، آدرس بلند، صاحب، زمان ساخت، زمان انقضا.
  • دسترسی فقط با کلید است و JOIN نداریم. پس یک key-value store یا یک دیتابیس رابطه‌ای با partition بر اساس کلید، هر دو خوب‌اند.

قدم ۵: redirect و کش

  1. درخواست به سرور می‌رسد. اول کش (مثل Redis) را نگاه می‌کند.
  2. اگر نبود، از دیتابیس می‌خواند و در کش می‌گذارد.
  3. لینک‌ها بعد از ساخت تقریباً عوض نمی‌شوند. پس کش خیلی خوب کار می‌کند. معمولاً بخش کوچکی از لینک‌ها بیشتر کلیک‌ها را می‌گیرند.

کد ۳۰۱ یا ۳۰۲؟

  • با ۳۰۱ (دائمی)، مرورگر جواب را کش می‌کند. دفعه بعد اصلاً به سرور ما نمی‌آید. بار کمتر، ولی کلیک‌های بعدی را نمی‌شمریم.
  • با ۳۰۲ (موقت)، هر بار به سرور ما می‌آید. بار بیشتر، ولی آمار دقیق‌تر است و می‌شود لینک را بعداً عوض یا غیرفعال کرد.
  • چون آمار کلیک یک نیاز است، ۳۰۲ را انتخاب می‌کنم.

قدم ۶: آمار کلیک

  • سرور در مسیر redirect فقط یک رویداد کلیک را در صف (مثل Kafka) می‌اندازد و فوراً جواب می‌دهد.
  • یک consumer کلیک‌ها را جمع می‌زند (مثلاً تعداد در ساعت برای هر لینک) و در یک دیتابیس جدا برای گزارش می‌نویسد.
  • اگر سیستم آمار کند شود، redirect کند نمی‌شود.

قدم ۷: مقیاس‌پذیری

  • سرورهای برنامه stateless هستند و پشت load balancer افقی زیاد می‌شوند.
  • دیتابیس بر اساس کلید کوتاه partition می‌شود. پخش بار یکنواخت است.
  • کش چند نود دارد.

سؤال پیگیری: یک نفر از سرویس برای پخش لینک‌های فیشینگ استفاده می‌کند. در طراحی چه چیزی اضافه می‌کنی؟

جواب پیگیری:

  • محدودیت نرخ (rate limit) برای ساختن لینک، به ازای هر کاربر و هر IP.
  • بررسی آدرس مقصد با لیست آدرس‌های مخرب شناخته‌شده، هنگام ساخت.
  • امکان غیرفعال کردن لینک. این هم یک دلیل دیگر برای ۳۰۲ است، چون ۳۰۱ در مرورگر کاربر کش شده و دیگر دست ما نیست.

نشانه خطر: درباره عددها حرفی نمی‌زند، یا تداخل کلید را نمی‌بیند، یا فرق ۳۰۱ و ۳۰۲ برای آمار را نمی‌داند.

ویرایش در GitHub

۲۸طراحی سیستم اعلان (ایمیل، پیامک، پوش)طراحیضریب ×۲طراحی سیستمKafkaIdempotency

سؤال: یک سیستم اعلان (notification) مرکزی طراحی کن که بقیه سرویس‌های شرکت از آن استفاده کنند. نیازها:

  • سه کانال: ایمیل، پیامک (SMS) و پوش موبایل.
  • روزی ۵۰ میلیون پیام. بخش بزرگی از آن کمپین‌های تبلیغاتی است که یک‌جا فرستاده می‌شوند.
  • بعضی پیام‌ها فوری‌اند، مثل کد یک‌بارمصرف ورود (OTP).
  • کاربر انتخاب می‌کند چه پیام‌هایی را از چه کانالی بگیرد.
  • اگر ارسال شکست خورد، دوباره تلاش شود.
  • تا جای ممکن، پیام تکراری فرستاده نشود.
  • سرویس‌دهنده‌های بیرونی (ارسال ایمیل و پیامک) محدودیت نرخ دارند.

جواب کوتاه: پیام‌ها را یک‌جا نمی‌فرستیم. یک API پیام را می‌گیرد و در صف می‌گذارد. بعد:

  1. برای هر کانال یک صف جدا داریم، تا کندی یکی روی بقیه اثر نگذارد.
  2. پیام‌های فوری و تبلیغاتی صف‌های جدا دارند، تا OTP پشت یک کمپین بزرگ نماند.
  3. هر پیام یک کلید idempotency دارد تا تکراری‌ها حذف شوند.
  4. کارگرهای هر کانال با سرعتی که سرویس‌دهنده اجازه می‌دهد می‌فرستند، و شکست‌ها را با تأخیر رو به افزایش دوباره امتحان می‌کنند.

قدم ۱: عددها

  1. ۵۰ میلیون در روز ÷ ۱۰۰۰۰۰ ثانیه = حدود ۵۰۰ پیام در ثانیه به طور میانگین.
  2. ولی کمپین‌ها ناگهانی‌اند. شاید چند میلیون پیام در چند دقیقه وارد شود.
  3. پس سیستم باید بار ناگهانی را در صف نگه دارد و آرام بفرستد. این دلیل اصلی استفاده از صف است.

قدم ۲: بخش‌های اصلی

  1. سرویس API: درخواست را می‌گیرد (کاربر، نوع پیام، داده، کلید idempotency). فقط اعتبارسنجی می‌کند و در صف ورودی می‌گذارد. سریع جواب ۲۰۲ می‌دهد.
  2. مسیریاب (Router): تنظیمات کاربر را می‌خواند. مثلاً کاربر پیامک تبلیغاتی نمی‌خواهد. بعد قالب را پر می‌کند و برای هر کانال لازم، یک پیام در صف آن کانال می‌گذارد.
  3. صف‌ها: مثلاً با Kafka یا RabbitMQ. برای هر کانال، یک صف فوری و یک صف عادی.
  4. کارگرهای کانال: از صف می‌خوانند و به سرویس‌دهنده بیرونی می‌فرستند.
  5. جدول وضعیت: برای هر پیام: در صف، فرستاده‌شده، شکست‌خورده، تحویل‌شده (اگر سرویس‌دهنده خبر بدهد).

قدم ۳: مدل داده

  • تنظیمات کاربر: کاربر، نوع پیام، کانال، فعال یا غیرفعال، ساعت سکوت.
  • پیام: شناسه، کلید idempotency (یکتا)، کاربر، کانال، اولویت، وضعیت، تعداد تلاش، زمان‌ها.
  • دستگاه‌ها: توکن پوش هر دستگاه کاربر.

قدم ۴: بخش‌های سخت

الف) پیام تکراری:

  1. صف‌ها معمولاً «حداقل یک بار» (at-least-once) تحویل می‌دهند. پس یک پیام ممکن است دو بار به کارگر برسد.
  2. کارگر قبل از ارسال، کلید idempotency را در جدول وضعیت چک می‌کند. اگر «فرستاده‌شده» بود، کاری نمی‌کند.
  3. ولی یک حالت سخت می‌ماند: ما فرستادیم، سرویس‌دهنده پیام را فرستاد، ولی جوابش به ما نرسید (timeout). ما نمی‌دانیم پیام رفته یا نه.
  4. اگر سرویس‌دهنده کلید idempotency بپذیرد، همان کلید را می‌فرستیم. اگر نه، باید انتخاب کنیم: دوباره بفرستیم (شاید تکراری) یا نفرستیم (شاید گم شود). برای OTP معمولاً دوباره فرستادن بهتر است. برای همین می‌گوییم «تا جای ممکن».

ب) محدودیت نرخ سرویس‌دهنده:

  • کارگرها با یک token bucket مشترک (مثلاً در Redis) سرعت ارسال را زیر سقف سرویس‌دهنده نگه می‌دارند.
  • اگر سرویس‌دهنده کد «درخواست زیاد» (مثل ۴۲۹) برگرداند، کارگر کمی صبر می‌کند و سرعت را کم می‌کند.

ج) تلاش دوباره:

  • خطاهای موقت (timeout، ۵۰۰، ۴۲۹) با تأخیر نمایی و کمی تصادف (jitter) دوباره امتحان می‌شوند.
  • خطاهای دائمی (شماره نامعتبر، توکن پوش منقضی) دوباره امتحان نمی‌شوند. توکن منقضی را پاک می‌کنیم.
  • بعد از چند تلاش، پیام به صف پیام‌های مرده (DLQ) می‌رود تا بررسی شود.

د) اولویت: کارگرهای صف فوری جدا هستند و همیشه ظرفیت خالی دارند. یک کمپین ۵ میلیونی نباید OTP را چند دقیقه عقب بیندازد.

قدم ۵: مقیاس‌پذیری

  • هر بخش stateless است و جدا scale می‌شود. صف ایمیل شلوغ است؟ کارگر ایمیل بیشتر.
  • برای هر کانال دو سرویس‌دهنده داشته باشیم. اگر یکی قطع شد، به دیگری برویم.

سؤال پیگیری: تیم مارکتینگ می‌خواهد یک کمپین را برای ۱۰ میلیون کاربر «همین حالا» بفرستد. چه اتفاقی می‌افتد و چطور مدیریتش می‌کنی؟

جواب پیگیری:

  • اگر سقف سرویس‌دهنده مثلاً چند صد پیام در ثانیه باشد، ۱۰ میلیون پیام چند ساعت طول می‌کشد. این ریاضی است و با کد عوض نمی‌شود. باید این را به تیم مارکتینگ روشن گفت.
  • کمپین در صف عادی می‌رود و آرام فرستاده می‌شود. صف فوری دست نمی‌خورد.
  • ساعت سکوت کاربر را رعایت می‌کنیم. پیام کاربرانی که الان شب است، برای صبح زمان‌بندی می‌شود.

نشانه خطر: همه کانال‌ها و همه اولویت‌ها را در یک صف می‌گذارد، یا ادعا می‌کند «دقیقاً یک بار» با سرویس‌دهنده بیرونی همیشه ممکن است.

ویرایش در GitHub

۲۹طراحی یک پیام‌رسان سادهطراحیضریب ×۲طراحی سیستم

سؤال: یک پیام‌رسان ساده طراحی کن (شبیه یک WhatsApp ساده). نیازها:

  • چت دونفره و گروه کوچک (حداکثر حدود ۱۰۰ نفر).
  • ۲۰ میلیون کاربر فعال روزانه.
  • وضعیت آنلاین بودن (online / last seen).
  • پیام‌ها در هر گفت‌وگو باید به ترتیب درست نشان داده شوند.
  • رسید تحویل و خوانده شدن (مثل تیک‌ها).
  • اگر گیرنده آفلاین است، پیام بعداً به او برسد.

جواب کوتاه:

  1. هر کاربر آنلاین یک اتصال WebSocket باز به یکی از سرورهای اتصال (gateway) دارد.
  2. یک رجیستری (مثلاً در Redis) می‌گوید هر کاربر الان به کدام gateway وصل است.
  3. پیام اول ذخیره می‌شود، بعد برای گیرنده فرستاده می‌شود. پس آفلاین بودن مشکلی نیست.
  4. ترتیب را سرور با یک شماره ترتیبی برای هر گفت‌وگو تعیین می‌کند، نه ساعت گوشی.

قدم ۱: عددها (با فرض)

  1. فرض: هر کاربر روزی ۴۰ پیام می‌فرستد. پس ۲۰ میلیون × ۴۰ = ۸۰۰ میلیون پیام در روز.
  2. تقسیم بر ۱۰۰۰۰۰ ثانیه: حدود ۸۰۰۰ پیام در ثانیه. در اوج شاید ۳ برابر.
  3. فرض: در ساعت اوج حدود یک چهارم کاربران آنلاین‌اند. یعنی حدود ۵ میلیون اتصال همزمان.
  4. اگر هر gateway حدود ۵۰۰۰۰ اتصال نگه دارد (این عدد را باید با تست بار اندازه گرفت)، حدود ۱۰۰ سرور اتصال لازم داریم.

قدم ۲: بخش‌های اصلی

  1. سرورهای اتصال (Gateway): اتصال WebSocket را نگه می‌دارند. منطق کسب‌وکار ندارند.
  2. رجیستری اتصال: کاربر به کدام gateway وصل است.
  3. سرویس چت: پیام را می‌گیرد، شماره ترتیبی می‌دهد، ذخیره می‌کند و برای پخش می‌فرستد.
  4. ذخیره‌ساز پیام: پیام‌ها را بر اساس گفت‌وگو نگه می‌دارد.
  5. سرویس پوش: برای کاربری که آفلاین است، اعلان پوش می‌فرستد.

قدم ۳: مدل داده

  • پیام: شناسه گفت‌وگو، شماره ترتیبی، شناسه پیام (ساخته‌شده توسط گوشی فرستنده)، فرستنده، متن، زمان.
  • کلید partition شناسه گفت‌وگو است و داخل آن پیام‌ها بر اساس شماره ترتیبی مرتب‌اند. این الگو برای یک دیتابیس wide-column (مثل Cassandra) مناسب است، چون تقریباً همیشه «آخرین پیام‌های یک گفت‌وگو» را می‌خوانیم.
  • عضویت: گفت‌وگو، کاربر، آخرین شماره تحویل‌شده، آخرین شماره خوانده‌شده.

قدم ۴: جریان یک پیام (قدم به قدم)

  1. گوشی علی پیام را با یک شناسه یکتا به gateway می‌فرستد.
  2. سرویس چت شماره ترتیبی بعدی آن گفت‌وگو را می‌دهد و پیام را ذخیره می‌کند.
  3. به علی جواب «ذخیره شد» می‌دهد (یک تیک).
  4. از رجیستری می‌پرسد سارا کجا وصل است و پیام را به gateway او می‌فرستد.
  5. گوشی سارا دریافت را تأیید می‌کند. سرور آخرین شماره تحویل‌شده را به‌روز می‌کند و به علی خبر می‌دهد (دو تیک).
  6. وقتی سارا چت را باز می‌کند، رسید «خوانده شد» به همین شکل برمی‌گردد.

قدم ۵: بخش‌های سخت

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

ب) پیام تکراری: اگر اتصال قطع شود، گوشی پیام را دوباره می‌فرستد. چون شناسه پیام را گوشی ساخته، سرور تکراری را تشخیص می‌دهد و دوباره ذخیره نمی‌کند.

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

د) پخش در گروه (fan-out): در گروه ۱۰۰ نفره، یک پیام یک بار ذخیره می‌شود و به ۱۰۰ عضو فرستاده می‌شود. برای گروه کوچک این مشکلی نیست. برای کانال‌های میلیونی طراحی دیگری لازم است، که اینجا خارج از نیاز است.

ه) وضعیت آنلاین: گوشی هر چند ثانیه یک heartbeat می‌فرستد. سرور «آخرین بازدید» را با زمان انقضا در Redis نگه می‌دارد. تغییر وضعیت را به همه مخاطب‌ها پخش نمی‌کنیم، چون میلیون‌ها پیام بی‌فایده می‌سازد. فقط وقتی کسی صفحه یک چت را باز کرده، وضعیت طرف مقابل را می‌گیرد.

قدم ۶: مقیاس‌پذیری

  • سرورهای اتصال افقی زیاد می‌شوند. اگر یکی بمیرد، گوشی‌ها دوباره به یک سرور دیگر وصل می‌شوند و پیام‌های جاافتاده را با شماره ترتیبی می‌گیرند.
  • ذخیره‌ساز بر اساس شناسه گفت‌وگو partition می‌شود.

سؤال پیگیری: کاربر با دو دستگاه (گوشی و لپ‌تاپ) وارد شده است. چه چیزی در طراحی عوض می‌شود؟

جواب پیگیری:

  • رجیستری به جای «کاربر به یک gateway»، باید «کاربر به چند دستگاه، هر کدام روی یک gateway» را نگه دارد.
  • پیام به همه دستگاه‌های گیرنده فرستاده می‌شود، و به بقیه دستگاه‌های خود فرستنده هم، تا همه همگام باشند.
  • هر دستگاه آخرین شماره خودش را دارد. پس همگام‌سازی برای هر دستگاه جداگانه کار می‌کند.

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

ویرایش در GitHub

۳۰طراحی بخش پرداخت فروشگاه آنلاینطراحیضریب ×۲طراحی سیستمIdempotencyالگوی Saga

سؤال: بخش پرداخت یک فروشگاه آنلاین را طراحی کن. خود پرداخت را یک سرویس‌دهنده بیرونی (Payment Provider) انجام می‌دهد. ما API آن را صدا می‌زنیم و او نتیجه را با یک webhook هم به ما خبر می‌دهد. نیازها:

  • روزی حدود ۱۰۰ هزار سفارش.
  • هیچ وقت از مشتری دو بار پول کم نشود.
  • سرویس‌دهنده گاهی دیر جواب می‌دهد یا timeout می‌شود.
  • وب‌هوک‌ها ممکن است تکراری، دیر یا با ترتیب اشتباه برسند.
  • آخر هر روز، پول ما باید با گزارش سرویس‌دهنده بخواند.

جواب کوتاه: عدد ترافیک کم است. سختی این سیستم درستی است، نه مقیاس. پنج ابزار اصلی:

  1. کلید idempotency برای هر تلاش پرداخت، که به سرویس‌دهنده هم فرستاده می‌شود.
  2. ماشین حالت (State Machine) روشن برای پرداخت، با حالت «نامعلوم».
  3. وب‌هوک idempotent که امضایش بررسی می‌شود.
  4. الگوی Outbox برای خبر دادن به بقیه سیستم.
  5. کار تطبیق (Reconciliation) روزانه و یک دفتر کل (Ledger) ساده.

قدم ۱: ماشین حالت پرداخت

حالت معنی
ساخته‌شده (CREATED) رکورد پرداخت ساخته شد، هنوز به سرویس‌دهنده نرفته
در جریان (PENDING) درخواست فرستاده شد، منتظر نتیجه
نامعلوم (UNKNOWN) جواب نیامد (timeout)؛ نمی‌دانیم پول کم شده یا نه
موفق (SUCCEEDED) پول گرفته شد
ناموفق (FAILED) پول گرفته نشد
  • فقط حرکت رو به جلو مجاز است. مثلاً از «موفق» نمی‌شود به «در جریان» برگشت.
  • این قانون مشکل وب‌هوک‌های با ترتیب اشتباه را هم حل می‌کند: وب‌هوک قدیمی‌تر نمی‌تواند حالت را عقب ببرد.

قدم ۲: جلوگیری از پرداخت دوباره (قدم به قدم)

  1. وقتی مشتری دکمه پرداخت را می‌زند، یک رکورد پرداخت با یک کلید idempotency یکتا می‌سازیم. روی «سفارش» یک قید یکتا (unique) داریم که فقط یک پرداخت فعال داشته باشد.
  2. درخواست را با همین کلید به سرویس‌دهنده می‌فرستیم. بسیاری از سرویس‌دهنده‌ها کلید idempotency را می‌پذیرند و درخواست تکراری با همان کلید را دوباره اجرا نمی‌کنند.
  3. اگر timeout شد، حالت را «نامعلوم» می‌گذاریم. بلافاصله با یک کلید جدید دوباره امتحان نمی‌کنیم. این دقیقاً راه دو بار گرفتن پول است.
  4. برای حالت نامعلوم: یا با همان کلید دوباره می‌فرستیم، یا وضعیت را از API سرویس‌دهنده می‌پرسیم، یا منتظر وب‌هوک می‌مانیم.
  5. به مشتری هم صادقانه می‌گوییم «پرداخت در حال بررسی است»، نه «ناموفق»، تا دوباره پرداخت نکند.

قدم ۳: وب‌هوک

  1. اول امضای وب‌هوک را بررسی می‌کنیم. هر کسی می‌تواند به آدرس ما درخواست بفرستد.
  2. شناسه رویداد را در یک جدول ذخیره می‌کنیم. اگر قبلاً دیده‌ایم، فقط جواب موفق می‌دهیم و کاری نمی‌کنیم.
  3. حالت پرداخت را طبق ماشین حالت جلو می‌بریم.
  4. سریع جواب ۲۰۰ می‌دهیم. کار سنگین را بعداً و async انجام می‌دهیم، وگرنه سرویس‌دهنده فکر می‌کند شکست خورده و دوباره می‌فرستد.

قدم ۴: الگوی Outbox

  1. وقتی پرداخت موفق شد، سفارش باید «پرداخت‌شده» شود و انبار و ارسال هم باید خبردار شوند.
  2. اگر اول دیتابیس را به‌روز کنیم و بعد پیام بفرستیم، ممکن است وسط کار سرویس بمیرد و پیام هیچ وقت نرود.
  3. پس در یک تراکنش، هم حالت پرداخت را عوض می‌کنیم و هم یک ردیف در جدول outbox می‌نویسیم.
  4. یک فرایند جدا ردیف‌های outbox را می‌خواند و منتشر می‌کند. گیرنده‌ها باید idempotent باشند، چون پیام ممکن است دو بار برسد.

قدم ۵: دفتر کل (Ledger)

  • هر جابه‌جایی پول یک ردیف جدید است. هیچ ردیفی ویرایش یا پاک نمی‌شود. بازپرداخت یک ردیف جدید است، نه پاک کردن ردیف قبلی.
  • در روش دوطرفه (double-entry)، هر تراکنش حداقل دو ردیف دارد که جمعشان صفر است. مثلاً «حساب مشتری» بدهکار و «حساب فروش» بستانکار.
  • مبلغ را به صورت عدد صحیح در کوچک‌ترین واحد پول (مثلاً سنت) همراه با نوع ارز ذخیره می‌کنیم، نه عدد اعشاری، تا خطای گرد کردن نداشته باشیم.

قدم ۶: تطبیق روزانه (Reconciliation)

  1. هر روز گزارش تراکنش‌ها را از سرویس‌دهنده می‌گیریم.
  2. آن را ردیف به ردیف با دفتر کل خودمان مقایسه می‌کنیم.
  3. هر اختلاف (پول گرفته شده ولی پیش ما ناموفق است، یا برعکس) یک مورد برای بررسی می‌سازد.
  4. حالت‌های «نامعلوم» قدیمی هم اینجا حل می‌شوند.
  5. این آخرین تور ایمنی است: حتی اگر وب‌هوکی گم شود، این کار اشتباه را پیدا می‌کند.

سؤال پیگیری: تطبیق نشان می‌دهد از یک مشتری پول گرفته شده، ولی سفارش او در سیستم ما «ناموفق» است و مشتری دوباره پرداخت کرده است. چه می‌کنی؟

جواب پیگیری:

  • اول به مشتری: پرداخت اضافه را بازپرداخت می‌کنیم، با یک ردیف جدید در دفتر کل.
  • بعد علت را پیدا می‌کنیم: احتمالاً یک timeout به اشتباه «ناموفق» ثبت شده، نه «نامعلوم»، و مشتری با کلید جدید دوباره پرداخت کرده است.
  • کد را درست می‌کنیم که timeout همیشه به «نامعلوم» برود، و یک هشدار برای حالت‌های نامعلوم قدیمی می‌گذاریم.

نشانه خطر: بعد از timeout، پرداخت را با یک درخواست جدید دوباره امتحان می‌کند، یا وب‌هوک را بدون بررسی امضا و بدون حذف تکراری‌ها قبول می‌کند، یا مبلغ را با عدد اعشاری ذخیره می‌کند.

ویرایش در GitHub