پست

طراحی Ad Click Aggregator

طراحی سیستم تجمیع کلیک تبلیغات برای ۱۰ هزار کلیک در ثانیه با تحلیل نزدیک به بلادرنگ و دقت بالا

طراحی Ad Click Aggregator

درک مسئله

Ad Click Aggregator سیستمی است که داده‌ی کلیک روی تبلیغات را جمع‌آوری و تجمیع می‌کند. تبلیغ‌کنندگان از آن برای ردیابی عملکرد تبلیغات و بهینه‌سازی کمپین‌هایشان استفاده می‌کنند. در این مسئله فرض می‌کنیم تبلیغات در یک وب‌سایت یا اپلیکیشن مثل فیسبوک نمایش داده می‌شوند.

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

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

  • کاربر بتواند روی یک تبلیغ کلیک کند و به وب‌سایت تبلیغ‌کننده هدایت شود.
  • تبلیغ‌کننده بتواند معیارهای کلیک روی تبلیغ را در طول زمان و با کمترین بازه‌ی ۱ دقیقه‌ای پرس‌وجو کند.

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

  • هدف‌گذاری تبلیغات (Ad Targeting).
  • ارائه‌ی تبلیغ (Ad Serving).
  • ردیابی بین دستگاه‌ها (Cross-device Tracking).
  • ادغام با کانال‌های بازاریابی آفلاین.

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

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

سیستم را برای ۱۰ میلیون تبلیغ فعال و حداکثر ۱۰ هزار کلیک در ثانیه طراحی می‌کنیم. تعداد کل کلیک‌ها در روز حدود ۱۰۰ میلیون خواهد بود.

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

  • Scalability: پشتیبانی از حداکثر ۱۰ هزار کلیک در ثانیه.
  • Query با Latency کم: زمان پاسخ زیر یک ثانیه برای Queryهای تحلیلی تبلیغ‌کنندگان.
  • Fault Tolerance و دقت داده: هیچ داده‌ی کلیکی نباید از بین برود.
  • تا حد امکان بلادرنگ: تبلیغ‌کننده باید بتواند خیلی زود پس از کلیک، داده را پرس‌وجو کند.
  • Idempotency در ردیابی کلیک: یک کلیک نباید چند بار شمرده شود.

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

  • تشخیص تقلب یا اسپم.
  • اطلاعات دموگرافیک و جغرافیایی کاربران.
  • ردیابی تبدیل (Conversion Tracking).

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

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

رابط سیستم

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

  • ورودی: داده‌ی کلیک کاربران روی تبلیغات.
  • خروجی: معیارهای کلیک تبلیغات برای تبلیغ‌کنندگان.

جریان داده

  1. کاربر روی تبلیغی در یک وب‌سایت کلیک می‌کند.
  2. کلیک ردیابی و در سیستم ذخیره می‌شود.
  3. کاربر به وب‌سایت تبلیغ‌کننده هدایت می‌شود.
  4. تبلیغ‌کنندگان معیارهای تجمیعی کلیک را از سیستم می‌پرسند.

این جریان عمداً ساده است. هرچه جلوتر برویم کامل‌ترش می‌کنیم، ولی شروع ساده و تکمیل تدریجی روش درستی است.

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

۱) کلیک روی تبلیغ و هدایت به مقصد

با بخش آسان شروع می‌کنیم. وقتی کاربر در مرورگر روی تبلیغی کلیک می‌کند، باید مطمئن شویم به وب‌سایت تبلیغ‌کننده هدایت می‌شود. یک Ad Placement Service معرفی می‌کنیم که مسئول قرار دادن تبلیغ در وب‌سایت و ارتباط دادن آن با URL مقصد درست است.

وقتی کاربر روی تبلیغی که این سرویس قرار داده کلیک می‌کند، درخواستی به Endpoint ‏/click ما می‌رود که کلیک را ردیابی می‌کند و سپس کاربر را به وب‌سایت تبلیغ‌کننده هدایت می‌کند.

برای مدیریت Redirect دو راه وجود دارد، یکی ساده‌تر و یکی قوی‌تر.

