پست

طراحی پلتفرم معاملات سهام مانند Robinhood

بررسی گام‌به‌گام طراحی پلتفرم کارگزاری سهام شامل نمایش زنده قیمت با SSE و Redis Pub/Sub، ثبت و لغو سفارش، پراکسی کردن اتصال به بورس و تضمین سازگاری سفارش‌ها با فرایند پاک‌سازی

طراحی پلتفرم معاملات سهام مانند Robinhood

درک مسئله

Robinhood یک پلتفرم معاملات بدون کمیسیون برای سهام، ETF ها، آپشن‌ها و ارزهای دیجیتال است. داده بازار را بلادرنگ نشان می‌دهد و مدیریت سفارش پایه دارد. Robinhood خودش بورس نیست، بلکه کارگزار سهام (Broker) است. معاملات را از طریق بازارسازها («بورس‌ها») مسیریابی می‌کند و این بورس‌ها از طریق «پرداخت برای جریان سفارش» (Payment for Order Flow) به آن غرامت می‌دهند.

زمینه: بازارهای مالی

چند اصطلاح مالی پایه هست که قبل از ورود به این طراحی باید بفهمید:

  • نماد (Symbol): مخفف مورد استفاده برای شناسایی یکتای سهام (مثل META و AAPL). به آن «تیکر» (Ticker) هم می‌گویند.
  • سفارش (Order): درخواستی برای خرید یا فروش سهام. می‌تواند سفارش بازار یا سفارش محدود باشد.
  • سفارش بازار (Market Order): درخواستی برای خرید یا فروش فوری سهام با قیمت فعلی بازار. قیمت هدف ندارد و فقط تعداد سهام را مشخص می‌کند.
  • سفارش محدود (Limit Order): درخواستی برای خرید یا فروش سهام با قیمت مشخص. تعداد سهام و قیمت هدف را مشخص می‌کند و می‌تواند در بورس منتظر تکمیل بماند یا توسط سازنده سفارش لغو شود.

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

برای هدف این مسئله می‌توانیم فرض کنیم Robinhood با یک «بورس» ارتباط دارد که این قابلیت‌ها را دارد:

  • پردازش سفارش: سفارش‌ها را هم‌زمان (Synchronous) از طریق API درخواست و پاسخ ثبت و لغو می‌کند.
  • فید معاملات (Trade Feed): اشتراک در فید معاملات برای نمادها را پیشنهاد می‌کند. هر بار که معامله‌ای انجام شود، داده را به کلاینت می‌فرستد، از جمله نماد، قیمت هر سهم، تعداد سهام و orderId.

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

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

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

  • کاربران بتوانند قیمت سهام را زنده ببینند.
  • کاربران بتوانند سفارش‌های سهام (سفارش بازار و محدود، ثبت و لغو سفارش) را مدیریت کنند.

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

  • کاربران بتوانند خارج از ساعات کاری بازار معامله کنند.
  • کاربران بتوانند ETF ها، آپشن‌ها و ارزهای دیجیتال را معامله کنند.
  • کاربران بتوانند دفتر سفارش (Order Book) را بلادرنگ ببینند.

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

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

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

  • سیستم سازگاری بالا را برای مدیریت سفارش ترجیح می‌دهد. این برای کاربران ضروری است که هنگام معامله، اطلاعات به‌روز سفارش را ببینند.
  • سیستم باید برای تعداد بالای معامله در روز مقیاس بگیرد (20 میلیون کاربر فعال روزانه، به‌طور میانگین 5 معامله در روز، 1000 نماد).
  • سیستم باید هنگام بازتاب به‌روزرسانی قیمت نماد و هنگام ثبت سفارش Latency (تأخیر) کم داشته باشد (زیر 200 میلی‌ثانیه).
  • سیستم باید تعداد کلاینت‌های فعال متصل به API خارجی بورس را حداقل کند. فیدهای داده بورس و اتصال‌های کلاینت معمولاً گران‌اند.

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

  • سیستم برای معاملات سهام به چند بورس وصل شود.
  • سیستم کارمزد و محاسبات معاملاتی را مدیریت کند (می‌توانیم فرض کنیم کارمزدها داخل محدوده نیستند).
  • سیستم محدودیت‌های روزانه را روی رفتار معاملاتی اعمال کند.
  • سیستم در برابر استفاده از ربات محافظت کند.

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

