Jamal Kaksouri
Jamal Kaksouri
خواندن ۱۱ دقیقه·۳ سال پیش

مدیریت Eventual Consistency با سیستم‌های توزیع شده

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

  • استفاده از رویدادها برای انتقال تغییرات سیستمی یا معماری مبتنی بر رویداد (Event-Driven Architecture)
  • استفاده از مدل‌های خواندن برای الگوهای دسترسی خاص (CQRS / Event Sourcing)
  • استفاده از replication داده بین مدل‌های ماندگار (Source/Replica)
  • استفاده از واسطه سریعتر جهت دسترسی به داده‌هایی که بیشتر مورد استفاده قرار می‌گیرند (Caching)

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

تعریف Eventual Consistency

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

شاید رایج‌ترین مثال زمانی باشد که شما تکثیر غیرهمزمان(asynchronous replication) را بین دو یا چند نمونه از یک پایگاه داده رابطه‌ای(RDBMS) بکار برده باشید. در مثال زیر مقیاس‌بندی اپلیکیشن به این صورت است که عملیات خواندن را به سمت replica هدایت می‌کند و عملیات نوشتن را در یک دیتابیس اصلی نگهداری می‌کند.

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

شکل1) عملیات تکثیر ساده
شکل1) عملیات تکثیر ساده


شکل 1 عملیات ساده تکثیر اطلاعات که بین دیتابیس اصلی(main) و دیتابیس تکثیر(replica) رخ داده است را نشان می‌دهد. در این مثال اگر کسی در دیتابیس تکثیر(replica) در لحظه‌ی T3 پرس و جویی انجام دهد به شرط اینکه T2>T3 و T2>T1 باشد، نتیجه ای که ارائه می دهد با آنچه در دیتابیس(main) موجود است مطابقت نخواهد داشت.

مثال دیگر در یک سیستم مبتنی بر رویداد(event-driven system) اتفاق می‌افتد که در آن پیام‌های ناهمگام که به عنوان رویداد شناخته می‌شوند، برای اطلاع از تغییرات ایجاد شده توسط سایر بخش‌های سیستم، منتشر خواهند شد. این رویدادها به نوبه خود اقدامات بیشتری را در سیستم‌هایی که از آنها استفاده میکنند انجام می‌دهند، از جمله بروزرسانی فضای ذخیره‌سازی با نسخه‌های کپی شده از یک داده. مشابه replication بین بخشی از سیستم که در ابتدا تغییر وضعیت را ثبت کرده است و سایر قسمت‌هایی که به این تغییرات علاقه‌مند هستند، ارتباط موقتی وجود دارد. این قطع ارتباط ذات این مدل است و مجموعه خاصی از چالش‌ها را به همراه دارد. در ادامه به دو مورد از رایج‌ترین آنها می‌پردازم.

چالش Read After Write

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

شکل 2) مسئله خواندن پس از نوشتن
شکل 2) مسئله خواندن پس از نوشتن


شکل 2 مواردی را نشان می دهد که برنامه شما در حال نوشتن، ایجاد یا به‌روزرسانی یک موجودیت است و در همان جریان اجرا سعی می‌کند آن را بخواند. از آنجا که عملیات تکثیر زمان‌بر است، بسته به بار فعلی برنامه و لایه persistence، ممکن است در زمان اجرای پرس و جو، تغییرات هنوز منتشر نشده باشند.

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

چالش Concurrency Control یا کنترل همزمانی

تقریباً مشابه چالش Read After Write، با سیستم‌های در نهایت سازگار(eventually consistent systems) اغلب می‌بینید که یک موجودیت را از یک مدل فقط خواندنی، فقط برای انجام تغییرات بیشتر بازیابی می‌کنید. در صورتیکه منبع فقط خواندنی به‌روز باشد، همه چیز خوب پیش می‌رود، اما اگر اینطور نباشد، ممکن است اطلاعات قبلی را از دست بدهید.

شکل 3) تغییر در T2 باعث بازنویسی در T1 می‌شود
شکل 3) تغییر در T2 باعث بازنویسی در T1 می‌شود


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

راه حل‌های پیشنهادی

در راه‌حل هایی که در ادامه بررسی خواهم کرد سعی می‌کنم چالش‌ها را به روش‌های مختلف بررسی و برطرف کنم. بسته به نوع چالش، ممکن است از یک یا ترکیب چند روش برای رسیدگی به مسئله‌ی مربوط به ثبات نهایی(eventual consistency) به شیوه‌ای زیبا استفاده کنیم.