رویکرد اول، Redirect سمت کلاینت: ساده‌ترین راه این است که همراه هر تبلیغ، یک Redirect URL هم به وب‌سایت بفرستیم. وقتی کاربر کلیک می‌کند، مرورگر خودش او را به URL مقصد می‌برد و هم‌زمان یک درخواست POST به Endpoint ‏/click می‌فرستیم تا کلیک ثبت شود. این روش ساده است و منطق سمت سرور اضافه نمی‌خواهد.

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

رویکرد دوم، Redirect سمت سرور: کاربر روی تبلیغ کلیک می‌کند و درخواست به سرور ما می‌رود. سرور کلیک را ثبت می‌کند و با کد وضعیت 302 (Redirect) کاربر را به وب‌سایت تبلیغ‌کننده می‌فرستد. این‌طور مطمئن می‌شویم هر کلیک ردیابی می‌شود و تجربه‌ی یکسانی برای کاربر و تبلیغ‌کننده داریم. همچنین می‌توانیم پارامترهای ردیابی اضافه به URL بدهیم تا تبلیغ‌کننده منبع کلیک را بشناسد، ولی این خارج از محدوده است.

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

۲) پرس‌وجوی معیارهای کلیک در بازه‌های ۱ دقیقه‌ای

کاربران با موفقیت هدایت شدند. حالا روی تبلیغ‌کنندگان تمرکز می‌کنیم. آن‌ها باید بتوانند سریع معیارهای تبلیغاتشان را ببینند. مسیر Click Processor را با بررسی چند گزینه برای پردازش و ذخیره‌ی کلیک گسترش می‌دهیم. وقتی /click درخواست را گرفت، بعد از آن چه اتفاقی بیفتد؟

رویکرد اول، ذخیره و Query در همان پایگاه داده: هر کلیک را همان لحظه در پایگاه داده ذخیره می‌کنیم. ساختار آن چیزی شبیه این است:

EventIdAdIdUserIdTimestamp
11234561640000000

وقتی تبلیغ‌کننده معیارها را می‌خواهد، همه‌ی رویدادهای هم‌خوان با AdId و بازه‌ی زمانی را با یک Query از نوع GROUP BY می‌خوانیم:

1
2
3
4
5
6
7
SELECT
  COUNT(*) AS total_clicks,
  COUNT(DISTINCT UserId) AS unique_users
FROM ClickEvents
WHERE AdId = 123
  AND Timestamp BETWEEN 1640000000 AND 1640000001
GROUP BY AdId

چالش: صادقانه بگویم، در مقیاس ما این راه‌حل بد است. با رسیدن به ۱۰ هزار کلیک در ثانیه، پایگاه داده سریع گلوگاه می‌شود. Query از نوع GROUP BY کند و ناکارآمد است و نمی‌توانیم Query با Latency کم به تبلیغ‌کنندگان بدهیم، که نیازمندی غیرعملکردی ما را نقض می‌کند.

رویکرد دوم، پردازش دسته‌ای (Batch) با داده‌ی Pre-aggregated: راه‌حل بهتر، داشتن یک پایگاه داده‌ی تحلیلی جدا برای داده‌ی از پیش تجمیع‌شده است. وقتی کلیک می‌رسد، رویداد خام را در Event Store ذخیره می‌کنیم. بعد به‌صورت دسته‌ای رویدادهای خام را پردازش و در پایگاه داده‌ای جدا که برای Query بهینه شده تجمیع می‌کنیم.

طرح پایگاه داده‌ی تحلیلی چیزی شبیه این است:

AdIdMinuteTimestampUniqueClicks
1231640000000100

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

برای Event Store به پایگاه داده‌ای نیاز داریم که برای نوشتن زیاد بهینه باشد. Cassandra انتخاب خوبی است. این پایگاه داده از ساختار ذخیره‌سازی شبیه LSM استفاده می‌کند: درج‌ها و به‌روزرسانی‌ها را در ساختار Log-مانندی به نام Memtable (در حافظه) می‌نویسد و به‌صورت دوره‌ای آن‌ها را در فایل‌هایی به نام SSTable روی دیسک می‌ریزد. SSTableها برای خواندن ردیف‌های مشخص بهینه‌اند، نه برای Query بازه‌ای یا تجمیعی. به همین دلیل باید پردازش دسته‌ای برای Pre-aggregation انجام دهیم.

برای پردازش دسته‌ای می‌توانیم از Apache Spark استفاده کنیم. Spark موتور محاسباتی توزیع‌شده‌ای است که برای این نوع Workflow بهینه شده و حجم زیاد داده را مدیریت می‌کند.