آماده‌سازی

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

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

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

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

برای Robinhood موجودیت‌های اولیه ساده‌اند:

  • User (کاربر): کاربر سیستم.
  • Symbol (نماد): سهامی که معامله می‌شود.
  • Order (سفارش): سفارش خرید یا فروش که توسط کاربر ساخته شده است.

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

API

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

با endpoint ای شروع می‌کنیم که نماد را همراه جزئیات و داده قیمت می‌گیرد:

1
2
GET /symbol/:name
Response: Symbol

برای ثبت سفارش، endpoint ممکن است این شکل را داشته باشد:

1
2
3
4
5
6
7
8
POST /order
Request: {
  position: "buy",
  symbol: "META",
  priceInCents: 52210,
  numShares: 10
}
Response: Order

توجه کنید که به‌جای price از priceInCents استفاده می‌کنیم تا از مشکلات دقت اعداد ممیز شناور (Floating-point) جلوگیری کنیم. به‌ویژه در کاربردهای مالی بهتر است برای جلوگیری از خطا از عدد صحیح استفاده کنید.

برای لغو سفارش، endpoint می‌تواند ساده باشد:

1
2
3
4
DELETE /order/:id
Response: {
  ok: true
}

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

1
2
GET /orders
Response: Order[] (paginated)

در همه این درخواست‌ها اطلاعات کاربر در هدرها (با Session Token یا JWT) منتقل می‌شود. این الگوی رایجی برای API هاست و راه خوبی برای تضمین احراز هویت و مجاز بودن کاربر برای انجام عمل است. اطلاعات کاربر را در بدنه درخواست نفرستید، چون کلاینت به‌راحتی می‌تواند آن را دستکاری کند.

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

1) مشاهده قیمت زنده سهام

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

رویکرد بد: Polling مستقیم بورس

این راه‌حل شامل Poll کردن مستقیم بورس برای داده قیمت برای هر نماد است. کلاینت هر چند ثانیه یک‌بار (برای هر نماد) Poll می‌کند و قیمت نشان‌داده‌شده به کاربر را بر اساس پاسخ به‌روز می‌کند.

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

  • تماس‌های اضافی با بورس: این رویکرد روش بسیار ناکارآمدی برای گرفتن اطلاعات قیمت از نظر حجم تماس با بورس است. شامل Polling ای است که بی‌رویه رخ می‌دهد، حتی اگر قیمت تغییر نکرده باشد. علاوه بر این، کلاینت‌های زیادی را درگیر می‌کند که اطلاعات یکسانی را از بورس می‌خواهند، که بیهوده است. اگر 5000 کلاینت هم‌زمان قیمت بخواهند، قیمت برای هر کلاینت فرق نمی‌کند، اما ما 5000 تماس (مکرر) می‌گیریم تا همین اطلاعات پخش شود.
  • به‌روزرسانی کند: اگر دنبال راه‌حل Polling باشیم، به‌روزرسانی اطلاعات قیمت نمادها کندتر می‌شود. در نیازمندی‌های غیرعملکردی گفتیم SLA نسبتاً کوتاهی برای به‌روز کردن کلاینت‌ها درباره قیمت نمادها می‌خواهیم (زیر 200 میلی‌ثانیه)، و تنها راه تضمین این با این راه‌حل Poll کردن داده هر 200 میلی‌ثانیه است، که غیرمنطقی است.

برای طراحی مثل این، می‌توانیم Polling بورس را به‌عنوان گزینه مناسب رد کنیم.

رویکرد خوب: Polling از Symbol Service با کش

این راه‌حل هنوز شامل Polling برای اطلاعات قیمت است، اما ما یک Symbol Service را Poll می‌کنیم که جست‌وجوی کلید-مقدار را در کش داخلی انجام می‌دهد. این کش توسط یک Symbol Price Processor که به قیمت‌های بورس گوش می‌دهد به‌روز نگه داشته می‌شود.

