پست

طراحی اپلیکیشن ردیابی تناسب اندام مانند Strava

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

طراحی اپلیکیشن ردیابی تناسب اندام مانند Strava

درک مسئله

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

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

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

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

  • کاربر بتواند دویدن یا دوچرخه‌سواری خود را شروع، موقتاً متوقف (Pause)، متوقف و ذخیره کند.
  • کاربر در حین دویدن یا دوچرخه‌سواری بتواند داده‌های فعالیت، از جمله مسیر، مسافت و زمان را ببیند.
  • کاربر بتواند جزئیات فعالیت‌های تکمیل‌شده خودش و فعالیت‌های دوستانش را ببیند.

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

  • اضافه یا حذف دوستان (مدیریت دوستان)
  • احراز هویت و مجوزدهی
  • نظر دادن یا لایک کردن فعالیت‌ها

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

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

  • سیستم باید بسیار در دسترس باشد (Availability بسیار مهم‌تر از Consistency).
  • اپلیکیشن باید در مناطق دورافتاده و بدون اتصال به شبکه کار کند.
  • اپلیکیشن باید در حین دویدن یا دوچرخه‌سواری آمار محلی دقیق و به‌روز به ورزشکار بدهد.
  • سیستم باید مقیاسی داشته باشد که از 10 میلیون فعالیت هم‌زمان پشتیبانی کند.

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

  • رعایت مقررات حریم خصوصی داده مثل GDPR
  • اقدامات امنیتی پیشرفته

آماده‌سازی

تعریف موجودیت‌های اصلی

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

برای برآورده کردن نیازمندی‌های عملکردی کلیدی، این موجودیت‌های اصلی را شناسایی می‌کنیم:

  • User (کاربر): فردی که از اپلیکیشن استفاده می‌کند. اطلاعات و تنظیمات پروفایل را دارد.
  • Activity (فعالیت): یک فعالیت دویدن یا دوچرخه‌سواری مجزا. شامل نوع فعالیت (دویدن یا سواری)، زمان شروع، زمان پایان، داده مسیر (مختصات GPS)، مسافت و مدت است.
  • Route (مسیر): مجموعه‌ای از مختصات GPS که در طول فعالیت ثبت می‌شود (این می‌تواند فقط یک فیلد داخل موجودیت Activity هم باشد).
  • Friend (دوست): ارتباط بین کاربران برای اشتراک‌گذاری فعالیت‌ها (توجه: مدیریت دوستان خارج از محدوده است، اما این مفهوم برای اشتراک‌گذاری ضروری است).

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

API

API هایی تعریف می‌کنیم که به اپلیکیشن موبایل اجازه می‌دهد با سرویس‌های باطن (Backend) کار کند. هدف ما این است که نیازمندی‌های اصلی را یکی‌یکی مرور کنیم و API های لازم برای برآورده کردن آن‌ها را تعریف کنیم. معمولاً این‌ها یک‌به‌یک با نیازمندی‌های عملکردی نگاشت می‌شوند، اما گاهی چند endpoint برای برآورده کردن یک نیازمندی لازم است، یا یک endpoint نیازهای چند نیازمندی را برآورده می‌کند (مثل endpoint به‌روزرسانی وضعیت فعالیت در ادامه).

اول یک endpoint تعریف می‌کنیم که برای ساخت فعالیت جدید استفاده می‌شود:

1
2
3
4
5
// Create a new activity
POST /activities -> Activity
{
    type: "RUN" | "RIDE"
}

بعد endpoint ای اضافه می‌کنیم که برای به‌روزرسانی وضعیت (State) آن فعالیت به کار می‌رود:

1
2
3
4
5
// Update the state of an activity
PATCH /activities/:activityId -> Activity
{
    state: "STARTED" | "PAUSED"
}