حجم داده دقیقاً چقدر است؟ با ۱۰ هزار کلیک در ثانیه و اجرای دسته‌ای هر ۵ دقیقه، در هر اجرا حدود ( 10000 \times 300 = 3 ) میلیون رویداد پردازش می‌کنیم. اگر هر رویداد حداکثر ۱۰۰ بایت باشد، در هر اجرا حدود ۳۰۰ مگابایت داده داریم. این به‌راحتی در توان Spark است.

Spark با الگوی MapReduce رویدادهای خام را به‌صورت تکه‌های موازی می‌خواند، بر اساس AdId و Timestamp تجمیع می‌کند و نتیجه را در پایگاه داده‌ی تحلیلی می‌نویسد.

برای پایگاه داده‌ی تحلیلی، فناوری‌ای می‌خواهیم که برای خواندن و تجمیع بهینه باشد. پایگاه‌های داده‌ی OLAP مثل Redshift، Snowflake یا BigQuery گزینه‌های خوبی‌اند. آن‌ها برای این نوع Query ساخته شده‌اند و حجم زیاد داده را مدیریت می‌کنند.

جریان پردازش دسته‌ای:

  1. کلیک به Click Processor Service می‌رسد.
  2. Click Processor رویداد را در Event Store می‌نویسد.
  3. هر N دقیقه یک Cron Job اجرا می‌شود و یک Spark Job راه می‌اندازد که رویدادها را می‌خواند و تجمیع می‌کند.
  4. داده‌ی تجمیع‌شده برای Query در پایگاه داده‌ی OLAP ذخیره می‌شود.
  5. تبلیغ‌کنندگان از پایگاه داده‌ی OLAP معیارهای تبلیغاتشان را می‌خوانند.

چالش: این راه‌حل کار می‌کند و خوب هم کار می‌کند، اما چند چالش کلیدی مانع «عالی» بودن آن می‌شود. اول، Batch Job تأخیر قابل‌توجهی در Pipeline داده ایجاد می‌کند. تبلیغ‌کنندگان همیشه داده‌ای را می‌بینند که چند دقیقه قدیمی است. دوم، سیستم مقیاس‌پذیر نیست؛ اگر کلیک‌ها ناگهان جهش کنند، سیستم بار را تحمل نمی‌کند.

اضافه کردن یک صف بین Click Processor و Event Store به مقیاس‌پذیری کمک می‌کند، اما مشکل Latency را حل نمی‌کند. برای حل هر دو چالش، طراحی پردازش جریانی (Stream Processing) معرفی می‌کنیم که رویدادها را بلادرنگ پردازش می‌کند.

رویکرد سوم، Real-time Analytics با Stream Processing: رویدادها را همان لحظه‌ی ورود پردازش می‌کنیم، نه اینکه منتظر اجرای یک Batch Job بمانیم.

وقتی کلیکی در Click Processor ثبت می‌شود، بلافاصله رویداد را در یک Stream مثل Kafka یا Kinesis می‌نویسد. بعد یک Stream Processor مثل Flink یا Spark Streaming رویدادها را از Stream می‌خواند و بلادرنگ تجمیع می‌کند. این کار با نگه داشتن شمارش کلیک‌ها در حافظه و به‌روزرسانی آن‌ها با ورود رویدادهای جدید انجام می‌شود. وقتی پنجره‌ی زمانی تمام شد، داده‌ی تجمیع‌شده را در پایگاه داده‌ی OLAP می‌ریزیم (Flush).

جریان پردازش بلادرنگ:

  1. Click Processor رویداد را در Stream مثل Kafka یا Kinesis می‌نویسد.
  2. Stream Processor مثل Flink یا Spark Streaming رویدادها را می‌خواند و بلادرنگ تجمیع می‌کند.
  3. داده‌ی تجمیع‌شده در پایگاه داده‌ی OLAP ذخیره می‌شود.
  4. تبلیغ‌کنندگان بلادرنگ از OLAP معیارها را می‌خوانند.

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

بهتر از آن، Flink فواصل Flush قابل‌تنظیم دارد. پس می‌توانیم روی مرز دقیقه تجمیع کنیم ولی نتایج را هر چند ثانیه Flush کنیم. این‌طور بهترین هر دو جهان را می‌گیریم؛ داده‌ی دقیقه‌ی جاری تا رسیدن به مرز دقیقه ناقص است، که رفتار مورد انتظار است.

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

