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

وقتی چندین میکروسرویس (مثل سرویس معامله، سرویس برداشت، سرویس انتقال دارایی) به طور همزمان به سرویس کیفپول درخواست بزنن، چند چالش اصلی میتونه بوجود بیاد:
سناریو:
کاربر ۱۰۰ تا موجودی داره.
درخواست A برداشت ۵۰ تا: خوندن موجودی به مقدار ۱۰۰ تا
درخواست B برداشت ۳۰ تا: خوندن موجودی قبل از ذخیره شدن A به مقدار ۱۰۰ تا
نوشتن درخواست A: مقدار ۱۰۰ منهای ۵۰ مساوی ۵۰ تا
نوشتن درخواست B: مقدار ۱۰۰ منهای ۳۰ مساوی ۷۰ تا
نتیجه: دیتابیس میگه موجودی ۷۰ تاست ولی در عمل کاربر ۸۰ تا استفاده کرده و باید موجودی ۲۰ تا باشه. صرافی پول از دست داده.
در صرافیها برای مدیریت کیفپول اصلی صرافی و یا کاربرهای وال که هزاران تراکنش در ثانیه دارن، اگر به ازای هر تراکنش قفل بزنیم، دیتابیس به یک Bottleneck تبدیل میشه و CPU درگیر مدیریت قفلها میشه بجای انجام پردازشها.
سناریو:
کاربر A به کاربر B پول میفرسته و کاربر B به کاربر A پول میفرسته.
ترنزکشن ۱ میاد و A رو لاک میکنه و برای B صبر میکنه.
ترنزکشن ۲ میاد و B رو لاک میکنه و برای A صبر کیکنه.
نتیجه: هردو freeze میشن تا زمانی که دیتابیس time out بشه.
اطمینان از اینکه هر درخواست، فقط یکبار اجرا میشه، ممکنه کاربر یا gateway به خاطر تایماوت شبکه چندین بار retry کنه. راهکار اینه که یک مکانیزم برای ایجاد Idempotency-Key یکتا برای هر عملیات مالی ایجاد کنیم و اگر جدید بود برچسب درحالاجرا بزنیم و بریم پردازشش کنیم، اگر درحالاجرا بود ردش کنیم و یا تو صف بذاریم. اگرم کامل شده بود، نتیجه رو بدون انجام پردازش مجدد براش بفرستیم.
سناریو: کاربر موجودیش رو ۱۰۰تا اضافه کرده. ما اومدیم و بالانسش رو ۱۰۰ تا بالا بردیم و همزمان روی کافکا هم مینویسیم که موجودیش افزایش پیدا کرده که بقیه سرویسها (مثل حسابداری و...) عملیاتشون انجام بدن. حالا فرض کنین این وسط سیستم مشکل بخوره و موجودی توی ردیس افزایش پیدا کنه اما روی کافکا نوشته نشه. اینجوری atomically write غیر ممکنه.
راهکارش اینه که به طور اتومیک ترنزکشن همزمان هم موجودی رو آپدیت کنیم و هم توی یک جدول به صول WAL (Write-Ahead Log) این تغییر موجودی رو به صورت pending بنویسیم و یک سرویس دیگه این جدول رو مانیتور کنه و روی کافکا بنویسه و status شو آپدیت کنه.
سناریو: فرض کنین که پیام برداشت ۱۰۰ تا از حساب رو از روی کافکا خوندیم، و وسط کار پردازش کرش کرده، دو حالت ممکنه پیش بیاد:
کامیت خوندن از کافکا رو قبل از ثبت دیتابیس انجام دادیم: بعد که سرویس بیاد بالا، با اینکه برداشت در دیتابیس ثبت نشده، فک میکنه موفق بوده.
کامیت خوندن از کافکا رو بعد از ثبت دیتابیس انجام دادیم: توی دیتابیس ثبت کردیم و سیستم بدون کامیت کافکا مشکل خورده و بعد که دوباره بالا میاد، مجدد برداشت رو ثبت میکنه.
راهکار: استفاده از Checkpointing Strategy با Idempotency یعنی در واقع کامیت رو بعد ثبت در دیتابیس انجام میدیم و به کمک Idempotency مطمئن میشیم که فقط یکبار انجام خواهد شد. چالش دیگهای که وجود داره اینه که کامیت بعد هر پیام کنده که راهکارهاش قابل بررسیه.
نقاط قوت: تضمین میکنه که race condition اتفاق نمیافته.
نقاط ضعف: قاتل پرفرمنسه. همه درخواستهای دیگه کاربر باید توی صف صبر کنن. اگر لاجیک ما ۱۰۰ میلیثانیه زمان ببره، حداکثر throughput (توان عملیاتی) کاربر ۱۰ درخواست در ثانیه خواهد بود (10 TPS).
نتیجه: برای کیفپولای با فرکانس پایین خوبه ولی مناسب Hot Wallet ها نیست.
به جدولها یک فیلد ورژن اضافه میکنیم. موقع درخواست نوشتن چک میکنیم که ورژن تغییر نکرده باشه.
تریدآف: اگر دیتابیس گزارش بده که ۰ ردیف آپدیت شده، یعنی ورژن تغییر کرده و باید اپلیکیشن retry کنه.
نتیجه: در صرافیهای با فرکانس بالا که هزاران درخواست در ثانیه رو میخوان هندل کنن، ۹۹ درصد درخواستها ناموفق میشن و Retry Storm بوجود میاد.
حساب کتاب ریاضی رو توی دیتابیس انجام میدیم: SET balance = balance - 50
نقاط قوت: فوقالعاده سریعه، دیتابیس خودش برای کمترین مقدار ممکن قفل رو مدیریت میکنه.
نقاط ضعف: مقدار بالانس رو بدون اینکه یک درخواست دوم بزنی و بخونیش، نمیدونی. و سخته که لاجیکهای پیچیده رو به این روش پیاده کرد.
این معماریهای اولیه رو اول در نظر بگیریم:
بجای سروکله زدن با قفلها، با قراردادن همه درخواستها در یک صف، کلا concurency رو از بین میبریم.
همه درخواستها (خرید/فروش/برداشت/انتقال) رو در یک صف (Kafka/RabbitMQ) به ازای هر user_id قرار میدیم. در واقع این همون WAL (Write-Ahead Log) هست.
یک Consumer (Worker) واحد به ازای هر user_id قرار میدیم که بخونه و وارد دیتابیس بکنه، چون یک پروسس واحد به ازای هر کاربر این کار رو میکنه، دیگه نیازی به database lock وجود نداره.
بالانس کاربر در ردیس نگهداری میشه (یا به هر روش دیگهای داخل مموری). و با کمک دستورای (INCRBY/DECRBY) به صورت اتومیک آپدیت میشه. و چون ذاتا ردیس به ازای هر فرمان single-threaded هست، به طور پیشفرش atomically safe هست. و اگر ناموفق بود باید کل پروسه رو revert کرد.
اما ترفند کار اینجاست که به طور همزمان ما نمیایم توی دیتابیس هم بنویسیم، میایم و لاگ ترنزکشن رو توی queue/DB به صورت Asynchronous مینویسیم (Write-Behind).
این معماریها سادهسازی شده که پیچیدگی فهمشون کمتر باشه. به عنوان مثال، راهکارهای بعضی از چالشهایی که در اول مقاله آوردم رو در نهایت باید بهشون اضافه کرد.
به این روش Event Sourcing pattern with In-Memory state هم میگن.
کانسپت: ورکر (پروسسر) کارش فقط خوندن و توی دیتابیس نوشتن نیست، بلکه یک stateful microservice هست که بالانس کاربر رو توی RAM نگه می داره.
درخواست وارد صف میشه > ورکر ایونت رو میخونه > رم رو بلافاصله آپدیت میکنه > توی دیتابیس به صورت غیرهمزمان ثبت میکنه (background thread).
وقتی درخواست بالانس میکنی، درخواست رو از یک مسیر خیلی سریع (high-priority channel or RPC) به ورکر میفرستی و میپرسی که موجودی کاربر در RAM چقدر است؟
اگر ردیس به مشکل خورد، دوباره بالانس رو با اجرای دوباره لاگ ترنزکشنهای ذخیره شده در دیتابیس میسازیم. در واقع ما میایم و هر ۱۰ دقیقه یا هر ۱۰ هزار درخواست، کل ردیس رو روی هارد Snapshots میگیریم و بعد Crash/Restart آخرین اسنپشات رو لود میکنیم و WAL رو از بعد اون اجرا میکنیم.
این روش مرسومی برای کیفپولهاس استاندارد و یا سیستمهای بانکی کلاسیکه، ولی مناسب HFT های جدید با توجه به تاخیری که در حد میلیثانیه میتونه ایجاد کنه نیست.
داده توی ردیس نگهداری میشه و توسط ingest service که در واقع API Gateway Layer هست، قبل از صف مدیریت میشه.
کاربر درخواست برداشت ۵۰ تا رو میزنه > لایه ورودی (Ingest Layer): بالانس ردیس رو چک میکنه که ۱۰۰ تاست، بلافاصله مقدار موجودی ۵۰ تا و موجودی قفل شده ۵۰ تا رو توی ردیس ثبت میکنه و درخواست برداشت ۵۰ تا رو به کافکا میفرسته > لایه پردازش (Processor): مقدار رو از کافکا میخونه، عملیات برداشت رو انجام میده و اگر موفق بود، ایونت تایید رو ارسال میکنه > موقعی که رویداد تایید میاد، لایه ورودی مقداد locked_balance رو حذف میکنه. اگر برداشت مشکل خورد باید موجودی قفل شده به موجودی اصلی برگردونده بشه.