اینجا از PATCH استفاده می‌کنیم، چون فقط می‌خواهیم زیرمجموعه‌ای از فیلدهای فعالیت را به‌روز کنیم. در حالی که PUT نیاز دارد کل منبع (Resource) فرستاده شود، با PATCH فقط فیلدهایی را می‌فرستیم که در حال به‌روزرسانی‌اند، که بهبود جزئی ولی واقعی در کارایی است. در مصاحبه، PUT و PATCH از نظر عملکردی معادل‌اند. با این حال با بعضی مصاحبه‌کننده‌ها روبه‌رو می‌شوید که درباره RESTful API ها سخت‌گیرند و درباره این مورد با شما بحث می‌کنند، پس خوب است آماده باشید.

وقتی نوبت به ردیابی داده‌های فعالیت می‌رسد، یک endpoint از نوع POST اضافه می‌کنیم که برای به‌روزرسانی مسیر و مسافت فعالیت استفاده می‌شود:

1
2
3
4
5
// Add a new location to the activity's route
POST /activities/:activityId/routes -> Activity
{
    location: GPSCoordinate,
}

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

1
2
3
4
5
// Mark an activity as complete using the same endpoint we use to update the activity state
PATCH /activities/:activityId -> Activity
{
    state: "COMPLETE"
}

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

1
2
// Get a list of activities for a user
GET /activities?mode={USER|FRIENDS}&page={page}&pageSize={pageSize} -> Partial<Activity>[]

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

وقتی روی یک فعالیت کلیک می‌کنیم، می‌خواهیم جزئیات بیشتری مثل نقشه با مسیر و هر معیار یا جزئیات اضافه (که در این سؤال نیست) ببینیم:

1
2
// Get an activity by id
GET /activities/:activityId -> Activity

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

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

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

1) شروع، توقف موقت، توقف و ذخیره فعالیت

باید سیستمی طراحی کنیم که هم endpoint ساخت فعالیت و هم endpoint به‌روزرسانی وضعیت را درست مدیریت کند. می‌توانیم با ترسیم یک معماری بسیار ابتدایی کلاینت-سرور شروع کنیم:

  • Client App (اپلیکیشن کلاینت): کاربران از طریق اپلیکیشن موبایل با سیستم کار می‌کنند.
  • Activity Service: درخواست‌های ورودی برای ساخت و به‌روزرسانی فعالیت‌ها را پاسخ می‌دهد.
  • پایگاه داده: اطلاعات فعالیت‌ها، از جمله مسیر، مسافت و زمان را ذخیره می‌کند.

کاربر با باز کردن اپلیکیشن و کلیک روی دکمه «شروع فعالیت» کار را شروع می‌کند:

  1. اپلیکیشن کلاینت یک POST به /activities می‌فرستد تا فعالیت جدید بسازد و مشخص می‌کند دویدن است یا سواری.
  2. Activity Service فعالیت را در پایگاه داده می‌سازد و شیء Activity را به کلاینت برمی‌گرداند.
  3. اگر کاربر فعالیتش را موقتاً متوقف کند یا از سر بگیرد، یک PATCH به /activities/:activityId با وضعیت به‌روزشده می‌فرستد و Activity Service فعالیت را در پایگاه داده به‌روز می‌کند.
  4. بعد از پایان فعالیت، کاربر روی «ذخیره فعالیت» کلیک می‌کند. این یک PATCH به /activities/:activityId می‌فرستد که وضعیت را روی COMPLETE می‌گذارد.

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

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

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

1
2
3
4
5
6
[
    { "status": "STARTED", "timestamp": "2021-01-01T00:00:00Z" },
    { "status": "PAUSED", "timestamp": "2021-01-01T00:10:00Z" },
    { "status": "RESUMED", "timestamp": "2021-01-01T00:15:00Z" },
    { "status": "STOPPED", "timestamp": "2021-01-01T00:20:00Z" }
]

