پست

طراحی سیستم Notification

طراحی پلتفرم ارسال نوتیفیکیشن از طریق Push و ایمیل و SMS با تحویل مطمئن و اولویت‌بندی و جلوگیری از ارسال تکراری

طراحی سیستم Notification

درک مسئله

سیستم Notification چیست؟ یک پلتفرم داخلی است که تیم‌های دیگر شرکت برای ارسال پیام به کاربران از طریق کانال‌هایی مثل Push، ایمیل و SMS از آن استفاده می‌کنند. سرویس‌های محصول یک پیام و یک گیرنده به آن می‌دهند و پلتفرم تحویل واقعی را بر عهده می‌گیرد.

این یک مسئله‌ی پلتفرمی است، نه محصول رو به کاربر، پس مشتریان ما تیم‌های مهندسی دیگر داخل شرکت هستند. سیستم‌های Notification برای فرستادن پیام به کاربران استفاده می‌شوند، پس مشتریان داخلی مثل سرویس Auth هستند که رمز یک‌بارمصرف (OTP) می‌فرستد، یا تیم بازاریابی که تبلیغ را هم‌زمان برای یک میلیون کاربر می‌فرستد.

نیازمندی‌های عملکردی

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

نیازمندی‌های اصلی:

  • سرویس‌های بالادستی (Upstream) بتوانند نوتیفیکیشن را از طریق Push، ایمیل یا SMS برای یک کاربر بفرستند، فوری یا در آینده.
  • سرویس‌های بالادستی بتوانند Campaign بفرستند، یعنی همان پیام برای یک Segment کامل از کاربران، فوری یا زمان‌بندی‌شده.
  • کاربران بتوانند ترجیحات نوتیفیکیشن را تنظیم کنند (انصراف از کانال و ساعات سکوت).

خارج از محدوده:

  • مدیریت Template و تولید محتوای غنی.
  • داشبوردهای تحلیل تحویل (نرخ باز شدن، ردیابی کلیک).
  • Feed نوتیفیکیشن داخل اپ و شمارنده‌ی Badge.
  • محدود کردن فرکانس (Frequency Capping) بین انواع نوتیفیکیشن.

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

نیازمندی‌های غیرعملکردی

پیش از نوشتن این‌ها به عددی نیاز داریم که بر اساس آن طراحی کنیم. این سؤال پلتفرمی است، پس اینکه سیستم زیر بار چطور تاب می‌آورد بیشتر چیزی است که از ما می‌پرسند.

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

اما نوتیفیکیشن‌ها یکنواخت پخش نمی‌شوند. اگر یکی از مشتریان ما Campaign را برای ساعت ۹ صبح زمان‌بندی کند، انتظار دارد بیشترش همان زمان تحویل شود، نه اینکه در طول روز کش بیاید. تحویل ۱ میلیون نوتیفیکیشن ظرف ۵ دقیقه یعنی بیش از ۳٬۰۰۰ در ثانیه، و این فقط یک Campaign است.

پس برای جهش‌های ۵٬۰۰۰ نوتیفیکیشن در ثانیه، یا حدود ۵۰ برابر نرخ پایدارمان، طراحی می‌کنیم. این به‌اندازه‌ای بزرگ است که باید چراغ‌های هشدار در ذهنتان درباره‌ی نحوه‌ی مدیریتش روشن شود و همین طراحی ما را متمرکز می‌کند.

نیازمندی‌های اصلی:

  • سیستم باید تحویل At-least-once را با Deduplication بر پایه‌ی بهترین تلاش (Best-effort) تضمین کند. یعنی ترجیح می‌دهیم نوتیفیکیشنی به‌طور تصادفی دوبار برود تا اینکه هرگز نرود.
  • سیستم باید جهش‌های حدود ۵٬۰۰۰ نوتیفیکیشن در ثانیه را مدیریت کند.
  • سیستم باید نوتیفیکیشن‌های با اولویت بالا مثل OTP و هشدارهای امنیتی را ظرف ۵ ثانیه از پذیرش تحویل دهد، حتی در زمان جهش.

خارج از محدوده:

  • ترتیب سخت‌گیرانه‌ی نوتیفیکیشن‌ها بین کانال‌ها.
  • گردش‌کارهای Compliance پیرامون رضایت و مقررات اسپم.
  • پایش، هشداردهی و CI/CD.

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

این نیازمندی‌های غیرعملکردی دو کلاس ترافیک کاملاً متفاوت را توصیف می‌کنند.

نوتیفیکیشن‌های اولویت بالا (OTP، هشدارهای امنیتی) حجم کم دارند ولی به Latency حساس‌اند. اگر می‌خواهید وارد حسابتان شوید و کد ورود ۲۰ دقیقه بعد برسد، احتمالاً فکر می‌کنید وب‌سایت کار نمی‌کند. در مقابل، نوتیفیکیشن‌های استاندارد (تبلیغات، خلاصه‌ها) در جهش‌های عظیم می‌رسند، ولی هیچ‌کس اهمیت نمی‌دهد ده دقیقه دیرتر برسند.

رد کردن هر دو از یک سیستم هسته‌ی این مسئله است. همچنین به همین دلیل API ما از تماس‌گیرنده‌ها می‌خواهد در هر ارسال اولویت را اعلام کنند.

روی تخته‌سفید ممکن است این‌طور باشد:

  • عملکردی: ۱) ارسال نوتیفیکیشن به کاربر، فوری یا زمان‌بندی‌شده، ۲) ارسال Campaign، فوری یا زمان‌بندی‌شده، ۳) کاربران بتوانند ترجیحات نوتیفیکیشن را تنظیم کنند.
  • غیرعملکردی: ۱) تحویل At-least-once با Deduplication بر پایه‌ی بهترین تلاش، ۲) مدیریت جهش‌های حدود ۵٬۰۰۰ نوتیفیکیشن در ثانیه، ۳) تحویل نوتیفیکیشن‌های حیاتی ظرف ۵ ثانیه، حتی در زمان جهش.

رویکرد برنامه‌ریزی

تعریف Entityهای اصلی