۱) چطور برای ۱۰ هزار کلیک در ثانیه مقیاس بدهیم؟

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

  • Click Processor Service: با افزودن Instanceهای بیشتر به‌راحتی افقی مقیاس می‌گیرد. ارائه‌دهندگان ابر مثل AWS، Azure و GCP سرویس‌های مدیریت‌شده‌ای دارند که بر اساس مصرف CPU یا حافظه خودکار مقیاس می‌دهند. جلوی سرویس به یک Load Balancer نیاز داریم تا بار را بین Instanceها پخش کند.
  • Stream: Kafka و Kinesis هر دو توزیع‌شده‌اند و تعداد زیادی رویداد در ثانیه را تحمل می‌کنند، اما باید درست پیکربندی شوند. مثلاً Kinesis سقف ۱ مگابایت در ثانیه یا ۱۰۰۰ رکورد در ثانیه برای هر Shard دارد، پس باید Sharding اضافه کنیم. Sharding بر اساس AdId انتخاب طبیعی است؛ این‌طور Stream Processor می‌تواند از چند Shard موازی بخواند، چون مستقل از هم‌اند (همه‌ی رویدادهای یک AdId در یک Shard می‌مانند).
  • Stream Processor: Flink با افزودن Job یا Task بیشتر افقی مقیاس می‌گیرد. برای هر Shard یک Flink Job جدا داریم که تجمیع AdIdهای آن Shard را انجام می‌دهد.
  • پایگاه داده‌ی OLAP: با افزودن Node افقی مقیاس می‌گیرد. می‌توانیم بر اساس AdId پارتیشن کنیم، اما Sharding بر اساس AdvertiserId را هم باید بسنجیم. این‌طور همه‌ی داده‌ی یک تبلیغ‌کننده روی یک Node می‌ماند و Query روی تبلیغات او سریع‌تر می‌شود. پیش‌بینی ما این است که تبلیغ‌کنندگان همه‌ی تبلیغات فعالشان را در یک نمای واحد پرس‌وجو می‌کنند. البته پایش عملکرد پایگاه داده و Queryها برای رعایت SLA و تنظیم استراتژی Sharding در صورت نیاز حیاتی است.

Hot Shard

با استراتژی‌های بالا باید بتوانیم ۱۰ هزار کلیک در ثانیه را مدیریت کنیم. فقط یک مشکل می‌ماند: Hot Shard. فرض کنید نایکی تبلیغ جدیدی با لبرون جیمز راه می‌اندازد. این تبلیغ کلیک‌های زیادی می‌گیرد و همه به یک Shard می‌روند. آن Shard غرق می‌شود، Latency بالا می‌رود و در بدترین حالت ممکن است داده از بین برود.

برای حل مشکل Hot Shard باید راهی برای پارتیشن‌بندی بیشتر داده پیدا کنیم. روش رایج این است که کلید پارتیشن را با اضافه کردن یک عدد تصادفی به AdId تغییر دهیم. می‌توانیم این کار را فقط برای تبلیغات پرطرفدار انجام دهیم، که بر اساس هزینه‌ی تبلیغ یا حجم کلیک قبلی تشخیص داده می‌شوند. در این حالت کلید پارتیشن به شکل AdId:0-N می‌شود که N تعداد پارتیشن‌های اضافه برای آن AdId است.

۲) چطور مطمئن شویم هیچ داده‌ی کلیکی از بین نمی‌رود؟

اولین نکته این است که ما از Stream مثل Kafka یا Kinesis برای ذخیره‌ی داده‌ی کلیک استفاده می‌کنیم. این Streamها به‌صورت پیش‌فرض توزیع‌شده، Fault Tolerant و Highly Available‌اند. داده را روی چند Node و حتی چند Data Center Replicate می‌کنند، پس حتی اگر یک Node بیفتد داده از بین نمی‌رود. نکته‌ی مهم برای ما این است که امکان Persistence هم می‌دهند؛ یعنی حتی بعد از مصرف داده توسط Stream Processor، داده برای مدت مشخصی در Stream می‌ماند.

