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

outbox pattern
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 log
CREATE 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()
);

پیاده سازی

طبق نمونه کد پیاده‌سازی، بررسی می‌کنیم:

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

در هر دو  حالت تراکنش  با موفقیت کامیت می‌شود:

12345678910111213141516POST &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) شما برای لحظاتی از دسترس خارج شده و سرویس شما موفق به ارسال رویداد نشده‌است!

در نتیجه هر ۲ اتفاق تراکنش ما انجام و کاربر جدید با موفقیت ثبت می‌شود اما اطلاع‌رسانی انجام نمی‌شود!

راه حل: اگر ما عملیات ارسال رویداد را قبل از پایان تراکنش(کامیت) انجام دهیم چه اتفاقی می افتد؟

12345678910POST &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 ذخیره شده و ارسال نمی‌شوند، که در خط ۱۵ نمونه کد زیر در مرحله سوم در تصویر شماره ۲ قابل مشاهده‌است.

1234567891011121314151617181920CREATE 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 به عنوان بخشی از تراکنش در نظر گرفته شده‌است، در این صورت مطمعن هستیم که در صورت موفق بودن و انجام شدن تراکنش، شما حتما یک رویداد ذخیره شده در جدول outbox خواهید داشت.
حالا فقط به یک job یا enqueuer نیاز داریم تا رویداد‌ها از outbox را به صف ارسال کند، در واقع یک فرآیند جداگانه ایجاد می‌کنیم که محتوای outbox را پردازش و پس از ارسال هر رویداد به صف(MessageBroker) آن را علامت گذاری می‌کند تا از ارسال مجدد رویدادهای ارسال‌شده به صف، جلوگیری کند، اگرچه بروز خطا در ارتباط با outbox ممکن است منجر به ارسال مجدد رویدادها شود که در ادامه به شرح آن می پردازیم.

تصویر شماره ۳: فرآیند ارسال رویدادها از جدول 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 استفاده کنید.