دوست دارم با نمای کلی از Entityهای اصلی شروع کنم. در این مرحله به هر ستون و فیلد نیاز نداریم. آن‌ها بعداً که طراحی شکل گرفت کامل می‌شوند. برای برآورده کردن نیازمندی‌های عملکردی به این‌ها نیاز داریم:

  • Notification: یک پیام به یک کاربر روی یک کانال، همراه وضعیت فعلی‌اش. مثلاً ایمیل بازاریابی برای John ساعت ۴ بعدازظهر.
  • Campaign: ارسال زمان‌بندی‌شده به گروهی از کاربران. چیزهایی مثل زمان تحویل، کانال (SMS یا ایمیل) و مخاطبان را ثبت می‌کند.
  • Segment: مخاطبان نام‌دار مثل «همه‌ی کاربران کانادا». عضویت توسط ابزاری که مخاطبان را تعریف می‌کند نگه‌داری می‌شود و ما هنگام Fan-out کردن Campaign آن را می‌خوانیم.
  • User: گیرنده، همراه ترجیحات نوتیفیکیشن و اطلاعات تماس.

(در فهرست تخته‌سفید متن اصلی Template هم آمده، ولی در تعریف Entityها نیست. در این طراحی Template فقط در Campaign به‌شکل templateId به کار می‌رود.)

به رابطه‌ی Campaign و Notification توجه کنید. Notification مثل نمونه (Instance) از یک کلاس است. این تفکیک «تعریف در برابر نمونه‌ها» در طراحی سیستم همه‌جا دیده می‌شود. همین شکل را در تحلیل Job Scheduler (Job تکرارشونده در برابر اجراهای منفردش) و در سیستم‌های تقویم (رویداد در برابر رخدادهایش) می‌بینید.

در مصاحبه‌ی واقعی، این می‌تواند فقط فهرست کوتاهی باشد. فقط مطمئن شوید درباره‌ی Entityها با مصاحبه‌کننده صحبت می‌کنید تا هم‌نظر باشید.

API یا رابط سیستم

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

1
2
3
4
5
6
7
8
POST /notifications -> 200 OK { id, status }
Body: {
  userId,
  channel,     // push | email | sms
  priority,    // high | standard
  content,     // { title, body }
  scheduledAt  // optional, defaults to now
}

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

Campaignها Endpoint مخصوص خودشان را دارند. می‌توانید POST /notifications را برای پذیرش segmentId گسترش دهید و کار را تمام کنید، ولی آن‌وقت یک Endpoint گاهی نوتیفیکیشن و گاهی Campaign می‌سازد که Overloading غیرضروری است.

1
2
3
4
5
6
7
8
POST /campaigns -> 202 Accepted { id }
Body: {
  segmentId,
  channel,     // push | email | sms
  priority,    // high | standard
  templateId,
  scheduledAt  // optional, defaults to now
}

برخلاف ارسال تکی، Campaign فوراً 202 جواب می‌دهد. ارسال بعداً انجام می‌شود، پس نتیجه‌ای نیست که هنگام ساخت بتواند گزارش کند.

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

هر دوی این Bodyها قبل از پایان یک Idempotency Key هم می‌گیرند. شروع با قرارداد ساده و تکامل دادنش اشکالی ندارد، به شرط اینکه به مصاحبه‌کننده بگویید دارید این کار را می‌کنید.

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

1
2
3
4
5
PUT /users/{userId}/preferences -> Preferences
Body: {
  optOuts,     // e.g. ["sms"]
  quietHours   // e.g. { start: "22:00", end: "08:00", tz }
}

اگر تحلیل‌های دیگر ما را خوانده باشید، دیدن userId در مسیر ممکن است زنگ خطر را به صدا درآورد. هویت کاربر معمولاً از JWT می‌آید و هرگز از کلاینت، و پاس دادنش در مسیر معمولاً هشدار است. چارچوب تحویل مصاحبه هم همین را می‌گوید [web:4].

اینجا userId به مسیر تعلق دارد، چون تماس‌گیرنده‌های ما اصلاً کاربران نهایی نیستند. آن‌ها سرویس‌های داخلی هستند که با API Key احراز هویت می‌شوند و از طرف کاربرانی عمل می‌کنند که ترجیحاتشان را تغییر می‌دهند. وقتی کسی در صفحه‌ی تنظیمات محصول یک کلید را می‌زند، Backend محصول با userId ای که از قبل احراز هویت کرده ما را صدا می‌زند.

اگر با تیم پلتفرم یا ابزارهای داخلی مصاحبه می‌کنید، ممکن است بخواهند بدانند Authorization در سیستم چطور حمل می‌شود. اغلب این به شکل Delegation Token یا Service Account است که به سرویس اجازه می‌دهد از طرف سرویس یا کاربر دیگر عمل کند.

طراحی سطح بالا

حالا نیازمندی‌های عملکردی را یکی‌یکی پیش می‌بریم و ساده‌ترین چیزی را که هر یک را برآورده می‌کند می‌سازیم. چند نیازمندی غیرعملکردی جذاب مطرح کردیم، ولی توصیه می‌کنم تمایل به حل کردن‌شان را در این مرحله مهار کنید. مهم‌تر است اول سیستم عملکردی کار کند، پیش از اینکه با پریدن مستقیم به خط پایان خودمان را غرق کنیم.

۱) سرویس‌های بالادستی بتوانند فوری یا زمان‌بندی‌شده نوتیفیکیشن بفرستند

اول مسیر مستقیم را می‌سازیم، یک نوتیفیکیشن که همین حالا ارسال شود، و آن را ایمیل می‌گیریم. SMS تقریباً همان جریان است و Push یک قطعه روی آن اضافه می‌کند، پس برای شروع یک کانال کافی است.

اجزا:

  • API Gateway: نقطه‌ی ورود سرویس‌های بالادستی. API Key هر سرویس را احراز هویت می‌کند و Rate Limit اعمال می‌کند تا سرویس رها‌شده‌ای موارد استفاده‌ی اولویت‌بالا مثل احراز هویت را خراب نکند.
  • Notification Service: هسته‌ی سیستم. آدرس ایمیل گیرنده را جستجو می‌کند، نوتیفیکیشن را ماندگار می‌کند و Provider را برای تحویل صدا می‌زند.
  • Database: ردیف‌های Notification به‌علاوه‌ی بخش ما از داده‌ی کاربر، مثل آدرس ایمیل هر کاربر را که از سرویس کاربر محصول همگام می‌شود ذخیره می‌کند. از Postgres استفاده می‌کنیم، هرچند صادقانه هر پایگاه داده‌ی مدرنی اینجا جواب می‌دهد. هیچ چیز در ردیف Notification نیاز به چیز عجیبی ندارد.
  • Email Provider: سرویسی که پیام را واقعاً به Inbox می‌رساند. ما مالک آخرین مایل تحویل نیستیم؛ Provider است. کار ما این است که درخواست‌های درست‌شکل‌گرفته را از طریق SMTP یا API آن به او بدهیم.