این رویکرد با «پراکسی کردن» از طریق سرویسی که ضروری‌ترین جزئیات، یعنی قیمت نماد را می‌شنود و ثبت می‌کند، از اتصال‌های اضافه به بورس جلوگیری می‌کند. بعد این قیمت از طریق Polling در اختیار کلاینت‌های Robinhood قرار می‌گیرد.

چالش‌ها: این رویکرد قطعاً پیشرفت است، اما هنوز مشکلاتی دارد. کلاینت‌ها هنوز بی‌رویه Poll می‌کنند، حتی اگر قیمت تغییر نکرده باشد، که به هدررفت ترافیک HTTP می‌رسد. علاوه بر این، این رویکرد روش کندی برای دریافت به‌روزرسانی قیمت است. فاصله Polling بدترین حالت SLA را برای به‌روزرسانی قیمتی که به کلاینت می‌رسد تعیین می‌کند. آیا می‌توانیم بهتر عمل کنیم؟

رویکرد عالی: Server-Sent Events (SSE)

یک رویکرد عالی اینجا Server-Sent Events (SSE) است. SSE یک اتصال پایدار است (مشابه WebSocket ها)، ولی یک‌طرفه است و به‌جای پروتکل جدا، روی HTTP کار می‌کند. در این مثال، کلاینت داده‌ای برای ما نمی‌فرستد، پس SSE انتخاب برتر نسبت به WebSocket است. برای جزئیات بیشتر درباره مبادله WebSocket و SSE، تحلیل «نظرات زنده Facebook» را ببینید.

الگو: به‌روزرسانی بلادرنگ (Real-time Updates). به‌روزرسانی لحظه‌ای قیمت سهام نمونه کاملی از الگوی به‌روزرسانی بلادرنگ است. اینجا از SSE برای ساخت اتصال‌های پایدار استفاده می‌کنیم که به سرورهای ما اجازه می‌دهد تغییرات قیمت واقعی را فوراً به کلاینت‌ها برسانند. این رویکرد اصول شبکه‌سازی ارتباطات بلادرنگ را مدیریت می‌کند و در عین حال تضمین می‌کند کاربران قیمت‌های فعلی بازار را بدون ناکارآمدی Polling مداوم می‌بینند.

این رویکرد شامل بازکاری API ما برای داشتن درخواست POST /subscribe است، با بدنه‌ای که فهرستی از نمادهایی را که می‌خواهیم مشترکشان شویم دارد. سپس باطن ما می‌تواند یک اتصال SSE بین کلاینت و Symbol Service راه بیندازد و فهرست اولیه قیمت نمادها را برای کلاینت بفرستد. این فهرست اولیه قیمت‌ها از یک کش سرو می‌شود که توسط پردازنده‌ای که به بورس گوش می‌دهد به‌روز نگه داشته می‌شود. به‌علاوه، آن پردازنده قیمت‌ها را به Symbol Service ما هم می‌فرستد تا Symbol Service بتواند آن داده را برای کلاینت‌هایی که مشترک به‌روزرسانی قیمت نمادهای مختلف‌اند Push کند.

چالش‌ها: اضافه کردن SSE چالش‌هایی می‌آورد که عمدتاً شامل حفظ ارتباط بین کلاینت و سرور است. ممکن است Load Balancer باید طوری پیکربندی شود که از «جلسات چسبنده» (Sticky Sessions) پشتیبانی کند تا کلاینت و سرور بتوانند ارتباط برقرار کنند. علاوه بر این، اتصال پایدار یعنی باید فکر کنیم قطع و وصل مجدد چطور کار می‌کند. در نهایت باید فکر کنیم اتصال‌های کاربر و داده نمادها چطور مسیریابی شوند. بعضی از این جزئیات بعداً در بخش‌های بررسی عمیق پوشش داده می‌شود، پس با ما بمانید.

2) مدیریت سفارش‌های سهام

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

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

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

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

رویکرد خوب: Order Service و صف به Order Dispatch Service

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

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