وقتی کاربر روی «توقف فعالیت» کلیک می‌کند، می‌توانیم زمان سپری‌شده را با جمع کردن مدت بین هر جفت مهر زمانی، بدون احتساب دوره‌های توقف موقت، حساب کنیم. در مثال بالا، زمان سپری‌شده 15 دقیقه است (10 دقیقه به‌علاوه 5 دقیقه).

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

2) مشاهده داده‌های فعالیت در حین انجام آن

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

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

روش کار این است:

  1. اپلیکیشن کلاینت مختصات GPS کاربر را در بازه زمانی ثابت ثبت می‌کند، مثلاً هر 2 ثانیه برای دوچرخه‌سواری و هر 5 ثانیه برای دویدن. برای این کار از سرویس‌های موقعیت‌یابی داخلی iOS و Android استفاده می‌کنیم:
    • iOS: از چارچوب Core Location، به‌ویژه کلاس CLLocationManager استفاده می‌کنیم. می‌توانیم به‌روزرسانی موقعیت را با متد startUpdatingLocation() راه‌اندازی کنیم و متد delegate به نام locationManager(_:didUpdateLocations:) را برای دریافت به‌روزرسانی‌ها پیاده کنیم.
    • Android: از Google Location Services API که بخشی از Google Play Services است استفاده می‌کنیم. می‌توانیم از کلاس FusedLocationProviderClient استفاده کنیم و متد requestLocationUpdates() را برای دریافت دوره‌ای موقعیت صدا بزنیم.
  2. اپلیکیشن کلاینت این مختصات جدید را به endpoint ای مثل /activities/:activityId می‌فرستد.
  3. Activity Service جدول مسیر را برای این فعالیت با مختصات جدید و زمان ثبت آن‌ها به‌روز می‌کند.
  4. همچنین فیلد مسافت را با محاسبه فاصله بین مختصات جدید و قبلی با فرمول Haversine به‌روز می‌کنیم.

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

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

3) مشاهده فعالیت‌های تکمیل‌شده خود و دوستان

بعد از تکمیل فعالیت‌ها، آن‌ها را قبلاً در پایگاه داده ذخیره کرده‌ایم و فقط لازم بود وضعیتشان را با همان endpoint به‌روزرسانی وضعیت به COMPLETE تغییر بدهیم.

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

1
2
3
4
5
SELECT * FROM activities
WHERE state = 'COMPLETE'
AND userId = :userId -- if mode is USER
AND userId IN (SELECT friendId FROM friends WHERE userId = :userId) -- if mode is FRIENDS
LIMIT :pageSize OFFSET (:page - 1) * :pageSize

جدول دوستان دو ستون دارد: userId و friendId. روابط را دوطرفه در نظر می‌گیریم، پس برای هر دوستی جدید دو ورودی به جدول اضافه می‌کنیم: یکی با userId و friendId و دیگری با جای این دو معکوس. با گذاشتن ستون اول به‌عنوان کلید اصلی (Primary Key)، می‌توانیم مطمئن شویم کوئری دوستان یک کاربر کارآمد است.

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

جریان کامل این‌طور است:

  1. کاربر به صفحه فهرست فعالیت‌ها در اپلیکیشن می‌رود.
  2. کلاینت یک GET به /activities?mode={USER|FRIENDS}&page={page}&pageSize={pageSize} می‌فرستد تا فهرست فعالیت‌ها را بگیرد.
  3. فهرست فعالیت‌ها در رابط کاربری نمایش داده می‌شود و کاربر برای دیدن جزئیات روی یک فعالیت کلیک می‌کند.
  4. کلاینت یک GET به /activities/:activityId می‌فرستد تا جزئیات کامل فعالیت را برای نمایش در صفحه جزئیات جدید بگیرد.

برای نمایش نقشه می‌توانیم از ترکیب داده مسیر و Google Maps API استفاده کنیم. می‌توانیم آرایه مختصات را به Google Maps API بدهیم تا خطی بکشد که همه نقاط را به هم وصل می‌کند.

بررسی‌های عمیق

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