وقتی سرویس بالادستی نوتیفیکیشن را همین حالا می‌فرستد:

  1. سرویس به /notifications با userId، channel و content یک POST می‌زند.
  2. API Gateway API Key سرویس را احراز هویت و درخواست را به Notification Service می‌فرستد.
  3. Notification Service آدرس ایمیل کاربر را از بخش داده‌ی کاربر ما جستجو می‌کند.
  4. سرویس ردیف Notification با وضعیت PENDING می‌نویسد، بعد Provider را صدا می‌زند.
  5. سرویس ردیف را بر اساس پاسخ Provider به SENT یا FAILED به‌روز می‌کند و 200 را با شناسه‌ی نوتیفیکیشن و آن وضعیت برمی‌گرداند.

با این، تماس‌گیرنده می‌تواند ایمیل یا SMS بفرستد، چون این‌ها آدرس‌های شناخته‌شده‌اند.

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

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

1
2
3
4
5
PUT /users/{userId}/devices -> Device
Body: {
  platform,   // ios | android
  pushToken
}

وقتی توکن کنار بقیه‌ی داده‌ی کاربر نشست، ارسال Push می‌تواند مثل ایمیل کار کند. سرویس فقط به‌جای آدرس، توکن را جستجو می‌کند.

شایع‌ترین اشتباهی که در این سؤال می‌بینم قاطی کردن پلتفرم Notification با Provider تحویل است. سیستم شما Router و حسابدار است. تصمیم می‌گیرد چه چیزی کجا برود و ثبت می‌کند چه شد. رابطه‌ی با اپراتورها و اتصال به دستگاه‌ها مال APNs، Twilio و Provider ایمیل است. وقتی نامزدی شروع می‌کند اتصال‌های Socket پایدار به گوشی‌ها طراحی کند، مسئله‌ی متفاوت و بسیار سخت‌تری را حل می‌کند و مصاحبه‌کننده‌ی خوب او را هدایت می‌کند (مگر اینکه همان را می‌خواست!). اینجاست که صریح بودن در نیازمندی‌های عملکردی کمک می‌کند.

حالا می‌توانیم لحظه‌ای که سرویس بالادستی درخواست می‌کند ایمیل، پیامک یا Push بفرستیم. اما نوتیفیکیشنی که قرار است بعداً برود چه؟

برای زمان‌بندی ارسال، Notification Service ردیف را با وضعیت SCHEDULED می‌نویسد و به‌جای صدا زدن Provider همان‌جا متوقف می‌شود. بعد یک جزء جدید اضافه می‌کنیم: Cron Job زمان‌بندی که هر دقیقه جدول Notifications را برای ردیف‌های زمان‌بندی‌شده‌ای که زمانشان رسیده اسکن می‌کند و هرکدام را از همان مسیر ارسال فوری عبور می‌دهد.

برای شروع این خوب است و دانه‌بندی دقیقه‌ای اینجا قابل‌قبول است. هر چیزی که به ثانیه اهمیت می‌دهد، مثل OTP، به‌صورت درخواست همگام می‌آید و هرگز به Cron Job دست نمی‌زند.

۲) سرویس‌های بالادستی بتوانند Campaign را به Segment کاربران بفرستند

حالا که می‌توانیم به یک کاربر نوتیفیکیشن برسانیم، طراحی را به Campaign گسترش می‌دهیم، که در آن یک درخواست از بازاریابی به نوتیفیکیشن برای کل Segment از کاربران تبدیل می‌شود.

وقتی تماس‌گیرنده به /campaigns POST می‌زند، ردیف Campaign می‌نویسیم و فوراً 202 برمی‌گردانیم. این فقط CRUD (ساخت، خواندن، به‌روزرسانی، حذف) است تا وقتی آن یک ردیف را به تحویل‌ها تبدیل کنیم.

از قبل Job زمان‌بندی داریم که ارسال‌های تأخیری را Poll می‌کند، پس آن را به جدول Campaigns هم گسترش می‌دهیم:

  1. Cron Job بیدار می‌شود و Campaign ای را که scheduledAt آن گذشته پیدا می‌کند.
  2. عضویت Segment را می‌خواند تا فهرست گیرنده‌ها را مشخص کند.
  3. برای هر گیرنده، ارسالی را از همان مسیر نوتیفیکیشن مستقیم عبور می‌دهد.
  4. Campaign را کامل علامت می‌زند.

Segment را هنگام شروع Fan-out به‌صورت Snapshot می‌خوانیم. یعنی کسی که وسط انتشار عضو شود این Campaign را از دست می‌دهد و Campaign بعدی را می‌گیرد. فکر می‌کنم برای Campaign بازاریابی اشکالی ندارد، ولی وقتی این را می‌پرسم می‌خواهم بشنوم که آن را تصدیق می‌کنید (یکی از چیزهای بزرگ جداکننده‌ی نامزد Senior از Junior همین متوجه شدن چنین مسائلی است).

حالا همه‌چیز را کنار هم گذاشتیم و اگرچه یک خط لوله برای همه‌چیز سیستم را ساده نگه می‌دارد، سه مشکل می‌سازد:

  • تماس با Provider مستقیم در مسیر درخواست است. اگر Twilio روز بدی داشته باشد، تماس‌گیرنده‌های ما باید منتظرش بمانند. بدتر، اگر سرویس ما بعد از نوشتن ردیف ولی پیش از صدا زدن Provider Crash کند، آن نوتیفیکیشن به سادگی گم می‌شود. دیگر چه At-least-once.
  • Campaign بزرگ با بقیه‌ی چیزها بر سر منابع می‌جنگد. وقتی Cron Job یک Campaign یک‌میلیونی را گسترش می‌دهد، ممکن است میلیون ارسال پیش از OTP که می‌خواهیم به کاربر برسانیم بنشینند.
  • یک میزبان میلیون تماس Provider می‌زند. کل Fan-out یک حلقه روی یک ماشین است، پس هر ارسال Campaign به زنده ماندن آن ماشین وابسته است. اگر وسط کار بمیرد، نمی‌دانیم کجا متوقف شده.