اگر لحظه‌های بار بالای صف را در نظر بگیریم، مثلاً هنگام ترافیک بالای معاملات و قبل از مقیاس گرفتن Order Dispatch Service، ارسال سفارش‌ها به بورس ممکن است مدتی طول بکشد و SLA هدف ما را نقض کند. در لحظه‌های حساس معاملاتی این برای کاربران می‌تواند بسیار بد باشد. کاربری را تصور کنید که می‌خواهد سریع سهام سفارش بدهد یا سریع سفارش معلقی را لغو کند. قابل قبول نیست که منتظر بماند تا Dispatcher ما در نهایت سفارشش را اجرا کند یا سرویس ما ماشین‌های بیشتری برای مقیاس با افزایش بار صف بالا بیاورد. چه گزینه‌های دیگری داریم؟

رویکرد عالی: Order Service مستقیم با NAT Gateway

این رویکرد شامل ارسال مستقیم سفارش‌ها از Order Service به یک Order Dispatch Gateway است. این Gateway ارتباط اینترنتی خارجی با بورس را از طریق Order Service ممکن می‌کند. Gateway باعث می‌شود درخواست‌ها به بورس طوری به نظر برسند که از مجموعه کوچکی از IP ها آمده‌اند.

برای این رویکرد، Gateway ما یک AWS NAT Gateway خواهد بود که به Order Service ما اجازه می‌دهد درخواست‌هایی به بورس بدهد، اما تحت یک یا تعداد کمی IP (Elastic IP ها، در اصطلاح AWS) ظاهر شود.

چون Gateway درخواست‌های خروجی را مدیریت می‌کند، این رویکرد روی Order Service که درخواست‌های کلاینت را می‌پذیرد تکیه می‌کند تا در مدیریت واقعی سفارش‌ها نقش داشته باشد. این سرویس منطق کسب‌وکار را برای مدیریت سفارش‌ها اجرا می‌کند و در صورت لزوم حجم سفارش را کنترل (Throttle) می‌کند. چون بار را کلاینت‌ها هدایت می‌کنند، می‌توانیم معیار Auto Scaling این سرویس را کاملاً حساس بگذاریم (مثلاً وقتی میانگین مصرف CPU در ناوگان به 50 درصد رسید مقیاس بگیرد) یا این سرویس را بیش از حد تأمین (Over-provision) کنیم تا جهش‌های معاملاتی را جذب کند.

چالش‌ها: این رویکرد از Order Service ما می‌خواهد سفارش‌ها را مدیریت کند، یعنی باید طوری نوشته شود که هم برای تعامل با کلاینت و هم برای تعامل با بورس کارآمد باشد (مثلاً سفارش‌ها را دسته‌ای کنند).

ذخیره سفارش‌ها و پردازشگر معاملات

حالا که می‌دانیم سفارش‌ها را چطور به بورس می‌فرستیم، باید در نظر بگیریم سفارش‌ها را سمت خودمان چطور ذخیره کنیم تا به کاربر نشانشان بدهیم. کاربر باید بتواند /orders را بگیرد تا همه سفارش‌های معلق خودش را ببیند. پس چطور داده را سمت خودمان نگه داریم تا این درخواست را پاسخ بدهیم؟

برای ردیابی سفارش‌ها می‌توانیم Order DB را بسازیم که هنگام ثبت یا لغو سفارش‌ها به‌روز می‌شود. Order DB یک پایگاه داده رابطه‌ای خواهد بود تا از طریق تضمین‌های ACID سازگاری را ارتقا دهد و بر اساس userId (شناسه کاربری که سفارش را تغییر می‌دهد) Shard شود. این باعث می‌شود کوئری سفارش‌های یک کاربر سریع باشد، چون کوئری به یک گره واحد می‌رود.

خود سفارش اطلاعاتی درباره سفارش ثبت‌شده یا لغوشده دارد. همچنین داده‌ای درباره وضعیت سفارش دارد (مثلاً «در انتظار» قبل از ارسال به بورس، «ارسال‌شده» وقتی به بورس فرستاده شده و غیره). در نهایت سفارش یک فیلد externalOrderId دارد که با شناسه‌ای پر می‌شود که بورس وقتی Order Service سفارش را هم‌زمان می‌فرستد با آن پاسخ می‌دهد.

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

