صفر تا صد تجربه فرایند حرکت به سمت KPI - چالش‌های فنی

مقدمه

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


تو این مقاله، من قصد دارم صفر تا صد تجربیاتمون طی چند ماه گذشته، در مسیر رسیدن به KPI هایی که برای سرویس پوش نوتیفیکیشن تبلیغاتی یکتانت تعریف کردیم رو باهاتون به اشتراک بذارم.

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

آشنایی با سرویس پوش

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

ما با هدف سودآوری حداکثری با در نظر گرفتن محدودیت‌های هر سه گروه، در نهایت سه تا KPI تعریف کردیم.

  • حداقل ۹۵٪ از بودجه‌ی روزانه‌ی کمپین‌هامون رو خرج کنیم.
  • به ۹۰٪ کاربرامون پوش بفرستیم
  • مجموع درآمد وبسایت‌های مهم رو به دو برابر ظرفیت کاربراشون برسونیم.

مورد اول مربوط به تبلیغ‌کننده‌ها، مورد دوم مربوط به کاربران و مورد سوم هم مربوط به منتشر‌کننده‌هاست.

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

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

نمودار تعداد ارسال روزانه در طول زمان
نمودار تعداد ارسال روزانه در طول زمان


چالش‌های فنی و روش‌های حل مساله

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

این روش‌ها لزوما بهترین راه ممکن نیستن، اما راه‌حل‌هایی بودن که ویژگی‌های مورد نظر‌ ما رو داشتن: سریع بودن، ممکن بودن و ما رو به KPI هامون نزدیک‌تر می‌کردن.

کوئری‌های خوندن از Redis کند شده و چندین ثانیه طول می‌کشه!

معمولا از Redis به عنوان اون دیتابیسی یاد می‌شه که برای cache کردن یا بیشتر شدن سرعت و بهبود کارایی می‌رن سراغش. اما ما تونسته بودیم کندش کنیم.

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

key -> value
--------------------
user:1234 -> True, ttl: 3600
user:1235 -> True, ttl: 7200
user:1236 -> True, ttl: 3600

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

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

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

key -> value
--------------------
ttl:1 -> set(1234, 1236)
ttl:2 -> set(1235)

مثلا، کلید ttl:2 به معنی این بود که کاربرانی که توی این مجموعه هستن، تا دو ساعت دیگه قرار نیست بهشون نوتیفی ارسال بشه. و از طرف دیگه، با یک async task که هر ساعت اجرا می‌شد، میومدیم و کاربر‌های هر ساعت رو انتقال می‌دادیم به یک ساعت کمتر.

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

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


+ اوضاعمون چطوره؟
- نمی‌دونم، باید log ارسال رو نگاه کنم.

بعد از مدتی، ما با این مشکل روبرو شدیم که هیچ دیدی نداشتیم که سیستممون الان در چه وضعیتیه، پیدا کردن مشکلات سیستم سخت شده بود و ابزار درست و راحت برای مانیتور کردن سیستم نداشتیم. حجم زیادی log داشتیم که اونها رو با tail و grep و ابزار‌های دیگه‌ی ترمینال دنبال می‌کردیم تا از وضعمون باخبر بشیم.

برای حل این مشکل، از ابزاری که مخصوص این کار ساخته شده استفاده کردیم، ما برای حفظ سرعت، به ساده‌ترین روشی که می‌شد ELK stack رو راه اندازی کردیم(یه مقاله در همین مورد تو بلاگمون نوشته شده که می‌تونین از اینجا بخونید) و لاگ‌هامون رو هدایت کردیم اونجا.

بعد مشغول ساختن نمودار‌های مختلف توی Kibana شدیم، نمودار‌هایی مثل اینکه امروز تا الان چقدر اقدام به ارسال کردیم، فیلتر‌های مختلفی که داشتیم چطور دارن کار می‌کنن، در هر ساعت چقدر می‌فرستیم، آیا بودجه‌ی کمپین رو به صورت عادلانه بین نوتیف‌هاش تقسیم می‌کنیم و …

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

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

نتیجه: اگر در هر جایی از مسیر پروژه احساس کردین که دارین کورکورانه و بر مبنای یک سری حدس و گمان تصمیم‌ به انجام کاری می‌گیرین، احتمالا به یک ابزار Visualization احتیاج دارین.

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

+ چقدر به KPI هامون نزدیکیم؟
- ایده‌ای ندارم!

مساله‌ی بعدی که بهش برخوردیم، این بود که ما نمی‌دونستیم چقدر داریم به سمت KPI هامون حرکت می‌کنیم. آیا ۹۵٪ بودجه‌ی کمپین‌ها رو خرج می‌کنیم؟ آیا به اندازه‌ی ظرفیت وبسایت‌های منتشر‌کننده‌هامون براشون درآمدزایی می‌کنیم؟

خوشبختانه، در همون ایام، تیم Business Intelligence یا BI در شرکت راه افتاده بود و بچه‌ها با استفاده از Power BI داشبورد‌های مختلفی برای هر پروژه می‌ساختن. ما هم برای سرویس پوش، نمودار‌های مربوط به KPI هامون رو ساختیم و از اونجا وضعیت رو بررسی می‌کردیم.

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

نتیجه: KPI هایی که تعریف می‌کنید، وحی منزّل نیستن و ممکنه تغییر کنن.