به این‌ها برمی‌گردیم، ولی اول نیازمندی‌های عملکردی را کامل کنیم.

۳) کاربران بتوانند ترجیحات نوتیفیکیشن را تنظیم کنند

خبر خوب این است که این نیازمندی هیچ جزء جدیدی نمی‌خواهد. ترجیحات ویژگی‌های روی User هستند که از طریق Endpoint ترجیحات نوشته می‌شوند و Gateway آن‌ها را به Notification Service هدایت می‌کند.

کاربران برای هر کانال انصراف و پنجره‌ی ساعات سکوت دارند. ساعات سکوت روی نوتیفیکیشن‌های استاندارد اعمال می‌شود، ولی البته روی اولویت بالا مثل OTP و هشدارهای تقلب نه.

هر نوتیفیکیشن درست پیش از فراخوانی Provider با این ترجیحات بررسی می‌شود. وقتی بررسی چیزی را سرکوب کند، به‌جای حذف بی‌صدا آن را همراه دلیل به‌صورت SUPPRESSED ثبت می‌کنیم (بعداً وقتی On-call هستیم و می‌پرسیم «چرا این کاربر این نوتیفیکیشن را نگرفت؟!» به کار می‌آید).

و با این، سیستمی کارا داریم. یک OTP در یک گام همگام می‌رود، Campaign یک‌میلیونی از طریق Cron Job گسترش می‌یابد و کاربران در آنچه به آن‌ها می‌رسد نظر دارند.

کار می‌کند، ولی حتی یکی از نیازمندی‌های غیرعملکردی ما را برآورده نمی‌کند. Deep Dive ها برای همین هستند.

Deep Dive های احتمالی

با سیستم کارا روی تخته، نیازمندی‌های غیرعملکردی چیزی است که می‌ماند.

۱) چطور تضمین کنیم نوتیفیکیشن پذیرفته‌شده هرگز گم نمی‌شود؟

این مشکل را هنگام کشیدن جریان Campaign نشان دادیم. الان Notification Service ردیف را می‌نویسد، Provider را درون‌خطی (Inline) صدا می‌زند، وضعیت را به‌روز می‌کند و فقط بعد به تماس‌گیرنده برمی‌گردد. چند لحظه پیش گفتیم چرا این بد است.

پس باید تحویل را از مسیر درخواست بیرون ببریم و سؤال این است که آن را روی چه چیزی بگذاریم.

الگو، Multi-step Processes: تحویل نوتیفیکیشن فرایند چندمرحله‌ای است (پذیرش، صف‌گذاری، تحویل، ثبت) که هر مرحله‌اش می‌تواند مستقل شکست بخورد. اگرچه تا آنجا نمی‌رویم که از چیزی مثل Temporal برای این مسئله استفاده کنیم (بیشتر مصاحبه‌کننده‌ها شما را رد می‌کنند چون بعضی از جالب‌ترین جنبه‌های این مسئله را پنهان می‌کند)، عملاً همان مسائل Workflow را حل می‌کنیم.

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

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

چالش‌ها: هر چیز دیگرش از چیزی که با آن شروع کردیم بدتر است. حلقه‌ی Retry در حافظه‌ی پروسس زندگی می‌کند، پس Crash یا Deploy روتین هنوز همه‌چیز را از بین می‌برد. همچنین در قطعی واقعی Provider، اتصال تماس‌گیرنده به طول Backoff ما باز می‌ماند و سرویس Auth شروع می‌کند به Timeout روی ورودها چون Provider پیامک ما افت کرده. اصلی که در طراحی سیستم خوب به آن می‌رسیم «سریع شکست بخور و بلند شکست بخور» است که این هیچ‌کدام را نمی‌کند. و تماس‌گیرنده نمی‌تواند فرق «ما دست کشیدیم» و «اصلاً نگرفتیم» را بفهمد. آنچه ساخته‌ایم تحویل At-most-once است، دقیقاً مخالف چیزی که به تماس‌گیرنده قول دادیم.

رویکرد دوم، جدول Notifications به‌عنوان صف: چیز بعدی که می‌توانیم امتحان کنیم جدا کردن فرستادن نوتیفیکیشن از تأیید به تماس‌گیرنده است. هر سه شکست از انجام هر دو در یک درخواست آمدند، پس به محض ثبت ایمن نوتیفیکیشن تأیید می‌کنیم و آن را جدا تحویل می‌دهیم، تقریباً مثل Campaignها.

Notification Service ردیف را با وضعیت PENDING می‌نویسد و به تماس‌گیرنده جواب می‌دهد. سرویس پس‌زمینه جدول Notifications را برای ردیف‌های PENDING Poll می‌کند، برای هرکدام Provider را صدا می‌زند و آن‌ها را SENT یا FAILED علامت می‌زند. ردیف‌های شکست‌خورده در Poll بعدی دوباره برداشته می‌شوند. چون ردیف پیش از هر چیز دیگر نوشته می‌شود، Dispatcher که Crash کند چیزی را از دست نمی‌دهد، چون کار هنگام برگشتنش هنوز در جدول است.

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

1
2
POST /notifications      -> 202 Accepted { id }
GET  /notifications/{id} -> { id, status, updatedAt }

چالش‌ها: این مشکل Restart را حل می‌کند، ولی ما فقط خودمان را متعهد کرده‌ایم صفی روی Postgres بسازیم و Postgres صف متوسطی می‌سازد (نظر جنجالی؛ آدم‌هایی هستند که Postgres را حتی بیشتر از ما دوست دارند).

یک Dispatcher هم کافی نیست، پس به چند تا که موازی اجرا شوند نیاز داریم. این مشکل جدیدی می‌سازد: دو Dispatcher می‌توانند همان ردیف PENDING را بردارند و نوتیفیکیشن را دوبار بفرستند. هر Dispatcher باید ردیف‌ها را هنگام خواندن ادعا کند، معمولاً در Postgres با SELECT ... FOR UPDATE SKIP LOCKED.

همچنین باید Retry را خودمان با ردیابی تعداد تلاش و زمان بعدی برای امتحان هر ردیف بسازیم. با گذشت زمان جدول هم صف ما می‌شود و هم تاریخچه، پس هر Poll باید دور انبوه رو‌به‌رشد ردیف‌های ارسال‌شده و شکست‌خورده کار کند، مگر اینکه Prune یا Partition کنیم.