شایان ذکر است این Trade Processor ناوگانی از ماشین‌هاست که به‌نوعی به بورس وصل می‌شوند تا به‌روزرسانی قیمت بگیرند. رابط ارتباطی اینجا خیلی مهم نیست. برای بیشتر سیستم‌هایی مثل این، کلاینت بورس مثل Robinhood یک endpoint ‏Webhook راه می‌اندازد و آن را در بورس ثبت می‌کند، و بورس هر وقت به‌روزرسانی داشت Webhook را صدا می‌زند. در مورد Webhook ها، Trade Processor یک Load Balancer و ناوگانی از ماشین‌ها دارد که به آن endpoint ‏Webhook خدمت می‌کنند. برای سادگی، Trade Processor را در فلوچارت به‌صورت یک مربع تصور می‌کنیم.

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

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

1) مقیاس‌دهی به‌روزرسانی قیمت زنده

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

مشکل اصلی که باید حل کنیم این است: چطور به‌روزرسانی‌های قیمت نماد را به سرورهای Symbol Service ای که به کاربرانی که به آن به‌روزرسانی‌ها اهمیت می‌دهند وصل‌اند، مسیریابی کنیم؟

برای ممکن کردن این عملکرد به روش مقیاس‌پذیر، می‌توانیم از Redis Pub/Sub (الگوی انتشار/اشتراک) برای انتشار و اشتراک به‌روزرسانی قیمت‌ها استفاده کنیم. کاربران می‌توانند از طریق Symbol Service مشترک به‌روزرسانی قیمت شوند و Symbol Service می‌تواند از طریق Redis اشتراک به‌روزرسانی قیمت نمادها را برای همه نمادهایی که کاربران به آن‌ها اهمیت می‌دهند تضمین کند.

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

  1. کاربر از طریق سرور Symbol Service مشترک می‌شود. سرور نگاشت Symbol -> Set را دنبال می‌کند، یعنی برای هر نمادی که کاربر مشترک آن است یک ورودی اضافه می‌کند.
  2. سرور Symbol Service اشتراک‌های Redis Pub/Sub را برای کانال‌های مربوط به هر نماد مدیریت می‌کند. این تضمین می‌کند برای هر نمادی که کاربر مشترک آن است، یک اشتراک فعال دارد. اگر اشتراک ندارد، برای دریافت نماد در کانال مشترک می‌شود.
  3. وقتی نمادی قیمتش را تغییر می‌دهد، آن به‌روزرسانی قیمت توسط Symbol Price Processor پردازش می‌شود، که به‌روزرسانی قیمت را در Redis منتشر می‌کند. هر سرور Symbol Service که مشترک به‌روزرسانی قیمت آن نماد است، آن را از طریق اشتراک دریافت می‌کند. سپس آن به‌روزرسانی قیمت را برای همه کاربرانی که به قیمت آن نماد اهمیت می‌دهند Push می‌کند.
  4. اگر کاربر اشتراک نمادها را لغو کند یا ارتباطش قطع شود (که با مکانیزم Heartbeat تشخیص داده می‌شود)، سرور Symbol Service روی هر نمادی که کاربر مشترک آن بوده پیمایش می‌کند و او را از Set حذف می‌کند. اگر سرور Symbol Service دیگر هیچ کاربر مشترک نمادی نداشته باشد، می‌تواند اشتراک خود را از کانال Redis آن نماد لغو کند.

موارد بالا مقیاس می‌گیرند، چون به ما اجازه می‌دهند بار را بین Symbol Service ها یکنواخت توزیع کنیم. علاوه بر این، مدیریت به‌روزرسانی‌های قیمتی که روی سرورهای Symbol Service منتشر می‌شوند خودتنظیم است.

2) ردیابی به‌روزرسانی سفارش‌ها

هنگام بررسی جریان ثبت سفارش، ارزش دارد مشخص کنیم چطور مطمئن شویم Order DB ما با به‌روزرسانی سفارش‌ها در بورس هماهنگ است.

اولاً، از طراحی فعلی ما روشن نیست Trade Processor چطور می‌تواند فقط بر اساس یک معامله، به‌روزرسانی را در Order DB منعکس کند. چون جدول بر اساس userId Shard شده، راه کارآمدی نیست که Trade Processor از طریق externalOrderId ردیف مربوط به به‌روزرسانی در جدول Order را پیدا کند. این به یک نگاشت کلید-مقدار جداگانه از externalOrderId به (orderId, userId) مربوط به آن نیاز دارد. برای این ذخیره‌ساز کلید-مقدار می‌توانیم از چیزی مثل RocksDB استفاده کنیم. این ذخیره‌ساز کلید-مقدار بعد از ارسال هم‌زمان سفارش به بورس توسط Order Service پر می‌شود.

