یکی از چالشهایی که در سیستمهای توزیع شده با آن مواجه میشویم، داشتن یک سازگاری مطمئن بین دادههایی است که در سرویسهای مختلف رد و بدل میشوند. به صورت کلی برای کار در مقیاس بالا و فراهم کردن انعطافپذیری در یک سیستم توزیع شده، از چندین الگو میتوانیم استفاده کنیم:
استفاده از هر کدام از روشهای بالا در واقع استقبال از این حقیقت است که سیستمها در نهایت سازگار هستند در حالیکه برخی از این روشها چالشهایی را ارائه میدهند که اغلب توسط توسعه دهندگان نادیده گرفته میشوند. در این مقاله قصد داریم برخی از این چالشها را مورد بررسی قرار دهیم.
در واقع اصطلاح Eventual Consistency برای توصیف کردن مدلی است که در سیستمهای توزیع شده به طور معمول یافت میشود، که تضمین میکند اگر یک آیتم بروزرسانیهای جدید را دریافت نکند در نهایت مقداری که از آن آیتم دریافت خواهیم کرد مطابق با آخرین بروزرسانیهای آن خواهد بود که در واقع یک نتیجه ثابت را ارائه میدهد. سیستمهایی که این نوع رفتار را ارائه میدهند، سیستمهای واگرا(convergent) نامیده میشوند.
شاید رایجترین مثال زمانی باشد که شما تکثیر غیرهمزمان(asynchronous replication) را بین دو یا چند نمونه از یک پایگاه داده رابطهای(RDBMS) بکار برده باشید. در مثال زیر مقیاسبندی اپلیکیشن به این صورت است که عملیات خواندن را به سمت replica هدایت میکند و عملیات نوشتن را در یک دیتابیس اصلی نگهداری میکند.
به دلیل نامتقارن بودن این روش، عملیات نوشتن در سیستم فقط در سطح محلی خواهد بود و فرآیند بعدی عملیات کپیبرداری را بر عهده خواهد داشت، در واقع عملیات نوشتن قبلی را بر روی دیتابیس خواندن(replica's) اعمال میکند. از آنجا که این فرآیند مستقل از عملیات نوشتن اصلی است و آنی نیست، احتمال دارد اطلاعات موجود در replica بروزشده نباشد.
شکل 1 عملیات ساده تکثیر اطلاعات که بین دیتابیس اصلی(main) و دیتابیس تکثیر(replica) رخ داده است را نشان میدهد. در این مثال اگر کسی در دیتابیس تکثیر(replica) در لحظهی T3 پرس و جویی انجام دهد به شرط اینکه T2>T3 و T2>T1 باشد، نتیجه ای که ارائه می دهد با آنچه در دیتابیس(main) موجود است مطابقت نخواهد داشت.
مثال دیگر در یک سیستم مبتنی بر رویداد(event-driven system) اتفاق میافتد که در آن پیامهای ناهمگام که به عنوان رویداد شناخته میشوند، برای اطلاع از تغییرات ایجاد شده توسط سایر بخشهای سیستم، منتشر خواهند شد. این رویدادها به نوبه خود اقدامات بیشتری را در سیستمهایی که از آنها استفاده میکنند انجام میدهند، از جمله بروزرسانی فضای ذخیرهسازی با نسخههای کپی شده از یک داده. مشابه replication بین بخشی از سیستم که در ابتدا تغییر وضعیت را ثبت کرده است و سایر قسمتهایی که به این تغییرات علاقهمند هستند، ارتباط موقتی وجود دارد. این قطع ارتباط ذات این مدل است و مجموعه خاصی از چالشها را به همراه دارد. در ادامه به دو مورد از رایجترین آنها میپردازم.
این شاید شایعترین مشکلی باشد که ما با آن روبرو هستیم. شما یک عملیات نوشتن را برای یک موجودیت از سیستم خود اجرا میکنید و بلافاصله برای بازیابی اطلاعات آن از حالت ماندگار(دیتابیس خواندن) تلاش میکنید و میبینیم که اطلاعات ما موجود نیست!
شکل 2 مواردی را نشان می دهد که برنامه شما در حال نوشتن، ایجاد یا بهروزرسانی یک موجودیت است و در همان جریان اجرا سعی میکند آن را بخواند. از آنجا که عملیات تکثیر زمانبر است، بسته به بار فعلی برنامه و لایه persistence، ممکن است در زمان اجرای پرس و جو، تغییرات هنوز منتشر نشده باشند.
بخش عجیب این است که در طول توسعه این مسئله خود را نشان نمیدهد. به طور معمول در هنگام اجرای برنامه در محیط توسعه این مشکلات رو نخواهیم دید و عملیات خواندن و نوشتن به خوبی کار میکنند. ولی هنگامی که برنامه خود را مستقر میکنیم و کاربران واقعی در تعامل با برنامه ما خواهند شد، متوجه رفتارهای عجیبی میشویم که باعث میشود اطلاعات به صورت دقیق در اختیار کاربران قرار نگیرد.
تقریباً مشابه چالش Read After Write، با سیستمهای در نهایت سازگار(eventually consistent systems) اغلب میبینید که یک موجودیت را از یک مدل فقط خواندنی، فقط برای انجام تغییرات بیشتر بازیابی میکنید. در صورتیکه منبع فقط خواندنی بهروز باشد، همه چیز خوب پیش میرود، اما اگر اینطور نباشد، ممکن است اطلاعات قبلی را از دست بدهید.
شکل 3 نشان میدهد که سفارش شما توسط T1 بهروز شده است، اما قبل از اینکه تغییر در مدل فقط خواندنی منتشر شود، تصمیم میگیرید سفارش خود را ویرایش کنید. سیستم به دلیل عدم در نظر گرفتن جنبه همزمان دسترسی، به وضعیت ناسازگار میرسد. البته، این فقط منحصر به سیستمهای سازگار نیست، اما جنبه مهمی است که نباید از آن غفلت کرد.
در راهحل هایی که در ادامه بررسی خواهم کرد سعی میکنم چالشها را به روشهای مختلف بررسی و برطرف کنم. بسته به نوع چالش، ممکن است از یک یا ترکیب چند روش برای رسیدگی به مسئلهی مربوط به ثبات نهایی(eventual consistency) به شیوهای زیبا استفاده کنیم.
مسلماً بهترین راه برای جلوگیری از مسئله read-after-write این است که اصلاً عملیات خواندن را انجام ندهید. وضعیتی را که در شکل 4 نشان داده شده تصور کنید:
شما فقط یک سفارش دادهاید و میخواهید یک صفحه تأیید به مشتری خود نشان دهید. از آنجا که ما قصد داریم بلافاصله سفارش ایجاد شده را بخوانیم، در صورت تاخیر آن را نخواهیم دید و این تاخیر ممکن است به دلیل بار زیاد روی سیستم، کارگزار پیام(message broker) با تعداد پیامهای زیاد و غیره باشد.
راه حل این است که سفارشات را نخوانید، و فقط اطلاعات سفارشی را که در لحظه قرار دادن در حافظه دارید نشان دهید.
اگر درخواست شما قراره که اطلاعاتی زیادی را در حین اجرای یک عملیات تولید کند، باید اطلاعاتی را که در حال حاضر دارید و آماده هستند را نشان دهید و به کاربر اطلاع دهید که عملیات در حال انجام است.
اگر در سیستم موردی وجود دارد که در آن یک موجودیت موجود را تغییر میدهید و سعی میکنید بلافاصله موجودیت بهروز شده را بازیابی کنید، یک راه حل این است که تعریف کنید نسخه مورد انتظار موجودیتی که میخواهید دریافت کنید چیست.
در این راه حل، شما این واقعیت را پذیرفتهاید که سیستم شما ممکن است در زمان درخواست یک موجودیت، همگرا نباشد. در این حالت اگر نسخه مورد انتظار یا نسخه بعدی را دریافت نکنید، میدانید که باید کاری انجام دهید و نمیتوانید از اطلاعاتی که به تازگی دریافت کردهاید استفاده کنید. در این لحظه میتوانید یک پیام سفارشی را به کاربر نشان دهید یا از یک اسپینر(spinner) استفاده کنید و واکشی اطلاعات را دوباره امتحان کنید تا زمانیکه دریافت اطلاعات موفقیت آمیز باشد. در واقع این روش یک رویکرد پیچیدهتر است که شما باید از نسخهای که برای موجودیتهایی که دستکاری میکنید استفاده کنید، اما پیادهسازی آن ساده است. داشتن نسخه به کنترل همزمانی نیز کمک میکند زیرا در سرور میتوانید به سادگی تغییری که انتظار میرفت نسخهای، قدیمیتر از نسخه موجود باشد را رد کنید.
اگر use case شما اجازه میدهد، ممکن بخواهید تغییرات را باهم ادغام کنید یا از نوع دادههای تکراری سازگار استفاده کنید.(CRDT)
در دو راهحل قبلی، سعی کردم تا از مشکلات پایداری نهایی(eventual consistency) صرفنظر از بازیابی هرگونه اطلاعات یا تشخیص آنچه که بازیابی شده بود، اجتناب کنم. اگرچه این مواردی که بهش اشاره کردیم را میتوانیم در بسیاری از موقعیتها بکار بگیریم اما اگر بر خلاف میل ما باشد، چه اتفاقی میافتد؟ برای مثال میخواهید سفارشی را که قبلاً ثبت کردهاید بازگردانید و سیستم پس از پردازش درخواست، یک کد بازگشتی تولید کند. خب مسلماً برای همچین سناریویی که قرار است پس از بازگشت سفارش به کاربر یک کد بازگشتی نشان دهیم، نمیتوانیم از روش Fake It استفاده کنیم زیرا در آن روش بخشی از اطلاعات ما در دسترس نبود و کاربر نمیتوانست همه اطلاعات را یکجا مشاهده نماید. راهحلی که در این روش در نظر گرفتیم این است که شما تا زمان بروزرسانی کامل اطلاعات، به کاربر یک تصویر بارگزاری اطلاعات یا (spinner) نشان دهید.
همانطور که در شکل 9 نشان داده شده است، پس از دریافت اطلاعات، چرخنده را متوقف کرده و اطلاعاتی را که کاربر باید مشاهده کند را ارائه میدهید. جنبه منفی این راه حل این است که با درخواستهای، بار سرور خود را افزایش میدهید. جهت کاهش بار سرور در این وضعیت، باید حداقل مکانیزمی را سمت کلاینت در نظر بگیرید که با استفاده از یک استراتژی پشت صحنه از حداکثر تعداد تلاشهای مجدد بتواند استفاده کند.
اگر در حال مانیتور کردن برنامه کاربردی خود هستید، میتوانید برای استفاده از use caseی که در تلاش برای رسیدگی به آن هستید، فواصل زمانی اولیه را براساس درصد Nم تنظیم کنید. حتی میتوانید تلاش مجدد اولیه را براساس درصد nth برای use caseی که برای رسیدگی به آن تلاش میکنید، تنظیم کنید. اگر شکل 11 زمان اجرا را برای use case نشان میدهد، میتوانید به عنوان مثال، 75٪ را انتخاب کرده و اولین تلاش را پس از 500ms، تلاش دوم را با 750ms، و سومین و آخرین را 1300ms تنظیم کنید.
اگر use case شما پیچیدهتر باشد، یا تعداد درخواستهای مورد نیاز برای کنترل وضعیت غیرقابلقبول باشد، راهحل بعدی میتواند کمک کند.
این راهحل بسیار پیچیده این مقاله است، اما قویترین و احتمالا انعطافپذیرترین راهحل است. در این روش از Websockets استفاده میکنیم و روشی است که ارتباط شما با مشتری از طریق backend برنامه میباشد. WebSockets یا WebSocket API راهحلی برای فعال کردن ارتباط دو طرفه بین سرویس گیرنده(معمولاً مرورگر) و سرور است. به این ترتیب، کلاینت میتواند بدون وقفه پیامهایی را به سرور ارسال کند و پاسخها را دریافت کند. مشکلاتی که سعی در حل این مساله دارد، همان مساله UI Poller است. اما وقتی سیستم به مقیاس میرسد، مانند افزایش تعداد کلاینتها یا تغییرات احتمالی زمان اجرا محدودیتها را بر طرف میکند.
شکل ۱۲ با استفاده از راهحل WebSockets جریان زیر را نشان میدهد:
این مساله محدودیتهای راهحل قبلی را حل میکند. هیچ درخواست اضافی و بیفایدهای از طرف کلاینت وجود ندارد، یعنی که زمانی که اطلاعات آماده است، سرور آن را به کلاینت ارسال خواهد کرد. علاوه بر این، موارد پیچیدهتری را ممکن میسازد که در آن یک عملیات واحد میتواند مراحل انفرادی زیادی داشته باشد که در شکل ۱۲ دیده میشود.
در این مثال، یک نماینده مراقبت از مشتری یک سفارش را بهروز رسانی میکند و به عنوان بخشی از عملیات انتظار میرود که مقدار جدیدی را ثبت کرده و اطلاعات حمل و نقل را در انبار بهروز رسانی کند. اکنون بیایید به اجزایی که میتوانند با فعال کردن این راه حل در هنگام استفاده از AWS مرتبط باشند نگاه کنیم.
سرویس API Gateway
سرویس Lambda A
سرویس DynamoDB
سرویس Step Function
سرویس SQS
سرویس Lambda B
همانطور که مشاهده میکنید، این راهحل بسیار پیچیده تری نسبت به موارد قبلی است، اما بسیار قدرتمند است چون به شما اجازه میدهد تا بازخوردهای پیوستهای را برای مشتری ارسال کنید. نکته مهمی که وجود دارد این است که انتظار میرود کدهای نسخه production، بتواند خطاها را حل کند، مثلاً زمانی که مشتری دیگر متصل نیست یا سعی میکند دوباره اتصال مجدد برقرار کند.
سازگاری نهایی(eventual consistency) برای سیستمهای ما یک واقعیت است و در بیشتر موارد اجتناب ناپذیر است. من برخی از رویکردها را ارائه دادم که میتوانید از آنها استفاده کنید که سعی سازگاری نهایی(eventual consistency) را حفظ کنند و راه هایی برای مدیریت آن در نظر بگیرند.
هنگام انتخاب یک راهحل، همیشه زمینه(context) خود را در نظر بگیرید و زمینهای را انتخاب کنید که به بهترین نحو به نیازهای کسب و کار شما پاسخ میدهد. این به شما کمک میکند تا از ایجاد پیچیدگی یا هزینه غیر ضروری برای راهحل خود جلوگیری کنید.