در نهایت، فاصله‌ی Poll یعنی Latency تحویل نمی‌تواند سریع‌تر از فاصله‌ی Poll باشد. اگر هر ۱۰ ثانیه Poll کنیم، پیامی که ابتدای بازه رسیده باید ۱۰ ثانیه صبر کند تا کاری انجام دهد (که در بهترین حالت کند احساس می‌شود).

رویکرد سوم، صف ماندگار (Durable Queue): هر چیز ناخوشایند رویکرد قبلی از وادار کردن Postgres به رفتار مثل صف می‌آمد. پس ساختن صف بد را کنار بگذاریم و از صف واقعی استفاده کنیم.

به‌جای نوشتن آن ردیف PENDING و تحویل از روی آن، Notification Service نوتیفیکیشن را به صف ماندگار می‌دهد و وقتی صف تأیید کرد 202 برمی‌گرداند. مسیر پذیرش دیگر در پایگاه داده نمی‌نویسد، فقط در صف. اینجا از SQS استفاده می‌کنیم، هرچند هر صف پیام ماندگاری جواب می‌دهد.

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

تحویل اینطور کار می‌کند:

  1. Worker پیامی را از صف دریافت می‌کند، که آن را برای مدت Visibility Timeout از Workerهای دیگر پنهان می‌کند.
  2. آدرس ایمیل یا توکن دستگاه گیرنده را جستجو می‌کند و ترجیحاتش را بررسی می‌کند.
  3. Provider را صدا می‌زند.
  4. نتیجه را در ردیف Notification در Postgres می‌نویسد، SENT اگر Provider پذیرفت و FAILED اگر نپذیرفت. ردیف FAILED تلاش را ثبت می‌کند و پیام را واجد شرایط Retry می‌گذارد.
  5. فقط بعد از آن پیام را از صف حذف می‌کند.

اگر Worker پیش از اتمام بمیرد، پیام در صف می‌ماند. وقتی Visibility Timeout آن تمام شد، Worker دیگری آن را برمی‌دارد و دوباره امتحان می‌کند. شکست‌های Provider هم همین‌طورند، با Retryهایی که با Exponential Backoff فاصله می‌گیرند. بعد از شکست‌های زیاد، پیام به Dead-letter Queue می‌رود تا کسی بررسی‌اش کند، به‌جای اینکه تا ابد Retry شود.

صف بیشتر این را برای ما مدیریت می‌کند. سیاست Retry، Visibility Timeout و Dead-letter Queue را پیکربندی می‌کنیم، به‌جای ساختن خودمان.

چالش‌ها: سیستم حالا بیش از چیزی که با آن شروع کردیم تکراری تولید می‌کند. SQS تحویل At-least-once دارد، پس حتی اگر با پیام تکراری شروع نکرده باشیم می‌تواند داخلی تکرار تولید کند. فراتر از آن، تماس‌گیرنده‌ها ممکن است خودشان تکرار ساخته باشند، پس راهی برای گرفتن‌شان لازم داریم.

مصرف کردن هم مخرب است. وقتی Worker پیام را حذف کرد آن را فراموش می‌کنیم. اگر Build بد Worker را Deploy کنیم، نمی‌توانیم ترافیک امروز بعدازظهر را برای Replay به عقب برگردانیم.

نامزدهای زیادی دیده‌ام که پیشنهاد می‌کنند اول نوتیفیکیشن را در پایگاه داده بنویسند و دوم در صف منتشر کنند. چالش آنجا این است که مشکل Dual Write معرفی می‌کنید که در آن Crash بین آن دو مرحله ردیفی می‌گذارد که هیچ پیامی نمایندگی‌اش نمی‌کند.

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

پیش از ادامه: ما SQS را انتخاب کردیم و نامزدهای قوی زیادی Kafka یا صف ماندگار دیگری انتخاب می‌کنند. جواب می‌دهد. قوی‌ترین دلیل برای Kafka این است که امکان Rewind و Replay (چون Stream است) یعنی اگر Build بد Worker را Deploy کردید، می‌توانید Offset را ریست کنید و ارسال‌های امروز صبح را دوباره پردازش کنید به‌جای از دست دادنشان. این مزیت واقعی است، ولی چیزی نیست که نیازمندی‌های ما بخواهند، پس با SQS ماندیم.

الگو، Scaling Writes: Campaign Blast جهش نوشتن است، با انفجار عظیم Throughput در بازه‌ی کوتاه. بافر کردن آن در صف ماندگار از راهبردهای کلیدی Scaling Writes است.

۲) چطور نوتیفیکیشن اولویت بالا را ظرف ۵ ثانیه تحویل دهیم، حتی در زمان Campaign Blast؟

این هسته‌ی مصاحبه است و جایی که وقتی این را می‌پرسم بیشترین وقت را صرف می‌کنم. همه‌چیز تا اینجا با همه‌ی نوتیفیکیشن‌ها یک‌جور رفتار می‌کرد و اینجا شروع می‌کنیم درباره‌ی اینکه چرا این جواب نمی‌دهد صحبت کنیم.

فرض کنید Campaign بازاریابی ساعت ۹ صبح برای یک میلیون کاربر نوتیفیکیشن می‌فرستد. می‌توانیم تقریباً فوراً هر میلیون نوتیفیکیشن را به صف اضافه کنیم، ولی Providerهای ما فقط اجازه می‌دهند ۵٬۰۰۰ در ثانیه روی سه کانال بفرستیم. این یعنی Backlog، ولی هنوز اضطراری نیست. صف کمی بیش از سه دقیقه طول می‌کشد تا خالی شود، که برای تبلیغ کاملاً خوب است. هیچ‌کس انتظار ندارد هر کاربر دقیقاً ساعت ۹ آن را بگیرد.

اما بعد سرویس Auth چند ثانیه بعد OTP ورود را می‌پذیرد. به همان صفی می‌رود که تبلیغ‌ها در آن هستند، یک میلیون پیام عقب‌تر، و با ۵٬۰۰۰ ارسال در ثانیه ۲۰۰ ثانیه منتظر می‌ماند. حالا مشکل داریم.