مثلاً می‌توانیم Retention را روی ۷ روز بگذاریم. اگر Stream Processor به هر دلیلی بیفتد، دوباره بالا می‌آید و داده‌ای را که از دست داده از Stream می‌خواند.

Stream Processorهایی مثل Flink قابلیتی به نام Checkpointing هم دارند: Processor به‌صورت دوره‌ای State خود را در ذخیره‌سازی پایدار مثل S3 می‌نویسد. اگر بیفتد، آخرین Checkpoint را می‌خواند و از همان جا ادامه می‌دهد. این وقتی مفید است که پنجره‌های تجمیع بزرگ باشند، مثل یک روز یا یک هفته. تصور کنید یک هفته داده در حافظه تجمیع کرده‌ایم و Processor می‌افتد؛ نمی‌خواهیم همه‌ی آن کار از بین برود.

اما در مورد ما پنجره‌های تجمیع بسیار کوچک‌اند. نامزدها معمولاً وقتی این سؤال را می‌پرسم Checkpointing را پیشنهاد می‌کنند، ولی من معمولاً عقب می‌کشم و می‌پرسم با پنجره‌های به این کوچکی واقعاً منطقی است؟ اگر Flink بیفتد حداکثر یک دقیقه داده‌ی تجمیع‌شده را از دست می‌دهیم. چون Persistence در Stream فعال است، می‌توانیم رویدادهای کلیک از دست‌رفته را دوباره از Stream بخوانیم و تجمیع کنیم.

این نوع نگاه کمی مخالف‌خوانی، راه مؤثری برای نشان دادن سطح Senior است. نامزدی که فقط مطالعه کرده ممکن است Checkpointing را به یاد بیاورد و پیشنهاد کند، اما نامزد باتجربه انتقادی فکر می‌کند که با توجه به شرایط مسئله اصلاً لازم است یا نه.

Reconciliation (تطبیق)

داده‌ی کلیک بسیار مهم است. اگر آن را از دست بدهیم، پول از دست می‌دهیم. پس باید مطمئن شویم داده‌هایمان درست است. این تعادل سختی است، چون تضمین صحت و Latency کم اغلب با هم تعارض دارند. می‌توانیم با معرفی Reconciliation دوره‌ای بین این دو تعادل برقرار کنیم.

با همه‌ی اقدامات بالا، باز هم ممکن است چیزی اشتباه شود. خطاهای گذرای پردازش در Flink، Deploy کد معیوب، رویدادهای نامرتب در Stream و موارد مشابه همه می‌توانند به نادرستی جزئی در داده منجر شوند. برای گرفتن این موارد، یک Reconciliation Job دوره‌ای هر ساعت یا هر روز اجرا می‌کنیم.

در انتهای Stream، کنار Stream Processorها، رویدادهای کلیک خام را در یک Data Lake مثل S3 می‌ریزیم. Flink این کار را با رابط FileSystem و کانکتورهای مختلف پشتیبانی می‌کند و خروجی پردازش دسته‌ای و بلادرنگ را مستقیم در Bucketهای S3 ذخیره می‌کند. بعد، مثل راه‌حل «خوب» در بخش دوم، یک Batch Job اجرا می‌کنیم که همه‌ی رویدادهای خام را از Data Lake می‌خواند و دوباره تجمیع می‌کند. نتیجه‌ی Batch را با نتیجه‌ی Stream Processor مقایسه می‌کنیم تا از تطابق مطمئن شویم. اگر مطابقت نداشت، مغایرت‌ها را بررسی و علت اصلی را رفع می‌کنیم و داده‌ی OLAP را با مقادیر درست به‌روز می‌کنیم.

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

۳) چطور از کلیک‌های چندباره‌ی کاربر روی یک تبلیغ جلوگیری کنیم؟

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

رویکرد اول، الزام ورود کاربر: ساده‌ترین راه این است که کاربر پیش از دیدن تبلیغ باید وارد حساب شده باشد. در این حالت شناسه‌ی کاربر را که از JWT یا Session Cookie به دست می‌آید، کنار داده‌ی کلیک ثبت می‌کنیم. بعد می‌توانیم کلیک‌ها را بر اساس ترکیب userId و adId Deduplicate کنیم.

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

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