راه‌حل اول: Fake It

مسلماً بهترین راه برای جلوگیری از مسئله read-after-write این است که اصلاً عملیات خواندن را انجام ندهید. وضعیتی را که در شکل 4 نشان داده شده تصور کنید:

شکل 4) یک سفارش قرار دهید و خلاصه‌ای از سفارش ایجاد شده را نشان دهید
شکل 4) یک سفارش قرار دهید و خلاصه‌ای از سفارش ایجاد شده را نشان دهید


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

راه حل این است که سفارشات را نخوانید، و فقط اطلاعات سفارشی را که در لحظه قرار دادن در حافظه دارید نشان دهید.

شکل 5) پس از ثبت سفارش فقط همان اطلاعاتی را که دارید به مشتری نشان دهید
شکل 5) پس از ثبت سفارش فقط همان اطلاعاتی را که دارید به مشتری نشان دهید


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

شکل 6) از اطلاعاتی که در اختیار دارید استفاده کنید و نشان دهید که بخش‌هایی از نتیجه هنوز در دسترس نیستند
شکل 6) از اطلاعاتی که در اختیار دارید استفاده کنید و نشان دهید که بخش‌هایی از نتیجه هنوز در دسترس نیستند

را‌ه‌حل دوم: Set an Expected Version (تنظیم یک نسخه مورد انتظار)

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

شکل 7) استفاده از نسخه مورد انتظار امکان شناسایی موجودیتی را که هنوز به‌روز نشده است می‌دهد
شکل 7) استفاده از نسخه مورد انتظار امکان شناسایی موجودیتی را که هنوز به‌روز نشده است می‌دهد


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

شکل 8) استفاده از نسخه به تشخیص تغییراتی که قبلاً در موجودیت شما اتفاق افتاده است کمک می‌کند
شکل 8) استفاده از نسخه به تشخیص تغییراتی که قبلاً در موجودیت شما اتفاق افتاده است کمک می‌کند


اگر use case شما اجازه می‌دهد، ممکن بخواهید تغییرات را باهم ادغام کنید یا از نوع داده‌های تکراری سازگار استفاده کنید.(CRDT)

را‌ه‌حل سوم: UI Poller

در دو راه‌حل قبلی، سعی کردم تا از مشکلات پایداری نهایی(eventual consistency) صرف‌نظر از بازیابی هرگونه اطلاعات یا تشخیص آنچه که بازیابی شده بود، اجتناب کنم. اگرچه این مواردی که بهش اشاره کردیم را می‌توانیم در بسیاری از موقعیت‌ها بکار بگیریم اما اگر بر خلاف میل ما باشد، چه اتفاقی می‌افتد؟ برای مثال می‌خواهید سفارشی را که قبلاً ثبت کرده‌اید بازگردانید و سیستم پس از پردازش درخواست، یک کد بازگشتی تولید کند. خب مسلماً برای همچین سناریویی که قرار است پس از بازگشت سفارش به کاربر یک کد بازگشتی نشان دهیم، نمی‌توانیم از روش Fake It استفاده کنیم زیرا در آن روش بخشی از اطلاعات ما در دسترس نبود و کاربر نمی‌توانست همه اطلاعات را یکجا مشاهده نماید. راه‌حلی که در این روش در نظر گرفتیم این است که شما تا زمان بروزرسانی کامل اطلاعات، به کاربر یک تصویر بارگزاری اطلاعات یا (spinner) نشان دهید.

شکل 9) استفاده از poller برای تلاش مجدد جهت پیدا کردن اطلاعات مورد نیاز کاربر
شکل 9) استفاده از poller برای تلاش مجدد جهت پیدا کردن اطلاعات مورد نیاز کاربر


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

شکل 10) افزایش وقفه بین تلاشهای مجدد
شکل 10) افزایش وقفه بین تلاشهای مجدد


اگر در حال مانیتور کردن برنامه کاربردی خود هستید، می‌توانید برای استفاده از use case‌ی که در تلاش برای رسیدگی به آن هستید، فواصل زمانی اولیه را براساس درصد Nم تنظیم کنید. حتی می‌توانید تلاش مجدد اولیه را براساس درصد nth برای use case‌ی که برای رسیدگی به آن تلاش می‌کنید، تنظیم کنید. اگر شکل 11 زمان اجرا را برای use case نشان می‌دهد، می‌توانید به عنوان مثال، 75٪ را انتخاب کرده و اولین تلاش را پس از 500ms، تلاش دوم را با 750ms، و سومین و آخرین را 1300ms تنظیم کنید.