رویکرد اول، مقیاس‌دهی Worker: این اولین جوابی است که شاید نیمی از اوقات می‌گیرم. صف عقب افتاده، پس ظرفیت اضافه کن تا سریع‌تر خالی شود. Worker Pool را بر اساس میزان عقب‌افتادگی صف Autoscale کنید، پول و منابع «الاستیک» AWS را بریزید و زودتر به آن OTP برسید.

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

برای خالی کردن یک میلیون پیام در کمتر از ۵ ثانیه به ۲۰۰٬۰۰۰ ارسال در ثانیه نیاز داریم، یعنی ۴۰ برابر هدف طراحی ما. AWS روی تعداد سروری که می‌توانید در یک لحظه بالا بیاورید سقف حساب می‌گذارد تا از چنین بارهایی جلوگیری کند. اگر بتوانید بگیرید برای شما عالی است، ولی برای آن‌ها وحشتناک گران است. کسی باید همه‌ی آن سرورهای بیکار را وقتی استفاده نمی‌کنید نگه دارد، پس ذخایرشان (و سودآوری‌شان) را کم می‌کند.

رویکرد دوم، صف‌های جدا بر اساس اولویت: بهبود قابل‌توجه، جدا کردن صف بر اساس اولویتی است که تماس‌گیرنده در API اعلام کرده، که صف اولویت بالا و صف استاندارد می‌دهد. سرویس را طوری تنظیم می‌کنیم که نوتیفیکیشن‌ها را به صف مناسب بفرستد و Workerها را طوری که اول صف اولویت بالا را خالی کنند.

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

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

به Providerهایمان فکر کنید. اگر ترافیک استاندارد کل سهمیه‌ی Rate مربوط به Twilio ما را مصرف کند، پیامک اولویت بالا توسط خود Twilio Throttle می‌شود حتی اگر در صف کوچکی سمت ما نشسته باشد.

نسخه‌ی مشابهی از همین مسئله را در محاسبات داریم. Workerهایی که اول صف اولویت بالا را خالی می‌کنند در حالت خوش‌بینانه سریع سرویس‌دهی می‌کنند، ولی باید مطمئن شویم خط لوله‌ی استاندارد رفتار درستی دارد. یک نتیجه‌ی بد این است که خود Worker پشت تماس کند Provider گیر کند و صف اولویت بالا را نگه دارد. حالت‌ها.

رویکرد سوم، لایه‌های اولویت با ایزوله‌سازی و بودجه‌ی Provider: احتمالاً خط آخر را دارید می‌بینید. به ایزوله‌سازی بیشتر (جدا کردن صف‌ها و Worker Poolها) و کنترل بیشتر (بودجه‌ی Provider و Token Bucket) نیاز داریم.

بگوییم Twilio ۱۰۰۰ پیامک در ثانیه به ما می‌فروشد. الان هر لایه می‌تواند هر ۱۰۰۰ را خرج کند و OTP ما گرسنه می‌ماند.

پس می‌توانیم آن سهمیه را بر اساس لایه تقسیم کنیم و با Token Bucket مشترک در Redis اعمال کنیم. هر Worker پیش از صدا زدن Provider یک توکن می‌کشد و Bucket را ابتدای هر پنجره‌ی یک‌ثانیه‌ای پر می‌کنیم، با دو قاعده:

  • اولویت بالا سهم رزروشده‌ای از Bucket می‌گیرد. مثلاً ۱۰۰ از ۱۰۰۰ درخواست در ثانیه فقط برای گروه اولویت بالا اختصاص داده می‌شود.
  • استاندارد ۹۰۰ باقی‌مانده را می‌گیرد و هر چقدر هم عقب بیفتد نمی‌تواند به ۱۰۰ رزروشده دست بزند.

باید به لایه‌ی اولویت بالا محاسبات اختصاصی هم بدهیم. صف آن Worker Pool جدا دارد.

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

بودجه یک بررسی Redis جلوی هر تماس Provider اضافه می‌کند، که یک قطعه‌ی متحرک دیگر برای ساختن و درست انجام دادن است. اما دوباره، همین است که می‌خواهیم.

زنده ماندن از افت Provider (Brownout)

Provider کند راه دیگر برای شکستن SLA ۵ ثانیه‌ای است. بخشی از این اجتناب‌ناپذیر است؛ نمی‌توانیم فیزیک را نقض کنیم تا نوتیفیکیشن‌هایمان سریع‌تر تحویل شوند. اما چند بهینه‌سازی هست و مواردی عالی برای ارائه در مصاحبه‌اند (یا آماده باشید توضیح دهید اگر بپرسند).

فرض کنید p99 مربوط به API ی Twilio از زیر یک ثانیه به ۳۰ ثانیه بالا برود. Workerهای ما این را نمی‌دانند، پس به صدا زدن ادامه می‌دهند و هر تماس گیرکرده یک Worker را ۳۰ ثانیه می‌خکوب می‌کند. در حالی که Provider افت کرده نمی‌توانیم همه‌چیز را به‌موقع تحویل دهیم، پس کار این است که پیام‌های اولویت بالا را در حرکت نگه داریم و بگذاریم بقیه‌ی چیزها صبر کنند.

سه راه معمول برای مدیریت:

  • Circuit Breaker: نرخ شکست و Timeout هر Provider را ردیابی می‌کنیم. وقتی از آستانه رد شد، Breaker باز می‌شود، پس تماس‌ها به‌جای انتظار برای Timeout ۳۰ ثانیه‌ای فوراً شکست می‌خورند، و بار غیرضروری را حذف (Shed) می‌کنیم تا جا برای پیام‌های اولویت بالا بماند.
  • Bulkhead: تعداد تماس‌های هم‌زمان به یک Provider را مثل Semaphore محدود کنید. وقتی Twilio سهمیه‌اش را در جریان دارد، Worker دیگری تماس تازه‌ای به آن شروع نمی‌کند، پس کل ظرفیتمان را روی سرویسی که بیشتر افتاده خرج نمی‌کنیم.
  • Provider Router: به کانال‌هایی که جایگزین دارند Provider اصلی و پشتیبان بدهید. در UI هم می‌توانیم هوشمند باشیم؛ اگر SMS افتاده، شاید ایمیل تنها گزینه‌ای باشد که برای احراز هویت پیشنهاد می‌دهیم.

۳) چطور روی تحویل At-least-once از نوتیفیکیشن تکراری جلوگیری کنیم؟