Retargeting را در نظر بگیرید که عمداً یک تبلیغ را چند بار در یک دوره‌ی طولانی به یک کاربر نشان می‌دهیم. باید صورت مسئله را اصلاح کنیم: برای هر کاربر به ازای هر Impression (نمایش) از تبلیغ فقط یک کلیک بشماریم. این تمایز ظریف ولی مهم است و راه‌حل فعلی ما آن را در نظر نمی‌گیرد.

رویکرد دوم، Impression ID امضاشده: روش بهتر این است که Ad Placement Service برای هر نمونه از تبلیغ که به کاربر نشان داده می‌شود، یک Impression ID یکتا تولید کند. این شناسه همراه تبلیغ به مرورگر می‌رود و به‌عنوان Idempotency Key عمل می‌کند. وقتی کاربر کلیک می‌کند، مرورگر Impression ID را همراه داده‌ی کلیک می‌فرستد. این‌طور می‌توانیم کلیک‌ها را بر اساس Impression ID حذف تکراری کنیم.

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

Deduplication را کجا انجام دهیم؟ در Flink نمی‌توانیم به‌راحتی این کار را بکنیم، چون باید بین پنجره‌های تجمیع Deduplicate کنیم. اگر کلیک تکراری دو طرف مرز دقیقه برسد، دو بار شمرده می‌شود. در عوض باید پیش از قرار دادن کلیک در Stream Deduplicate کنیم. وقتی کلیکی می‌رسد، بررسی می‌کنیم Impression ID در Cache هست یا نه. اگر بود، تکراری است و نادیده‌اش می‌گیریم. اگر نبود، کلیک را در Stream می‌گذاریم و Impression ID را به Cache اضافه می‌کنیم.

این رویکرد خوب است، اما مشکل جدیدی می‌سازد: کاربر مخرب می‌تواند تعداد زیادی کلیک جعلی با Impression ID جعلی بفرستد. چون همه یکتا هستند، راه‌حل فعلی همه را می‌شمارد. برای حل این مشکل، Impression ID را پیش از ارسال به مرورگر با یک Secret Key امضا می‌کنیم. وقتی کلیک می‌رسد، پیش از بررسی Cache امضا را تأیید می‌کنیم تا مطمئن شویم Impression ID معتبر است.

خلاصه‌ی جریان:

  1. Ad Placement Service برای هر نمونه از تبلیغ که به کاربر نشان داده می‌شود یک Impression ID یکتا تولید می‌کند.
  2. Impression ID با Secret Key امضا می‌شود و همراه تبلیغ به مرورگر می‌رود.
  3. وقتی کاربر کلیک می‌کند، مرورگر Impression ID را همراه داده‌ی کلیک می‌فرستد.
  4. Click Processor امضای Impression ID را تأیید می‌کند.
  5. Click Processor بررسی می‌کند Impression ID در Cache هست یا نه. اگر بود، تکراری است و نادیده گرفته می‌شود. اگر نبود، کلیک وارد Stream می‌شود و Impression ID به Cache اضافه می‌شود.

چالش: این راه‌حل بی‌چالش نیست. پیچیدگی اضافه کرده‌ایم و اگر Cache درست مقیاس نگیرد می‌تواند گلوگاه شود. با این حال، داده‌ی Cache نسبتاً کوچک است. با ۱۰۰ میلیون کلیک در روز، حتی اگر همه Impression یکتا باشند، فقط ( 100{,}000{,}000 \times 16 ) بایت (۱۲۸ بیت) یعنی حدود ۱٫۶ گیگابایت می‌شود، که کوچک است.

به هر حال می‌توانیم با یک Distributed Cache مثل Redis Cluster یا Memcached محتاط‌تر عمل کنیم. برای مقابله با از بین رفتن Cache، یک Replica از آن نگه می‌داریم و Persistence را فعال می‌کنیم تا داده‌ی Cache گم نشود.

۴) چطور Query با Latency کم برای تبلیغ‌کنندگان تضمین کنیم؟

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

جایی که Query هنوز می‌تواند کند باشد، تجمیع در پنجره‌های زمانی بزرگ‌تر مثل روز، هفته یا حتی سال است. در این حالت می‌توانیم داده را در OLAP از قبل Roll-up کنیم، یعنی جدول جدیدی بسازیم که داده‌ی تجمیعی را با جزئیات کمتر، مثلاً روزانه یا هفتگی، ذخیره کند. این کار با یک Cron Job شبانه شدنی است که Queryی برای تجمیع داده اجرا می‌کند و نتیجه را در جدول جدید می‌ریزد. وقتی تبلیغ‌کننده داده را می‌خواهد، می‌تواند برای سطح جزئیات بالاتر از جدول Pre-aggregated بخواند و در صورت نیاز به جزئیات بیشتر، به سطح پایین‌تر برود.