این ذخیره‌ساز کلید-مقدار جدید به Trade Processor اجازه می‌دهد سریع تشخیص بدهد معامله شامل سفارشی از سیستم ما هست یا نه و بعد سفارش را پیدا کند (با userId به Shard درست برود و سفارش را با orderId به‌روز کند). جریان این‌طور می‌شود: Trade Processor معامله را از بورس می‌گیرد، externalOrderId را در ذخیره‌ساز کلید-مقدار جست‌وجو می‌کند تا (orderId, userId) را بگیرد، بعد با userId Shard درست Order DB را پیدا می‌کند و جزئیات سفارش را به‌روز می‌کند.

3) مدیریت سازگاری سفارش

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

قبل از بررسی، اول مکانیزم ذخیره‌سازی سفارش را مرور می‌کنیم. Order DB ما یک پایگاه داده رابطه‌ای Shard شده افقی (مثلاً Postgres) خواهد بود. همه به‌روزرسانی‌های سفارش روی یک گره (Shard ای که سفارش در آن است) رخ می‌دهد. همه خواندن‌های سفارش هم روی یک گره انجام می‌شود، چون بر اساس userId Shard می‌کنیم.

جریان ثبت سفارش

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

  1. سفارش را با وضعیت «در انتظار» (Pending) در Order DB ذخیره کنید. مهم است که این اول ذخیره شود، چون از مشتری سابقه‌ای داریم که می‌توانیم به آن مراجعه کنیم. اگر اول این را ذخیره نمی‌کردیم، می‌توانست سفارشی در بورس ساخته شود، سیستم از کار بیفتد و بعد سیستم ما راهی برای فهمیدن وجود سفارش معلق نداشته باشد.
  2. سفارش را به بورس بفرستید. فوراً externalOrderId را پس می‌گیرید (ارسال سفارش هم‌زمان است).
  3. externalOrderId را در ذخیره‌ساز کلید-مقدار ما بنویسید و سفارش را در Order DB با وضعیت «ارسال‌شده» (Submitted) و externalOrderId دریافت‌شده به‌روز کنید.
  4. به مشتری پاسخ بدهید که سفارش موفق بود.

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

  • ذخیره سفارش ناموفق: اگر در ذخیره سفارش مشکلی بود، می‌توانیم به مشتری با شکست پاسخ بدهیم و گردش کار را متوقف کنیم.
  • ارسال سفارش به بورس ناموفق: اگر ارسال سفارش به بورس شکست خورد، می‌توانیم سفارش را «ناموفق» (Failed) علامت بزنیم و به مشتری پاسخ بدهیم.
  • پردازش ناموفق سفارش بعد از ارسال به بورس: اگر هنگام به‌روزرسانی پایگاه داده بعد از ارسال به بورس خطایی رخ داد، ممکن است یک کار «پاک‌سازی» (Cleanup) داشته باشیم که به سفارش‌های معلقِ گیرکرده در پایگاه داده می‌پردازد. بیشتر API های بورس هنگام ارسال سفارش یک فیلد Metadata به نام clientOrderId می‌دهند (مثلاً API سفارش E*TRADE را ببینید)، پس کار «پاک‌سازی» می‌تواند ناهم‌زمان از بورس بپرسد آیا سفارشی با این clientOrderId انجام شده یا نه، و یکی از این دو کار را بکند: ۱) اگر سفارش انجام شده، externalOrderId را ثبت کند، یا ۲) اگر انجام نشده، سفارش را «ناموفق» علامت بزند.

جریان لغو سفارش

حالا جریان لغو را در نظر می‌گیریم. وقتی سفارشی لغو می‌شود، سیستم این گردش کار را طی می‌کند:

  1. وضعیت سفارش را به pending_cancel به‌روز کنید. این را اول انجام می‌دهیم تا بعداً لغوهای ناموفق را حل کنیم.
  2. لغو سفارش را به بورس بفرستید.
  3. لغو سفارش را در پایگاه داده ثبت کنید.
  4. به مشتری پاسخ بدهید که لغو موفق بود.

