چالش‌های معماری میکروسرویس - قسمت دوم


در قسمت اول درباره انواع روش‌های ارتباط و تعامل میان سرویس‌ها و چالش‌های روش هم‌زمان صحبت کردیم. در این قیمت میخوایم بریم سراغ روش غیر هم‌زمان یا asynchronous.

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

ساختار کلی ارتباط از طریق پیام

یک پیام به طور کلی به دو قسمت header و body تقسیم می‌شود. در قسمت header می‌توان فراداده‌های گوناگونی درباره‌ی این پیام قرار داد و در قسمت body محتوی اصلی پیام با یک چهارچوب مشخص قرار می‌گیرد که می‌تواند به فرمت متنی یا باینری باشد.

پیام‌ها به سه دسته تقسیم می شوند :

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

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

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

دو معماری کلی برای پیاده سازی تبادل پیام

برای پیاده سازی تبادل پیام دو معماری وجود دارد :

  • سرویس‌ها به طور مسقیم با هم پیام تبادل کنند.
  • سرویس‌ها از طریق یک واسط یا دلال (broker) با هم به تبادل پیام بپردازند.
دو معماری کلی برای پیاده سازی تبادل پیام
دو معماری کلی برای پیاده سازی تبادل پیام

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

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

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

فاکتور‌های انتخاب Message Broker

زبان‌های برنامه نویس که تعامل با broker را پشتیبانی می‌کنند - استاندارد مورد استفاده (AMQP یا STOMP) - تضمین مرتب سازی پیام‌ها - ماندگاری (در صورت از کار افتادن broker پیام‌ها به شکل قابل اعتماد ذخیره می‌شوند و بعدا به طور کامل بازیابی می‌شوند یا خیر) - آیا در صورتی که دریافت کننده برای مدتی از کار بیفتد و دوباره به شبکه متصل شود، پیام‌هایی که در این مدت برای او ارسال شده به دستش می‌رسد - مقیاس پذیری

چالش‌ها

تضمین مرتب سازی پیام‌ها

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

راه حل

برای حل این مشکل broker‌ها از روش تکه کردن کانال استفاده می‌کنند. به این شکل که کانال را تکه کرده و به هر نمونه از سرویس، یک تکه خاص را منتسب می‌کنند. در صورت اضافه شدن و یا حذف یک نمونه این فرایند تکه کردن بازیابی می‌شود. حال broker برای قرار دادن پیام در هر تکه از کانال از چیزی به نام shard-key استفاده می‌کند که توسط فرستنده پیام مشخص می‌شود. در مثال بالا می‌تواند شناسه سفارش به عنوان shard-key در نظر گرفت. اتفاقی که میافتد این است که broker تمامی پیام‌هایی که shard-key یکسان دارند را به سمت تنها یک تکه از کانال ارسال می‌کنند. در واقعا پیام‌هایی که shard-key یکسان دارند تنها توسط یک نمونه از سرویس پردازش می‌شوند.

اداره‌ی پیام‌های تکراری

دلال به طور ایده‌آل هر پیام را باید دقیقا یک بار به مقصد برساند. اما تضمین این دقیقا یک بار بسیار هزینه بر است و همین امر سبب می شود تا brokerها تضمین کنند که پیام حداقل یک بار به مقصد می‌رسد. پس این امکان وجود دارد که پیام تکراری به دست گیرنده برسد. برای حل این مشکل دو روش وجود دارد که روش اول تنها در بعضی موارد و روش دوم در همه موارد کاربرد دارد :

نوشتن برنامه‌های بیخیال شونده !!!

یعنی برنامه ای که هر چندبار که آن را با مقادیر مشخص فراخوانی کنیم هیچ واکنش عجیب و غریبی نمی‌دهد.

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

پیگیری پیام‌ها و دور انداختن پیام‌های تکراری

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

ارسال تراکنشی پیام‌ها

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

راه حل‌ها

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

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


افزایش دسترس پذیری

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

راه حال

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

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