Pre-aggregation در پایگاه داده‌ی OLAP تکنیک رایجی برای بهبود عملکرد Query است. می‌توان آن را نوعی Caching دانست؛ فضای ذخیره‌سازی را با عملکرد Query برای رایج‌ترین Queryها معاوضه می‌کنیم.

طراحی نهایی

با کنار هم گذاشتن همه‌ی این‌ها، طراحی نهایی چنین جریانی دارد: کاربر روی تبلیغ کلیک می‌کند و درخواست از Load Balancer به Click Processor می‌رسد. Click Processor امضا و Impression ID را بررسی می‌کند، تکراری‌ها را با Cache حذف می‌کند و کلیک یکتا را در Stream می‌نویسد، سپس کاربر را با 302 به وب‌سایت تبلیغ‌کننده می‌فرستد. Flink رویدادها را از Shardهای Stream می‌خواند، بلادرنگ تجمیع می‌کند و نتیجه را در پایگاه داده‌ی OLAP می‌ریزد، و رویدادهای خام را هم به S3 می‌فرستد. یک Batch Job دوره‌ای نتیجه را با داده‌ی S3 مقایسه و Reconcile می‌کند. تبلیغ‌کنندگان از OLAP، با جدول‌های Pre-aggregated برای بازه‌های بزرگ، Query می‌زنند.

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

Mid-level:

  • عرض در برابر عمق: نامزد این سطح بیشتر روی عرض تمرکز می‌کند (حدود ۸۰٪ عرض در برابر ۲۰٪ عمق). باید بتوانید طراحی سطح بالایی بسازید که نیازمندی‌های عملکردی تعریف‌شده را برآورده کند، ولی با خیلی از اجزا فقط در حد آشنایی سطحی کار می‌کنید.
  • بررسی مبانی: مصاحبه‌کننده مدتی را صرف بررسی مبانی می‌کند تا مطمئن شود می‌دانید هر جزء چه کاری می‌کند. مثلاً اگر API Gateway اضافه کنید، ممکن است بپرسد چه می‌کند و در سطح بالا چطور کار می‌کند. یعنی هیچ دانشی بدیهی فرض نمی‌شود.
  • ترکیب راننده‌بودن و سرنشین‌بودن: در مراحل ابتدایی باید خودتان مصاحبه را هدایت کنید، ولی انتظار نمی‌رود مشکلات طراحی را با دقت بالا تشخیص دهید. به همین دلیل مصاحبه‌کننده در مراحل بعدی هنگام بررسی طرح شما هدایت را به عهده می‌گیرد.
  • انتظار برای Ad Click Aggregator: از نامزد ماهر E4 انتظار دارم نیاز به Pre-aggregation داده را بفهمد و دست‌کم یک راه‌حل پردازش دسته‌ای پیشنهاد کند. باید بتواند هنگام پرسش‌های کاوشگرانه‌ی من درباره Idempotency یا انتخاب پایگاه داده، مسئله را به‌خوبی حل کند.