1) پشتیبانی از ردیابی فعالیت در حالت آفلاین

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

بینش کلیدی این است که تا زمانی که از اشتراک‌گذاری بلادرنگ فعالیت‌ها پشتیبانی نمی‌کنیم، می‌توانیم داده‌های فعالیت را محلی و مستقیم روی دستگاه کلاینت ثبت کنیم و فقط وقتی فعالیت تمام شد و/یا کاربر دوباره آنلاین شد، آن را با سرور همگام (Sync) کنیم.

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

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

این سؤال یک جنبه مهم و اغلب نادیده‌گرفته‌شده طراحی سیستم را نشان می‌دهد: کلاینت به‌عنوان یک شرکت‌کننده فعال. گوشی‌های هوشمند و مرورگرهای مدرن به طیف گسترده‌ای از حسگرها، توان پردازشی قابل توجه و ذخیره‌سازی محلی قابل توجه دسترسی دارند. بسیاری از داوطلبان توانایی‌های کلاینت را نادیده می‌گیرند و فقط روی راه‌حل‌های سمت سرور تمرکز می‌کنند. اما برای سیستم‌هایی مثل Strava، Dropbox یا Spotify، کلاینت اغلب نقش مهمی در طراحی کلی دارد و ویژگی‌هایی مثل عملکرد آفلاین، کم کردن بار سرور و بهبود تجربه کاربری را ممکن می‌کند.

پس این در عمل چطور کار می‌کند؟

  1. مثل طراحی سطح بالا، از سرویس‌های موقعیت‌یابی روی دستگاه برای ثبت مختصات GPS کاربر در بازه ثابت استفاده می‌کنیم.
  2. این داده‌های رویداد را محلی روی دستگاه در یک بافر درون‌حافظه‌ای (مثلاً آرایه‌ای از جفت‌های مختصات GPS و مهر زمانی) ثبت می‌کنیم.
  3. برای جلوگیری از از دست رفتن داده در صورت خاموش شدن دستگاه یا تمام شدن باتری، این بافر را حدود هر 10 ثانیه یک‌بار در ذخیره‌سازی محلی دستگاه ذخیره می‌کنیم:
    • iOS: می‌توانیم برای مجموعه داده‌های بزرگ‌تر و ساختاریافته از Core Data یا برای مقدارهای کوچک‌تر کلید-مقدار از UserDefaults استفاده کنیم.
    • Android: می‌توانیم برای مجموعه داده‌های بزرگ‌تر و ساختاریافته از پایگاه داده Room یا برای مقدارهای کوچک‌تر کلید-مقدار از SharedPreferences استفاده کنیم.
  4. وقتی اپلیکیشن دوباره باز می‌شود یا فعالیت از سر گرفته می‌شود، اول ذخیره‌سازی محلی را برای هر داده ذخیره‌شده بررسی می‌کنیم و قبل از ادامه ثبت داده جدید، آن را داخل بافر درون‌حافظه‌ای بارگذاری می‌کنیم.
  5. بعد از تمام شدن فعالیت و آنلاین شدن دستگاه، همه داده انباشته‌شده را در یک درخواست به سرور می‌فرستیم. برای فعالیت‌های بسیار طولانی ممکن است لازم باشد یک استراتژی Chunking برای مدیریت کارآمد حجم زیاد داده پیاده کنیم.
  6. همچنین می‌توانیم یک مکانیزم همگام‌سازی پس‌زمینه پیاده کنیم که وقتی اتصال در دسترس شد، دوره‌ای سعی کند داده‌ها را آپلود کند، حتی اگر فعالیت هنوز کامل نشده باشد. این کار بین کارایی و دوام داده تعادل برقرار می‌کند.
  7. پس از تأیید اینکه داده در پایگاه داده راه دور ذخیره شده، می‌توانیم بافر محلی را حذف کنیم و برای استفاده دوباره آماده باشیم.

