هرکسی کار خودش، بار خودش، دیتا توی دیتابیس خودش !

Database per service pattern
Database per service pattern

وقتی از معماری مایکروسرویسز استفاده می‌کنیم، یکی از الگو‌های مناسب برای استفاده از پایگاه‌ها داده، Database-per-Service است. درست مثل بقیه الگو‌ها، این الگو هم خوبی‌ها و بدی‌های خودش رو داره. یکی از مشکل‌های این الگو کار کردن با پایگاه‌داده و استفاده از تراکنش‌ها است. در این پست، سعی کردیم راه‌های غلبه بر این مشکل رو بررسی کنیم. اما قبل از اون باید با مفهوم تراکنش و ACID آشنا بشیم.

تراکنش‌های اسیدی!

فرض کنید کاوه می‌خواد برای سارا، مبلغ ۱۰ هزارتومن پول واریز کنه. سناریو خیلی ساده‌ست، نه ؟

  • از حساب کاوه ۱۰ هزار‌تومن کم کن.
  • به حساب سارا ۱۰ هزار تومن اضافه کن.

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

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

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

به طور کلی تراکنش‌ها باید ۴ خاصیت داشته باشند، که به آن‌ها ACID گفته می‌شود، که مخفف این خواص است :‌

تجزیه ناپذیری ( Atomicity )

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

هم‌خوانی ( Consistency )

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

انزوا ( Isolation )

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

پایایی ( Durability )

به این معنی است که بعد از این‌که تراکنش کامیت شد، تغییراتش پابرجا می‌ماند.

بیشتر راجع‌به تراکنش‌ها بخوانید...

تراکنش در الگوی پایگاه داده به ازای سرویس

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

اما مشکل وقتی ایجاد می‌شود که بخواهیم یک تراکنش بین این سرویس‌ها داشته باشیم.

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

به عنوان مثال فکر کنید در اسنپ! دو سرویس وجود دارد که هرکدام پایگاه‌داده خاص خودشون رو دارند.
یکی از این سرویس‌ها وظیفه مدیریت سفر‌های مسافران را برعهده دارد و دیگری وظیفه مدیریت کیف پول کاربران را دارد.

حالا به عنوان مثال میخواهیم تراکنشی به این صورت ایجاد کنیم:

  • ثبت وضعیت <اتمام> برای سفر ( مربوط به سرویس شماره ۱ )
  • کسر هزینه از کیف پول مسافر ( مربوط به سرویس شماره ۲ )

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

  • الگو 2PC
  • الگو SAGA

در ادامه به معرفی این دو الگو می‌پردازیم

الگو 2PC

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

در این فاز سرویس‌ها تراکنش را انجام داده، اما کامیت نمی‌کنند.

اگر همه قادر به انجام تراکنش باشند، در فاز بعدی Transaction Coordinator سیگنال Commit را برای همه می‌فرستد و تغییرات را نهایی می‌کند.
اما اگر حتی یکی از سرویس‌ها، قادر به انجام تراکنش نباشد، در فاز بعدی، Transaction Coordinator به همه سرویس‌ها سیگنال RollBack را می‌فرستد و همه‌چیز به حالت قبل برمی‌گردد.

Successful scenario
Successful scenario


Unsuccessful diagram
Unsuccessful diagram

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

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

برای غلبه بر این مشکلات این چنینی، میتوانیم از الگو SAGA استفاده کنیم.

الگو SAGA

در این الگو تراکنش‌ها به صورت سریال و پشت‌ سر‌هم انجام می‌شوند.

تراکنش‌ها به صورت سریال و پشت‌سر‌هم انجام می‌شوند
تراکنش‌ها به صورت سریال و پشت‌سر‌هم انجام می‌شوند

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

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

  1. روش Choreography
  2. روش Orchestration

روش Choreography

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

Choreography SAGA pattern
Choreography SAGA pattern


روش Orchestration

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