تا اینجا هر تصمیمی گرفته‌ایم به‌درستی به نفع تحویل At-least-once بوده است. عیبش این است که ارسال پیام تکراری حالا خیلی محتمل‌تر است.

مثلاً وقتی نوتیفیکیشنی را از صف برمی‌داریم، Worker می‌تواند Twilio را صدا بزند و پیش از حذف پیام بمیرد. پیامک واقعاً فرستاده شد ولی صف این را نمی‌داند. پس وقتی Visibility Timeout در SQS تمام شد، Worker دیگری همان پیام را برمی‌دارد و نوتیفیکیشن را دوباره می‌فرستد.

تماس‌گیرنده‌ها هم می‌توانند تکرار بسازند. ممکن است سرویس بالادستی نوتیفیکیشن جدیدی را POST کند، Timeout بخورد و دوباره POST کند. یا Cron Job ما وسط Fan-out یک Segment Crash کند و وقتی دوباره اجرا شود هر چیزی که قبلاً اضافه شده دوباره به صف برود.

تحویل Exactly-once واقعی غیرممکن است، ولی می‌توانیم تکرار را تا حد زیادی نادر نگه داریم.

رویکرد اول، Idempotency Key و قفل SET NX: پیش از هر چیز به شناسه‌ی پایدار نیاز داریم که معمولاً Idempotency Key یا Deduplication Key نامیده می‌شود. اگر Retry شناسه‌ای متفاوت از اصلی تولید کند، هیچ چیز پایین‌دست نمی‌تواند این دو را از هم تشخیص دهد.

تماس‌گیرنده‌ها می‌توانند Idempotency Key را روی POST /notifications بدهند و ما شناسه‌ی نوتیفیکیشن را از آن مشتق می‌کنیم، پس درخواست Retry‌شده همان شناسه‌ای را دارد که اصلی داشت. پیام‌های Campaign به همین شکل شناسه می‌گیرند، با مهر campaignId:userId، پس Cron Job که Crash کند و Segment را دوباره منتشر کند دقیقاً همان شناسه‌هایی را تولید می‌کند که پاس اول تولید کرد.

این فقط وقتی کار می‌کند که خود Campaign شناسه‌ی پایدار داشته باشد، که به همین دلیل POST /campaigns هم Idempotency Key می‌گیرد. بدون آن، ساخت Campaign Retry‌شده campaignId تازه می‌گیرد، هر پیام در آن متفاوت مهر می‌خورد و هر گیرنده تبلیغ را دوبار می‌گیرد.

حالا که هر نوتیفیکیشن شناسه‌ی پایدار دارد، می‌توانیم از خود آن شناسه به‌عنوان قفل استفاده کنیم. پیش از Dispatch، Worker سعی می‌کند آن را با SET NX در Redis بنویسد، که کلید را فقط اگر از قبل وجود ندارد تنظیم می‌کند. مثل این است که Worker دستش را بالا ببرد و بگوید «این یکی را من می‌گیرم».

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

چالش‌ها: این جلوی دو Worker رقیب را که هر دو بفرستند می‌گیرد، ولی وقتی Worker Crash کند بد می‌شکند.

  1. کاربر «Log in» را می‌زند و سرویس Auth یک OTP با Idempotency Key برابر otp-8412 POST می‌کند.
  2. Notification Service شناسه‌ی n:otp-8412 را مشتق می‌کند، به صف اولویت بالا می‌فرستد و 202 برمی‌گرداند.
  3. Worker A پیام را می‌گیرد و SET n:otp-8412 NX را اجرا می‌کند. OK می‌گیرد، پس می‌تواند ادامه دهد.
  4. میزبان Worker A با Deploy نابود می‌شود. چیزی فرستاده نشد و پیام هرگز حذف نشد.
  5. Visibility Timeout تمام می‌شود و صف پیام را به Worker B دوباره تحویل می‌دهد.
  6. Worker B SET n:otp-8412 NX را اجرا می‌کند، nil می‌گیرد و به اشتباه فرض می‌کند نوتیفیکیشن از قبل مدیریت شده است.
  7. هر تحویل مجدد بعد از آن دقیقاً همان کاری را می‌کند که Worker B کرد.

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

ما تازه At-least-once را به At-most-once تبدیل کردیم.

رویکرد دوم، بررسی پیش از ارسال و ثبت علامت بعد از ارسال: راه‌حل این است که ترتیب را برعکس کنیم. می‌توانیم پیش از Dispatch بررسی کنیم قبلاً ارسال کرده‌ایم یا نه و علامت Dedup را فقط پس از پذیرش Provider بنویسیم.

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

پس در عوض می‌توانیم Redis را جلو بگذاریم به‌عنوان Cache از شناسه‌های اخیراً ارسال‌شده، با پایگاه داده به‌عنوان منبع حقیقت پشتش:

1
2
GET sent:{notificationId}              # before dispatch: present -> duplicate, drop it
SET sent:{notificationId} 1 EX 172800  # only after the provider accepts (48h covers DLQ replays)

حالا وقتی Worker نوتیفیکیشنی را برمی‌دارد، اول Redis را بررسی می‌کند که قبلاً ارسال شده یا نه. اگر بله، آن را رها می‌کند و بعدی را می‌گیرد. اگر نه، آن را می‌فرستد، پایگاه داده را به‌روز می‌کند و Cache را به‌روز می‌کند.

چالش‌ها: Worker Twilio را صدا می‌زند، Twilio پیام را می‌پذیرد و می‌فرستد و Worker پیش از نوشتن ردیف نتیجه می‌میرد. هنوز در آن پنجره‌ی باریک ممکن است تکراری رد شود. تحویل مجدد رکوردی نمی‌یابد، دوباره می‌فرستد و کاربر همان کد ورود را دوبار می‌گیرد. تکرار نادر را می‌پذیریم تا Drop غیرممکن شود.

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

معناشناسی تحویل خود را در مصاحبه صریح نام ببرید. Exactly-once در مرز Provider متوقف می‌شود، پس طراحی صادقانه At-least-once با Deduplication در هر نقطه‌ای است که تکرار می‌تواند وارد شود. باید بتوانید هرکدام از آن نقطه‌ها را روی نمودارتان نشان دهید.