این رویکرد تضمین می‌کند حتی اگر دستگاه به‌طور غیرمنتظره خاموش شود، حداکثر 10 ثانیه از داده فعالیت (یا هر بازه‌ای که برای ذخیره دوره‌ای انتخاب کنیم) را از دست می‌دهیم. با این حال، مهم است که تناوب ردیابی GPS و ماندگاری داده را با ملاحظات عمر باتری متعادل کنید.

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

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

2) مقیاس‌دهی برای پشتیبانی از 10 میلیون فعالیت هم‌زمان

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

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

برای برآورد حجم ذخیره‌سازی هر فعالیت، آن را تفکیک می‌کنیم:

  • Metadata پایه: مثل وضعیت، شناسه کاربر، زمان شروع و پایان و غیره باید بسیار ارزان باشد. فرض کنید 100 بایت برای هر فعالیت.
  • مسیر: گران‌ترین بخش است. اگر فعالیت متوسط 30 دقیقه باشد و مختصات GPS را به‌طور متوسط هر 3 ثانیه بگیریم، حدود 600 نقطه برای هر فعالیت داریم. هر ردیف مسیر یک فیلد عرض جغرافیایی، یک فیلد طول جغرافیایی و یک مهر زمانی می‌خواهد. یعنی (8 بایت + 8 بایت + 8 بایت) × 600 حدود 15 کیلوبایت اضافه.

15 کیلوبایت × 36.5 میلیارد فعالیت در سال، حدود 547.5 ترابایت داده در هر سال می‌شود.

این حجم داده زیاد است، اما از توان مدیریت دور نیست. چند کار هست که می‌توانیم برای مدیریت آن انجام بدهیم:

  • Sharding: می‌توانیم پایگاه داده را Shard کنیم تا داده بین چند نمونه پایگاه داده تقسیم شود. می‌توانیم بر اساس زمان تکمیل فعالیت Shard کنیم، چون بیشتر درخواست‌های ما فعالیت‌های اخیر را می‌خواهند.
  • لایه‌بندی داده (Data Tiering): احتمال اینکه کسی بخواهد دویدنی چند سال قبل را ببیند بسیار کم است. برای کم کردن هزینه ذخیره‌سازی، می‌توانیم داده‌های قدیمی‌تر را به لایه‌های ذخیره‌سازی ارزان‌تر ببریم: داده داغ (فعالیت‌های اخیر) روی ذخیره‌سازی سریع و گران می‌ماند؛ داده گرم (3 تا 12 ماه) به ذخیره‌سازی کندتر و ارزان‌تر می‌رود؛ و داده سرد (بیش از 1 سال) به ذخیره‌سازی آرشیوی مثل S3 می‌رود.
  • کش: در صورت نیاز می‌توانیم کش اضافه کنیم. اگر ببینیم مکرر فعالیت‌های مشابه را کوئری می‌کنیم، می‌توانیم کش بگذاریم تا بار خواندن پایگاه داده کم شود. این برای من اولویت نیست، چون Throughput خواندن باید بسیار کم باشد. اما اگر زمان بارگذاری غیرقابل قبول شود، اولین کاری است که انجام می‌دهیم.

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

برای Activity Service، چون Throughput خواندن و نوشتن هر دو باید بسیار کم باشند، دلیلی نمی‌بینم آن را به میکروسرویس‌هایی تقسیم کنیم که مستقل مقیاس بگیرند. به‌جای آن، وقتی با محدودیت حافظه، CPU یا شبکه مواجه شدیم، می‌توانیم سرویس را افقی مقیاس بدهیم.

3) اشتراک‌گذاری بلادرنگ فعالیت‌ها با دوستان

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

این موضوع طراحی را به سیستم‌های هم‌زمان محبوب دیگر مثل نظرات زنده Facebook (FB Live Comments) یا WhatsApp نزدیک می‌کند.