Senior:

  • عمق تخصص: انتظارات به دانش عمیق‌تر تغییر می‌کند، حدود ۶۰٪ عرض و ۴۰٪ عمق. باید در حوزه‌هایی که تجربه‌ی عملی دارید وارد جزئیات فنی شوید و درک عمیقی از مفاهیم و فناوری‌های کلیدی نشان دهید.
  • طراحی سیستم پیشرفته: باید با اصول طراحی پیشرفته آشنا باشید. مثلاً بدانید یک Batch Job چطور از MapReduce استفاده می‌کند یا پردازش بلادرنگ Flink در سطح بالا چطور کار می‌کند.
  • تشریح تصمیم‌های معماری: باید مزایا و معایب انتخاب‌های معماری را به‌روشنی توضیح دهید، به‌ویژه اثرشان روی Scalability، عملکرد و قابلیت نگهداری. تصمیم‌هایتان را توجیه می‌کنید و Trade-offها را توضیح می‌دهید.
  • حل مسئله و ابتکار عمل: باید مهارت قوی حل مسئله و رویکرد فعال نشان دهید، یعنی چالش‌های احتمالی طرح را پیش‌بینی کنید و بهبود پیشنهاد دهید. باید در شناسایی و رفع گلوگاه‌ها، بهینه‌سازی عملکرد و تضمین قابلیت اطمینان مسلط باشید.
  • انتظار برای Ad Click Aggregator: از نامزد E5 انتظار می‌رود طراحی سطح بالای اولیه را سریع پیش ببرد تا بتوانید درباره بهینه‌سازی‌های مقیاس، کاهش Latency بین کلیک و Query و تضمین Fault Tolerance با جزئیات صحبت کنید. باید Trade-offهای فناوری‌ها را بحث کنید، انتخاب‌هایتان را توجیه کنید و بفهمید چرا یکی را بر دیگری ترجیح می‌دهید. در حالت ایده‌آل، نامزد Senior نیاز به پردازش بلادرنگ را تشخیص می‌دهد و آن را با پردازش دسته‌ای مقایسه می‌کند، اما ممکن است راه‌حل کاملاً Fault Tolerant ارائه ندهد و به همه‌ی عمق بالا نرسد. در عوض چند Deep Dive از موارد بالا را انتخاب می‌کند و روی آن‌ها تمرکز می‌کند.

Staff و بالاتر:

  • تأکید بر عمق: از نامزد Staff+ انتظار می‌رود وارد ظرافت‌های طراحی سیستم شود. من حدود ۴۰٪ عرض و ۶۰٪ عمق می‌خواهم. ایده این است که حتی اگر این مسئله‌ی خاص را قبلاً حل نکرده‌اید، آن‌قدر مسئله‌ی واقعی حل کرده‌اید که بتوانید با تکیه بر تجربه‌تان با اطمینان راه‌حل طراحی کنید.
  • کاربرد عملی فناوری: باید بدانید از چه فناوری‌هایی استفاده کنید، نه فقط در تئوری بلکه در عمل، و از تجربه‌ی گذشته توضیح دهید چطور برای حل مسائل مشخص به کار می‌روند. مصاحبه‌کننده می‌داند که مبانی (REST API، نرمال‌سازی داده و مانند آن) را بلدید، پس سریع از آن‌ها عبور کنید تا برای بخش‌های جالب وقت بماند.
  • فعال‌بودن در سطح بالا: ابتکار عمل در سطح استثنایی انتظار می‌رود. باید مسائل را مستقل شناسایی و حل کنید، چالش‌ها را پیش‌بینی کنید و راه‌حل پیشگیرانه بیاورید. مصاحبه‌کننده فقط برای تمرکز دادن مداخله می‌کند، نه هدایت.
  • حل مسئله‌ی پیچیده و تصمیم‌گیری: نه‌تنها چالش‌های فنی پیچیده را حل می‌کنید، بلکه تصمیم‌های آگاهانه‌ای می‌گیرید که Scalability، عملکرد، قابلیت اطمینان و نگهداری را در نظر می‌گیرد.
  • طراحی پیشرفته و Scalability: رویکرد شما باید روی مقیاس‌پذیری و قابلیت اطمینان در بار بالا متمرکز باشد. این یعنی درک کامل سیستم‌های توزیع‌شده، Load Balancing، استراتژی‌های ذخیره‌سازی و دیگر مفاهیم پیشرفته.
  • انتظار برای Ad Click Aggregator: از نامزد Staff+ انتظار دارم به‌روشنی بین پردازش دسته‌ای و بلادرنگ تمایز بگذارد و پیامدهای هر انتخاب را با جزئیات بحث کند. باید راه‌حلی برای Fault Tolerance پیشنهاد دهد و Trade-offهای انتخاب‌های مختلف پایگاه داده را بحث کند. همچنین باید بتواند درباره پیامدهای استراتژی‌های ذخیره‌سازی داده و اثر آن‌ها بر عملکرد و Scalability حرف بزند. ممکن است همان ۴ Deep Dive بالا را انتخاب نکند، اما باید بتواند در حوزه‌هایی که انتخاب کرده عمیق شود و در حالت ایده‌آل، بر پایه‌ی تجربه‌اش چیز تازه‌ای درباره موضوع به مصاحبه‌کننده یاد بدهد.