(این بخش با رفتار Redis که در Deep Dive رسمی آن هم آمده سازگار است. SET ... NX EX کلید را فقط اگر وجود ندارد و با انقضا می‌سازد، اسکریپت Lua به‌صورت یک دستور Atomic اجرا می‌شود، و Replication آن Asynchronous است، پس Redis سیستم ثبت (System of Record) نیست و قفل Redis ابزار کارایی است که گاهی شکست می‌خورد، نه تضمین درستی [web:8].)

طراحی نهایی

با کنار هم گذاشتن همه‌چیز، به این می‌رسیم: سرویس بالادستی از API Gateway (احراز هویت با API Key و Rate Limit) به Notification Service می‌رسد که نوتیفیکیشن را با اولویتش به یکی از دو صف SQS (اولویت بالا یا استاندارد، هرکدام با Dead-letter Queue) می‌فرستد. Cron Job زمان‌بندی Campaignها و ارسال‌های تأخیری را به همان صف‌ها Fan-out می‌کند. Workerهای جدا برای هر لایه، ترجیحات کاربر را می‌خوانند، Token Bucket مشترک در Redis را برای بودجه‌ی Provider بررسی می‌کنند، Dedup Cache در Redis را چک می‌کنند و Provider مناسب (Twilio برای SMS، SMTP برای ایمیل، APNs یا FCM برای Push) را صدا می‌زنند. نتیجه (SENT، FAILED یا SUPPRESSED) در Postgres نوشته می‌شود.

Deep Dive های اضافی که می‌توانید در نظر بگیرید

مصاحبه‌کننده می‌تواند این مسئله را به جاهای زیادی ببرد که ما نرفتیم:

  • بهینه‌سازی زمان ارسال (Send-time Optimization): Campaign ساعت ۹ صبح بازاریابی را ساعت ۹ تحویل دادیم. نسخه‌ی پیشرفته‌تر می‌پرسد هر کاربر واقعاً کی تعامل دارد و داخل پنجره‌ای که بازاریابی اجازه می‌دهد زمان ارسال به‌ازای هر کاربر انتخاب می‌کند. خط لوله‌ی ما از قبل میلیون‌ها ارسال زمان‌بندی‌شده‌ی کوچک را مدیریت می‌کند، پس بخش جالب تعامل با مدل ML آفلاین است که آن زمان‌های ارسال را امتیازدهی می‌کند. اگر مصاحبه‌کننده سابقه‌ی ML داشته باشد، احتمالاً می‌خواهد درباره‌اش صحبت کند.
  • محدود کردن فرکانس (Frequency Capping): پلتفرم واقعی سرانجام تعداد نوتیفیکیشن‌های هر کاربر در روز را بین همه‌ی انواع سقف‌دار می‌کند. شروع خوب کنار بررسی ترجیحات هنگام Dispatch است، ولی باید درباره‌ی شمارنده‌ها صحبت کنیم.
  • لغو Campaign: بازاریابی Blast زمان‌بندی می‌کند و بعد بخش حقوقی می‌خواهد متوقفش کند. یا غلط تایپی دیده‌ایم. انتظار داریم نادر ولی جدی باشد. چطور لغوها را بدون کند کردن عملکرد بررسی کنیم؟
  • تحویل آگاه از منطقه‌ی زمانی: «۹ صبح» معمولاً یعنی ۹ صبح برای کاربر، پس آن Campaign احتمالاً در ۲۴ ساعت پخش می‌شود. ماشین‌آلات زمان‌بندی که ساخته‌ایم جور است، فقط با دو دوجین زمان سررسید به‌جای یکی.

انتظار در هر سطح

بیشتر از آنچه هر مصاحبه‌ی ۴۵ دقیقه‌ای بتواند پوشش دهد ساخته‌ایم. آنچه واقعاً انتظار می‌رود به سطح شما بستگی دارد.

Mid-level: نامزد E4 برای این سؤال API و مدل داده را به‌روشنی تعریف کرده است. یعنی Endpointهای ارسال و Campaign که هرکدام اولویت حمل می‌کنند و Campaign ای که به Notificationها Fan-out می‌شود. از آنجا طراحی سطح بالای کارایی می‌خواهم که هم مسیر ارسال فوری و هم Campaignهای زمان‌بندی‌شده را پوشش دهد. می‌خواهم تشخیص دهند تماس همگام با Provider می‌تواند نوتیفیکیشن‌ها را از دست بدهد و با کمی راهنمایی دست‌کم به راه‌حل خوب تحویل ماندگار برسند. ایزوله‌سازی اولویت و ترتیب Dedup چیزهایی نیست که انتظار داشته باشم بدون کمکی از من به آن برسند.

Senior: نامزدهای E5 باید طراحی سطح بالای اولیه را سریع رد کنند تا وقتمان را در Deep Dive ها صرف کنیم، و هم تحویل ماندگار و هم ایزوله‌سازی لایه‌ی اولویت بالا را بدون هدایت من پیش ببرند. جزئیات ظریف زیادی درباره‌ی ایزوله‌سازی، صف‌ها و اولویت‌بندی که در این تحلیل لمس کردیم هست که می‌خواهم نامزد با اطمینان درباره‌شان بحث کند. هرچه بیشتر به مشکلات احتمالی فکر کند بهتر است. این مسئله به‌طرز شگفت‌آوری پیچیده و عمیق است و نشانه‌ی خوب نامزد Senior این است که بتواند آن عمق را بدون گرفتن دستش دنبال کند. نامزد Senior احتمالاً به بودجه‌ی Provider برای هر لایه یا ترتیب Dedup خودش نمی‌رسد و اشکالی ندارد.

Staff و بالاتر: برای نامزد Staff+ انتظار عمق بالا در هر سه Deep Dive است. بدون پرسش روی مسئله عدد می‌گذارند تا ببینند مشکل کجاست. همچنین می‌دانند سقف Provider است، نه Workerهای خودمان، که چیزی است که ما را از افزودن ظرفیت در هر گام بازمی‌دارد. همچنین قضاوت در حالت‌های شکست می‌خواهم. وقتی Dedup Store از کار بیفتد سیستم چه کند؟ Failover به Provider پشتیبان کی ارزش هزینه‌اش را دارد؟ (فقط لایه‌ی اولویت بالا.) چرا ادعای Idempotency Key پیش از تماس با Provider، At-least-once را به At-most-once تبدیل می‌کند؟ بهترین نامزدهای Staff چیزی به من یاد می‌دهند، معمولاً قید عملیاتی‌ای که هرگز روی نمودار نمی‌آید.