برای فعال کردن اشتراک‌گذاری بلادرنگ با دوستان، به‌روزرسانی‌های دوره‌ای سرور را در طول فعالیت‌ها دوباره معرفی می‌کنیم. در حالی که منطق محلی دستگاه را برای داده کاربر نگه می‌داریم، موقعیت را هر 2 تا 5 ثانیه به سرور می‌فرستیم. وقتی سرور این به‌روزرسانی‌ها را می‌گیرد، آن‌ها را در پایگاه داده ذخیره می‌کند و برای همه دوستان کاربر پخش (Broadcast) می‌کند.

حالا می‌دانم احتمالاً به چه فکر می‌کنید: «WebSocket ها!» این به‌خودی‌خود اشتباه نیست و کاملاً می‌توانید این را با WebSocket یا SSE پیاده کنید، اما من قویاً استدلال می‌کنم که این برای مسئله مهندسی بیش از حد است. می‌توانید یک Real-time Tracking Service معرفی کنید که با WebSocket یا SSE به کلاینت‌های دوستان وصل شود و برای پخش به‌روزرسانی‌ها به دوستان از Pub/Sub استفاده کند، اما این پیچیدگی‌های غیرضروری زیادی می‌آورد.

به‌جای آن، دو بینش کلیدی نشان می‌دهد مکانیزم ساده‌تر Polling مؤثرتر خواهد بود:

  • به‌روزرسانی‌ها قابل پیش‌بینی‌اند: برخلاف پیام‌رسان یا نظرات زنده، می‌دانیم به‌روزرسانی بعدی باید در 2 تا 5 ثانیه آینده برسد. این قابل پیش‌بینی بودن، بازه‌های Polling کارآمد را ممکن می‌کند.
  • دقت بلادرنگ مهم نیست: دوستان لازم نیست اطلاعات لحظه‌ای ببینند. تأخیر جزئی چند ثانیه‌ای قابل قبول است و تأثیر قابل توجهی روی تجربه کاربری نمی‌گذارد.

با دانستن این بینش‌ها، می‌توانیم یک مکانیزم Polling ساده پیاده کنیم که در آن کلاینت‌های دوستان دوره‌ای و در همان بازه‌ای که دستگاه ورزشکار به‌روزرسانی‌ها را به سرور می‌فرستد، به‌روزرسانی می‌خواهند (با چند ثانیه تنظیم برای احتساب Latency).

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

4) نمایش فهرست ورزشکاران برتر (Leaderboard)

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

رویکرد بد: کوئری و تجمیع مستقیم از پایگاه داده

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

1
2
3
4
5
6
7
8
9
SELECT
    u.name,
    SUM(a.distance) as total_distance
FROM activities a
JOIN users u ON a.userId = u.userId
WHERE a.state = 'COMPLETE'
AND a.type = 'RUN'
GROUP BY u.userId
ORDER BY total_distance DESC

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

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

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

برای بهتر کردن رویکرد ساده‌لوحانه می‌توانیم سیستم تجمیع دوره‌ای پیاده کنیم. یعنی یک جدول امتیازات جدا می‌سازیم که مجموع از پیش محاسبه‌شده هر کاربر را نگه می‌دارد. یک Background Job در بازه‌های منظم (مثلاً روزانه) اجرا می‌شود تا این مجموع‌ها را بر اساس فعالیت‌های جدید به‌روز کند. سپس کوئری جدول امتیازات به یک SELECT ساده از این جدول از پیش تجمیع‌شده، مرتب‌شده بر اساس مسافت کل، تبدیل می‌شود.

چالش‌ها: اگرچه این روش زمان کوئری درخواست‌های جدول امتیازات را به‌طور محسوسی کم می‌کند، Eventual Consistency (سازگاری نهایی) می‌آورد. جدول امتیازات تغییرات بلادرنگ را نشان نمی‌دهد و اگر آخرین فعالیت کاربر فوراً در رتبه‌اش اثر نکند، ممکن است کاربر گیج شود. علاوه بر این، تعیین تناوب بهینه به‌روزرسانی دشوار است: به‌روزرسانی مکرر ممکن است منابع سیستم را تحت فشار بگذارد و به‌روزرسانی نادر باعث کهنگی داده می‌شود.