شکست‌های مختلف را مرور می‌کنیم تا مطمئن شویم از ناهماهنگی در امانیم:

  • به‌روزرسانی ناموفق وضعیت به pending_cancel: اگر همان ابتدا در به‌روزرسانی وضعیت سفارش مشکلی باشد، به مشتری با خطا پاسخ می‌دهیم و گردش کار را متوقف می‌کنیم.
  • لغو ناموفق سفارش در بورس: اگر لغو سفارش در بورس شکست خورد، می‌توانیم به مشتری با شکست پاسخ بدهیم و برای اسکن سفارش‌های pending_cancel به فرایند «پاک‌سازی» تکیه کنیم (تا مطمئن شویم لغو شده‌اند).
  • ذخیره نشدن وضعیت «لغو‌شده» در پایگاه داده: اگر در به‌روزرسانی وضعیت سفارش در پایگاه داده مشکلی رخ داد، می‌توانیم به فرایند «پاک‌سازی» تکیه کنیم تا سفارش‌های pending_cancel را بررسی کند و وضعیت لغو را به‌روز کند (اگر در بورس قبلاً لغو شده باشد).

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

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

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

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

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

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

سطح Mid-level

  • عرض در برابر عمق: تمرکز بیشتر روی عرض است (حدود 80 درصد عرض و 20 درصد عمق). باید بتوانید طراحی سطح بالایی بسازید که نیازمندی‌های عملکردی تعریف‌شده را پوشش بدهد، هرچند با بسیاری از اجزا فقط آشنایی سطحی داشته باشید.
  • مرور مبانی: مصاحبه‌کننده مدتی را صرف بررسی مبانی می‌کند تا مطمئن شود می‌دانید هر جزء چه کاری می‌کند. مثلاً اگر API Gateway اضافه کنید، انتظار داشته باشید بپرسد چه کاری می‌کند و در سطح بالا چطور کار می‌کند. او هیچ چیز را بدیهی فرض نمی‌کند.
  • ترکیب هدایت و همراهی: در مراحل اولیه مصاحبه باید خودتان جلو بروید، اما مصاحبه‌کننده انتظار ندارد مشکلات طراحی را با دقت بالا پیش‌دستانه تشخیص بدهید. طبیعی است که در مراحل بعد، با بررسی طراحی شما، خودش هدایت را به دست بگیرد.
  • معیار برای Robinhood: داوطلب E4 باید endpoint های API و مدل داده را روشن تعریف کرده باشد و طراحی سطح بالایی ساخته باشد که برای به‌روزرسانی قیمت و سفارش‌دهی کاربردی است. انتظار ندارم داوطلبان دانش عمیقی از فناوری‌های خاص داشته باشند، اما داوطلب باید روی ایده‌هایی همگرا شود که انتشار کارآمد به‌روزرسانی قیمت و مدیریت پایدار سفارش را شامل می‌شوند. همچنین انتظار دارم داوطلب راه‌های مؤثر پراکسی کردن بورس را برای جلوگیری از اتصال‌ها یا کلاینت‌های اضافه بداند.

سطح Senior

  • عمق تخصص: انتظار به سمت دانش عمیق‌تر می‌رود، حدود 60 درصد عرض و 40 درصد عمق. باید در حوزه‌هایی که تجربه عملی دارید وارد جزئیات فنی شوید و درک عمیقی از مفاهیم و فناوری‌های کلیدی مسئله نشان بدهید.
  • طراحی سیستم پیشرفته: باید با اصول پیشرفته طراحی سیستم (فناوری‌های مختلف، موارد استفاده‌شان و چگونگی تطبیق آن‌ها با هم) آشنا باشید. توانایی شما در حرکت با اطمینان و وضوح در این موضوع‌های پیشرفته کلیدی است.
  • تشریح تصمیم‌های معماری: باید مزایا و معایب انتخاب‌های معماری را روشن بیان کنید، به‌ویژه اثر آن‌ها روی مقیاس‌پذیری، عملکرد و قابلیت نگهداری، و تصمیم‌هایتان را توجیه کنید.
  • حل مسئله و پیش‌قدمی: باید مهارت حل مسئله قوی و رویکرد فعالانه نشان بدهید. یعنی چالش‌های احتمالی طراحی را پیش‌بینی کنید، بهبود پیشنهاد بدهید، گلوگاه‌ها را پیدا و رفع کنید، عملکرد را بهینه کنید و از قابلیت اطمینان سیستم مطمئن شوید.
  • معیار برای Robinhood: از داوطلبان E5 انتظار می‌رود طراحی سطح بالای اولیه را سریع جلو ببرند تا بتوانند وقتشان را صرف بحث درباره نحوه مدیریت انتشار بلادرنگ قیمت و سفارش‌های سازگار کنند. انتظار دارم داوطلب راه‌حل معقول و مقیاس‌پذیری برای قیمت‌های زنده طراحی کند و گردش کار سفارش خوبی با در نظر گرفتن سازگاری و Fault Tolerance (تحمل خطا) طراحی کند.