وضعیت مصرف بودجه کمپین‌ها
وضعیت مصرف بودجه کمپین‌ها


+ فلان منتشر‌کننده خواسته این قابلیت اضافه بشه.
- خب اینو بخوایم بزنیم باید کلی چیز توی کد تغییر کنه!

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

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

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

+ چرا به اون حجم از ارسال که می‌خوایم نمی‌رسیم؟
- کوئری‌ روی جدول کاربرا خیلی کنده!

یکی از اساسی‌ترین مسائلی که در طول توسعه‌ی این سرویس داشتیم، این بود که برای پیدا کردن و فیلتر کردن کاربر هامون بر اساس محدودیت‌های کمپین‌ها و پیچیدگی‌های سیستم، اولا با یک جدول نسبتا بزرگ توی دیتابیس(ما از دیتابیس postgreSQL استفاده می‌کنیم) سر و کار داشتیم، دوما کوئری‌های متنوعی روی این جدول داشتیم و نمی‌تونستیم با تعریف یک یا چند تا index روی اون سرعت کوئری‌هامون رو زیاد کنیم و خوشحال باشیم.

ما دائما با این موضوع درگیر بودیم که چطور کوئری‌هامون رو سریع‌تر کنیم. بعد از تلاش‌های بسیار، تحلیل کوئری‌ها با استفاده از explain و کار‌های مختلفی که کردیم (سعی می‌کنم در آینده تو یه مقاله‌ی جدا در مورد تلاش‌هامون برای بهبود سرعت و کارایی کوئری‌های دیتابیس بنویسم) در نهایت تونستیم با Cluster کردن جدول کاربر‌ها روی indexی که روی فیلد وبسایت تعریف شده بود، به سرعت مطلوب و حتی بهتر از چیزی که انتظارشو داشتیم برسیم. برای اینکه یکم حس پیدا کنین، میانگین زمان کوئری‌های سنگینی که داشتیم، از حدود ۲۰۰ ثانیه، به ۲ ثانیه رسید.

لازمه به این موضوع اشاره کنم که هر جدول دیتابیس رو فقط روی یک index می‌تونید Cluster کنید، چرا که به صورت فیزیکی چینش row های جدولتون رو عوض می‌کنه. در نتیجه لازمه مطمئن بشین که اولا راه‌های دیگه رو امتحان کردید، دوما واقعا به این cluster احتیاج دارید و در حال حاضر اون index پر استفاده ترین و مهم‌ترین ایندکس جدوله.

+ چرا اینهمه اخطار داریم که RAM و Disk پره؟
- خب الان که سرعت ارسالمون بالا رفته به همه‌ی کاربرا می‌فرستیم و Redis پر می‌شه.

بعد از همه‌ی این تغییرات، حالا تقریبا به KPI هامون رسیده بودیم. اما از اونجایی که حالا داشتیم به اکثر کاربرامون پوش ارسال می‌کردیم، حجم داده‌ای که توی Redis نگه می‌داشتیم خیلی زیاد شده بود، این اتفاق دوتا اثر منفی داشت، اول میزان مصرف ما از RAM سرور به بالای ۹۰ درصد رسید، دوم اینکه این حجم داده‌ی زیاد Redis دائم باید backup گرفته می‌شد و در بازه‌های زمانی کوتاه، Disk I/O رو هم خیلی بالا برده بود.

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

انتظارمون این بود که این کار مصرف RAM رو کمتر کنه، اما از اون طرف برناممون کندتر بشه، چرا که عملیات اضافه انجام می‌دادیم، اما نتیجه مقداری متفاوت بود.

سرعتمون از قبل بیشتر شد، علتش هم این بود که حجم داده‌ی کمتر، زمان خوندن و نوشتن توی Redis رو هم کمتر کرده بود، و این زمان خیلی بیشتر از زمانی بود که ما برای فشرده‌ کردن داده‌ها صرف می‌کردیم. با این کار، میزان حافظه‌ی مورد استفادمون نسبت به قبل حدودا ۰.۰۰۲ برابر شد. سرعت خوندن و نوشتن هم بین ۵ تا ۱۰ برابر سریع‌تر شد.

حجم ارسالمون دوباره خیلی کم شده!

به مرحله‌ی نگهداری از سرویس رسیده بودیم، بعد از چند روز مانیتور کردن نمودارها تو کیبانا، دیدیم میانگین زمان کوئری به ازای هر وب‌سایت تقریبا ۵۰ برابر قبل شده بود. یعنی ما ۵۰ برابر کند‌تر شده بودیم. بعد از کمی گوگل کردن و تفکر، فهمیدیم علتش آپدیت‌های زیاد دیتابیس بعد از Cluster کردنه. اول اینکه وقتی یه جدول رو Cluster می‌کنیم، محتواش به صورت فیزیکی جاشون عوض می‌شه و سازمان‌دهی می‌شن بر اساس اون index، دوم اینکه عملیات update در دیتابیس PosgreSQL در عمل یک insert و یک delete هست. در نتیجه، جای فیزیکی یک رکورد بعد از update عوض می‌شه و این به معنی خراب شدن Cluster در صورت آپدیت زیاده.

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

Cluster breakdown
Cluster breakdown


حرف آخر

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

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

درباره ما

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


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