الگوی OUTBOX برای تبادل داده‌ها در میکروسرویس‌ها

outbox patternبا پیشرفت فضای آنلاین و گسترش نرم‌افزارهای پیچیده, معماری‌های مختلفی برای مدیریت پیچیدگی‌ها در طراحی نرم‌افزارها بوجود آمده‌است. یکی از محبوب‌ترین این معماری‌ها، معماری میکروسرویس است که امروزه بسیاری از سیستم‌های بزرگ با تعداد کاربر بالا از آن استفاده می‌کنند. در این مدل معماری، سیستم را براساس برخی ویژگی‌های منتخب به سرویس‌های کوچک با وظایف مستقل تقسیم می‌کنند، که باید طبق پروتکل‌های مشخصی با هم در ارتباط باشند و از آخرین تغییرات همدیگر مطلع شوند بنابراین سرویس‌های مرتبط، تغییرات را باید به یکدیگر اطلاع‌رسانی کنند. فرض کنید یک سرویس موفق به اطلاع‌رسانی به سرویس‌های دیگر نشود و نتواند تغییرات اعمال‌شده در داده‌ها را به آن‌ها اطلاع دهد، در این صورت یکپارچگی داده‌ها در کل سیستم تحت تاثیر قرار می‌گیرد و مشکلاتی را با خود به همراه دارد. در راستای این مشکلات تصمیم گرفتم در این مقاله، الگوی outbox را که تبادل داده‌ها بین سرویس‌ها را تضمین می‌کند، بررسی کنم.مهمترین و شاید دشوار‌ترین بخش در مورد معماری میکروسرویس‌ها مدیریت و تبادل داده‌ها است میکروسرویس‌ها نیاز دارند برای حفظ یکپارچگی داده‌ها، هم‌زمان با بروزرسانی پایگاه داده رویداد یا پیام ارسال کنند، اما سوالی که پیش می‌آید این است که: چطور می توانیم مطمئن شویم که در یک میکروسرویس رویداد(Message/Event)های حاصل از تغییرات، حتما ارسال و تبادل داده‌ها به درستی انجام شده است؟سرویس‌ها اغلب رویدادها را بعد از اتمام تراکنش دیتابیس(Transaction) منتشر می‌کنند، در واقع انجام تراکنش دیتابیس و ارسال رویدادها دو عملیات متفاوت است و باید به شکل اتمی انجام گیرد، چراکه انجام یک تراکنش و عدم انتشار رویداد(Event/Message) می‌تواند یکپارچگی داده‌ها را دچار مشکل کنند. در نتیجه برای جلوگیری از ناسازگاری داده‌ها، به‌روزرسانی پایگاه داده و ارسال پیام باید اتمی باشد. بنابراین، در فراخوانی یک سرویس برای انجام یک فرآیند مشخص، کلاینت یا سرویس‌گیرنده به طور کلی انتظار دارد که سرویس‌دهنده درخواست HTTP را به شکل اتمی(Atomic Transaction) انجام دهد. درخواست‌های HTTP درست همانند تراکنش‌های دیتابیس، انجام یک کار مشخصی در یک واحد(Transactional Unit Of works) با یک آغاز و پایان و نتیجه مشخص هستند، برای توضیح بهتر ما به یک سرویس نمونه خواهیم پرداخت تا ببینیم که چگونه درخواست‌های HTTP و Transaction ها به خوبی روی یکدیگر تطبیق داده می‌شوند و با کمک ACID یک Unit of work را می سازند.سرویس ساده ثبت‌نام کاربر با ایمیلحالا یک سرویس تست ساده با یک EndPoint برای ایجاد کاربر می‌سازیم که کلاینت با پارامتر ایمیل آن را فراخوانی می‌کند و سیستم برای کاربری که می‌سازد با Status ‌Code 201 پاسخ می‌دهد، همینطور در نظر داریم که API ما Idempotent است و زمانی که کلاینت درخواست تکراری با پارامترهای قبلی ارسال می‌کند با پاسخ OK 200 (همه چیز مرتب است) و پیغام "کاربر قبلا ثبت نام کرده‌است" مواجه می‌شود.POST /users?email=jane@example.comحال ما قصد داریم برای پیاده سازی ۳ مورد زیر را انجام دهیم :در صورتی که کاربر موجود باشد ادامه نمی‌دهیم.برای کاربر جدید رکورد ثبت می‌کنیم .یه لاگ از ثبت کاربر جدید با 'شناسه کاربری' و 'زمان' ثبت می‌کنیم.ساختار جدولCREATE TABLE users(id BIGSERIAL PRIMARY KEY,email TEXT NOT NULL CHECK (char_length(email) <= 255));-- our "user action" audit logCREATE TABLE user_actions(id BIGSERIAL PRIMARY KEY,user_id BIGINT NOT NULL REFERENCES users (id),action TEXT NOT NULL CHECK (char_length(action) < 100),occurred_at TIMESTAMPTZ NOT NULL DEFAULT now());پیاده سازیطبق نمونه کد پیاده‌سازی، بررسی می‌کنیم:اگر کاربر موجود باشد، پس فورا پاسخ را به کلاینت ارسال می‌کنیماگر کاربر جدید باشد، کاربر و لاگ را ایجاد و پاسخ را به کلاینت ارسال می‌کنیم.در هر دو حالت تراکنش با موفقیت کامیت می‌شود:POST &quot/users/:email&quot do |email| DB.transaction(isolation: :serializable) do user = User.find(email) halt(200, 'User exists') unless user.nil? #create the user user = User.create(email: email) #create the user action UserAction.create(user_id: user.id, action: 'created') #pass back a successful response [201, 'User created'] #Commit End #enqueue a job to tell an external support service, #that a new user's been created enqueue(:create_user_in_support_service, email: email) Endو SQL تضمین می‌کند که درج موفقیت آمیز انجام می‌شود به تصویر زیر توجه کنید:START TRANSACTION ISOLATION LEVEL SERIALIZABLE; SELECT * FROM users WHERE email = 'jane@example.com'; INSERT INTO users (email) VALUES ('jane@example.com'); INSERT INTO user_actions (user_id, action) VALUES (1, 'created'); COMMIT;هم زمانی و محافظت از داده‌هادر نگاه اول ممکن است این سوال پیش بیاید که چرا فیلد ایمیل UNIQUE در نظر گرفته نشده است؟ اگر دقت کنید برای جلوگیری از درج تکراری داده‌ها مدل ("isolation (ACID's "I از نوع SERIALIZABLE در نظر گرفته شده‌است، این مدل تضمین می‌کند تراکنش‌ها یکی پس از دیگری انجام می‌شود و به طور همزمان انجام نخواهند شد و در صورت بروز هم زمانی فقط یکی از تراکنش‌ها موفق خواهد بود. اگرچه که این روش، شما رو از درج تکراری داده‌ها محافظت می‌کند اما اضافه کردن UNIQUE هم به عنوان یک محافظ دیگر، از داده‌های شما در برابر درخواست‌های تداخلی و همزمان و همچنین باگ‌ها محافظت می‌کند که بهتر است در نظر گرفته‌شود.به طور کلی برای کنترل هم‌زمانی بهتر است isolation-level تراکنش‌های دیتابیس را هم در نظر بگیرید. در نتیجه با در نظر گرفتن قوانین بالا در صورت بروز هم‌زمانی دو تراکنش که در آن نتیجه یکی از تراکنش‌ها، دیگری را تحت تاثیر قرار دهد، یکی از آن دو ناموفق و Rollback خواهد شد.ارسال رویدادحالا بعد از درج موفق دیتا قصد داریم با ارسال یک پیام به سرویس دیگر آن را از تغییرات دیتا با خبر کنیم به این شکل که رویداد مورد نظر را به صف ارسال می‌کنیم، سرویس‌های دیگر رویداد‌ها را از صف(MessageBroker) برداشته و پردازش می‌کنند و از تغییرات داده‌ها مطلع می شوند.Service(Publisher) ---Publish---> MessageBroker ---Consume----> Service(Consumer)چه زمانی رویداد را به صف ارسال کنیم؟فرض کنیم که دقیقا بعد از کامیت شدن تراکنش دیتابیس و اعمال تغییرات داده‌ها، Event یا رویداد مربوط به اضافه‌شدن کاربر جدید را ارسال کنیم(مطابق شکل زیر) چه اتفاقی می‌افتد؟در واقع بعد از اجرا شدن خط ۱۲ از نمونه کد بالا و مرحله ۳ که در تصویر مشاهده می‌کنید، تراکنش ما کامیت شده و در خط بعد (مرحله ۴) ما قصد داریم رویداد خود را ارسال کنیم .تصویر شماره ۱ : ارسال رویداد پس از انجام تراکنشممکن است با دو مشکل مواجه شویم:سیستم بعد از کامیت شدن تراکنش و دقیقا قبل از ارسال رویداد دچار مشکل شود!لحظه ی ارسال رویداد(Message Broker) شما برای لحظاتی از دسترس خارج شده و سرویس شما موفق به ارسال رویداد نشده‌است!در نتیجه هر ۲ اتفاق تراکنش ما انجام و کاربر جدید با موفقیت ثبت می‌شود اما اطلاع‌رسانی انجام نمی‌شود!راه حل: اگر ما عملیات ارسال رویداد را قبل از پایان تراکنش(کامیت) انجام دهیم چه اتفاقی می افتد؟POST &quot/users/:email&quot do |email| DB.transaction(isolation: :serializable) do ... #enqueue a job to tell an external support service #that a new user's been created enqueue(:create_user_in_support_service, email: email) ... #commit End Endدر این حالت، در صورت rollback شدن تراکنش(مانند حالتی که بالاتر توضیح داده شد)، یک رویداد ارسال می‌شود که در واقع به ازای آن تغییری روی دیتابیس وجود ندارد!!برای افزایش قابلیت اطمینان سیستم، چه کاری می‌توانیم انجام بدهیم؟در الگوی Outbox به جای اینکه رویدادها مستقیم به صف ارسال شوند، یک جدول در دیتابیس در نظر گرفته‌می‌شود و رویدادها در محدوده تراکنش در جدول outbox ذخیره شده و ارسال نمی‌شوند، که در خط ۱۵ نمونه کد زیر در مرحله سوم در تصویر شماره ۲ قابل مشاهده‌است.CREATE TABLE outbox ( id BIGSERIAL PRIMARY KEY, job_name TEXT NOT NULL, job_args JSONB NOT NULL ); POST &quot/users/:email&quot do |email| DB.transaction(isolation: :serializable) do user = User.find(email) halt(200, 'User exists') unless user.nil? #create the user user = User.create(email: email) #create the user action UserAction.create(user_id: user.id, action: 'created') #saving data to OUTBOX Outbox.create( user_id: user.id, action: 'create_user', status:'pending') #pass back a successful response [201, 'User created'] #commit End Endتصویر شماره ۲: ذخیره رویداد در جدول outbox به عنوان بخشی از تراکنشهمانطور که مشاهده می‌کنید، ذخیره رویداد در جدول outbox به عنوان بخشی از تراکنش در نظر گرفته شده‌است، در این صورت مطمعن هستیم که در صورت موفق بودن و انجام شدن تراکنش، شما حتما یک رویداد ذخیره شده در جدول outbox خواهید داشت. حالا فقط به یک job یا enqueuer نیاز داریم تا رویداد‌ها از outbox را به صف ارسال کند، در واقع یک فرآیند جداگانه ایجاد می‌کنیم که محتوای outbox را پردازش و پس از ارسال هر رویداد به صف(MessageBroker) آن را علامت گذاری می‌کند تا از ارسال مجدد رویدادهای ارسال‌شده به صف، جلوگیری کند، اگرچه بروز خطا در ارتباط با outbox ممکن است منجر به ارسال مجدد رویدادها شود که در ادامه به شرح آن می پردازیم.تصویر شماره ۳: فرآیند ارسال رویدادها از جدول outboxچگونه ممکن است رویدادها بیش از یکبار ارسال شوند؟همانطور که در تصویر شماره ۳ می‌بینید، فرض کنید توسط فرآیند جدید ایجاد شده(Job)، یکی از رویدادهای جدول outbox را پردازش و آن را به صف(MessageBroker) ارسال کردیم، حالا باید آن را در جدول علامت‌گذاری کنیم و status آن را تغییر دهیم تا از ارسال مجدد آن جلوگیری نماییم ،اگر در همین لحظه ارتباط ما با جدول outbox قطع شود، رویداد علامت‌گذاری نشده باقی می‌ماند و سیستم آن را ارسال‌نشده در نظر می‌گیرد در حالی که در حقیقت ارسال شده‌است، و پس از برقراری، رویداد مجددا ارسال می‌شود، در واقع این الگو تضمین می‌کند که رویدادها حتما یک بار ارسال می‌شوند اما ممکن است بیش از یکبار ارسال شوند.توجه کنید:میکروسرویس دریافت‌کننده رویداد‌ها از صف یا همان consumer (که رویداد‌های ارسالی شما را از صف برداشته و پردازش می کند)، حتما idempotent باشد به این معنی که با تکرار یک درخواست(پردازش رویداد تکراری)، سرویس دچار مشکل در اجرای فرآیند نشده و همواره نتیجه یکسانی داشته باشد.اگرچه به طور کلی در استفاده از Message Broker ها، سرویس‌های دریافت‌کننده پیام یا همان Consumerها باید Idempotent باشند چراکه حتی اگر شما یکبار پیام را به صف ارسال کرده‌باشید ممکن است آن ها پیام شما را بیش از یک بار به Consumer ارسال کنند و اغلب At-Least-Once Delivery هستند، آن‌ها فقط تضمین می‌کنند که پیام شما حتما یک بار را ارسال شود اما تضمین نمی‌دهند که فقط یک بار ارسال شود.در پایانبا توجه به استفاده گسترده از معماری مایکروسرویس‌ها و مشکلات بروزرسانی داده‌های آن‌ها، الگوی outbox می‌تواند به عنوان یک مدل قابل اعتماد برای تبادل داده‌ها مورد استفاده قرار بگیرد و در صورتی که Message-Broker شما به هر دلیلی (به روز رسانی،بروز مشکل، restart و ...)از دسترس خارج شود این الگو این اطمینان را به شما می‌دهد که Event(رویداد)های شما از دست نرفته و در outbox موجود است و بعدا با استفاده از یک job پردازش شده و ارسال خواهند شد. برای پیاده سازی آن می توانید از MessageBrokerهای قابل اعتمادی همچون Apache Kafka, RabbitMQ استفاده کنید.