رویکرد عالی: Sorted Set در Redis

برای راه‌حل مقیاس‌پذیرتر و بلادرنگ می‌توانیم از Redis، یک ذخیره‌ساز ساختار داده درون‌حافظه‌ای، استفاده کنیم. از Sorted Set های Redis برای نگهداری جدول امتیازات استفاده می‌کنیم، با شناسه کاربر به‌عنوان عضو (Member) و مجموع مسافت او به‌عنوان امتیاز (Score). وقتی فعالیت جدیدی ثبت می‌شود، امتیاز کاربر را در Sorted Set افزایش می‌دهیم. برای کوئری از جدول امتیازات، فقط N عضو برتر را از Sorted Set می‌گیریم، که Redis می‌تواند بسیار کارآمد انجام دهد.

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

برای فیلتر کشور:

  • می‌توانیم برای هر کشور Sorted Set جدا بسازیم، مثلاً leaderboard:run:USA و leaderboard:run:Canada و غیره.
  • وقتی فعالیتی ثبت می‌شود، هم جدول امتیازات جهانی و هم جدول امتیازات مخصوص کشور را به‌روز می‌کنیم.
  • برای کوئری از جدول امتیازات مخصوص یک کشور، فقط از Sorted Set مناسب همان کشور می‌خوانیم.
1
2
3
4
5
6
# Add the activity distance to the user's total in the leaderboards
redis.zincrby("leaderboard:run:global", distance, user_id)
redis.zincrby("leaderboard:run:USA", distance, user_id)

# Query the leaderboard
redis.zrange("leaderboard:run:global", 0, -1, desc=True, withscores=True)

برای فیلتر بازه زمانی، رویکرد پیچیده‌تری داریم که Sorted Set و Hash را ترکیب می‌کند. از ترکیب Sorted Set ها و Hash های Redis برای ذخیره مهر زمانی و مسافت استفاده می‌کنیم. برای هر فعالیت این‌ها را ذخیره می‌کنیم:

  • یک ورودی Sorted Set که شناسه فعالیت عضو آن و مهر زمانی امتیاز آن است.
  • یک ورودی Hash که شناسه فعالیت کلید آن است و فیلدهایی برای شناسه کاربر و مسافت دارد.

هنگام کوئری جدول امتیازات برای یک بازه زمانی مشخص:

  1. با ZRANGEBYSCORE شناسه فعالیت‌های داخل بازه زمانی مطلوب را می‌گیریم.
  2. برای این شناسه‌های فعالیت، شناسه کاربر و مسافت مربوط را از Hash می‌گیریم.
  3. مسافت‌ها را بر اساس شناسه کاربر در حافظه جمع می‌زنیم.
  4. نتایج تجمیع‌شده را مرتب می‌کنیم تا جدول امتیازات نهایی ساخته شود.

نمونه شبه‌کد:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# When logging a new activity
activity_id = generate_unique_id()
redis.zadd("activities:timestamps", {activity_id: timestamp})
redis.hset(f"activity:{activity_id}", mapping={"user_id": user_id, "distance": distance})

# Querying the leaderboard for last 24 hours
now = current_timestamp()
day_ago = now - 24*60*60
activity_ids = redis.zrangebyscore("activities:timestamps", day_ago, now)

user_distances = {}
for activity_id in activity_ids:
    activity_data = redis.hgetall(f"activity:{activity_id}")
    user_id = activity_data["user_id"]
    distance = float(activity_data["distance"])
    user_distances[user_id] = user_distances.get(user_id, 0) + distance

# Sort users by total distance
leaderboard = sorted(user_distances.items(), key=lambda x: x[1], reverse=True)

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

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