شکل 11) استفاده از زمان اجرا برای تعیین وقفه مجدد
شکل 11) استفاده از زمان اجرا برای تعیین وقفه مجدد


اگر use case‌ شما پیچیده‌تر باشد، یا تعداد درخواست‌های مورد نیاز برای کنترل وضعیت غیرقابل‌قبول باشد، راه‌حل بعدی می‌تواند کمک کند.

هنگام انتخاب یک راه‌حل، همیشه زمینه(context) خود را در نظر بگیرید و زمینه‌ای را انتخاب کنید که به بهترین نحو به نیازهای کسب و کار شما پاسخ می‌دهد. این به شما کمک می‌کند تا از ایجاد پیچیدگی یا هزینه غیر ضروری برای راهحل خود جلوگیری کنید.

این راه‌حل بسیار پیچیده این مقاله است، اما قوی‌ترین و احتمالا انعطاف‌پذیرترین راه‌حل است. در این روش از Websockets استفاده می‌کنیم و روشی است که ارتباط شما با مشتری از طریق backend برنامه می‌باشد. WebSockets یا WebSocket API راه‌حلی برای فعال کردن ارتباط دو طرفه بین سرویس گیرنده(معمولاً مرورگر) و سرور است. به این ترتیب، کلاینت می‌تواند بدون وقفه پیام‌هایی را به سرور ارسال کند و پاسخ‌ها را دریافت کند. مشکلاتی که سعی در حل این مساله دارد، همان مساله UI Poller است. اما وقتی سیستم به مقیاس می‌رسد، مانند افزایش تعداد کلاینت‌ها یا تغییرات احتمالی زمان اجرا محدودیت‌ها را بر طرف می‌کند.

شکل ۱۲ با استفاده از راه‌حل WebSockets جریان زیر را نشان می‌دهد:

  • کلاینت اطلاعات لازم برای انجام عملیات را ارائه می‌دهد.
  • کلاینت اتصال WebSocket را با سرور ایجاد می‌کند.
  • سرور عملیات را شروع می‌کند.
  • سرور عملیات را به پایان می‌رساند و اطلاعات را برای مشتری ارسال می‌کند.

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

شکل ۱۲) یک case پیچیده شامل چندین مرحله است
شکل ۱۲) یک case پیچیده شامل چندین مرحله است


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

شکل 13) راه‌حلی برای استفاده از WebSockets در یک عملیات پیچیده
شکل 13) راه‌حلی برای استفاده از WebSockets در یک عملیات پیچیده


سرویس API Gateway

  • درخواست را دریافت می‌کند و WebSocket را مدیریت می‌کند
  • درخواست را به Lambda مربوطه هدایت می‌کند

سرویس Lambda A

  • درخواست را اعتبارسنجی می‌کند
  • شناسه اتصال را در DynamoDB ذخیره می‌کند
  • تابع Step را شروع می‌کند

سرویس DynamoDB

  • شناسه اتصال مرتبط با WebSocket را ذخیره می‌کند

سرویس Step Function

  • اجرای سرویس‌های میکرو را هماهنگ می‌کند
  • اطلاع از به‌روز رسانی از طریق SQS

سرویس SQS

  • حاوی پیام‌هایی است که ارتباطی را که سرویس می‌خواهد به مشتری ارائه دهد ، نشان می‌دهد

سرویس Lambda B

  • پیام‌های SQS را دریافت می‌کند ، شناسه اتصال را تعیین می‌کند و محتوا را از طریق API Gateway به مشتری ارسال می‌کند.

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

نتیجه‌گیری

سازگاری نهایی(eventual consistency) برای سیستمهای ما یک واقعیت است و در بیشتر موارد اجتناب ناپذیر است. من برخی از رویکردها را ارائه دادم که می‌توانید از آنها استفاده کنید که سعی سازگاری نهایی(eventual consistency) را حفظ کنند و راه هایی برای مدیریت آن در نظر بگیرند.

هنگام انتخاب یک راه‌حل، همیشه زمینه(context) خود را در نظر بگیرید و زمینه‌ای را انتخاب کنید که به بهترین نحو به نیازهای کسب و کار شما پاسخ می‌دهد. این به شما کمک می‌کند تا از ایجاد پیچیدگی یا هزینه غیر ضروری برای راه‌حل خود جلوگیری کنید.



Distributed Systemseventual consistencyawswebsocketserverless
یه مهندس نرم‌افزار که تو زمین زندگی میکنه..
شاید از این پست‌ها خوشتان بیاید