سطح Staff به بالا

  • تأکید بر عمق: انتظار می‌رود وارد ظرافت‌های طراحی سیستم شوید، با حدود 40 درصد عرض و 60 درصد عمق. حتی اگر این مسئله خاص را قبلاً حل نکرده باشید، باید در دنیای واقعی آن‌قدر مسئله حل کرده باشید که با پشتوانه تجربه‌تان با اطمینان راه‌حل طراحی کنید.
  • دانش عملی فناوری‌ها: باید بدانید از چه فناوری‌هایی استفاده کنید، نه فقط در تئوری بلکه در عمل، و با تجربه‌های قبلی توضیح بدهید چطور برای حل مسائل خاص به کار می‌روند. مصاحبه‌کننده می‌داند مباحث ساده‌تر (REST API، نرمال‌سازی داده و غیره) را می‌دانید، پس سریع از آن‌ها عبور می‌کنید تا برای بخش‌های جذاب‌تر وقت بماند.
  • پیش‌قدمی بالا: در این سطح انتظار درجه استثنایی از پیش‌قدمی می‌رود. باید مسائل را مستقل شناسایی و حل کنید و چالش‌های اصلی طراحی را بشناسید. این فقط پاسخ به مشکلات پس از بروز نیست، بلکه پیش‌بینی آن‌ها و اجرای راه‌حل‌های پیشگیرانه هم هست. مصاحبه‌کننده فقط باید برای تمرکز دخالت کند، نه برای هدایت.
  • کاربرد عملی فناوری: باید در به‌کارگیری عملی فناوری‌های مختلف مسلط باشید. تجربه شما باید گفت‌وگو را هدایت کند و درک روشنی از پیکربندی ابزارها و سیستم‌ها در سناریوهای واقعی نشان بدهد.
  • حل مسئله پیچیده و تصمیم‌گیری: نه‌تنها باید چالش‌های فنی پیچیده را حل کنید، بلکه تصمیم‌های آگاهانه‌ای بگیرید که مقیاس‌پذیری، عملکرد، قابلیت اطمینان و نگهداری را در نظر می‌گیرد.
  • طراحی پیشرفته و مقیاس‌پذیری: رویکرد شما باید بر مقیاس‌پذیری و قابلیت اطمینان در بار بالا متمرکز باشد. این شامل درک کامل سیستم‌های توزیع‌شده، Load Balancing، استراتژی‌های ذخیره‌سازی و دیگر مفاهیم پیشرفته است.
  • معیار برای Robinhood: برای داوطلب Staff به بالا، انتظار از عمق و کیفیت راه‌حل‌ها بالاست، به‌ویژه برای سناریوهای پیچیده‌ای که بحث شد. داوطلبان استثنایی در هر یک از موضوع‌های بالا عمیق می‌شوند و حتی ممکن است گفت‌وگو را به سمت موضوعی که به نظرشان جالب یا مرتبط‌تر است هدایت کنند و گسترده روی آن تمرکز کنند. همچنین انتظار می‌رود درک کاملی از مبادلات بین راه‌حل‌های مختلف داشته باشند، بتوانند آن‌ها را روشن بیان کنند و با مصاحبه‌کننده مثل یک همتا رفتار کنند.