Orchestration SAGA pattern
Orchestration SAGA pattern

بیشتر درباره SAGA بخوانید...

آخر کی برنده میشه ؟‌SAGA یا 2PC ؟

بستگی داره.

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

بزرگترین مشکلی که 2PC داره، همونطوری که قبلا گفتیم، اینه که چون ما توی 2PC در واقع داریم یک کامیت بزرگ انجام میدیم، منابع ما برای یک مدت طولانی باید قفل بمونن تا همه سرویس‌ها جواب بدن. اما این مشکل توی SAGA به این ترتیب حل شده که به جای یک کامیت بزرگ، ما سلسله کامیت های کوچک داریم که باعث می‌شود بر این مشکل SPOF به راحتی غلبه کنیم.

یکی از مزیت های 2PC اما جایی است که ما نیاز به Consistency شدید داریم، که قطعا 2PC به خاطر تک-کامیته بودنش، گزینه بهتری است نسبت به SAGA که درواقع Eventually consistent است.

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

پس به طور کلی روش بهتری وجود ندارد، باید شرایط را سنجید و بهترین روش را انتخاب کرد.

تجربه ما Idempotency

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

اما idempotency چیه ؟‌ به طور کلی یک سیستم وقتی Idempotent است که همیشه، در ازای درخواست های یکسان، جواب یکسان بده.
به عنوان مثال، سیستم زیر idempotent است :‌

func GetNumber() { 
    return 5 
}


چرا ؟ چون همیشه، برای یک ورودی یکسان، جواب یکسان میده. اما سیستم زیر، یک مثال از سیستم غیر idempotent است :‌

func GetNumber() { 
    return time.Now().Minute()
}

دلیلش هم اینه که به ازای درخواست های یکسان، ممکنه/حتما جواب های متفاوت بده.
برای idempotent کردن یک سیستم روش‌های متفاوتی وجود داره، یکی از این روش‌ها idempotent کردن درخواست‌ها به یک سرویس است.
به عنوان مثال وقتی سرویس شماره ۱ تغییرات رو در پایگاه داده خودش اعمال کرده و موفق بوده، درخواستی به سرویس شماره ۲ میده، و میگه این تسک رو برای من انجام بده.
دو تا حالت ممکنه پیش بیاد :‌
۱. سرویس دوم هم کارش رو درست انجام بده و به سرویس اول میگه کار رو انجام داده.
۲. سرویس دوم جوابی نمیده.
توی سناریو شماره یک، مشکلی به وجود نمیاد و همه چیز درسته، اما سناریو دوم جاییه که به مشکل میخوریم.

ممکنه سرویس دوم به مشکلی خورده باشه و تسک رو انجام نداده باشه، یا ممکنه تسک رو انجام داده باشه، اما به دلیل مشکلات ارتباطی، نتونسته بگه که کار رو انجام داده. یکی از راه حل های این مشکل اینه که ما یک ID برای هر تسک مشخش کنیم و کنار درخواستمون این آیدی رو هم بفرستیم. در این صورت سرویس دوم، اگر تسکی با آیدی تکراری دریافت کنه،خیلی راحت تسک رو انجام نمیده! و سرویس اول میتونه با خیال راحت، هرچندبار که دوست داشته باشه، درخواستش رو تکرار کنه. در صورتی که تسک بعد از چند بار تکرار به درستی انجام نشد، میتونه یک جا ذخیره بشه، تا بعدا بهش رسیدگی بشه .
راجع‌به DLQ بخوانید...
راه حل های زیادی برای داشتن یک سیستم idempotent وجود داره که میشه با توجه به معماری و جنس سیستم انتخابشون کرد.

اما به طور کلی شما در یک سیستم idempotent یک دستور نمیدهید، بلکه state بعدی رو مشخص میکنید، به این ترتیب درخواست های تکراری، نمیتونن دردسر ساز بشن.

حرف آخر

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

خوب باشید !